discoclaw 1.3.0 → 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 (56) hide show
  1. package/.env.example +4 -6
  2. package/.env.example.full +13 -32
  3. package/README.md +1 -1
  4. package/dist/cli/dashboard.test.js +0 -4
  5. package/dist/cli/init-wizard.js +4 -8
  6. package/dist/cli/init-wizard.test.js +4 -10
  7. package/dist/config.js +2 -42
  8. package/dist/config.test.js +8 -72
  9. package/dist/dashboard/server.js +1 -5
  10. package/dist/dashboard/server.test.js +3 -6
  11. package/dist/discord/actions.js +112 -6
  12. package/dist/discord/actions.test.js +117 -1
  13. package/dist/discord/help-command.js +1 -1
  14. package/dist/discord/message-coordinator.js +3 -8
  15. package/dist/discord/models-command.js +1 -1
  16. package/dist/discord/reaction-handler.js +2 -2
  17. package/dist/discord/reaction-handler.test.js +55 -0
  18. package/dist/discord/verify-push.js +31 -36
  19. package/dist/discord/verify-push.test.js +34 -6
  20. package/dist/discord/voice-command.js +1 -31
  21. package/dist/discord/voice-command.test.js +21 -259
  22. package/dist/discord/voice-status-command.js +3 -22
  23. package/dist/discord/voice-status-command.test.js +16 -124
  24. package/dist/discord-followup.test.js +133 -0
  25. package/dist/health/config-doctor.js +5 -27
  26. package/dist/health/config-doctor.test.js +1 -4
  27. package/dist/index.js +1 -28
  28. package/dist/runtime-overrides.js +2 -3
  29. package/dist/runtime-overrides.test.js +27 -193
  30. package/dist/tasks/store.js +10 -6
  31. package/dist/tasks/store.test.js +44 -0
  32. package/dist/tasks/task-action-executor.test.js +162 -50
  33. package/dist/tasks/task-action-mutations.js +22 -2
  34. package/dist/tasks/task-action-read-ops.js +7 -1
  35. package/dist/tasks/task-action-runner-types.js +19 -1
  36. package/dist/voice/audio-pipeline.js +145 -298
  37. package/docs/configuration.md +4 -9
  38. package/docs/official-docs.md +6 -9
  39. package/docs/runtime-switching.md +1 -1
  40. package/package.json +1 -1
  41. package/dist/voice/audio-pipeline.test.js +0 -1100
  42. package/dist/voice/stt-deepgram.js +0 -154
  43. package/dist/voice/stt-deepgram.test.js +0 -275
  44. package/dist/voice/stt-factory.js +0 -42
  45. package/dist/voice/stt-factory.test.js +0 -45
  46. package/dist/voice/stt-openai.js +0 -156
  47. package/dist/voice/stt-openai.test.js +0 -281
  48. package/dist/voice/tts-cartesia.js +0 -169
  49. package/dist/voice/tts-cartesia.test.js +0 -228
  50. package/dist/voice/tts-deepgram.js +0 -84
  51. package/dist/voice/tts-deepgram.test.js +0 -220
  52. package/dist/voice/tts-factory.js +0 -52
  53. package/dist/voice/tts-factory.test.js +0 -53
  54. package/dist/voice/tts-openai.js +0 -70
  55. package/dist/voice/tts-openai.test.js +0 -138
  56. package/dist/voice/types.test.js +0 -90
@@ -1,7 +1,7 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { ChannelType, PermissionFlagsBits } from 'discord.js';
3
3
  import { buildUnavailableActionTypesNotice } from './output-common.js';
4
- import { parseDiscordActions, executeDiscordActions, discordActionsPromptSection, buildTieredDiscordActionsPromptSection, buildDisplayResultLines, buildAllResultLines, withoutRequesterGatedActionFlags, } from './actions.js';
4
+ import { parseDiscordActions, executeDiscordActions, discordActionsPromptSection, buildTieredDiscordActionsPromptSection, buildDisplayResultLines, buildAllResultLines, buildCappedResultLines, withoutRequesterGatedActionFlags, } from './actions.js';
5
5
  import { TaskStore } from '../tasks/store.js';
6
6
  import { _resetDestructiveConfirmationForTest } from './destructive-confirmation.js';
7
7
  import { shouldTriggerFollowUp } from './action-categories.js';
@@ -892,6 +892,122 @@ describe('buildAllResultLines', () => {
892
892
  ]);
893
893
  });
894
894
  });
