discoclaw 1.2.4 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/.context/voice.md +30 -2
  2. package/.env.example +7 -3
  3. package/.env.example.full +13 -32
  4. package/README.md +1 -1
  5. package/dist/cli/dashboard.js +7 -1
  6. package/dist/cli/dashboard.test.js +0 -4
  7. package/dist/cli/init-wizard.js +4 -8
  8. package/dist/cli/init-wizard.test.js +4 -10
  9. package/dist/config.js +5 -38
  10. package/dist/config.test.js +8 -72
  11. package/dist/cron/executor.js +72 -1
  12. package/dist/dashboard/api/metrics.js +7 -0
  13. package/dist/dashboard/api/metrics.test.js +16 -0
  14. package/dist/dashboard/api/traces.js +14 -0
  15. package/dist/dashboard/api/traces.test.js +40 -0
  16. package/dist/dashboard/page.js +187 -8
  17. package/dist/dashboard/server.js +82 -19
  18. package/dist/dashboard/server.test.js +123 -10
  19. package/dist/discord/actions.js +112 -6
  20. package/dist/discord/actions.test.js +117 -1
  21. package/dist/discord/deferred-runner.js +306 -219
  22. package/dist/discord/help-command.js +1 -1
  23. package/dist/discord/message-coordinator.js +4 -36
  24. package/dist/discord/models-command.js +1 -1
  25. package/dist/discord/reaction-handler.js +83 -5
  26. package/dist/discord/reaction-handler.test.js +55 -0
  27. package/dist/discord/verify-push.js +31 -36
  28. package/dist/discord/verify-push.test.js +34 -6
  29. package/dist/discord/voice-command.js +1 -31
  30. package/dist/discord/voice-command.test.js +21 -259
  31. package/dist/discord/voice-status-command.js +3 -22
  32. package/dist/discord/voice-status-command.test.js +16 -124
  33. package/dist/discord-followup.test.js +133 -0
  34. package/dist/health/config-doctor.js +5 -27
  35. package/dist/health/config-doctor.test.js +1 -4
  36. package/dist/index.js +15 -28
  37. package/dist/observability/trace-store.js +56 -0
  38. package/dist/observability/trace-utils.js +31 -0
  39. package/dist/runtime/codex-cli.js +3 -2
  40. package/dist/runtime/codex-cli.test.js +33 -0
  41. package/dist/runtime/model-tiers.js +1 -1
  42. package/dist/runtime/model-tiers.test.js +9 -0
  43. package/dist/runtime/openai-tool-schemas.js +17 -0
  44. package/dist/runtime-overrides.js +2 -3
  45. package/dist/runtime-overrides.test.js +27 -193
  46. package/dist/tasks/store.js +10 -6
  47. package/dist/tasks/store.test.js +44 -0
  48. package/dist/tasks/task-action-executor.test.js +162 -50
  49. package/dist/tasks/task-action-mutations.js +22 -2
  50. package/dist/tasks/task-action-read-ops.js +7 -1
  51. package/dist/tasks/task-action-runner-types.js +19 -1
  52. package/dist/voice/audio-pipeline.js +183 -96
  53. package/dist/voice/audio-receiver.js +8 -0
  54. package/dist/voice/audio-receiver.test.js +16 -0
  55. package/dist/voice/conversation-buffer.js +16 -6
  56. package/dist/voice/providers/gemini-live-provider.js +481 -0
  57. package/dist/voice/providers/gemini-live-provider.test.js +834 -0
  58. package/dist/voice/providers/gemini-live-responder.js +267 -0
  59. package/dist/voice/providers/gemini-live-responder.test.js +615 -0
  60. package/dist/voice/providers/gemini-live-token-estimator.js +100 -0
  61. package/dist/voice/providers/gemini-live-token-estimator.test.js +160 -0
  62. package/dist/voice/providers/gemini-live-types.js +32 -0
  63. package/dist/voice/providers/gemini-tool-mapper.js +91 -0
  64. package/dist/voice/providers/gemini-tool-mapper.test.js +253 -0
  65. package/dist/voice/providers/index.js +3 -0
  66. package/dist/voice/voice-prompt-builder.js +26 -17
  67. package/dist/voice/voice-prompt-builder.test.js +16 -1
  68. package/docs/configuration.md +4 -9
  69. package/docs/official-docs.md +6 -9
  70. package/docs/runtime-switching.md +1 -1
  71. package/package.json +1 -1
  72. package/dist/voice/audio-pipeline.test.js +0 -619
  73. package/dist/voice/stt-deepgram.js +0 -154
  74. package/dist/voice/stt-deepgram.test.js +0 -275
  75. package/dist/voice/stt-factory.js +0 -42
  76. package/dist/voice/stt-factory.test.js +0 -45
  77. package/dist/voice/stt-openai.js +0 -156
  78. package/dist/voice/stt-openai.test.js +0 -281
  79. package/dist/voice/tts-cartesia.js +0 -169
  80. package/dist/voice/tts-cartesia.test.js +0 -228
  81. package/dist/voice/tts-deepgram.js +0 -84
  82. package/dist/voice/tts-deepgram.test.js +0 -220
  83. package/dist/voice/tts-factory.js +0 -52
  84. package/dist/voice/tts-factory.test.js +0 -53
  85. package/dist/voice/tts-openai.js +0 -70
  86. package/dist/voice/tts-openai.test.js +0 -138
  87. package/dist/voice/types.test.js +0 -84
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { ChannelType, PermissionFlagsBits } from 'discord.js';
2
3
  import { appendActionResults, buildTieredDiscordActionsPromptSection, executeDiscordActions, parseDiscordActions } from './actions.js';
