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
@@ -13,7 +13,7 @@ export const KNOWN_RUNTIMES = new Set([
13
13
  'openai',
14
14
  'openrouter',
15
15
  ]);
16
- const KNOWN_RUNTIME_OVERRIDE_KEYS = new Set(['ttsVoice', 'voiceRuntime', 'fastRuntime']);
16
+ const KNOWN_RUNTIME_OVERRIDE_KEYS = new Set(['voiceRuntime', 'fastRuntime']);
17
17
  const DEPRECATED_ENV_VARS = {
18
18
  DISCOCLAW_FAST_RUNTIME: {
19
19
  recommendation: "Replace DISCOCLAW_FAST_RUNTIME with '!models set fast <model>' and remove the env var once the runtime override is no longer needed.",
@@ -146,8 +146,6 @@ async function readRuntimeOverridesFileState(filePath) {
146
146
  }
147
147
  const obj = parsed;
148
148
  const overrides = {};
149
- if (typeof obj['ttsVoice'] === 'string')
150
- overrides.ttsVoice = obj['ttsVoice'];
151
149
  if (typeof obj['voiceRuntime'] === 'string')
152
150
  overrides.voiceRuntime = obj['voiceRuntime'];
153
151
  if (typeof obj['fastRuntime'] === 'string')
@@ -568,32 +566,12 @@ export function detectMissingSecrets(ctx) {
568
566
  }
569
567
  const voiceEnabled = parseBoolean(ctx.env.DISCOCLAW_VOICE_ENABLED, false);
570
568
  if (voiceEnabled) {
571
- const sttProvider = trimValue(ctx.env.DISCOCLAW_STT_PROVIDER) ?? 'deepgram';
572
- const ttsProvider = trimValue(ctx.env.DISCOCLAW_TTS_PROVIDER) ?? 'cartesia';
573
- const voiceSecretTargets = [];
574
- if (sttProvider === 'deepgram') {
575
- voiceSecretTargets.push({ label: 'DISCOCLAW_STT_PROVIDER', provider: sttProvider, secretKey: 'DEEPGRAM_API_KEY' });
576
- }
577
- else if (sttProvider === 'openai') {
578
- voiceSecretTargets.push({ label: 'DISCOCLAW_STT_PROVIDER', provider: sttProvider, secretKey: 'OPENAI_API_KEY' });
579
- }
580
- if (ttsProvider === 'cartesia') {
581
- voiceSecretTargets.push({ label: 'DISCOCLAW_TTS_PROVIDER', provider: ttsProvider, secretKey: 'CARTESIA_API_KEY' });
582
- }
583
- else if (ttsProvider === 'deepgram') {
584
- voiceSecretTargets.push({ label: 'DISCOCLAW_TTS_PROVIDER', provider: ttsProvider, secretKey: 'DEEPGRAM_API_KEY' });
585
- }
586
- else if (ttsProvider === 'openai') {
587
- voiceSecretTargets.push({ label: 'DISCOCLAW_TTS_PROVIDER', provider: ttsProvider, secretKey: 'OPENAI_API_KEY' });
588
- }
589
- for (const target of voiceSecretTargets) {
590
- if (hasSecret(ctx.env, target.secretKey))
591
- continue;
569
+ if (!hasSecret(ctx.env, 'GEMINI_API_KEY')) {
592
570
  findings.push({
593
- id: `missing-secret:${target.label}:${target.secretKey}`,
571
+ id: 'missing-secret:DISCOCLAW_VOICE_ENABLED:GEMINI_API_KEY',
594
572
  severity: 'error',
595
- message: `${target.label}=${target.provider} requires ${target.secretKey}, but it is not set.`,
596
- recommendation: `Set ${target.secretKey} or switch ${target.label} to a provider that is already configured.`,
573
+ message: 'DISCOCLAW_VOICE_ENABLED=1 requires GEMINI_API_KEY, but it is not set.',
574
+ recommendation: 'Set GEMINI_API_KEY or disable voice on this install.',
597
575
  autoFixable: false,
598
576
  });
599
577
  }
@@ -242,8 +242,6 @@ describe('detectMissingSecrets', () => {
242
242
  await writeEnv(cwd, [
243
243
  'PRIMARY_RUNTIME=openrouter',
244
244
  'DISCOCLAW_VOICE_ENABLED=1',
245
- 'DISCOCLAW_STT_PROVIDER=deepgram',
246
- 'DISCOCLAW_TTS_PROVIDER=cartesia',
247
245
  'DISCOCLAW_COLD_STORAGE_ENABLED=1',
248
246
  'DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=1',
249
247
  'IMAGEGEN_DEFAULT_MODEL=imagen-4.0-generate-001',
@@ -256,8 +254,7 @@ describe('detectMissingSecrets', () => {
256
254
  expect(findings.map((finding) => finding.id)).toEqual([
257
255
  'missing-secret:PRIMARY_RUNTIME:OPENROUTER_API_KEY',
258
256
  'missing-secret:runtime-overrides.voiceRuntime:OPENAI_API_KEY',
259
- 'missing-secret:DISCOCLAW_STT_PROVIDER:DEEPGRAM_API_KEY',
260
- 'missing-secret:DISCOCLAW_TTS_PROVIDER:CARTESIA_API_KEY',
257
+ 'missing-secret:DISCOCLAW_VOICE_ENABLED:GEMINI_API_KEY',
261
258
  'missing-secret:DISCOCLAW_COLD_STORAGE_ENABLED:COLD_STORAGE_API_KEY-or-OPENAI_API_KEY',
262
259
  'missing-secret:DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN:OPENAI_API_KEY-or-IMAGEGEN_GEMINI_API_KEY',
263
260
  'missing-secret:IMAGEGEN_DEFAULT_MODEL:IMAGEGEN_GEMINI_API_KEY',
package/dist/index.js CHANGED
@@ -19,7 +19,7 @@ import { buildDurableMemorySection } from './discord/prompt-common.js';
19
19
  import { parseDiscordActions, executeDiscordActions, buildTieredDiscordActionsPromptSection, buildAllResultLines, appendActionResults } from './discord/actions.js';
20
20
  import { DiscordTransportClient } from './discord/transport-client.js';
21
21
  import { buildVoiceActionFlags } from './voice/voice-action-flags.js';
22
- import { loadVoiceIdentity, buildVoicePrompt, buildVoiceFollowUpPrompt, buildVoicePromptSectionEstimates } from './voice/voice-prompt-builder.js';
22
+ import { loadVoiceIdentity, buildVoicePrompt, buildVoiceFollowUpPrompt, buildVoicePromptSectionEstimates, buildVoiceSystemInstruction, } from './voice/voice-prompt-builder.js';
23
23
  import { sanitizeForVoice, sanitizeVoiceReplyForSpeech } from './voice/voice-sanitize.js';
24
24
  import { shouldTriggerFollowUp } from './discord/action-categories.js';
25
25
  import { configureDeferredScheduler } from './discord/deferred-runner.js';
@@ -1105,9 +1105,6 @@ if (currentOverridesState.fastRuntime) {
1105
1105
  log.warn({ fastRuntime: currentOverridesState.fastRuntime, availableRuntimes: runtimeRegistry.list() }, 'runtime-overrides: fastRuntime is not a registered runtime; ignoring');
1106
1106
  }
1107
1107
  }
1108
- if (currentOverridesState.ttsVoice) {
1109
- log.info({ ttsVoice: currentOverridesState.ttsVoice }, 'runtime-overrides: ttsVoice override will be applied');
1110
- }
1111
1108
  // Track which roles have active file-backed overrides (used by !models show).
1112
1109
  // Only mark as override if the stored value differs from env defaults.
1113
1110
  const overrideSources = detectOverrideSources(currentModelConfig, envModelDefaults);
@@ -1261,8 +1258,6 @@ const botParams = {
1261
1258
  spawnCtx: undefined,
1262
1259
  voiceCtx: undefined,
1263
1260
  voiceStatusCtx: undefined,
1264
- setTtsVoice: undefined,
1265
- getTtsVoice: undefined,
1266
1261
  configCtx: undefined,
1267
1262
  deferOpts: undefined,
1268
1263
  messageHistoryBudget,
@@ -1325,13 +1320,8 @@ const botParams = {
1325
1320
  voiceEnabled: cfg.voiceEnabled,
1326
1321
  voiceAutoJoin: cfg.voiceAutoJoin,
1327
1322
  voiceModelCtx: voiceModelRef,
1328
- voiceSttProvider: cfg.voiceSttProvider,
1329
- voiceTtsProvider: cfg.voiceTtsProvider,
1330
1323
  voiceHomeChannel: cfg.voiceHomeChannel,
1331
- deepgramApiKey: cfg.deepgramApiKey,
1332
- deepgramSttModel: cfg.deepgramSttModel,
1333
- deepgramTtsVoice: cfg.deepgramTtsVoice,
1334
- cartesiaApiKey: cfg.cartesiaApiKey,
1324
+ geminiApiKey: cfg.geminiApiKey,
1335
1325
  botStatus: cfg.botStatus,
1336
1326
  botActivity: cfg.botActivity,
1337
1327
  botActivityType: cfg.botActivityType,
@@ -2077,30 +2067,35 @@ if (taskCtx) {
2077
2067
  }
2078
2068
  };
2079
2069
  }
2070
+ const buildGeminiSystemInstruction = async () => {
2071
+ const identity = await loadVoiceIdentity(workspaceCwd);
2072
+ return buildVoiceSystemInstruction({
2073
+ identity,
2074
+ durableMemory: '',
2075
+ voiceSystemPrompt: cfg.voiceSystemPrompt,
2076
+ actionsSection: '',
2077
+ });
2078
+ };
2080
2079
  audioPipeline = new AudioPipelineManager({
2081
2080
  log,
2082
2081
  voiceConfig: {
2083
2082
  enabled: cfg.voiceEnabled,
2084
- sttProvider: cfg.voiceSttProvider,
2085
- ttsProvider: cfg.voiceTtsProvider,
2086
2083
  homeChannel: cfg.voiceHomeChannel,
2087
- deepgramApiKey: cfg.deepgramApiKey,
2088
- deepgramSttModel: cfg.deepgramSttModel,
2089
- deepgramTtsVoice: overrides.ttsVoice ?? cfg.deepgramTtsVoice,
2090
- deepgramTtsSpeed: cfg.deepgramTtsSpeed,
2091
- cartesiaApiKey: cfg.cartesiaApiKey,
2092
- openaiApiKey: cfg.openaiApiKey,
2093
2084
  },
2094
2085
  allowedUserIds: allowUserIds,
2095
2086
  createDecoder: opusDecoderFactory,
2087
+ geminiApiKey: cfg.geminiApiKey,
2088
+ enabledTools: runtimeTools,
2096
2089
  invokeAi: voiceInvokeAi,
2097
2090
  runtime: voiceRuntimeRef.runtime.id,
2098
2091
  runtimeModel: voiceModelRef.model,
2099
2092
  runtimeCwd: workspaceCwd,
2100
2093
  runtimeTimeoutMs,
2094
+ sessionRotationMs: cfg.geminiSessionRotationMs,
2101
2095
  transcriptMirror,
2102
2096
  botDisplayName,
2103
2097
  backfill,
2098
+ buildGeminiSystemInstruction,
2104
2099
  onTranscription: (guildId, result) => {
2105
2100
  if (result.isFinal && result.text.trim()) {
2106
2101
  log.info({ guildId, text: result.text, confidence: result.confidence }, 'voice:transcription');
@@ -2120,14 +2115,6 @@ if (taskCtx) {
2120
2115
  },
2121
2116
  });
2122
2117
  botParams.voiceStatusCtx = { voiceManager };
2123
- botParams.setTtsVoice = async (voice) => {
2124
- const count = await audioPipeline.setTtsVoice(voice);
2125
- botParams.deepgramTtsVoice = voice;
2126
- currentOverridesState.ttsVoice = voice;
2127
- saveOverrides(overridesPath, currentOverridesState).catch((err) => log.warn({ err, voice }, 'runtime-overrides: ttsVoice save failed'));
2128
- return count;
2129
- };
2130
- botParams.getTtsVoice = () => audioPipeline.ttsVoice;
2131
2118
  if (cfg.discordActionsVoice) {
2132
2119
  botParams.voiceCtx = { voiceManager };
2133
2120
  log.info('voice:action context initialized with audio pipeline');
@@ -88,6 +88,62 @@ export class TraceStore {
88
88
  .slice(0, limit)
89
89
  .map(cloneTrace);
90
90
  }
91
+ get size() {
92
+ return this.traces.size;
93
+ }
94
+ summary() {
95
+ const allTraces = [...this.traces.values()];
96
+ const flows = ['message', 'reaction', 'cron', 'defer'];
97
+ const byFlow = {};
98
+ for (const flow of flows) {
99
+ const matching = allTraces.filter((t) => t.flow === flow);
100
+ const completed = matching.filter((t) => t.outcome !== 'in_progress');
101
+ const succeeded = completed.filter((t) => t.outcome === 'success').length;
102
+ const totalDuration = completed.reduce((sum, t) => sum + t.durationMs, 0);
103
+ byFlow[flow] = {
104
+ total: matching.length,
105
+ succeeded,
106
+ failed: completed.length - succeeded,
107
+ inProgress: matching.length - completed.length,
108
+ avgDurationMs: completed.length > 0 ? Math.round(totalDuration / completed.length) : 0,
109
+ };
110
+ }
111
+ const byOutcome = {};
112
+ for (const trace of allTraces) {
113
+ byOutcome[trace.outcome] = (byOutcome[trace.outcome] ?? 0) + 1;
114
+ }
115
+ const MAX_RECENT_ERRORS = 10;
116
+ const recentErrors = [];
117
+ const sorted = [...allTraces].sort((a, b) => b.startedAt - a.startedAt);
118
+ for (const trace of sorted) {
119
+ if (recentErrors.length >= MAX_RECENT_ERRORS)
120
+ break;
121
+ if (trace.outcome === 'success' || trace.outcome === 'in_progress')
122
+ continue;
123
+ const lastError = [...trace.events].reverse().find((e) => e.type === 'error');
124
+ recentErrors.push({
125
+ traceId: trace.traceId,
126
+ flow: trace.flow,
127
+ message: lastError && 'message' in lastError ? lastError.message : trace.outcome,
128
+ at: trace.startedAt,
129
+ });
130
+ }
131
+ let oldestAt = null;
132
+ let newestAt = null;
133
+ if (allTraces.length > 0) {
134
+ oldestAt = Math.min(...allTraces.map((t) => t.startedAt));
135
+ newestAt = Math.max(...allTraces.map((t) => t.startedAt));
136
+ }
137
+ return {
138
+ total: allTraces.length,
139
+ inProgress: allTraces.filter((t) => t.outcome === 'in_progress').length,
140
+ oldestAt,
141
+ newestAt,
142
+ byFlow,
143
+ byOutcome,
144
+ recentErrors,
145
+ };
146
+ }
91
147
  makeRoomForNewTrace() {
92
148
  this.pruneToLimit(this.maxEntries - 1);
93
149
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Shared trace-event helpers for summarizing values before they enter trace events.
3
+ */
4
+ export function summarizeTraceText(value, maxChars = 160) {
5
+ const normalized = value.replace(/\s+/g, ' ').trim();
6
+ if (!normalized) {
7
+ return undefined;
8
+ }
9
+ if (normalized.length <= maxChars) {
10
+ return normalized;
11
+ }
12
+ return `${normalized.slice(0, Math.max(1, maxChars - 1))}…`;
13
+ }
14
+ export function summarizeTraceValue(value, maxChars = 160) {
15
+ if (value == null) {
16
+ return undefined;
17
+ }
18
+ if (typeof value === 'string') {
19
+ return summarizeTraceText(value, maxChars);
20
+ }
21
+ try {
22
+ const serialized = JSON.stringify(value);
23
+ if (serialized) {
24
+ return summarizeTraceText(serialized, maxChars);
25
+ }
26
+ }
27
+ catch {
28
+ // Fall through to String(value).
29
+ }
30
+ return summarizeTraceText(String(value), maxChars);
31
+ }
@@ -21,7 +21,8 @@ function mergeSystemPrompt(systemPrompt, appendSystemPrompt) {
21
21
  function normalizeInvokeParams(params, opts) {
22
22
  const requestedModel = params.model || opts.defaultModel;
23
23
  const remappedModel = remapCrossRuntimeTierModel(requestedModel, 'codex');
24
- const effectiveModel = remappedModel?.model ?? requestedModel;
24
+ const normalizedModel = remappedModel?.model ?? requestedModel;
25
+ const effectiveModel = normalizedModel || opts.defaultModel;
25
26
  const effectiveReasoningEffort = params.reasoningEffort
26
27
  ?? (remappedModel ? resolveReasoningEffort(remappedModel.sourceTier, 'codex') : undefined);
27
28
  if (remappedModel) {
@@ -34,7 +35,7 @@ function normalizeInvokeParams(params, opts) {
34
35
  }
35
36
  return {
36
37
  ...params,
37
- model: effectiveModel,
38
+ model: normalizedModel,
38
39
  ...(effectiveReasoningEffort ? { reasoningEffort: effectiveReasoningEffort } : {}),
39
40
  systemPrompt: mergeSystemPrompt(params.systemPrompt, opts.appendSystemPrompt),
40
41
  ...(opts.disableSessions ? { sessionKey: undefined } : {}),
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
4
  import { CODEX_RUNTIME_CAPABILITIES } from './tool-capabilities.js';
5
+ import { initTierOverrides } from './model-tiers.js';
5
6
  const { mockExeca } = vi.hoisted(() => {
6
7
  return {
7
8
  mockExeca: vi.fn(),
@@ -129,10 +130,12 @@ describe('Codex CLI runtime adapter', () => {
129
130
  const originalStableHome = process.env.DISCOCLAW_CODEX_STABLE_HOME;
130
131
  beforeEach(() => {
131
132
  mockExeca.mockReset();
133
+ initTierOverrides({});
132
134
  delete process.env.DISCOCLAW_CLI_LAUNCHER_STATE_HARDENING;
133
135
  delete process.env.DISCOCLAW_CODEX_STABLE_HOME;
134
136
  });
135
137
  afterEach(() => {
138
+ initTierOverrides({});
136
139
  if (originalHardening === undefined) {
137
140
  delete process.env.DISCOCLAW_CLI_LAUNCHER_STATE_HARDENING;
138
141
  }
@@ -294,6 +297,36 @@ describe('Codex CLI runtime adapter', () => {
294
297
  sourceTier: 'fast',
295
298
  }), 'codex:model remapped to codex-compatible tier default');
296
299
  });
300
+ it('falls back to the codex default model when the codex fast tier is configured as adapter-default sentinel', async () => {
301
+ initTierOverrides({ DISCOCLAW_TIER_CODEX_FAST: '' });
302
+ const log = {
303
+ debug: vi.fn(),
304
+ warn: vi.fn(),
305
+ };
306
+ mockExeca.mockReturnValue(createMockSubprocess({
307
+ stdout: 'cli remap ok',
308
+ exitCode: 0,
309
+ }));
310
+ const rt = createCodexCliRuntime({
311
+ codexBin: 'codex',
312
+ defaultModel: 'gpt-5.4',
313
+ log,
314
+ });
315
+ await collectEvents(rt.invoke({
316
+ prompt: 'Summarize',
317
+ model: 'gpt-5-mini',
318
+ cwd: '/tmp/non-default-cwd',
319
+ }));
320
+ const callArgs = mockExeca.mock.calls[0][1];
321
+ const modelIdx = callArgs.indexOf('-m');
322
+ expect(callArgs[modelIdx + 1]).toBe('gpt-5.4');
323
+ expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({
324
+ requestedModel: 'gpt-5-mini',
325
+ effectiveModel: 'gpt-5.4',
326
+ sourceRuntimeId: 'openai',
327
+ sourceTier: 'fast',
328
+ }), 'codex:model remapped to codex-compatible tier default');
329
+ });
297
330
  it('large prompt uses stdin instead of positional arg', async () => {
298
331
  const largePrompt = 'x'.repeat(200_000);
299
332
  mockExeca.mockReturnValue(createMockSubprocess({
@@ -168,7 +168,7 @@ export function remapCrossRuntimeTierModel(model, targetRuntimeId) {
168
168
  if (!sourceTier)
169
169
  return null;
170
170
  const targetModel = tierMap[targetRuntimeId]?.[sourceTier];
171
- if (!targetModel || normalizeModelLookup(targetModel) === normalized)
171
+ if (targetModel === undefined || normalizeModelLookup(targetModel) === normalized)
172
172
  return null;
173
173
  return {
174
174
  sourceRuntimeId,
@@ -238,6 +238,15 @@ describe('remapCrossRuntimeTierModel', () => {
238
238
  model: 'gpt-5.4',
239
239
  });
240
240
  });
241
+ it('preserves adapter-default sentinels when the target runtime tier is intentionally blank', () => {
242
+ initTierOverrides({ DISCOCLAW_TIER_CODEX_FAST: '' });
243
+ expect(remapCrossRuntimeTierModel('gpt-5-mini', 'codex')).toEqual({
244
+ sourceRuntimeId: 'openai',
245
+ sourceTier: 'fast',
246
+ targetRuntimeId: 'codex',
247
+ model: '',
248
+ });
249
+ });
241
250
  it('does not remap ambiguous cross-runtime models', () => {
242
251
  expect(remapCrossRuntimeTierModel('gpt-5.4', 'codex')).toBeNull();
243
252
  });
@@ -3,7 +3,11 @@
3
3
  *
4
4
  * Maps internal tool names (Read, Write, …) to OpenAI function names
5
5
  * (read_file, write_file, …) and provides JSON Schema parameter definitions.
6
+ *
7
+ * Also provides `buildGeminiToolDeclarations()` to produce Gemini-compatible
8
+ * function declarations for the Multimodal Live API (Phase 2.1).
6
9
  */
10
+ import { toGeminiTools } from '../voice/providers/gemini-tool-mapper.js';
7
11
  /** Discoclaw tool name → OpenAI function names */
8
12
  const DISCO_TO_OPENAI_NAMES = {
9
13
  Read: ['read_file'],
@@ -369,3 +373,16 @@ export function buildToolSchemas(enabledTools) {
369
373
  }
370
374
  return schemas;
371
375
  }
376
+ /**
377
+ * Build Gemini-compatible function declarations for the given enabled tools.
378
+ *
379
+ * Composes `buildToolSchemas()` → `toGeminiTools()` so callers (e.g.
380
+ * AudioPipelineManager) can get Gemini Live–ready declarations in one call.
381
+ * Returns `undefined` when no tools match (same semantics as `toGeminiTools`).
382
+ */
383
+ export function buildGeminiToolDeclarations(enabledTools, opts = {}) {
384
+ const schemas = buildToolSchemas(enabledTools);
385
+ return toGeminiTools(schemas, opts.nonBlocking
386
+ ? { nonBlockingFunctionNames: schemas.map((schema) => schema.function.name) }
387
+ : undefined);
388
+ }
@@ -2,7 +2,7 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { parseRuntimeNameForPlacement } from './runtime/runtime-path-contract.js';
4
4
  const OVERRIDES_FILENAME = 'runtime-overrides.json';
5
- const KNOWN_OVERRIDE_KEYS = ['ttsVoice', 'voiceRuntime', 'fastRuntime'];
5
+ const KNOWN_OVERRIDE_KEYS = ['voiceRuntime', 'fastRuntime'];
6
6
  export function normalizeRuntimeOverrides(overrides) {
7
7
  const next = { ...overrides };
8
8
  let changed = false;
@@ -57,8 +57,6 @@ export async function loadOverrides(filePath, onWarn) {
57
57
  }
58
58
  const obj = parsed;
59
59
  const overrides = {};
60
- if (typeof obj['ttsVoice'] === 'string')
61
- overrides.ttsVoice = obj['ttsVoice'];
62
60
  if (typeof obj['voiceRuntime'] === 'string')
63
61
  overrides.voiceRuntime = obj['voiceRuntime'];
64
62
  if (typeof obj['fastRuntime'] === 'string')
@@ -75,6 +73,7 @@ export async function saveOverrides(filePath, overrides) {
75
73
  const tmpPath = `${filePath}.tmp.${process.pid}`;
76
74
  try {
77
75
  const preserved = await readExistingRawObject(filePath);
76
+ delete preserved['ttsVoice'];
78
77
  for (const key of KNOWN_OVERRIDE_KEYS) {
79
78
  delete preserved[key];
80
79
  }