verbalcoding 0.2.11 → 0.2.13

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 (235) hide show
  1. package/.env.example +98 -2
  2. package/README.es.md +134 -0
  3. package/README.fr.md +134 -0
  4. package/README.ja.md +134 -0
  5. package/README.ko.md +134 -0
  6. package/README.md +118 -74
  7. package/README.ru.md +134 -0
  8. package/README.zh.md +133 -0
  9. package/app-node/agent_adapters.mjs +37 -5
  10. package/app-node/agent_adapters.test.mjs +27 -1
  11. package/app-node/agent_detect.mjs +73 -0
  12. package/app-node/agent_detect.test.mjs +77 -0
  13. package/app-node/agent_routing.mjs +148 -0
  14. package/app-node/agent_routing.test.mjs +138 -0
  15. package/app-node/agent_turn.mjs +86 -0
  16. package/app-node/agent_turn.test.mjs +109 -0
  17. package/app-node/bridge_context.mjs +73 -0
  18. package/app-node/bridge_context.test.mjs +54 -0
  19. package/app-node/bridge_state.mjs +4 -0
  20. package/app-node/bridge_wireup.test.mjs +462 -0
  21. package/app-node/cli_install.test.mjs +31 -0
  22. package/app-node/cross_agent_routing.test.mjs +78 -0
  23. package/app-node/discord_command_router.mjs +204 -0
  24. package/app-node/discord_command_router.test.mjs +311 -0
  25. package/app-node/discord_voice_setup.mjs +251 -0
  26. package/app-node/discord_voice_setup.test.mjs +86 -0
  27. package/app-node/hermes_profiles.test.mjs +12 -1
  28. package/app-node/install_config.mjs +113 -3
  29. package/app-node/install_config.test.mjs +8 -0
  30. package/app-node/instance_doctor.test.mjs +9 -0
  31. package/app-node/instances.test.mjs +8 -1
  32. package/app-node/main.mjs +513 -1058
  33. package/app-node/mcp_tools.test.mjs +7 -0
  34. package/app-node/notification_handler.mjs +89 -0
  35. package/app-node/notification_handler.test.mjs +187 -0
  36. package/app-node/notify.mjs +73 -0
  37. package/app-node/notify.test.mjs +68 -0
  38. package/app-node/plan_dispatcher.mjs +215 -0
  39. package/app-node/plan_dispatcher.test.mjs +101 -0
  40. package/app-node/plan_mode.mjs +203 -0
  41. package/app-node/plan_mode.test.mjs +231 -0
  42. package/app-node/progress_handler.mjs +220 -0
  43. package/app-node/progress_handler.test.mjs +193 -0
  44. package/app-node/progress_speech.mjs +54 -32
  45. package/app-node/progress_speech.test.mjs +12 -3
  46. package/app-node/project_sessions.mjs +5 -2
  47. package/app-node/project_sessions.test.mjs +7 -0
  48. package/app-node/research_mode.mjs +282 -0
  49. package/app-node/research_mode.test.mjs +264 -0
  50. package/app-node/restart_notice.mjs +3 -0
  51. package/app-node/restart_notice.test.mjs +11 -0
  52. package/app-node/session_ontology.mjs +271 -0
  53. package/app-node/session_ontology.test.mjs +130 -0
  54. package/app-node/smart_progress.mjs +94 -0
  55. package/app-node/smart_progress.test.mjs +66 -0
  56. package/app-node/stream_sentencer.mjs +91 -0
  57. package/app-node/stream_sentencer.test.mjs +129 -0
  58. package/app-node/streaming_tts_queue.mjs +52 -0
  59. package/app-node/streaming_tts_queue.test.mjs +64 -0
  60. package/app-node/stt_whisper.mjs +24 -0
  61. package/app-node/stt_whisper.test.mjs +32 -0
  62. package/app-node/text_routing.mjs +22 -0
  63. package/app-node/text_routing.test.mjs +23 -1
  64. package/app-node/tts_backends.mjs +537 -3
  65. package/app-node/tts_backends.test.mjs +454 -0
  66. package/app-node/tts_player.mjs +164 -0
  67. package/app-node/tts_player.test.mjs +202 -0
  68. package/app-node/tts_runtime.mjs +134 -0
  69. package/app-node/tts_runtime.test.mjs +89 -0
  70. package/app-node/tts_settings.mjs +150 -3
  71. package/app-node/tts_settings.test.mjs +204 -0
  72. package/app-node/tts_voice_config.mjs +136 -2
  73. package/app-node/tts_voice_config.test.mjs +94 -0
  74. package/app-node/utterance_router.mjs +216 -0
  75. package/app-node/utterance_router.test.mjs +236 -0
  76. package/app-node/voice_autojoin.mjs +37 -0
  77. package/app-node/voice_autojoin.test.mjs +59 -0
  78. package/app-node/voice_io.mjs +272 -0
  79. package/app-node/voice_io.test.mjs +102 -0
  80. package/app-node/voice_turn_runner.mjs +449 -0
  81. package/app-node/voice_turn_runner.test.mjs +289 -0
  82. package/docs/CONFIGURATION.md +79 -96
  83. package/docs/FRESH_INSTALL.md +105 -63
  84. package/docs/HARNESSES.md +58 -0
  85. package/docs/HARNESS_AIDER.md +50 -0
  86. package/docs/HARNESS_CLAUDE.md +56 -0
  87. package/docs/HARNESS_CODEX.md +56 -0
  88. package/docs/HARNESS_CURSOR.md +45 -0
  89. package/docs/HARNESS_GEMINI.md +45 -0
  90. package/docs/HARNESS_HERMES.md +57 -0
  91. package/docs/HARNESS_OPENCLAW.md +44 -0
  92. package/docs/HARNESS_OPENCODE.md +44 -0
  93. package/docs/HERMES_VOICE.md +65 -0
  94. package/docs/MULTI_INSTANCE.md +16 -0
  95. package/docs/README.md +50 -0
  96. package/docs/RELEASE.md +42 -19
  97. package/docs/ROADMAP.md +53 -0
  98. package/docs/TROUBLESHOOTING.md +126 -0
  99. package/docs/TTS_BACKENDS.md +227 -0
  100. package/docs/USAGE.md +94 -40
  101. package/docs/assets/figures/verbalcoding-flow.svg +1 -1
  102. package/docs/i18n/AGENTS.es.md +34 -0
  103. package/docs/i18n/AGENTS.fr.md +34 -0
  104. package/docs/i18n/AGENTS.ja.md +34 -0
  105. package/docs/i18n/AGENTS.ko.md +34 -0
  106. package/docs/i18n/AGENTS.ru.md +34 -0
  107. package/docs/i18n/AGENTS.zh.md +34 -0
  108. package/docs/i18n/CONFIGURATION.es.md +25 -0
  109. package/docs/i18n/CONFIGURATION.fr.md +25 -0
  110. package/docs/i18n/CONFIGURATION.ja.md +25 -0
  111. package/docs/i18n/CONFIGURATION.ko.md +25 -0
  112. package/docs/i18n/CONFIGURATION.ru.md +25 -0
  113. package/docs/i18n/CONFIGURATION.zh.md +25 -0
  114. package/docs/i18n/FRESH_INSTALL.es.md +27 -2
  115. package/docs/i18n/FRESH_INSTALL.fr.md +27 -2
  116. package/docs/i18n/FRESH_INSTALL.ja.md +27 -2
  117. package/docs/i18n/FRESH_INSTALL.ko.md +27 -2
  118. package/docs/i18n/FRESH_INSTALL.ru.md +27 -2
  119. package/docs/i18n/FRESH_INSTALL.zh.md +27 -2
  120. package/docs/i18n/HARNESSES.es.md +58 -0
  121. package/docs/i18n/HARNESSES.fr.md +58 -0
  122. package/docs/i18n/HARNESSES.ja.md +58 -0
  123. package/docs/i18n/HARNESSES.ko.md +58 -0
  124. package/docs/i18n/HARNESSES.ru.md +58 -0
  125. package/docs/i18n/HARNESSES.zh.md +58 -0
  126. package/docs/i18n/HARNESS_AIDER.es.md +48 -0
  127. package/docs/i18n/HARNESS_AIDER.fr.md +48 -0
  128. package/docs/i18n/HARNESS_AIDER.ja.md +50 -0
  129. package/docs/i18n/HARNESS_AIDER.ko.md +50 -0
  130. package/docs/i18n/HARNESS_AIDER.ru.md +48 -0
  131. package/docs/i18n/HARNESS_AIDER.zh.md +48 -0
  132. package/docs/i18n/HARNESS_CLAUDE.es.md +55 -0
  133. package/docs/i18n/HARNESS_CLAUDE.fr.md +55 -0
  134. package/docs/i18n/HARNESS_CLAUDE.ja.md +56 -0
  135. package/docs/i18n/HARNESS_CLAUDE.ko.md +56 -0
  136. package/docs/i18n/HARNESS_CLAUDE.ru.md +55 -0
  137. package/docs/i18n/HARNESS_CLAUDE.zh.md +56 -0
  138. package/docs/i18n/HARNESS_CODEX.es.md +55 -0
  139. package/docs/i18n/HARNESS_CODEX.fr.md +55 -0
  140. package/docs/i18n/HARNESS_CODEX.ja.md +56 -0
  141. package/docs/i18n/HARNESS_CODEX.ko.md +56 -0
  142. package/docs/i18n/HARNESS_CODEX.ru.md +55 -0
  143. package/docs/i18n/HARNESS_CODEX.zh.md +56 -0
  144. package/docs/i18n/HARNESS_CURSOR.es.md +42 -0
  145. package/docs/i18n/HARNESS_CURSOR.fr.md +42 -0
  146. package/docs/i18n/HARNESS_CURSOR.ja.md +45 -0
  147. package/docs/i18n/HARNESS_CURSOR.ko.md +45 -0
  148. package/docs/i18n/HARNESS_CURSOR.ru.md +42 -0
  149. package/docs/i18n/HARNESS_CURSOR.zh.md +42 -0
  150. package/docs/i18n/HARNESS_GEMINI.es.md +44 -0
  151. package/docs/i18n/HARNESS_GEMINI.fr.md +44 -0
  152. package/docs/i18n/HARNESS_GEMINI.ja.md +45 -0
  153. package/docs/i18n/HARNESS_GEMINI.ko.md +45 -0
  154. package/docs/i18n/HARNESS_GEMINI.ru.md +44 -0
  155. package/docs/i18n/HARNESS_GEMINI.zh.md +45 -0
  156. package/docs/i18n/HARNESS_HERMES.es.md +54 -0
  157. package/docs/i18n/HARNESS_HERMES.fr.md +54 -0
  158. package/docs/i18n/HARNESS_HERMES.ja.md +57 -0
  159. package/docs/i18n/HARNESS_HERMES.ko.md +57 -0
  160. package/docs/i18n/HARNESS_HERMES.ru.md +54 -0
  161. package/docs/i18n/HARNESS_HERMES.zh.md +57 -0
  162. package/docs/i18n/HARNESS_OPENCLAW.es.md +41 -0
  163. package/docs/i18n/HARNESS_OPENCLAW.fr.md +41 -0
  164. package/docs/i18n/HARNESS_OPENCLAW.ja.md +44 -0
  165. package/docs/i18n/HARNESS_OPENCLAW.ko.md +44 -0
  166. package/docs/i18n/HARNESS_OPENCLAW.ru.md +41 -0
  167. package/docs/i18n/HARNESS_OPENCLAW.zh.md +42 -0
  168. package/docs/i18n/HARNESS_OPENCODE.es.md +41 -0
  169. package/docs/i18n/HARNESS_OPENCODE.fr.md +41 -0
  170. package/docs/i18n/HARNESS_OPENCODE.ja.md +44 -0
  171. package/docs/i18n/HARNESS_OPENCODE.ko.md +44 -0
  172. package/docs/i18n/HARNESS_OPENCODE.ru.md +41 -0
  173. package/docs/i18n/HARNESS_OPENCODE.zh.md +44 -0
  174. package/docs/i18n/HERMES_VOICE.es.md +46 -0
  175. package/docs/i18n/HERMES_VOICE.fr.md +46 -0
  176. package/docs/i18n/HERMES_VOICE.ja.md +46 -0
  177. package/docs/i18n/HERMES_VOICE.ko.md +65 -0
  178. package/docs/i18n/HERMES_VOICE.ru.md +46 -0
  179. package/docs/i18n/HERMES_VOICE.zh.md +46 -0
  180. package/docs/i18n/MULTI_INSTANCE.es.md +25 -0
  181. package/docs/i18n/MULTI_INSTANCE.fr.md +25 -0
  182. package/docs/i18n/MULTI_INSTANCE.ja.md +25 -0
  183. package/docs/i18n/MULTI_INSTANCE.ko.md +25 -0
  184. package/docs/i18n/MULTI_INSTANCE.ru.md +25 -0
  185. package/docs/i18n/MULTI_INSTANCE.zh.md +25 -0
  186. package/docs/i18n/README.es.md +20 -134
  187. package/docs/i18n/README.fr.md +20 -134
  188. package/docs/i18n/README.ja.md +20 -134
  189. package/docs/i18n/README.ko.md +20 -133
  190. package/docs/i18n/README.ru.md +20 -134
  191. package/docs/i18n/README.zh.md +20 -133
  192. package/docs/i18n/RELEASE.es.md +26 -1
  193. package/docs/i18n/RELEASE.fr.md +26 -1
  194. package/docs/i18n/RELEASE.ja.md +26 -1
  195. package/docs/i18n/RELEASE.ko.md +26 -1
  196. package/docs/i18n/RELEASE.ru.md +26 -1
  197. package/docs/i18n/RELEASE.zh.md +26 -1
  198. package/docs/i18n/TROUBLESHOOTING.es.md +39 -0
  199. package/docs/i18n/TROUBLESHOOTING.fr.md +39 -0
  200. package/docs/i18n/TROUBLESHOOTING.ja.md +39 -0
  201. package/docs/i18n/TROUBLESHOOTING.ko.md +39 -0
  202. package/docs/i18n/TROUBLESHOOTING.ru.md +39 -0
  203. package/docs/i18n/TROUBLESHOOTING.zh.md +39 -0
  204. package/docs/i18n/USAGE.es.md +25 -0
  205. package/docs/i18n/USAGE.fr.md +25 -0
  206. package/docs/i18n/USAGE.ja.md +25 -0
  207. package/docs/i18n/USAGE.ko.md +25 -0
  208. package/docs/i18n/USAGE.ru.md +25 -0
  209. package/docs/i18n/USAGE.zh.md +25 -0
  210. package/docs/superpowers/plans/2026-05-13-phase1-streaming-pipeline.md +122 -0
  211. package/docs/superpowers/plans/2026-05-13-phase10-push-notifications.md +152 -0
  212. package/docs/superpowers/plans/2026-05-13-phase2-agent-adapters.md +242 -0
  213. package/docs/superpowers/plans/2026-05-13-phase6-smart-progress.md +172 -0
  214. package/docs/superpowers/plans/2026-05-13-phase7-voice-plan-mode.md +108 -0
  215. package/docs/superpowers/plans/2026-05-14-cross-agent-voice-transfer.md +625 -0
  216. package/docs/superpowers/plans/2026-05-21-audio-overview-narrated-diffs.md +95 -0
  217. package/docs/superpowers/plans/2026-05-21-autoresearch-ontology.md +83 -0
  218. package/docs/superpowers/plans/2026-05-21-phase11-push-to-talk-wakeword-v2.md +77 -0
  219. package/docs/superpowers/plans/2026-05-21-phase12-multi-user-voice.md +147 -0
  220. package/docs/superpowers/plans/2026-05-21-phase14-verbalbench.md +136 -0
  221. package/docs/superpowers/plans/2026-05-21-phase15-phone-companion.md +72 -0
  222. package/integrations/fireredtts2/mlx_llm.py +183 -0
  223. package/integrations/fireredtts2/synth.py +156 -0
  224. package/integrations/fireredtts2/synth_mlx.py +196 -0
  225. package/integrations/mlxaudio/synth.py +74 -0
  226. package/integrations/neuttsair/synth.py +104 -0
  227. package/integrations/omnivoice/synth.py +110 -0
  228. package/package.json +7 -1
  229. package/scripts/cli.mjs +88 -3
  230. package/scripts/doctor.mjs +115 -4
  231. package/scripts/install.mjs +20 -2
  232. package/scripts/install_fireredtts2.sh +109 -0
  233. package/scripts/install_mlxaudio.sh +34 -0
  234. package/scripts/install_mossttsnano.sh +46 -0
  235. package/scripts/postinstall.mjs +34 -0
