discoclaw 1.2.4 → 2.0.0

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 (87) hide show
  1. package/.context/voice.md +30 -2
  2. package/.env.example +7 -3
  3. package/.env.example.full +13 -32
  4. package/README.md +1 -1
  5. package/dist/cli/dashboard.js +7 -1
  6. package/dist/cli/dashboard.test.js +0 -4
  7. package/dist/cli/init-wizard.js +4 -8
  8. package/dist/cli/init-wizard.test.js +4 -10
  9. package/dist/config.js +5 -38
  10. package/dist/config.test.js +8 -72
  11. package/dist/cron/executor.js +72 -1
  12. package/dist/dashboard/api/metrics.js +7 -0
  13. package/dist/dashboard/api/metrics.test.js +16 -0
  14. package/dist/dashboard/api/traces.js +14 -0
  15. package/dist/dashboard/api/traces.test.js +40 -0
  16. package/dist/dashboard/page.js +187 -8
  17. package/dist/dashboard/server.js +82 -19
  18. package/dist/dashboard/server.test.js +123 -10
  19. package/dist/discord/actions.js +112 -6
  20. package/dist/discord/actions.test.js +117 -1
  21. package/dist/discord/deferred-runner.js +306 -219
  22. package/dist/discord/help-command.js +1 -1
  23. package/dist/discord/message-coordinator.js +4 -36
  24. package/dist/discord/models-command.js +1 -1
  25. package/dist/discord/reaction-handler.js +83 -5
  26. package/dist/discord/reaction-handler.test.js +55 -0
  27. package/dist/discord/verify-push.js +31 -36
  28. package/dist/discord/verify-push.test.js +34 -6
  29. package/dist/discord/voice-command.js +1 -31
  30. package/dist/discord/voice-command.test.js +21 -259
  31. package/dist/discord/voice-status-command.js +3 -22
  32. package/dist/discord/voice-status-command.test.js +16 -124
  33. package/dist/discord-followup.test.js +133 -0
  34. package/dist/health/config-doctor.js +5 -27
  35. package/dist/health/config-doctor.test.js +1 -4
  36. package/dist/index.js +15 -28
  37. package/dist/observability/trace-store.js +56 -0
  38. package/dist/observability/trace-utils.js +31 -0
  39. package/dist/runtime/codex-cli.js +3 -2
  40. package/dist/runtime/codex-cli.test.js +33 -0
  41. package/dist/runtime/model-tiers.js +1 -1
  42. package/dist/runtime/model-tiers.test.js +9 -0
  43. package/dist/runtime/openai-tool-schemas.js +17 -0
  44. package/dist/runtime-overrides.js +2 -3
  45. package/dist/runtime-overrides.test.js +27 -193
  46. package/dist/tasks/store.js +10 -6
  47. package/dist/tasks/store.test.js +44 -0
  48. package/dist/tasks/task-action-executor.test.js +162 -50
  49. package/dist/tasks/task-action-mutations.js +22 -2
  50. package/dist/tasks/task-action-read-ops.js +7 -1
  51. package/dist/tasks/task-action-runner-types.js +19 -1
  52. package/dist/voice/audio-pipeline.js +183 -96
  53. package/dist/voice/audio-receiver.js +8 -0
  54. package/dist/voice/audio-receiver.test.js +16 -0
  55. package/dist/voice/conversation-buffer.js +16 -6
  56. package/dist/voice/providers/gemini-live-provider.js +481 -0
  57. package/dist/voice/providers/gemini-live-provider.test.js +834 -0
  58. package/dist/voice/providers/gemini-live-responder.js +267 -0
  59. package/dist/voice/providers/gemini-live-responder.test.js +615 -0
  60. package/dist/voice/providers/gemini-live-token-estimator.js +100 -0
  61. package/dist/voice/providers/gemini-live-token-estimator.test.js +160 -0
  62. package/dist/voice/providers/gemini-live-types.js +32 -0
  63. package/dist/voice/providers/gemini-tool-mapper.js +91 -0
  64. package/dist/voice/providers/gemini-tool-mapper.test.js +253 -0
  65. package/dist/voice/providers/index.js +3 -0
  66. package/dist/voice/voice-prompt-builder.js +26 -17
  67. package/dist/voice/voice-prompt-builder.test.js +16 -1
  68. package/docs/configuration.md +4 -9
  69. package/docs/official-docs.md +6 -9
  70. package/docs/runtime-switching.md +1 -1
  71. package/package.json +1 -1
  72. package/dist/voice/audio-pipeline.test.js +0 -619
  73. package/dist/voice/stt-deepgram.js +0 -154
  74. package/dist/voice/stt-deepgram.test.js +0 -275
  75. package/dist/voice/stt-factory.js +0 -42
  76. package/dist/voice/stt-factory.test.js +0 -45
  77. package/dist/voice/stt-openai.js +0 -156
  78. package/dist/voice/stt-openai.test.js +0 -281
  79. package/dist/voice/tts-cartesia.js +0 -169
  80. package/dist/voice/tts-cartesia.test.js +0 -228
  81. package/dist/voice/tts-deepgram.js +0 -84
  82. package/dist/voice/tts-deepgram.test.js +0 -220
  83. package/dist/voice/tts-factory.js +0 -52
  84. package/dist/voice/tts-factory.test.js +0 -53
  85. package/dist/voice/tts-openai.js +0 -70
  86. package/dist/voice/tts-openai.test.js +0 -138
  87. package/dist/voice/types.test.js +0 -84
