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,138 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ parseAgentRoutingCommand,
5
+ resolveBackendAlias,
6
+ isAgentRoutingDecision,
7
+ renderAgentPrefix,
8
+ buildCrossAgentPrompt,
9
+ buildFallbackDecision,
10
+ isRoutingOnlyUtterance,
11
+ } from './agent_routing.mjs';
12
+
13
+ test('parseAgentRoutingCommand recognizes "ask X" as single-turn', () => {
14
+ assert.deepEqual(parseAgentRoutingCommand('ask Codex what it thinks', 'en'),
15
+ { type: 'route', backend: 'codex', sticky: false });
16
+ assert.deepEqual(parseAgentRoutingCommand('ask aider to write the test', 'en'),
17
+ { type: 'route', backend: 'aider', sticky: false });
18
+ });
19
+
20
+ test('parseAgentRoutingCommand recognizes "switch to X" as sticky', () => {
21
+ assert.deepEqual(parseAgentRoutingCommand('switch to Aider', 'en'),
22
+ { type: 'route', backend: 'aider', sticky: true });
23
+ assert.deepEqual(parseAgentRoutingCommand('switch to claude code', 'en'),
24
+ { type: 'route', backend: 'claude', sticky: true });
25
+ });
26
+
27
+ test('parseAgentRoutingCommand recognizes Korean routing phrases', () => {
28
+ assert.deepEqual(parseAgentRoutingCommand('코덱스한테 물어봐', 'ko'),
29
+ { type: 'route', backend: 'codex', sticky: false });
30
+ assert.deepEqual(parseAgentRoutingCommand('aider로 전환해', 'ko'),
31
+ { type: 'route', backend: 'aider', sticky: true });
32
+ });
33
+
34
+ test('parseAgentRoutingCommand recognizes restore-default phrases', () => {
35
+ assert.deepEqual(parseAgentRoutingCommand('back to default', 'en'),
36
+ { type: 'restore' });
37
+ assert.deepEqual(parseAgentRoutingCommand('use the default agent', 'en'),
38
+ { type: 'restore' });
39
+ assert.deepEqual(parseAgentRoutingCommand('기본으로 돌아가', 'ko'),
40
+ { type: 'restore' });
41
+ });
42
+
43
+ test('parseAgentRoutingCommand returns none on unrelated input', () => {
44
+ assert.deepEqual(parseAgentRoutingCommand('just write the function', 'en'),
45
+ { type: 'none' });
46
+ assert.deepEqual(parseAgentRoutingCommand('plan it first', 'en'),
47
+ { type: 'none' });
48
+ });
49
+
50
+ test('resolveBackendAlias maps user-facing names to canonical backends', () => {
51
+ assert.equal(resolveBackendAlias('Claude Code'), 'claude');
52
+ assert.equal(resolveBackendAlias('claude'), 'claude');
53
+ assert.equal(resolveBackendAlias('cursor cli'), 'cursor');
54
+ assert.equal(resolveBackendAlias('gemini cli'), 'gemini');
55
+ assert.equal(resolveBackendAlias('코덱스'), 'codex');
56
+ assert.equal(resolveBackendAlias('unknown'), null);
57
+ });
58
+
59
+ test('isAgentRoutingDecision detects which_agent slot', () => {
60
+ assert.equal(isAgentRoutingDecision({ slot: 'which_agent', options: ['codex', 'aider'] }), true);
61
+ assert.equal(isAgentRoutingDecision({ slot: 'oauth_provider', options: ['google', 'github'] }), false);
62
+ assert.equal(isAgentRoutingDecision({ slot: 'agent', options: ['codex', 'aider'] }), true);
63
+ assert.equal(isAgentRoutingDecision(null), false);
64
+ });
65
+
66
+ test('renderAgentPrefix uses English label for en', () => {
67
+ assert.equal(renderAgentPrefix('codex', 'en'), 'Codex says: ');
68
+ assert.equal(renderAgentPrefix('claude', 'en'), 'Claude Code says: ');
69
+ });
70
+
71
+ test('renderAgentPrefix uses Korean label for ko', () => {
72
+ assert.equal(renderAgentPrefix('codex', 'ko'), '코덱스: ');
73
+ assert.equal(renderAgentPrefix('claude', 'ko'), 'Claude Code: ');
74
+ });
75
+
76
+ test('renderAgentPrefix returns empty when backend unknown', () => {
77
+ assert.equal(renderAgentPrefix('', 'en'), '');
78
+ assert.equal(renderAgentPrefix(null, 'en'), '');
79
+ assert.equal(renderAgentPrefix('unknownbackend', 'en'), '');
80
+ });
81
+
82
+ test('buildCrossAgentPrompt prepends handoff block in English', () => {
83
+ const out = buildCrossAgentPrompt({
84
+ prompt: 'Refactor the login route to use OAuth.',
85
+ fromBackend: 'claude', toBackend: 'codex',
86
+ resolvedDecisions: { oauth_provider: 'github' },
87
+ priorUtterances: ['plan it first', 'skip step 2'],
88
+ language: 'en',
89
+ });
90
+ assert.match(out, /Cross-agent handoff from Claude Code to Codex/);
91
+ assert.match(out, /Prior decisions: oauth_provider=github/);
92
+ assert.match(out, /Recent user voice: plan it first \| skip step 2/);
93
+ assert.match(out, /User request: Refactor the login route to use OAuth\./);
94
+ });
95
+
96
+ test('buildCrossAgentPrompt omits empty sections', () => {
97
+ const out = buildCrossAgentPrompt({
98
+ prompt: 'do it', fromBackend: 'claude', toBackend: 'codex',
99
+ resolvedDecisions: {}, priorUtterances: [], language: 'en',
100
+ });
101
+ assert.doesNotMatch(out, /Prior decisions:/);
102
+ assert.doesNotMatch(out, /Recent user voice:/);
103
+ assert.match(out, /User request: do it/);
104
+ });
105
+
106
+ test('buildCrossAgentPrompt renders Korean header for ko', () => {
107
+ const out = buildCrossAgentPrompt({
108
+ prompt: '로그인 라우트 리팩토링해줘',
109
+ fromBackend: 'claude', toBackend: 'codex',
110
+ resolvedDecisions: {}, priorUtterances: [], language: 'ko',
111
+ });
112
+ assert.match(out, /에이전트 핸드오프: Claude Code → 코덱스/);
113
+ assert.match(out, /사용자 요청: 로그인 라우트 리팩토링해줘/);
114
+ });
115
+
116
+ test('buildFallbackDecision yields a yes/no shape', () => {
117
+ const d = buildFallbackDecision('codex', 'Claude Code', 'en');
118
+ assert.equal(d.slot, 'fallback');
119
+ assert.deepEqual(d.options, ['yes', 'no']);
120
+ assert.match(d.question, /codex/);
121
+ assert.match(d.question, /Claude Code/);
122
+ });
123
+
124
+ test('buildFallbackDecision yields a Korean prompt for ko', () => {
125
+ const d = buildFallbackDecision('codex', 'Claude Code', 'ko');
126
+ assert.match(d.question, /codex/);
127
+ assert.match(d.question, /Claude Code/);
128
+ });
129
+
130
+ test('isRoutingOnlyUtterance detects command-only utterances', () => {
131
+ assert.equal(isRoutingOnlyUtterance('switch to codex'), true);
132
+ assert.equal(isRoutingOnlyUtterance('switch to Aider.'), true);
133
+ assert.equal(isRoutingOnlyUtterance('back to default'), true);
134
+ assert.equal(isRoutingOnlyUtterance('codex로 전환'), true);
135
+ assert.equal(isRoutingOnlyUtterance('기본으로 돌아가'), true);
136
+ assert.equal(isRoutingOnlyUtterance('switch to codex and write a test'), false);
137
+ assert.equal(isRoutingOnlyUtterance('ask codex what it thinks'), false);
138
+ });
@@ -0,0 +1,86 @@
1
+ // Per-turn lifecycle helpers shared by the voice path (handleRecording)
2
+ // and the text path (handleTextAgentMessage). Both paths used to inline
3
+ // nearly the same controller setup + cleanup, with a subtle difference
4
+ // in the finally block: the voice path aborted ANY currently-active
5
+ // progress controller without confirming it was still the turn's own,
6
+ // which Codex flagged as a race. This module centralises both ends so
7
+ // they stay consistent.
8
+ //
9
+ // Usage:
10
+ // const lifecycle = createAgentTurnLifecycle({ bridge, warn });
11
+ //
12
+ // if (bridge.processing) { /* caller handles "busy" reply */ return; }
13
+ // const turn = lifecycle.start({ withTurnId: true });
14
+ // try {
15
+ // // ... per-turn work using turn.signal / turn.progressController.signal ...
16
+ // } finally {
17
+ // // Optionally clear progress-speech batch first (text path does this).
18
+ // lifecycle.finish(turn);
19
+ // // Any path-specific extras (e.g. drainDeferredProcessingUtterances).
20
+ // }
21
+ //
22
+ // `withTurnId: true` opts into the voice-path semantics where
23
+ // bridge.activeTurnId is incremented per turn and bridge.interruptedTurns
24
+ // tracks aborts. The text path leaves turnId unset.
25
+
26
+ export function createAgentTurnLifecycle(deps) {
27
+ const { bridge, warn } = deps;
28
+ // Note: clearing the progress-speech batch is intentionally NOT part of
29
+ // finish(). The voice path's pre-refactor cleanup did not call it; only
30
+ // the text path did. Callers that need it (currently
31
+ // handleTextAgentMessage) invoke clearProgressSpeechBatch explicitly
32
+ // before finish().
33
+
34
+ function start({ withTurnId = false } = {}) {
35
+ const controller = new AbortController();
36
+ bridge.currentAbortController = controller;
37
+ const progressController = new AbortController();
38
+ bridge.activeProgressAbortController = progressController;
39
+ bridge.activeProgressSignal = progressController.signal;
40
+ bridge.activeProgressLastEventAt = Date.now();
41
+ const previousTranscriptChannelId = bridge.activeTranscriptChannelId;
42
+ bridge.processing = true;
43
+ const turnId = withTurnId ? ++bridge.activeTurnId : undefined;
44
+ return {
45
+ controller,
46
+ signal: controller.signal,
47
+ progressController,
48
+ previousTranscriptChannelId,
49
+ turnId,
50
+ };
51
+ }
52
+
53
+ function finish(turn) {
54
+ if (!turn) return;
55
+ const { controller, progressController, previousTranscriptChannelId, turnId } = turn;
56
+
57
+ // Only abort the progress controller if it's still THIS turn's. The
58
+ // voice path's old inline cleanup omitted this check and could abort
59
+ // a newer turn's controller.
60
+ if (progressController
61
+ && bridge.activeProgressAbortController === progressController
62
+ && !progressController.signal.aborted) {
63
+ try { progressController.abort(); } catch (e) { warn?.('abort progress speech in cleanup failed', e?.stack || e); }
64
+ }
65
+ if (bridge.activeProgressAbortController === progressController) {
66
+ bridge.activeProgressAbortController = null;
67
+ }
68
+ if (bridge.activeProgressSignal === progressController?.signal) {
69
+ bridge.activeProgressSignal = null;
70
+ }
71
+
72
+ if (bridge.currentAbortController === controller) {
73
+ bridge.currentAbortController = null;
74
+ }
75
+ bridge.activeTranscriptChannelId = previousTranscriptChannelId;
76
+
77
+ if (turnId !== undefined) {
78
+ bridge.interruptedTurns.delete(turnId);
79
+ if (bridge.activeTurnId === turnId) bridge.activeTurnId = 0;
80
+ }
81
+
82
+ bridge.processing = false;
83
+ }
84
+
85
+ return { start, finish };
86
+ }
@@ -0,0 +1,109 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createAgentTurnLifecycle } from './agent_turn.mjs';
4
+ import { createBridge } from './bridge_context.mjs';
5
+
6
+ const noop = () => {};
7
+
8
+ function makeLifecycle(overrides = {}) {
9
+ const bridge = createBridge();
10
+ const lifecycle = createAgentTurnLifecycle({
11
+ bridge,
12
+ warn: noop,
13
+ ...overrides,
14
+ });
15
+ return { bridge, lifecycle };
16
+ }
17
+
18
+ test('start sets up controllers, progress signals, and processing flag', () => {
19
+ const { bridge, lifecycle } = makeLifecycle();
20
+ bridge.activeTranscriptChannelId = 'prev-ch';
21
+ const turn = lifecycle.start();
22
+ assert.ok(turn.controller instanceof AbortController);
23
+ assert.ok(turn.progressController instanceof AbortController);
24
+ assert.equal(turn.signal, turn.controller.signal);
25
+ assert.equal(turn.previousTranscriptChannelId, 'prev-ch');
26
+ assert.equal(turn.turnId, undefined, 'no turnId without withTurnId opt');
27
+ assert.equal(bridge.processing, true);
28
+ assert.equal(bridge.currentAbortController, turn.controller);
29
+ assert.equal(bridge.activeProgressAbortController, turn.progressController);
30
+ assert.equal(bridge.activeProgressSignal, turn.progressController.signal);
31
+ assert.ok(bridge.activeProgressLastEventAt > 0);
32
+ });
33
+
34
+ test('start with withTurnId allocates and exposes a turnId', () => {
35
+ const { bridge, lifecycle } = makeLifecycle();
36
+ const turn = lifecycle.start({ withTurnId: true });
37
+ assert.equal(typeof turn.turnId, 'number');
38
+ assert.equal(bridge.activeTurnId, turn.turnId);
39
+ const turn2 = lifecycle.start({ withTurnId: true });
40
+ assert.equal(turn2.turnId, turn.turnId + 1, 'turn ids monotonically increment');
41
+ });
42
+
43
+ test('finish clears all controllers + flags and restores transcript channel', () => {
44
+ const { bridge, lifecycle } = makeLifecycle();
45
+ bridge.activeTranscriptChannelId = 'prev-ch';
46
+ const turn = lifecycle.start();
47
+ bridge.activeTranscriptChannelId = 'turn-ch'; // simulate mutation during work
48
+ lifecycle.finish(turn);
49
+ assert.equal(bridge.processing, false);
50
+ assert.equal(bridge.currentAbortController, null);
51
+ assert.equal(bridge.activeProgressAbortController, null);
52
+ assert.equal(bridge.activeProgressSignal, null);
53
+ assert.equal(bridge.activeTranscriptChannelId, 'prev-ch', 'restored');
54
+ assert.equal(turn.progressController.signal.aborted, true);
55
+ });
56
+
57
+ test('finish does NOT touch the progress-speech batch (caller responsibility)', () => {
58
+ // Pre-refactor the voice path did not call clearProgressSpeechBatch; the
59
+ // text path did. The lifecycle stays path-agnostic — callers that need
60
+ // the batch cleared do it explicitly before finish().
61
+ const { bridge, lifecycle } = makeLifecycle();
62
+ bridge.progressSpeechBatch = ['queued event'];
63
+ bridge.progressSpeechBatchSignal = 'some-sig';
64
+ const turn = lifecycle.start();
65
+ lifecycle.finish(turn);
66
+ // Batch state preserved — caller decides when to drop it.
67
+ assert.deepEqual(bridge.progressSpeechBatch, ['queued event']);
68
+ assert.equal(bridge.progressSpeechBatchSignal, 'some-sig');
69
+ });
70
+
71
+ test('finish does NOT abort a newer turn that overrode the active progress controller', () => {
72
+ // This was the race Codex flagged on the voice path. We start turn A,
73
+ // then while A is still "in flight" we start turn B (simulating a
74
+ // re-entrant call); A's finish() must not abort B's progress controller.
75
+ const { bridge, lifecycle } = makeLifecycle();
76
+ const turnA = lifecycle.start();
77
+ // Turn B takes over (this WOULDN'T happen in practice because the
78
+ // processing guard prevents re-entry, but the cleanup must still be
79
+ // defensive against any path that leaves a stale controller in place).
80
+ const turnB = lifecycle.start();
81
+ // Now finish A. Its progressController was overridden by B; A's finish
82
+ // must leave B's controller alone.
83
+ lifecycle.finish(turnA);
84
+ assert.equal(bridge.activeProgressAbortController, turnB.progressController, 'B\'s progress controller untouched');
85
+ assert.equal(turnB.progressController.signal.aborted, false, 'B not aborted by A\'s cleanup');
86
+ // But A's own progressController did get aborted (it's still A's; it just wasn't bridge.active anymore).
87
+ // The contract is "only abort if it's the active one." A's controller is no longer active so it stays unaborted.
88
+ assert.equal(turnA.progressController.signal.aborted, false);
89
+ });
90
+
91
+ test('finish with withTurnId clears interruptedTurns and resets activeTurnId only when it matches', () => {
92
+ const { bridge, lifecycle } = makeLifecycle();
93
+ const turnA = lifecycle.start({ withTurnId: true });
94
+ bridge.interruptedTurns.add(turnA.turnId);
95
+ // Simulate a newer turn taking activeTurnId.
96
+ const turnB = lifecycle.start({ withTurnId: true });
97
+ lifecycle.finish(turnA);
98
+ assert.ok(!bridge.interruptedTurns.has(turnA.turnId), 'A removed from interruptedTurns');
99
+ assert.equal(bridge.activeTurnId, turnB.turnId, 'activeTurnId left as B\'s id (only resets when matching)');
100
+ });
101
+
102
+ test('finish is a no-op when passed null/undefined', () => {
103
+ const { bridge, lifecycle } = makeLifecycle();
104
+ bridge.processing = true;
105
+ lifecycle.finish(null);
106
+ lifecycle.finish(undefined);
107
+ // processing is unchanged because we ignored both calls
108
+ assert.equal(bridge.processing, true);
109
+ });
@@ -0,0 +1,73 @@
1
+ // Shared mutable state for the Discord voice bridge.
2
+ //
3
+ // main.mjs used to hold ~40 module-level `let` bindings and `Map`/`Set`
4
+ // containers that the extracted voice_io / tts_player / utterance_router
5
+ // modules need to read and write by reference. Wrapping them in one object
6
+ // means every extracted module sees the same live state without having to
7
+ // thread dozens of getters/setters through call sites.
8
+ //
9
+ // Fields that depend on settings/client/etc. are seeded after the caller
10
+ // has built those (see `seedBridge`); the factory itself only allocates
11
+ // containers and primitive defaults.
12
+
13
+ export function createBridge() {
14
+ return {
15
+ // Discord client & active voice channel state
16
+ connection: null,
17
+ player: null,
18
+ speaking: false,
19
+ processing: false,
20
+ activeTurnId: 0,
21
+ activeVoiceChannelId: '',
22
+ activeTranscriptChannelId: '',
23
+ currentAbortController: null,
24
+ activeStreams: new Map(),
25
+ interruptedTurns: new Set(),
26
+ speechPlaybackGeneration: 0,
27
+ recentDiscordTextByChannel: new Map(),
28
+
29
+ // External adapters/state holders set during boot
30
+ ttsBackend: null,
31
+ bridgeState: null,
32
+
33
+ // Verbose / progress speech
34
+ verboseProgress: false,
35
+ activeProgressSignal: null,
36
+ verboseProgressSpeechQueue: Promise.resolve(),
37
+ activeProgressAbortController: null,
38
+ progressSpeechBatch: [],
39
+ progressSpeechBatchTimer: null,
40
+ progressSpeechBatchSignal: null,
41
+ progressSpeechBatchStartedAt: 0,
42
+ activeProgressLastEventAt: 0,
43
+ lastVerboseProgressText: '',
44
+ lastVerboseProgressTextAt: 0,
45
+
46
+ // Streaming TTS pipeline
47
+ activeSentencer: null,
48
+ activeStreamingQueue: null,
49
+ streamingSpeechDelivered: false,
50
+
51
+ // Notifications (ntfy/pushover)
52
+ notifyUserOptIn: false,
53
+ notifierInstance: null,
54
+ lastNotifyAt: 0,
55
+ lastNotifyBody: '',
56
+
57
+ // Plan mode / cross-agent routing / per-channel session state
58
+ planStates: new Map(),
59
+ agentAdaptersBySession: new Map(),
60
+ agentAdaptersByBackend: new Map(),
61
+ routingStateByChannel: new Map(),
62
+ ontologyByChannel: new Map(),
63
+ installedBinaryCache: new Map(),
64
+
65
+ // Barge-in sensitivity
66
+ sensitivityMode: 'normal',
67
+ sensitivityModeExpiresAt: 0,
68
+
69
+ // Smart progress summarizer
70
+ smartProgressEnabled: false,
71
+ smartProgressSummarizer: null,
72
+ };
73
+ }
@@ -0,0 +1,54 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createBridge } from './bridge_context.mjs';
4
+
5
+ test('createBridge returns independent state per call', () => {
6
+ const a = createBridge();
7
+ const b = createBridge();
8
+ a.speaking = true;
9
+ a.activeStreams.set('user-1', { foo: 1 });
10
+ a.planStates.set('chan', { steps: [] });
11
+ assert.equal(b.speaking, false);
12
+ assert.equal(b.activeStreams.size, 0);
13
+ assert.equal(b.planStates.size, 0);
14
+ });
15
+
16
+ test('createBridge seeds the expected containers and primitives', () => {
17
+ const b = createBridge();
18
+ // Containers
19
+ for (const key of [
20
+ 'activeStreams', 'interruptedTurns', 'recentDiscordTextByChannel',
21
+ 'planStates', 'agentAdaptersBySession', 'agentAdaptersByBackend',
22
+ 'routingStateByChannel', 'ontologyByChannel', 'installedBinaryCache',
23
+ ]) {
24
+ assert.ok(b[key] instanceof Map || b[key] instanceof Set, `${key} is a container`);
25
+ assert.equal(b[key].size, 0, `${key} starts empty`);
26
+ }
27
+ // Primitive defaults
28
+ assert.equal(b.connection, null);
29
+ assert.equal(b.player, null);
30
+ assert.equal(b.ttsBackend, null);
31
+ assert.equal(b.bridgeState, null);
32
+ assert.equal(b.speaking, false);
33
+ assert.equal(b.processing, false);
34
+ assert.equal(b.activeTurnId, 0);
35
+ assert.equal(b.activeVoiceChannelId, '');
36
+ assert.equal(b.activeTranscriptChannelId, '');
37
+ assert.equal(b.speechPlaybackGeneration, 0);
38
+ assert.equal(b.notifyUserOptIn, false);
39
+ assert.equal(b.streamingSpeechDelivered, false);
40
+ assert.equal(b.sensitivityMode, 'normal');
41
+ assert.ok(b.verboseProgressSpeechQueue instanceof Promise);
42
+ assert.deepEqual(b.progressSpeechBatch, []);
43
+ });
44
+
45
+ test('mutations on one bridge do not leak to another', () => {
46
+ const a = createBridge();
47
+ const b = createBridge();
48
+ a.routingStateByChannel.set('c1', { backend: 'codex' });
49
+ a.processing = true;
50
+ a.activeTurnId = 7;
51
+ assert.equal(b.routingStateByChannel.size, 0);
52
+ assert.equal(b.processing, false);
53
+ assert.equal(b.activeTurnId, 0);
54
+ });
@@ -32,6 +32,10 @@ export function createBridgeState({ log = () => {}, cleanupFile = () => {} } = {
32
32
 
33
33
  function deletePending(userId) {
34
34
  const pending = pendingUtterances.get(userId);
35
+ if (pending?.timer) {
36
+ clearTimeout(pending.timer);
37
+ pending.timer = null;
38
+ }
35
39
  pendingUtterances.delete(userId);
36
40
  return pending;
37
41
  }