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.
- package/.env.example +4 -6
- package/.env.example.full +13 -32
- package/README.md +1 -1
- package/dist/cli/dashboard.test.js +0 -4
- package/dist/cli/init-wizard.js +4 -8
- package/dist/cli/init-wizard.test.js +4 -10
- package/dist/config.js +2 -42
- package/dist/config.test.js +8 -72
- package/dist/dashboard/server.js +1 -5
- package/dist/dashboard/server.test.js +3 -6
- package/dist/discord/actions.js +112 -6
- package/dist/discord/actions.test.js +117 -1
- package/dist/discord/help-command.js +1 -1
- package/dist/discord/message-coordinator.js +3 -8
- package/dist/discord/models-command.js +1 -1
- package/dist/discord/reaction-handler.js +2 -2
- package/dist/discord/reaction-handler.test.js +55 -0
- package/dist/discord/verify-push.js +31 -36
- package/dist/discord/verify-push.test.js +34 -6
- package/dist/discord/voice-command.js +1 -31
- package/dist/discord/voice-command.test.js +21 -259
- package/dist/discord/voice-status-command.js +3 -22
- package/dist/discord/voice-status-command.test.js +16 -124
- package/dist/discord-followup.test.js +133 -0
- package/dist/health/config-doctor.js +5 -27
- package/dist/health/config-doctor.test.js +1 -4
- package/dist/index.js +1 -28
- package/dist/runtime-overrides.js +2 -3
- package/dist/runtime-overrides.test.js +27 -193
- package/dist/tasks/store.js +10 -6
- package/dist/tasks/store.test.js +44 -0
- package/dist/tasks/task-action-executor.test.js +162 -50
- package/dist/tasks/task-action-mutations.js +22 -2
- package/dist/tasks/task-action-read-ops.js +7 -1
- package/dist/tasks/task-action-runner-types.js +19 -1
- package/dist/voice/audio-pipeline.js +145 -298
- package/docs/configuration.md +4 -9
- package/docs/official-docs.md +6 -9
- package/docs/runtime-switching.md +1 -1
- package/package.json +1 -1
- package/dist/voice/audio-pipeline.test.js +0 -1100
- package/dist/voice/stt-deepgram.js +0 -154
- package/dist/voice/stt-deepgram.test.js +0 -275
- package/dist/voice/stt-factory.js +0 -42
- package/dist/voice/stt-factory.test.js +0 -45
- package/dist/voice/stt-openai.js +0 -156
- package/dist/voice/stt-openai.test.js +0 -281
- package/dist/voice/tts-cartesia.js +0 -169
- package/dist/voice/tts-cartesia.test.js +0 -228
- package/dist/voice/tts-deepgram.js +0 -84
- package/dist/voice/tts-deepgram.test.js +0 -220
- package/dist/voice/tts-factory.js +0 -52
- package/dist/voice/tts-factory.test.js +0 -53
- package/dist/voice/tts-openai.js +0 -70
- package/dist/voice/tts-openai.test.js +0 -138
- 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
|
|
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
|
-
|
|
765
|
-
|
|
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
|
-
'**
|
|
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,
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
8
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
'
|
|
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
|
}
|