@@ -63,6 +63,7 @@ import { parseUpdateCommand, handleUpdateCommand } from './update-command.js';
63
63
  import { consumeDestructiveConfirmation } from './destructive-confirmation.js';
64
64
  import { globalMetrics } from '../observability/metrics.js';
65
65
  import { globalTraceStore } from '../observability/trace-store.js';
66
+ import { summarizeTraceValue } from '../observability/trace-utils.js';
66
67
  import { OnboardingFlow } from '../onboarding/onboarding-flow.js';
67
68
  import { completeOnboarding } from './onboarding-completion.js';
68
69
  import { isOnboardingComplete } from '../workspace-bootstrap.js';
@@ -93,34 +94,6 @@ async function waitForEditOrTimeout(editOp, timeoutMs) {
93
94
  clearTimeout(timer);
94
95
  return completed;
95
96
  }
96
- function summarizeTraceText(value, maxChars = 160) {
97
- const normalized = value.replace(/\s+/g, ' ').trim();
98
- if (!normalized) {
99
- return undefined;
100
- }
101
- if (normalized.length <= maxChars) {
102
- return normalized;
103
- }
104
- return `${normalized.slice(0, Math.max(1, maxChars - 1))}…`;
105
- }
106
- function summarizeTraceValue(value, maxChars = 160) {
107
- if (value == null) {
108
- return undefined;
109
- }
110
- if (typeof value === 'string') {
111
- return summarizeTraceText(value, maxChars);
112
- }
113
- try {
114
- const serialized = JSON.stringify(value);
115
- if (serialized) {
116
- return summarizeTraceText(serialized, maxChars);
117
- }
118
- }
119
- catch {
120
- // Fall through to String(value).
121
- }
122
- return summarizeTraceText(String(value), maxChars);
123
- }
124
97
  const RELEASE_REHEARSAL_SLUG_RE = /\brr-\d{8}-\d{6}-[a-z0-9]+\b/gi;
125
98
  const QUOTED_VALUE_PATTERN = "(?:`([^`\\n]+)`|\"([^\"\\n]+)\"|'([^'\\n]+)')";
