mixdog 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/package.json +10 -3
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/background-task-meta-smoke.mjs +1 -1
  6. package/scripts/bench-run.mjs +262 -0
  7. package/scripts/compact-smoke.mjs +12 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  9. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  10. package/scripts/internal-comms-bench.mjs +727 -0
  11. package/scripts/internal-comms-smoke.mjs +75 -0
  12. package/scripts/lead-workflow-smoke.mjs +4 -4
  13. package/scripts/live-worker-smoke.mjs +9 -9
  14. package/scripts/output-style-bench.mjs +285 -0
  15. package/scripts/output-style-smoke.mjs +13 -10
  16. package/scripts/patch-replay.mjs +90 -0
  17. package/scripts/provider-stream-stall-test.mjs +276 -0
  18. package/scripts/provider-toolcall-test.mjs +599 -1
  19. package/scripts/routing-corpus.mjs +281 -0
  20. package/scripts/session-bench.mjs +1526 -0
  21. package/scripts/session-diag.mjs +595 -0
  22. package/scripts/session-ingest-smoke.mjs +2 -2
  23. package/scripts/task-bench.mjs +207 -0
  24. package/scripts/tool-failures.mjs +6 -6
  25. package/scripts/tool-smoke.mjs +306 -66
  26. package/scripts/toolcall-args-test.mjs +81 -0
  27. package/src/agents/debugger/AGENT.md +4 -4
  28. package/src/agents/heavy-worker/AGENT.md +4 -2
  29. package/src/agents/reviewer/AGENT.md +4 -4
  30. package/src/agents/worker/AGENT.md +4 -2
  31. package/src/app.mjs +10 -6
  32. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  33. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  34. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  35. package/src/headless-role.mjs +14 -14
  36. package/src/help.mjs +1 -0
  37. package/src/lib/mixdog-debug.cjs +0 -22
  38. package/src/lib/plugin-paths.cjs +1 -7
  39. package/src/lib/rules-builder.cjs +34 -56
  40. package/src/mixdog-session-runtime.mjs +710 -319
  41. package/src/output-styles/default.md +12 -7
  42. package/src/output-styles/minimal.md +25 -0
  43. package/src/output-styles/oneline.md +21 -0
  44. package/src/output-styles/simple.md +10 -9
  45. package/src/repl.mjs +12 -4
  46. package/src/rules/agent/00-common.md +7 -5
  47. package/src/rules/agent/30-explorer.md +7 -8
  48. package/src/rules/lead/01-general.md +3 -1
  49. package/src/rules/lead/lead-tool.md +7 -0
  50. package/src/rules/shared/01-tool.md +17 -12
  51. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  52. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  53. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  54. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  55. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  56. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  57. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  58. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  59. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  60. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  61. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  62. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  63. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  64. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  65. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +359 -106
  66. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +63 -51
  67. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  68. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  69. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  70. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  71. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  72. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +86 -30
  73. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +254 -280
  74. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +191 -50
  75. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  76. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  77. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  78. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +265 -1
  79. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  80. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  81. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  82. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  83. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  84. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  85. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  86. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  87. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +1 -1
  88. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -32
  89. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  90. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -44
  91. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  92. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  93. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  94. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  95. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  100. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  101. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  102. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  103. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  104. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  105. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  106. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  107. package/src/runtime/channels/backends/discord.mjs +99 -9
  108. package/src/runtime/channels/backends/telegram.mjs +501 -0
  109. package/src/runtime/channels/index.mjs +441 -1254
  110. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  111. package/src/runtime/channels/lib/config.mjs +54 -3
  112. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  113. package/src/runtime/channels/lib/executor.mjs +0 -3
  114. package/src/runtime/channels/lib/format.mjs +4 -2
  115. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  116. package/src/runtime/channels/lib/output-forwarder.mjs +77 -71
  117. package/src/runtime/channels/lib/runtime-paths.mjs +29 -6
  118. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  119. package/src/runtime/channels/lib/session-discovery.mjs +0 -4
  120. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  121. package/src/runtime/channels/lib/tool-format.mjs +1 -2
  122. package/src/runtime/channels/lib/transcript-discovery.mjs +20 -11
  123. package/src/runtime/channels/lib/webhook.mjs +59 -31
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  126. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  127. package/src/runtime/memory/index.mjs +187 -43
  128. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  129. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  130. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  131. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  132. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  133. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  134. package/src/runtime/memory/lib/memory-ops-policy.mjs +0 -1
  135. package/src/runtime/memory/lib/memory.mjs +101 -4
  136. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  137. package/src/runtime/memory/lib/runtime-fetcher.mjs +43 -18
  138. package/src/runtime/memory/lib/session-ingest.mjs +116 -7
  139. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  140. package/src/runtime/memory/tool-defs.mjs +6 -3
  141. package/src/runtime/search/index.mjs +2 -7
  142. package/src/runtime/search/lib/config.mjs +0 -4
  143. package/src/runtime/search/lib/state.mjs +1 -15
  144. package/src/runtime/search/lib/web-tools.mjs +0 -1
  145. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  146. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  147. package/src/runtime/shared/child-spawn-gate.mjs +0 -6
  148. package/src/runtime/shared/config.mjs +9 -0
  149. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  150. package/src/runtime/shared/schedules-store.mjs +21 -19
  151. package/src/runtime/shared/tool-surface.mjs +98 -13
  152. package/src/runtime/shared/transcript-writer.mjs +129 -0
  153. package/src/runtime/shared/update-checker.mjs +214 -0
  154. package/src/standalone/agent-tool.mjs +255 -109
  155. package/src/standalone/channel-admin.mjs +133 -40
  156. package/src/standalone/channel-worker.mjs +8 -291
  157. package/src/standalone/explore-tool.mjs +2 -2
  158. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  159. package/src/standalone/provider-admin.mjs +11 -0
  160. package/src/standalone/seeds.mjs +1 -11
  161. package/src/standalone/usage-dashboard.mjs +1 -1
  162. package/src/tui/App.jsx +2137 -750
  163. package/src/tui/components/ConfirmBar.jsx +47 -0
  164. package/src/tui/components/ContextPanel.jsx +5 -3
  165. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  166. package/src/tui/components/Markdown.jsx +22 -98
  167. package/src/tui/components/Message.jsx +14 -35
  168. package/src/tui/components/Picker.jsx +87 -12
  169. package/src/tui/components/PromptInput.jsx +146 -9
  170. package/src/tui/components/QueuedCommands.jsx +1 -1
  171. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  172. package/src/tui/components/Spinner.jsx +7 -7
  173. package/src/tui/components/StatusLine.jsx +40 -21
  174. package/src/tui/components/TextEntryPanel.jsx +51 -7
  175. package/src/tui/components/ToolExecution.jsx +177 -100
  176. package/src/tui/components/TurnDone.jsx +4 -4
  177. package/src/tui/components/UsagePanel.jsx +1 -1
  178. package/src/tui/components/tool-output-format.mjs +312 -40
  179. package/src/tui/components/tool-output-format.test.mjs +180 -1
  180. package/src/tui/display-width.mjs +69 -0
  181. package/src/tui/display-width.test.mjs +35 -0
  182. package/src/tui/dist/index.mjs +7324 -2393
  183. package/src/tui/engine.mjs +287 -126
  184. package/src/tui/index.jsx +117 -7
  185. package/src/tui/keyboard-protocol.mjs +42 -0
  186. package/src/tui/lib/voice-recorder.mjs +453 -0
  187. package/src/tui/markdown/format-token.mjs +354 -142
  188. package/src/tui/markdown/format-token.test.mjs +155 -17
  189. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  190. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  191. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  192. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  193. package/src/tui/markdown/table-layout.mjs +9 -9
  194. package/src/tui/paste-attachments.mjs +0 -11
  195. package/src/tui/prompt-history-store.mjs +129 -0
  196. package/src/tui/prompt-history-store.test.mjs +52 -0
  197. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  198. package/src/tui/theme.mjs +41 -647
  199. package/src/tui/themes/base.mjs +86 -0
  200. package/src/tui/themes/basic.mjs +85 -0
  201. package/src/tui/themes/catppuccin.mjs +72 -0
  202. package/src/tui/themes/dracula.mjs +70 -0
  203. package/src/tui/themes/everforest.mjs +71 -0
  204. package/src/tui/themes/gruvbox.mjs +71 -0
  205. package/src/tui/themes/index.mjs +71 -0
  206. package/src/tui/themes/indigo.mjs +78 -0
  207. package/src/tui/themes/kanagawa.mjs +80 -0
  208. package/src/tui/themes/light.mjs +81 -0
  209. package/src/tui/themes/nord.mjs +72 -0
  210. package/src/tui/themes/onedark.mjs +16 -0
  211. package/src/tui/themes/rosepine.mjs +70 -0
  212. package/src/tui/themes/teal.mjs +81 -0
  213. package/src/tui/themes/tokyonight.mjs +79 -0
  214. package/src/tui/themes/utils.mjs +106 -0
  215. package/src/tui/themes/warm.mjs +79 -0
  216. package/src/tui/transcript-tool-failures.mjs +13 -2
  217. package/src/ui/markdown.mjs +1 -1
  218. package/src/ui/model-display.mjs +2 -2
  219. package/src/ui/statusline.mjs +26 -27
  220. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -623
  221. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  222. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  223. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  224. package/src/workflows/default/WORKFLOW.md +39 -12
  225. package/src/workflows/sequential/WORKFLOW.md +46 -0
  226. package/src/workflows/solo/WORKFLOW.md +7 -0
  227. package/vendor/ink/build/display-width.js +62 -0
  228. package/vendor/ink/build/ink.js +154 -20
  229. package/vendor/ink/build/measure-text.js +4 -1
  230. package/vendor/ink/build/output.js +115 -9
  231. package/vendor/ink/build/render-node-to-output.js +4 -1
  232. package/vendor/ink/build/render.js +4 -0
  233. package/src/hooks/lib/permission-rules.cjs +0 -170
  234. package/src/hooks/lib/settings-loader.cjs +0 -112
  235. package/src/lib/hook-pipe-path.cjs +0 -10
  236. package/src/output-styles/extreme-simple.md +0 -20
  237. package/src/rules/lead/04-workflow.md +0 -51
  238. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  239. package/src/workflows/default/workflow.json +0 -13
  240. package/src/workflows/solo/workflow.json +0 -7
