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.
- package/.context/voice.md +30 -2
- package/.env.example +7 -3
- package/.env.example.full +13 -32
- package/README.md +1 -1
- package/dist/cli/dashboard.js +7 -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 +5 -38
- package/dist/config.test.js +8 -72
- package/dist/cron/executor.js +72 -1
- package/dist/dashboard/api/metrics.js +7 -0
- package/dist/dashboard/api/metrics.test.js +16 -0
- package/dist/dashboard/api/traces.js +14 -0
- package/dist/dashboard/api/traces.test.js +40 -0
- package/dist/dashboard/page.js +187 -8
- package/dist/dashboard/server.js +82 -19
- package/dist/dashboard/server.test.js +123 -10
- package/dist/discord/actions.js +112 -6
- package/dist/discord/actions.test.js +117 -1
- package/dist/discord/deferred-runner.js +306 -219
- package/dist/discord/help-command.js +1 -1
- package/dist/discord/message-coordinator.js +4 -36
- package/dist/discord/models-command.js +1 -1
- package/dist/discord/reaction-handler.js +83 -5
- 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 +15 -28
- package/dist/observability/trace-store.js +56 -0
- package/dist/observability/trace-utils.js +31 -0
- package/dist/runtime/codex-cli.js +3 -2
- package/dist/runtime/codex-cli.test.js +33 -0
- package/dist/runtime/model-tiers.js +1 -1
- package/dist/runtime/model-tiers.test.js +9 -0
- package/dist/runtime/openai-tool-schemas.js +17 -0
- 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 +183 -96
- package/dist/voice/audio-receiver.js +8 -0
- package/dist/voice/audio-receiver.test.js +16 -0
- package/dist/voice/conversation-buffer.js +16 -6
- package/dist/voice/providers/gemini-live-provider.js +481 -0
- package/dist/voice/providers/gemini-live-provider.test.js +834 -0
- package/dist/voice/providers/gemini-live-responder.js +267 -0
- package/dist/voice/providers/gemini-live-responder.test.js +615 -0
- package/dist/voice/providers/gemini-live-token-estimator.js +100 -0
- package/dist/voice/providers/gemini-live-token-estimator.test.js +160 -0
- package/dist/voice/providers/gemini-live-types.js +32 -0
- package/dist/voice/providers/gemini-tool-mapper.js +91 -0
- package/dist/voice/providers/gemini-tool-mapper.test.js +253 -0
- package/dist/voice/providers/index.js +3 -0
- package/dist/voice/voice-prompt-builder.js +26 -17
- package/dist/voice/voice-prompt-builder.test.js +16 -1
- 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 -619
- 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 -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
|
-
|
|
792
|
-
|
|
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
|
-
'**
|
|
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';
|
|
@@ -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
|
-
|
|
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 (
|
|
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 =
|
|
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 {
|
|
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
|
}
|