whosaid
Optional local LLM/embedding backend used by whosaid index to build a meaning-based (semantic) index over transcripts in a meeting workspace, complementing the default full-text index that powers whosaid search and whosaid context.
Audio capture target for whosaid record --app: uses ScreenCaptureKit to record a Zoom call's audio (optionally alongside the mic as a stereo track) and then automatically transcribes, diarizes, and speaker-labels the meeting.
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
whosaid
Local, speaker-attributed transcription for Apple Silicon — who said what, on your Mac, nothing leaves the machine.

whosaid pairs Whisper speech-to-text with speaker diarization to turn a meeting,
interview, call, or podcast recording into a transcript that says who spoke when — running fully
offline and on-device on an Apple Silicon Mac, with no cloud service, no API keys, and no Hugging
Face token. Think of it as whisper + speaker labels + voice-based speaker recognition, in one
command.
Example output (meeting.speakers.txt):
[00:00:04] Alice: Thanks for jumping on, I know it's late for you.
[00:00:11] SPEAKER_01: No problem at all, happy to make it work.
[00:00:19] Alice: Let's start with the roadmap for next quarter.
[00:00:27] SPEAKER_01: Sounds good, I've got a few updates on that.Why whosaid
Meetings, interviews, calls, podcasts — get a transcript where every turn is attributed to a person, not just a wall of text.
Privacy by construction — audio, text, and voice embeddings never leave your Mac. The only cloud step is one you opt into:
[summarizer] engine = "claude"sends each transcript's text to Anthropic to draft action items. Leave it unset and nothing is sent.Your name on your own lines — a one-time ~45s voice enrollment teaches whosaid your voice, so your turns read as your name instead of
SPEAKER_00.Tells you how many people spoke — the number of distinct speakers is auto-detected and reported up front, with per-speaker turn counts and talk time.
Speaker cards to identify who's who — for every speaker, whosaid writes a card of that voice's most representative snippets, so you can read a few lines and know who was talking.
Remembers people you name — identify a speaker once with
whosaid relabel, and their voiceprint is saved to a private local registry so they're auto-named in every future transcript.Fast on long recordings — recordings over ~15 min are diarized in parallel windows and stitched back into consistent speakers by voiceprint, so an 80-minute meeting is minutes, not tens of minutes, recovering the same speakers as a single-pass run.
No accounts, no API keys, no Hugging Face token — every model comes from an open, ungated source.
Search everything you have recorded.
whosaid indexbuilds a local full-text (and, with Ollama, meaning) index over every transcript in a meeting workspace;whosaid searchfinds the turn,whosaid contextshows the minute around it, andwhosaid graphanswers who committed to what, when. See Search your meetings.Hands-free from Voice Memos.
whosaid watch installputs a launchd agent on the Voice Memos folder (or any folder): stop recording, and a few minutes later the meeting is transcribed, summarized, and searchable. See Hands-free ingest.Usable from an AI agent, too.
whosaid mcpexposes transcribe, relabel, doctor, and the read-only workspace search tools as MCP tools for Claude Code, Claude Desktop, Kiro, and other MCP clients, with the same local-only guarantee as the CLI.
Related MCP server: apple-fm-mcp
How it compares
Looking for a MacWhisper, whisperX, or aTrain alternative? If you want speaker-attributed
transcription that runs fully offline on Apple Silicon and can put real names on voices — not just
SPEAKER_00 labels — that's the gap whosaid fills. Here's how it stacks up:
whosaid | whisperX | plain mlx-whisper | cloud transcription APIs | |
Speaker labels | Yes | Yes | No | Varies by provider |
Names speakers by voice | Yes (enrollment) | No | No | No |
Remembers speakers across meetings | Yes — persistent local voiceprint registry | No | No | No |
Runs fully offline | Yes | Partial — needs a gated model download | Yes | No |
Parallel diarization on long audio | Yes | No | — (no diarization) | Varies by provider |
Needs an account / token | No | Yes — Hugging Face token for gated pyannote models | No | Yes — API key |
Install weight |
|
|
| None (network client only) |
Quickstart
git clone https://github.com/sblattj/whosaid && cd whosaid # get the code
./bootstrap.sh # check deps, download models, install ~/.local/bin/whosaid
whosaid enroll # ~45s reading a printed passage — teaches whosaid your voice
whosaid path/to/meeting.m4a # transcribe + diarize + label -> meeting.speakers.txt (and friends)If ~/.local/bin is not on your shell's PATH, add it or invoke the installed command by its
absolute path. WHOSAID_INSTALL_DIR=/another/bin ./whosaid install selects another install
directory. The installed command is a symlink to the checkout, so updating the checkout updates the
command without copying or duplicating the implementation.
whosaid's helper scripts need Python 3.11 or newer, because whosaid.toml is read with tomllib.
The launcher picks one interpreter: $WHOSAID_PYTHON if it is set, otherwise the first of python3,
python3.13, python3.12 and python3.11 on your PATH that is 3.11+, otherwise
uv python find '>=3.11'. It skips the stock macOS python3 (3.9) automatically. If no interpreter
qualifies, commands that run Python stop with an error; uv python install 3.13 fixes that.
whosaid doctor shows which interpreter was picked.
Homebrew is optional when uv, ffmpeg, and ffprobe are already installed. The launcher
and watcher search ~/.local/bin, ~/bin, and ~/homebrew/bin as well as the usual system
prefixes. Setup can install a pinned, checksum-verified Apple Silicon uv archive into
~/.local/bin. Homebrew FFmpeg installation requires a bottle; a nonstandard prefix will not
silently trigger a source build.
For a Mac without Homebrew, or one where no bottle is available, use:
./bootstrap.sh --build-ffmpegThis builds pinned FFmpeg source into user-local ffmpeg and ffprobe executables. It needs
Apple's Command Line Tools (xcode-select --install if missing), compilation time, and free
disk space, but no administrator-owned install prefix or GPG installation. The source archive
is checked with macOS shasum against a checksum verified from FFmpeg's signed release.
Existing usable tools are retained. Setup then warms the Python environments and downloads
the models as usual.
Commands
Command | What it does |
| Capability check, dependency install, model pre-download, and command installation. |
| Install/update the command symlink in |
| Records ~45s from the mic reading a printed passage, saves |
| Extracts a clip from an existing recording instead of the mic (extract → verify → save, no interaction). |
| Foreground mic capture to |
| Capture one app's audio (Zoom, a browser, ...) through ScreenCaptureKit, plus the mic, to a stereo |
| Record one browser meeting tab (Teams web, Meet, Zoom web) through the Chrome DevTools port to a stereo |
| The default command: transcribe + diarize + label one or more audio files. |
| Put real names on clusters after reading the speaker cards. Rewrites the transcript + cards and saves each named voiceprint to the local registry for future transcripts — refusing (unless |
| Average the cluster into Alice's saved print instead of replacing it, to strengthen the print from a new recording. Same similarity guard as a normal relabel. See Speaker identity: enrollment clips vs. the registry. |
| Transcript-only label: renames the cluster in the transcript + cards and never writes to the registry. For a speaker the conversation makes obvious but whose cluster is a poor voiceprint — a long mixed cluster saved as that person would degrade their enrolled print. The label is kept in the sidecar ( |
| Re-apply naming to an existing transcript with no assignments: re-runs registry matching + the absorb pass over the cached sidecar (and the |
| Apply current registry names and roles to historical meetings, regenerate commitments, and refresh roll-up, worklists, search, graph, and wiki. Reports conflicts before changing meeting files. |
| Export the private speaker registry to a new backup file, including voiceprints, roles, and metadata. |
| Restore a registry backup. An existing destination requires an explicit conflict policy; |
| Export one short representative WAV per speaker cluster — the longest diarized segment, clamped to |
| Transcribe a batch into dated meeting folders (idempotent by content hash). |
| Rebuild the workspace index ( |
| Re-diarize meetings an older diarizer got wrong, reusing the existing ASR ( |
| Print the ranked personal worklist (P1/P2/P3) on demand from the corpora, without a roll-up. See Dev-commitments. |
| Build |
| Search every speaker-labeled transcript; each hit is a meeting, a timestamp, a speaker, and a snippet. |
| The verbatim turns around a moment, for reading a hit in place. |
| Entity views: action items with filters, one item's history, a person's commitments and asks, PR mentions, meeting coverage, talk share. |
| Regenerate |
| Hands-free ingest of new recordings via a launchd agent. See Hands-free ingest. |
| Voice Memos helpers that never touch the app's files or database. See Voice Memos helpers. |
| Read-only environment report, including Ollama reachability and the index status of |
| Print the installed version (plus a |
Record a browser meeting tab (no permissions)
whosaid record --app needs Screen & System Audio Recording permission for your terminal. A browser meeting can skip that. Start a Chromium-family browser (Chrome, Vivaldi, Edge) with a debugging port, join the meeting in it, then record that one tab:
# once, when you launch the browser
open -a "Google Chrome" --args --remote-debugging-port=9222
whosaid record --browser # port 9222, or $WHOSAID_CDP_PORT
whosaid record --browser --cdp-port 9222 --target teams.microsoft.comOnly that tab is recorded. Left channel is your mic, right channel is the meeting audio. No system sounds, no notifications.
--targetis a targetId or a piece of the tab's URL. You need it when more than one tab could match. The error lists the candidates.It stops on its own when the call ends. It arms once the hang-up button has been seen, then stops after 2 missed polls. If the button is never seen, use Ctrl-C or
--max-seconds(default 10800).--leave-selector CSSoverrides the built-in Teams, Meet and Zoom web selectors.--leaveclicks hang-up at--max-seconds.If the tab is closed mid-call, whatever was already captured is kept.
--into DIRwritesDIR/<timestamp>[-label].m4aand skips transcription. The file appears only when it is complete, so point it at awhosaid watchsource folder and the watcher ingests it.From an agent, use the
whosaid_record_browserMCP tool withactionset tostart,statusorstop. It runs in the background and needs no terminal.
Record every call, unattended: whosaid autorecord
whosaid autorecord waits for a call, records it, saves it, and waits for the next one. Pair it with whosaid watch and every call ends up transcribed in your workspace with no clicks.
# desktop meeting app (here native Microsoft Teams); needs Screen & System Audio Recording
whosaid autorecord install --app com.microsoft.teams2 --label teams
# or meetings in the browser; needs no permission and no admin rights
whosaid autorecord install --browser --label teams
# transcribe each saved call
whosaid watch install --into ~/meetings --source ~/Recordings --no-fda
whosaid autorecord status # installed? running? recent log lines (--json for scripts)
whosaid autorecord pause --minutes 60 # skip calls for an hour; `resume` undoes it
whosaid autorecord stop-now # end the call being recorded now and save it
whosaid autorecord uninstall # a call in progress is saved firstA new Mac in one command. After git clone, whosaid autorecord setup runs whosaid setup --yes (dependencies, models), autorecord install, then watch install, stops at the first failed step, and lists what is left to do by hand (the permission toggle, whosaid enroll). --owner starts the workspace's whosaid.toml with your name; an existing one is left as is. Try it with --dry-run first.
git clone https://github.com/sblattj/whosaid ~/code/whosaid && cd ~/code/whosaid
./whosaid autorecord setup --app com.microsoft.teams2 --label teams \
--workspace ~/meetings --owner Alice_Example # or --browser instead of --appWhen it records.
--appwatches whether the app has the microphone open: it starts when the app opens the mic and stops 30 s (--idle-seconds) after it closes it.--browserwatches meeting tabs (Teams web, Meet, Zoom web) for their hang-up button. Neither reads the screen.Login.
installstarts it at every login.--appbuilds a small launcher,~/Applications/Whosaid Call Recorder.app, and opens System Settings so you can turn it on under Screen & System Audio Recording. That permission belongs to the launcher, which is only rebuilt when whosaid changes it, so re-runninginstallwith other flags keeps it.No admin rights? If that toggle asks for an admin password, use
--browserwith the meeting on the web. Start the browser with--remote-debugging-port=9222(autorecord never launches or changes your browser). An organization policy can turn remote debugging off.Robust by default. One copy per folder. An app that isn't running or a browser that isn't open yet is a waiting state; other failures back off to 5 minutes and keep trying. Log:
~/Recordings/.autorecord.log.Live with every call (opt-in). Add
--livetoinstallorsetupand each capture also runswhosaid live, which starts when the capture starts and stops when it is saved, so the app's Live now and thewhosaid_livetool follow every call with no extra step. Its log:~/Recordings/.autorecord/live.log. If that transcriber dies mid-call, autorecord logslive transcript failed (exit N), restarts it up to 3 times with growing delays (5 s, 15 s, 45 s), andautorecord status(and--json:live_state,live_exit,live_restarts) and the menu bar show it, while the recording carries on. The app's Recorder page shows whether it is on and the command to turn it on.Environment for the agent. launchd starts the job with a bare environment, so
installandsetupcopy the certificate variables from the shell you run them in (SSL_CERT_FILE,SSL_CERT_DIR,REQUESTS_CA_BUNDLE,CURL_CA_BUNDLE,UV_SYSTEM_CERTS,UV_NATIVE_TLS) plusWHOSAID_BINandWHOSAID_ACTION_ITEMS_HOOKinto the LaunchAgent, same aswatch install.--env KEY=VALUE(repeatable) adds more and wins over the copied ones; values you set earlier survive a reinstall.--offlineaddsHF_HUB_OFFLINE=1(plusTRANSFORMERS_OFFLINE, telemetry off) so nothing round-trips to huggingface.co. Check withwhosaid autorecord install --dry-run.whosaid autorecord runis the same loop in the foreground, for a terminal tab.
Follow the call as it happens: whosaid live
While record --app or autorecord is capturing a call, whosaid live transcribes it as it goes, on this Mac, and keeps a rough transcript you or an agent can read before the call ends.
whosaid live # follow each call being recorded; Ctrl-C to quit
whosaid live show --minutes 5 # the last five minutes of the newest live transcript
whosaid live follow --speaker 'Alice|Bob' --exclude-self --once # each new line from them, once, until the call endsMe vs them, no diarization. The recording keeps your mic and the meeting app apart, so each line is
me:orthem:with the wall-clock time. Your mic also hears the other side through your speakers; mic audio that isn't clearly louder than the app is ignored, so their words aren't repeated as yours.Where it goes.
~/Recordings/.live/<start>.mdand.jsonl, updated every ~20 s (--chunk-seconds). Silent stretches are skipped. Model:whisper-large-v3-turbo(orWHOSAID_LIVE_MODEL).Follow it as it happens. The
.mdand.jsonlare rewritten whole on every update, so do nottail -Fthem (each update replays the call from its first line).whosaid live followprints each new line once instead, line-buffered for a pipe or an agent's background monitor, then anendedline when the call is over (--onceexits there; otherwise it waits for the next call).--speaker REGEX(repeatable) matches the live name or role; lines no known voice matched are kept and marked? (v9, nearest: Bob 0.71)unless--named-only, because one person can show up as a named and an unnamed voice, and a voice that keeps sounding like a known one is noted once (# v9 may be Bob (0.71)). A line where the speaker changes mid-segment is marked(mixed, change at HH:MM:SS); withwhosaid live run --split-words(orWHOSAID_LIVE_SPLIT_WORDS=1) Whisper also returns word times and the line is split at the change instead, at some extra decode time.--format jsonlgives one object per line with a cursor.Ask about the meeting. The
whosaid_liveMCP tool returns the live transcript (or its last N minutes) and whether the call is still going, so an agent can answer "what did they just ask me?" mid-call. It also returns acursor: pass it back assinceto get only the lines after it, with the samespeaker,exclude_selfandnamed_onlyfilters aslive follow.whosaid_live_insightsadds the call's topics, rolling summaries and agreed or suggested next steps (the Live page's data), andwhosaid_live_askfinds the lines that match a question, with their times. whosaid itself sends nothing anywhere; the agent you ask sends the text it reads to its own model.Topics and next steps.
whosaid live insights followturns the newest live transcript into topic buckets, wordgraph nodes, 5-minute summaries and agreed / suggested next steps in<start>.insights.json, locally (live insights build --jsonl Ffor one file).whosaid live ask --question "..."answers from the live transcript: a search by default, Claude only when the workspace opts in with[summarizer] engine = "claude".AI labels and recap (opt-in, CLOUD). With
[live]ai = "claude"in<ws>/whosaid.toml,whosaid live insights follow --ws <ws>(the app passes it) also asks Claude (claude -p, Sonnet) for topic labels, a short recap and next steps once perai_everyseconds of talk (default 180), at mostai_max_calls(40) per call,ai_timeout90 s andai_max_tokens1500 per call. Each call sends the newest ~24k characters of the transcript plus the local topics to Anthropic, about 9k tokens. Answers land in<start>.ai.jsonand are laid over the local insights; every topic keeps itslocalLabel, and a failed call keeps the last good answer. Remove the line (or setai = "") to turn it off.It only reads the recording in progress. The full transcript with names and action items still comes from
whosaid watchafter the call.
One status feed for apps and scripts: whosaid app-state
whosaid app-state --json --workspace ~/meetings # one snapshot
whosaid app-state --watch 2 --workspace ~/meetings # a JSON line now and on every changeIt joins autorecord status --json, the watcher's state for the workspace (the same rules as the SwiftBar plugin), live status --json, the newest meeting and the count of unnamed voices from the last two weeks into one object, plus a single menubar.state (warn, agent-not-loaded, recording-live, recording, paused, ingesting, waiting, done, idle). It is read-only. whosaid tracking set-status <ws> CM-012 done --note "..." is the matching write path for marking an item done (see docs/tracking.md).
A native macOS app: whosaid app
A menu bar app plus a library window: meetings, live now, action items, your commitments, topics, chats, people, unnamed voices and the recorder, in one vertical sidebar. It is SwiftUI, built with the Command Line Tools only (no Xcode needed), and it reads whosaid the way an agent does: whosaid mcp for the library and one whosaid app-state --watch for the menu bar.
whosaid app sign-setup # once per Mac: a local signing certificate, so updates keep Full Disk Access
whosaid app install # swift build -c release, then ~/Applications/whosaid.app
whosaid app open
whosaid app test # swift test for WhosaidKit
whosaid app uninstall
The app never records and never asks for Screen & System Audio Recording: Whosaid Call Recorder.app keeps that grant. Pick the workspace and the recordings folder in its Settings (or set WHOSAID_WORKSPACE / WHOSAID_RECORDINGS). With no workspace, or one that is not indexed yet, the window says what to do next. Settings also has "Open at login". Needs macOS 15 and Swift 6.
The menu bar item shows one glyph per state, from whosaid app-state: a waveform while idle, a red record dot with the elapsed time while a call is recorded (plus a pencil when whosaid live is transcribing it), gears while the last call is transcribed, a check just after, a pause sign while autorecord is paused and a warning triangle when something needs a look. Its panel has Stop and save now, Pause autorecord for 1 hour, the last three meetings, Name N unknown voices, search and Settings.
The library window lists meetings and Teams chats newest first (a call is named by what recorded it, a chat by its chat name). A meeting opens on its transcript, colored per speaker under a talk-time bar, with Summary, Action items and Speakers tabs. ⌘K searches every meeting (whosaid_search) and shows the minute around a hit (whosaid_context). People lists every named voice with what they own and asked for (whosaid_person); Unnamed voices lists the ones still called SPEAKER_NN. If the workspace sits in ~/Desktop or ~/Documents, macOS asks once whether whosaid may read that folder.
A meeting's Speakers tab has one card per voice: turns and talk time, a name field, role chips (self, boss, peer, report, external) and Save, which runs whosaid relabel and then whosaid index so the name shows up everywhere. An unnamed voice shows the closest enrolled voice that just missed the match ("Sounds like Carol (0.64)"), one click from the name field. Play clip cuts a few seconds of that voice with whosaid samples into ~/Library/Caches/whosaid/clips and plays it; the app plays nothing any other way.
My commitments is the ranked worklist for the workspace owner (whosaid_worklist): P1 to P3 chips, why each ranks where it does, and a link that opens the meeting at the moment it was said. Above it, "Looks done?" lists later statements that may show an item is finished (whosaid_tracking_suggestions), with the quote and why it matched. Nothing changes until you click Mark done, which records one status event with that quote as evidence (whosaid_set_status). Action items lists every item from every meeting, with the same Open / Done / All filter.
Live now follows the newest whosaid live session in five lanes: the me / them transcript, topic buckets, a wordgraph, 5-minute summaries and next steps (agreed vs suggested). Topics are buckets: when the talk comes back to a subject, its bucket is marked "back 2×" instead of starting a new one, and a fresh one is marked "new". The wordgraph is the balloon graph from jibbr (topic, idea, detail by color; dashed lines join words shared by two topics): drag and throw the balloons, click one to fold its branch, or switch it to full window. The lanes come from whosaid live insights follow, a small local engine the app starts only while a call is live; it writes <start>.insights.json next to the live transcript. ⌘L opens a narrow floating panel to keep next to the call, with the last lines, the topics and "Ask about this call" (whosaid live ask): it searches the live transcript, unless the workspace sets [summarizer] engine = "claude", which sends the transcript to Claude for the answer. With [live] ai = "claude" the topic labels, a "So far (AI)" recap and the next steps come from Claude instead (see Live above). The wordgraph page is bundled in the app and can load only its own files: no fetches, and any other navigation is cancelled.
Recorder shows what autorecord is doing (waiting, recording, paused), what it records and into which folder, whether the live transcript and the watcher are on, the last saved call or error and the recent log, with the same Stop and save now, Pause and Resume as the menu bar. It never installs anything itself: when the live transcript is off it shows the whosaid autorecord install ... --live command to copy.

Topics rolls up what each meeting was about, across meetings, from <workspace>/_topics.json. Topics are ranked by how many meetings they came up in, then by how recently. A topic shows its terms, its own wordgraph and the meetings it came up in, each opening its transcript where the topic starts. Rebuild runs whosaid topics build.

Topics across meetings: whosaid topics build
whosaid topics build ~/meetings # writes ~/meetings/_topics.json
whosaid topics build ~/meetings --chats # Teams chats and mail tooIt runs the same local topic clustering as whosaid live insights over each meeting's speaker transcript, then merges topics across meetings: same label, or terms that match closely enough. A topic keeps its id as meetings are added, so links to it stay put. Each topic lists its meetings (date, the label it had there, talk time, where it starts), its top terms and a wordgraph. Speaker names are not written to the file. Stdlib only: no model, no network.
A drafts outbox for agent-written chat messages: whosaid drafts
When several AI agents draft chat messages for you, they fight over the one browser tab, you
lose track of which session wrote which draft, and a draft goes stale as the work moves.
whosaid drafts keeps them offline until you stage them, one folder per draft under
~/.drafts/<id>/ (WHOSAID_DRAFTS_DIR overrides): draft.md, claims.json and draft.json.
whosaid drafts new --to "Test Person" --topic "dns records" --page ~/pages/dns-plan.html
whosaid drafts stakeholders ~/meetings --topic "dns records" --repo ~/code/infra --path dns/
whosaid drafts claims <id> --file claims.json # claims, stakeholders, docs
whosaid drafts text <id> --file draft.md
whosaid drafts verify <id>
whosaid drafts lint --all # the voice lint alone, read-only
whosaid drafts list # state and the session that wrote each one
whosaid drafts merge <into> <from> [<from> ...] --text-file merged.md # fold rival drafts into one
whosaid drafts stale-check ~/meetings # stakeholders said something new since verify?
whosaid drafts stage <id> # paste it into the Teams chat; nothing is sent
whosaid drafts unstage <id> --reason R # you cleared the compose box by hand: back to verified
whosaid drafts stage <id> --replace # the compose box already holds text: overwrite it
whosaid drafts stage --all --queue # every verified draft, oldest first, each in its chat
whosaid drafts mark-sent --check ~/meetings # staged drafts you have since sent (and edits you saved)
whosaid drafts stage <id> --edit # a sent draft's verified correction into Teams' Edit box
whosaid drafts html # review page of every open draftWho wrote it.
newrecords the Claude Code session (CLAUDE_CODE_SESSION_ID, joined to the session registry for its name, cwd and pid) and a resume command.showprints it.Claims first. Each claim in
claims.jsonhas akind(code, tech, people, status), averdict(confirmed, refuted, unverified) andevidence.verifyrefuses a draft with an unchecked claim, a refuted claim not markeddropped, a confirmed claim without evidence, a confirmed tech claim without a doc URL, no stakeholders, a stakeholder with no searched topics, or an em dash. It stamps a hash of the message, claims and pages; any later edit reads as stale.Voice lint.
verifyalso checks the message shape withlib/voice_lint.py(plain rules, no model). Shape rules (more than one paragraph, a greeting opener likeHi Sam,, or an opener the message already implies (heads up,can you review,FYI,quick question,just wanted to), anattached pageorThanks!closer) fail verify when the workspace'swhosaid.tomlsays so; the rest (over 47 words, "want me to" asks, banned words and characters) only print warnings. The workspace is--ws, elseWHOSAID_WORKSPACE; with none, the defaultwarnapplies.[drafts] voice_lint = "enforce" # enforce | warn (default: every hit is a warning) | off [drafts.voice] # optional: voice_lint.py profile keys over the default banned_words = ["synergy"]A real exception gets a per-draft override with a reason, kept in
draft.jsonand its history:whosaid drafts verify <id> --voice warn --reason "..."(--voice cleardrops it).lint [<id>|--all]prints what verify would say without stamping anything (exit 1 if one would fail).Who has opinions.
stakeholdersranks speakers by search rank on the topics with a 90-day recency half-life (unnamedSPEAKER_NNvoices skipped), plus who changed--pathin--repoover the last six months. Read what the top people said before claiming what they want.Staging last.
stagetakes~/.drafts/.outbox.lock, refuses stale drafts and a second open draft to the same person, then runs the stager (the built-in one, orWHOSAID_DRAFTS_STAGER, see below) with--chat NAME --md FILE [--attach PAGE ...], plus--replace --only-these-attachmentson a restage, andTEAMS_OUTBOX_LOCKset. One draft per run with--all;--all --queuestages every verified draft, oldest first, each into its own chat's compose box, and stops at the first stager failure.mark-sentanddropclose a draft (a soft delete:list --allstill shows it).Staging from an agent or the app. The MCP tool
whosaid_draft_stage(id, orallwith an optionalqueue,dry_run,replace) runs the samestage, so an agent can stage a draft when you ask instead of handing you the command. The app's Drafts page (sidebar, Outbox) lists the open drafts with a count of ready ones, previews each message and its attached pages, and has Stage in Teams and Stage all ready. None of them sends: the stager never presses Enter or a send button, so you read the message in Teams and send it there.Correcting a message you already sent (#209). A draft marked sent keeps the id of the message it went out as. Write the corrected message (
drafts text, plusdrafts claimsfor anything new, such as a link),drafts verifyit like any draft, thendrafts stage <id> --edit(MCPwhosaid_draft_stage(edit=true), or Stage edit in Teams in the app). The stager finds your message in the chat by its id, opens Teams' Edit on it, replaces the text (rich text, links kept) and stops: it never presses Enter and never clicks a save, Done or send control, so you check the edit and save it in Teams. It refuses a correction that isn't verified or changes nothing (a link-only change counts), a message it can't find or that isn't yours, and a message someone else answered since (--allow-newerto edit anyway; your own later messages only warn). A sent draft takes a new text, claims, stakeholders and docs, never pages or mentions (Teams can't change those on a sent message). Each staged edit is kept in the draft'sedits[](old text, new text, revision,staged_at), andmark-sent --checksetsedited_atonce the chat shows the edit: Teams' edit time from the desktop cache, or the new text on that message id.whosaid teams syncre-ingests a message Teams edited since it was ingested.drafts edit-sent <id>is an alias with the same gates, plus--match-filefor a chat that isn't ingested.Sent check and same-name chats.
mark-sent --check(and the already-sent check instage) also reads a chat whose name is the recipient's plus a(chat 1a2b3c)suffix, the name the Teams cache gives a second chat with the same title (a group chat named like the 1:1).Merging rivals. Teams has one compose box per chat, so
stagerefuses a second open draft to the same person (listshows arivalscount per recipient).drafts merge INTO FROM [FROM ...]folds them into one: claims union bykey(a key with a different verdict or evidence refuses and lists the clashes), stakeholders byspeakerwith their searched topics merged, pages, mentions and docs union, and the FROM owners become contributors of INTO.--text-fileis the merged message; without it the texts are joined with a blank line and verify flags the extra paragraphs. Each FROM is closed asdroppedwithmerged_intoand amergedhistory event (list --allshows it); INTO getsmerged_from(showlists it) and is stale until verified again. A FROM owned by another live session needs--force, likedrop.--dry-runprints the result and clashes and writes nothing. MCP:whosaid_draft_merge(into, from, text?, force?, dry_run?).The built-in stager. When
WHOSAID_DRAFTS_STAGERis unset,stageuseslib/drafts_stager.py. It needs either the Teams desktop app or a Chromium browser started with--remote-debugging-portwith Teams web signed in (port order below). It opens the chat by exact name (left rail, else search;--new-chatstarts one via the To picker), pastes the draft as rich text, attaches the pages, and with--replaceclears the box (--only-these-attachmentsalso removes the attachments already there). Each--mention NAMEbecomes a real @mention at the start of the message, in order, before the body; a literal@NAMEin the text is dropped so it is not doubled. It never sends (no Enter, no click on Send), prints a JSON line and leaves the chat focused. SetWHOSAID_DRAFTS_STAGERto your own command to stage into another chat app.Staging into the Teams desktop app. The app is a WebView2 (Edge) host, and it only speaks CDP when it is started with a debugging port.
whosaid teams launch [--port N] [--check] [--force] [--wait S]quits Teams and relaunches it withWEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=N(default 9333), and refuses while the mic is open (you may be in a call);--checkonly reports whether the port answers,--forceskips the mic check,--wait Sis how long to wait for the port. The same by hand:osascript -e 'quit app "Microsoft Teams"' open -a "Microsoft Teams" --env WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS="--remote-debugging-port=9333"MCP:
whosaid_teams_launch(paramsport,check_only,force). Two ports, so the app and your everyday browser can both be up:WHOSAID_TEAMS_CDP_PORTis the app's (default 9333) andWHOSAID_CDP_PORTis the browser's (default 9222). The stager picks the port in this order:--cdp-port N, elseWHOSAID_CDP_PORT(either one is used as is, browser mode, nothing probed); else the app port if it answers asEdg/(app mode); else 9222. If the app port answers as something else (a browser you started there), it printsport N is held by <Browser>, not the Teams app; using the browser portand uses 9222. In app mode the stager picks the chat window among the Teams pages (titleChat | <name>, else anyChat |window, else the page that has the search box) and never opens a tab; with no chat window it exits 1 "Teams app is up but no chat window found". In browser mode it takes the first Teams page, else opens one, as before.One owner at a time. Changing a draft another live session owns is refused without
--force(orforce=trueover MCP): ask that session instead. A draft whose session is gone is adopted by whoever changes it next.One draft per person.
newto someone who already has an open draft joins it: same id, you become a contributor who may write it, and your text becomes a new revision. Merge your points in instead of starting a rival message.--newmakes a separate draft anyway.--mention NAMErecords who to @mention; it is passed to the stager as--mention.Every revision kept. Each text change is saved under
revisions/with who wrote it.show <id> --rev Nprints one,diff <id> [--from N --to M]compares two.Reads the chat first. With a workspace of ingested chats (
whosaid teams ingest),stageprints the last messages with that person and skips a draft that already appears there as your message.mark-sent --checkmarks staged drafts sent the same way, so the outbox closes itself after you send.
Agents use the same outbox over MCP: whosaid_draft_new, whosaid_draft_write,
whosaid_draft_verify, whosaid_draft_close, whosaid_drafts, whosaid_draft_stakeholders,
whosaid_draft_stale, whosaid_draft_sent_check: create, read (with rev and diff),
update and close. whosaid_draft_verify takes voice and reason for the override and
returns voice_warnings; whosaid_drafts(action="lint") is the read-only lint.
whosaid_draft_stage stages (and with edit=true loads a correction into Teams' Edit box); there
is no send tool.
Enroll from an existing recording
Already have a clip of the person speaking? Skip the mic and cut a reference straight from it:
whosaid enroll Alice --from meeting.m4a --ss 3:20 --t 20 # or --to 3:40Meeting workspaces
One-off transcriptions are files; a recurring meeting series is a corpus. The meeting-workspace layer gives that corpus a home: every recording is transcribed into a dated folder, each meeting can carry generated action items and dev commitments, and one roll-up produces the index, the audit, a living action-item list, and the dev-commitments corpus across all of them — still entirely offline.
whosaid ingest — a batch into dated folders
whosaid ingest weekly/*.m4a --into ./meetings --folder-by created \
--tz America/Los_Angeles --action-itemsEvery file is transcribed with the full set of whosaid <audio>… flags passed through, into
meetings/YYYY-MM-DD-HHMM/. The timestamp comes from the recording's own container
creation_time rendered in --tz (default UTC), falling back to the file's mtime — so folder
order reflects when meetings actually happened, not when you got around to copying the files.
Ingest is idempotent by source sha256: re-running a batch never re-transcribes or duplicates a
recording.
With --action-items, each meeting also gets an action-items.md. Generation is pluggable: the
hook command receives the speaker-labeled transcript on stdin, plus WHOSAID_SPEAKERS and
WHOSAID_TRANSCRIPT_PATH in its environment, and whatever it writes to stdout becomes the
markdown. Pass it per-run with --hook CMD, or set WHOSAID_ACTION_ITEMS_HOOK once. With no
hook, a skeleton is written instead and everything stays offline. Example hook — illustrative
only; any command that turns stdin into markdown works:
#!/bin/sh
# Drafts action items with a local Ollama model.
ollama run llama3.2 "List this meeting's action items as markdown bullets (Owner: task):"You no longer need a hook to get a real draft: --engine ollama (or the default --engine auto, which uses Ollama when it is running) turns on the built-in summarizer described in
Action items drafted by a local model; --engine claude
is the opt-in cloud alternative. --engine
implies --action-items. Add --index and, once every file is in, ingest runs
whosaid roll-up <ws> --action-items and whosaid index <ws> for you, so the new meetings are
searchable in the same command:
whosaid ingest weekly/*.m4a --into ./meetings --engine ollama --indexWith --commitments, each meeting also gets a commitments.md — the first-person commitments
you made in it (see Dev-commitments). Roles set once via
whosaid relabel --role decide whose cues count: tag your own voice self (and your manager
boss), and the extractor tracks what you promised, ranking boss-requested items higher.
--min-words N tunes the fragment filter for the run (fragments such as "I'll do that" are
dropped; default [commitments] min_words, else 2; 0 disables).
whosaid roll-up — index, audit, and the action-item corpus
whosaid roll-up ./meetings --action-itemsCoverage index —
_INDEX.mdholds one row per meeting (created date, duration, and whether it is transcribed, diarized, and has action items), plus a nothing-missing audit that flags orphan directories and stale manifest entries, and a recurring-topics section that surfaces themes appearing across meetings. Once any dev commitments exist, a one-line open/total count section is appended too. Both output paths are overridable with-oand--action-items-out.Action-item corpus — with
--action-items,_ACTION-ITEMS.mddeduplicates items across meetings (by text similarity; threshold--similarity-threshold, 0.5–1.0, default 0.82) and groups them by owner, then status. Ids are stable (AI-001… and never renumber), each item carriesfirst_seen/last_seendates and its occurrence list, and open/ongoing/resolved statuses survive re-runs — so the corpus reads as living history across the series, not a per-meeting snapshot. Items can carry a free-form type, rendered in parens after the status, and pairs scoring just under the threshold are surfaced in a Possible duplicates (review) section at the end.Dev-commitments corpus — each meeting's
commitments.json(fromingest --commitments) folds into_COMMITMENTS.mdautomatically whenever any exists: stableCM-NNNids that never renumber, the same 0.82 text-similarity dedupe and 0.10 near-miss review band, grouped by status then speaker, with**[boss]**marking boss-requested items. Hand edits in_COMMITMENTS.md(mark onedone, retitle, merge via(merged CM-NNN)) fold back on the next roll-up, exactly like the action-item corpus. The fold applies the same[commitments] min_wordsfragment rule as extraction, so per-meeting files written before the filter (or by a hook) never put "I'll do that" into the corpus: skipped entries are listed under Dropped fragments (review) and kept in_commitments.jsonasdropped;CMitems already in the corpus are never touched. Roll-up refolds only the meetings whose commitment inputs changed (commitments.json,action-items.md,*.speakers.txt, roles) or that are new or gone, so a small Teams delta takes seconds; it logsrefolding N changed commitment folder(s): ...and, on a long fold,folded N/M commitment folder(s)every 25 folders. Ids and hand edits stay put, and the fields a removed line contributed (cue, boss priority, requester) are re-derived from what is left. A[commitments]change inwhosaid.tomlstill refolds every meeting.Personal worklist
_WORKLIST-<Owner>.md: whenever a commitments corpus or owner-attributed action items exist, roll-up also writes the owner's open items ranked into P1/P2/P3 (see Dev-commitments). The owner is--owner NAME, else theself-roled speaker, else[workspace] owner;--all-ownerswrites one file per participant. It is a regenerated view, never reconciled: edit the corpora, not the worklist.
Dedupe is text similarity (difflib, the --similarity-threshold above) and, when a local
Ollama answers on 127.0.0.1 with [search] embed = true, also embedding cosine: two texts
match when either the difflib ratio or the [search] embed_model cosine clears its threshold
([commitments] embed_threshold, default 0.90), so "I'll write the rollout runbook for the
platform team" and "for the platform team write the rollout runbook" fold into one item. No
Ollama, embed = false, or a non-loopback URL means difflib only, with one log line saying so.
Roll-up is incremental and append-only by default: re-running with nothing new writes nothing.
--rebuild is the escape hatch — it resets the manifest and corpus and regenerates both from the
folders on disk.
State is plain JSON in the workspace directory — _workspace.json (the manifest; it points at
the dev-commitments corpus via commitments_corpus) and _action-items.json (the corpus; it
also records the similarity_threshold in effect), plus the parallel _commitments.json.
All are safe to read and hand-edit — marking an item resolved by hand is the intended way to
close one the extractor phrased wrong. Hand edits made directly in _ACTION-ITEMS.md (and,
same contract, _COMMITMENTS.md) are folded back on the next roll-up and survive re-runs:
statuses, types, retitles, and (merged AI-NNN) merge annotations (the merged item stays at
its id, rendered collapsed as [merged → AI-NNN]). Only --rebuild discards them. --index
runs whosaid index <ws> right after the roll-up.
Upgrading to 1.11: reprocess older meetings
v1.10 fixed two diarizer faults: short fragments inflated the automatic speaker count, and
recordings under 15 minutes ignored --speakers N and undercounted. Meetings processed before
that keep their old speaker split, and their action items and commitments inherit it (a phantom
"speaker" owns items, or two people are merged into one). whosaid reprocess re-diarizes them
without re-running Whisper.
whosaid reprocess ./meetings --dry-run # 1. what would change; touches nothing
whosaid reprocess ./meetings # 2. re-diarize the flagged meetings (or --all)
$EDITOR ./meetings/_ACTION-ITEMS.md # 3. review the refolded corpusThe detector (
--dry-run). It reads each meeting's<base>.diarization.jsonand prints findings per meeting.likely:brief-fragments(an auto count that includes a cluster made only of turns under 2 s) andshort-hinted-undercount(audio under 900 s,--speakers Nhinted, fewer than N found), andbound-pinned(a--max-speakersbound, ormax_speakersinwhosaid.toml, set a saturated auto count to the bound instead of capping it; fixed in v1.11.2).possible:short-whole-file(audio under 900 s, auto count, no count estimate; a v1.10--no-chunkrun looks the same) andref-order(two or more--refvoices matched, which were assigned in argument order before v1.10). Sidecars written by v1.11.0 or v1.11.1 are checked forbound-pinnedonly; v1.11.2 or later are never flagged. With noMEETINGand no--all,reprocesstakes exactly the meetings with a finding;MEETINGnames (folder names) or--alloverride that.No ASR. Each meeting is re-run as
whosaid transcribe --reuse-asr, which reuses the existing<base>.json. Diarization takes seconds to a minute per meeting. Regenerating action items runs the summarizer once per meeting, and theclaudeengine is a cloud call per meeting (see The claude engine);--engine Eand--hook CMDchoose the summarizer as iningest.--no-itemsskips regenerating action items and commitments.Speaker counts. The flags a meeting was first diarized with (
--speakers Nor the--min/--max-speakersbounds, recovered from the sidecar'sdetect_mode) are reused unless you pass--speakers,--min-speakers,--max-speakers, or--expected-speakers, which replace them. Expected-speaker names are not recorded in the sidecar, so they are not recovered.--match-threshold Xsets the registry match threshold.Preserved.
relabel --no-savelabels carry over to the new clusters by segment overlap. A role is never re-applied for a name the registry already knows. Thenreprocessrunswhosaid roll-up <ws> --action-items --refold MEETING=<backup>/action-items.mdfor each regenerated meeting, so corpus ids (AI-NNN/CM-NNN), hand-set status, and hand-edited titles survive while owners follow the new speaker labels.Backups and restore. Before touching a meeting,
reprocesscopies its<base>.speakers.txt,<base>.speaker-cards.txt,<base>.rttm,<base>.diarization.json,action-items.{md,json}, andcommitments.{md,json}to<ws>/_reprocess-backups/<meeting>/<UTC timestamp>/, and restores them itself if a step fails. To undo a run you did not want, copy those files back into the meeting folder, then runwhosaid roll-up <ws> --refold MEETING(a plain roll-up skips already-folded meetings). The_-prefixed folder is skipped by roll-up and workspace scans; delete it when you are satisfied.
On 8 synthetic meetings first processed by v1.8.2 (ground truth known), the total absolute
speaker-count error went from 11 to 6, and the two meetings run with --speakers N went from 2
speakers found (of 5 and of 3) to exact. Whisper was skipped in 8 of 8, and a --no-save label,
a hand-set done, and a hand retitle all survived the refold with ids unchanged.
The MCP tool whosaid_reprocess wraps the same command; its dry_run defaults to true.
whosaid teams ingest — Microsoft Teams chat, no audio
Many decisions and commitments are made in Teams chat, not in recorded calls. whosaid teams ingest adds a chat export to the same workspace. There is no diarization and no Whisper, because
every message already names its author.
whosaid teams ingest teams-export.json --into ~/meetings --tz America/Los_Angeles --self Alice_Example
whosaid roll-up ~/meetings --indexThe input is the output of the browser scraper in
contrib/teams-scraper/. That can be its raw_by_chat map,
a flat list or NDJSON of {chat, author, ts, epoch, text} records, or the same records with
timestamp_iso/epoch_ms. The scraper's README also covers how to run it and the Teams web DOM
contract it depends on. Ingest writes one dated folder per chat per local
day (YYYY-MM-DD-HHMM, where the time is that day's first message). Each folder holds:
teams.speakers.txt:[HH:MM:SS] Speaker: textturns, stamped with the local time of day.teams.diarization.json: the provenance sidecar.source.kindis"teams-chat", and each segment keeps the message's exactepoch_ms.
Search, the graph, commitments and the worklist all read teams.speakers.txt the same way they
read a recording's transcript. A search hit's file is teams.speakers.txt, and whosaid graph <ws> meetings --kind teams-chat (or the kind argument of the whosaid_meetings MCP tool)
tells chat days apart from recordings. A folder with no audio takes its created time from the
sidecar's source.creation_time. The roll-up audit treats it as text-only rather than flagging a
missing transcript.
Map Teams display names to the names you use elsewhere, and give people roles, in whosaid.toml:
[teams.names]
"Doe, Jane (Vendor, consultant)" = "Jane_Example"
[teams.roles]
Jane_Example = "boss"Unmapped authors keep their display name. When a role is not set in [teams.roles], it comes
from the voice registry's role for the same name. --self NAME overrides both. Re-ingesting an
overlapping export is safe: messages are deduplicated on epoch_ms and merged into the existing
folder, and --dry-run shows what would be written.
whosaid teams live: Teams chats live from the desktop app's cache
The new Teams desktop app keeps recent chats in a local Chromium IndexedDB store. whosaid reads a
copy of it, so messages show up seconds after they arrive. There is no network call (nothing to
throttle or rate-limit), no Graph permission and no browser automation. The store only holds what
the app has synced (recent chats and the ones you opened lately), so teams ingest of a scraper
export stays the backfill path.
whosaid teams live # newest messages from every chat, last 2 hours
whosaid teams live --chat "release" --since 3d --limit 20
whosaid teams chats --since 7d # conversations, newest first, with the last message
whosaid teams sync ~/meetings --dry-run # what would land in the workspace
whosaid teams sync ~/meetings # write cache messages the workspace lacks
whosaid teams cache-status # where the cache is, when Teams last wrote it--json on each gives machine output; --since takes 90m, 2h, 3d, 1w or an ISO time.
sync writes the same chat-day folders as teams ingest (scraper teams-local-cache@1) and
passes only message ids the workspace does not have, so folders an earlier scrape wrote are
merged into, not duplicated. Chat names come from the folders already in the workspace (matched
by message id), else [teams.chat_names], else the Teams left-rail name (topic, the other person
for a 1:1, or the members' first names):
[teams]
live_cache = true # the watcher syncs on every pass, then refreshes the index
live_cache_since = "14d" # how far back each pass looks (default 14d)
[teams.chat_names]
"19:0123abcd@thread.v2" = "Release room"The first read takes a few seconds (the store is copied, then decoded with the vendored MIT
ccl_chromium_reader, see lib/vendor/README.md); the decoded result is cached in
~/.cache/whosaid/teams-live.json (mode 600) until Teams writes again. WHOSAID_TEAMS_CACHE
points at a different IndexedDB directory (off disables the cache in whosaid drafts). Over MCP,
whosaid_teams_live reads the same data. whosaid drafts stage and mark-sent --check also look
at the cache, so a message you sent minutes ago counts before it is ingested. It is your own
data on your own disk, but check your organization's policy before syncing chats into files.
whosaid teams calendar: your Teams calendar from the same cache
The Teams app keeps the calendar weeks it has shown on disk. whosaid teams calendar lists your
meetings from there: no network call, no Graph permission, no browser. It reads two stores and
merges them, preferring the first: the Outlook calendar the Teams Calendar tab now embeds (its own
IndexedDB under WebStorage/<bucket>/, found through the profile's QuotaManager), and the older
Teams:calendar store, which stops updating once the app switches to the Outlook calendar. Each
event says which one it came from (source), and sources reports each store's range.
whosaid teams calendar # today through 7 days on, local times
whosaid teams calendar --next-week # Monday through Sunday (also --this-week, --from last-week)
whosaid teams calendar --from tomorrow --days 1
whosaid teams calendar --from 2026-11-02 --to 2026-11-07 --q "planning"
whosaid teams calendar --ws ~/meetings --json # each event lists the recordings made during it
whosaid teams calendar status # which weeks the cache holds, when Teams syncedIt is a window, not the whole calendar: every result carries covers (first and last event start
in the cache) and synced_at, and an empty day outside that range means unknown, not free. Open
the week in the Teams Calendar tab to pull it in. Times in the store are UTC and are shown in
this Mac's zone. Recurring meetings come one row per occurrence; an occurrence the cache stores
without attendees or body takes them from its series (attendees_from: "series"). Cancelled
events and ones shown as Free are hidden unless --cancelled / --free, and the header says how
many were. --q matches the subject, organizer, location or an attendee name. With --ws (or
WHOSAID_WORKSPACE), recorded lists the meeting folders that started from 10 minutes before
an event to its end, which ties a recording to the invite it belongs to. The decoded result is
cached in ~/.cache/whosaid/teams-calendar.json (mode 600) until Teams writes again. Over MCP,
whosaid_teams_calendar reads the same data.
whosaid teams ingest-transcript: Microsoft Teams meeting transcripts, no audio
When Teams has already transcribed a meeting, the Recap page's Transcript panel names every
speaker. whosaid teams ingest-transcript adds that transcript to the workspace without the audio.
whosaid teams ingest-transcript transcript.json --into ~/meetings --tz America/Los_Angeles --self Alice_ExampleThe input is window.__tt.result from
contrib/teams-scraper/teams-transcript-scraper.js.
The panel is a virtualized list, so the scraper reads the list's full item array from React
rather than the few rows on screen, and falls back to a scroll-and-harvest pass that puts the
scroll position back. One meeting becomes one folder named from the meeting start
(YYYY-MM-DD-HHMM). teams.speakers.txt stamps each turn as an offset from the start, like a
recording's transcript, and teams.diarization.json has source.kind "teams-transcript",
the transcript_id, and the Start/Stop events. The start comes from the export's
meeting.start_iso or from --start ISO. Re-ingest rewrites the folder that already holds the
same transcript_id. [teams.names], [teams.roles], --self and --dry-run work as for chat.
Search your meetings
A workspace with a dozen meetings is a corpus you cannot reread. whosaid index turns it into
something you can query: an exact full-text index over every turn, optional meaning search
through a local embedding model, an entity graph (people, meetings, action items, timestamped
commitments, PR mentions), and a generated wiki. Everything lives in the workspace, everything is
rebuildable, and nothing leaves the machine.
whosaid index ./meetings # build _search.db + _WIKI.md (embeds if Ollama is up)
whosaid index ./meetings --no-embed # exact search only, no Ollama needed
whosaid search ./meetings "budget" # every turn that says budget
whosaid search ./meetings "who is blocked" --mode meaning
whosaid search ./meetings '"token exchange"' --speaker Bob_Example --meeting 2026-09-16 -k 5
whosaid context ./meetings 2026-09-16-0900 00:00:06 # the verbatim minute around a hit
whosaid graph ./meetings items --owner Alice_Example --status open
whosaid graph ./meetings item AI-003 # one item: occurrences + timestamped commitments
whosaid graph ./meetings person Bob_Example # talk share, commitments made, asks received
whosaid graph ./meetings prs | whosaid graph ./meetings meetings | whosaid graph ./meetings speakers
whosaid wiki ./meetings --stdout # the generated wiki, regenerated from the graph<ws> can be left out everywhere: the commands fall back to $WHOSAID_WORKSPACE, then to the
current directory when it holds _workspace.json or whosaid.toml. Any subfolder with a
*.speakers.txt counts as a meeting, dated or hand-named, so transcripts you made by hand are
indexed too.
Three search modes. --mode exact (the default) is SQLite FTS5: words, "quoted phrases",
AND/OR/NOT, and prefix*, and it needs nothing but Python. --mode meaning embeds the
query with nomic-embed-text through Ollama on 127.0.0.1:11434 and returns the nearest turns,
so "who is blocked" finds "I'm stuck until the review lands" with no shared words. --mode hybrid
runs both and fuses the rankings, which is usually what you want once the index has embeddings.
Every hit prints the same way, and --json gives you one object per hit with the same fields:
$ whosaid search ./meetings "budget"
whosaid: engine: exact
[2026-09-16-0900 @ 00:00:06] Bob_Example: I will review the »budget« spreadsheet before Thursday.
1 hit(s).
$ whosaid context ./meetings 2026-09-16-0900 00:00:06 --before 10 --after 10
whosaid: 2026-09-16-0900: 3 turn(s) between 0s and 16s
[00:00:01] Alice_Example: Let's start with the roadmap for next quarter.
[00:00:06] Bob_Example: I will review the budget spreadsheet before Thursday.
[00:00:14] Alice_Example: Great, and I will send the draft out to the team today.Set Ollama up once if you want meaning search: brew install ollama && brew services start ollama && ollama pull nomic-embed-text. Without it, index skips the embedding pass with a note and
search runs exact-only; nothing fails.
Graph views. The entity tables are built from reliable signals only: the manifest, the
deduplicated action-item corpus, each meeting's action-items.md, and the transcripts
themselves. items filters by --owner, --requester, --status, and --type; item AI-NNN
shows one item's occurrences and every timestamped commitment behind it; person [Name] is the
per-owner view (talk share, what they committed to, what they asked others for); prs lists
pull-request numbers mentioned in speech or in the notes; meetings is coverage per folder; and
speakers is talk share across the workspace. Every view takes --json.
The wiki. _WIKI.md is a generated rollup: people, meetings, open items, and PR mentions,
each action item citing the [meeting @ time] it was committed at. It is regenerated by index
and by whosaid wiki, so it never drifts from the data. Edit the corpus, not the wiki.
Per-workspace settings: whosaid.toml. Optional, next to the transcripts. Every key has a
default; WHOSAID_OWNER, WHOSAID_OLLAMA, and WHOSAID_SUMMARIZER_MODEL override the matching
keys per run.
[workspace]
owner = "Alice_Example" # whose action items this workspace tracks
aliases = ["Ali", "Alicia"] # how the transcript may misspell the owner (default: first name)
[groups] # optional, ordered; drives the action-item sections
leadership = ["Bob_Example"]
team = ["Carol_Example", "Dan_Example"]
[summarizer]
engine = "auto" # auto | ollama | claude | hook | none (claude: opt-in, cloud)
model = "qwen2.5:14b"
timeout = 900 # seconds per model call
num_predict = 2048 # max tokens per model reply; caps a runaway generation
think = false # thinking models (qwen3, qwen3.x) skip reasoning; much faster
# per model: think = { "qwen3:14b" = true, default = false }
claude_model = "opus" # engine = "claude": any `claude --model` value
claude_timeout = 900 # seconds for the one claude call
claude_bin = "" # path to `claude` (default: WHOSAID_CLAUDE_BIN, PATH, ~/.local/bin)
fallback = "ollama" # engine = "claude" failed: "ollama" (if it is up) or "none" (skeleton)
[search]
ollama = "http://127.0.0.1:11434"
embed_model = "nomic-embed-text"
embed = true # false: exact search only, never contact Ollama
[commitments] # optional; ranks _WORKLIST-<Owner>.md. A list you set replaces
boss = [] # the default list, so omit a key to keep the built-in cues.
deadline_cues = ["today", "tonight", "tomorrow", "eod", "end of day", "this week", "next week"]
blocking_cues = ["blocking", "blocked", "urgent", "asap", "critical", "hotfix", "outage", "prod issue", "release blocker", "customer escalation"]
negators = ["not", "no", "non", "never", "isn't", "won't", "don't", "without", "hardly"]
embed_threshold = 0.90 # cosine at or above this is a duplicate (with [search] embed)
min_words = 2 # content words a commitment clause needs; 0 keeps every fragment
[commitments.weights] # score = sum of the signals that fired
boss = 5
blocking = 4
deadline = 4
overdue = 1 # replaces deadline once a relative deadline has passed
repeat = 2 # per extra meeting
recent = 1
strong = 1
requested = 1
negative = -3
[watch]
source = "" # folder to watch (default: the macOS Voice Memos store)
# speakers = 2 # exact diarization speaker count (a 1:1-call workspace)
# min_speakers = 2 # lower bound on auto-detected speaker count
# max_speakers = 4 # upper bound (a ceiling for a mixed-size meeting workspace)
# expected_speakers = ["Alice_Example", "Bob_Example"] # roster, or "Alice_Example,Bob_Example"
[diarize] # same four keys, for EVERY ingest into this workspace
# max_speakers = 8 # e.g. a ceiling near the team's real headcountWhat _search.db is. One SQLite file in the workspace: an FTS5 table of every turn (meeting,
timestamp, speaker, text), a table of stored embeddings keyed by turn, and the graph tables.
It is a derived artifact. Delete it whenever you like and run whosaid index again; --rebuild
does the same and also re-embeds every turn.
Action items drafted by a local model
whosaid ingest --action-items can draft each meeting's action-items.md with a local model
instead of a hook. --engine (or [summarizer] engine in whosaid.toml) picks how:
Engine | What happens |
| The hook if one is configured, else Ollama if it answers on localhost, else the skeleton. |
| The built-in summarizer below. If Ollama is down or the model is missing, it warns and writes the skeleton (exit 0). |
| Opt-in, cloud. The transcript text goes to Anthropic. See The claude engine. |
| The |
| The skeleton only (speakers listed, no items). |
The model never gets to invent timestamps or evidence. The script picks the candidate turns
deterministically (every substantive turn by the workspace owner, every turn that names the
owner, plus one model pass over the rest for unnamed asks), then the model reads one turn at a
time (long turns in sentence groups) and answers either SKIP or bullets that each carry a short
verbatim quote. Every quote is checked against its turn before it is kept; a mismatch is flagged
rather than dropped silently, and the evidence turns are appended verbatim at the bottom of the
file.
Sections come from your config: one section per [groups] entry (asks from leadership, asks
from the team, and so on), team directives from leadership, the owner's own commitments, and an
inferred section for the rest. A team directive is a priority, deadline, process or expectation
that leadership sets for the whole team, such as "from now on every PR needs a linked ticket". It
counts as the owner's item even when the owner is never named and never speaks. Leadership is the
[groups] entry named leadership plus anyone the transcript role-tags boss; without either,
the section is left out. The
file opens with a DRAFT banner so a reader knows it has not been reviewed, and ends with an
evidence block of the exact turns each bullet came from. Read the evidence before folding
anything into _ACTION-ITEMS.md.
Model choice: the default is qwen2.5:14b (ollama pull qwen2.5:14b; about a minute for an
hour-long meeting on an M-series Mac). WHOSAID_SUMMARIZER_MODEL=qwen2.5:7b is roughly twice as
fast and roughly twice as noisy. Ollama is only ever contacted on 127.0.0.1.
The claude engine (opt-in, cloud)
engine = "claude" (or --engine claude) drafts the same file with Claude through the
Claude Code CLI you are already signed in to: one claude -p
call per meeting reads the whole transcript and returns the items as JSON. This sends the
transcript's text to Anthropic. Audio and voiceprints stay local. Only turn it on for
recordings you may send to a cloud model; auto never picks it.
The rest is the local engine's contract: the same sections (the plan decides them, not the model), the same guards (a team directive from someone outside leadership, or on a sentence that opens "Sam, ...", is dropped), and the same verification. The speaker and time of every item come from the transcript turn that holds its quote, a quote no turn holds is flagged ⚠, and the evidence block lists the verified turns verbatim. The note under the banner names the model and says the transcript went to Anthropic.
The call is isolated: no tools, no MCP servers, no settings, hooks, skills, or slash commands, no
saved session, an empty temp directory as its cwd, and ANTHROPIC_API_KEY /
ANTHROPIC_AUTH_TOKEN scrubbed so it runs on your Claude login, not an API key. Model-alias
overrides (ANTHROPIC_MODEL, ANTHROPIC_DEFAULT_OPUS_MODEL, ...) and a parent Claude Code
session's CLAUDECODE / CLAUDE_CODE_* variables are dropped too, so claude_model alone picks
the model and a run from inside Claude Code matches the watcher's. The binary is
[summarizer] claude_bin, else WHOSAID_CLAUDE_BIN, else claude on PATH, else
~/.local/bin/claude, so the whosaid watch LaunchAgent finds it too. If the call fails, the
engine warns and falls back to the local Ollama engine when fallback = "ollama" and Ollama is
up, else writes the skeleton (exit 0). On the six eval fixtures the claude engine scores F1 0.960
in 6 calls, against 0.899 for the same Opus model run through the per-turn pipeline (84 calls) and
0.895 for qwen3:14b (docs/eval.md).
Hands-free ingest: whosaid watch
Record a meeting in Voice Memos, walk away, and have it transcribed, summarized, and searchable a
few minutes after you press stop. whosaid watch is a launchd LaunchAgent that watches a folder
(the macOS Voice Memos store by default) and pushes each new recording through
whosaid ingest --action-items --commitments, then roll-up (which also refreshes the
worklist), then index.
whosaid watch install --into ~/meetings --seed # install; mark the memos already there as done
whosaid watch install --into ~/meetings --dry-run # print the plist and every step; change nothing
whosaid watch status --into ~/meetings # installed? loaded? last run? (--json too)
whosaid watch run --into ~/meetings --dry-run # what WOULD be ingested right now
whosaid watch uninstall --into ~/meetings --purge # stop the agent, remove state + staging + interpreterHow a pass works: every audio file in the source folder that is not yet in
<ws>/.watch_state.json and whose mtime has been stable for [watch] stable_seconds (default
120) is copied into <ws>/.watch_staging/ and ingested from there; a file whose mtime is still
moving is treated as still recording or still syncing, and the pass waits (bounded by
max_wait_seconds) and rescans. A .watch.lock keeps two passes from overlapping. The agent
fires on folder changes (WatchPaths) and every --interval seconds (default 900) as a safety
net. --seed marks the recordings already present as done so an existing library is never
reprocessed. --source DIR watches any folder, not just Voice Memos; --offline bakes the
Hugging Face offline variables into the agent so a machine with no network access never tries to
fetch; --env K=V adds any other environment the agent should carry; --engine E on run picks
the summarizer. The recordings source and the meeting workspace (transcripts, corpora,
_search.db) must be separate folders — never the same directory, never nested inside each
other — or install/run refuse to start.
Diarization speaker hints. The watcher can never ask how many people were actually in the
room, and blind speaker auto-detect can over-split a long call into a pile of phantom speakers
when it should have found two. Set [watch] speakers = 2 for a workspace that only records 1:1
calls, or max_speakers as a ceiling for a workspace with mixed-size meetings; min_speakers
and expected_speakers (see the --expected-speakers flag reference below) are read from the same
block. These are workspace-wide, not per-recording — every ingested recording gets the same
hint — and take effect on the very next pass with no reinstall, since run re-reads
whosaid.toml each time it runs; install's --dry-run summary echoes the hints it would use.
An invalid value (a non-whole-number, min_speakers above max_speakers, an empty name) makes
both run and install refuse, naming the bad key.
[diarize] takes the same four keys and applies to every whosaid ingest --into the workspace
(manual or watcher) and to MCP whosaid_transcribe when its output folder sits inside the
workspace. It is a default, not a merge: any speaker flag on the command (including the
watcher's own [watch] hints) replaces the whole [diarize] set for that run. A bad value
makes ingest, MCP transcribe, run and install refuse, naming the bad key.
Reinstall preserves saved environment settings, including custom tool paths. Current exported
certificate settings and explicit --env values can override saved values; --env takes final
precedence. On a managed network, the watcher can use the organization's installed macOS trust
roots or its CA bundle without turning off certificate verification:
whosaid watch install --into ~/meetings --no-fda --env UV_SYSTEM_CERTS=1
# If your organization supplies a PEM bundle instead:
whosaid watch install --into ~/meetings --no-fda \
--env SSL_CERT_FILE=/path/to/organization-ca.pem --env UV_SYSTEM_CERTS=1The installer also carries exported SSL_CERT_DIR, REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE,
and the legacy UV_NATIVE_TLS setting into launchd. Certificate failures retain the original
error and provide this setup guidance. To discard saved environment settings completely,
uninstall the watcher, then install it with the desired settings. Uninstall leaves shared
user-local tools and CA files intact.
Full Disk Access, scoped to one binary. The Voice Memos store is TCC-protected: only an
executable you have granted Full Disk Access can read it, and macOS grants that per executable
path. Granting it to your terminal or your everyday Python would privilege everything they run.
So install provisions one dedicated, ad-hoc-signed interpreter at
$HOME/.local/opt/whosaid-watch/bin/whosaid-watch (named so that is what the FDA list shows),
points the agent at it, and opens the right System Settings pane. That interpreter runs nothing
but the watcher; it copies each recording out of the store and hands the copy to whosaid, so
whosaid, uv, ffmpeg, and the models read an ordinary file and need no grant at all. Adding
that one path is the single manual step. Already granted a binary? --interpreter PATH reuses
it instead of provisioning a new one. Several workspaces can coexist: the label defaults to
com.whosaid.watch.<8 hex of the workspace path>, or pass --label.
Logs land in <ws>/.watch.log (launchd captures the watcher's stdout and stderr there).
uninstall unloads and removes the agent; --purge also removes the dedicated interpreter and
the workspace's .watch_state.json, .watch.lock, and .watch_staging/, never the log.
No admin rights?
The Full Disk Access toggle asks for an administrator's password — even though install scopes
the grant to one dedicated binary — and on a company-managed Mac the Privacy & Security pane may
be hidden entirely, so a standard user often cannot grant it at all. The escape hatch needs no
admin time: watch already reads any folder outside ~/Library with no FDA, and --no-fda
makes that the default (it watches ~/Recordings, creating it if needed, when no --source and
no [watch] source is set):
whosaid watch install --into ~/meetings --no-fda # watches ~/Recordings; no FDA, no adminThis mode reuses the launcher's Python and does not create a dedicated copied interpreter.
For Voice Memos FDA installs, the installer selects a Python that supports copied virtual
environments. If only Apple's Command Line Tools Python is available, install a compatible
user-local interpreter with uv python install 3.12, then rerun; --interpreter PATH still
selects an existing interpreter explicitly.
Three ways to get recordings into that plain folder:
iPhone Shortcut "Record Audio -> Save File" -> Dropbox (the one fully automatic option): in the Shortcuts app, chain the Record Audio and Save File actions with a Dropbox folder (say
whosaid) as the destination, then bind the shortcut to the Action Button (Settings > Action Button > Shortcut) or Back Tap (Settings > Accessibility > Touch > Back Tap). Recordings sync to~/Dropbox/whosaidon the Mac.QuickTime Player (built in, nothing to install): File > New Audio Recording, record, stop, and save into
~/Recordings.Drag and drop: drag memos out of the Voice Memos app into
~/Recordings, or from iOS use Share > Save to Files > the Dropbox folder. The watcher ingests them on the next pass like any other file.
Dropbox beats iCloud Drive here: ~/Dropbox is a plain folder outside ~/Library, while iCloud
Drive's on-disk root (~/Library/Mobile Documents) is TCC-adjacent and not verifiably readable
without FDA. The same applies to capture apps that sync via iCloud Drive (e.g. Just Press
Record): their files land under ~/Library, so they cannot feed the no-FDA watcher — an App
Store recorder is only an option if it writes into a folder you choose outside ~/Library.
The menu bar glyph works on a no-admin setup too, with one expected gap. Every glyph the
SwiftBar plugin (whosaid watch menubar) shows except 🔴 REC comes from watch status --json
and the workspace's .watch.log — no grant needed — so the idle/waiting/ingesting/done/
warn/not-loaded glyphs, the dropdown links and actions, and the transition notifications all
work untouched against a --no-fda watcher. 🔴 REC is the exception for two independent
reasons:
REC is a Voice-Memos-store signal by design. "Recording right now" means a store row with no synced file and 0 seconds; a plain folder has no in-progress signal, so a
--no-fdawatcher never shows REC even with every grant in place — absence of the dot is expected, not a failure.SwiftBar itself holds a separate Full Disk Access grant, admin-gated exactly like the watcher's. Without it the plugin's store probe fails silently — the REC dot never appears, and the dropdown says so with a red "SwiftBar cannot read the Voice Memos store" line, a link to the grant pane, and a relaunch action. Everything else in the menu keeps working.
See Menu bar glyph for the full story, including why a fresh grant needs a relaunch.
Menu bar glyph: whosaid watch menubar (SwiftBar)
Installed the native app (
whosaid app install)? Its menu bar item is the default and shows the same states plus autorecord, the live transcript, stop-and-save, pause and the last meetings, without SwiftBar or its Full Disk Access grant. The SwiftBar plugin below keeps working as the lightweight option; run one or the other, not both.
whosaid watch menubar install [--workspace <ws>] [--interval 10s] symlinks a stdlib-only
SwiftBar plugin (contrib/swiftbar/whosaid.10s.py) into SwiftBar's plugin directory, so the
watcher's state lives in your menu bar at a glance; menubar uninstall removes it and
menubar status reports whether SwiftBar is installed and running and whether the configured
recording source is readable. whosaid doctor prints the same report. A watcher using an
ordinary folder, such as ~/Recordings, does not require a separate Voice Memos access grant.
If SwiftBar has no plugin directory preference, the installer configures ~/.config/swiftbar
and verifies the preference before linking the plugin. An existing directory preference is
preserved. Refresh or relaunch SwiftBar after installation to load the plugin; a successful
preference write and symlink do not by themselves prove the app has loaded it.
glyph | meaning |
🎙 | idle, watching the store |
🔴 REC | a memo is being recorded right now |
⏳ 90s | new memo, waiting for the file to settle |
⚙️ 3m | transcribing / diarizing / action items (elapsed) |
✅ | ingested (held 30 min, then back to idle) |
⚠️ | needs a look |
🎙✗ | launch agent not loaded |
The dropdown carries the stage log tail, the agent line, links to the workspace, the latest
dated meeting folder, every _WORKLIST-<Owner>.md, and _WIKI.md, plus actions to follow the
log, kick the watcher now (launchctl kickstart -k), and refresh or relaunch SwiftBar.
Two platform notes the plugin cannot fix for you: when Voice Memos is the configured source,
reading its store requires
SwiftBar itself to hold Full Disk Access (the watcher's grant is separate — the plugin
shows the pane link and a relaunch action when the probe fails, because a fresh grant only
applies after a relaunch); and on a notched display a new status item can land behind the
notch — defaults write com.ameba.SwiftBar "NSStatusItem Preferred Position <plugin path>" -float <n> plus a relaunch fixes it.
Voice Memos helpers: whosaid memos
whosaid memos list # titles and sync state (reads a COPY of the database)
whosaid memos pull --latest -o ./inbox # copy the newest recording out of the store
whosaid memos pull --title "Standup" -o . # or one by exact title
whosaid memos delete "Standup" --yes # delete through the app's own action
whosaid memos shortcut-recipe # build (or print how to build) the Shortcut delete usesdelete never touches CloudRecordings.db or the .m4a files. It runs the Voice Memos
"Delete Recordings" App Intent through a one-action Shortcut named "Delete Voice Memo", which is
exactly what tapping Delete in the app does: iCloud stays in sync across your devices, the memo
lands in Recently Deleted (restorable for 30 days), and the app's database stays consistent.
Editing the store behind the app's back desyncs iCloud and can corrupt the library, which is why
there is no --force path that does. shortcut-recipe builds and signs that Shortcut for you
when signing is available; on a Mac with no iCloud account it prints the one-action recipe to
build by hand in the Shortcuts app (--no-sign prints the recipe only).
A/B a setting on your own recording: whosaid ab
whosaid ab standup.m4a --a "--speakers 2" --b "" --same-asr # one transcription, two diarizations
whosaid ab --compare run-old/ run-new/ # compare two existing output dirsRuns whosaid twice on a copy of your speaker registry (nothing is learned or ingested) and reports speaker counts, talk shares, word agreement, the biggest disagreements and name agreement, with no ground truth needed. Details: docs/ab-compare.md.
Dev-commitments
A dev-commitment is a first-person promise the self-roled speaker made to someone else
("I'll send the migration plan by Friday"). Each meeting's transcript yields a
commitments.md + commitments.json; roll-up folds those into one living corpus,
_COMMITMENTS.md / _commitments.json — so "what did I promise across this whole series?"
has a single answer.
Extraction is a stdlib heuristic, no LLM and no network: a clause starting with a first-person
cue (i'll, i will, i plan to, let me, i owe, …) records the clause; negations
(i won't, i can't) are kept but flagged negative, and question clauses are skipped. Roles
drive it: with roles set only the self speaker's cues count, and when the turn before a
commitment was a different speaker asking or directing ("can you…", "please…"), the item
records requested_by — priority high when that speaker's role is boss. A pluggable hook
(--hook CMD on the commitments subcommand, or WHOSAID_COMMITMENTS_HOOK) can replace the
heuristic; it receives the transcript on stdin plus WHOSAID_SPEAKERS and WHOSAID_ROLES.
What gets dropped. A cue alone is not a commitment: "I'll do that", "I'll check", "I'll
bring that up" carry nothing to act on (the thing promised lives in the previous turn), so
they would only clutter the worklist as rows with no object that never dedupe against the real
item. The extractor therefore requires at least [commitments] min_words content words per
clause (default 2), where content words are what is left after removing a stoplist of pronouns
(including the cue itself), determiners, particles, prepositions, auxiliaries and fillers: "I'll
bump the version" has two (bump, version); "I'll see what I can do" has one; "I'll do that"
has none. One content word is enough when the item has a requested_by or names a deadline or
urgency (today, tomorrow, this week, by Friday, urgent, blocking), since the request
supplies the object: after "Can you own the rollout?", "I'll own it" is kept. Clause hygiene
runs first: a stuttered cue collapses ("I'll I'll start investigating that while I" becomes
"I'll start investigating that"), a clause ends at a subordinator (while, because, if,
when, unless, until, which, ...: "I can create an Epic if needed" becomes "I can create
an Epic"), and a dangling trailing pronoun or preposition is trimmed. Set min_words = 0 to
keep every clause, or pass --min-words N on whosaid ingest for one run.
Seeing the near misses. Dropped clauses are reported the way low-similarity merges are:
each meeting's commitments.md ends with a Dropped fragments (review) section (- (Alice_Example) I'll check @ 00:00:22 (fragment: 1 content word, min 2)) mirrored as dropped in
commitments.json, and the extractor logs dropped N fragment(s) (min_words=2). Roll-up
applies the same rule when folding, so older commitments.json files never seed fragments into
the corpus; its skips land under Dropped fragments (review) in _COMMITMENTS.md and persist
in _commitments.json. If a real commitment shows up there, lower min_words in
whosaid.toml and re-run (roll-up --rebuild re-folds every meeting).
The corpus works exactly like the action-item corpus: stable CM-NNN ids that never renumber,
the same 0.82 text-similarity dedupe with a 0.10 near-miss review band, and hand edits in
_COMMITMENTS.md folded back on the next roll-up. An item's rendered shape:
- **CM-002** [open] (Stephen) 2026-09-14-1802 → 2026-09-21-1802 (2×): **[boss]** update the exec deckThe ranked worklist: _WORKLIST-<Owner>.md and whosaid commitments
The corpus answers "what did I promise"; the worklist answers "what should I do first". Every
roll-up (and whosaid commitments <ws> on demand) takes the union of the owner's commitments
(CM-NNN, speaker = owner) and the action items assigned to them (AI-NNN, owner matched
case-insensitively with _/space interchangeable, plus [workspace] aliases), folds the two
sources together when a source pointer or explicit ID reference establishes equivalence
(the item appears once, as (also AI-012)),
and ranks what is open. Deterministic, no LLM:
Tier | Rule |
P1 | boss-requested ( |
P2 | seen in 2 meetings, requested by anyone, or a strong cue ( |
P3 | the rest (weak cues such as |
A negated commitment (i won't) is never P1 and carries a penalty. Within a tier the order is
score (sum of the signal weights), then most recent, then id, and every line says why:
- **CM-002** [open] 2026-09-14-1802 → 2026-09-21-1802 (2×) P1 · boss · due=tomorrow · 2 meetings: update the exec deck (also AI-012)Three rules keep the cues honest (GitHub issue #21):
Negation. A cue with a negator in the three words before it, inside the same clause, does not fire: "send non-urgent questions to the channel", "not blocking anyone", "no rush", "isn't really critical", "won't block us" earn nothing, while "not done yet, this is urgent" still does (the comma starts a new clause). The negator list is
[commitments] negators(defaultnot,no,non,never,isn't,aren't,wasn't,won't,don't,doesn't,didn't,without,nothing,hardly, plus the apostrophe-free spellings a transcript may produce).Bare nouns are not emergencies.
prod,production,release,ship,customer(s)are not blocking cues by default: "post it in the release chat" and "shipping version 2.5" are plain work. The defaults carry phrases instead (prod issue,production is down,release blocker,blocking the release,before we ship,customer escalation,customer is waiting, …). A workspace where the bare noun really does mean pressure adds it back through[commitments] blocking_cues(the list you set replaces the default, so include what you keep).Relative deadlines expire.
today,tonight,eod,tomorrow,this week,eow,next week,by Friday,before the 14th,on sept 3,by 9/20and ISO dates resolve against the date of the meeting the item was last seen in (this weekis that week's Friday, or the next Friday from a weekend;next weekthe Friday after that;the 14throlls to the next month once it has passed). While the resolved date is still ahead the line saysdue=<cue>and the deadline counts toward P1; once it is behind today the line saysoverdue=YYYY-MM-DD, the smalloverdueweight (default 1) replacesdeadline, and the item is no longer P1 on the deadline alone, so a "today" from two weeks ago sits below this morning's real work. Cues with no calendar meaning (this sprint,before the demo,before the release) never expire.WHOSAID_TODAY=YYYY-MM-DDpins "today" for tests and replays.
Resolved and merged items sit under ## Done / history. The file is a regenerated view (hand
edits are overwritten on the next run; ids never renumber; the JSON corpora stay the source of
truth), so close or retitle items in _COMMITMENTS.md / _ACTION-ITEMS.md. The owner is
--owner NAME, or --owner me (the default): the speaker with role self in the newest
meeting's commitments.json or # Role: header, else [workspace] owner / WHOSAID_OWNER.
--all-owners writes one file per participant. whosaid commitments <ws> --json emits
{owner, generated_from, items: [{id, source, text, status, tier, score, why, first_seen, last_seen, occurrences, requested_by, negative, …}]} for scripts and the MCP tool. Cue lists,
boss names, weights, and the embedding threshold are overridable in whosaid.toml
[commitments] (see the example under Search your meetings).
Evidence, milestones, and leadership priorities
The optional workspace _tracking.json ledger records reviewed evidence separately
from generated corpora. Reindexing preserves it. Item, person, and worklist views
use the same status history, milestone state, and accepted ownership. A merged
change can complete a coding milestone while deployment remains unfinished.
Missing evidence never means not_started.
whosaid tracking import ./meetings ./reviewed-events.json
whosaid commitments ./meetings --status delivered --json
whosaid commitments ./meetings --accepted-only --as-of 2020-01-31 --json
whosaid tracking priorities ./meetings --group leadership --as-of 2020-01-31Obligation kinds distinguish explicit commitments, requests, standing directives, suggestions, and inferred next steps. Acceptance needs attributed evidence; assignment alone is insufficient. Standing directives and inferred tasks remain visible separately and do not accrue overdue scores. Exact source pointers can reconcile a summary item with its extracted commitment without inventing a second promise. Ambiguous pointers remain separate.
Priority assertions retain the speaker, scope, evidence, and date. An explicit rank is distinguished from an inferred rank; different leaders' conflicting assertions stay visible. Requested handoffs remain pending until accepted. Historical views use event occurrence time and the current configured group membership, not a reconstruction of the historical organization chart.
Imported messages retain all source citations while confirmed overlapping copies count once in search results and recurrence scoring. Ambiguous conversation matches need explicit local aliases. See message identity and the tracking event format and examples.
MCP clients can call whosaid_priorities and whosaid_tracking_suggestions.
Suggestions scan the local index within a deadline and require review before
import; they neither verify external artifacts nor change status automatically.
The existing item/worklist tools accept kind, accepted_only, and as_of.
Every omitted page limit caps the response at 50, including offset-only calls.
Compact rows retain status, kind, acceptance, and evidence availability. Snapshot
tokens and cache freshness cover corpus, ledger, configuration, transcript, and
diarization sidecar content changes, including added and removed sources.
Older caches without source fingerprints remain readable but are marked stale.
Historical queries cannot substitute a current cached answer. Live worklists use
source identity and do not probe or call an embedding model.
Use it from an AI agent (MCP)
whosaid's local, private, GPU transcription and speaker diarization are also exposed as MCP tools,
so any MCP client — Claude Code, Claude Desktop, and others — can call them directly instead of
shelling out to the CLI. Launch is whosaid mcp, a stdio server that needs only uv, which whosaid
already requires; audio never leaves the machine, exactly as with the CLI.
Add it to your MCP client config:
{
"mcpServers": {
"whosaid": { "command": "/ABSOLUTE/PATH/TO/whosaid", "args": ["mcp"] }
}
}command is the path to the whosaid script itself — the checkout's ./whosaid, or the installed
~/.local/bin/whosaid symlink.
Tool | What it does |
| Transcribes + diarizes an audio file and writes the labeled transcript, speaker cards, and a sidecar for relabeling. |
| Names |
| Read-only: lists enrolled voices and registry names (with role tags) already known. |
| Read-only readiness check — models cached, deps present, mic/audio devices — run this first when a transcribe fails. |
| Enrolls a named voice from an existing audio clip, no mic needed. |
| Exports one short representative WAV per speaker cluster (longest segment, clamped to |
| Re-diarizes older meetings in a workspace (wraps |
| Records a browser meeting tab over the DevTools port. |
| Write a chat draft into the drafts outbox with its claims, stakeholders and pages, then check the gates; |
| List, show or diff drafts and their revisions; rank who spoke about a topic; find drafts whose stakeholders said something new since verify. Read-only. |
| The newest Teams chat messages (or chats, |
| Your Teams meetings in a window (default today plus 7 days) read from the same local cache: subject, times (UTC and local), organizer, attendees, response, join URL, and with a workspace the recordings made during each. Says which weeks the cache covers. Read-only. See |
enroll and mic/app record stay CLI-only. They need an interactive terminal and
Microphone permission. The first whosaid_transcribe call downloads ~1.5 GB of models; call
whosaid_doctor first to check readiness.
Workspace search tools (read-only)
The workspace search layer is exposed too, so an agent can answer "what
did we decide about the budget" from the transcripts instead of reading them whole. Every tool
takes an optional workspace argument and otherwise uses WHOSAID_WORKSPACE from the server's
environment, then the workspace of the only installed whosaid watch agent (with several agents the
error lists them as candidates); successful responses carry workspace_source (argument,
WHOSAID_WORKSPACE, or watch agent <label>). There is no current-directory fallback. None of them rebuilds the index: whosaid index (or the watcher) owns writes, and the tools read the index, corpora, and optional tracking ledger.
Tool | What it does |
| Turn-level hits (meeting folder, timestamp, speaker, snippet) for a query in |
| The verbatim turns around one hit, so an agent reads a minute instead of a transcript. |
| Filter by owner, requester, status, type, kind, accepted ownership, or historical date. Numeric ID order, 50 per page by default, with counts, continuation offset, and a snapshot token. Compact rows preserve status and evidence availability. |
| One item with its occurrences and timestamped commitments. |
| A person's talk share, the commitments they made, and the asks they received. |
| Every meeting folder with coverage figures. |
| Pull-request numbers mentioned in speech or in the notes. |
| Talk share per speaker across the workspace. |
| Whether the index exists, how many turns it holds, and whether Ollama is reachable. |
| Ranked and reconciled commitments, filterable by tier, kind, status, acceptance and historical date. Pages default to 50. Timeouts include elapsed time and phase; current caches report generation time and freshness. |
| Current or historical priority assertions, optionally filtered to a configured leadership group, retaining evidence and conflicts. |
| Bounded local search for later completion evidence. Returns review-required candidates and limitations; never changes status. |
Resources, all reading WHOSAID_WORKSPACE (or the single watch agent's workspace): whosaid://workspace/wiki (_WIKI.md),
whosaid://workspace/action-items (_ACTION-ITEMS.md), whosaid://workspace/index
(_INDEX.md), and per meeting whosaid://workspace/meeting/{folder}/transcript and
whosaid://workspace/meeting/{folder}/action-items.
Register it with the workspace set. Claude Code:
claude mcp add --scope user whosaid -e WHOSAID_WORKSPACE=$HOME/meetings -- whosaid mcpKiro (~/.kiro/settings/mcp.json) and any client that takes the same JSON shape:
{
"mcpServers": {
"whosaid": {
"command": "whosaid",
"args": ["mcp"],
"env": { "WHOSAID_WORKSPACE": "/Users/<you>/meetings" }
}
}
}Write the workspace as an absolute path (most clients do not expand $HOME inside env), and use
the absolute path to the whosaid script for command if it is not on the client's PATH.
Key flags (on whosaid <audio>…)
Flag | Meaning |
| Output directory (default: alongside the input file). |
| Whisper model to use. |
| Use the full |
| Use |
| Force the transcription language. |
| Output format: |
| Override the output base name (single input only; default: derived from the input filename). |
| Exact number of speakers. Overrides auto-detect (and any |
| Lower bound on the auto-detected speaker count. |
| Upper bound on the auto-detected speaker count; also lowers the hard cap of 20. Use a range when you know roughly who was in the room but not exactly — an estimate that lands on the bound is reported as unreliable rather than shipped as a result. |
| Roster of people you expect in this recording (comma-separated, repeatable). Each name must already be a known voice — an enrolled clip or a registry entry. Clustering is anchored to their voiceprints: a turn within |
| Per-turn cosine required before |
| Parallel diarization workers for long audio (default: auto, ~cores−2, capped at 8). |
| Window length for parallel diarization (default: auto — about |
| Diarize the whole file with sherpa's own clustering (no parallel chunking, no count estimator). |
| Cosine similarity a known voice must reach before it may claim a cluster (alias |
| Cosine similarity at which a still-unnamed cluster is folded into a known voice, merging phantom splits of one person. Default |
| Lower absorb bar for a voice already placed in this meeting (it owns a cluster): a still-unnamed cluster folds into it at cosine |
| Skip diarization; write the plain transcript only. |
Environment variables
Variable | Purpose |
| Python 3.11+ interpreter for the helper scripts (default: auto-detected, see Quickstart). |
| Default Whisper model, overridden by |
| Default transcription language, overridden by |
| Override the directory of enrollment voice clips (default: |
| Local speaker registry of named voiceprints (default: |
| Default registry/reference match threshold, overridden by |
| Default absorb-pass threshold, overridden by |
| Default absorb threshold for a voice already placed in the meeting, overridden by |
| Default per-turn anchoring threshold for |
| Speaker-embedding model. Default is NeMo |
| avfoundation input device used by |
| Command install directory used by |
| Default action-items hook for |
| Default meeting workspace for |
| Whose action items a workspace tracks; overrides |
|
|
| Ollama base URL for embeddings and the built-in summarizer (default: |
| Ollama model for |
| The |
| The Teams IndexedDB directory |
| The |
| Default hook for dev-commitments extraction ( |
| Hugging Face cache location (where the Whisper model lands). |
| Diarization model cache location (default: |
Speaker identity: enrollment clips vs. the registry
whosaid has two independent ways to put a name on a SPEAKER_NN cluster, and knowing which one
is driving a given label matters — this was GitHub issue #1's other point of confusion.
1. Enrollment clips (voices/, WHOSAID_VOICE_REFS). Every *.wav/*.m4a file in the
directory is embedded fresh on every transcribe and passed to the diarizer as --ref Name=path (the --ref "$refname=$ref" loop in the whosaid script, run before each diarize
call). Written by whosaid enroll [Name] (mic, or --from FILE to cut a clip from an existing
recording). Because a clip is re-embedded every run, it survives a DIARIZE_EMB_NAME change
with no extra step: the embedding is always current for whichever model is active.
2. The registry (~/.config/whosaid/speakers.json, WHOSAID_SPEAKER_DB). A JSON file of
{name, model, embedding, added} records, written once by whosaid relabel SPEAKER_XX=Name
(or --save-speaker on a transcribe) and reused forever with no re-embedding. Each entry is
keyed by the embedding model it was saved under, so switching DIARIZE_EMB_NAME silently
orphans every registry entry for the old model — they don't error, they just stop matching —
until you relabel again under the new model.
Role tags. A registry entry may carry an optional "role" string — the conventional set is
self, boss, peer, report, external, and free-form lowercase tags are allowed. Set one
with whosaid relabel <base> --role Karen=boss (repeatable) or the roles map on the MCP
whosaid_relabel tool; it is stored on the registry entry, preserved when the voiceprint is
re-saved, and rendered as # Role: Karen = boss header lines in <base>.speakers.txt, a
Karen [boss] label on the speaker card, and a top-level "roles" key in the sidecar.
Roles carry semantics downstream: self marks your own voice, and a boss-roled speaker's
requests rank higher in action items and the dev-commitments corpus.
Apply registry changes to historical meetings. Relabeling a meeting updates that meeting; use backfill to bring an existing workspace into line with the current registry:
whosaid backfill ~/meetings --dry-run # inspect per-meeting changes and conflicts
whosaid backfill ~/meetings # apply, then refresh all derived viewsBackfill works from cached diarization sidecars without transcribing audio again. It preserves
transcript prose and transcript-only labels, updates speaker role headers and cards, and
regenerates heuristic commitments. Conflicting edits to a meeting's commitments are reported
before meeting files are written; reconcile those edits before retrying. Corpus curation,
including edited commitment text and statuses, survives source refresh. Roll-up fingerprints
each meeting's commitment inputs and roles and refolds only the meetings whose fingerprint
changed (or that are new or gone), keeping ids and curation; a [commitments] change in
whosaid.toml refolds every meeting. Run backfill after changing registry roles to update the
per-meeting inputs as well.
Back up or move the registry. Both commands respect WHOSAID_SPEAKER_DB and work without
loading a model:
whosaid speakers export --out ./speakers-backup.json
whosaid speakers import ./speakers-backup.json # destination must not exist
whosaid speakers import ./speakers-backup.json --merge # add non-conflicting entriesExports preserve names, model identifiers, embeddings, roles, notes, and other registry metadata
inside a versioned format. Import validates the complete file before writing and refuses merge
conflicts. To deliberately replace every destination entry, use --overwrite; this also removes
voices absent from the backup. Export refuses an existing output path. Registry writes are
atomic and backup/import files have owner-only permissions. These files contain private
voiceprints: keep them out of source control and use a private, encrypted transfer when moving
them between machines. Enrollment audio in voices/ is separate from the registry backup.
Which one actually names a cluster. name_clusters() (lib/diarize_sherpa.py) runs three
passes in order, and a later pass only touches a cluster the earlier ones left unnamed: (1)
registry one-best, (2) --ref enrollment clips, (3) absorb (folds a still-unnamed cluster into
a known voice — registry or clip — above --absorb-threshold). Passes 1 and 2 both gate on
--match-threshold (default 0.50, env WHOSAID_MATCH_THRESHOLD): below it a cluster keeps
its anonymous label rather than take a low-confidence name. Two gates (#77) keep absent voices
out: a registry/ref voice cannot claim a cluster that scores at least as high against a voice
already placed in the meeting (the report records matched: false with blocked_by), and the
absorb pass may use the lower --placed-absorb-threshold (0.70) only for an already-placed
voice and only when at least 60% of the cluster's talk time (seconds-weighted) is in turns
closest to that voice (the decision field talk_share; the older per-turn purity is still
recorded for comparison) and the cluster sits at least 0.035 closer to that voice's own
cluster in this recording than to its voiceprint (sibling_sim), which keeps an unenrolled
speaker who merely sounds like an enrolled one anonymous; every other voice still needs
--absorb-threshold (0.85).
Inspecting each. ls voices/ lists enrollment clips; whosaid doctor prints the registry's
voiceprint count and path; the MCP whosaid_list_speakers tool lists both at once.
<base>.diarization.json's registry_matches records which pass named (or nearly named) every
cluster, so you can tell exactly which mechanism fired — and whosaid samples <base> lets you
listen to a clip per cluster to confirm a label before trusting either one.
Transcript-only labels (relabel --no-save). Naming a cluster doesn't have to mean rewriting the
registry. whosaid relabel <base> SPEAKER_04=Alice_Example --no-save [--note TEXT] renames the
cluster in the transcript and cards only and never writes to the registry — what you want when the
conversation makes the speaker obvious but the cluster doesn't represent one clean voice. That is
GitHub issue #19's case: in a 7-person meeting, SPEAKER_04 was identifiable as Alice from what she
said, but her 176-turn cluster was a mixture of voices, and saving it would have replaced her
enrolled print with a degraded one. The label is stored in the sidecar under local_labels (with the
optional --note alongside it), so it survives relabel --auto and whosaid samples, and each
speaker card reads Alice_Example (transcript-only label; registry untouched) so the label's
provenance is never in doubt. Related guard, on the other side of the same decision: the
registry-writing paths (relabel with an assignment, transcribe --save-speaker) now refuse to
replace an existing print whose similarity to the new cluster is below the match threshold (default
0.50) — pass --force to overwrite deliberately, or --no-save to label this transcript only.
Strengthening a print (relabel --blend). A plain relabel replaces the saved print with this
one cluster, so a print built from a 1:1 call can match the same person worse in a group meeting
recorded under different conditions. whosaid relabel <base> SPEAKER_00=Alice_Example --blend
averages the cluster into the existing print instead (mean of the two unit vectors, renormalized)
and records the source in the entry's added field as <previous>+blend:<base>. The similarity
guard above still applies. With no existing print, the cluster is saved as-is.
How it works
audio file
|
v
MLX Whisper (Metal GPU) -------------> <base>.txt / .srt / .vtt / .tsv / .json
|
v
sherpa-onnx diarization (CPU) --------> <base>.rttm
|
v
cosine-match vs voices/*.wav ---------> <base>.speakers.txtTranscription and diarization run as two independent local stages that get merged at the end.
Transcription uses MLX Whisper (the full large-v3, mlx-community/whisper-large-v3-mlx, by default, or
large-v3-turbo with --fast) on the Mac's GPU via Metal, through a hallucination-hardened decode
path: the temperature-fallback ladder stays enabled, condition_on_previous_text is turned off, and
a hallucination-silence threshold keeps dead air from turning into repeated-token filler. Diarization
runs on the CPU via sherpa-onnx offline diarization: pyannote's segmentation-3.0 ONNX model finds
who's speaking when, a speaker-embedding model (NeMo TitaNet-small by default, configurable via
DIARIZE_EMB_NAME) embeds each turn, and clustering (optionally hinted by
--speakers N) groups turns into speakers. Both diarization models are small (~30 MB total), ungated
GitHub releases — no Hugging Face token required — cached locally in ~/.cache/sherpa-diarization/.
Rolling back to turbo. The default moved from large-v3-turbo to large-v3 (steadier WER, about 1.85x
the transcription time on long meetings). To restore turbo, pass --fast for one run, or set
WHOSAID_MODEL=mlx-community/whisper-large-v3-turbo to make it persistent (WHOSAID_MODEL has always
been the default-model override; an explicit -m, --fast or --accurate wins over it).
Finally, every clip in voices/ — plus every voiceprint in your local registry — is embedded the
same way and matched to a cluster by cosine similarity (a match at or above --match-threshold,
default 0.50, names the cluster);
unmatched clusters keep a SPEAKER_00-style label. Aside from the one-time model downloads,
everything runs in ephemeral uv environments, so there's no persistent Python install left behind
on your machine.
Tell it who to expect. When you already know the roster, --expected-speakers Alice,Bob,Carol
turns diarization from a guess into a lookup. Every listed name must already be a known voice (an
enrolled clip in voices/, or a registry entry), and clustering is then anchored: each turn's
voiceprint is compared to each expected person's, a turn at cosine ≥ --anchor-threshold (default
0.70) is pinned directly to that person, and only the residual turns — guests, strangers, and
the occasional turn that scored low — go through the ordinary count estimate and clustering. Anyone
on the list who never speaks is silently dropped, so one roster works for a standing meeting whose
attendance changes week to week. That is the difference from --speakers N: an exact count merges
two distinct people the moment fewer than N show up, and auto-detect over-segments and then leaves a
major speaker's averaged cluster unmatched. The threshold sits deliberately high — TitaNet-small
scores same-speaker turns at ~0.90 (p10 ~0.72) against ~0.25 for strangers (p90 ~0.45), and a false
anchor puts a real name on the wrong words, while a missed one merely falls through to clustering
where the absorb pass can still recover it. Each anchor's result (turns, mean_cosine) lands in
the sidecar under anchors, and each anchored cluster in registry_matches with pass: "anchor".
Because anchoring needs per-turn voiceprints, this flag forces the chunked diarization path at any
recording length.
Long recordings run in parallel. For audio over ~15 minutes, diarization splits into
non-overlapping time windows that are segmented and embedded concurrently across CPU workers, then a
single global clustering pass over every turn's voiceprint recovers speakers that stay consistent
across window boundaries. Because the clustering sees all turns at once, it recovers the same
speakers as a single-pass run while finishing several times faster. Shorter recordings use the same
per-turn clustering over one window, so the speaker-count estimator and --min-speakers/--max-speakers
work at any length. Pass --no-chunk to use sherpa's whole-file clustering instead, or
--jobs/--chunk-seconds to tune it.
Output files
File | Contents |
| Plain transcript. |
| SubRip subtitles. |
| WebVTT subtitles. |
| Tab-separated segments with timestamps. |
| Full Whisper segment output. |
| Raw diarization turns, standard RTTM format. |
| Speaker-labeled transcript: Whisper text merged with diarization turns and enrollment names; carries |
| One card per speaker with turn count, talk time, and representative snippets — read it to identify who each |
| Cached segments + per-cluster voiceprints, so |
| Created on demand by |
A meeting workspace (whosaid ingest --into <ws>) adds these at the workspace root. Underscored
files are generated and safe to delete; the dotfiles are the watcher's working state.
File | Contents |
| One meeting: the source audio, |
| The manifest: one entry per ingested meeting with its source sha256 (what makes ingest idempotent). |
| Coverage index and nothing-missing audit, from |
| The deduplicated action-item corpus and its state (stable |
| SQLite: FTS5 over every turn, stored embeddings, and the entity graph tables. Rebuilt by |
| The generated wiki, regenerated by |
| Optional per-workspace settings (owner, groups, summarizer, search, watch). The one file here you write by hand. |
|
|
The sidecar's machine-readable extras, so a consumer never has to scrape stderr or shell out to
ffprobe:
Sidecar key | Contents |
| One record per naming decision, including near-misses: |
| Recording provenance: |
| Optional — present only when at least one speaker carries a registry role: |
|
|
| Physical speaker-cluster counts before and after the anonymous-phantom check, plus a note when it ran. Equal counts and |
In a meeting workspace, ingest --commitments additionally writes per-meeting
commitments.md / commitments.json, which roll-up folds into the workspace-level
_COMMITMENTS.md / _commitments.json corpus — see Dev-commitments.
Match confidence and the threshold. Auto-naming only asserts a name when the cluster's cosine
similarity to a known voiceprint reaches --match-threshold (default 0.50); below it the cluster
keeps its SPEAKER_NN label — see the >= ref_threshold guards in name_clusters()
(lib/diarize_sherpa.py). The default was raised from 0.40 to 0.50 because on real meeting
audio TitaNet-small produced wrong assertions in the 0.40–0.53 band, while genuine same-speaker
matches score far higher — in the end-to-end test the enrolled reference matches its cluster at
0.986, against 0.194 for the nearest stranger, so 0.50 sits in a wide empty gap. Use
registry_matches to see exactly how close every near-miss came, then lower the threshold
deliberately if a real speaker is being missed.
Troubleshooting
whosaid: command not foundafter it used to work.whosaid installmakes~/.local/bin/whosaida symlink to the checkout — it doesn't copy the implementation. If that checkout lived in a temp directory (/tmp,/private/tmp,$TMPDIR, e.g. a clone under/private/tmp/whosaid-latest/), the OS eventually purges it and the symlink starts pointing at nothing, so the shell reports a plain "command not found" with no hint why.whosaid install(and./bootstrap.sh) now refuse to install from a temp-directory checkout in the first place unless you pass--force. To fix an already-dangling install: clone/move the checkout to a stable location (e.g.~/code/whosaid) and runwhosaid installagain from there. Because the installed symlink itself is what's broken,whosaid doctorcan't be run by name to confirm this — run./whosaid doctorfrom the fresh checkout instead; it reports aDANGLINGinstall line when it finds this.Enroll/record produces silence. This is almost always a macOS microphone permission problem: go to System Settings → Privacy & Security → Microphone and grant access to your terminal app. macOS feeds an unauthorized app silent zeros instead of an error, so whosaid detects this by checking the captured volume rather than trusting a clean exit code.
Doesn't run on my Intel Mac. MLX is Apple-Silicon-only, so whosaid requires an
arm64Mac.First run is slow. The first
./bootstrap.sh(or first transcribe, if you skip it) downloads the Whisper model (~1.5 GB) and the diarization models (~30 MB). Every run after that uses the local cache.Long recordings degrade into repeated text. This is Whisper's well-known hallucination/repetition-collapse failure mode, most likely on long or low-signal audio. It's why whosaid calls the
mlx-whisperlibrary directly (lib/transcribe_mlx.py) instead of the bare CLI: the bare CLI's single-temperature default is exactly the configuration that lets this happen, whereas the library call keeps the temperature-fallback ladder, disables conditioning on previous text, and applies a hallucination-silence threshold.Speakers show up as
SPEAKER_00/SPEAKER_01instead of a name. No enrolled voice or registry entry matched closely enough. Read<base>.speaker-cards.txtto tell who each cluster is, then runwhosaid relabel <base> SPEAKER_01=Name— this labels them and remembers them for next time. (Enrollment viawhosaid enroll <Name>still works too.) Naming uses a cosine-similarity threshold (--match-threshold, default 0.50), so a short or noisy sample can fall just short of it. Checkregistry_matchesin<base>.diarization.jsonfor the exact similarity of every near-miss, then lower the threshold deliberately if a real speaker is being missed.The same person shows up as two speakers (phantom split), or a known voice stays
UNIDENTIFIED. On long recordings the diarizer can split one voice across several clusters. A registry/enrolled voice names its single closest cluster, so the extra clusters used to stay unnamed. whosaid now runs an absorb pass: any still-unnamed cluster whose voiceprint is within--absorb-threshold(default 0.85) of a known voice is folded into that person, and the speaker cards merge those clusters into one card. To apply this to a transcript you already have, runwhosaid relabel <base> --auto— it re-names from the registry + absorb pass with no re-transcription.relabel --autoignored a clip I enrolled, or keeps a name whose voiceprint I deleted.--autonow matches thevoices/enrollment clips (whatwhosaid enroll NAME --from FILEsaves) the same waytranscribedoes, not just the registry. It also drops a name that an automatic pass (registry or absorb) gave a cluster once that name has neither a registry entry nor avoices/clip, and the freed cluster is re-named to a better match if one exists. Names you set yourself (SPEAKER_NN=Name,--no-savelabels) are never auto-dropped; neither is a name from a sidecar that predatesregistry_matches, since its source is unknown, nor a name a--refclip matched (that clip may live outsidevoices/; use--forget).whosaid relabel <base> --auto --forget NAME(repeatable) clears NAME from this meeting's clusters first, even a transcript-only label, then re-runs naming. The registry is untouched, so a NAME that is still enrolled and still matches can legitimately come back; delete the voiceprint as well if it should stay gone.Distinct people get merged into one speaker (or the count is too low). The speaker-embedding model must match the spoken language. whosaid defaults to an English-native model (NeMo TitaNet-small); on English audio the Mandarin-trained model cannot tell similar voices apart and collapses them. For predominantly Mandarin audio, set
DIARIZE_EMB_NAMEto the…zh-cn…model from the same release.The speaker count looks one too high, with a cluster that has ~1 second of speech. Forcing
--speakers Ntoo high can carve a phantom cluster out of crosstalk. Omit--speakersto auto-detect, which is usually more accurate.Auto-detect reports far too many speakers on a long recording (it used to always say 20). Fixed in the current version. The speaker count is now estimated by average-linkage agglomerative clustering of the per-turn voiceprints, cut at cosine
0.58(envWHOSAID_COUNT_THRESHOLD; calibrated against real TitaNet-small embeddings, where same-speaker turns measure ~0.90 and different speakers ~0.25). The previous estimator compared each turn to a single earlier turn rather than to a cluster, so on any recording with real channel variation it kept opening new clusters until it pinned at the hard cap of 20. The cap is now a bound, not a target: if the estimate lands on it, the count is reported as untrustworthy — aWARNING:line is written into<base>.speaker-cards.txtright under the count. Suspicious estimates first try stable clustering cuts using turns of at least one second, then assign the shorter turns. A short turn whose voiceprint is too far from those substantive voices prevents that fallback.count_estimatepreserves the primary count and recovery evidence;count_warningexplains the action taken or remaining uncertainty. On a problematic fresh auto run, whosaid also checks anonymous phantom clusters for repair after clustering and recordscount_before_fold,count_after_fold, andfold_notein the sidecar and MCP result. For an existing cached auto sidecar, runwhosaid relabel <base> --auto --fold-unknown; it only merges confident anonymous clusters and does not give them names. Cached repairs preserve their original similarity evidence infold_evidence, so repeating the command cannot create a chain of merges through averaged voices. Named speakers and transcript-only labels are protected. If you know roughly how many people were present, pass--min-speakers/--max-speakers; if you know exactly, pass--speakers N. When the count still saturates and the fallback abstains, the warning ends withTry --max-speakers N, and the sidecar and MCP result carry the same N assuggested_max_speakers. N counts the clusters that have at least 3 substantive turns at the normal cut. It is only a suggestion: the count itself does not change. To measure the estimator on your own recordings, see docs/count-eval.md. The estimator runs at every recording length. Only--no-chunkskips it and uses sherpa's own clustering, which takes an exact count only, so a--min-speakers/--max-speakersrange there is reported as unenforced unless the two are equal.whosaid search --mode meaningsays Ollama is unreachable, orindexskipped embeddings. Meaning and hybrid search need Ollama withnomic-embed-texton127.0.0.1:11434(brew install ollama && brew services start ollama && ollama pull nomic-embed-text, or pointWHOSAID_OLLAMAat where it listens). Nothing else breaks:indexstill builds the full-text tables andsearchfalls back to exact mode, andwhosaid doctorprints an Ollama line so you can see which state you are in. Once Ollama is up, runwhosaid index <ws>again to embed the turns that were skipped.no search index at <ws>/_search.db; run: whosaid index <ws>. Search, context, the graph views, and the MCP workspace tools only read the index; none of them builds it. Runwhosaid index <ws>once (and again after adding meetings, or useingest --index/roll-up --index/ the watcher so it stays current). The same message fromwhosaid doctormeansWHOSAID_WORKSPACEpoints at a workspace that has not been indexed yet.whosaid watchnever sees new memos, orwatch runexits 3 with "cannot read the source". The dedicated interpreter does not have Full Disk Access yet. Open System Settings, Privacy & Security, Full Disk Access, and add the exact pathwhosaid watch installprinted (default$HOME/.local/opt/whosaid-watch/bin/whosaid-watch), thenlaunchctl kickstart -k gui/$(id -u)/<label>or just wait for the next interval.whosaid watch status --into <ws>shows the label and whether the agent is loaded;<ws>/.watch.loghas the pass-by-pass detail. Granting your terminal FDA instead would work but privileges everything you run from it, which is exactly what the dedicated binary avoids.
Testing
./test/e2e.sh is a fully offline smoke test: it synthesizes a two-speaker dialog with two macOS
say voices, builds a one-clip voice enrollment for one of them, and runs the real transcribe +
diarize + name pipeline against it end to end — then asserts the speaker-labeled transcript names
the enrolled speaker and labels the other speaker distinctly. Run ./bootstrap.sh once first so the
models are cached locally; the test itself makes no network calls.
./test/version_test.sh is a fast, offline unit test that checks whosaid version, --version,
and -V all print a matching whosaid X.Y.Z line and exit 0.
./test/install_guard_test.sh covers the temp-directory install guard: refusal without
--force, success with it, install --check-only, and whosaid doctor detecting a dangling
install symlink.
./test/enroll_from_file_test.sh covers whosaid enroll --from in isolation (time-window parsing,
the 15s/silence floor, and the --force overwrite guard) with synthesized say audio — no model
download required.
./test/stereo_channels_test.py covers the mic-vs-app attribution for record --app stereo
files: per-turn sides, splitting mic-only runs out of a merged cluster, owner naming, and tag
detection on a real tagged file.
./test/record_app_test.sh covers whosaid record --app help text and flag errors, and typechecks
the Swift capture helper when swiftc is installed. Capture itself needs a display and a Screen
Recording grant, so it is not exercised.
./test/samples_test.sh covers whosaid samples (longest-segment picking, the --seconds clamp,
--per-speaker, naming, output format, and the source/--audio fallback) against a hand-written
sidecar and synthesized say audio — no model download required.
The workspace-search layer has its own offline tests, all against synthetic workspaces with
placeholder speakers and --no-embed so Ollama is never contacted:
./test/cli_test.shchecks the launcher: every new command inwhosaid help, usage hints and--helppaths, then an end-to-endroll-up,index,search,context,graph,wiki,roll-up --index,doctor, and a stubingest --indexover a two-meeting workspace../test/search_test.shcoverslib/search.py: the FTS5 build, exact queries with filters,context,speakers,status, and the no-Ollama fallback.python3 test/graph_test.pycoverslib/graph.py: the entity tables, each view, and the wiki.uv run --quiet --with websockets python test/stager_dom_test.pyruns the built-in stager's--edit-sentagainst a hand-written Teams chat fixture (test/fixtures/stager-edit-dom/) in jsdom under bun, behind a small CDP server: the stager's own JavaScript finds the message, opens Edit from the hover toolbar, replaces the text, and never saves or sends; and--mentionwith a one-letter last name against the fixture's @ picker, including a failed mention leaving no text. It needsbun; jsdom is installed once into~/.cache/whosaid-test-jsdom. Without bun it skips with a SKIP line (WHOSAID_REQUIRE_DOM_TEST=1makes that a failure).python3 test/teams_chat_test.pycoverswhosaid teams ingest: chat-day folders, turn parsing, name and role mapping, idempotent re-ingest, and a CLI ingest → index → search run.node test/teams_scraper_pills_test.jsruns the Teams scraper against a stub DOM (no real Teams): a pressed filter pill is cleared before chats are listed, recorded on__ts.pills, warned about when the chat count changes, and restored at the end (#57).python3 test/teams_manifest_test.pycovers audio-less folders in the roll-up and graph:createdfrom the sidecar, thekindfield, andmeetings --kind.python3 test/rollup_incremental_test.pycovers the incremental commitments refold (#98): only changed, new or removed folders refold, ids and curation hold, stripped cues and boss priority are re-derived, a[commitments]change keeps the full refresh with final-id logging, and fold-time review pairs survive.python3 test/action_items_test.pycovers the built-in summarizer with a fake model: candidate selection, quote verification, section assignment, and the evidence block.python3 test/eval_test.pycovers the action-item eval harness with fake models: the scorer, record and replay, and the reference backend's isolation../test/watch_test.shcoverslib/watch.pyagainst a temp source folder and a stubwhosaid: the stable-mtime wait, staging, state,--seed,--dry-run, and the plist.python3 test/mcp_descriptions_test.pyalso checks the new read-only workspace tools.
test/eval/run_eval.py measures how good the built-in summarizer's drafts are. It runs the
pipeline over synthetic meetings with hand-labeled action items, reports precision, recall and
F1, and records each run so it can be replayed offline. It can also draft the same meetings with a
cloud model for comparison. That backend is opt-in developer tooling that sends only the committed
synthetic fixtures; whosaid itself never calls a cloud model. The recorded baseline is F1 0.717
for the default qwen2.5:14b against 0.921 for the Claude opus reference, and most of the gap is
precision. See docs/eval.md.
test/diarize_eval/ measures diarization on synthetic meetings. It assembles fake meetings from
text-to-speech voices with exact ground truth, then scores how many people whosaid found and
who it says spoke each turn, blind, with --speakers N, and with enrolled voices. Only the
scripted lines go to the TTS vendors, and only while the voice pool is rendered. The voices are
TTS, not real people. See docs/diarize-eval.md.
./test/roles_test.sh covers speaker role tags offline: --role/--save-role validation, role
preservation across registry re-saves, the # Role: header lines in .speakers.txt, the
sidecar's roles key, and the [role] card label — no model download required.
./test/commitments_test.sh covers the dev-commitments extractor and corpus offline: cue,
negation, and question handling, self-role gating, boss-requested priority, the CM-NNN
roll-up dedupe and hand-edit reconcile, the semantic dedupe under WHOSAID_EMBED_FAKE=1
versus the difflib fallback when the embed server is unreachable, and the fragment filter
(clause hygiene, min_words at extraction and at fold, --min-words, the [commitments] min_words key, and the dropped-fragments review sections); no model download required.
./test/worklist_test.sh covers the ranked worklist offline: every tier rule, ordering, why
strings, owner resolution (--owner, me, the toml owner, aliases), the CM + AI union and its
cross-source dedupe, _WORKLIST-<Owner>.md as a regenerated view, --all-owners, --json, and
the whosaid commitments launcher command.
The client setup and history regressions have focused suites as well:
./test/bootstrap_test.shexercises the no-Homebrew, required-tool, bottle-failure, and checksum-failure paths with isolated tool mocks.python3 test/menubar_test.pyexercises the real plugin and watcher CLI, including active versus stopped PIDs, configured-source permissions, and first-run preferences.uv run --with numpy python test/backfill_test.pyexercises historical backfill, corpus freshness and curation, dry-run behavior, malformed inputs, and rollback.python3 test/speaker_registry_migration_test.pyexercises private registry round trips, validation, conflict policies, permissions, and concurrent creation.
Running the suites
Each suite is run directly; the runner depends on what it imports:
*.shsuites:./test/<name>.sh.Stdlib-only Python suites (
graph_test.py,teams_chat_test.py, ...):python3 test/<name>.py.numpy suites (
anchor,count_eval,diarize_absorb,diarize_recovery,estimate_k,max_speakers_hint,registry_matchesimport numpy;local_labelsandbackfillneed it for the CLI they drive):uv run --quiet --with numpy python test/<name>_test.py.MCP suites (
mcp_descriptions_test.py,mcp_workspace_tools_test.pyimportmcp):uv run --with "mcp[cli]" python test/<name>.py.
Couplings to know before editing code:
test/graph_test.pypins themeetingtable as row tuples (SELECT * FROM meeting), themeetings --jsonoutput, and the| folder | kind | created | ...wiki table row, so adding ameetingscolumn means editing those snapshots.A speaker-registry fixture's
modelmust be an ONNX basename ("<name>.onnx");_validate_modelinlib/speaker_registry.pymakesread_jsonreject anything else.macOS has no GNU
timeout. In-script deadlines useperl -e 'alarm shift; exec @ARGV' <secs> <cmd>(seetest/enroll_from_file_test.sh).
License
MIT — see LICENSE.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
This server cannot be deployed
Maintenance
Related MCP Connectors
Bot-free macOS Zoom/Meet/Teams call transcripts, Apple Vision screen OCR, and live SQLite MCP.
OCR, transcription, file extraction, and image generation for AI agents via MCP.
Search, read, and write your Apple Notes from ChatGPT/Claude via a local Mac agent + MCP relay.
Search speech in podcasts, government meetings, and your own audio: speakers, entities, timestamps.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables bidirectional voice interaction for Claude Code using local speech-to-text and text-to-speech models optimized for Apple Silicon. It provides tools to listen to user speech via microphone and speak responses aloud through system speakers.16Apache 2.0
- AlicenseNot gradedqualityCmaintenanceExposes Apple's on-device 3B parameter LLM to Claude Code and Claude Desktop via MCP. Zero API cost, fully private, runs on Neural Engine.29 PyPI1MIT
- AlicenseAqualityDmaintenanceLocal, private audio transcription MCP server enabling AI agents to transcribe audio files entirely on-device without uploading data.3MIT
- AlicenseNot gradedqualityBmaintenancePrivate, local audio and video transcription for Claude: an MCP server that runs Whisper on your computer, on the GPU or CPU, with text, SRT subtitle, or JSON output. Works with Claude Desktop, Claude Code, and claude.ai.1MIT