895
+ describe('buildCappedResultLines', () => {
896
+ it('microcompacts multiline readMessages-style payloads to representative id lines', () => {
897
+ const results = [{
898
+ ok: true,
899
+ summary: [
900
+ 'Messages in #ops:',
901
+ '[alice] alpha update (id:1001)',
902
+ '[bob] beta update (id:1002)',
903
+ '[carol] gamma update (id:1003)',
904
+ '[dave] delta update (id:1004)',
905
+ '[erin] epsilon update (id:1005)',
906
+ '[frank] zeta update (id:1006)',
907
+ '[grace] eta update (id:1007)',
908
+ '[heidi] theta update (id:1008)',
909
+ '[ivan] iota update (id:1009)',
910
+ ].join('\n'),
911
+ }];
912
+ const [line] = buildCappedResultLines(results);
913
+ expect(line).toContain('Done: Messages in #ops:');
914
+ expect(line).toContain('(id:1001)');
915
+ expect(line).toContain('(id:1002)');
916
+ expect(line).toContain('(id:1008)');
917
+ expect(line).toContain('(id:1009)');
918
+ expect(line).toContain('...[omitted 5 lines]');
919
+ expect(line).not.toContain('(id:1005)');
920
+ });
921
+ it('keeps task thread jump URLs in multiline closeout blocks for follow-up reuse', () => {
922
+ const results = [{
923
+ ok: true,
924
+ summary: [
925
+ 'Task ws-204 closed',
926
+ 'Status: done',
927
+ 'Thread: https://discord.com/channels/111111111111111111/222222222222222222/333333333333333333',
928
+ 'Project: discoclaw',
929
+ 'Assignee: ClawBot',
930
+ 'Updated by: @weston',
931
+ 'Note: Closeout posted in the linked Discord thread.',
932
+ 'Artifacts: /tmp/task-closeout.md',
933
+ 'Next action: use taskShow ws-204 if you need the stored linkage later.',
934
+ 'Watcher: ops',
935
+ ].join('\n'),
936
+ }];
937
+ const [line] = buildCappedResultLines(results);
938
+ expect(line).toContain('Done: Task ws-204 closed');
939
+ expect(line).toContain('Status: done');
940
+ expect(line).toContain('Thread: https://discord.com/channels/111111111111111111/222222222222222222/333333333333333333');
941
+ expect(line).toContain('Artifacts: /tmp/task-closeout.md');
942
+ expect(line).toContain('Next action: use taskShow ws-204 if you need the stored linkage later.');
943
+ expect(line).toContain('...[omitted 4 lines]');
944
+ expect(line).not.toContain('Assignee: ClawBot');
945
+ });
946
+ it('keeps section headings and first values in memoryShow-style blocks', () => {
947
+ const results = [{
948
+ ok: true,
949
+ summary: [
950
+ '**Durable memory:**',
951
+ '- prefers black coffee',
952
+ '- uses fish shell',
953
+ '**By entity:**',
954
+ '- [project] discoclaw migration',
955
+ '**Rolling summary:**',
956
+ 'Working on prompt compaction.',
957
+ '**Short-term memory:**',
958
+ 'Need task-42 and /tmp/report.md for the next reply.',
959
+ 'Extra noisy note A that should be dropped.',
960
+ 'Extra noisy note B that should be dropped.',
961
+ 'Extra noisy note that should be dropped.',
962
+ ].join('\n'),
963
+ }];
964
+ const [line] = buildCappedResultLines(results);
965
+ expect(line).toContain('Done: **Durable memory:**');
966
+ expect(line).toContain('- prefers black coffee');
967
+ expect(line).toContain('**By entity:**');
968
+ expect(line).toContain('- [project] discoclaw migration');
969
+ expect(line).toContain('**Rolling summary:**');
970
+ expect(line).toContain('Working on prompt compaction.');
971
+ expect(line).toContain('**Short-term memory:**');
972
+ expect(line).toContain('Need task-42 and /tmp/report.md for the next reply.');
973
+ expect(line).toContain('...[omitted ');
974
+ expect(line).not.toContain('Extra noisy note B that should be dropped.');
975
+ });
976
+ it('keeps failed prefixes, paths, and actionable error lines in multiline failures', () => {
977
+ const results = [{
978
+ ok: false,
979
+ error: [
980
+ 'Forge sync failed while opening workspace state',
981
+ 'Workspace: /home/davidmarsh/code/discoclaw/workspace/state.json',
982
+ 'Last error: ENOENT: no such file or directory',
983
+ 'Stack: at openWorkspaceState (src/discord/actions.ts:400:12)',
984
+ 'Stack: at runForgeSync (src/discord/actions.ts:512:8)',
985
+ 'Stack: at async executeForgeAction (src/discord/actions.ts:618:3)',
986
+ 'Retry hint: recreate the state file, then rerun forge sync',
987
+ 'Node: v24.0.0',
988
+ 'cwd: /home/davidmarsh/code/discoclaw',
989
+ ].join('\n'),
990
+ }];
991
+ const [line] = buildCappedResultLines(results);
992
+ expect(line).toContain('Failed: Forge sync failed while opening workspace state');
993
+ expect(line).toContain('Workspace: /home/davidmarsh/code/discoclaw/workspace/state.json');
994
+ expect(line).toContain('Last error: ENOENT: no such file or directory');
995
+ expect(line).toContain('Retry hint: recreate the state file, then rerun forge sync');
996
+ expect(line).toContain('cwd: /home/davidmarsh/code/discoclaw');
997
+ expect(line).toContain('...[omitted ');
998
+ expect(line).not.toContain('Stack: at runForgeSync');
999
+ });
1000
+ it('falls back to the final hard cap when retained text is still too long', () => {
1001
+ const results = [{
1002
+ ok: true,
1003
+ summary: `Downloaded attachment to /tmp/${'x'.repeat(120)}`,
1004
+ }];
1005
+ const [line] = buildCappedResultLines(results, 60);
1006
+ expect(line.length).toBeLessThanOrEqual(60);
1007
+ expect(line).toContain('Done: Downloaded attachment');
1008
+ expect(line.endsWith('...[truncated]')).toBe(true);
1009
+ });
1010
+ });
895
1011
  describe('discordActionsPromptSection', () => {
896
1012
  it('always includes the standard guidance when actions are enabled', () => {
897
1013
  const flags = {
@@ -23,7 +23,7 @@ export function handleHelpCommand() {
23
23
  '- `!update` — check for or apply code updates; `!update help` for details',
24
24
  '- `!restart` — restart the discoclaw service; `!restart help` for details',
25
25
  '- `!stop` — abort all active AI streams and cancel any running forge',
26
- '- `!voice status` — show voice subsystem status (STT/TTS providers, connections)',
26
+ '- `!voice status` — show Gemini Live voice status and active connections',
27
27
  '- `!help` — this message',
28
28
  ].join('\n');
29
29
  }
@@ -761,15 +761,12 @@ export function createMessageCreateHandler(params, queue, statusRef) {
761
761
  const connMap = (params.voiceCtx ?? params.voiceStatusCtx)?.voiceManager.listConnections() ?? new Map();
762
762
  const voiceSnapshot = {
763
763
  enabled: params.voiceEnabled ?? false,
764
- sttProvider: params.voiceSttProvider ?? 'deepgram',
765
- ttsProvider: params.voiceTtsProvider ?? 'cartesia',
764
+ provider: 'gemini-live',
765
+ geminiKeySet: Boolean(params.geminiApiKey),
766
+ model: params.voiceModelCtx?.model,
766
767
  homeChannel: params.voiceHomeChannel,
767
- deepgramKeySet: Boolean(params.deepgramApiKey),
768
- cartesiaKeySet: Boolean(params.cartesiaApiKey),
769
768
  autoJoin: params.voiceAutoJoin ?? false,
770
769
  actionsEnabled: params.discordActionsVoice ?? false,
771
- deepgramSttModel: params.deepgramSttModel,
772
- deepgramTtsVoice: params.getTtsVoice?.() ?? params.deepgramTtsVoice,
773
770
  connections: [...connMap.entries()].map(([guildId, info]) => ({
774
771
  guildId,
775
772
  channelId: info.channelId,
@@ -780,10 +777,8 @@ export function createMessageCreateHandler(params, queue, statusRef) {
780
777
  };
781
778
  const voiceReply = await handleVoiceCommand(voiceCmd, {
782
779
  voiceEnabled: params.voiceEnabled ?? false,
783
- ttsProvider: params.voiceTtsProvider ?? 'cartesia',
784
780
  statusSnapshot: voiceSnapshot,
785
781
  botDisplayName: params.botDisplayName,
786
- setTtsVoice: params.setTtsVoice,
787
782
  });
788
783
  await msg.reply({ content: voiceReply, allowedMentions: NO_MENTIONS });
789
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';
@@ -1094,7 +1094,7 @@ function createReactionHandler(mode, params, queue, statusRef) {
1094
1094
  let nextFollowUp = null;
1095
1095
  if (shouldQueueFollowUp) {
1096
1096
  const token = buildFollowUpToken();
1097
- const followUpLines = buildAllResultLines(actionResults);
1097
+ const followUpLines = buildCappedResultLines(actionResults);
1098
1098
  const failureRetryPlaceholder = buildFailureRetryPlaceholder(parsedActions, actionResults);
1099
1099
  const followUpSuffix = failureRetryPlaceholder
1100
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.`
@@ -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
  }