@@ -0,0 +1,453 @@
1
+ /**
2
+ * src/tui/lib/voice-recorder.mjs — TUI-local voice install/toggle orchestration.
3
+ *
4
+ * Step1 (/voice command): owns the `voice.enabled` config flag and drives the
5
+ * managed whisper.cpp + ffmpeg runtime install (voice-runtime-fetcher.mjs) the
6
+ * first time voice is turned ON.
7
+ *
8
+ * Step2 (Ctrl+Space recorder): startRecording/stopRecording/cancelRecording
9
+ * drive a small idle -> recording -> transcribing -> idle state machine.
10
+ * Recording spawns ffmpeg against a DirectShow ("dshow") capture device (the
11
+ * only capture API implemented — mixdog's managed voice runtime targets
12
+ * win32; the enumerate/record commands below are DirectShow-specific).
13
+ * Transcription reuses the SAME whisper-server.mjs singleton manager the
14
+ * channels pipeline uses (ensureReady + transcribe), so a TUI-triggered
15
+ * transcription and a Discord-attachment transcription share one warm
16
+ * whisper-server child instead of racing two.
17
+ *
18
+ * Config load is a STATIC import here (approved design decision) — unlike
19
+ * theme.mjs's dist-aware dynamic import of config.mjs, this file is not on the
20
+ * TUI's hot boot path and esbuild bundling config.mjs's relative dependency
21
+ * graph (plugin-paths/atomic-file/user-data-guard + the keychain .cjs via
22
+ * createRequire) into src/tui/dist/index.mjs is harmless: config.mjs's
23
+ * createRequire('../../lib/keychain-cjs.cjs') resolves relative to
24
+ * import.meta.url, and src/tui/dist/ sits at the same depth-from-src as
25
+ * src/runtime/shared/ (2 levels), so the relative path still lands on
26
+ * src/lib/keychain-cjs.cjs after bundling.
27
+ *
28
+ * The whisper.cpp/ffmpeg runtime installer (voice-runtime-fetcher.mjs) and
29
+ * the whisper-server manager (whisper-server.mjs) are BOTH loaded lazily via
30
+ * dynamic import — neither should load into memory unless a user actually
31
+ * asks for voice.
32
+ */
33
+ import { spawn, spawnSync } from 'node:child_process';
34
+ import { cpus, tmpdir } from 'node:os';
35
+ import { join } from 'node:path';
36
+ import { unlinkSync } from 'node:fs';
37
+ import { readSection, updateSection } from '../../runtime/shared/config.mjs';
38
+ import { resolvePluginData } from '../../runtime/shared/plugin-paths.mjs';
39
+
40
+ let _voiceRuntimeFetcherPromise = null;
41
+ function loadVoiceRuntimeFetcher() {
42
+ if (!_voiceRuntimeFetcherPromise) {
43
+ _voiceRuntimeFetcherPromise = import('../../runtime/channels/lib/voice-runtime-fetcher.mjs');
44
+ }
45
+ return _voiceRuntimeFetcherPromise;
46
+ }
47
+
48
+ let _whisperServerPromise = null;
49
+ // Set once loadWhisperServer()'s import resolves, so disposeRecorder() (a
50
+ // SYNC function called from a React unmount cleanup) can check "was the
51
+ // whisper-server manager ever engaged this session" without triggering a
52
+ // fresh dynamic import just to shut down something that was never started.
53
+ let _whisperServerModule = null;
54
+ function loadWhisperServer() {
55
+ if (!_whisperServerPromise) {
56
+ _whisperServerPromise = import('../../runtime/channels/lib/whisper-server.mjs').then((mod) => {
57
+ _whisperServerModule = mod;
58
+ return mod;
59
+ });
60
+ }
61
+ return _whisperServerPromise;
62
+ }
63
+
64
+ // Reentrancy guard: only one install (ensureWhisperRuntime/ensureWhisperModel/
65
+ // ensureFfmpegRuntime sequence) may run at a time per process.
66
+ let _voiceInstallBusy = false;
67
+
68
+ /** True while a Step1 install sequence is in flight. */
69
+ export function isVoiceInstallBusy() {
70
+ return _voiceInstallBusy;
71
+ }
72
+
73
+ /** Read `voice.enabled` from mixdog-config.json (top-level `voice` section). */
74
+ export function isVoiceEnabled() {
75
+ try {
76
+ return readSection('voice')?.enabled === true;
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
82
+ // Coalesce ensure*'s onProgress ({ phase, downloaded, total } | { phase:'extra', ... })
83
+ // into a throttled (2s) human-readable pushNotice call so a multi-hundred-MB
84
+ // model download doesn't spam the notice toast on every chunk.
85
+ function makeThrottledProgressNotice(pushNotice, intervalMs = 2000) {
86
+ let lastEmitAt = 0;
87
+ const phaseLabel = (phase) => {
88
+ if (phase === 'model') return 'model';
89
+ if (phase === 'ffmpeg') return 'ffmpeg';
90
+ if (phase === 'extra') return 'extra data';
91
+ return 'whisper runtime';
92
+ };
93
+ return (progress = {}) => {
94
+ if (typeof pushNotice !== 'function') return;
95
+ const now = Date.now();
96
+ if (now - lastEmitAt < intervalMs) return;
97
+ lastEmitAt = now;
98
+ const total = Number(progress.total) || 0;
99
+ const downloaded = Number(progress.downloaded) || 0;
100
+ const label = phaseLabel(progress.phase);
101
+ const text = total > 0
102
+ ? `Voice: downloading ${label}… ${Math.min(100, Math.round((downloaded / total) * 100))}%`
103
+ : `Voice: downloading ${label}…`;
104
+ pushNotice(text, 'info');
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Ensure every managed voice-runtime component (whisper binary+server,
110
+ * whisper model, ffmpeg) is installed. Installs ONLY the missing pieces —
111
+ * an already-installed component is never re-fetched. Returns the resolved
112
+ * runtime descriptor (resolveVoiceRuntime shape) once `installed` is true;
113
+ * throws on any ensure* failure (manifest fetch, sha256 mismatch, etc.).
114
+ */
115
+ export async function ensureVoiceRuntimeReady({ dataDir = resolvePluginData(), pushNotice } = {}) {
116
+ const fetcher = await loadVoiceRuntimeFetcher();
117
+ let runtime = fetcher.resolveVoiceRuntime(dataDir);
118
+ if (runtime.installed) return runtime;
119
+
120
+ const onProgress = makeThrottledProgressNotice(pushNotice);
121
+ if (!runtime.binary || !runtime.serverCmd) {
122
+ await fetcher.ensureWhisperRuntime(dataDir, onProgress);
123
+ }
124
+ if (!runtime.model) {
125
+ await fetcher.ensureWhisperModel(dataDir, onProgress);
126
+ }
127
+ if (!runtime.ffmpeg) {
128
+ await fetcher.ensureFfmpegRuntime(dataDir, onProgress);
129
+ }
130
+
131
+ runtime = fetcher.resolveVoiceRuntime(dataDir);
132
+ if (!runtime.installed) {
133
+ throw new Error('voice runtime install did not complete (still missing a required component)');
134
+ }
135
+ return runtime;
136
+ }
137
+
138
+ /**
139
+ * /voice command entry point. OFF -> immediately flips `voice.enabled` to
140
+ * false and notices "Voice OFF" (no install work, no busy gate — a disable
141
+ * must never be blocked by an in-flight install of a DIFFERENT toggle-on).
142
+ * ON -> busy-guarded: checks resolveVoiceRuntime, installs only missing
143
+ * components, then persists `voice.enabled: true` and notices "Voice ON —
144
+ * Ctrl+Space to record". Any ensure* failure notices the cause and leaves
145
+ * `voice.enabled` untouched (still off).
146
+ *
147
+ * Returns the NEW enabled state (true/false) on success, or null when the
148
+ * toggle could not run (install already in flight) or failed.
149
+ */
150
+ export async function toggleVoice({ pushNotice } = {}) {
151
+ const dataDir = resolvePluginData();
152
+ if (isVoiceEnabled()) {
153
+ // Med-5: updateSection is a synchronous file write (readAllForRmW +
154
+ // atomic write under a file lock) and can throw — a locked/corrupt
155
+ // config, a permissions error, etc. An unguarded throw here would
156
+ // propagate out of the OFF branch uncaught (toggleVoice's try/catch
157
+ // below only wraps the ON path) and crash the caller. Guard it the
158
+ // same way the ON path is guarded, and report failure via pushNotice
159
+ // instead of silently pretending the toggle succeeded.
160
+ try {
161
+ updateSection('voice', (current) => ({ ...current, enabled: false }));
162
+ } catch (err) {
163
+ pushNotice?.(`Voice OFF failed: ${err?.message || err}`, 'error');
164
+ return { ok: false, error: err?.message || String(err) };
165
+ }
166
+ pushNotice?.('Voice OFF', 'info');
167
+ return false;
168
+ }
169
+ if (_voiceInstallBusy) {
170
+ pushNotice?.('Voice install is already running', 'warn');
171
+ return null;
172
+ }
173
+ _voiceInstallBusy = true;
174
+ try {
175
+ await ensureVoiceRuntimeReady({ dataDir, pushNotice });
176
+ updateSection('voice', (current) => ({ ...current, enabled: true }));
177
+ pushNotice?.('Voice ON — Ctrl+Space to record', 'info');
178
+ return true;
179
+ } catch (err) {
180
+ pushNotice?.(`Voice setup failed: ${err?.message || err}`, 'error');
181
+ return null;
182
+ } finally {
183
+ _voiceInstallBusy = false;
184
+ }
185
+ }
186
+
187
+ // ── Step2: idle -> recording -> transcribing -> idle ────────────────────────
188
+
189
+ const RECORDER_STATE = Object.freeze({
190
+ IDLE: 'idle',
191
+ STARTING: 'starting',
192
+ RECORDING: 'recording',
193
+ TRANSCRIBING: 'transcribing',
194
+ });
195
+
196
+ // Module-scoped: the TUI has exactly one prompt/recorder, so one singleton
197
+ // state machine (mirrors whisper-server.mjs's own single-manager pattern).
198
+ const rec = {
199
+ state: RECORDER_STATE.IDLE,
200
+ child: null, // ffmpeg ChildProcess while RECORDING
201
+ wavPath: null, // tmp wav path for the in-flight/just-finished recording
202
+ };
203
+
204
+ /** Current recorder state — 'idle' | 'recording' | 'transcribing'. */
205
+ export function getRecorderState() {
206
+ return rec.state;
207
+ }
208
+
209
+ function newTmpWavPath() {
210
+ return join(tmpdir(), `mixdog-rec-${Date.now()}.wav`);
211
+ }
212
+
213
+ // Resolve the ffmpeg binary: prefer the managed runtime (Step1 install),
214
+ // fall back to a bare 'ffmpeg' on PATH (spawn resolves PATH itself when no
215
+ // path separator is present), so a user with a system ffmpeg install still
216
+ // gets Ctrl+Space without running /voice's installer.
217
+ async function resolveFfmpegCmd(dataDir) {
218
+ const fetcher = await loadVoiceRuntimeFetcher();
219
+ const managed = fetcher.resolveManagedFfmpegPath(dataDir);
220
+ if (managed) return managed;
221
+ const probe = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['ffmpeg'], { windowsHide: true });
222
+ if (probe.status === 0) return 'ffmpeg';
223
+ return null;
224
+ }
225
+
226
+ // Enumerate DirectShow audio-capture devices via
227
+ // `ffmpeg -hide_banner -list_devices true -f dshow -i dummy`, which ffmpeg
228
+ // (by design) always exits non-zero on while printing the device list to
229
+ // stderr. Returns the first "(audio)" device name, or null when none is
230
+ // found / the probe fails. UTF-8 decoded (device names may be non-ASCII on
231
+ // a localized Windows install).
232
+ function listFirstDshowAudioDevice(ffmpegCmd) {
233
+ const r = spawnSync(ffmpegCmd, ['-hide_banner', '-list_devices', 'true', '-f', 'dshow', '-i', 'dummy'], {
234
+ windowsHide: true,
235
+ encoding: 'utf8',
236
+ });
237
+ const text = `${r.stderr || ''}${r.stdout || ''}`;
238
+ // ffmpeg prints device lines like: "Mic Name" (audio)
239
+ const lines = text.split(/\r?\n/);
240
+ for (const line of lines) {
241
+ const m = /"([^"]+)"\s*\(audio\)/.exec(line);
242
+ if (m) return m[1];
243
+ }
244
+ return null;
245
+ }
246
+
247
+ /**
248
+ * Begin recording. IDLE -> RECORDING. No-op (returns null) unless the
249
+ * recorder is currently idle. Claims the IDLE -> STARTING transition
250
+ * SYNCHRONOUSLY (before any `await`) so a second Ctrl+Space fired while
251
+ * ffmpeg/device resolution is still in flight sees STARTING (not IDLE) and
252
+ * is a no-op rather than racing a second spawn onto the same rec.* fields.
253
+ * Resolves ffmpeg (managed -> PATH fallback), enumerates the first dshow
254
+ * audio-capture device, and spawns ffmpeg writing 16kHz mono PCM to a fresh
255
+ * tmp wav. `-y` overwrites (tmp path is always fresh so this is defensive,
256
+ * not load-bearing). Any failure on the STARTING path (no ffmpeg, no
257
+ * device, spawn throw) reverts to IDLE before returning.
258
+ *
259
+ * Returns `{ ok: true }` on a successful spawn, `{ ok: false, reason }` on
260
+ * any failure (no ffmpeg, no device, spawn error) — the caller (App.jsx)
261
+ * turns `reason` into a user-facing notice — or `null` when the recorder
262
+ * was not IDLE (already starting/recording/transcribing): a silent no-op,
263
+ * matching stopRecording's null-for-wrong-state contract.
264
+ */
265
+ export async function startRecording({ dataDir = resolvePluginData() } = {}) {
266
+ if (rec.state !== RECORDER_STATE.IDLE) return null;
267
+ // Claim the slot BEFORE the first await — see High-1 in the doc comment
268
+ // above. Everything below this line runs against an already-STARTING
269
+ // rec.state, so a concurrent startRecording() call bails out via the
270
+ // guard above instead of racing a second ffmpeg spawn.
271
+ rec.state = RECORDER_STATE.STARTING;
272
+ const ffmpegCmd = await resolveFfmpegCmd(dataDir);
273
+ if (!ffmpegCmd) {
274
+ rec.state = RECORDER_STATE.IDLE;
275
+ return { ok: false, reason: 'ffmpeg not found — run /voice to install it, or install ffmpeg on PATH' };
276
+ }
277
+ const device = listFirstDshowAudioDevice(ffmpegCmd);
278
+ if (!device) {
279
+ rec.state = RECORDER_STATE.IDLE;
280
+ return { ok: false, reason: 'no microphone found (DirectShow audio device enumeration returned none)' };
281
+ }
282
+ const wavPath = newTmpWavPath();
283
+ let child;
284
+ try {
285
+ child = spawn(ffmpegCmd, [
286
+ '-f', 'dshow',
287
+ '-i', `audio=${device}`,
288
+ '-ar', '16000',
289
+ '-ac', '1',
290
+ '-y', wavPath,
291
+ ], { stdio: ['pipe', 'ignore', 'pipe'], windowsHide: true });
292
+ } catch (err) {
293
+ rec.state = RECORDER_STATE.IDLE;
294
+ return { ok: false, reason: `failed to start ffmpeg: ${err?.message || err}` };
295
+ }
296
+ // EPIPE guard (Med-3): a write to ffmpeg's stdin AFTER it has already
297
+ // closed the pipe (e.g. process died right as stopRecording() writes 'q')
298
+ // emits an 'error' on the stream — Node throws uncaught if nothing is
299
+ // listening. Attached once, here, so every future stdin write/close on
300
+ // this child is covered (stopRecording's write included).
301
+ child.stdin?.on('error', () => {});
302
+ // A spawn error surfaces async (e.g. EACCES on the resolved path even
303
+ // though the sync probe above found something on PATH) — fall back to
304
+ // IDLE so a stuck RECORDING state never blocks the next Ctrl+Space.
305
+ child.once('error', () => {
306
+ if (rec.child === child) {
307
+ rec.state = RECORDER_STATE.IDLE;
308
+ rec.child = null;
309
+ rec.wavPath = null;
310
+ }
311
+ });
312
+ rec.state = RECORDER_STATE.RECORDING;
313
+ rec.child = child;
314
+ rec.wavPath = wavPath;
315
+ return { ok: true };
316
+ }
317
+
318
+ // Graceful-stop budget before escalating to a hard kill. ffmpeg needs a
319
+ // moment to flush the wav trailer after receiving 'q' on stdin.
320
+ const RECORDING_STOP_GRACE_MS = 3000;
321
+
322
+ function waitForExit(child, timeoutMs) {
323
+ return new Promise((resolve) => {
324
+ let done = false;
325
+ const finish = (exited) => {
326
+ if (done) return;
327
+ done = true;
328
+ resolve(exited);
329
+ };
330
+ child.once('exit', () => finish(true));
331
+ setTimeout(() => finish(false), timeoutMs).unref?.();
332
+ });
333
+ }
334
+
335
+ /**
336
+ * Stop the in-flight recording. RECORDING -> TRANSCRIBING -> (transcribe) ->
337
+ * IDLE. Sends 'q' on ffmpeg's stdin (its documented graceful-quit key —
338
+ * flushes the wav trailer); if ffmpeg has not exited within
339
+ * RECORDING_STOP_GRACE_MS it is force-killed (the partial wav is still
340
+ * attempted). No-op (returns null) unless currently RECORDING.
341
+ *
342
+ * On success runs ensureReady + transcribe against the SAME whisper-server
343
+ * singleton the channels pipeline uses, using `voice.language` from config
344
+ * (undefined -> whisper auto-detect) and `voice.transcription.threadCount`
345
+ * (falls back to the same quartered-cpu-count default index.mjs uses).
346
+ * Returns `{ ok: true, text }` on success or `{ ok: false, reason }` on any
347
+ * failure; state returns to IDLE either way.
348
+ *
349
+ * Med-1: rec.child is INTENTIONALLY kept set (not nulled) for the entire
350
+ * grace-wait window below — only cleared once the child has actually
351
+ * exited (or been killed). This lets disposeRecorder(), called mid-grace
352
+ * (e.g. the user quits the TUI right after Ctrl+Space-stop), still see and
353
+ * kill the SAME child instead of finding rec.child already null and
354
+ * orphaning a live ffmpeg process.
355
+ */
356
+ export async function stopRecording({ dataDir = resolvePluginData() } = {}) {
357
+ if (rec.state !== RECORDER_STATE.RECORDING || !rec.child) return null;
358
+ const child = rec.child;
359
+ const wavPath = rec.wavPath;
360
+ rec.state = RECORDER_STATE.TRANSCRIBING;
361
+ try {
362
+ try { child.stdin?.write('q'); } catch { /* stdin may already be closed */ }
363
+ const exited = await waitForExit(child, RECORDING_STOP_GRACE_MS);
364
+ if (!exited) {
365
+ try { child.kill('SIGKILL'); } catch { /* best-effort */ }
366
+ await waitForExit(child, RECORDING_STOP_GRACE_MS);
367
+ }
368
+ // Only now is the child provably gone (or we gave up trying) — safe to
369
+ // drop the reference so a concurrent disposeRecorder() no longer needs
370
+ // (and no longer has) anything to kill.
371
+ if (rec.child === child) rec.child = null;
372
+ return await transcribeWav(wavPath, { dataDir });
373
+ } finally {
374
+ if (rec.child === child) rec.child = null;
375
+ rec.state = RECORDER_STATE.IDLE;
376
+ rec.wavPath = null;
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Cancel the in-flight recording. RECORDING -> IDLE (no transcription).
382
+ * Force-kills ffmpeg immediately and deletes the tmp wav. No-op (returns
383
+ * false) unless currently RECORDING.
384
+ */
385
+ export function cancelRecording() {
386
+ if (rec.state !== RECORDER_STATE.RECORDING || !rec.child) return false;
387
+ const child = rec.child;
388
+ const wavPath = rec.wavPath;
389
+ try { child.kill('SIGKILL'); } catch { /* best-effort */ }
390
+ if (wavPath) {
391
+ try { unlinkSync(wavPath); } catch { /* tmp file may not exist yet */ }
392
+ }
393
+ rec.state = RECORDER_STATE.IDLE;
394
+ rec.child = null;
395
+ rec.wavPath = null;
396
+ return true;
397
+ }
398
+
399
+ /**
400
+ * Best-effort teardown for TUI unmount/exit: kills any live ffmpeg child and
401
+ * deletes its tmp wav, regardless of current state (RECORDING or
402
+ * TRANSCRIBING — the latter can still hold a live child mid-stop-grace).
403
+ * Never throws.
404
+ */
405
+ export function disposeRecorder() {
406
+ const child = rec.child;
407
+ const wavPath = rec.wavPath;
408
+ if (child) {
409
+ try { child.kill('SIGKILL'); } catch { /* best-effort */ }
410
+ }
411
+ if (wavPath) {
412
+ try { unlinkSync(wavPath); } catch { /* best-effort */ }
413
+ }
414
+ rec.state = RECORDER_STATE.IDLE;
415
+ rec.child = null;
416
+ rec.wavPath = null;
417
+ // High-2: shut down the whisper-server child too, not just ffmpeg — a
418
+ // TUI-triggered transcription can leave the managed whisper-server.exe
419
+ // process running past TUI exit (it's a long-lived singleton by design,
420
+ // kept warm across transcriptions), which otherwise orphans it. Only
421
+ // meaningful if loadWhisperServer() actually resolved at least once this
422
+ // session (_whisperServerModule set) — nothing to stop otherwise, and we
423
+ // must not trigger a fresh dynamic import from a sync unmount callback.
424
+ if (_whisperServerModule) {
425
+ try { void _whisperServerModule.stopVoiceWhisperServer?.(); } catch { /* best-effort */ }
426
+ }
427
+ }
428
+
429
+ async function transcribeWav(wavPath, { dataDir }) {
430
+ // Med-2: the runtime.installed check is now INSIDE the try so an early
431
+ // return here still hits the finally below and unlinks wavPath — the
432
+ // original early-return (before try) skipped cleanup and leaked the tmp
433
+ // wav on every "voice runtime not installed" path.
434
+ try {
435
+ const fetcher = await loadVoiceRuntimeFetcher();
436
+ const runtime = fetcher.resolveVoiceRuntime(dataDir);
437
+ if (!runtime?.installed) {
438
+ return { ok: false, reason: 'voice runtime not installed — run /voice to install it' };
439
+ }
440
+ const server = await loadWhisperServer();
441
+ const cfg = readSection('voice');
442
+ const cpuCount = (() => { try { return cpus().length; } catch { return 2; } })();
443
+ const threadCount = cfg?.transcription?.threadCount ?? Math.max(1, Math.ceil(cpuCount / 4));
444
+ const language = cfg?.language || undefined;
445
+ await server.ensureReady({ serverCmd: runtime.serverCmd, modelPath: runtime.modelPath, threadCount, host: '127.0.0.1' });
446
+ const text = await server.transcribe(wavPath, { language });
447
+ return { ok: true, text: text.trim() };
448
+ } catch (err) {
449
+ return { ok: false, reason: err?.message || String(err) };
450
+ } finally {
451
+ try { unlinkSync(wavPath); } catch { /* best-effort cleanup */ }
452
+ }
453
+ }