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
@@ -7,6 +7,11 @@ import path from 'node:path';
7
7
  import { createVerbalCodingMcpTools, readEnvFile } from './mcp_tools.mjs';
8
8
  import { AUTO_RESTART_ENV_KEY } from './restart_policy.mjs';
9
9
 
10
+ const __tempRoots = [];
11
+ test.after(() => {
12
+ for (const root of __tempRoots) try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
13
+ });
14
+
10
15
  test('MCP tool definitions expose VerbalCoding control surface', () => {
11
16
  const { toolDefs, tools } = createVerbalCodingMcpTools({ root: process.cwd() });
12
17
  const names = toolDefs.map(tool => tool.name).sort();
@@ -16,6 +21,7 @@ test('MCP tool definitions expose VerbalCoding control surface', () => {
16
21
 
17
22
  test('set_auto_restart MCP tool writes the default-off restart flag', async () => {
18
23
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-mcp-'));
24
+ __tempRoots.push(dir);
19
25
  const envPath = path.join(dir, '.env');
20
26
  const { tools } = createVerbalCodingMcpTools({ root: dir, envPath });
21
27
  const off = await tools.get('set_auto_restart').handler({ enabled: false });
@@ -28,6 +34,7 @@ test('set_auto_restart MCP tool writes the default-off restart flag', async () =
28
34
 
29
35
  test('set_language MCP tool updates STT, progress, and TTS language together', async () => {
30
36
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-mcp-lang-'));
37
+ __tempRoots.push(dir);
31
38
  const envPath = path.join(dir, '.env');
32
39
  const { tools } = createVerbalCodingMcpTools({ root: dir, envPath });
33
40
  const result = await tools.get('set_language').handler({ language: 'en' });
@@ -0,0 +1,89 @@
1
+ // Runtime notification dispatcher built on top of `./notify.mjs`'s createNotifier.
2
+ //
3
+ // Phase 5b extraction from main.mjs. Closes over bridge state
4
+ // (notifierInstance, notifyUserOptIn, lastNotifyAt, lastNotifyBody,
5
+ // activeVoiceChannelId) plus the Discord client for human-count lookups.
6
+ //
7
+ // `ttsFallbackNotice` stays in main.mjs: it's wired through createTtsBackend's
8
+ // onFallback callback at module init (before ttsPlayer / sendText / speakText
9
+ // are fully bound), and threading it through a factory would require thunk
10
+ // indirection for marginal gain.
11
+
12
+ import { createNotifier, buildDiscordDeepLink } from './notify.mjs';
13
+
14
+ export function createNotificationHandler(deps) {
15
+ const {
16
+ bridge,
17
+ client,
18
+ log,
19
+ warn,
20
+ } = deps;
21
+
22
+ function ensureNotifier() {
23
+ if (bridge.notifierInstance) return bridge.notifierInstance;
24
+ bridge.notifierInstance = createNotifier({
25
+ provider: (process.env.NOTIFY_PROVIDER || 'ntfy').toLowerCase(),
26
+ topic: process.env.NTFY_TOPIC || '',
27
+ pushoverUser: process.env.PUSHOVER_USER || '',
28
+ pushoverToken: process.env.PUSHOVER_TOKEN || '',
29
+ });
30
+ return bridge.notifierInstance;
31
+ }
32
+
33
+ function notifyStatusText() {
34
+ const provider = (process.env.NOTIFY_PROVIDER || 'ntfy').toLowerCase();
35
+ const hasTopic = provider === 'ntfy' ? Boolean(process.env.NTFY_TOPIC) : (provider === 'pushover' ? Boolean(process.env.PUSHOVER_USER && process.env.PUSHOVER_TOKEN) : true);
36
+ const mode = bridge.notifyUserOptIn ? 'always' : 'empty-channel only';
37
+ const config = hasTopic ? 'configured' : 'NOT configured';
38
+ return `notify: ${mode} via ${provider} (${config}). Threshold: ${process.env.NOTIFY_MIN_TASK_MS || '60000'}ms.`;
39
+ }
40
+
41
+ async function getVoiceChannelHumanCount() {
42
+ if (!bridge.activeVoiceChannelId) return 0;
43
+ try {
44
+ const ch = await client.channels.fetch(bridge.activeVoiceChannelId).catch(() => null);
45
+ if (!ch || !ch.members) return 0;
46
+ let count = 0;
47
+ for (const [, m] of ch.members) if (!m.user?.bot) count += 1;
48
+ return count;
49
+ } catch (e) {
50
+ warn('humanCount failed', e?.message || e);
51
+ return 0;
52
+ }
53
+ }
54
+
55
+ async function maybeNotifyTaskComplete({ answer, label, elapsedMs, guildId }) {
56
+ const provider = (process.env.NOTIFY_PROVIDER || '').toLowerCase();
57
+ if (!provider || provider === 'noop') return;
58
+ const minTaskMs = Number(process.env.NOTIFY_MIN_TASK_MS || '60000');
59
+ const debounceMs = Number(process.env.NOTIFY_DEBOUNCE_MS || '30000');
60
+ const humanCount = await getVoiceChannelHumanCount();
61
+ const notifier = ensureNotifier();
62
+ if (!notifier.shouldNotify({ humanCount, taskMs: elapsedMs, minTaskMs, userOptIn: bridge.notifyUserOptIn })) return;
63
+ const text = String(answer || '').trim();
64
+ const lastSentence = text.split(/(?<=[.!?。!?])\s+/).filter(Boolean).pop() || text;
65
+ const body = lastSentence.slice(0, 200);
66
+ const now = Date.now();
67
+ if (body && body === bridge.lastNotifyBody && now - bridge.lastNotifyAt < debounceMs) {
68
+ log('notify debounced', 'sinceLastMs', now - bridge.lastNotifyAt);
69
+ return;
70
+ }
71
+ const title = label ? `${label} finished` : 'VerbalCoding finished';
72
+ const deepLink = buildDiscordDeepLink({ guildId, channelId: bridge.activeVoiceChannelId });
73
+ try {
74
+ const result = await notifier.send({ title, body, deepLink });
75
+ bridge.lastNotifyAt = now;
76
+ bridge.lastNotifyBody = body;
77
+ log('notify sent', 'provider', provider, 'status', result?.status || result?.ok, 'skipped', result?.skipped || false);
78
+ } catch (e) {
79
+ warn('notify send failed', e?.message || e);
80
+ }
81
+ }
82
+
83
+ return {
84
+ ensureNotifier,
85
+ notifyStatusText,
86
+ getVoiceChannelHumanCount,
87
+ maybeNotifyTaskComplete,
88
+ };
89
+ }
@@ -0,0 +1,187 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createNotificationHandler } from './notification_handler.mjs';
4
+ import { createBridge } from './bridge_context.mjs';
5
+
6
+ function makeDeps(overrides = {}) {
7
+ const bridge = createBridge();
8
+ const client = {
9
+ channels: {
10
+ fetch: async () => ({ members: new Map() }),
11
+ },
12
+ };
13
+ return { bridge, client, log: () => {}, warn: () => {}, ...overrides };
14
+ }
15
+
16
+ test('createNotificationHandler exposes the expected functions', () => {
17
+ const h = createNotificationHandler(makeDeps());
18
+ for (const name of ['ensureNotifier', 'notifyStatusText', 'getVoiceChannelHumanCount', 'maybeNotifyTaskComplete']) {
19
+ assert.equal(typeof h[name], 'function', `${name} is exposed`);
20
+ }
21
+ });
22
+
23
+ test('ensureNotifier memoizes onto bridge.notifierInstance', () => {
24
+ const deps = makeDeps();
25
+ const { ensureNotifier } = createNotificationHandler(deps);
26
+ const first = ensureNotifier();
27
+ const second = ensureNotifier();
28
+ assert.equal(first, second);
29
+ assert.equal(deps.bridge.notifierInstance, first);
30
+ });
31
+
32
+ test('notifyStatusText reflects userOptIn and provider configuration', () => {
33
+ const prev = { ...process.env };
34
+ process.env.NOTIFY_PROVIDER = 'ntfy';
35
+ process.env.NTFY_TOPIC = '';
36
+ try {
37
+ const deps = makeDeps();
38
+ const { notifyStatusText } = createNotificationHandler(deps);
39
+ let text = notifyStatusText();
40
+ assert.match(text, /empty-channel only/);
41
+ assert.match(text, /NOT configured/);
42
+ deps.bridge.notifyUserOptIn = true;
43
+ process.env.NTFY_TOPIC = 'topic-x';
44
+ text = notifyStatusText();
45
+ assert.match(text, /always/);
46
+ assert.match(text, /\(configured\)/);
47
+ } finally {
48
+ Object.keys(process.env).forEach(k => { if (!(k in prev)) delete process.env[k]; });
49
+ Object.assign(process.env, prev);
50
+ }
51
+ });
52
+
53
+ test('getVoiceChannelHumanCount returns 0 when no active channel', async () => {
54
+ const deps = makeDeps();
55
+ const { getVoiceChannelHumanCount } = createNotificationHandler(deps);
56
+ assert.equal(await getVoiceChannelHumanCount(), 0);
57
+ });
58
+
59
+ test('getVoiceChannelHumanCount excludes bots', async () => {
60
+ const members = new Map([
61
+ ['1', { user: { bot: false } }],
62
+ ['2', { user: { bot: true } }],
63
+ ['3', { user: { bot: false } }],
64
+ ]);
65
+ const deps = makeDeps({ client: { channels: { fetch: async () => ({ members }) } } });
66
+ deps.bridge.activeVoiceChannelId = 'vc-1';
67
+ const { getVoiceChannelHumanCount } = createNotificationHandler(deps);
68
+ assert.equal(await getVoiceChannelHumanCount(), 2);
69
+ });
70
+
71
+ test('maybeNotifyTaskComplete returns early when provider is unset/noop', async () => {
72
+ const prev = process.env.NOTIFY_PROVIDER;
73
+ try {
74
+ delete process.env.NOTIFY_PROVIDER;
75
+ const deps = makeDeps();
76
+ const { maybeNotifyTaskComplete } = createNotificationHandler(deps);
77
+ await maybeNotifyTaskComplete({ answer: 'ok', label: 'agent', elapsedMs: 99999, guildId: 'g' });
78
+ assert.equal(deps.bridge.notifierInstance, null, 'no notifier when provider unset');
79
+ process.env.NOTIFY_PROVIDER = 'noop';
80
+ await maybeNotifyTaskComplete({ answer: 'ok', label: 'agent', elapsedMs: 99999, guildId: 'g' });
81
+ assert.equal(deps.bridge.notifierInstance, null, 'no notifier when provider noop');
82
+ } finally {
83
+ if (prev === undefined) delete process.env.NOTIFY_PROVIDER;
84
+ else process.env.NOTIFY_PROVIDER = prev;
85
+ }
86
+ });
87
+
88
+ // --- happy-path send + debounce + body construction ----------------------
89
+
90
+ test('maybeNotifyTaskComplete fires notifier.send with last sentence as body, deep link, and tracks lastNotify state', async () => {
91
+ const prev = { ...process.env };
92
+ const calls = [];
93
+ process.env.NOTIFY_PROVIDER = 'ntfy';
94
+ process.env.NTFY_TOPIC = 'topic-x';
95
+ process.env.NOTIFY_MIN_TASK_MS = '0'; // pass the min-task gate
96
+ process.env.NOTIFY_DEBOUNCE_MS = '5000';
97
+ try {
98
+ const deps = makeDeps();
99
+ deps.bridge.notifyUserOptIn = true; // bypass humanCount/empty-channel gate
100
+ deps.bridge.activeVoiceChannelId = 'vc-1'; // for deepLink construction
101
+ // Pre-seed a notifier so we don't depend on createNotifier internals.
102
+ deps.bridge.notifierInstance = {
103
+ shouldNotify: () => true,
104
+ send: async payload => { calls.push(payload); return { ok: true, status: 200 }; },
105
+ };
106
+ const { maybeNotifyTaskComplete } = createNotificationHandler(deps);
107
+ const answer = 'first sentence here. SECOND SENTENCE is the body.';
108
+ await maybeNotifyTaskComplete({ answer, label: 'hermes', elapsedMs: 5000, guildId: 'g-1' });
109
+ assert.equal(calls.length, 1, 'notifier.send called exactly once');
110
+ assert.equal(calls[0].title, 'hermes finished');
111
+ assert.equal(calls[0].body, 'SECOND SENTENCE is the body.', 'body = last sentence');
112
+ assert.match(calls[0].deepLink, /g-1/, 'deep link includes guild id');
113
+ assert.ok(deps.bridge.lastNotifyAt > 0, 'lastNotifyAt updated');
114
+ assert.equal(deps.bridge.lastNotifyBody, 'SECOND SENTENCE is the body.');
115
+ } finally {
116
+ Object.keys(process.env).forEach(k => { if (!(k in prev)) delete process.env[k]; });
117
+ Object.assign(process.env, prev);
118
+ }
119
+ });
120
+
121
+ test('maybeNotifyTaskComplete debounces identical body within debounce window', async () => {
122
+ const prev = { ...process.env };
123
+ process.env.NOTIFY_PROVIDER = 'ntfy';
124
+ process.env.NTFY_TOPIC = 'topic-x';
125
+ process.env.NOTIFY_MIN_TASK_MS = '0';
126
+ process.env.NOTIFY_DEBOUNCE_MS = '60000';
127
+ try {
128
+ let sendCalls = 0;
129
+ const deps = makeDeps();
130
+ deps.bridge.notifyUserOptIn = true;
131
+ deps.bridge.notifierInstance = {
132
+ shouldNotify: () => true,
133
+ send: async () => { sendCalls++; return { ok: true }; },
134
+ };
135
+ const { maybeNotifyTaskComplete } = createNotificationHandler(deps);
136
+ const answer = 'identical message';
137
+ await maybeNotifyTaskComplete({ answer, label: 'a', elapsedMs: 5000, guildId: 'g' });
138
+ await maybeNotifyTaskComplete({ answer, label: 'a', elapsedMs: 5000, guildId: 'g' });
139
+ assert.equal(sendCalls, 1, 'second identical call is debounced');
140
+ } finally {
141
+ Object.keys(process.env).forEach(k => { if (!(k in prev)) delete process.env[k]; });
142
+ Object.assign(process.env, prev);
143
+ }
144
+ });
145
+
146
+ test('maybeNotifyTaskComplete respects shouldNotify=false (e.g. occupied channel, opt-out)', async () => {
147
+ const prev = { ...process.env };
148
+ process.env.NOTIFY_PROVIDER = 'ntfy';
149
+ process.env.NOTIFY_MIN_TASK_MS = '0';
150
+ try {
151
+ let sendCalls = 0;
152
+ const deps = makeDeps();
153
+ deps.bridge.notifierInstance = {
154
+ shouldNotify: () => false,
155
+ send: async () => { sendCalls++; return { ok: true }; },
156
+ };
157
+ const { maybeNotifyTaskComplete } = createNotificationHandler(deps);
158
+ await maybeNotifyTaskComplete({ answer: 'hi', label: 'x', elapsedMs: 9999, guildId: 'g' });
159
+ assert.equal(sendCalls, 0, 'send not called when shouldNotify returns false');
160
+ assert.equal(deps.bridge.lastNotifyAt, 0, 'lastNotifyAt untouched');
161
+ } finally {
162
+ Object.keys(process.env).forEach(k => { if (!(k in prev)) delete process.env[k]; });
163
+ Object.assign(process.env, prev);
164
+ }
165
+ });
166
+
167
+ test('maybeNotifyTaskComplete swallows notifier.send errors and warns', async () => {
168
+ const prev = { ...process.env };
169
+ process.env.NOTIFY_PROVIDER = 'ntfy';
170
+ process.env.NOTIFY_MIN_TASK_MS = '0';
171
+ try {
172
+ const warnCalls = [];
173
+ const deps = makeDeps({ warn: (...args) => warnCalls.push(args) });
174
+ deps.bridge.notifyUserOptIn = true;
175
+ deps.bridge.notifierInstance = {
176
+ shouldNotify: () => true,
177
+ send: async () => { throw new Error('network down'); },
178
+ };
179
+ const { maybeNotifyTaskComplete } = createNotificationHandler(deps);
180
+ // Must not reject the calling code.
181
+ await maybeNotifyTaskComplete({ answer: 'hi', label: 'x', elapsedMs: 9999, guildId: 'g' });
182
+ assert.ok(warnCalls.some(args => /notify send failed/.test(args[0])), 'warn called with explanatory message');
183
+ } finally {
184
+ Object.keys(process.env).forEach(k => { if (!(k in prev)) delete process.env[k]; });
185
+ Object.assign(process.env, prev);
186
+ }
187
+ });
@@ -0,0 +1,73 @@
1
+ const SECRET_RE = /\b(?:token|api[_-]?key|password|secret|authorization|bearer)\b\s*[:=]?\s*\S+/gi;
2
+ const SK_RE = /\bsk-[a-zA-Z0-9_-]{8,}\b/g;
3
+ const NTFY_BASE_DEFAULT = 'https://ntfy.sh';
4
+
5
+ function redact(text) {
6
+ return String(text || '')
7
+ .replace(SECRET_RE, '$& [REDACTED]')
8
+ .replace(/(\[REDACTED\]\s*)+/g, '[REDACTED] ')
9
+ .replace(SK_RE, '[REDACTED]');
10
+ }
11
+
12
+ export function buildDiscordDeepLink({ guildId, channelId } = {}) {
13
+ if (!guildId || !channelId) return '';
14
+ return `https://discord.com/channels/${guildId}/${channelId}`;
15
+ }
16
+
17
+ export function createNotifier({
18
+ provider = 'ntfy',
19
+ topic = '',
20
+ fetchImpl = globalThis.fetch,
21
+ ntfyBase = NTFY_BASE_DEFAULT,
22
+ pushoverUser = '',
23
+ pushoverToken = '',
24
+ } = {}) {
25
+ async function sendNtfy({ title, body, deepLink }) {
26
+ if (!topic) return { skipped: true, reason: 'no topic' };
27
+ const headers = { Title: title };
28
+ if (deepLink) headers.Click = deepLink;
29
+ const res = await fetchImpl(`${ntfyBase}/${encodeURIComponent(topic)}`, {
30
+ method: 'POST',
31
+ headers,
32
+ body,
33
+ });
34
+ return { ok: !!res?.ok, status: res?.status };
35
+ }
36
+
37
+ async function sendPushover({ title, body, deepLink }) {
38
+ if (!pushoverUser || !pushoverToken) return { skipped: true, reason: 'no pushover creds' };
39
+ const payload = {
40
+ user: pushoverUser,
41
+ token: pushoverToken,
42
+ title,
43
+ message: body,
44
+ };
45
+ if (deepLink) {
46
+ payload.url = deepLink;
47
+ payload.url_title = 'Open Discord';
48
+ }
49
+ const res = await fetchImpl('https://api.pushover.net/1/messages.json', {
50
+ method: 'POST',
51
+ headers: { 'content-type': 'application/json' },
52
+ body: JSON.stringify(payload),
53
+ });
54
+ return { ok: !!res?.ok, status: res?.status };
55
+ }
56
+
57
+ async function send({ title = 'VerbalCoding', body = '', deepLink = '' } = {}) {
58
+ const safeBody = redact(body).slice(0, 200);
59
+ const safeTitle = redact(title).slice(0, 80);
60
+ if (provider === 'ntfy') return sendNtfy({ title: safeTitle, body: safeBody, deepLink });
61
+ if (provider === 'pushover') return sendPushover({ title: safeTitle, body: safeBody, deepLink });
62
+ if (provider === 'noop') return { ok: true };
63
+ throw new Error(`unknown notify provider ${provider}`);
64
+ }
65
+
66
+ function shouldNotify({ humanCount = 0, taskMs = 0, minTaskMs = 60_000, userOptIn = false } = {}) {
67
+ if (taskMs < minTaskMs) return false;
68
+ if (userOptIn) return true;
69
+ return humanCount === 0;
70
+ }
71
+
72
+ return { send, shouldNotify };
73
+ }
@@ -0,0 +1,68 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createNotifier, buildDiscordDeepLink } from './notify.mjs';
4
+
5
+ test('ntfy provider posts to topic URL with title body and click headers', async () => {
6
+ const calls = [];
7
+ const fetchImpl = async (url, opts) => { calls.push({ url, opts }); return { ok: true, status: 200 }; };
8
+ const n = createNotifier({ provider: 'ntfy', topic: 'verbalcoding-test', fetchImpl });
9
+ const res = await n.send({ title: 'Done', body: 'All green.', deepLink: 'discord://x' });
10
+ assert.equal(res.ok, true);
11
+ assert.equal(calls.length, 1);
12
+ assert.match(calls[0].url, /ntfy\.sh\/verbalcoding-test/);
13
+ assert.equal(calls[0].opts.headers.Title, 'Done');
14
+ assert.equal(calls[0].opts.headers.Click, 'discord://x');
15
+ assert.equal(calls[0].opts.body, 'All green.');
16
+ });
17
+
18
+ test('ntfy returns skipped when topic missing', async () => {
19
+ const n = createNotifier({ provider: 'ntfy', fetchImpl: async () => ({ ok: true }) });
20
+ const res = await n.send({ title: 't', body: 'b' });
21
+ assert.equal(res.skipped, true);
22
+ });
23
+
24
+ test('shouldNotify true when zero humans and task long enough', () => {
25
+ const n = createNotifier({ provider: 'noop' });
26
+ assert.equal(n.shouldNotify({ humanCount: 0, taskMs: 10_000, minTaskMs: 1000 }), true);
27
+ assert.equal(n.shouldNotify({ humanCount: 1, taskMs: 10_000, minTaskMs: 1000 }), false);
28
+ assert.equal(n.shouldNotify({ humanCount: 0, taskMs: 100, minTaskMs: 1000 }), false);
29
+ assert.equal(n.shouldNotify({ humanCount: 1, taskMs: 10_000, minTaskMs: 1000, userOptIn: true }), true);
30
+ });
31
+
32
+ test('redacts secret patterns from body', async () => {
33
+ const calls = [];
34
+ const fetchImpl = async (u, o) => { calls.push(o); return { ok: true }; };
35
+ const n = createNotifier({ provider: 'ntfy', topic: 'x', fetchImpl });
36
+ await n.send({ title: 't', body: 'token=abc123 finished. sk-fooBar12 also.', deepLink: '' });
37
+ assert.match(calls[0].body, /\[REDACTED\]/);
38
+ assert.doesNotMatch(calls[0].body, /sk-fooBar12/);
39
+ });
40
+
41
+ test('truncates long body to 200 chars', async () => {
42
+ const calls = [];
43
+ const fetchImpl = async (u, o) => { calls.push(o); return { ok: true }; };
44
+ const n = createNotifier({ provider: 'ntfy', topic: 'x', fetchImpl });
45
+ await n.send({ title: 't', body: 'x'.repeat(500) });
46
+ assert.equal(calls[0].body.length, 200);
47
+ });
48
+
49
+ test('pushover posts json payload with url', async () => {
50
+ const calls = [];
51
+ const fetchImpl = async (url, opts) => { calls.push({ url, opts }); return { ok: true, status: 200 }; };
52
+ const n = createNotifier({ provider: 'pushover', pushoverUser: 'u', pushoverToken: 'tk', fetchImpl });
53
+ await n.send({ title: 'Done', body: 'all good', deepLink: 'discord://x' });
54
+ assert.match(calls[0].url, /pushover\.net/);
55
+ const body = JSON.parse(calls[0].opts.body);
56
+ assert.equal(body.user, 'u');
57
+ assert.equal(body.url, 'discord://x');
58
+ });
59
+
60
+ test('buildDiscordDeepLink composes web URL', () => {
61
+ assert.equal(buildDiscordDeepLink({ guildId: '1', channelId: '2' }), 'https://discord.com/channels/1/2');
62
+ assert.equal(buildDiscordDeepLink({}), '');
63
+ });
64
+
65
+ test('unknown provider throws', async () => {
66
+ const n = createNotifier({ provider: 'unknown' });
67
+ await assert.rejects(() => n.send({ title: 't', body: 'b' }), /unknown notify provider/);
68
+ });
@@ -0,0 +1,215 @@
1
+ // Plan-mode dispatcher: STATEFUL plan-mode logic that builds on
2
+ // ./plan_mode.mjs's pure parsers/renderers. Owns the per-channel plan
3
+ // state lifecycle on bridge.planStates and the multi-turn decision UX.
4
+ //
5
+ // Phase 7b extraction from utterance_router.mjs. Five functions:
6
+ // - planChannelKey: which channel-id keys the plan state. Kept here
7
+ // because plan state, routing state, and ontology state all share
8
+ // this key shape.
9
+ // - askNextDecision / finalizePlanReady / planNarrationLines: the
10
+ // narration helpers used to prompt the user for next decisions or
11
+ // confirm a plan is ready to run.
12
+ // - dispatchPlanModeUtterance: the multi-turn state machine. Detects
13
+ // plan-entry utterances, processes voice approve/skip/insert/cancel
14
+ // commands, resolves decisions one at a time, and either re-prompts
15
+ // or returns { handled, prompt? } for the caller to feed to the agent.
16
+ //
17
+ // Caller integration: voice_turn_runner consumes dispatchPlanModeUtterance
18
+ // as a dep. The Discord text path doesn't touch plan mode (no plan-mode
19
+ // integration in text agent messages today).
20
+
21
+ export function createPlanDispatcher(deps) {
22
+ const {
23
+ bridge,
24
+ settings,
25
+ sendText,
26
+ speakText,
27
+ routingStateFor,
28
+ adapterForBackend,
29
+ adapterForProjectSession,
30
+ resolveProjectSessionForChannel,
31
+ isAgentRoutingDecision,
32
+ parseDecisionAnswer,
33
+ parsePlanVoiceCommand,
34
+ applyPlanCommand,
35
+ parsePlanOutput,
36
+ renderDecisionPrompt,
37
+ renderResolvedDecisions,
38
+ renderFinalPlan,
39
+ planModePreamble,
40
+ planExecutionPreamble,
41
+ isPlanEntryUtterance,
42
+ } = deps;
43
+
44
+ function planChannelKey() {
45
+ return bridge.activeVoiceChannelId || settings.transcriptChannelId || 'default';
46
+ }
47
+
48
+ async function askNextDecision(state, signal) {
49
+ const decision = state.decisions[state.pendingDecisionIndex];
50
+ if (!decision) return;
51
+ const text = renderDecisionPrompt(decision, state.language);
52
+ await sendText(`❓ ${text}`);
53
+ await speakText(text, signal, null);
54
+ }
55
+
56
+ async function finalizePlanReady(state, signal) {
57
+ const language = state.language;
58
+ const resolvedLine = renderResolvedDecisions(state.resolvedDecisions, language);
59
+ const plan = planNarrationLines(state.steps, language);
60
+ const tail = /^en/i.test(String(language || ''))
61
+ ? `${plan}\n${resolvedLine}\nSay "approve" to run, or edit with skip/insert.`
62
+ : `${plan}\n${resolvedLine}\n"실행"이라고 하면 시작할게. skip/insert로 수정도 돼.`;
63
+ await sendText(`📝 ${tail}`);
64
+ await speakText(tail, signal, null);
65
+ }
66
+
67
+ async function dispatchPlanModeUtterance(prompt, signal) {
68
+ const language = settings.voiceLanguage;
69
+ const key = planChannelKey();
70
+ const existing = bridge.planStates.get(key);
71
+
72
+ if (existing && existing.pendingDecisionIndex < existing.decisions.length) {
73
+ const controlCommand = parsePlanVoiceCommand(prompt, language);
74
+ if (controlCommand.type === 'cancel') {
75
+ const cancelState = routingStateFor(key);
76
+ if (existing.routingSnapshot) cancelState.activeRouting = { ...existing.routingSnapshot };
77
+ cancelState.pendingFallbackPrompt = null;
78
+ cancelState.lastResolvedDecisions = {};
79
+ bridge.planStates.delete(key);
80
+ const msg = /^en/i.test(String(language || '')) ? 'Plan cancelled.' : '계획을 취소했어.';
81
+ await sendText(`❎ ${msg}`);
82
+ await speakText(msg, signal, null);
83
+ return { handled: true };
84
+ }
85
+ const decision = existing.decisions[existing.pendingDecisionIndex];
86
+ const answer = parseDecisionAnswer(prompt, decision, language);
87
+ if (answer.type === 'unknown') {
88
+ await sendText(/^en/i.test(String(language || ''))
89
+ ? '⚠️ I did not catch that. Please pick an option.'
90
+ : '⚠️ 못 알아들었어. 옵션 중에 하나 골라줘.');
91
+ await askNextDecision(existing, signal);
92
+ return { handled: true };
93
+ }
94
+ const next = {
95
+ ...existing,
96
+ resolvedDecisions: { ...existing.resolvedDecisions, [decision.slot]: answer.choice },
97
+ pendingDecisionIndex: existing.pendingDecisionIndex + 1,
98
+ };
99
+ bridge.planStates.set(key, next);
100
+ if (isAgentRoutingDecision(decision) && answer.choice) {
101
+ const candidate = adapterForBackend(answer.choice, resolveProjectSessionForChannel(key));
102
+ if (candidate) {
103
+ routingStateFor(key).activeRouting = { backend: answer.choice, sticky: true };
104
+ } else {
105
+ const msg = /^en/i.test(String(language || ''))
106
+ ? `${answer.choice} is not installed; staying with ${settings.agent.label}.`
107
+ : `${answer.choice}이(가) 설치되어 있지 않아. ${settings.agent.label}로 진행할게.`;
108
+ await sendText(`⚠️ ${msg}`);
109
+ await speakText(msg, signal, null);
110
+ }
111
+ }
112
+ if (next.pendingDecisionIndex < next.decisions.length) {
113
+ await askNextDecision(next, signal);
114
+ } else {
115
+ await finalizePlanReady(next, signal);
116
+ }
117
+ return { handled: true };
118
+ }
119
+
120
+ if (existing) {
121
+ const cmd = parsePlanVoiceCommand(prompt, language);
122
+ if (cmd.type === 'skip' || cmd.type === 'insert') {
123
+ const nextSteps = applyPlanCommand(existing.steps, cmd);
124
+ bridge.planStates.set(key, { ...existing, steps: nextSteps });
125
+ await finalizePlanReady({ ...existing, steps: nextSteps }, signal);
126
+ return { handled: true };
127
+ }
128
+ if (cmd.type === 'cancel') {
129
+ const cancelState = routingStateFor(key);
130
+ if (existing.routingSnapshot) cancelState.activeRouting = { ...existing.routingSnapshot };
131
+ cancelState.pendingFallbackPrompt = null;
132
+ cancelState.lastResolvedDecisions = {};
133
+ bridge.planStates.delete(key);
134
+ const msg = /^en/i.test(String(language || '')) ? 'Plan cancelled.' : '계획을 취소했어.';
135
+ await sendText(`❎ ${msg}`);
136
+ await speakText(msg, signal, null);
137
+ return { handled: true };
138
+ }
139
+ if (cmd.type === 'approve') {
140
+ routingStateFor(key).lastResolvedDecisions = existing.resolvedDecisions || {};
141
+ const finalPlan = renderFinalPlan(existing.steps);
142
+ const resolvedLine = renderResolvedDecisions(existing.resolvedDecisions, language);
143
+ const promptToRun = [
144
+ planExecutionPreamble(language),
145
+ '',
146
+ finalPlan,
147
+ resolvedLine,
148
+ '',
149
+ `Original user request: ${existing.originalPrompt}`,
150
+ ].filter(Boolean).join('\n');
151
+ bridge.planStates.delete(key);
152
+ const note = /^en/i.test(String(language || '')) ? 'Running the plan now.' : '계획대로 실행할게.';
153
+ await sendText(`▶ ${note}`);
154
+ await speakText(note, signal, null);
155
+ return { handled: false, prompt: promptToRun };
156
+ }
157
+ bridge.planStates.delete(key);
158
+ return { handled: false, prompt };
159
+ }
160
+
161
+ if (isPlanEntryUtterance(prompt, language)) {
162
+ const planPrompt = `${planModePreamble(language)}\n\nUser request: ${prompt}`;
163
+ const adapter = adapterForProjectSession(resolveProjectSessionForChannel(planChannelKey()));
164
+ const plan = { task: false, label: adapter.label, verboseProgress: false, language, projectContext: '' };
165
+ const result = await adapter.run(planPrompt, signal, plan).catch(e => ({ answer: '', error: e }));
166
+ const { steps, decisions } = parsePlanOutput(result.answer || '');
167
+ if (!steps.length) {
168
+ const failMsg = /^en/i.test(String(language || ''))
169
+ ? 'I could not produce a plan. Continuing as a regular turn.'
170
+ : '계획을 만들지 못했어. 일반 작업으로 진행할게.';
171
+ await sendText(`⚠️ ${failMsg}`);
172
+ return { handled: false, prompt };
173
+ }
174
+ const planKey = planChannelKey();
175
+ const routingSnapshot = { ...routingStateFor(planKey).activeRouting };
176
+ const state = {
177
+ steps,
178
+ decisions,
179
+ resolvedDecisions: {},
180
+ pendingDecisionIndex: 0,
181
+ originalPrompt: prompt,
182
+ language,
183
+ routingSnapshot,
184
+ };
185
+ bridge.planStates.set(planKey, state);
186
+ const narration = planNarrationLines(steps, language);
187
+ await sendText(`📝 ${narration}`);
188
+ await speakText(narration, signal, null);
189
+ if (decisions.length) {
190
+ await askNextDecision(state, signal);
191
+ } else {
192
+ await finalizePlanReady(state, signal);
193
+ }
194
+ return { handled: true };
195
+ }
196
+ return { handled: false, prompt };
197
+ }
198
+
199
+ function planNarrationLines(steps, language) {
200
+ const visible = steps.filter(s => s.status !== 'skipped');
201
+ const header = /^en/i.test(String(language || ''))
202
+ ? `Plan with ${visible.length} steps. Say "skip step N", "add X after step N", or "approve" to run.`
203
+ : `${visible.length}단계 계획. "step N 건너뛰어", "step N 다음에 X 추가", "실행"이라고 말해줘.`;
204
+ const body = visible.map((s, i) => `${i + 1}. ${s.text}`).join('\n');
205
+ return `${header}\n${body}`;
206
+ }
207
+
208
+ return {
209
+ planChannelKey,
210
+ askNextDecision,
211
+ finalizePlanReady,
212
+ dispatchPlanModeUtterance,
213
+ planNarrationLines,
214
+ };
215
+ }