126
99
  function extractQuotedMatch(match) {
@@ -788,15 +761,12 @@ export function createMessageCreateHandler(params, queue, statusRef) {
788
761
  const connMap = (params.voiceCtx ?? params.voiceStatusCtx)?.voiceManager.listConnections() ?? new Map();
789
762
  const voiceSnapshot = {
790
763
  enabled: params.voiceEnabled ?? false,
791
- sttProvider: params.voiceSttProvider ?? 'deepgram',
792
- ttsProvider: params.voiceTtsProvider ?? 'cartesia',
764
+ provider: 'gemini-live',
765
+ geminiKeySet: Boolean(params.geminiApiKey),
766
+ model: params.voiceModelCtx?.model,
793
767
  homeChannel: params.voiceHomeChannel,
794
- deepgramKeySet: Boolean(params.deepgramApiKey),
795
- cartesiaKeySet: Boolean(params.cartesiaApiKey),
796
768
  autoJoin: params.voiceAutoJoin ?? false,
797
769
  actionsEnabled: params.discordActionsVoice ?? false,
798
- deepgramSttModel: params.deepgramSttModel,
799
- deepgramTtsVoice: params.getTtsVoice?.() ?? params.deepgramTtsVoice,
800
770
  connections: [...connMap.entries()].map(([guildId, info]) => ({
801
771
  guildId,
802
772
  channelId: info.channelId,
@@ -807,10 +777,8 @@ export function createMessageCreateHandler(params, queue, statusRef) {
807
777
  };
808
778
  const voiceReply = await handleVoiceCommand(voiceCmd, {
809
779
  voiceEnabled: params.voiceEnabled ?? false,
810
- ttsProvider: params.voiceTtsProvider ?? 'cartesia',
811
780
  statusSnapshot: voiceSnapshot,
812
781
  botDisplayName: params.botDisplayName,
813
- setTtsVoice: params.setTtsVoice,
814
782
  });
815
783
  await msg.reply({ content: voiceReply, allowedMentions: NO_MENTIONS });
816
784
  return;
@@ -127,7 +127,7 @@ export function handleModelsCommand(cmd, opts) {
127
127
  '',
128
128
  '**Note:** `!models set imagegen <model>` changes the default image generation model at runtime (persisted). Use `!models reset imagegen` to revert to the env/fallback default.',
129
129
  '',
130
- '**TTS voice:** Use `!voice set <name>` to switch the Deepgram TTS voice at runtime (e.g. `!voice set aura-2-luna-en`). See `!voice help` for details.',
130
+ '**Voice note:** `!voice set` is gone. Gemini Live owns the output voice now; use `!voice status` for current voice runtime details.',
131
131
  ].join('\n');
132
132
  }
133
133
  if (cmd.action === 'show') {
@@ -6,7 +6,7 @@ import { shouldCanvasPromptBeSurfaced } from '../canvas/canvas-action.js';
6
6
  import { discordSessionKey } from './session-key.js';
7
7
  import { ensureIndexedDiscordChannelContext, resolveDiscordChannelContext } from './channel-context.js';
8
8
  import { fetchMessageHistory } from './message-history.js';
9
- import { parseDiscordActions, executeDiscordActions, buildTieredDiscordActionsPromptSection, buildAllResultLines, appendActionResults } from './actions.js';
9
+ import { parseDiscordActions, executeDiscordActions, buildTieredDiscordActionsPromptSection, buildCappedResultLines, appendActionResults } from './actions.js';
10
10
  import { shouldTriggerFollowUp, actionDedupeKey, isDuplicateAction, buildActionHistorySummary } from './action-categories.js';
11
11
  import { tryResolveReactionPrompt } from './reaction-prompts.js';
12
12
  import { REACTION_STOP_ABORT_CAUSE, tryAbort, isActivelyStreaming, snapshotAbort, } from './abort-registry.js';
@@ -21,6 +21,8 @@ import { downloadMessageImages, resolveMediaType } from './image-download.js';
21
21
  import { downloadTextAttachments, classifyAttachments, downloadDocumentAttachments } from './file-download.js';
22
22
  import { mapRuntimeErrorToUserMessage } from './user-errors.js';
23
23
  import { globalMetrics } from '../observability/metrics.js';
24
+ import { globalTraceStore } from '../observability/trace-store.js';
25
+ import { summarizeTraceValue } from '../observability/trace-utils.js';
24
26
  import { resolveModel } from '../runtime/model-tiers.js';
25
27
  import { resolveGroundedToolCapabilities } from '../runtime/tool-capabilities.js';
26
28
  import { adaptRuntimeEventText } from './runtime-event-text-adapter.js';
@@ -522,6 +524,9 @@ function createReactionHandler(mode, params, queue, statusRef) {
522
524
  let currentPrompt = prompt;
523
525
  let pendingFollowUp = null;
524
526
  const actionHistory = [];
527
+ const traceId = `reaction_${randomUUID()}`;
528
+ let traceOutcome = 'success';
529
+ globalTraceStore.startTrace(traceId, sessionKey, 'reaction', reaction.message.channelId);
525
530
  try {
526
531
  // -- auto-follow-up loop --
527
532
  while (true) {
@@ -606,8 +611,15 @@ function createReactionHandler(mode, params, queue, statusRef) {
606
611
  const t0 = Date.now();
607
612
  const previewMode = params.streamPreviewMode ?? 'compact';
608
613
  const debugStreamPreviewLines = Boolean(params.debugStreamPreviewLines);
614
+ const toolStartTimesByName = new Map();
609
615
  metrics.recordInvokeStart('reaction');
610
616
  params.log?.info({ flow: 'reaction', sessionKey }, 'obs.invoke.start');
617
+ globalTraceStore.addEvent(traceId, {
618
+ type: 'invoke_start',
619
+ at: t0,
620
+ summary: followUpDepth === 0 ? 'initial invoke' : `follow-up ${followUpDepth}`,
621
+ promptPreview: summarizeTraceValue(currentPrompt, 220),
622
+ });
611
623
  let invokeError = null;
612
624
  let lastEditAt = 0;
613
625
  let streamEditTimeoutStreak = 0;
@@ -819,10 +831,39 @@ function createReactionHandler(mode, params, queue, statusRef) {
819
831
  lastEventAt = Date.now();
820
832
  stallWarned = false;
821
833
  lastStallProgressAt = 0;
822
- if (evt.type === 'tool_start')
834
+ if (evt.type === 'tool_start') {
823
835
  activeToolCount++;
824
- else if (evt.type === 'tool_end')
836
+ const startedAt = Date.now();
837
+ const existingStarts = toolStartTimesByName.get(evt.name) ?? [];
838
+ existingStarts.push(startedAt);
839
+ toolStartTimesByName.set(evt.name, existingStarts);
840
+ globalTraceStore.addEvent(traceId, {
841
+ type: 'tool_start',
842
+ at: startedAt,
843
+ toolName: evt.name,
844
+ inputSummary: summarizeTraceValue(evt.input),
845
+ });
846
+ }
847
+ else if (evt.type === 'tool_end') {
825
848
  activeToolCount = Math.max(0, activeToolCount - 1);
849
+ const endedAt = Date.now();
850
+ const existingStarts = toolStartTimesByName.get(evt.name) ?? [];
851
+ const startedAt = existingStarts.shift();
852
+ if (existingStarts.length > 0) {
853
+ toolStartTimesByName.set(evt.name, existingStarts);
854
+ }
855
+ else {
856
+ toolStartTimesByName.delete(evt.name);
857
+ }
858
+ globalTraceStore.addEvent(traceId, {
859
+ type: 'tool_end',
860
+ at: endedAt,
861
+ toolName: evt.name,
862
+ ok: evt.ok,
863
+ durationMs: startedAt == null ? undefined : Math.max(0, endedAt - startedAt),
864
+ outputSummary: summarizeTraceValue(evt.output),
865
+ });
866
+ }
826
867
  if (evt.type === 'text_final') {
827
868
  hadTextFinal = true;
828
869
  finalText = evt.text;
@@ -845,6 +886,14 @@ function createReactionHandler(mode, params, queue, statusRef) {
845
886
  }
846
887
  else if (evt.type === 'error') {
847
888
  invokeError = evt.message;
889
+ traceOutcome = 'error';
890
+ globalTraceStore.addEvent(traceId, {
891
+ type: 'error',
892
+ at: Date.now(),
893
+ message: evt.message,
894
+ stage: 'runtime',
895
+ summary: followUpDepth === 0 ? 'initial invoke' : `follow-up ${followUpDepth}`,
896
+ });
848
897
  metrics.recordInvokeResult('reaction', Date.now() - t0, false, evt.message);
849
898
  params.log?.error({ sessionKey, error: evt.message }, `${logPrefix}:runtime error`);
850
899
  params.log?.warn({ flow: 'reaction', sessionKey, error: evt.message }, 'obs.invoke.error');
@@ -864,6 +913,15 @@ function createReactionHandler(mode, params, queue, statusRef) {
864
913
  streamEditQueue = Promise.resolve();
865
914
  }
866
915
  metrics.recordInvokeResult('reaction', Date.now() - t0, !invokeError, invokeError ?? undefined);
916
+ if (invokeError) {
917
+ traceOutcome = 'error';
918
+ }
919
+ globalTraceStore.addEvent(traceId, {
920
+ type: 'invoke_end',
921
+ at: Date.now(),
922
+ ok: !invokeError,
923
+ summary: followUpDepth === 0 ? 'initial invoke' : `follow-up ${followUpDepth}`,
924
+ });
867
925
  params.log?.info({ flow: 'reaction', sessionKey, ms: Date.now() - t0, ok: !invokeError }, 'obs.invoke.end');
868
926
  let processedText = finalText || deltaText || (collectedImages.length > 0 ? '' : '(no output)');
869
927
  params.log?.info({ sessionKey, sessionId, ms: Date.now() - t0, hadError: Boolean(invokeError) }, `${logPrefix}:invoke:end`);
@@ -939,8 +997,18 @@ function createReactionHandler(mode, params, queue, statusRef) {
939
997
  spawnCtx: params.spawnCtx,
940
998
  });
941
999
  actionResults = results;
942
- for (const result of results) {
1000
+ for (let i = 0; i < results.length; i++) {
1001
+ const result = results[i];
943
1002
  metrics.recordActionResult(result.ok);
1003
+ globalTraceStore.addEvent(traceId, {
1004
+ type: 'action_result',
1005
+ at: Date.now(),
1006
+ action: parsed.actions[i]?.type ?? 'unknown',
1007
+ ok: result.ok,
1008
+ detail: result.ok
1009
+ ? summarizeTraceValue(result.summary, 220)
1010
+ : summarizeTraceValue(result.error, 220),
1011
+ });
944
1012
  params.log?.info({ flow: 'reaction', sessionKey, ok: result.ok }, 'obs.action.result');
945
1013
  }
946
1014
  // Record action history for follow-up dedup.
@@ -1026,7 +1094,7 @@ function createReactionHandler(mode, params, queue, statusRef) {
1026
1094
  let nextFollowUp = null;
1027
1095
  if (shouldQueueFollowUp) {
1028
1096
  const token = buildFollowUpToken();
1029
- const followUpLines = buildAllResultLines(actionResults);
1097
+ const followUpLines = buildCappedResultLines(actionResults);
1030
1098
  const failureRetryPlaceholder = buildFailureRetryPlaceholder(parsedActions, actionResults);
1031
1099
  const followUpSuffix = failureRetryPlaceholder
1032
1100
  ? `One or more actions failed. If you retry, explicitly tell the user what failed and whether the retry succeeded or failed. Do not announce success before the action confirms it.`
@@ -1089,6 +1157,15 @@ function createReactionHandler(mode, params, queue, statusRef) {
1089
1157
  } // end while (true)
1090
1158
  }
1091
1159
  catch (innerErr) {
1160
+ traceOutcome = 'error';
1161
+ globalTraceStore.addEvent(traceId, {
1162
+ type: 'error',
1163
+ at: Date.now(),
1164
+ message: innerErr instanceof Error ? innerErr.message : String(innerErr),
1165
+ name: innerErr instanceof Error ? innerErr.name : undefined,
1166
+ stack: innerErr instanceof Error ? summarizeTraceValue(innerErr.stack, 400) : undefined,
1167
+ stage: 'reaction_flow',
1168
+ });
1092
1169
  // Inner catch: attempt to show the error in the reply before the finally
1093
1170
  // block runs dispose(). Setting replyFinalized = true on success prevents
1094
1171
  // the finally's safety-net delete from removing the error message.
@@ -1107,6 +1184,7 @@ function createReactionHandler(mode, params, queue, statusRef) {
1107
1184
  throw innerErr;
1108
1185
  }
1109
1186
  finally {
1187
+ globalTraceStore.endTrace(traceId, traceOutcome);
1110
1188
  // Safety net runs before dispose() so cold-start recovery can still see
1111
1189
  // the in-flight entry if the delete fails.
1112
1190
  if (!replyFinalized && reply && !isShuttingDown()) {
@@ -2157,6 +2157,61 @@ describe('reaction prompt interception', () => {
2157
2157
  expect(followUpPrompt).toContain('[Auto-follow-up]');
2158
2158
  expect(followUpPrompt).toContain('Failed:');
2159
2159
  });
2160
+ it('microcompacts multiline action results in reaction auto-follow-up prompts', async () => {
2161
+ const invokeCalls = [];
2162
+ const executeSpy = vi.spyOn(discordActions, 'executeDiscordActions').mockResolvedValue([{
2163
+ ok: true,
2164
+ summary: [
2165
+ 'Messages in #ops:',
2166
+ '[alice] alpha update (id:1001)',
2167
+ '[bob] beta update (id:1002)',
2168
+ '[carol] gamma update (id:1003)',
2169
+ '[dave] delta update (id:1004)',
2170
+ '[erin] epsilon update (id:1005)',
2171
+ '[frank] zeta update (id:1006)',
2172
+ '[grace] eta update (id:1007)',
2173
+ '[heidi] theta update (id:1008)',
2174
+ '[ivan] iota update (id:1009)',
2175
+ ].join('\n'),
2176
+ }]);
2177
+ const runtime = {
2178
+ id: 'claude_code',
2179
+ capabilities: new Set(['streaming_text']),
2180
+ async *invoke(p) {
2181
+ invokeCalls.push(p);
2182
+ if (invokeCalls.length === 1) {
2183
+ yield { type: 'text_final', text: '<discord-action>{"type":"channelList"}</discord-action>' };
2184
+ }
2185
+ else {
2186
+ yield { type: 'text_final', text: 'Compacted follow-up handled.' };
2187
+ }
2188
+ yield { type: 'done' };
2189
+ },
2190
+ };
2191
+ const params = makeParams({
2192
+ runtime,
2193
+ discordActionsEnabled: true,
2194
+ discordActionsChannels: true,
2195
+ actionFollowupDepth: 1,
2196
+ });
2197
+ try {
2198
+ const handler = createReactionAddHandler(params, mockQueue());
2199
+ await handler(mockReaction(), mockUser());
2200
+ expect(invokeCalls).toHaveLength(2);
2201
+ const followUpPrompt = invokeCalls[1].prompt;
2202
+ expect(followUpPrompt).toContain('[Auto-follow-up]');
2203
+ expect(followUpPrompt).toContain('Done: Messages in #ops:');
2204
+ expect(followUpPrompt).toContain('(id:1001)');
2205
+ expect(followUpPrompt).toContain('(id:1002)');
2206
+ expect(followUpPrompt).toContain('(id:1008)');
2207
+ expect(followUpPrompt).toContain('(id:1009)');
2208
+ expect(followUpPrompt).toContain('...[omitted 5 lines]');
2209
+ expect(followUpPrompt).not.toContain('(id:1005)');
2210
+ }
2211
+ finally {
2212
+ executeSpy.mockRestore();
2213
+ }
2214
+ });
2160
2215
  it('posts the follow-up placeholder, starts the watchdog, and carries the same lifecycle token through completion', async () => {
2161
2216
  const order = [];
2162
2217
  const invokeCalls = [];
@@ -1,20 +1,35 @@
1
- import { execa } from 'execa';
1
+ import { execFile } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { promisify } from 'node:util';
4
+ const execFileAsync = promisify(execFile);
2
5
  // ---------------------------------------------------------------------------
3
6
  // Git helpers
4
7
  // ---------------------------------------------------------------------------
5
- function localGitEnv() {
8
+ function localGitEnv(cwd) {
6
9
  const env = { ...process.env };
7
- delete env.GIT_DIR;
8
- delete env.GIT_WORK_TREE;
10
+ for (const key of Object.keys(env)) {
11
+ if (key.startsWith('GIT'))
12
+ delete env[key];
13
+ }
14
+ env.GIT_CEILING_DIRECTORIES = path.resolve(cwd);
9
15
  return env;
10
16
  }
17
+ async function runCommand(command, args, cwd, options = {}) {
18
+ const result = await execFileAsync(command, args, {
19
+ cwd,
20
+ env: localGitEnv(cwd),
21
+ encoding: 'utf8',
22
+ timeout: options.timeout,
23
+ maxBuffer: 10 * 1024 * 1024,
24
+ });
25
+ return {
26
+ stdout: result.stdout,
27
+ stderr: result.stderr,
28
+ };
29
+ }
11
30
  async function gitIsAvailable(cwd) {
12
31
  try {
13
- await execa('git', ['rev-parse', '--is-inside-work-tree'], {
14
- cwd,
15
- env: localGitEnv(),
16
- stdio: 'pipe',
17
- });
32
+ await runCommand('git', ['rev-parse', '--is-inside-work-tree'], cwd);
18
33
  return true;
19
34
  }
20
35
  catch {
@@ -23,11 +38,7 @@ async function gitIsAvailable(cwd) {
23
38
  }
24
39
  async function getCurrentBranch(cwd) {
25
40
  try {
26
- const result = await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
27
- cwd,
28
- env: localGitEnv(),
29
- stdio: 'pipe',
30
- });
41
+ const result = await runCommand('git', ['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
31
42
  const branch = result.stdout.trim();
32
43
  return branch && branch !== 'HEAD' ? branch : null;
33
44
  }
@@ -37,11 +48,7 @@ async function getCurrentBranch(cwd) {
37
48
  }
38
49
  async function fetchOrigin(cwd) {
39
50
  try {
40
- await execa('git', ['fetch', 'origin'], {
41
- cwd,
42
- env: localGitEnv(),
43
- stdio: 'pipe',
44
- });
51
+ await runCommand('git', ['fetch', 'origin'], cwd);
45
52
  }
46
53
  catch {
47
54
  // Best-effort — remote may be unreachable
@@ -49,11 +56,7 @@ async function fetchOrigin(cwd) {
49
56
  }
50
57
  async function hasUpstream(cwd, branch) {
51
58
  try {
52
- await execa('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], {
53
- cwd,
54
- env: localGitEnv(),
55
- stdio: 'pipe',
56
- });
59
+ await runCommand('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], cwd);
57
60
  return true;
58
61
  }
59
62
  catch {
@@ -62,7 +65,7 @@ async function hasUpstream(cwd, branch) {
62
65
  }
63
66
  async function countUnpushedCommits(cwd, branch) {
64
67
  try {
65
- const result = await execa('git', ['rev-list', '--count', `${branch}@{upstream}..HEAD`], { cwd, env: localGitEnv(), stdio: 'pipe' });
68
+ const result = await runCommand('git', ['rev-list', '--count', `${branch}@{upstream}..HEAD`], cwd);
66
69
  return parseInt(result.stdout.trim(), 10) || 0;
67
70
  }
68
71
  catch {
@@ -76,18 +79,10 @@ async function countUnpushedCommits(cwd, branch) {
76
79
  async function isCommitOnRemote(cwd, branch, shortHash) {
77
80
  try {
78
81
  // Resolve the short hash to a full hash first
79
- const result = await execa('git', ['rev-parse', shortHash], {
80
- cwd,
81
- env: localGitEnv(),
82
- stdio: 'pipe',
83
- });
82
+ const result = await runCommand('git', ['rev-parse', shortHash], cwd);
84
83
  const fullHash = result.stdout.trim();
85
84
  // Check if the commit is an ancestor of (reachable from) the upstream
86
- await execa('git', ['merge-base', '--is-ancestor', fullHash, `${branch}@{upstream}`], {
87
- cwd,
88
- env: localGitEnv(),
89
- stdio: 'pipe',
90
- });
85
+ await runCommand('git', ['merge-base', '--is-ancestor', fullHash, `${branch}@{upstream}`], cwd);
91
86
  return true;
92
87
  }
93
88
  catch {
@@ -103,7 +98,7 @@ async function isCommitOnRemote(cwd, branch, shortHash) {
103
98
  */
104
99
  async function checkPRExists(branchName, cwd) {
105
100
  try {
106
- const result = await execa('gh', ['pr', 'list', '--head', branchName, '--json', 'number,state,url', '--limit', '1'], { cwd, env: localGitEnv(), stdio: 'pipe', timeout: 5_000 });
101
+ const result = await runCommand('gh', ['pr', 'list', '--head', branchName, '--json', 'number,state,url', '--limit', '1'], cwd, { timeout: 5_000 });
107
102
  const prs = JSON.parse(result.stdout.trim() || '[]');
108
103
  if (Array.isArray(prs) && prs.length > 0) {
109
104
  return { available: true, exists: true, url: prs[0].url };
@@ -6,12 +6,11 @@
6
6
  * Uses real git repos in temp directories to exercise the actual git
7
7
  * helper functions (no mocking of child_process).
8
8
  */
9
- import { describe, expect, it, beforeEach, afterEach } from 'vitest';
9
+ import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
10
10
  import { execFileSync } from 'node:child_process';
11
11
  import fs from 'node:fs';
12
12
  import os from 'node:os';
13
13
  import path from 'node:path';
14
- import { verifyPushStatus, formatPushWarning, } from './verify-push.js';
15
14
  // ---------------------------------------------------------------------------
16
15
  // Helpers
17
16
  // ---------------------------------------------------------------------------
@@ -29,13 +28,28 @@ function makePhase(overrides = {}) {
29
28
  };
30
29
  }
31
30
  function git(cwd, args) {
31
+ const env = { ...process.env };
32
+ for (const key of Object.keys(env)) {
33
+ if (key.startsWith('GIT'))
34
+ delete env[key];
35
+ }
36
+ env.GIT_CEILING_DIRECTORIES = cwd;
37
+ env.GIT_AUTHOR_NAME = 'Test';
38
+ env.GIT_AUTHOR_EMAIL = 'test@test';
39
+ env.GIT_COMMITTER_NAME = 'Test';
40
+ env.GIT_COMMITTER_EMAIL = 'test@test';
32
41
  return execFileSync('git', args, {
33
42
  cwd,
34
43
  encoding: 'utf-8',
35
44
  stdio: 'pipe',
36
- env: { ...process.env, GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 'test@test', GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 'test@test' },
45
+ env,
37
46
  }).trim();
38
47
  }
48
+ async function loadVerifyPushModule() {
49
+ vi.resetModules();
50
+ vi.unmock('execa');
51
+ return import('./verify-push.js');
52
+ }
39
53
  /** Create a temp dir with an initialized git repo and one initial commit. */
40
54
  function initRepo(dir) {
41
55
  git(dir, ['init', '--initial-branch=main']);
@@ -79,6 +93,7 @@ afterEach(() => {
79
93
  // ---------------------------------------------------------------------------
80
94
  describe('verifyPushStatus', () => {
81
95
  it('returns git-unavailable warning for non-repo directory', async () => {
96
+ const { verifyPushStatus } = await loadVerifyPushModule();
82
97
  const nonRepo = path.join(tmpBase, 'not-a-repo');
83
98
  fs.mkdirSync(nonRepo);
84
99
  const result = await verifyPushStatus(nonRepo, [makePhase({ status: 'done', gitCommit: 'abc1234' })]);
@@ -88,6 +103,7 @@ describe('verifyPushStatus', () => {
88
103
  expect(result.warning).toMatch(/Git is not available/);
89
104
  });
90
105
  it('returns no warning when there are no done phases with commits', async () => {
106
+ const { verifyPushStatus } = await loadVerifyPushModule();
91
107
  initRepo(workDir);
92
108
  const result = await verifyPushStatus(workDir, [
93
109
  makePhase({ status: 'pending' }),
@@ -99,6 +115,7 @@ describe('verifyPushStatus', () => {
99
115
  expect(result.warning).toBeUndefined();
100
116
  });
101
117
  it('warns when branch has no remote tracking branch', async () => {
118
+ const { verifyPushStatus } = await loadVerifyPushModule();
102
119
  initRepo(workDir);
103
120
  const hash = makeCommit(workDir, 'file1.ts', 'phase 1 work');
104
121
  const phases = [makePhase({ status: 'done', gitCommit: hash })];
@@ -110,6 +127,7 @@ describe('verifyPushStatus', () => {
110
127
  expect(result.warning).toMatch(/1 phase commit/);
111
128
  });
112
129
  it('warns about multiple unpushed phase commits with no remote', async () => {
130
+ const { verifyPushStatus } = await loadVerifyPushModule();
113
131
  initRepo(workDir);
114
132
  const hash1 = makeCommit(workDir, 'a.ts', 'phase 1');
115
133
  const hash2 = makeCommit(workDir, 'b.ts', 'phase 2');
@@ -123,6 +141,7 @@ describe('verifyPushStatus', () => {
123
141
  expect(result.warning).toMatch(/2 phase commit/);
124
142
  });
125
143
  it('returns clean result when all phase commits are pushed', async () => {
144
+ const { verifyPushStatus } = await loadVerifyPushModule();
126
145
  initRepo(workDir);
127
146
  addBareRemote(workDir, bareDir);
128
147
  const hash = makeCommit(workDir, 'file1.ts', 'phase 1 work');
@@ -136,6 +155,7 @@ describe('verifyPushStatus', () => {
136
155
  expect(result.warning).toBeUndefined();
137
156
  });
138
157
  it('detects unpushed phase commits when remote exists but commits not pushed', async () => {
158
+ const { verifyPushStatus } = await loadVerifyPushModule();
139
159
  initRepo(workDir);
140
160
  addBareRemote(workDir, bareDir);
141
161
  const hash = makeCommit(workDir, 'file1.ts', 'local-only work');
@@ -148,6 +168,7 @@ describe('verifyPushStatus', () => {
148
168
  expect(result.warning).toContain(hash);
149
169
  });
150
170
  it('differentiates pushed vs unpushed commits in mixed scenario', async () => {
171
+ const { verifyPushStatus } = await loadVerifyPushModule();
151
172
  initRepo(workDir);
152
173
  addBareRemote(workDir, bareDir);
153
174
  // Phase 1: pushed
@@ -166,6 +187,7 @@ describe('verifyPushStatus', () => {
166
187
  expect(result.warning).toMatch(/1 unpushed phase commit/);
167
188
  });
168
189
  it('ignores non-done phases even if they have gitCommit', async () => {
190
+ const { verifyPushStatus } = await loadVerifyPushModule();
169
191
  initRepo(workDir);
170
192
  addBareRemote(workDir, bareDir);
171
193
  const hash = makeCommit(workDir, 'wip.ts', 'work in progress');
@@ -180,6 +202,7 @@ describe('verifyPushStatus', () => {
180
202
  expect(result.warning).toBeUndefined();
181
203
  });
182
204
  it('works on a non-main branch with tracking', async () => {
205
+ const { verifyPushStatus } = await loadVerifyPushModule();
183
206
  initRepo(workDir);
184
207
  addBareRemote(workDir, bareDir);
185
208
  git(workDir, ['checkout', '-b', 'feature/verify-push']);
@@ -194,6 +217,7 @@ describe('verifyPushStatus', () => {
194
217
  expect(result.warning).toMatch(/feature\/verify-push/);
195
218
  });
196
219
  it('handles detached HEAD gracefully', async () => {
220
+ const { verifyPushStatus } = await loadVerifyPushModule();
197
221
  initRepo(workDir);
198
222
  const headHash = git(workDir, ['rev-parse', 'HEAD']);
199
223
  git(workDir, ['checkout', headHash]);
@@ -202,6 +226,7 @@ describe('verifyPushStatus', () => {
202
226
  expect(result.warning).toMatch(/Detached HEAD/);
203
227
  });
204
228
  it('includes prCheck field in results', async () => {
229
+ const { verifyPushStatus } = await loadVerifyPushModule();
205
230
  initRepo(workDir);
206
231
  addBareRemote(workDir, bareDir);
207
232
  const hash = makeCommit(workDir, 'file.ts', 'phase work');
@@ -217,7 +242,8 @@ describe('verifyPushStatus', () => {
217
242
  // formatPushWarning
218
243
  // ---------------------------------------------------------------------------
219
244
  describe('formatPushWarning', () => {
220
- it('returns undefined when there is no warning', () => {
245
+ it('returns undefined when there is no warning', async () => {
246
+ const { formatPushWarning } = await loadVerifyPushModule();
221
247
  const result = {
222
248
  branch: 'main',
223
249
  hasRemote: true,
@@ -227,7 +253,8 @@ describe('formatPushWarning', () => {
227
253
  };
228
254
  expect(formatPushWarning(result)).toBeUndefined();
229
255
  });
230
- it('formats warning with emoji and push guidance when no PR', () => {
256
+ it('formats warning with emoji and push guidance when no PR', async () => {
257
+ const { formatPushWarning } = await loadVerifyPushModule();
231
258
  const result = {
232
259
  branch: 'main',
233
260
  hasRemote: false,
@@ -242,7 +269,8 @@ describe('formatPushWarning', () => {
242
269
  expect(formatted).toContain('remote branch');
243
270
  expect(formatted).toContain('local-only');
244
271
  });
245
- it('includes PR URL when PR exists', () => {
272
+ it('includes PR URL when PR exists', async () => {
273
+ const { formatPushWarning } = await loadVerifyPushModule();
246
274
  const result = {
247
275
  branch: 'feature/test',
248
276
  hasRemote: true,
@@ -15,9 +15,6 @@ export function parseVoiceCommand(content) {
15
15
  return { action: 'status' };
16
16
  if (sub === 'help' && tokens.length === 2)
17
17
  return { action: 'help' };
18
- // Preserve original case for voice names (e.g. "aura-2-asteria-en").
19
- if (sub === 'set' && tokens.length === 3)
20
- return { action: 'set', voice: tokens[2] };
21
18
  return null;
22
19
  }
23
20
  // ---------------------------------------------------------------------------
@@ -27,14 +24,9 @@ const HELP_TEXT = [
27
24
  '**!voice commands:**',
28
25
  '- `!voice` — show voice subsystem status',
29
26
  '- `!voice status` — same as above',
30
- '- `!voice set <name>` — switch the Deepgram TTS voice at runtime',
31
27
  '- `!voice help` — this message',
32
28
  '',
33
- '**Examples:**',
34
- '- `!voice set aura-2-asteria-en`',
35
- '- `!voice set aura-2-luna-en`',
36
- '',
37
- '**Note:** Voice name switching requires the Deepgram TTS provider (`DISCOCLAW_TTS_PROVIDER=deepgram`).',
29
+ 'Voice now runs on Gemini Live only. The legacy STT/TTS pipeline and runtime voice switching have been removed.',
38
30
  ].join('\n');
39
31
  export async function handleVoiceCommand(cmd, opts) {
40
32
  if (!opts.voiceEnabled) {
@@ -47,28 +39,6 @@ export async function handleVoiceCommand(cmd, opts) {
47
39
  }
48
40
  return renderVoiceStatusReport(opts.statusSnapshot, opts.botDisplayName);
49
41
  }
50
- case 'set': {
51
- if (opts.ttsProvider !== 'deepgram') {
52
- return `Voice name switching requires \`deepgram\` TTS provider (current: \`${opts.ttsProvider}\`).`;
53
- }
54
- if (opts.setTtsVoice) {
55
- const restarted = await opts.setTtsVoice(cmd.voice);
56
- const pipelineLabel = restarted === 1 ? '1 active pipeline' : `${restarted} active pipelines`;
57
- return restarted > 0
58
- ? `Voice set to \`${cmd.voice}\`. ${pipelineLabel} restarted.`
59
- : `Voice set to \`${cmd.voice}\`. Will take effect on the next pipeline start.`;
60
- }
61
- if (opts.voiceConfig) {
62
- opts.voiceConfig.deepgramTtsVoice = cmd.voice;
63
- }
64
- const pipelineCount = opts.activePipelineCount ?? 0;
65
- if (pipelineCount > 0 && opts.restartPipelines) {
66
- await opts.restartPipelines();
67
- const pipelineLabel = pipelineCount === 1 ? '1 active pipeline' : `${pipelineCount} active pipelines`;
68
- return `Voice set to \`${cmd.voice}\`. ${pipelineLabel} restarted.`;
69
- }
70
- return `Voice set to \`${cmd.voice}\`. Will take effect on the next pipeline start.`;
71
- }
72
42
  case 'help': {
73
43
  return HELP_TEXT;
74
44
  }