
whosaid
io.github.sblattj由 GitHub 账号 sblattj 发布v1.15.2更新于 Oct 25, 2026
Local speaker-attributed transcription and meeting search for Apple Silicon. Audio stays on the Mac.
概览
面向 Apple Silicon 的本地会议转写与说话人标注工具,通过只读工具在会议工作区中检索内容与承诺事项。
- 可以做什么
- 检索并阅读会议转写:按精确、语义或混合模式返回逐轮命中,附上下文、会议目录与各说话人发言占比
- 查询承诺与行动项:按负责人、提出人、状态或类型筛选,查看单条及其出现位置,并获取带优先级的排序工作清单
- 查看人物信息:某人的发言占比、其做出的承诺以及收到的请求
- 复核完成建议:在本地做有界检索寻找后续完成证据,仅返回待人工确认的候选,不改变状态
- 读取工作区资源:wiki、行动项与索引文件,以及每场会议的转写和行动项
- 跟进进行中的通话:读取实时转写、主题与摘要,或询问哪些语句与问题相关
- 适用场景
- 适合会议已在 Mac 上完成转写并建立索引的场景,让助手回答“预算的事最后怎么定的”“谁答应了什么”“某人被要求做什么”等问题,而无需整篇粘贴转写。也适合在通话进行中通过实时工具跟进。
- 运行要求
- 在 Apple Silicon macOS 上本地运行 whosaid 命令(从 git 仓库经 bootstrap 安装,或使用 uvx 运行 PyPI 包)。需要 Python 3.11 或更高版本、ffmpeg 与 ffprobe、已下载的模型,并以绝对路径在 WHOSAID_WORKSPACE 中指定工作区。索引须事先由 whosaid index 或监视器建立。语义检索可选依赖 Ollama。部分录音模式需要 macOS 15、命令行工具以及屏幕与系统音频录制权限。
安装
在 SourceWeft 中
- 打开 控制台中的 whosaid,将其添加到工作区。
- 为需要使用其工具的对话启用该服务。
Desktop only,通过 STDIO。 STDIO 服务会启动本地进程,因此需要 SourceWeft 桌面宿主。
其他 MCP 客户端
参照 仓库 中的启动说明。
README
whosaid
Local, speaker-attributed transcription for Apple Silicon — who said what, on your Mac, nothing leaves the machine.
[License: MIT] [Platform: macOS Apple Silicon]
[whosaid demo — speaker-attributed transcription on the terminal]
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):
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.
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:
Quickstart
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:
This 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
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:
- Only 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.
A 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.
- When 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. The app's Recorder page shows whether it is on and the command to turn it on. 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.
- Me 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). - 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.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
It 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.
[Live now in the app, on a synthetic call]
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.
[The Recorder page on a fixture]
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 on a fixture workspace]
Topics across meetings: whosaid topics build
It 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.
Enroll from an existing recording
Already have a clip of the person speaking? Skip the mic and cut a reference straight from it:
Meeting 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
Every 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:
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:
With --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
- Coverage 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.
- The 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.
The 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:
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 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.
The 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.
<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:
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.
What _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:
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.
How 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:
The 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):
This 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.
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
delete 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
Runs 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:
The 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:
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:
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.
Obligation 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:
command is the path to the whosaid script itself — the checkout's ./whosaid, or the installed
~/.local/bin/whosaid symlink.
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; 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.
Resources, all reading WHOSAID_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:
Kiro (~/.kiro/settings/mcp.json) and any client that takes the same JSON shape:
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>…)
Environment variables
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:
Backfill 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:
Exports 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
Transcription 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
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.
The sidecar's machine-readable extras, so a consumer never has to scrape stderr or shell out to
ffprobe:
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.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.
来源:README.md,提交 27a5dc6
工具
0版本历史
4- v1.15.2最新Oct 25, 2026
- v1.15.1Oct 24, 2026
- v1.15.0Oct 22, 2026
- v1.14.0Oct 20, 2026
评分
已满足适用检查项的 50%
该评分衡量此收录条目的完整程度,依据注册中心条目、端点与公开来源计算得出。它不是对服务器本身的评价,也不表示服务器安全或当前可用:可连通性是带日期的检查结果,评分也不包含安装量或用户评价。
- 近 30 天内可连通25 分
请保持端点能够响应 MCP 握手;SourceWeft 每 30 天探测一次。
- 已声明认证方式10 分
请在注册中心条目中声明凭据请求头,或保持端点能够响应探测,以便判定其认证方式。
- 已列出工具15 分
请让服务器在无凭据时响应 tools/list,或发布可在沙箱中无凭据启动的软件包。
- 描述必需15 分
- 图标10 分
- 文档15 分
- 许可证10 分