会议(测试版)¶
在本地录制通话:Hex 会将对方(系统音频)和您(麦克风)分别录制成独立音轨,在本 Mac 上转写,并保存一份包含 You 与 Them 轮次的 Markdown 转写文稿。只有当通话来自被设为“始终录制”的网站或应用时,录制才会自动开始;并且除非开启 Labs › 通话结束时停止,否则录制绝不会自动停止(参见 meetings.auto-stop)。本图谱描述的是 macOS 上的行为;Linux 和 Windows 列出了存在差异的地方。
关联的检查项均为定位器,除非下方记录了实际执行结果。
如何找到它¶
Labs > Meetings switch (macOS) // meetings.opt-in; off by default
├── Off -> only the Labs switch -> Turn on -> meetings.enabled = true
├── On -> Meetings joins the sidebar; header switch Recordings | Settings;
│ menu-bar items, call detection, Option-Control-V
│ Settings view: Call recording, After a meeting, Calendar, Detected calls, Advanced ▸
│ ├── System Audio Recording // meetings.permission; asked only now
│ │ ├── Untrusted row shown / Allow -> retired Hex IDs cleared (`ScreenCapture` + `AudioCapture`)
│ │ ├── Allow -> macOS prompt the first time (tour's permission_control)
│ │ ├── Decided before -> Privacy pane + drag guide ("System Audio Recording Only")
│ │ ├── Asked -> pulse; TCC preflight re-read on each poll, no reopen needed
│ │ ├── Returned from System Settings still untrusted -> "Still grey? Fix it"
│ │ │ (`AudioCapture` reset on running bundle + retired IDs, re-prompt / guide)
│ │ └── Granted -> the Allow pill morphs into a green Allowed; moves to Advanced
│ ├── After a meeting -> Summaries, Keep meetings, Transcript folder
│ │ (~/Documents/Hex Meetings; Reveal / choose; its error shows here)
│ │ └── Summaries pop-up (meetings.summaries; one-time migration
│ │ "meeting-summaries-choice" from the two retired switches)
│ │ ├── Off -> "No titles or summaries"
│ │ ├── On this Mac (default) -> Apple Intelligence only; "Titles and
│ │ │ short summaries, written on this Mac"; never sends anything to
│ │ │ OpenCode, so the old OpenCode fallback is gone
│ │ │ └── Apple Intelligence unavailable -> the option stays chosen
│ │ │ but disabled ("Turn on Apple Intelligence in System
│ │ │ Settings"); the row's line turns amber "Turn on Apple
│ │ │ Intelligence in System Settings to write them on this Mac";
│ │ │ a meeting records notes_error as before
│ │ └── OpenCode -> "Sends the transcript to your OpenCode model";
│ │ Model nests under it: a searchable picker of the running
│ │ OpenCode's models (refreshed each time it opens; no Refresh),
│ │ "Hex picks a fast model" first (clears meetings.opencode_model
│ │ -> the ranked fast models); a pick is used alone, no fallback.
│ │ OpenCode missing or failing -> a notice with Check again /
│ │ Retry, the saved model kept and shown by its ID (#1107, #1301)
│ │ // checks: summaries_are_one_choice_with_opencode_s_model_under_it,
│ │ // on_this_mac_without_apple_intelligence_stays_chosen_with_the_fix
│ │ // (tests/panes.rs); scenes meetings-summaries,
│ │ // meetings-summaries-unavailable, meetings-summary-model
│ ├── Advanced -> Show recording indicator (on by default; corner-snapping
│ │ capsule) and the System Audio Recording row once allowed
│ ├── Calendar -> Connect calendar; reminders before meetings // meetings.calendar
│ └── Detected calls -> Ask / Always record / Never offer per provider // meetings.policy
│ ├── Slack huddles, Zoom, Google Meet, Microsoft Teams, FaceTime, WhatsApp,
│ │ Gather, Discord (always listed)
│ └── then each website or app chosen for from an offer's "…"
└── Off -> Controller idles; no microphone-activity inspection or shortcut;
the pane leaves the sidebar
Debug builds -> Always in the sidebar (DEVELOPER_FEATURES_ENABLED)
Agents -> hex mcp (read-only MCP server; works whether or not Meetings is on)
Subtitles -> hex meetings show <id> --srt | --vtt // Labs › Subtitle export, off by default (#633)
one cue per transcript entry (You / Them), timed by entry offsets,
not word timings; off -> refused naming hex labs on subtitle-export
(docs/transcript-cli.md; no export button in the window yet)
Apps -> GET /meetings/events on the local API // Labs › Meeting finished event, off by default (#964)
one SSE event per meeting once its final transcript is written:
{"type":"meeting.finished","id":"<id>"}, nothing else; one subscriber;
off -> 404 labs-off (docs/streams.md, labs.md)
Recordings list icons // always on (#1290; graduated from Labs)
each row's title gets a small faint icon: Zoom, Google Meet, Teams,
Slack, or Webex when the meeting's app names it, else a neutral call
icon (FaceTime, Discord, WhatsApp, manual recordings)
(platform_icon in rune_ui/meetings.rs; CallProvider::from_meeting_app in
calendar.rs; check: platform_icons_fall_back_to_a_neutral_one; goldens
meetings light/dark show Zoom, FaceTime, and the neutral icon)
Live transcript scrolling // Labs › Follow live transcript, off by default (#1332)
on -> while the selected meeting is Starting or Recording (no Stop
pending), the detail card's scroll sticks to the bottom: new turns
stay in view; scrolling up stops following, scrolling back to the
bottom follows again. Finished meetings and off -> scroll by hand as before
(follows_live_transcript in rune_ui/meetings.rs; not observed on a real call)
此功能需要所选的本机听写模型、麦克风以及系统音频录制权限(“隐私与安全性 › 屏幕与系统音频录制”下的“仅限系统音频录制”列表;NSAudioCaptureUsageDescription)。即便只获得屏幕录制权限,也仍可通过 ScreenCaptureKit 回退方案正常工作。启用开关是 settings.json 中的 meetings.enabled,与它并列的还有 meetings.show_recording_indicator(默认 true)和 meetings.recording_indicator_corner(默认 top_right)(app_settings.rs MeetingSettings)。缺少其中任一授权时,录制会拒绝开始,并会向 macOS 请求系统音频录制权限。
Linux 与 Windows¶
同一套控制器、录音器、转录流水线、存储、窗格、胶囊和备注在 Linux 和 Windows(测试版,位于 Labs,默认关闭)上运行。只有以下适配器不同(meeting/linux.rs、meeting/windows.rs、microphone_activity/linux.rs、microphone_activity/windows.rs、meeting/runtime.rs):
System audio // meetings.system-audio
├── Linux -> pw-record of the default output's monitor (stream.capture.sink);
│ the session manager follows a changed default; no pw-record -> recording
│ refuses with "Install PipeWire's pw-record"
└── Windows -> WASAPI loopback of every active output (cpal), mixed into the
one system track, so a call playing on a headset that isn't the default
output is recorded (#1221; audio_on_any_output_reaches_the_system_track,
unverified on a real call); silence while nothing plays is padded on
resume; a change in the list of outputs reopens it; an output whose
loopback fails is dropped while the others keep recording
Call detection // meetings.detect
├── Linux -> one pw-dump --monitor: running Stream/Input/Audio nodes that are
│ not monitor captures, by application.process.binary (node or client)
└── Windows -> active IAudioSessionManager2 sessions on every capture device,
by process image name
└── executables map to the macOS keys (zoom -> us.zoom.xos, chrome ->
com.google.Chrome, ms-teams.exe -> com.microsoft.teams2, ...)
Browser calls -> offered as "Call in <browser>": no site is read yet, so a
call on any website (Teams on the web, a Teams web app/PWA, Meet, ...) is
a browser call after 20 s, never the Microsoft Teams (or other service's)
choice; Always record can't be set for it from an offer
└── browsers: chrome, chromium, brave, firefox, msedge, vivaldi (vivaldi-bin,
vivaldi-stable, vivaldi-snapshot; a Vivaldi web app runs in Vivaldi's
process); others are not detected (#1105)
Permissions -> none asked; the System Audio Recording row is macOS-only
Calendar -> none (no Connect calendar row)
Notes and live chapters -> After a meeting › Summaries is a switch (off by
default; on = OpenCode): "Titles and short summaries. Sends the transcript
to your OpenCode model"; Model nests under it while on, as on macOS
(migrated from Summarize with OpenCode)
// check: summaries_are_a_switch_off_a_mac (tests/panes.rs)
├── Summarize -> "Writing a title and summary with OpenCode…" (never
│ Apple Intelligence off a Mac that has it)
└── No writer (OpenCode not running) -> "Notes could not be written." with
the reason, recorded as notes_error (#1151: it was dropped silently)
Idle sleep -> systemd-inhibit idle:sleep (Linux),
SetThreadExecutionState (Windows) while recording
Tray -> Record meeting, Stop & transcribe, Copy last meeting transcript
while Meetings is on
Quit while recording or transcribing -> audio kept; the transcript finishes at
the next launch
Offer capsule can't open (Linux; GNOME on Wayland opens no interactive
overlay there, only the HUD on XWayland, #1271)
-> 2 s after the offer Rune reports the overlay unavailable -> logged
"meeting recording offer could not show", and the window opens on
Meetings saying so with Rune's reason; its Start recording records;
while Labs › Floating meeting capsules on GNOME is off, the note
names it
├── Labs › Floating meeting capsules on GNOME on (off by default, #1274),
│ layer-shell missing, this user's Xwayland already running (checked at
│ each show; never started) -> offer and recording capsules float on
│ XWayland: override-redirect, clicks on their buttons, never keyboard
│ focus; if Rune still can't open them, the pane path above
│ // checks: linux/meeting_capsules.rs gating tests; not run on GNOME
└── not observed on a real GNOME session; no automated check
Files -> Reveal opens the folder (xdg-open, Explorer); Delete moves to the
freedesktop Trash or the Recycle Bin
窗格的 Start recording、Stop & transcribe、Rename、Delete、Reveal 和 Summarize 在窗口自身进程中运行。在 #566 之前,这些操作会发送给 OS host,而 OS host 会忽略会议请求,因此已删除的录音仍会留在列表中且不显示错误。检查:pane_requests_reach_the_window_s_own_meetings(见 rune_ui/meetings.rs);在真实 Windows PC 上尚未观察到回收站删除操作。
Linux 和 Windows 上尚不支持:Option-Control-V 会议粘贴快捷键、从托盘 Copy section、将录音胶囊拖到另一个角落、hex meetings 和 hex mcp 命令(shared-control),以及浏览器通话的网站名称。已验证:Linux 和 Windows x64 构建与 lint(cargo clippy、cargo xwin clippy);PipeWire 监视器捕获和 pw-dump 解析已在 Arch/niri 上手工检查。未验证:任一操作系统上端到端的录音通话,以及硬件上的 Windows 回环和会话检测。
子功能¶
检测与提供¶
Zoom / Teams / Slack huddle / FaceTime / WhatsApp / Discord / browser call uses the microphone
(WhatsApp: net.whatsapp.WhatsApp, the older desktop.WhatsApp, WhatsApp.exe or
WhatsApp.Root.exe on Windows, web.whatsapp.com in a browser; 20 s, as voice
notes also hold the microphone; Gather: com.gather.GatherV2 and its helpers,
com.gather.Gather, Gather.exe, app.v2.gather.town and app.gather.town; 20 s,
as sitting in a space can hold it; Discord: com.hnc.Discord and its helpers,
its Canary and PTB builds, Discord.exe, the Linux discord binary and Flatpak,
#1283)
-> CoreAudio input process -> canonical host app // parent PID + bundle ID
-> On the microphone for 2 s by the monotonic clock // meetings.detect
│ Google Meet and Banshee (banshee.app) in a browser count as known
│ services; a slow watcher loop never stretches the time
└── Browser on a page that is not a known call site -> 20 s,
so voice notes and dictation fields are not offered
-> Provider = the website when known, else the app; the website's choice
when it has one, else the app's (a browser's choice covers its other
sites), else Ask
├── Never offer -> nothing shown; an earlier offer stays snoozed
├── Always record -> starts recording, no offer // meetings.auto-record
│ └── recording capsule says "Started automatically" for 10 s; with the
│ indicator off, a "Recording automatically" capsule with Stop
│ and ✕ (✕ only hides it; the recording goes on, in the menu bar)
└── Ask -> Capsule below the menu bar, focus-free // meetings.offer
├── Title: "Google Meet call detected", "Call on meet.jit.si",
│ "Call in Brave Browser" (site unknown), or the meeting's name
├── Record -> Start recording
├── Not now -> Close
├── … -> "<provider>" · "Change anytime in Settings" (capsule widens)
│ ├── Never offer -> saved, close
│ ├── Always record -> saved, records this call too
│ │ └── not offered for a browser on an unknown page
│ └── Cancel
└── 20 s timeout -> Close // never records on timeout
└── unanswered -> the snooze ends the moment the call app leaves
the microphone, so a call joined after its pre-join screen is
offered again when it retakes it (#1267; check
`a_timed_out_offer_is_offered_again_when_the_call_retakes_the_microphone`;
not observed on a real Meet call)
-> Same call is snoozed by key and app until off the microphone for 60 s,
offered or recorded; stopping an automatic recording does not restart it
└── Absence counts from the last time the call app was on the microphone,
a gap included (sleep, Meetings off, a recording): an app that left
the microphone during a recording is not snoozed for another minute
├── A call whose page names a meeting code (Google Meet's, from the
│ address or a code-only tab title) is snoozed by that code: another
│ code in the same browser is a new call and is offered or recorded;
│ a page without a code still counts as the snoozed call (#1113)
└── Stopped by hand -> the snooze ends once the call app has been off
the microphone for 3 s (seen while transcribing too), so the next
call in the same app is offered; a dismissed offer keeps the minute
for a muted call dropping off mid-call. Checks:
`a_different_meet_call_in_the_same_browser_is_not_snoozed_by_the_first`,
`a_stopped_call_s_app_is_snoozed_only_until_it_leaves_for_a_few_seconds`
-> "Left the call" prompt after the call app is off the microphone for 3 s
-> Times are wall-clock runs: observations more than 10 s apart (sleep, a
stalled loop) start a stability run again instead of counting the gap
└── A call off the microphone for at most 2 s (a missed check) keeps
its run; a longer release starts it over
Choices persist as `meetings.providers` (`key`, `label`, `policy`:
`ask` / `always_record` / `never_offer`) in `settings.json`. Missing means Ask.
The older `meetings.never_offer` list reads as Never offer entries and is
rewritten as `providers` on the next load.
呼叫检测支持 Brave、Chrome、Safari、Firefox、Edge、Arc(company.thebrowser.Browser,前台名称为 Arc;其标签页的读取方式与 Brave 相同)以及 Vivaldi(com.vivaldi.Vivaldi;Linux 上为 vivaldi-bin 等,Windows 上为 vivaldi.exe)。Arc 直到 #801 才被加入,因此其 Meet 通话从未被检测到;Vivaldi 直到 #1105 才被加入,因此其通话(包括 Vivaldi Web 应用中的 Teams)从未被提供。检查项:a_google_meet_call_in_arc_records_after_about_two_seconds、vivaldi_on_the_microphone_is_a_browser_call。未在真实的 Vivaldi 通话中观察到。
Chromium 和 Electron 应用(Brave、Chrome、Edge、Arc、Slack、Teams)会在沙箱化的 audio.mojom.AudioService 实用工具进程(*.helper)中捕获麦克风音频,这些进程不会向 NSRunningApplication 注册;Zoom 使用 us.zoom.CptHost,Firefox 使用 org.mozilla.plugincontainer,Safari 使用 com.apple.WebKit.GPU(Safari Graphics and Media),FaceTime 使用 com.apple.avconferenced。microphone_activity.rs 通过父 PID 谱系、CoreAudio 的 kAudioProcessPropertyBundleID 和 NSRunningApplication 将每个输入进程归因于其规范宿主应用。当 Brave 使用麦克风且未被暂停时,dictation/context.rs 中的 ContextSnapshot::capture_for_meeting 会读取 Brave 前置窗口的活动标签页,即使 Brave 位于另一个窗口之后。当该 AppleScript 读取失败或未返回任何内容(未允许自动化、脚本失败;#827、Arc)时,页面将来自浏览器进程通过辅助功能(AXURL)聚焦的 Web 区域,即使仍在另一个窗口之后;每个浏览器的首次失败会记录为 could not read the browser tab。脚本通过前置窗口为每个属性命名标签页,而不是将其保存在变量中,Arc 在每次读取时都无法解析这一点(#1113;尚未针对真实 Arc 观察到)。位于另一个应用之后的 Arc(以及其他 Chromium 浏览器)通常也不会暴露 Web 区域,因此在没有页面的情况下,将使用浏览器自身聚焦窗口的标题:标题为 Meet - … / Meet – … 的标签页在 meet.google.com 上计为 Google Meet 通话(2 秒,始终记录适用);着陆页(“Google Meet”)不算。在此之前,没有自动化权限的后台 Arc 中的 Meet 通话是未命名的浏览器通话(#827)。检查项:a_google_meet_call_in_arc_without_its_address_is_known_by_its_title;尚未针对真实的后台 Arc 观察到。不会搜索后台标签页:后台标签页中打开的通话无法说明哪个标签页持有麦克风,否则其他位置的语音笔记会被赋予已知服务的 2 秒阈值或触发始终记录。(因此,后台标签页中的通话将根据活动标签页的页面来判断,对于新站点即为未知页面的 20 秒。)该查询是一次 AppleScript 往返,因此监视器会在自己的线程上启动它(一次最多一个,大约每秒一次),并在其完成后最多 5 秒内读取最新答案;缓慢或超时的查询(现有的 2 秒限制)绝不会阻塞循环。当 Hex 正在录制、Meetings 关闭、浏览器因无会议代码的通话而被暂停,或没有浏览器持有麦克风时,答案和记住的页面将被丢弃;此时在途的查询不会重复启动,其答案也会被丢弃。当为已经进行的运行找到浏览器页面时(第一遍尚无答案),运行会继续计数,现在朝已知服务的 2 秒计数。当没有答案命名页面时,浏览器在持有麦克风期间显示的最后页面会在 60 秒内命名通话(BrowserPageMemory),因此即使查询失败或 Hex 位于前台,站点仍然有效。Safari、Chrome、Firefox 和 Edge 不暴露 URL,因此除非前台上下文另有说明,否则它们的通话未命名。Banshee 没有可识别的房间路径,因此任何 banshee.app 页面都算数;它不在设置中始终列出的提供者中,但一旦从其提供项的“…”中设置选择,就会出现在“检测到的通话”下。未观察到:针对带有 Banshee 通话的真实 Brave 的新标签页查询(脚本可编译,线程和内存使用虚假查询进行了单元测试);Banshee URL 形状不可用,因此主机规则是对“任何页面”的猜测。
2026-10-21,Brave 在 problems.kitlangton.com(看板语音笔记)上每次打开麦克风 3-6 秒,每次爆发都收到“检测到浏览器通话”的提供项;这正是 20 秒浏览器阈值所防止的。2026-10-26 的第二次 Banshee 通话在 Kit 手动开始录制前的 30 秒内没有收到任何提供项:“约 20 秒”阈值是 40 次循环观察,每次循环约需 0.9 秒(500 毫秒睡眠加上浏览器检查),因此未知浏览器页面大约需要 37 秒。现在的阈值基于时钟时间。检查项:a_slow_loop_still_offers_an_unknown_browser_call_after_about_20_seconds、a_slow_loop_still_ends_a_snooze_after_a_minute、a_stalled_loop_does_not_count_the_gap_as_time_on_the_microphone、banshee_is_offered_after_about_two_seconds、the_site_is_resolved_while_hex_is_in_front、a_page_seen_minutes_ago_no_longer_names_the_call、a_remembered_page_belongs_to_its_browser_and_its_call、finding_the_page_keeps_the_run_toward_the_known_threshold、a_voice_note_on_another_site_is_not_a_banshee_call、a_gap_with_the_app_absent_counts_as_absence、a_recorded_call_that_left_the_microphone_is_not_snoozed_for_another_minute、a_slow_tab_query_never_holds_up_the_loop、a_reset_drops_the_answer_in_flight_without_starting_a_second_query、an_answer_is_dated_by_when_its_query_finished、a_cold_tab_query_then_its_answer_leads_to_a_banshee_offer 和 a_snoozed_or_non_browser_application_is_not_queried(meeting/watcher.rs)、the_meeting_tab_query_reads_only_the_active_tab(dictation/context.rs)、a_browser_voice_note_is_not_offered_but_a_sustained_browser_call_is、offer_titles_name_the_service_site_or_browser_without_repeating_call、choices_apply_to_the_site_when_known_and_the_app_otherwise、always_record_starts_after_the_same_stability_time_as_an_offer 和 never_offer_and_snoozed_calls_never_start 位于 meeting/detection.rs;meeting_providers_ask_by_default_and_legacy_never_offer_entries_stay_never 位于 app_settings.rs;胶囊检查位于 meeting_capsules.rs(always_record_saves_the_choice_and_records_this_call、never_offer_saves_the_choice_and_dismisses、a_browser_on_an_unknown_page_cannot_be_set_to_always_record、an_offer_names_the_call_without_asking_about_consent、an_automatic_recording_says_so_at_first);以及 meetings_settings_save_and_ask_for_system_audio(Linux 窗格测试)。将 Detection::Record 转换为录制的控制器循环没有自动化测试;它共享手动录制路径。
来源:meeting/detection.rs、microphone_activity.rs、dictation/context.rs、meeting/watcher.rs 中的控制器(在触发提议时记录 INFO),以及 meeting_capsules.rs 中的提议胶囊。
检查项:resolves_unregistered_chromium_and_electron_audio_helpers_to_their_host_apps、resolves_helper_bundle_ids_when_parent_lookup_is_unavailable、attributes_safari_webkit_gpu_and_facetime_avconferenced_only_when_active、canonical_bundle_matching_covers_supported_apps_and_variants、classifies_helper_and_host_microphone_applications、cleans_google_meet_titles_and_ignores_raw_meet_codes、switching_tabs_during_a_snoozed_browser_call_does_not_re_offer、parses_brave_tab_url_and_title_output、requires_stability_and_offers_once_per_session、a_brief_microphone_release_does_not_re_offer_the_same_call 以及 named_meetings_keep_their_title_and_app_calls_use_the_app。
观察(2026-10-19):在 Studio 上进行实时 CoreAudio 进程检查,并在 Pro 上通过无头 Brave WebRTC getUserMedia({audio: true}) 复现,确认 com.brave.Browser.helper 和 com.tinyspeck.slackmacgap.helper 在父进程/包解析之前为 NSRunningApplication == nil,之后解析为 com.brave.Browser 和 com.tinyspeck.slackmacgap。
这些胶囊没有 --snap 场景。HEX_CAPSULE_SNAPS=<folder> cargo test --bin hex capsule_snapshots 会在配备 GPU 的 Mac 上以浅色和深色模式渲染提议、其选项、日历提醒以及自动录制状态。
日历提醒¶
Meetings > Settings > Calendar // meetings.calendar (macOS)
├── Connect calendar -> macOS full-access prompt, on this click only
│ ├── Granted -> meetings.calendar.connected = true; reminders on, 1 min lead
│ │ └── trusts the prompt's answer: macOS keeps reporting "not determined"
│ │ to the asking process until it relaunches
│ └── Denied / Restricted / write-only -> "Calendar access is off"
│ └── Open settings -> Privacy & Security › Calendars
└── Connected
├── Remind me before meetings -> on by default
├── Remind me -> At start / 1 / 2 / 5 min
├── Calendars -> All calendars, or "2 of 3 calendars"; each switched in the menu
└── Next 3 meetings: title, "Today · 2:30 PM", provider icon when it has a call link
Meeting = not all-day, declined, free, or cancelled, AND has other attendees or a
Zoom / Google Meet / Teams / Slack huddle / Webex link (URL, location, notes)
Lead time reached (or Mac wakes within 5 min of the start) // meetings.reminder
-> Reminder capsule, offer-capsule placement, focus-free
├── "Design sync" · "Starts in 1 min · Zoom"
│ ├── Not now -> close; this call is not offered when detected
│ ├── Join & record (only with a link) -> open the link, then record
│ └── Record -> recording titled "Design sync" with its attendees
└── Service set to Always record: "Starts in 1 min · Hex records automatically"
├── Don't record -> close; the detected call neither offers nor auto-records
└── Join (only with a link) -> open the link; Always record starts on detection
-> Closes 60 s after the start (at least 30 s after showing); withdrawn if the
meeting is cancelled, moved, or reminders are turned off. One at a time: an
overlapping meeting's reminder waits until this one is answered or closes.
Waits while Hex records; a recording that starts closes it.
Detected call during a calendar meeting on the same service
-> recording takes the calendar title and attendees
设置以 meetings.calendar(connected、excluded_calendars、remind、lead_minutes)的形式保存在 settings.json 中;缺少该字段表示未连接,因此来自旧版本的文件保持关闭状态。每个日历都会被包含,除非被排除。Hex 在启动时从不请求日历访问:只有“连接日历”会调用 requestFullAccessToEvents(NSCalendarsFullAccessUsageDescription)。当会议功能关闭时,没有日历卡片、没有 EventKit 读取,也没有提醒。
控制器线程会在存储的更改通知递增其计数器时读取 EventKit(一个有界查询,向前 48 小时,最多 200 个事件),或者按墙钟每分钟读取一次,因此唤醒后会重新读取;每个循环随后都是一次开销很小的调度器遍历。提醒以事件 ID 和开始时间作为每次事件实例的键,因此被移动的会议会再次提醒,且每个事件实例只提醒一次。设置面板会在窗口显示期间于一个读取线程上读取自己的快照,并在设置、访问权限或(一旦连接后)存储发生变化时以及每 30 秒重新读取;窗口线程从不调用 EventKit。没有网络请求,也绝不在音频或 UI 线程上执行。从提醒开始录制(或将其关闭)会暂停来自链接服务的已检测通话,直到会议结束;如果没有链接,则只覆盖下一个已检测通话,并且只持续到开始后 5 分钟(如果更早结束,则到结束为止)。清单的 calendar 字段和 Markdown 的 Attendees 行携带日历标题和参与者,笔记提示会将它们作为上下文。
检查项:meetings_need_other_people_or_a_call_link、all_day_declined_free_and_cancelled_events_are_skipped、excluded_calendars_are_left_out_and_meetings_sort_by_start、call_links_are_found_in_the_url_location_or_notes、other_links_and_lookalikes_are_not_calls、a_link_after_a_bare_http_in_the_same_word_is_found、the_url_field_wins_over_the_notes、an_overlapping_meeting_waits_for_the_showing_reminder、a_late_reminder_stays_up_at_least_half_a_minute、a_reminder_shows_once_at_the_lead_time、at_start_reminds_when_the_meeting_begins、waking_shortly_after_the_start_still_reminds_but_not_much_later、a_showing_reminder_closes_when_its_meeting_is_cancelled_or_long_started、a_moved_meeting_is_reminded_at_its_new_time、back_to_back_meetings_each_remind、reminded_occurrences_are_forgotten_after_they_end、turning_reminders_off_withdraws_the_showing_one,以及 calendar.rs 中的 a_detected_call_belongs_to_the_current_meeting_on_its_service;meeting/detection.rs 中的 a_call_answered_from_its_calendar_reminder_is_not_offered_again 和 a_reminder_without_a_call_link_covers_only_the_next_call;meeting/watcher.rs 中的 no_reminder_shows_over_a_recording 和 a_dismissed_reminder_without_a_link_snoozes_only_around_its_start;app_settings.rs 中的 calendar_reminders_stay_off_until_connected_and_survive_older_files;a_calendar_meeting_gives_its_title_and_attendees_as_context 和 markdown_lists_the_calendar_attendees;meeting_capsules.rs 中的提醒胶囊检查(a_reminder_with_a_link_joins_and_records_on_one_click、a_reminder_without_a_link_only_records、dismissing_a_reminder_tells_the_controller、an_always_record_reminder_says_so_and_never_offers_record、the_reminder_counts_down_to_the_start);以及 Linux 面板测试 the_calendar_asks_for_access_only_when_connect_is_pressed、a_connected_calendar_saves_reminders_and_lists_meetings 和 denied_calendar_access_links_to_privacy_settings;应答检查 a_grant_is_full_access_even_while_the_status_is_stale 和 a_refusal_is_a_denial_unless_the_request_failed 位于 calendar.rs,而 a_granted_calendar_is_saved_connected_and_stays_connected、a_failed_save_shows_its_error、a_denied_calendar_is_not_saved_and_stays_blocked 和 a_failed_request_keeps_connect_and_shows_the_error 位于 macos_host.rs。连接日历的每一步都会向 process.log 记录一行 calendar:。场景:meetings-settings(未连接)、meetings-calendar 和 meetings-calendar-denied。
尚未观察到:针对真实日历的 EventKit 桥接、macOS 权限提示和屏幕上的提醒。将提醒点击变成录制的控制器循环没有自动化测试;它共用手动 Record 路径。来源:calendar.rs、calendar.swift、meeting/watcher.rs 中的 Reminders、macos_host.rs 中的 CalendarFeed,以及 platform_settings.rs。
录制¶
Record (offer, menu bar "Record Meeting", Meetings pane, or voice command)
-> Title: named meeting OR "<App> call — 2:30 PM" OR "Meeting — 2:30 PM"
-> system.wav + microphone.wav, owner-only, Application Support/meetings/<id>
-> No idle sleep; no dictation mute or media pause // meetings.record
-> Menu bar: record symbol + elapsed time
├── "Recording · <title> · 12:34" -> Meetings pane
│ └── title follows the provisional title (see Titles And Summary)
└── Stop & Transcribe
(Idle menu bar: "Record Meeting"; "Copy Last Meeting Transcript" sits
under Paste Last Dictation; there is no Meetings… item, Settings… opens
the same window)
-> Floating recording indicator (when Show recording indicator is on) // meetings.indicator
├── Rune interactive overlay: a non-activating panel that never becomes key
├── Only the drawn pill takes the pointer (OverlayInput::Regions, the
│ pill `.hit()`); the window is as wide as the open pill, and clicks
│ in its transparent rest reach the app beneath (#1305; not observed
│ on a real Mac)
├── In the saved recording_indicator_corner
├── Compact: pulsing red dot · title · mm:ss, plus "+N" when turns wait
├── Hover -> widens toward the screen with the actions below; pointer
│ leaving -> eases back to compact (a held press keeps it open)
├── Drag -> follows the pointer; release -> snaps to the corner nearest
│ its centre and saves recording_indicator_corner (window settings too)
├── Copy new (N) -> copies the turns not yet copied or pasted, through
│ the click (see meetings.copy-through-now)
│ ├── Shares Option-Control-V's cursor: either one advances both
│ ├── N = speaker turns the ticker has seen; always enabled while
│ │ recording, since the latest speech may not be counted yet
│ ├── "Transcribing…" (disabled) while it waits, then copies; "Copied" for 1.5 s
│ └── The wait runs on its own thread, never on the main thread
├── Copy all -> the in-call transcript so far as speaker-turn Markdown;
│ disabled until there is text
│ └── Both read the Parakeet in-call transcript (TranscriptReader) once a
│ second on the ticker thread, never on the main thread
├── Open -> activates Hex, then opens the active meeting in the Meetings pane
└── Stop -> Stop & Transcribe
-> Settings window may stay closed
Detected app leaves the microphone for about 3 s // meetings.call-ended
-> "Zoom left the call" capsule: Keep recording / Stop & transcribe
-> No timeout; nothing stops automatically
Labs › Movable meeting capsules (macOS, off by default; #1093) // meetings.panel-drag
The panel capsules: offer, reminder, left the call, Meeting transcript
ready, Recording automatically
-> On: each opens in the saved recording_indicator_corner (read as it shows)
├── Drag its body -> follows the pointer; release -> snaps to the corner
│ nearest its centre (by its own width) and saves it, so the recording
│ capsule moves there too
├── Press on a button -> the button, as before
└── Escape mid-drag -> back where it was, nothing saved
-> Off: top right below the menu bar, no drag, exactly as before
Checks: a_released_panel_snaps_by_its_own_size,
each_corner_places_the_panel_at_its_edges, only_a_movable_panel_drags
(rune_ui/meeting_capsules.rs); not observed on a real Mac. Every panel
capsule takes the pointer only on its body, never the window's margin
around it (#1305); a click on the body outside its buttons still stops
there
Labs › Stop when the call ends (macOS, off by default; #835) // meetings.auto-stop
Only a recording Hex started on its own (Always record); manual, offered,
and calendar recordings are never stopped
-> The call app counts as present while it holds the microphone OR any of
its Core Audio processes plays output (WhatsApp keeps the microphone
while muted; its output stays open until hang-up)
-> Both off for about 3 s -> capsule says "Hex stops recording in 30 s
unless you keep recording."
├── App back on the microphone or output within 30 s -> nothing stops
├── Keep recording -> nothing stops for this absence; leaving again re-arms
├── Stop & transcribe -> stops now
└── 30 s pass -> Stop & Transcribe, as the menu bar's; logged
"meeting recording stopped automatically after its call ended"
-> Off -> exactly as above: microphone only, nothing stops
Capture sources (meeting/capture.rs) // meetings.capture
├── Microphone -> dictation's input (saved device, else System default) via cpal, resampled to 16 kHz
│ ├── Detected call app (macOS 14+) -> the input that app records from, by
│ │ device UID, ahead of the saved device and the default (#1086)
│ │ ├── App switches input mid-call -> followed within 1 s, same track
│ │ ├── App reports no input (muted) -> keeps the one it followed
│ │ └── Not reported, not listed, Hex Microphone, or won't open -> as below
│ │ // a_meeting_follows_the_input_its_call_app_records_from,
│ │ // a_meeting_finds_the_input_its_call_uses_by_uid; unverified on a real call
│ ├── System default changes, the Settings microphone changes, or a missing
│ │ saved device returns, mid-meeting
│ │ -> New input opens before the old drops; same track, no retry gap
│ │ // macOS only (meetings are macOS); a failed open keeps the old input
│ └── Saved device listed but would not open -> System default stands in
│ -> tried again after 1, 2, 4, 8, 16, 30, 30, 30 s, then only on a change
└── System audio -> Core Audio process tap (global, excluding Hex) on a private
aggregate device; no display dependency
└── Tap not authorized + Screen Recording granted -> ScreenCaptureKit fallback
A source fails (display sleep ends SCStream, device unplugged, tap stalls 3 s)
-> Only that source stops; the other keeps recording
-> Retried every 0.5-5 s until it reopens or the meeting stops
-> Resumes into the same track; silence fills the gap (>= 1 s) on the
shared capture clock, recorded in the manifest as track `gaps`
-> Rolling transcription restarts that source after the gap; the padded WAV
keeps the final pass on the same timeline
``````
开始录音不再会打开设置窗口;失败时仍会打开。
失败时会在录音上方显示“Meeting could not be recorded.”,直到点击其关闭按钮将其消除([#562](https://github.com/anomalyco/hex/issues/562);检查 `a_meeting_recording_error_can_be_dismissed`)。
检查项:`call_end_is_reported_once_per_sustained_absence`、`an_ended_call_stops_after_the_grace_unless_it_comes_back_or_is_kept`(见 [meeting/detection.rs](https://atomgit.com/GitHub_Trending/hex19/hex/blob/dev/src/meeting/detection.rs))、`only_automatic_recordings_stop_when_the_call_ends_and_only_with_the_lab_on` 和 `keep_recording_disarms_a_pending_automatic_stop`(见 [meeting/watcher.rs](https://atomgit.com/GitHub_Trending/hex19/hex/blob/dev/src/meeting/watcher.rs))、`an_automatic_stop_says_so_and_keep_recording_cancels_it`(见 [meeting_capsules.rs](https://atomgit.com/GitHub_Trending/hex19/hex/blob/dev/src/rune_ui/meeting_capsules.rs))(自动停止;尚未在真实 Mac 上观察到:其输出读取了 `microphone_activity::application_running_output`,并与实时 WhatsApp 通话、静音通话以及 Zoom 或 Teams 辅助进程进行了对照)、`paste_cursor_counts_coalesced_turns_and_advances_only_on_success`(见 [meeting/tests.rs](https://atomgit.com/GitHub_Trending/hex19/hex/blob/dev/src/meeting/tests.rs))、`meeting_delta_coalesces_turns_in_time_order`(见 [parakeet.rs](https://atomgit.com/GitHub_Trending/hex19/hex/blob/dev/src/parakeet.rs))、`paste_cursor_neither_repeats_nor_drops_text_when_the_transcript_is_rewritten`,以及 `meeting_menu_offers_one_action_per_state`(见 [macos_rune/status_item.rs](https://atomgit.com/GitHub_Trending/hex19/hex/blob/dev/src/macos_rune/status_item.rs)),还有 `mid_meeting_capture_loss_proceeds_to_transcription_when_audio_was_recorded`、`writer_fills_a_capture_gap_with_silence_and_records_it` 和 `a_source_that_resumes_after_an_outage_is_padded_back_onto_the_timeline`(见 [meeting/tests.rs](https://atomgit.com/GitHub_Trending/hex19/hex/blob/dev/src/meeting/tests.rs))。菜单栏的 Record Meeting 和 Stop & Transcribe,以及录音和就绪胶囊按钮,已于 2026-10-22 在真实 Mac 上点击过;目前没有自动化测试覆盖它们。
这些胶囊是 [meeting_capsules.rs](https://atomgit.com/GitHub_Trending/hex19/hex/blob/dev/src/rune_ui/meeting_capsules.rs) 中的 Rune 覆盖层:位于每个 Space 状态层的交互式、非激活 `NSPanel`,由菜单栏 pump 接收到的控制器事件驱动([macos_rune.rs](https://atomgit.com/GitHub_Trending/hex19/hex/blob/dev/src/macos_rune.rs))。已移植了 offer(20 秒)、“left the call”、ready(15 秒,Copy / Reveal / ✕,笔记刷新)以及录音胶囊(标题、已用时间、Copy new、Copy all、Open、Stop,位于配置的角落,悬停前保持紧凑,可在角落之间拖动)。Copy new 和 Copy all 由 `the_recording_capsule_copies_the_in_call_transcript` 和 `copy_new_copies_the_transcript_read_through_the_click` 检查,并由 `capsule_snapshots`(`recording-empty`、`-live`、`-transcribing`、`-copied`、`-open`、`-left`)渲染;它们尚未在真实 Mac 上被点击过。悬停和角落吸附由 `the_recording_capsule_rests_compact_and_opens_under_the_pointer`、`dragging_the_recording_capsule_moves_it_and_snaps_to_a_corner` 和 `a_released_capsule_snaps_to_the_corner_nearest_its_centre` 检查;两者都尚未用真实指针尝试过。“Never offer”或“Always record”的选择会被保存,并同时写入窗口的实时设置中。2026-10-22,一台 MacBook Pro 上的签名构建显示了录音胶囊(Open 在继续录音的同时打开了会议;Stop 进行了转录)和 ready 胶囊(Copy 复制了 Markdown)。在那天的修复之前,录音胶囊从未出现:它在一个覆盖层批次中关闭并重新打开,而 hide 最后运行。offer、“left the call”、“…”选项以及自动录音胶囊在屏幕上仍未出现。ready 和自动录音胶囊的 ✕(#996)由 `dismissing_a_ready_transcript_only_closes_it` 和 `dismissing_an_automatic_recording_never_stops_it` 检查,并由 `panel_goldens`(`meeting-capsule-ready-*`、`-auto-started-*`)绘制;它尚未在真实 Mac 上被点击过。
### 转录 {#transcribe}
```ts
While recording // meetings.rolling
-> Each track -> one-second blocks -> bounded queue (about 10 minutes)
-> 16 kHz streaming resampler -> chunker + echo envelope
├── First pause after 6 s (pause segmenter; never under 3 s) -> boundary
├── No pause by 10 s -> hard cut; next chunk repeats its last 2 s
│ (so a chunk is at most 12 s of audio)
└── Silent chunk (no 20 ms frame ≥ -60 dB) -> skipped, no inference
-> Each chunk runs on dictation's loaded model, only while dictation is idle
├── No speech detected (Silero, once installed) -> empty transcript, no
│ inference; coverage still advances
└── A dictation request or live stream came first -> retried once idle
-> Timed words joined word by word at a shared word near each overlap's middle
-> sentences (punctuation or a 600 ms pause)
-> transcript.rolling.ndjson: per-track coverage line, then entries
├── Only settled sentences: wholly before the next seam, so a later chunk
│ never rewrites or retracts a published line
├── You entries publish only once the system track is transcribed past them,
│ or once system capture is over 5 s behind the microphone (stalled)
├── Capture gap -> the track restarts after the gap; nothing is dropped
├── Backlog over 120 s of audio -> oldest chunks skipped, track marked
│ incomplete; rebuilt from its WAV after Stop
└── Model failure -> transcript.rolling.error.txt -> "Live transcript unavailable."
-> Every in-call view reads it: Meetings pane, `hex meetings show`, MCP,
provisional titles (TranscriptReader)
Copy new or Option-Control-V while recording // meetings.copy-through-now
-> The capture writer sends its partial blocks, then the request, in
capture order, so the request covers every sample received
-> The rolling worker answers it before any later audio:
├── Queued chunks as usual (published to the snapshot)
├── Each track's audio not yet in a chunk (no 6 s minimum), into a copy
│ of its transcript: the snapshot and later chunks are unaffected
├── Waits for dictation like every meeting chunk; never a second model
└── The same echo and You-waits-for-Them rules as the snapshot
├── Every sample up to the request, even 100 ms past a hard cut; the
│ system track counts as transcribed through all its audio
-> Each track remembers where the copy ended; its sentence ends there
(between the words whose middles lie either side, as boundaries move
when a later chunk hears more), so the copied part and the rest of it
publish as separate entries and the paste cursor neither repeats nor
drops either. A track rebuilt from its WAV after Stop keeps the cuts
-> A copy pressed as capture ends is still answered; quitting ends the wait
-> Up to 3 s (COPY_TRANSCRIBE_WAIT): usually under 1 s; past that, or with
dictation holding the model, copies the in-call transcript as it is and
logs a warning
-> After Stop, or in another process: the in-call or final transcript
Stop & Transcribe
-> Flush tails -> finish pending chunks // usually seconds
-> Any track that fell behind or failed -> stream its WAV in 1 s blocks
-> Join on one timeline -> Echo suppression -> Final publication
Hex quits while recording // meetings.quit
-> Capture stops; WAVs and manifest finalized; no chunk starts, no model loads
-> Interrupted with its rolling draft + final.pending beside the manifest
Hex quits during Stop & Transcribe's final pass (#1284)
-> Same: Interrupted + final.pending, never Failed with "Hex is quitting"
-> Next launch, Meetings on, no recording, once the dictation engine is up
(or 30 s without one) -> full-WAV final pass -> Complete + Markdown + notes
├── Quit again or model not installed -> stays pending
└── Other failure -> stays Interrupted with the error and its draft
退出会在 _exit 前给其工作进程 5 秒时间,而长会议的最终处理可能需要更长时间,或者在听写引擎已关闭后加载模型。
来源:record、finish_pending(位于 meeting.rs)以及 PendingFinals(位于 meeting/watcher.rs)。检查:quitting_mid_meeting_loads_no_model_and_finishes_in_time。没有可重复的测试驱动会在真实录音期间退出并重新启动。
设计选择:块是听写转录引擎(TranscriptionEngine::background)上的后台任务,使用听写已加载的任何模型,而不是模型的第二份副本(约 0.7 GB)。在每个块之前,会议会等待(每个块最多 20 秒),此时 parakeet::dictation_busy() 报告正在进行捕获(从按下快捷键直到其片段被提交)或听写推理;引擎线程在运行任务前会再次检查。任务从不中断实时流:在流期间到达的任务,或在更新的听写请求发送后到达的任务,会被拒绝,并在听写空闲后 250 毫秒重试。正在运行的块无法中止(transcribe.cpp 在 Metal 上忽略其取消回调),但它只可能在按下之前启动,因此它会在 300 毫秒保持阈值和 200 毫秒释放尾部内完成(在 Pro 上,最多 12 秒的块耗时远低于 0.2 秒)。改为在有意保持处进行门控,反而会让块在阈值之前启动;在最短保持下,这会在 Pro 上增加约 85 毫秒(p95)。该任务在引擎线程上以 utility QoS 运行,之后线程会恢复为 user-interactive。Apple Speech 在进程外运行,因此当选择 Apple Speech 时,以及在缺少引擎的进程(CLI)中,会议会像以前一样加载自己的转录器。
接缝:两个块在一个词处拼接,该词在重叠中点前后 300 毫秒内被两边都听到(同一个词的时间可能在不同块之间偏移);如果没有这样的词,则在中点之前开始的词属于较早的块。连接处的重复词(两个到十二个)会被移除,Stop 会发布仍在接缝处等待的词。拼接词而非句子,可以保持跨越硬切片的句子完整;拼接句子会丢失一侧的词(在 You 行上词错误率为 10%)。
无语音块:模型会为静音和噪音编造文本(“Thank you”、“Grazie a tutti”或词汇提示),而房间或键盘噪音能通过 -60 dB 静音块检查,因此每个通过该检查的块也会经过听写所使用的 Silero 语音检测器(参见 dictation)。在没有至少 160 毫秒语音的情况下,块在滚动转录、Stop & Transcribe 回退和 meeting echo 中会变成空转录,不进行推理。在树外的一次探测中,对一个 63 分钟意大利语通话的每个麦克风条目运行,检测器在 152 个编造条目中发现 1 个有语音(一个 “Grazie a tutti.”),在 33 个真实条目中发现 32 个有语音(遗漏:一个位于块边界的 40 毫秒 “Certo.”),并在 CPU 上用 1.6 秒扫描了整个通话。包含语音的块内部的编造文本未处理。没有检测器时,仅适用 -60 dB 检查,和以前一样。只返回词汇提示的 Whisper 或 Qwen3-ASR 块也会被清空(models)。
通话中文本在 2026-10-22 之前是 Moonshine 草稿:窗格只在滚动转录已覆盖的时间段内显示 Parakeet,而 Option-Control-V 和 GPUI 胶囊的 Copy new 只粘贴 Moonshine 草稿,这会导致名称和术语乱码。会议现在不再加载 Moonshine;短 Parakeet 块取代了它。始终在线的流式 Parakeet 被否决,因为一个已加载的模型无法同时服务它:transcribe-cpp(0.2.4,以及 0.4.1 中仍然)允许每个模型在所有会话中只有一个活动流,并且在流打开时用 Busy 拒绝任何其他运行或流(Session::stream)。两条音轨将需要混合音轨(失去 You/Them 和回声抑制)或每条音轨一个模型副本(每个约 0.7 GB),而且每次听写都必须关闭会议流并丢失其 5.6 秒的左侧上下文。缓冲流还会在每个 1.04 秒步骤中重新编码一个 7.7 秒的窗口(左侧 5.6 秒,块 1.04 秒,右侧 1.04 秒),而块大约每秒编码一次:Studio 的 1,619 个时长 3 秒或更长的听写流(3.2 小时音频,2026-10-23 日志)每秒音频花费 44 毫秒的馈送时间,对于两条音轨,这将占用引擎约 9% 的时间。
成本:2026-10-22 在 Pro 上使用 meeting_live_replay 观察:将一段录制的扬声器模式通话的 10 分钟内容实时馈送到生产录音器,旁边是已预热的听写引擎(Parakeet unified English),每 30 秒进行一次 4 秒的听写。它直接馈送数据包,而非通过采集。词错误是针对较早 30 秒块生成的最终转录计算得出的。
Moonshine draft + Parakeet only,
30 s Parakeet chunks 3-12 s chunks
Meeting CPU 105% of a core 4.4%
Footprint, idle ~830 MB mean 1345, peak 1438 mean 854, peak 869 MB
Dictation release -> text p50 80, p95 93 ms p50 84, p95 89 ms
Speech end -> copyable p50 16.5, p95 28.7 s p50 6.3, p95 11.4 s
(Parakeet; the draft showed in about 1 s)
Option-Control-V text Moonshine, 17% WER Parakeet, 5.6% mid-call
Pasted vs final, Them/You 16.7% / 14.9% 0.0% / 0.0%, no repeats
Final transcript after flush 181 ms after Stop, 3.7% WER
听写延迟仍在运行间差异范围内(早先仅 Parakeet 的运行测得 p50 87-92 毫秒,p95 103-104 毫秒)。
回声抑制¶
Call on speakers -> the microphone hears the remote side tens of ms later
Each microphone segment // meetings.echo
├── Signal (meeting/echo): 20 ms energy of both tracks on one timeline
│ ├── Lag (-300..+600 ms) and coupling from envelope correlation over ±60 s,
│ │ cached per 10 s block once its audio is complete
│ │ └── Correlation < 0.2 (headphones) -> every sound is local
│ ├── Bleed gain = 35th percentile of mic − delayed system, before or after
│ └── Frame is local when mic > predicted bleed (decaying reverb) + 9 dB
│ ├── ≥ 50% local -> Local -> kept, even with matching words (double-talk)
│ ├── ≤ 20% local and no 0.5 s of local sound within 1 s -> Bleed -> dropped
│ └── otherwise -> decided by words
└── Words: ≥ 60% match Them speech within ±1.5 s (60% edit similarity) -> echo
Rolling and final: segments are sentences, grouped into utterances
└── An echo utterance keeps its sentences of 3+ words that the signal shows local
Applied in: rolling snapshot (so every in-call view) and the final transcript
被丢弃的计数会以 echo_suppressed_utterances 的形式保存在 manifest 中。
Kit 2026-10-16 通话中出现 You 孪生条目的根本原因(笔记本扬声器、内置麦克风、Pro 处于高负载状态):
- 实时 worker 的有界队列在 CPU 高负载下丢弃了 10 毫秒的麦克风数据包,并且到达时间间隔小于 20 毫秒的间隙未被计入。草稿的麦克风时间线在 18 分钟内漂移到了系统轨道前方最多 6 秒,因此回声包络比较了不相关的音频,bleed 被判定为
Local(84 行)或无法判定(70 行)。回声的You行出现在其Them孪生条目之前。现在数据包会携带它们的 WAV 采样位置。 - 滚动快照没有覆盖该段时间,因此草稿在最后一个保留的
You条目(616 秒)之后接管,尽管滚动已将那段音频判定为回声。 - 一条滚动
You条目可能在解释它的系统块之前就被发布。
通过选择加入的逐层回放(meeting_echo_replay、Mac Studio)对那段录音进行观察,发生在 Moonshine 草稿被移除之前(草稿行不再适用)。类回声 = 至少 60% 的词语出现在 6 秒时间窗内的远端语音中。Kit 真正说话的六条话语是手工标注的。
Pane as recorded (old code) 77 You entries, 49 echo-like
Draft only (rolling unavailable) 11 You, 2 echo-like, 2 of 6 local lost*
Pane, rolling caught up 10 You, 1 echo-like, 0 of 6 lost
Pane while recording, rolling 25-300 s behind: 25 ever shown, 3 echo-like
Final transcript 10 You, 1 echo-like, 0 of 6 lost
* Moonshine lines that mix bleed with Kit's sentence stay hidden until
rolling, which splits them into sentences, covers that time.
声学回声消除(Voice Processing IO)经过评估后未被采用:它用 AVAudioEngine/VPIO 输入替换 ScreenCaptureKit 的原始麦克风,压低其他音频,将蓝牙耳机切换到通话模式,为被转录的轨道添加增益和噪声处理,与通话应用自身的语音处理器并行运行,并丢失 ScreenCaptureKit 的共享时钟。参见 ROADMAP.md。
来源:meeting/transcript.rs、meeting/echo.rs、dictation.rs 中的 StreamingResampler。回声检查:speaker_bleed_is_classified_as_bleed、talking_over_the_remote_side_is_local、speech_during_remote_silence_is_local、headphones_make_every_microphone_sound_local、late_muffled_bleed_is_bleed_and_speech_over_it_is_local、a_short_phrase_inside_a_long_bleed_segment_is_not_bleed、the_signal_overrides_word_matching_both_ways、synthetic_speaker_bleed_is_dropped_and_double_talk_kept、a_local_sentence_inside_an_echo_utterance_is_kept、microphone_entries_wait_until_the_system_track_covers_them 和 timed_words_become_sentences_at_punctuation_and_pauses(合成信号)。其他检查:continuous_speech_is_hard_cut_with_overlap_and_covers_every_sample、chunks_end_at_the_first_pause_after_the_target_length、short_recordings_become_one_chunk_and_silence_tails_are_dropped、overlapping_chunks_join_at_the_middle_of_the_overlap、seam_drops_repeated_words、speechless_chunks_skip_the_model_and_still_advance_coverage、a_speechless_chunk_after_an_overlap_keeps_earlier_entries、a_sentence_across_a_hard_cut_keeps_every_word、rolling_publishes_only_entries_a_later_seam_cannot_replace、a_capture_gap_restarts_the_track_after_it_instead_of_dropping_it、a_copy_includes_speech_up_to_the_click、repeated_copies_neither_repeat_nor_drop_words_as_later_chunks_overlap_them、repeated_copies_cut_tracks_that_start_at_different_capture_times、a_copy_just_after_a_hard_cut_keeps_you_while_them_is_silent、a_copy_cut_holds_when_a_later_chunk_moves_the_words_slightly、a_track_rebuilt_from_its_wav_after_stop_keeps_the_copies_cuts、a_copy_waiting_when_capture_ends_is_still_answered、a_copy_stops_waiting_when_hex_quits、a_copy_that_runs_out_of_time_gets_no_reply、a_copy_waits_out_dictation_and_a_preempted_chunk、a_copy_reaches_only_the_recording_this_process_runs、the_feed_forwards_a_copy_after_the_audio_before_it、a_backlog_skips_the_oldest_audio_and_marks_the_track_for_rebuilding、silent_chunks_are_not_transcribed、speaker_echo_of_remote_speech_is_dropped、word_level_echo_is_dropped_as_one_utterance、distinct_or_distant_local_speech_is_kept、discontinuous_packets_are_placed_on_the_timeline 和 streaming_resampler_matches_whole_buffer_conversion。它们使用合成音频和转录文本,而不是模型推理或真实的说话人回声。
选择加入的测量(被忽略的测试;它们需要私有录音,并且永远不会纳入 CI):meeting_echo_replay(每一层、缓存转录文本、手工标注)、moonshine_command_update_latency 和 dictation_latency(位于 meeting/echo_replay_tests.rs),以及 meeting_live_replay(位于 meeting/live_replay_tests.rs):生产录音器与处于热状态的听写引擎并行,以实时速度输入一段录音,每 30 秒进行一次听写,报告 CPU、内存占用、听写延迟、语音到可复制文本的延迟,以及通话中途的 Copy all 和 Option-Control-V 文本与最终转录文本的对比结果。
保存与复用¶
Final publication // meetings.output
├── Internal: transcript.ndjson + transcript.md (atomic, owner-only)
└── Markdown copy -> <folder>/<YYYY-MM-DD HHMM> <title>.md
├── Title, date, duration, app, You / Them turns
├── Never overwrites; adds " (2)"
└── Failure -> Meeting still Complete; "Markdown not saved: …"
Transcript ready capsule (15 s) -> Copy (Markdown or speaker turns fallback) / Reveal / ✕ closes
Meetings pane -> Copy icon on a completed row's hover, the card's Copy transcript icon, Reveal Markdown (or Reveal Files when none)
Menu bar (idle) -> Copy Last Meeting Transcript
Option-Control-V -> `MeetingPasteCursor` in `meeting/delta.rs` over the transcript
│ through the press (meetings.copy-through-now)
├── Pastes turns added since the last successful paste
├── Runs in order with dictation output: a dictation finished during its
│ wait (at most 3 s) pastes after it
├── Remembers, per source, the capture time spans it pasted (spans within 1.5 s
│ merge); an entry is new when its midpoint is outside them, so nothing
│ repeats or drops when the final transcript replaces rolling after Stop, and
│ text the final pass recovers for audio rolling skipped still pastes
└── Resets when Hex restarts
会议面板¶
标题:开始录制(面板的唯一主按钮)/ 红点、已用时间及停止并转写(次要按钮),在右侧分组显示
列表(紧贴标题边缘,无分隔线;按今天/昨天/日期分组)
├── 搜索会议(标题、应用、笔记、最终转写文本)位于行上方
└── 行:标题(存在时生成)、右侧显示时间;元信息行
“录音中 · Zoom” / “失败 · 2 分钟” / “Zoom · 38 分钟”(录音中或失败时显示为红色);无悬停填充;
已完成的行在悬停时(仅指针操作)于时间位置显示复制图标(显示“已复制”并带对勾)
详情(列表旁的凸起卡片)
├── 标题、状态标签(就绪状态不显示)、日期 · 时间 · 时长 · 应用
├── 右上角幽灵图标:复制转写文本、重命名(仅已完成)、省略号“…”菜单
│ └── 复制摘要(保持打开以显示“已复制”)、显示 Markdown、生成摘要 /
│ 重新生成摘要(运行中显示“正在生成摘要…”并禁用)、分隔线、
│ 删除…(仅对已完成会议的最后三项操作有效)
├── 滚动区域顶部的错误信息
├── 摘要:一段文字,至多两句
├── 章节(存在时):开始时间、名称、分隔线及复制章节图标
└── 转写文本:逐条叠加显示,无气泡;“你”用文字颜色显示,“对方”用淡色显示,
各带时间戳;合并的发言;30 秒以上停顿线;录音中显示“正在聆听…”
空状态:“暂无会议。”并附一行提示;搜索时显示“无匹配会议。”
重命名 -> 行内输入框;回车或保存 // meetings.rename
-> 清单标题;仅当 Markdown 标题仍为旧标题时才更新标题
-> 文件名不变;空标题将被拒绝
删除…(溢出菜单)-> “将录音与 Markdown 移入废纸篓”(或“将录音移入废纸篓”)
/ 取消,显示在标题下方以替代图标位置,直至做出选择
-> 先处理导出的 Markdown,再处理 Application Support/meetings/<id>,均移入废纸篓
-> 录音中、转写中或写入笔记时拒绝删除 // meetings.delete
选择说明:删除操作会将 Markdown 副本一并移入废纸篓,因为它包含转写文本;
两者均可从废纸篓恢复。来源:rune_ui/meetings.rs、
rune_ui/macos_host.rs 中的请求,以及
meeting.rs 中的 trash /
rename。检查项:deleting_trashes_the_markdown_then_the_recording_and_refuses_while_recording、
renaming_updates_the_manifest_and_an_unedited_markdown_heading、
retitling_replaces_only_a_matching_first_heading,以及 Rune 面板测试
meetings_select_search_and_copy_a_transcript、
meetings_rename_from_the_title_field、
deleting_a_meeting_takes_the_menu_then_a_confirmation 和
the_overflow_menu_copies_the_summary_and_asks_for_new_notes。
场景:meetings、meetings-recording、meetings-actions、
meetings-delete、meetings-rename、meetings-failed、meetings-empty。
检查项:markdown_lists_metadata_and_labeled_turns 与
export_never_overwrites_and_uses_safe_names。
保留策略¶
会议数据是风险资产,因此新会议默认保留 24 小时。
会议 › 设置 -> 会议保留时长:24 小时 / 7 天 / 30 天 / 永久
│ `meetings.retention` 值为 day|week|month|forever,或未设置(未选择)
├── 未选择(默认)-> 自默认策略生效以来录制的会议
│ (清单中 `retention_applies`)保留 24 小时;更早的会议将一直保留
│ 直至用户做出选择(`meeting-retention-default` 迁移会将
│ #604 保存为默认值的 `forever` 取消设置)
└── 已选择 -> 适用于所有已完成的会议,包括更早的会议
每张已完成卡片 -> “24 小时后删除”(或 7 天、30 天)
会议控制器,无论会议开启或关闭 // meetings.retention
├── 在启动时、每小时以及设置变更后不久执行
├── 年龄依据 ended_at_ms,否则依据 created_at_ms;超过保留期限
│ -> 录音文件夹(音频、转写文本、笔记和摘要、实时草稿)移入废纸篓,而非直接删除
│ -> 导出的 Markdown 仅在 SHA-256 仍与 Hex 最后写入的副本匹配时
│ 才移入废纸篓(清单 `export_sha256`;Hex 自身的标题和笔记重写
│ 会使其保持最新状态);用户编辑过的副本将保留
├── 跳过录音中、转写中、笔记写入中以及存在 final.pending 的
│ 会议;后续轮次会重试这些项目
└── Hex 未运行时不会过期任何项目
查询(`hex meetings`、`hex mcp`、面板)-> 立即排除已过期会议,
早于后台清理任务处理;从未加载过设置的程序会
直接读取 settings.json 中的 `meetings.retention` 而不写入
来源:meeting.rs 中的 retention_for、
expired、expire 和 trash_expired,meeting/watcher.rs 中的 MeetingExpiry,meeting/query.rs 中的 Library::scan,以及 rune_ui/platform_settings.rs 中的对应行。
检查项:retention_expires_meetings_by_when_they_ended_and_skips_active_ones、
until_one_is_chosen_only_meetings_recorded_since_the_default_expire、
expiry_leaves_a_markdown_copy_the_user_edited 和
hex_rewriting_its_markdown_keeps_it_hexs_and_a_user_edit_does_not。尚未观察到
针对真实废纸篓执行过期清理的实际运行实例。
标题与摘要(Apple Intelligence)¶
录音中、设置开启、模型可用 // meetings.title.provisional
-> meeting-title 线程,utility 服务质量;每 10 秒检查转写文本
(滚动分块、回声过滤后)
-> 语音达到 ≥ 90 秒(或 ≥ 600 个词;任一方向 ≥ 60 个词)
-> 当听写繁忙时等待(≤30 秒)
-> 对到目前为止的转写文本发起仅 1 次的仅标题调用(≤5,000 字符,30 秒超时)
-> 录音器将 provisional_title 存入清单
-> 菜单栏显示“录音中 · <标题>”,会议面板列表与详情中同样显示
-> 语音达到 10 分钟时刷新一次;失败则在一分钟后重试;
每次会议至多调用 3 次;停止操作会丢弃进行中的调用
-> 导出的 Markdown 文件名与标题在标题先到时优先使用该标题
最终发布完成 // meetings.notes
-> meeting-notes 线程,utility 服务质量;听写繁忙时等待(≤30 秒)
-> 对受限摘录发起仅 1 次调用,不使用 map-reduce
├── 实验室 › 会议笔记词汇表 开启时(默认关闭;#1154)->
│ 在日历上下文之前加入“可能出现的名称和术语:…”(≤30 个词汇术语,
│ 每次生成时读取,包括重新生成);其长度计入预算。关闭 -> 提示词不变
├── 行格式“[mm:ss] 你|对方:文本”(长句在 600 字符处拆分)
├── 整个转写文本可容纳时使用全部;否则使用开头 30 %、至多六段
│ 均匀分布的中间段落、结尾 20 %,段落间以“[ … ]”分隔
├── 预算 = min(12,000 字符,(上下文 − 1,000 token)× 3 字符)
└── 上下文超出限制 -> 使用一半摘录重试一次
-> 总时限 90 秒;调用时长 ≤60 秒
-> 清单:notes { 标题 ≤8 个词, 摘要 ≤2 句 / 400 字符,
excerpt_chars, transcript_chars, written_by }
└── written_by: {"writer": "apple_intelligence"} 或 {"writer": "opencode",
"model": "provider/model"}(OpenCode 默认模型应答时不含 model);
#1125 之前写入的笔记中该字段不存在
-> Markdown 副本,同名文件,原子替换
├── 标题 -> 最终标题,仅在仍为 Hex 最后写入的标题时生效
├── <!-- hex:notes --> ## 摘要(一段)位于 ## 转写文本 之上
│ └── 页脚注明写入方:“由 Apple Intelligence 在此 Mac 上撰写”,
│ “由 <provider/model> 通过 OpenCode 撰写”,或者
│ 对于不含 written_by 的旧笔记,不显示写入方(#1125;
│ 检查项:the_notes_footer_names_the_writer_that_wrote_them)
├── 块外文本保留;写入中途发生变化 -> 不写入
└── 文件缺失 -> 仅写入清单
-> 失败 / 超时 -> notes_error;Markdown 保持原样;录音状态保持已完成
-> OpenCode 未选择模型时:排序后的模型按服务缓存;
任一模型失败即丢弃缓存(下次调用重新读取 OpenCode 目录),
全部模型失败时则回退到 OpenCode 自身的默认模型;因此在 OpenCode
仍在加载时读取的列表不再导致每次调用失败,直到重启才恢复
(#1123;检查项:stale_ranked_models_fall_back_to_the_default_model)
不可用(不符合条件 / Apple Intelligence 关闭 / 模型未就绪 / macOS < 26)
-> 设置行显示原因;不执行任何操作
会议面板 -> 列表与详情中显示标题;转写文本上方显示摘要段落;
溢出菜单中显示“生成摘要” / “重新生成摘要” / “正在生成摘要…”
转写文本就绪胶囊 -> 存在临时或最终标题时显示“就绪 · <标题>”;
副标题显示“正在使用 Apple Intelligence 生成摘要…”,随后显示摘要
标题规则:显示的标题是用户的标题,除非它仍然是默认的 <App> 通话 — 2:30 PM / 会议 — 2:30 PM;此时使用最终笔记标题,否则使用临时标题,再否则使用默认标题。已命名或重命名的会议保留其名称。文件永远不会被重命名,因此链接、已打开的编辑器和 Reveal 都能继续正常工作。
行动项和决策已移除:其他代理通过 MCP 读取转录记录以获取详细信息。旧版 manifest 仍可解码:列表摘要会合并为一个段落,行动项、决策和分块计数会被忽略(并在该 manifest 下次写入时被丢弃)。其 Markdown 副本在重新生成之前会保留旧的 notes 块。
来源:meeting/notes.rs、apple_intelligence.rs 以及 FoundationModels 桥接 apple_intelligence.swift(GeneratedNotes 生成标题 + 摘要,GeneratedTitle 仅生成标题),从 build.rs 弱链接;record 中的录音器循环位于 meeting.rs。meeting::notes::tests 中的检查(假模型):a_short_transcript_is_sent_whole_in_one_call、long_meetings_are_excerpted_from_the_opening_middle_and_ending、context_overflow_halves_the_excerpt_once_and_failures_stop、vocabulary_terms_lead_the_notes_prompt_and_share_its_budget、titles_are_requested_after_the_opening_then_once_at_ten_minutes、provisional_titles_use_the_title_prompt_over_the_transcript_so_far、generated_and_provisional_titles_replace_only_default_titles、older_notes_with_points_action_items_and_decisions_still_read、notes_are_inserted_above_the_transcript_and_regenerate_idempotently 和 user_edits_outside_the_notes_block_are_preserved;菜单文本位于 meeting_menu_offers_one_action_per_state。真实录音期间的录音器线程、manifest 写入和菜单/面板刷新未被覆盖。
真实模型(debug CLI):hex meeting summarize <file.md> [--excerpt-chars N] 会在录音请求临时标题的时点对临时标题计时,再对最终标题和摘要计时,但不保存;hex meeting summarize <id> 会像 Regenerate 一样写入它们。
观察结果(2026-10-15,Mac Studio M2 Max,macOS 27.0.1,上下文 8,192 tokens,debug 构建)基于一段合成的 50 分钟、7,700 词的转录记录(206 轮对话,46,000 字符):
Provisional title at 01:41 (261 words, 101 s of speech) 0.7 s (1.0 s first call)
Final title + summary, one call, 11,151-char excerpt 3.4–3.5 s (3 runs)
└── budget 9,000 -> 2.6–3.4 s; 18,000 -> 5.7 s
Before: map-reduce over 8 chunks, 9 calls, 51-minute call 96 s
实时章节(Labs)¶
一个 Labs 开关,默认关闭:实时章节(labs.live_chapters,在录音开始时读取)。关闭时,下面的一切都不会启动,会议行为与未开启时相同。
Recording starts, toggle on // meetings.chapters.live
-> meeting-chapters thread, utility QoS; checks the rolling transcript every 5 s
(nothing read while Apple Intelligence is unavailable or titles are off)
-> ≥ 3 min of new meeting time and ≥ 40 new words -> waits (≤30 s) for dictation
-> ONE call over only the new lines (≤3,600 chars, newest kept, "[…]" before)
├── Prompt: vocabulary terms (≤30, vocabulary experiment) -> new lines ->
│ "Current chapter: X"
└── Answer: a 2–5 word name, one line, and "same topic or not"
-> Rust decides, not the model's flag alone
├── First name -> chapter 1 at 00:00
├── Name sharing > half its words with this chapter's last 3 names, or any
│ word when the model says "same" -> stays (clears a pending departure)
├── Otherwise a departure; it opens a chapter, dated at its window's first
│ line, only when the next pass departs too and the current chapter has
│ lasted ≥ 90 s. A second departure naming yet another topic waits for
│ its own confirmation
└── Refused passage -> counted as read; failure -> retry after 1, 2 … 5 min
-> Recorder stores chapters in the manifest -> title, menu bar, pane
Stop
-> A departure still waiting for its second pass opens (≥ 90 s rule applies)
-> A pass finishing after Stop is discarded
After final publication, inside the notes thread // meetings.chapters.refine
-> One call per boundary, all within 30 s: lines (not merged turns) within
3 min of the live boundary, "Chapter before/after: name (line)"
-> Model lists the L numbers about the chapter after; Rust moves the boundary
to the split that disagrees with the fewest labels, only if the labels
cover at least half the lines after the live boundary and the new start
stays after the previous chapter's
-> chapters_refined = true; a failed call keeps its boundary
-> Each call first waits (≤30 s) for dictation; a stop or quit ends the pass
with nothing saved, so the next notes run redoes it
Copy section (pane, per chapter; menu bar "Copy Section" while recording;
`hex meetings show --chapter N`)
-> "## <name>\n\n<line>\n\n" + the transcript lines that start inside the
chapter, joined into turns only within it (one shared function)
标题:开启实时章节时,manifest 会记录 title_source(default、call、calendar、user);关闭时,该字段只会被写入到已经包含该字段或已有章节的 manifest。除非是 user,否则一旦存在两个章节,显示的标题将为基础标题加上按会议顺序排列的最多三个最长章节:"Team sync: image storage, motion lint, chat app name"(≤100 字符;会超出该上限的名称会被省略)。录音期间,最后一个章节会一直持续到当前时间。用户输入的标题(包括录音或转写期间的重命名)永远不会被扩展。当标题不是默认值时,没有该字段的 manifest 会被视为 user。
读取接口:hex meetings chapters <id> [--json]、hex meetings show <id>
--chapter N、show 中的 ## Chapters 列表、show --json 中的 chapters
(JSON 仍为版本 1),以及 MCP 的 get_chapters。
来源:meeting/chapters.rs(工作进程、决策、边界轮次、标题),apple_intelligence.swift 中的 GeneratedChapter / GeneratedChapterLines,meeting.rs 中的记录循环和 TitleSource。meeting::chapters::tests 中的检查(假模型):轮次计划与退避、首个章节、两轮之遥、最小长度、名称重叠与模型标志、拒绝、提示顺序、轮次窗口、在另一个主题上确认、停止打开等待中的出发、边界轮次(每个边界一次调用、不支持的回答保留、部分失败、停止的轮次不保存任何内容)、标题构建与录音标题、章节 Markdown,以及工作进程;meeting::tests::chapters_extend_every_title_but_one_the_user_typed;copy_section_matches_the_menu_bar_and_the_cli(macOS 主机:当一位说话者的发言跨越边界时,窗格、菜单栏和 CLI 给出相同的章节);copy_section_copies_one_chapter(仅限 Linux 的 Rune 测试)。菜单栏子菜单、真实录音期间录音器写入章节,以及 Mac 屏幕上的 meetings-chapters 场景未被演练。
真实模型(调试 CLI):hex meeting chapters <file.md> 会像录音时一样运行实时轮次,然后执行边界轮次,且不保存。
观察(2026-10-23,Mac mini,调试构建):在一个合成的 15 分钟、三个主题的转录文稿(真实切换发生在 05:32 和 11:02)上进行,5 次运行:轮次耗时 1.0–2.4 秒;边界轮次耗时 1.8–3.4 秒(2–3 次调用,每次约 1,500 个提示字符)。所有运行都发现了全部三个主题;5 次中有 2 次在 03:42 处额外生成了一个虚假的 “cdn cache headers” 章节。第二个切换在 3 次运行中落在 11:02,在 2 次运行中晚了一行(11:24);第一个在 2 次运行中落在 05:32,在 3 次运行中仍保持在录音时的 06:38。模型的同主题标志接近随机(对不相关名称输出 “same”,对当前主题输出 “new”),这正是 Rust 的名称重叠判定起作用的原因。
代理访问(MCP)¶
发布版 CLI 还通过此查询层支持 hex meetings list、show <id|latest>(带 --from/--to 偏移或 --chapter N)、chapters <id> 和 search <query>。另请参阅 transcript CLI 映射 和 版本化 JSON 契约。
hex mcp [--meetings-dir <path>] // meetings.mcp; release builds
├── MCP over stdio; independent of the Meetings opt-in (read-only)
├── Dispatches before process logging, the instance lock, GUI, and permissions
│ └── Safe beside the running app; logs only to stderr (HEX_MCP_LOG)
├── Default folder: Application Support/hex/meetings (hex on older builds)
│ └── --meetings-dir -> any mirrored copy (rsync of that folder)
├── A copy named hex-meetings-mcp -> always `hex mcp` with the same flags
│ (no subcommand never falls back to listening)
└── Tools (all readOnlyHint)
├── list_meetings(since?, until?, query?, limit?, include_in_progress?)
│ └── id · title (generated when present) · ISO start · mm:ss · status ·
│ transcript kind · participants "You, Them (<app>)" · notes yes/no
├── search_transcripts(query, since?, until?, meeting_id?, limit?, fuzzy?)
│ └── per-meeting counts + ranked passages: id, title, speaker,
│ mm:ss @ ISO time, snippet with **matches**, before/after lines
├── get_transcript(meeting_id, start?, end?, speaker?, max_chars?, cursor?)
│ ├── merged You/Them turns "[mm:ss · HH:MM:SS] Them: …" + next_cursor
│ └── start/end, or the same as from/to (own schema fields; not both):
│ entries overlapping the range; a start not before the end (an
│ omitted start is 0) or past the meeting's end is an error
├── get_meeting_notes(meeting_id)
│ └── stored title + two-sentence summary + "read the transcript for
│ details, decisions, and action items", or an explicit
│ "No Apple Intelligence notes are stored" + get_transcript hint
└── get_chapters(meeting_id) // Labs
└── "N. mm:ss–mm:ss · name" + line each, whether boundaries were
corrected, a get_transcript start/end hint; or "No chapters are
stored" ("No chapters yet" while recording); never generates any
Resources: hex-meeting://<id> -> Markdown (metadata, notes, transcript)
只读且私有:服务器仅以只读方式打开文件,绝不写入、修复或恢复 meetings 文件夹中的任何内容(不同于会持久化恢复状态的 meeting::list),不在磁盘上创建索引或缓存,不运行任何模型,也不发出任何网络请求。它从不读取 ~/Documents 中导出的 Markdown,因此 SSH 会话无需 Documents 访问权限。转录文稿仅通过 MCP 客户端使用的任何传输方式(stdio 或 SSH)离开 Mac。
并发:录音器持有 active.lock;服务器以只读、非阻塞的共享 flock 探测该锁,并立即释放。若被持有 -> 显示 recording (in progress),附带回声过滤后的滚动转录文稿,并标记为临时。若未被持有 -> 应用恢复规则在无写入的情况下生效:已发布的 transcript.ndjson 视为最终文本,未完成草稿标记为 interrupted。已结束录音留下的陈旧锁文件会被视为未持有。镜像无法看到 Pro 的锁,因此仍在该处录制中的会议会显示为 interrupted,并带有截至当前的滚动转录文稿。无法读取的 manifest 会被列为跳过的文件夹;无法读取的 NDJSON 行会被跳过并计数;未终止的最后一行会被视为正在写入中。
搜索:在内存中进行,当会议的文件大小或时间发生变化时,会按会议重建。段落是同一个说话者最多 60 个词的连续片段(3 秒轮换间隔),外加每个已存储笔记块一个段落。文本经过 NFKD 折叠(大小写和变音符号)、分词和轻度词干还原(scheduled/scheduling -> schedul)。对扩展词项采用 BM25(k1 1.2,b 0.75):精确匹配 1.0,前缀匹配 0.5,编辑距离 1(8 个字母及以上为 2)0.35,3–5 个字母词的 Soundex 0.2(Dacks -> Dax);部分词项覆盖以相同方式加权,相邻词项有加成。与每个词项完全匹配的段落排在所有其他结果之上。编辑距离和 Soundex 回退仅适用于精确匹配少于三个的段落词项,并且会跳过在所搜索段落中出现频率超过 0.5%(至少五个)的词,除非该词项没有任何精确命中且该词刚好相差一个编辑距离。这样一来,一旦 made、blog、Bob 和 believe 在三个段落中精确出现,它们就不会混入 media/blob 搜索,而罕见词的笔误仍能找到。词项在段落中的匹配形式首先是其精确词干(如果存在),然后是最高得分、类型和按字母顺序排在最前面的词干,因此平局是可确定的。"Quoted words" 是必须精确匹配(词干化后)的短语;fuzzy: false 只保留精确词干。hex history --search 共用这个排序器,将条目的原始文本和最终文本视为一个文档。
语义搜索尚未随版本提供。端侧候选方案是 Apple NaturalLanguage 的 NLContextualEmbedding(需下载资源,macOS 14+)或更老的静态 NLEmbedding 句子模型,通过类似 apple_speech 的 Swift 桥接实现,并在 ~/Library/Caches/hex/meeting-index 下按会议指纹建立缓存。下面的真实查询由带词干提取和模糊匹配的全文检索来回答;嵌入方案会留待某个查询因缺乏同义改写而失败时再启用。
来源:meeting/query.rs(meeting::query,查询与渲染)和 meeting/mcp.rs(rmcp 3.5 适配器);run_mcp 位于 main.rs。检查项(tests/fixtures/meetings 与 tests/fixtures/meetings-ranking 中的合成夹具):meeting::query::tests::*(列出与被跳过的文件夹、过滤器、拼写容错与排序、词干提取、重音、必需短语、上下文、草稿、游标翻页、笔记、持有的锁、queries_never_write_to_the_meetings_folder、时间与严格偏移解析、针对最终/滚动/进行中转录文本的偏移范围(包括 “so far” 错误)、exact_stems_outrank_loose_matches_to_common_words、passages_matching_every_term_exactly_rank_first、an_all_exact_passage_outranks_a_higher_scoring_partial_one、a_term_found_in_fewer_passages_still_reaches_its_neighbours、a_misspelled_rare_word_still_finds_its_passage 和 spelling_fallbacks_need_rare_terms_and_uncommon_candidates)以及 tests/meetings_mcp.rs(使用 2025-06-18 协议初始化、带有 schemas(包括 from/to)和只读注解的 tools/list、tools/call,包括 from/to 范围以及 null start/end 及其错误、resources/read、stdout 仅携带 JSON-RPC,未创建 Application Support 状态)。
观察(2026-10-23,Mac Studio,debug 构建,真实 meetings 文件夹,只读):hex meetings search "images database blob storage media" 匹配段落从 315 条减少到 140 条,前 25 条中没有仅靠模糊匹配得到的命中(以前有四条,分别经由 blog、blow、believe、made 和 motto);最顶部命中仍是进行中会议 115:02 处的段落,前 10 条中有 6 条落在该会议 113:03–120:43 的存储讨论中。show <id> --from 113:03 --to 120:43 以 Markdown 和 JSON 返回了该区间的 168 条滚动记录。
观察(2026-10-15,Mac Studio,release 构建,Pro 的 8 个会议的只读副本(不含 WAV)):新签名二进制首次启动到 initialize 需要 440 ms(Gatekeeper),之后为 8 ms;list_meetings 冷启动 4–7 ms;搜索 0.5–1.2 ms;51 分钟的 get_transcript 用时 0.5 ms;resources/read 0.6 ms。
另一台 Mac 的会议(镜像)¶
Don't: MCP client config runs `ssh pro hex mcp` // one SSH per client
-> 41 OpenCode instances = 41 SSH sessions -> the Pro's sshd reset connections
Do: one periodic pull, then local servers // meetings.mcp.mirror
scripts/sync-meetings-mirror.sh (LaunchAgent template, every 120 s,
ProcessType Background, Nice 10, LowPriorityIO)
├── ONE rsync over ssh (BatchMode, ConnectTimeout 5, --timeout 60)
│ pro:Library/Application Support/hex/meetings/
│ -> ~/Library/Caches/hex-meetings-mirror/ (0700)
├── Skips *.wav, *.lock, *.tmp; --delay-updates (files land together at the
│ end); --delete-delay (meetings trashed on the Pro disappear)
└── Pro asleep / unreachable -> exit 0 silently; next run retries
Each MCP client -> local `hex mcp --meetings-dir <mirror>` (no SSH, no lock)
└── No lock in the mirror -> a meeting still recording on the Pro reads as
`interrupted` with its rolling transcript so far; final once published
模板:hex-meetings-mirror.plist(包含 checkout 和 home 的占位符;不会自动安装)。
观察(2026-10-15,Studio 从 Pro 拉取):一次同步 0.44 秒,8 个会议,1.0 MB,没有 WAV 或锁;不可达的主机立即以 0 退出,无响应的地址在 5 秒连接超时后以 0 退出。hex-meetings-mcp --meetings-dir(~/.local/bin 中的 release 构建)通过 stdio:新二进制首次启动到 initialize 需要 378 ms(Gatekeeper),之后 7 ms;list_meetings 冷启动 3.5–11 ms,热启动 0.5 ms;搜索 0.6–1.5 ms;48,000 字符的 get_transcript 用时 0.6 ms;get_meeting_notes ≤0.3 ms,包括带 action items 的旧 manifest;resources/read 0.5 ms。40 个并行实例:全部初始化(中位数 13 ms,最大 22 ms)、列出并以 0 退出,总耗时 76 ms。LaunchAgent 未加载,因此从 launchd 访问 ssh-agent 尚未验证。
Granola 导入:参见 granola-import.md。
未覆盖项¶
- 本次变更中没有用真实通话执行端到端录音,也没有屏幕录制授权或扬声器回声测试;请参见交接文档中的测试脚本。
- 进程 tap、其“系统音频录制”提示以及显示休眠恢复都未在已录音会议中运行过。TCC 状态通过私有
TCCAccessPreflight获取;否则,若 tap 被拒绝,就会录下静音。 - 麦克风和系统音频现在运行在独立的设备时钟上,按到达时间对齐;低于 1 秒间隙阈值的漂移未被校正,回声阈值是在 ScreenCaptureKit 的共享时钟上调校的。
- 日历标题(EventKit)尚未实现。
- 通话内文本比语音滞后一个 chunk 及其稳定时间(数秒,参见回放数字);在此之前不会显示部分句子。落后超过 120 秒的音轨会跳过音频,直到 Stop 重新构建它。
- 录音胶囊的 Copy new / Copy all 尚未在真实 Mac 屏幕上验证;其计数可能比转录文本滞后最多一秒。
- 通过点击进行的复制仅用假模型检查过;其在真实 Mac 上的延迟以及 3 秒等待超时的频率尚未测量。在
transcribe中把复制的剪切从滚动 worker 交给 WAV 重建,这一过程本身没有测试;带剪切的重建是有测试的。复制会等待下一个音频包到达,因此当两个源都停止时,它会超时并复制通话内转录文本。 - Meetings 面板通过 Rune golden 基准图在屏外渲染验证(在 Linux 和 hailmary 的 Metal 上),而非在 Mac 屏幕上;胶囊没有 golden。
- 回声阈值是在两次扬声器模式通话(2026-10-15 和 2026-10-16)上调校的,使用了六条手动标注的本地语音;其他房间、外部扬声器和蓝牙尚未测量。2026-10-16 的回放音频随 Studio 的临时目录一起丢失,因此这些数字无法重新运行。应用中的真实 NSFileManager 移入废纸篓和原生重命名字段均未经过验证。
- CPU、内存和延迟数据来自 Pro 上的回放测试框架,而非应用中带真实采集和快捷键听写的录音。
- 短分块的滚动转录文本尚未在真实通话中运行。
- 滚动 worker 在 Pro 上停顿超过一分钟的原因尚不清楚;现在会记录慢 chunk(> 5 秒)。
- 标题和摘要尚未在应用中的真实录音之后运行;CLI 路径使用了真实模型,而临时标题线程、其 manifest 写入以及菜单/面板刷新在实时录音中均未经过验证。
- 实时章节(Labs)尚未在真实录音中运行:worker、manifest 写入、标题更新、菜单栏 Copy Section 以及发布后的边界处理只在单元测试和调试 CLI 中,针对一份合成转录文本运行过。阈值(3 分钟通过、最短 90 秒、名称重叠)是在该转录文本上调校的;模型的同主题标志不可靠,短暂偏离仍可能成为独立章节。边界处理只在会议笔记运行时、发布之后执行。
本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 hex19/hex