@@ -0,0 +1,129 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createSentencer } from './stream_sentencer.mjs';
4
+
5
+ test('emits a sentence on terminal punctuation', () => {
6
+ const out = [];
7
+ const s = createSentencer({ minChars: 1, maxLatencyMs: 999999 });
8
+ s.on('sentence', t => out.push(t));
9
+ s.push('Hello world. ');
10
+ assert.deepEqual(out, ['Hello world.']);
11
+ });
12
+
13
+ test('does not emit on partial sentence', () => {
14
+ const out = [];
15
+ const s = createSentencer({ minChars: 1, maxLatencyMs: 999999 });
16
+ s.on('sentence', t => out.push(t));
17
+ s.push('Reading file');
18
+ assert.deepEqual(out, []);
19
+ s.push(' main.mjs.');
20
+ assert.deepEqual(out, ['Reading file main.mjs.']);
21
+ });
22
+
23
+ test('strips ANSI before emitting', () => {
24
+ const out = [];
25
+ const s = createSentencer({ minChars: 1, maxLatencyMs: 999999 });
26
+ s.on('sentence', t => out.push(t));
27
+ s.push('\x1b[32mDone.\x1b[0m ');
28
+ assert.deepEqual(out, ['Done.']);
29
+ });
30
+
31
+ test('filters VERBALCODING_PROGRESS lines', () => {
32
+ const out = [];
33
+ const s = createSentencer({ minChars: 1, maxLatencyMs: 999999 });
34
+ s.on('sentence', t => out.push(t));
35
+ s.push('VERBALCODING_PROGRESS: reading files main.mjs\nAll set.');
36
+ s.flush();
37
+ assert.deepEqual(out, ['All set.']);
38
+ });
39
+
40
+ test('flush emits residual on close', () => {
41
+ const out = [];
42
+ const s = createSentencer({ minChars: 1, maxLatencyMs: 999999 });
43
+ s.on('sentence', t => out.push(t));
44
+ s.push('No terminator here');
45
+ s.flush();
46
+ assert.deepEqual(out, ['No terminator here']);
47
+ });
48
+
49
+ test('strips Hermes box characters', () => {
50
+ const out = [];
51
+ const s = createSentencer({ minChars: 1, maxLatencyMs: 999999 });
52
+ s.on('sentence', t => out.push(t));
53
+ s.push('│ Done.');
54
+ s.flush();
55
+ assert.deepEqual(out, ['Done.']);
56
+ });
57
+
58
+ test('emits multiple sentences in one push', () => {
59
+ const out = [];
60
+ const s = createSentencer({ minChars: 1, maxLatencyMs: 999999 });
61
+ s.on('sentence', t => out.push(t));
62
+ s.push('First. Second. Third.');
63
+ assert.deepEqual(out, ['First.', 'Second.', 'Third.']);
64
+ });
65
+
66
+ test('does not split on common abbreviations', () => {
67
+ const out = [];
68
+ const s = createSentencer({ minChars: 1, maxLatencyMs: 999999 });
69
+ s.on('sentence', t => out.push(t));
70
+ s.push('Use e.g. main.mjs for the entry point. ');
71
+ assert.deepEqual(out, ['Use e.g. main.mjs for the entry point.']);
72
+ });
73
+
74
+ test('treats decimals as one sentence', () => {
75
+ const out = [];
76
+ const s = createSentencer({ minChars: 1, maxLatencyMs: 999999 });
77
+ s.on('sentence', t => out.push(t));
78
+ s.push('Version 3.14 is out. ');
79
+ assert.deepEqual(out, ['Version 3.14 is out.']);
80
+ });
81
+
82
+ test('drops fenced code blocks from speech', () => {
83
+ const out = [];
84
+ const s = createSentencer({ minChars: 1, maxLatencyMs: 999999 });
85
+ s.on('sentence', t => out.push(t));
86
+ s.push('Here is the change. ```js\nconst x = 1;\n``` Done.');
87
+ s.flush();
88
+ assert.deepEqual(out, ['Here is the change.', 'Done.']);
89
+ });
90
+
91
+ test('detects fence marker split 1+1+1 backticks across three pushes', () => {
92
+ const out = [];
93
+ const s = createSentencer({ minChars: 1, maxLatencyMs: 999999 });
94
+ s.on('sentence', t => out.push(t));
95
+ s.push('Open fence. `');
96
+ s.push('`');
97
+ s.push('`python\nconst x = 1\n```\nClosed.');
98
+ s.flush();
99
+ assert.deepEqual(out, ['Open fence.', 'Closed.']);
100
+ });
101
+
102
+ test('detects fence marker split across pushes', () => {
103
+ const out = [];
104
+ const s = createSentencer({ minChars: 1, maxLatencyMs: 999999 });
105
+ s.on('sentence', t => out.push(t));
106
+ s.push('Open fence. ``');
107
+ s.push('`python\nconst x = 1\n``');
108
+ s.push('` Closed.');
109
+ s.flush();
110
+ assert.deepEqual(out, ['Open fence.', 'Closed.']);
111
+ });
112
+
113
+ test('keeps fence state across pushes', () => {
114
+ const out = [];
115
+ const s = createSentencer({ minChars: 1, maxLatencyMs: 999999 });
116
+ s.on('sentence', t => out.push(t));
117
+ s.push('Open fence. ```python\nimport os');
118
+ s.push('\nprint(os.getcwd())\n``` Closed.');
119
+ s.flush();
120
+ assert.deepEqual(out, ['Open fence.', 'Closed.']);
121
+ });
122
+
123
+ test('terminates on Korean closing quote', () => {
124
+ const out = [];
125
+ const s = createSentencer({ minChars: 1, maxLatencyMs: 999999 });
126
+ s.on('sentence', t => out.push(t));
127
+ s.push('그가 말했다. "안녕." ');
128
+ assert.deepEqual(out, ['그가 말했다.', '"안녕."']);
129
+ });
@@ -0,0 +1,52 @@
1
+ export function createStreamingTTSQueue({ synth, play, signal, cleanup, log = () => {}, onSynthError = null } = {}) {
2
+ if (typeof synth !== 'function') throw new Error('synth is required');
3
+ if (typeof play !== 'function') throw new Error('play is required');
4
+
5
+ const queue = [];
6
+ let pumping = null;
7
+ let droppedCount = 0;
8
+
9
+ async function pump() {
10
+ while (queue.length && !signal?.aborted) {
11
+ const text = queue.shift();
12
+ let file;
13
+ try {
14
+ file = await synth(text);
15
+ } catch (e) {
16
+ log('streaming tts synth failed', e?.message || e);
17
+ droppedCount += 1;
18
+ try { onSynthError?.({ text, error: e, droppedCount }); } catch {}
19
+ continue;
20
+ }
21
+ if (!file) continue;
22
+ if (signal?.aborted) {
23
+ try { await cleanup?.(file); } catch {}
24
+ return;
25
+ }
26
+ try {
27
+ await play(file);
28
+ } catch (e) {
29
+ if (signal?.aborted) {
30
+ try { await cleanup?.(file); } catch {}
31
+ return;
32
+ }
33
+ log('streaming tts play failed', e?.message || e);
34
+ }
35
+ try { await cleanup?.(file); } catch {}
36
+ }
37
+ }
38
+
39
+ return {
40
+ enqueue(text) {
41
+ const trimmed = String(text || '').trim();
42
+ if (!trimmed || signal?.aborted) return;
43
+ queue.push(trimmed);
44
+ if (!pumping) pumping = pump().finally(() => { pumping = null; });
45
+ },
46
+ async drain() {
47
+ while (pumping) await pumping;
48
+ },
49
+ get size() { return queue.length; },
50
+ get droppedCount() { return droppedCount; },
51
+ };
52
+ }
@@ -0,0 +1,64 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createStreamingTTSQueue } from './streaming_tts_queue.mjs';
4
+
5
+ test('synths and plays in enqueue order', async () => {
6
+ const order = [];
7
+ const q = createStreamingTTSQueue({
8
+ synth: async (t) => { order.push(`synth:${t}`); return `f-${t}`; },
9
+ play: async (f) => { order.push(`play:${f}`); },
10
+ });
11
+ q.enqueue('A.');
12
+ q.enqueue('B.');
13
+ await q.drain();
14
+ assert.deepEqual(order, ['synth:A.', 'play:f-A.', 'synth:B.', 'play:f-B.']);
15
+ });
16
+
17
+ test('abort stops further playback', async () => {
18
+ const ctrl = new AbortController();
19
+ const order = [];
20
+ const q = createStreamingTTSQueue({
21
+ synth: async (t) => `f-${t}`,
22
+ play: async (f) => { order.push(`play:${f}`); if (f === 'f-A.') ctrl.abort(); },
23
+ signal: ctrl.signal,
24
+ });
25
+ q.enqueue('A.');
26
+ q.enqueue('B.');
27
+ await q.drain();
28
+ assert.deepEqual(order, ['play:f-A.']);
29
+ });
30
+
31
+ test('cleanup runs after play', async () => {
32
+ const cleaned = [];
33
+ const q = createStreamingTTSQueue({
34
+ synth: async (t) => `f-${t}`,
35
+ play: async () => {},
36
+ cleanup: async (f) => { cleaned.push(f); },
37
+ });
38
+ q.enqueue('A.');
39
+ await q.drain();
40
+ assert.deepEqual(cleaned, ['f-A.']);
41
+ });
42
+
43
+ test('synth error skips that sentence but continues, and reports dropped count + onSynthError', async () => {
44
+ const played = [];
45
+ const errors = [];
46
+ const q = createStreamingTTSQueue({
47
+ synth: async (t) => { if (t === 'A.') throw new Error('boom'); return `f-${t}`; },
48
+ play: async (f) => { played.push(f); },
49
+ onSynthError: ev => errors.push(ev),
50
+ });
51
+ q.enqueue('A.');
52
+ q.enqueue('B.');
53
+ await q.drain();
54
+ assert.deepEqual(played, ['f-B.']);
55
+ assert.equal(q.droppedCount, 1);
56
+ assert.equal(errors.length, 1);
57
+ assert.equal(errors[0].text, 'A.');
58
+ assert.match(errors[0].error.message, /boom/);
59
+ });
60
+
61
+ test('throws when synth or play missing', () => {
62
+ assert.throws(() => createStreamingTTSQueue({ play: async () => {} }), /synth is required/);
63
+ assert.throws(() => createStreamingTTSQueue({ synth: async () => {} }), /play is required/);
64
+ });
@@ -0,0 +1,24 @@
1
+ export const DEFAULT_WHISPER_TIMEOUT_MS = 90000;
2
+
3
+ export function parsePositiveInt(value, fallback) {
4
+ const n = Number(value);
5
+ if (!Number.isFinite(n) || n <= 0) return fallback;
6
+ return Math.floor(n);
7
+ }
8
+
9
+ export function whisperTimeoutMs(env = process.env) {
10
+ return parsePositiveInt(env.WHISPER_CPP_TIMEOUT_MS || env.WHISPER_TIMEOUT_MS, DEFAULT_WHISPER_TIMEOUT_MS);
11
+ }
12
+
13
+ export function whisperFailureMessage(error) {
14
+ const signal = error?.signal ? `signal ${error.signal}` : '';
15
+ const code = error?.code !== undefined && error?.code !== null ? `code ${error.code}` : '';
16
+ const killed = error?.killed ? 'killed' : '';
17
+ const message = String(error?.message || '').trim();
18
+ const stderr = String(error?.stderr || '').trim();
19
+ const stdout = String(error?.stdout || '').trim();
20
+ const detail = stderr || stdout || message || 'unknown error';
21
+ const tail = detail.length > 1400 ? detail.slice(-1400) : detail;
22
+ const prefix = [killed, signal, code].filter(Boolean).join(' ');
23
+ return prefix ? `${prefix}: ${tail}` : tail;
24
+ }
@@ -0,0 +1,32 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { DEFAULT_WHISPER_TIMEOUT_MS, parsePositiveInt, whisperFailureMessage, whisperTimeoutMs } from './stt_whisper.mjs';
5
+
6
+ test('whisperTimeoutMs defaults high enough for cold Metal startup plus transcription', () => {
7
+ assert.equal(DEFAULT_WHISPER_TIMEOUT_MS, 90000);
8
+ assert.equal(whisperTimeoutMs({}), 90000);
9
+ });
10
+
11
+ test('whisperTimeoutMs accepts explicit positive env override', () => {
12
+ assert.equal(whisperTimeoutMs({ WHISPER_CPP_TIMEOUT_MS: '120000' }), 120000);
13
+ assert.equal(whisperTimeoutMs({ WHISPER_TIMEOUT_MS: '45000' }), 45000);
14
+ });
15
+
16
+ test('parsePositiveInt rejects invalid timeout values', () => {
17
+ assert.equal(parsePositiveInt('0', 7), 7);
18
+ assert.equal(parsePositiveInt('-1', 7), 7);
19
+ assert.equal(parsePositiveInt('nope', 7), 7);
20
+ assert.equal(parsePositiveInt('12.8', 7), 12);
21
+ });
22
+
23
+ test('whisperFailureMessage keeps concise tail instead of dumping full backend log', () => {
24
+ const err = new Error('Command failed');
25
+ err.killed = true;
26
+ err.signal = 'SIGTERM';
27
+ err.stderr = `${'load_backend\n'.repeat(300)}final useful line`;
28
+ const msg = whisperFailureMessage(err);
29
+ assert.match(msg, /killed signal SIGTERM/);
30
+ assert.match(msg, /final useful line/);
31
+ assert.ok(msg.length < 1500);
32
+ });
@@ -6,3 +6,25 @@ export function shouldRouteDiscordTextToAgent({ content = '', channelId = '', tr
6
6
  if (!target) return true;
7
7
  return String(channelId || '') === target;
8
8
  }
9
+
10
+ export function appendRecentDiscordText(state, { channelId = '', authorLabel = 'user', content = '', now = Date.now(), maxEntries = 12, messageId = '' } = {}) {
11
+ const id = String(channelId || '').trim();
12
+ const text = String(content || '').trim();
13
+ if (!id || !text || text.startsWith('!')) return;
14
+ const entries = state.get(id) || [];
15
+ const mid = String(messageId || '').trim();
16
+ if (mid && entries.some(entry => entry.messageId === mid)) return;
17
+ entries.push({ at: Number(now) || Date.now(), authorLabel: String(authorLabel || 'user'), content: text.slice(0, 500), messageId: mid || undefined });
18
+ state.set(id, entries.slice(-maxEntries));
19
+ }
20
+
21
+ export function formatRecentDiscordContext(state, { channelId = '', now = Date.now(), maxAgeMs = 10 * 60 * 1000, maxEntries = 6 } = {}) {
22
+ const id = String(channelId || '').trim();
23
+ if (!id) return '';
24
+ const cutoff = (Number(now) || Date.now()) - maxAgeMs;
25
+ const entries = (state.get(id) || [])
26
+ .filter(entry => Number(entry.at) >= cutoff)
27
+ .slice(-maxEntries);
28
+ if (!entries.length) return '';
29
+ return ['최근 텍스트 채널 메시지:', ...entries.map(entry => `- ${entry.authorLabel}: ${entry.content}`)].join('\n');
30
+ }
@@ -1,7 +1,11 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
 
4
- import { shouldRouteDiscordTextToAgent } from './text_routing.mjs';
4
+ import {
5
+ appendRecentDiscordText,
6
+ formatRecentDiscordContext,
7
+ shouldRouteDiscordTextToAgent,
8
+ } from './text_routing.mjs';
5
9
 
6
10
  test('routes normal transcript-channel text to the shared agent session', () => {
7
11
  assert.equal(shouldRouteDiscordTextToAgent({
@@ -16,3 +20,21 @@ test('does not route commands or other channels to the shared agent session', ()
16
20
  assert.equal(shouldRouteDiscordTextToAgent({ content: '다른 채널 말', channelId: 'other', transcriptChannelId: 'transcript' }), false);
17
21
  assert.equal(shouldRouteDiscordTextToAgent({ content: ' ', channelId: 'transcript', transcriptChannelId: 'transcript' }), false);
18
22
  });
23
+
24
+ test('formats recent Discord text context for voice turns without commands', () => {
25
+ const state = new Map();
26
+ appendRecentDiscordText(state, { channelId: 'thread', authorLabel: 'user', content: '음성채널에서만 나가줘', now: 1000 });
27
+ appendRecentDiscordText(state, { channelId: 'thread', authorLabel: 'user', content: '!ping', now: 1100 });
28
+ appendRecentDiscordText(state, { channelId: 'thread', authorLabel: 'assistant', content: '알겠어', now: 1200 });
29
+
30
+ const context = formatRecentDiscordContext(state, {
31
+ channelId: 'thread',
32
+ now: 2000,
33
+ maxAgeMs: 5000,
34
+ });
35
+
36
+ assert.match(context, /최근 텍스트 채널 메시지/);
37
+ assert.match(context, /user: 음성채널에서만 나가줘/);
38
+ assert.doesNotMatch(context, /!ping/);
39
+ assert.match(context, /assistant: 알겠어/);
40
+ });