3
4
  import { DiscordTransportClient } from './transport-client.js';
@@ -12,6 +13,7 @@ import { buildPromptSectionEstimates, buildContextFiles, buildOpenTasksSection,
12
13
  import { mapRuntimeErrorToUserMessage } from './user-errors.js';
13
14
  import { resolveModel } from '../runtime/model-tiers.js';
14
15
  import { globalMetrics } from '../observability/metrics.js';
16
+ import { globalTraceStore } from '../observability/trace-store.js';
15
17
  import { buildPlanForgeAvailabilityNote } from './plan-forge-availability.js';
16
18
  const REQUESTER_DENY_ALL = { __requesterDenyAll: true };
17
19
  function getThreadParentId(candidate) {
@@ -78,247 +80,332 @@ function buildDeferredActionFlags(state, depth, maxDepth) {
78
80
  export function configureDeferredScheduler(opts) {
79
81
  const handleDeferredRun = async (run) => {
80
82
  const { action, context } = run;
81
- const guild = context.guild;
82
- if (!guild) {
83
- opts.log?.warn({ flow: 'defer', run, action }, 'defer:missing-guild');
84
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
85
- opts.status?.handlerError({ sessionKey: 'defer' }, 'deferred run skipped: no guild context');
86
- return;
87
- }
88
- const channel = resolveChannel(guild, action.channel);
89
- if (!channel) {
90
- opts.log?.warn({ flow: 'defer', run, channel: action.channel }, 'defer:target channel not found');
91
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
92
- opts.status?.handlerError({ sessionKey: `defer:${action.channel}` }, `deferred run skipped: channel "${action.channel}" not found`);
93
- return;
94
- }
95
- if (opts.state.allowChannelIds?.size) {
96
- const parentId = getThreadParentId(channel) ?? '';
97
- const allowed = opts.state.allowChannelIds.has(channel.id) ||
98
- (parentId && opts.state.allowChannelIds.has(parentId));
99
- if (!allowed) {
100
- opts.log?.warn({ flow: 'defer', channelId: channel.id }, 'defer:target channel not allowlisted');
83
+ const traceId = `defer_${randomUUID()}`;
84
+ const sessionKey = `defer:${action.channel}`;
85
+ let traceOutcome = 'success';
86
+ globalTraceStore.startTrace(traceId, sessionKey, 'defer', undefined);
87
+ try {
88
+ const guild = context.guild;
89
+ if (!guild) {
90
+ opts.log?.warn({ flow: 'defer', run, action }, 'defer:missing-guild');
91
+ traceOutcome = 'error';
92
+ globalTraceStore.addEvent(traceId, {
93
+ type: 'error',
94
+ at: Date.now(),
95
+ message: 'deferred run skipped: no guild context',
96
+ stage: 'defer_setup',
97
+ });
101
98
  // eslint-disable-next-line @typescript-eslint/no-floating-promises
102
- opts.status?.handlerError({ sessionKey: `defer:${channel.id}` }, `deferred run skipped: channel ${channel.id} not in allowlist`);
99
+ opts.status?.handlerError({ sessionKey: 'defer' }, 'deferred run skipped: no guild context');
103
100
  return;
104
101
  }
105
- }
106
- const threadParentId = getThreadParentId(channel);
107
- const requesterMember = await resolveRequesterMember(context);
108
- if (isRequesterDenyAll(requesterMember)
109
- || (requesterMember && !requesterCanAccessTargetChannel(channel, requesterMember))) {
110
- opts.log?.warn({ flow: 'defer', channelId: channel.id, requesterId: context.requesterId }, 'defer:target channel permission denied');
111
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
112
- opts.status?.handlerError({ sessionKey: `defer:${channel.id}` }, `deferred run skipped: requester lacks permission for channel ${channel.id}`);
113
- return;
114
- }
115
- const channelCtx = resolveDiscordChannelContext({
116
- ctx: opts.discordChannelContext,
117
- isDm: false,
118
- channelId: channel.id,
119
- threadParentId,
120
- });
121
- const paFiles = await loadWorkspacePaFiles(opts.workspaceCwd, { skip: !!opts.appendSystemPrompt });
122
- const contextFiles = buildContextFiles(paFiles, opts.discordChannelContext, channelCtx.contextPath);
123
- let inlinedContext = {
124
- text: '',
125
- sections: [],
126
- };
127
- if (contextFiles.length > 0) {
102
+ const channel = resolveChannel(guild, action.channel);
103
+ if (!channel) {
104
+ opts.log?.warn({ flow: 'defer', run, channel: action.channel }, 'defer:target channel not found');
105
+ traceOutcome = 'error';
106
+ globalTraceStore.addEvent(traceId, {
107
+ type: 'error',
108
+ at: Date.now(),
109
+ message: `target channel "${action.channel}" not found`,
110
+ stage: 'defer_setup',
111
+ });
112
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
113
+ opts.status?.handlerError({ sessionKey: `defer:${action.channel}` }, `deferred run skipped: channel "${action.channel}" not found`);
114
+ return;
115
+ }
116
+ if (opts.state.allowChannelIds?.size) {
117
+ const parentId = getThreadParentId(channel) ?? '';
118
+ const allowed = opts.state.allowChannelIds.has(channel.id) ||
119
+ (parentId && opts.state.allowChannelIds.has(parentId));
120
+ if (!allowed) {
121
+ opts.log?.warn({ flow: 'defer', channelId: channel.id }, 'defer:target channel not allowlisted');
122
+ traceOutcome = 'error';
123
+ globalTraceStore.addEvent(traceId, {
124
+ type: 'error',
125
+ at: Date.now(),
126
+ message: `target channel "${action.channel}" not allowlisted`,
127
+ stage: 'defer_setup',
128
+ });
129
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
130
+ opts.status?.handlerError({ sessionKey: `defer:${channel.id}` }, `deferred run skipped: channel ${channel.id} not in allowlist`);
131
+ return;
132
+ }
133
+ }
134
+ const threadParentId = getThreadParentId(channel);
135
+ const requesterMember = await resolveRequesterMember(context);
136
+ if (isRequesterDenyAll(requesterMember)
137
+ || (requesterMember && !requesterCanAccessTargetChannel(channel, requesterMember))) {
138
+ opts.log?.warn({ flow: 'defer', channelId: channel.id, requesterId: context.requesterId }, 'defer:target channel permission denied');
139
+ traceOutcome = 'error';
140
+ globalTraceStore.addEvent(traceId, {
141
+ type: 'error',
142
+ at: Date.now(),
143
+ message: `requester lacks permission for channel ${channel.id}`,
144
+ stage: 'defer_setup',
145
+ });
146
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
147
+ opts.status?.handlerError({ sessionKey: `defer:${channel.id}` }, `deferred run skipped: requester lacks permission for channel ${channel.id}`);
148
+ return;
149
+ }
150
+ const channelCtx = resolveDiscordChannelContext({
151
+ ctx: opts.discordChannelContext,
152
+ isDm: false,
153
+ channelId: channel.id,
154
+ threadParentId,
155
+ });
156
+ const paFiles = await loadWorkspacePaFiles(opts.workspaceCwd, { skip: !!opts.appendSystemPrompt });
157
+ const contextFiles = buildContextFiles(paFiles, opts.discordChannelContext, channelCtx.contextPath);
158
+ let inlinedContext = {
159
+ text: '',
160
+ sections: [],
161
+ };
162
+ if (contextFiles.length > 0) {
163
+ try {
164
+ inlinedContext = await inlineContextFilesWithMeta(contextFiles, {
165
+ required: new Set(opts.discordChannelContext?.paContextFiles ?? []),
166
+ });
167
+ }
168
+ catch (err) {
169
+ opts.log?.warn({ flow: 'defer', channelId: channel.id, err }, 'defer:context inline failed');
170
+ }
171
+ }
172
+ const deferDepth = (context.deferDepth ?? 0) + 1;
173
+ const deferredActionFlags = buildDeferredActionFlags(opts.state, deferDepth, opts.deferMaxDepth);
174
+ const openTasksSection = buildOpenTasksSection(opts.state.taskCtx?.store);
175
+ let actionsReferenceSection = '';
176
+ let actionSchemaSelection = null;
177
+ if (opts.state.discordActionsEnabled) {
178
+ const actionSelection = buildTieredDiscordActionsPromptSection(deferredActionFlags, opts.botDisplayName, {
179
+ channelName: channelCtx.channelName,
180
+ channelContextPath: channelCtx.contextPath,
181
+ isThread: threadParentId !== null,
182
+ userText: action.prompt,
183
+ canvasWriteBridgeEnabled: opts.state.canvasCtx?.writeBridgeEnabled,
184
+ imagegenDefaultModel: opts.state.imagegenCtx ? resolveDefaultModel(opts.state.imagegenCtx) : undefined,
185
+ });
186
+ actionsReferenceSection = actionSelection.prompt;
187
+ actionSchemaSelection = {
188
+ includedCategories: actionSelection.includedCategories,
189
+ tierBuckets: actionSelection.tierBuckets,
190
+ keywordHits: actionSelection.keywordHits,
191
+ };
192
+ }
193
+ const promptSectionEstimates = buildPromptSectionEstimates({
194
+ contextSections: inlinedContext.sections,
195
+ channelContextPath: channelCtx.contextPath,
196
+ openTasksSection,
197
+ actionsReferenceSection,
198
+ });
199
+ opts.log?.info({
200
+ flow: 'defer',
201
+ channelId: channel.id,
202
+ sections: promptSectionEstimates.sections,
203
+ totalChars: promptSectionEstimates.totalChars,
204
+ totalEstTokens: promptSectionEstimates.totalEstTokens,
205
+ actionSchemaSelection,
206
+ }, 'defer:prompt:section-estimates');
207
+ const noteLines = [];
208
+ let effectiveTools = opts.runtimeTools;
128
209
  try {
129
- inlinedContext = await inlineContextFilesWithMeta(contextFiles, {
130
- required: new Set(opts.discordChannelContext?.paContextFiles ?? []),
210
+ const toolsInfo = await resolveEffectiveTools({
211
+ workspaceCwd: opts.workspaceCwd,
212
+ runtimeTools: opts.runtimeTools,
213
+ runtimeCapabilities: resolveGroundedToolCapabilities(opts.runtime),
214
+ runtimeId: opts.runtime.id,
215
+ log: opts.log,
131
216
  });
217
+ effectiveTools = toolsInfo.effectiveTools;
218
+ if (toolsInfo.permissionNote)
219
+ noteLines.push(`Permission note: ${toolsInfo.permissionNote}`);
220
+ if (toolsInfo.runtimeCapabilityNote)
221
+ noteLines.push(`Runtime capability note: ${toolsInfo.runtimeCapabilityNote}`);
132
222
  }
133
223
  catch (err) {
134
- opts.log?.warn({ flow: 'defer', channelId: channel.id, err }, 'defer:context inline failed');
224
+ opts.log?.warn({ flow: 'defer', channelId: channel.id, err }, 'defer:resolve effective tools failed');
135
225
  }
136
- }
137
- const deferDepth = (context.deferDepth ?? 0) + 1;
138
- const deferredActionFlags = buildDeferredActionFlags(opts.state, deferDepth, opts.deferMaxDepth);
139
- const openTasksSection = buildOpenTasksSection(opts.state.taskCtx?.store);
140
- let actionsReferenceSection = '';
141
- let actionSchemaSelection = null;
142
- if (opts.state.discordActionsEnabled) {
143
- const actionSelection = buildTieredDiscordActionsPromptSection(deferredActionFlags, opts.botDisplayName, {
144
- channelName: channelCtx.channelName,
145
- channelContextPath: channelCtx.contextPath,
146
- isThread: threadParentId !== null,
147
- userText: action.prompt,
148
- canvasWriteBridgeEnabled: opts.state.canvasCtx?.writeBridgeEnabled,
149
- imagegenDefaultModel: opts.state.imagegenCtx ? resolveDefaultModel(opts.state.imagegenCtx) : undefined,
226
+ const planForgeAvailabilityNote = buildPlanForgeAvailabilityNote({
227
+ planCommandsEnabled: opts.state.planCommandsEnabled !== false,
228
+ forgeCommandsEnabled: opts.state.forgeCommandsEnabled !== false,
229
+ planActionsEnabled: Boolean(opts.state.discordActionsPlan),
230
+ forgeActionsEnabled: Boolean(opts.state.discordActionsForge),
150
231
  });
151
- actionsReferenceSection = actionSelection.prompt;
152
- actionSchemaSelection = {
153
- includedCategories: actionSelection.includedCategories,
154
- tierBuckets: actionSelection.tierBuckets,
155
- keywordHits: actionSelection.keywordHits,
156
- };
157
- }
158
- const promptSectionEstimates = buildPromptSectionEstimates({
159
- contextSections: inlinedContext.sections,
160
- channelContextPath: channelCtx.contextPath,
161
- openTasksSection,
162
- actionsReferenceSection,
163
- });
164
- opts.log?.info({
165
- flow: 'defer',
166
- channelId: channel.id,
167
- sections: promptSectionEstimates.sections,
168
- totalChars: promptSectionEstimates.totalChars,
169
- totalEstTokens: promptSectionEstimates.totalEstTokens,
170
- actionSchemaSelection,
171
- }, 'defer:prompt:section-estimates');
172
- const noteLines = [];
173
- let effectiveTools = opts.runtimeTools;
174
- try {
175
- const toolsInfo = await resolveEffectiveTools({
176
- workspaceCwd: opts.workspaceCwd,
177
- runtimeTools: opts.runtimeTools,
178
- runtimeCapabilities: resolveGroundedToolCapabilities(opts.runtime),
232
+ if (planForgeAvailabilityNote)
233
+ noteLines.push(`Runtime capability note: ${planForgeAvailabilityNote}`);
234
+ const prompt = buildScheduledSelfInvocationPrompt({
235
+ inlinedContext: inlinedContext.text,
236
+ openTasksSection,
237
+ actionsReferenceSection,
238
+ noteLines,
179
239
  runtimeId: opts.runtime.id,
180
- log: opts.log,
240
+ runtimeCapabilities: opts.runtime.capabilities,
241
+ runtimeTools: opts.runtimeTools,
242
+ enableHybridPipeline: opts.state.enableHybridPipeline,
243
+ invocationNotice: `Deferred follow-up scheduled for <#${channel.id}> (runs at ${fmtTime(run.runsAt)}).`,
244
+ userMessage: action.prompt,
181
245
  });
182
- effectiveTools = toolsInfo.effectiveTools;
183
- if (toolsInfo.permissionNote)
184
- noteLines.push(`Permission note: ${toolsInfo.permissionNote}`);
185
- if (toolsInfo.runtimeCapabilityNote)
186
- noteLines.push(`Runtime capability note: ${toolsInfo.runtimeCapabilityNote}`);
187
- }
188
- catch (err) {
189
- opts.log?.warn({ flow: 'defer', channelId: channel.id, err }, 'defer:resolve effective tools failed');
190
- }
191
- const planForgeAvailabilityNote = buildPlanForgeAvailabilityNote({
192
- planCommandsEnabled: opts.state.planCommandsEnabled !== false,
193
- forgeCommandsEnabled: opts.state.forgeCommandsEnabled !== false,
194
- planActionsEnabled: Boolean(opts.state.discordActionsPlan),
195
- forgeActionsEnabled: Boolean(opts.state.discordActionsForge),
196
- });
197
- if (planForgeAvailabilityNote)
198
- noteLines.push(`Runtime capability note: ${planForgeAvailabilityNote}`);
199
- const prompt = buildScheduledSelfInvocationPrompt({
200
- inlinedContext: inlinedContext.text,
201
- openTasksSection,
202
- actionsReferenceSection,
203
- noteLines,
204
- runtimeId: opts.runtime.id,
205
- runtimeCapabilities: opts.runtime.capabilities,
206
- runtimeTools: opts.runtimeTools,
207
- enableHybridPipeline: opts.state.enableHybridPipeline,
208
- invocationNotice: `Deferred follow-up scheduled for <#${channel.id}> (runs at ${fmtTime(run.runsAt)}).`,
209
- userMessage: action.prompt,
210
- });
211
- const addDirs = [];
212
- if (opts.useGroupDirCwd)
213
- addDirs.push(opts.workspaceCwd);
214
- if (opts.discordChannelContext)
215
- addDirs.push(opts.discordChannelContext.contentDir);
216
- const uniqueAddDirs = addDirs.length > 0 ? Array.from(new Set(addDirs)) : undefined;
217
- const t0 = Date.now();
218
- globalMetrics.recordInvokeStart('defer');
219
- opts.log?.info({ flow: 'defer', channelId: channel.id }, 'obs.invoke.start');
220
- let finalText = '';
221
- let deltaText = '';
222
- let runtimeError;
223
- let invokeResultRecorded = false;
224
- try {
225
- for await (const evt of opts.runtime.invoke({
226
- prompt,
227
- model: resolveModel(opts.state.runtimeModel, opts.runtime.id),
228
- cwd: opts.workspaceCwd,
229
- addDirs: uniqueAddDirs,
230
- tools: effectiveTools,
231
- timeoutMs: opts.runtimeTimeoutMs,
232
- })) {
233
- if (evt.type === 'text_final') {
234
- finalText = evt.text;
235
- }
236
- else if (evt.type === 'text_delta') {
237
- deltaText += evt.text;
246
+ const addDirs = [];
247
+ if (opts.useGroupDirCwd)
248
+ addDirs.push(opts.workspaceCwd);
249
+ if (opts.discordChannelContext)
250
+ addDirs.push(opts.discordChannelContext.contentDir);
251
+ const uniqueAddDirs = addDirs.length > 0 ? Array.from(new Set(addDirs)) : undefined;
252
+ const t0 = Date.now();
253
+ globalMetrics.recordInvokeStart('defer');
254
+ globalTraceStore.addEvent(traceId, {
255
+ type: 'invoke_start',
256
+ at: t0,
257
+ summary: `deferred run for <#${channel.id}>`,
258
+ promptPreview: action.prompt.slice(0, 220),
259
+ });
260
+ opts.log?.info({ flow: 'defer', channelId: channel.id }, 'obs.invoke.start');
261
+ let finalText = '';
262
+ let deltaText = '';
263
+ let runtimeError;
264
+ let invokeResultRecorded = false;
265
+ try {
266
+ for await (const evt of opts.runtime.invoke({
267
+ prompt,
268
+ model: resolveModel(opts.state.runtimeModel, opts.runtime.id),
269
+ cwd: opts.workspaceCwd,
270
+ addDirs: uniqueAddDirs,
271
+ tools: effectiveTools,
272
+ timeoutMs: opts.runtimeTimeoutMs,
273
+ })) {
274
+ if (evt.type === 'text_final') {
275
+ finalText = evt.text;
276
+ }
277
+ else if (evt.type === 'text_delta') {
278
+ deltaText += evt.text;
279
+ }
280
+ else if (evt.type === 'error') {
281
+ runtimeError = evt.message;
282
+ finalText = mapRuntimeErrorToUserMessage(evt.message);
283
+ traceOutcome = 'error';
284
+ globalTraceStore.addEvent(traceId, {
285
+ type: 'error',
286
+ at: Date.now(),
287
+ message: evt.message,
288
+ stage: 'runtime',
289
+ });
290
+ globalMetrics.recordInvokeResult('defer', Date.now() - t0, false, evt.message);
291
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
292
+ opts.status?.runtimeError({ sessionKey: `defer:${channel.id}` }, evt.message);
293
+ opts.log?.warn({ flow: 'defer', channelId: channel.id, error: evt.message }, 'obs.invoke.error');
294
+ invokeResultRecorded = true;
295
+ break;
296
+ }
238
297
  }
239
- else if (evt.type === 'error') {
240
- runtimeError = evt.message;
241
- finalText = mapRuntimeErrorToUserMessage(evt.message);
242
- globalMetrics.recordInvokeResult('defer', Date.now() - t0, false, evt.message);
298
+ }
299
+ catch (err) {
300
+ const msg = err instanceof Error ? err.message : String(err);
301
+ runtimeError ??= msg;
302
+ finalText = mapRuntimeErrorToUserMessage(msg);
303
+ traceOutcome = 'error';
304
+ globalTraceStore.addEvent(traceId, {
305
+ type: 'error',
306
+ at: Date.now(),
307
+ message: msg,
308
+ name: err instanceof Error ? err.name : undefined,
309
+ stage: 'runtime',
310
+ stack: err instanceof Error ? err.stack?.slice(0, 400) : undefined,
311
+ });
312
+ if (!invokeResultRecorded) {
313
+ globalMetrics.recordInvokeResult('defer', Date.now() - t0, false, msg);
243
314
  // eslint-disable-next-line @typescript-eslint/no-floating-promises
244
- opts.status?.runtimeError({ sessionKey: `defer:${channel.id}` }, evt.message);
245
- opts.log?.warn({ flow: 'defer', channelId: channel.id, error: evt.message }, 'obs.invoke.error');
315
+ opts.status?.runtimeError({ sessionKey: `defer:${channel.id}` }, msg);
246
316
  invokeResultRecorded = true;
247
- break;
248
317
  }
318
+ opts.log?.warn({ flow: 'defer', channelId: channel.id, err }, 'defer:runtime invocation failed');
249
319
  }
250
- }
251
- catch (err) {
252
- const msg = err instanceof Error ? err.message : String(err);
253
- runtimeError ??= msg;
254
- finalText = mapRuntimeErrorToUserMessage(msg);
255
320
  if (!invokeResultRecorded) {
256
- globalMetrics.recordInvokeResult('defer', Date.now() - t0, false, msg);
257
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
258
- opts.status?.runtimeError({ sessionKey: `defer:${channel.id}` }, msg);
259
- invokeResultRecorded = true;
321
+ globalTraceStore.addEvent(traceId, {
322
+ type: 'invoke_end',
323
+ at: Date.now(),
324
+ ok: true,
325
+ summary: `completed in ${Date.now() - t0}ms`,
326
+ });
327
+ globalMetrics.recordInvokeResult('defer', Date.now() - t0, true);
328
+ opts.log?.info({ flow: 'defer', channelId: channel.id, ms: Date.now() - t0, ok: true }, 'obs.invoke.end');
260
329
  }
261
- opts.log?.warn({ flow: 'defer', channelId: channel.id, err }, 'defer:runtime invocation failed');
262
- }
263
- if (!invokeResultRecorded) {
264
- globalMetrics.recordInvokeResult('defer', Date.now() - t0, true);
265
- opts.log?.info({ flow: 'defer', channelId: channel.id, ms: Date.now() - t0, ok: true }, 'obs.invoke.end');
266
- }
267
- const processedText = finalText || deltaText || '';
268
- const parsed = parseDiscordActions(processedText, deferredActionFlags);
269
- const actCtx = {
270
- guild,
271
- client: context.client,
272
- channelId: channel.id,
273
- messageId: `defer-${Date.now()}`,
274
- requesterId: context.requesterId,
275
- threadParentId,
276
- deferScheduler: context.deferScheduler,
277
- deferDepth,
278
- transport: new DiscordTransportClient(guild, context.client),
279
- confirmation: {
280
- mode: 'automated',
281
- },
282
- };
283
- let actionResults = [];
284
- if (parsed.actions.length > 0) {
285
- actionResults = await executeDiscordActions(parsed.actions, actCtx, opts.log, {
286
- taskCtx: opts.state.taskCtx,
287
- cronCtx: opts.state.cronCtx,
288
- forgeCtx: opts.state.forgeCtx,
289
- planCtx: opts.state.planCtx,
290
- memoryCtx: opts.state.memoryCtx,
291
- configCtx: opts.state.configCtx,
292
- canvasCtx: opts.state.canvasCtx,
293
- imagegenCtx: opts.state.imagegenCtx,
294
- voiceCtx: opts.state.voiceCtx,
295
- spawnCtx: opts.state.spawnCtx,
296
- });
297
- for (let i = 0; i < actionResults.length; i++) {
298
- const result = actionResults[i];
299
- globalMetrics.recordActionResult(result.ok);
300
- opts.log?.info({ flow: 'defer', channelId: channel.id, ok: result.ok }, 'obs.action.result');
301
- if (!result.ok) {
302
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
303
- opts.status?.actionFailed(parsed.actions[i].type, result.error);
330
+ const processedText = finalText || deltaText || '';
331
+ const parsed = parseDiscordActions(processedText, deferredActionFlags);
332
+ const actCtx = {
333
+ guild,
334
+ client: context.client,
335
+ channelId: channel.id,
336
+ messageId: `defer-${Date.now()}`,
337
+ requesterId: context.requesterId,
338
+ threadParentId,
339
+ deferScheduler: context.deferScheduler,
340
+ deferDepth,
341
+ transport: new DiscordTransportClient(guild, context.client),
342
+ confirmation: {
343
+ mode: 'automated',
344
+ },
345
+ };
346
+ let actionResults = [];
347
+ if (parsed.actions.length > 0) {
348
+ actionResults = await executeDiscordActions(parsed.actions, actCtx, opts.log, {
349
+ taskCtx: opts.state.taskCtx,
350
+ cronCtx: opts.state.cronCtx,
351
+ forgeCtx: opts.state.forgeCtx,
352
+ planCtx: opts.state.planCtx,
353
+ memoryCtx: opts.state.memoryCtx,
354
+ configCtx: opts.state.configCtx,
355
+ canvasCtx: opts.state.canvasCtx,
356
+ imagegenCtx: opts.state.imagegenCtx,
357
+ voiceCtx: opts.state.voiceCtx,
358
+ spawnCtx: opts.state.spawnCtx,
359
+ });
360
+ for (let i = 0; i < actionResults.length; i++) {
361
+ const result = actionResults[i];
362
+ globalMetrics.recordActionResult(result.ok);
363
+ globalTraceStore.addEvent(traceId, {
364
+ type: 'action_result',
365
+ at: Date.now(),
366
+ action: parsed.actions[i].type,
367
+ ok: result.ok,
368
+ detail: result.ok ? undefined : ('error' in result ? result.error : undefined),
369
+ });
370
+ opts.log?.info({ flow: 'defer', channelId: channel.id, ok: result.ok }, 'obs.action.result');
371
+ if (!result.ok) {
372
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
373
+ opts.status?.actionFailed(parsed.actions[i].type, result.error);
374
+ }
304
375
  }
305
376
  }
306
- }
307
- let outgoingText = appendActionResults(parsed.cleanText.trim(), parsed.actions, actionResults);
308
- outgoingText = appendUnavailableActionTypesNotice(outgoingText, parsed.strippedUnrecognizedTypes).trim();
309
- outgoingText = appendParseFailureNotice(outgoingText, parsed.parseFailures).trim();
310
- if (!outgoingText && runtimeError) {
311
- outgoingText = runtimeError;
312
- }
313
- if (!outgoingText) {
314
- opts.log?.warn({ flow: 'defer', channelId: channel.id }, 'defer:empty output, nothing to send');
315
- return;
316
- }
317
- try {
318
- await channel.send({ content: outgoingText, allowedMentions: NO_MENTIONS });
377
+ let outgoingText = appendActionResults(parsed.cleanText.trim(), parsed.actions, actionResults);
378
+ outgoingText = appendUnavailableActionTypesNotice(outgoingText, parsed.strippedUnrecognizedTypes).trim();
379
+ outgoingText = appendParseFailureNotice(outgoingText, parsed.parseFailures).trim();
380
+ if (!outgoingText && runtimeError) {
381
+ outgoingText = runtimeError;
382
+ }
383
+ if (!outgoingText) {
384
+ opts.log?.warn({ flow: 'defer', channelId: channel.id }, 'defer:empty output, nothing to send');
385
+ return;
386
+ }
387
+ try {
388
+ await channel.send({ content: outgoingText, allowedMentions: NO_MENTIONS });
389
+ }
390
+ catch (err) {
391
+ opts.log?.warn({ flow: 'defer', channelId: channel.id, err }, 'defer:failed to post follow-up');
392
+ }
319
393
  }
320
394
  catch (err) {
321
- opts.log?.warn({ flow: 'defer', channelId: channel.id, err }, 'defer:failed to post follow-up');
395
+ const msg = err instanceof Error ? err.message : String(err);
396
+ traceOutcome = 'error';
397
+ globalTraceStore.addEvent(traceId, {
398
+ type: 'error',
399
+ at: Date.now(),
400
+ message: msg,
401
+ name: err instanceof Error ? err.name : undefined,
402
+ stage: 'defer_flow',
403
+ stack: err instanceof Error ? err.stack?.slice(0, 400) : undefined,
404
+ });
405
+ opts.log?.error({ flow: 'defer', err }, 'defer:handler failed');
406
+ }
407
+ finally {
408
+ globalTraceStore.endTrace(traceId, traceOutcome);
322
409
  }
323
410
  };
324
411
  const deferScheduler = new DeferSchedulerImpl({
@@ -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
  }