verbalcoding 0.2.12 → 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 (169) hide show
  1. package/.env.example +74 -4
  2. package/README.es.md +3 -1
  3. package/README.fr.md +3 -1
  4. package/README.ja.md +3 -1
  5. package/README.ko.md +4 -2
  6. package/README.md +4 -2
  7. package/README.ru.md +3 -1
  8. package/README.zh.md +3 -1
  9. package/app-node/agent_adapters.test.mjs +14 -0
  10. package/app-node/agent_routing.mjs +148 -0
  11. package/app-node/agent_routing.test.mjs +138 -0
  12. package/app-node/agent_turn.mjs +86 -0
  13. package/app-node/agent_turn.test.mjs +109 -0
  14. package/app-node/bridge_context.mjs +73 -0
  15. package/app-node/bridge_context.test.mjs +54 -0
  16. package/app-node/bridge_state.mjs +4 -0
  17. package/app-node/bridge_wireup.test.mjs +462 -0
  18. package/app-node/cli_install.test.mjs +31 -0
  19. package/app-node/cross_agent_routing.test.mjs +78 -0
  20. package/app-node/discord_command_router.mjs +204 -0
  21. package/app-node/discord_command_router.test.mjs +311 -0
  22. package/app-node/discord_voice_setup.mjs +251 -0
  23. package/app-node/discord_voice_setup.test.mjs +86 -0
  24. package/app-node/hermes_profiles.test.mjs +12 -1
  25. package/app-node/install_config.mjs +110 -3
  26. package/app-node/install_config.test.mjs +8 -0
  27. package/app-node/instance_doctor.test.mjs +9 -0
  28. package/app-node/instances.test.mjs +8 -1
  29. package/app-node/main.mjs +488 -1368
  30. package/app-node/mcp_tools.test.mjs +7 -0
  31. package/app-node/notification_handler.mjs +89 -0
  32. package/app-node/notification_handler.test.mjs +187 -0
  33. package/app-node/plan_dispatcher.mjs +215 -0
  34. package/app-node/plan_dispatcher.test.mjs +101 -0
  35. package/app-node/plan_mode.mjs +36 -7
  36. package/app-node/plan_mode.test.mjs +78 -0
  37. package/app-node/progress_handler.mjs +220 -0
  38. package/app-node/progress_handler.test.mjs +193 -0
  39. package/app-node/progress_speech.mjs +54 -32
  40. package/app-node/progress_speech.test.mjs +12 -3
  41. package/app-node/project_sessions.mjs +5 -2
  42. package/app-node/project_sessions.test.mjs +7 -0
  43. package/app-node/research_mode.mjs +282 -0
  44. package/app-node/research_mode.test.mjs +264 -0
  45. package/app-node/restart_notice.mjs +3 -0
  46. package/app-node/restart_notice.test.mjs +11 -0
  47. package/app-node/session_ontology.mjs +271 -0
  48. package/app-node/session_ontology.test.mjs +130 -0
  49. package/app-node/smart_progress.mjs +1 -1
  50. package/app-node/stream_sentencer.mjs +32 -2
  51. package/app-node/stream_sentencer.test.mjs +65 -0
  52. package/app-node/streaming_tts_queue.mjs +5 -1
  53. package/app-node/streaming_tts_queue.test.mjs +7 -1
  54. package/app-node/stt_whisper.mjs +24 -0
  55. package/app-node/stt_whisper.test.mjs +32 -0
  56. package/app-node/text_routing.mjs +4 -2
  57. package/app-node/tts_backends.mjs +537 -3
  58. package/app-node/tts_backends.test.mjs +454 -0
  59. package/app-node/tts_player.mjs +164 -0
  60. package/app-node/tts_player.test.mjs +202 -0
  61. package/app-node/tts_runtime.mjs +134 -0
  62. package/app-node/tts_runtime.test.mjs +89 -0
  63. package/app-node/tts_settings.mjs +150 -3
  64. package/app-node/tts_settings.test.mjs +204 -0
  65. package/app-node/tts_voice_config.mjs +136 -2
  66. package/app-node/tts_voice_config.test.mjs +94 -0
  67. package/app-node/utterance_router.mjs +216 -0
  68. package/app-node/utterance_router.test.mjs +236 -0
  69. package/app-node/voice_autojoin.mjs +37 -0
  70. package/app-node/voice_autojoin.test.mjs +59 -0
  71. package/app-node/voice_io.mjs +272 -0
  72. package/app-node/voice_io.test.mjs +102 -0
  73. package/app-node/voice_turn_runner.mjs +449 -0
  74. package/app-node/voice_turn_runner.test.mjs +289 -0
  75. package/docs/CONFIGURATION.md +12 -2
  76. package/docs/HARNESSES.md +58 -0
  77. package/docs/HARNESS_AIDER.md +50 -0
  78. package/docs/HARNESS_CLAUDE.md +56 -0
  79. package/docs/HARNESS_CODEX.md +56 -0
  80. package/docs/HARNESS_CURSOR.md +45 -0
  81. package/docs/HARNESS_GEMINI.md +45 -0
  82. package/docs/HARNESS_HERMES.md +57 -0
  83. package/docs/HARNESS_OPENCLAW.md +44 -0
  84. package/docs/HARNESS_OPENCODE.md +44 -0
  85. package/docs/README.md +1 -0
  86. package/docs/ROADMAP.md +20 -5
  87. package/docs/TTS_BACKENDS.md +227 -0
  88. package/docs/USAGE.md +22 -0
  89. package/docs/i18n/AGENTS.es.md +34 -0
  90. package/docs/i18n/AGENTS.fr.md +34 -0
  91. package/docs/i18n/AGENTS.ja.md +34 -0
  92. package/docs/i18n/AGENTS.ko.md +34 -0
  93. package/docs/i18n/AGENTS.ru.md +34 -0
  94. package/docs/i18n/AGENTS.zh.md +34 -0
  95. package/docs/i18n/HARNESSES.es.md +58 -0
  96. package/docs/i18n/HARNESSES.fr.md +58 -0
  97. package/docs/i18n/HARNESSES.ja.md +58 -0
  98. package/docs/i18n/HARNESSES.ko.md +58 -0
  99. package/docs/i18n/HARNESSES.ru.md +58 -0
  100. package/docs/i18n/HARNESSES.zh.md +58 -0
  101. package/docs/i18n/HARNESS_AIDER.es.md +48 -0
  102. package/docs/i18n/HARNESS_AIDER.fr.md +48 -0
  103. package/docs/i18n/HARNESS_AIDER.ja.md +50 -0
  104. package/docs/i18n/HARNESS_AIDER.ko.md +50 -0
  105. package/docs/i18n/HARNESS_AIDER.ru.md +48 -0
  106. package/docs/i18n/HARNESS_AIDER.zh.md +48 -0
  107. package/docs/i18n/HARNESS_CLAUDE.es.md +55 -0
  108. package/docs/i18n/HARNESS_CLAUDE.fr.md +55 -0
  109. package/docs/i18n/HARNESS_CLAUDE.ja.md +56 -0
  110. package/docs/i18n/HARNESS_CLAUDE.ko.md +56 -0
  111. package/docs/i18n/HARNESS_CLAUDE.ru.md +55 -0
  112. package/docs/i18n/HARNESS_CLAUDE.zh.md +56 -0
  113. package/docs/i18n/HARNESS_CODEX.es.md +55 -0
  114. package/docs/i18n/HARNESS_CODEX.fr.md +55 -0
  115. package/docs/i18n/HARNESS_CODEX.ja.md +56 -0
  116. package/docs/i18n/HARNESS_CODEX.ko.md +56 -0
  117. package/docs/i18n/HARNESS_CODEX.ru.md +55 -0
  118. package/docs/i18n/HARNESS_CODEX.zh.md +56 -0
  119. package/docs/i18n/HARNESS_CURSOR.es.md +42 -0
  120. package/docs/i18n/HARNESS_CURSOR.fr.md +42 -0
  121. package/docs/i18n/HARNESS_CURSOR.ja.md +45 -0
  122. package/docs/i18n/HARNESS_CURSOR.ko.md +45 -0
  123. package/docs/i18n/HARNESS_CURSOR.ru.md +42 -0
  124. package/docs/i18n/HARNESS_CURSOR.zh.md +42 -0
  125. package/docs/i18n/HARNESS_GEMINI.es.md +44 -0
  126. package/docs/i18n/HARNESS_GEMINI.fr.md +44 -0
  127. package/docs/i18n/HARNESS_GEMINI.ja.md +45 -0
  128. package/docs/i18n/HARNESS_GEMINI.ko.md +45 -0
  129. package/docs/i18n/HARNESS_GEMINI.ru.md +44 -0
  130. package/docs/i18n/HARNESS_GEMINI.zh.md +45 -0
  131. package/docs/i18n/HARNESS_HERMES.es.md +54 -0
  132. package/docs/i18n/HARNESS_HERMES.fr.md +54 -0
  133. package/docs/i18n/HARNESS_HERMES.ja.md +57 -0
  134. package/docs/i18n/HARNESS_HERMES.ko.md +57 -0
  135. package/docs/i18n/HARNESS_HERMES.ru.md +54 -0
  136. package/docs/i18n/HARNESS_HERMES.zh.md +57 -0
  137. package/docs/i18n/HARNESS_OPENCLAW.es.md +41 -0
  138. package/docs/i18n/HARNESS_OPENCLAW.fr.md +41 -0
  139. package/docs/i18n/HARNESS_OPENCLAW.ja.md +44 -0
  140. package/docs/i18n/HARNESS_OPENCLAW.ko.md +44 -0
  141. package/docs/i18n/HARNESS_OPENCLAW.ru.md +41 -0
  142. package/docs/i18n/HARNESS_OPENCLAW.zh.md +42 -0
  143. package/docs/i18n/HARNESS_OPENCODE.es.md +41 -0
  144. package/docs/i18n/HARNESS_OPENCODE.fr.md +41 -0
  145. package/docs/i18n/HARNESS_OPENCODE.ja.md +44 -0
  146. package/docs/i18n/HARNESS_OPENCODE.ko.md +44 -0
  147. package/docs/i18n/HARNESS_OPENCODE.ru.md +41 -0
  148. package/docs/i18n/HARNESS_OPENCODE.zh.md +44 -0
  149. package/docs/superpowers/plans/2026-05-14-cross-agent-voice-transfer.md +625 -0
  150. package/docs/superpowers/plans/2026-05-21-audio-overview-narrated-diffs.md +95 -0
  151. package/docs/superpowers/plans/2026-05-21-autoresearch-ontology.md +83 -0
  152. package/docs/superpowers/plans/2026-05-21-phase11-push-to-talk-wakeword-v2.md +77 -0
  153. package/docs/superpowers/plans/2026-05-21-phase12-multi-user-voice.md +147 -0
  154. package/docs/superpowers/plans/2026-05-21-phase14-verbalbench.md +136 -0
  155. package/docs/superpowers/plans/2026-05-21-phase15-phone-companion.md +72 -0
  156. package/integrations/fireredtts2/mlx_llm.py +183 -0
  157. package/integrations/fireredtts2/synth.py +156 -0
  158. package/integrations/fireredtts2/synth_mlx.py +196 -0
  159. package/integrations/mlxaudio/synth.py +74 -0
  160. package/integrations/neuttsair/synth.py +104 -0
  161. package/integrations/omnivoice/synth.py +110 -0
  162. package/package.json +6 -1
  163. package/scripts/cli.mjs +84 -0
  164. package/scripts/doctor.mjs +104 -4
  165. package/scripts/install.mjs +5 -1
  166. package/scripts/install_fireredtts2.sh +109 -0
  167. package/scripts/install_mlxaudio.sh +34 -0
  168. package/scripts/install_mossttsnano.sh +46 -0
  169. package/scripts/postinstall.mjs +34 -0
@@ -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
  }