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
@@ -10,11 +10,15 @@ import { DASHBOARD_HOST, DEFAULT_DASHBOARD_PORT, formatDashboardUrl } from './op
10
10
  import { renderDashboardPage } from './page.js';
11
11
  import { buildSnapshotResponse } from './api/snapshot.js';
12
12
  import { buildSettingsGetResponse, buildSettingsPostResponse, } from './api/settings.js';
13
+ import { buildMetricsResponse } from './api/metrics.js';
14
+ import { buildTracesResponse } from './api/traces.js';
13
15
  import { hasErrorCode, mapListenError } from './server-errors.js';
14
16
  import { applyFixes, inspect, KNOWN_RUNTIMES, loadDoctorContext, updateEnvKey } from '../health/config-doctor.js';
15
17
  import { DEFAULTS as MODEL_DEFAULTS, saveModelConfig } from '../model-config.js';
16
18
  import { isModelTier } from '../runtime/model-tiers.js';
17
19
  import { saveOverrides } from '../runtime-overrides.js';
20
+ import { findRuntimeForModel } from '../runtime/model-tiers.js';
21
+ import { getRuntimePathDefinition } from '../runtime/runtime-path-contract.js';
18
22
  import { getPlatformCommands, getServiceLogs, getServiceStatus, normalizeServiceName, summarizeServiceStatus, } from '../service-control.js';
19
23
  const DASHBOARD_MODEL_ROLES = [
20
24
  'chat',
@@ -178,6 +182,9 @@ function normalizeRuntimeName(value, knownRuntimes) {
178
182
  const normalized = trimmed === 'claude_code' ? 'claude' : trimmed;
179
183
  return knownRuntimes.has(normalized) ? normalized : undefined;
180
184
  }
185
+ function presetToPrimaryRuntime(preset) {
186
+ return preset === 'codex' ? 'codex-cli' : 'claude-cli';
187
+ }
181
188
  async function loadServiceName(inspectOpts, deps) {
182
189
  const ctx = await deps.loadDoctorContext(inspectOpts);
183
190
  return normalizeServiceName(ctx.env.DISCOCLAW_SERVICE_NAME);
@@ -352,16 +359,14 @@ async function applyPreset(preset, inspectOpts, deps) {
352
359
  throw new Error(`Unknown preset: ${preset}. Allowed values: ${[...ALLOWED_PRESETS].join(', ')}`);
353
360
  }
354
361
  const ctx = await deps.loadDoctorContext(inspectOpts);
355
- await deps.updateEnvKey(ctx.configPaths.env, 'PRIMARY_RUNTIME', preset);
356
- const preservedOverrides = {};
357
- if (ctx.runtimeOverrides.ttsVoice) {
358
- preservedOverrides.ttsVoice = ctx.runtimeOverrides.ttsVoice;
359
- }
360
- await deps.saveOverrides(ctx.configPaths.runtimeOverrides, preservedOverrides);
362
+ const primaryRuntime = presetToPrimaryRuntime(preset);
363
+ await deps.updateEnvKey(ctx.configPaths.env, 'PRIMARY_RUNTIME', primaryRuntime);
364
+ await deps.saveOverrides(ctx.configPaths.runtimeOverrides, {});
361
365
  await deps.saveModelConfig(ctx.configPaths.models, { ...MODEL_DEFAULTS });
362
366
  return {
363
367
  message: `Preset switched to ${preset}. Models reset to tier defaults. Restart the service to apply.`,
364
- snapshot: await collectDashboardSnapshot(inspectOpts, deps),
368
+ snapshot: await collectDashboardSnapshot({ cwd: inspectOpts.cwd, env: { ...inspectOpts.env, PRIMARY_RUNTIME: primaryRuntime } }, deps),
369
+ primaryRuntime,
365
370
  };
366
371
  }
367
372
  async function buildPresetResponse(input, inspectOpts, deps) {
@@ -373,6 +378,27 @@ async function buildPresetResponse(input, inspectOpts, deps) {
373
378
  ...await applyPreset(preset, inspectOpts, deps),
374
379
  };
375
380
  }
381
+ async function persistChatRuntimeSelection(runtimeName, inspectOpts, deps) {
382
+ const ctx = await deps.loadDoctorContext(inspectOpts);
383
+ await deps.updateEnvKey(ctx.configPaths.env, 'PRIMARY_RUNTIME', runtimeName);
384
+ let nextModels = ctx.models;
385
+ let clearedCrossRuntimeOverride = false;
386
+ const targetRuntimeId = getRuntimePathDefinition(runtimeName)?.runtimeId;
387
+ const savedChatModel = ctx.models['chat'];
388
+ const savedChatModelRuntimeId = savedChatModel ? findRuntimeForModel(savedChatModel) : undefined;
389
+ if (savedChatModel && targetRuntimeId && savedChatModelRuntimeId && savedChatModelRuntimeId !== targetRuntimeId) {
390
+ nextModels = updateModelConfig(nextModels, 'chat', null);
391
+ await deps.saveModelConfig(ctx.configPaths.models, nextModels);
392
+ clearedCrossRuntimeOverride = true;
393
+ }
394
+ return {
395
+ message: clearedCrossRuntimeOverride
396
+ ? `Saved startup chat runtime: ${runtimeName}. Cleared the saved chat model override because it targeted a different provider. Restart the service to apply.`
397
+ : `Saved startup chat runtime: ${runtimeName}. Restart the service to apply.`,
398
+ snapshot: await collectDashboardSnapshot({ cwd: inspectOpts.cwd, env: { ...inspectOpts.env, PRIMARY_RUNTIME: runtimeName } }, deps),
399
+ primaryRuntime: runtimeName,
400
+ };
401
+ }
376
402
  function isDashboardBadRequest(message) {
377
403
  return (message === 'Request body too large'
378
404
  || message === 'JSON body must be an object'
@@ -415,6 +441,11 @@ export async function startDashboardServer(opts = {}) {
415
441
  const liveAuthCheckHandler = opts.liveAuthCheckHandler;
416
442
  // Mutable flag — set when a persisted config change requires a restart to take effect.
417
443
  let pendingRestart = false;
444
+ const previewEnvOverrides = {};
445
+ const configInspectOpts = () => ({
446
+ cwd: inspectOpts.cwd,
447
+ env: { ...inspectOpts.env, ...previewEnvOverrides },
448
+ });
418
449
  const server = http.createServer(async (req, res) => {
419
450
  const method = req.method ?? 'GET';
420
451
  const requestHostname = parseHostHeaderHostname(req.headers.host);
@@ -422,14 +453,15 @@ export async function startDashboardServer(opts = {}) {
422
453
  respondJson(res, 403, { ok: false, message: DNS_REBIND_ERROR });
423
454
  return;
424
455
  }
425
- const pathname = new URL(req.url ?? '/', `http://${DASHBOARD_HOST}`).pathname;
456
+ const parsedUrl = new URL(req.url ?? '/', `http://${DASHBOARD_HOST}`);
457
+ const pathname = parsedUrl.pathname;
426
458
  try {
427
459
  if (method === 'GET' && pathname === '/') {
428
460
  respondHtml(res, 200, html);
429
461
  return;
430
462
  }
431
463
  if (method === 'GET' && pathname === '/api/snapshot') {
432
- respondJson(res, 200, withLiveSnapshot(withStartupMcpSnapshot(await buildSnapshotResponse(inspectOpts, deps), opts.startupMcpStatus, opts.startupMcpWarnings), opts.liveSnapshotProvider, pendingRestart));
464
+ respondJson(res, 200, withLiveSnapshot(withStartupMcpSnapshot(await buildSnapshotResponse(configInspectOpts(), deps), opts.startupMcpStatus, opts.startupMcpWarnings), opts.liveSnapshotProvider, pendingRestart));
433
465
  return;
434
466
  }
435
467
  if (method === 'GET' && pathname === '/api/status') {
@@ -463,7 +495,7 @@ export async function startDashboardServer(opts = {}) {
463
495
  return;
464
496
  }
465
497
  if (method === 'GET' && pathname === '/api/doctor') {
466
- respondJson(res, 200, await buildDoctorResponse(inspectOpts, deps));
498
+ respondJson(res, 200, await buildDoctorResponse(configInspectOpts(), deps));
467
499
  return;
468
500
  }
469
501
  if (pathname === '/api/doctor/fix') {
@@ -475,7 +507,7 @@ export async function startDashboardServer(opts = {}) {
475
507
  respondJson(res, 403, { ok: false, message: CROSS_ORIGIN_MUTATION_ERROR });
476
508
  return;
477
509
  }
478
- respondJson(res, 200, withLiveSnapshot(withStartupMcpSnapshot(await buildDoctorFixResponse(inspectOpts, deps), opts.startupMcpStatus, opts.startupMcpWarnings), opts.liveSnapshotProvider, pendingRestart));
510
+ respondJson(res, 200, withLiveSnapshot(withStartupMcpSnapshot(await buildDoctorFixResponse(configInspectOpts(), deps), opts.startupMcpStatus, opts.startupMcpWarnings), opts.liveSnapshotProvider, pendingRestart));
479
511
  return;
480
512
  }
481
513
  if (pathname === '/api/live-model') {
@@ -494,6 +526,7 @@ export async function startDashboardServer(opts = {}) {
494
526
  const body = await readJsonBody(req);
495
527
  const role = typeof body.role === 'string' ? body.role.trim() : '';
496
528
  const model = typeof body.model === 'string' ? body.model.trim() : '';
529
+ const persist = body.persist === true;
497
530
  if (!role)
498
531
  throw new Error('Model role is required.');
499
532
  if (!model)
@@ -503,8 +536,24 @@ export async function startDashboardServer(opts = {}) {
503
536
  respondJson(res, 400, { ok: false, message: result.error });
504
537
  return;
505
538
  }
506
- const snapshot = await collectDashboardSnapshot(inspectOpts, deps);
507
- respondJson(res, 200, withLiveSnapshot(withStartupMcpSnapshot({ ok: true, message: result.summary, snapshot }, opts.startupMcpStatus, opts.startupMcpWarnings), opts.liveSnapshotProvider, pendingRestart));
539
+ let message = result.summary;
540
+ let snapshot = await collectDashboardSnapshot(configInspectOpts(), deps);
541
+ if (persist && role === 'chat') {
542
+ const runtimeInput = normalizeRuntimeName(model, KNOWN_RUNTIMES);
543
+ if (runtimeInput) {
544
+ const persisted = await persistChatRuntimeSelection(runtimeInput, configInspectOpts(), deps);
545
+ previewEnvOverrides['PRIMARY_RUNTIME'] = persisted.primaryRuntime;
546
+ message = `${message} ${persisted.message}`;
547
+ snapshot = persisted.snapshot;
548
+ }
549
+ else {
550
+ const persisted = await applyModelChange({ role, model }, configInspectOpts(), deps, KNOWN_RUNTIMES);
551
+ message = `${message} ${persisted.message}`;
552
+ snapshot = persisted.snapshot;
553
+ }
554
+ pendingRestart = true;
555
+ }
556
+ respondJson(res, 200, withLiveSnapshot(withStartupMcpSnapshot({ ok: true, message, snapshot }, opts.startupMcpStatus, opts.startupMcpWarnings), opts.liveSnapshotProvider, pendingRestart));
508
557
  return;
509
558
  }
510
559
  if (pathname === '/api/model') {
@@ -517,7 +566,7 @@ export async function startDashboardServer(opts = {}) {
517
566
  return;
518
567
  }
519
568
  const body = await readJsonBody(req);
520
- const modelResponse = await buildModelResponse(body, inspectOpts, deps, KNOWN_RUNTIMES);
569
+ const modelResponse = await buildModelResponse(body, configInspectOpts(), deps, KNOWN_RUNTIMES);
521
570
  pendingRestart = true;
522
571
  respondJson(res, 200, withLiveSnapshot(withStartupMcpSnapshot(modelResponse, opts.startupMcpStatus, opts.startupMcpWarnings), opts.liveSnapshotProvider, pendingRestart));
523
572
  return;
@@ -532,7 +581,11 @@ export async function startDashboardServer(opts = {}) {
532
581
  return;
533
582
  }
534
583
  const body = await readJsonBody(req);
535
- const presetResponse = await buildPresetResponse(body, inspectOpts, deps);
584
+ const presetResponse = await buildPresetResponse(body, configInspectOpts(), deps);
585
+ const preset = typeof body.preset === 'string' ? body.preset.trim().toLowerCase() : '';
586
+ if (preset) {
587
+ previewEnvOverrides['PRIMARY_RUNTIME'] = presetToPrimaryRuntime(preset);
588
+ }
536
589
  pendingRestart = true;
537
590
  respondJson(res, 200, withLiveSnapshot(withStartupMcpSnapshot(presetResponse, opts.startupMcpStatus, opts.startupMcpWarnings), opts.liveSnapshotProvider, pendingRestart));
538
591
  return;
@@ -555,10 +608,11 @@ export async function startDashboardServer(opts = {}) {
555
608
  throw new Error(`Unknown secret key: ${key}`);
556
609
  if (!value)
557
610
  throw new Error('Secret value is required.');
558
- const ctx = await deps.loadDoctorContext(inspectOpts);
611
+ const ctx = await deps.loadDoctorContext(configInspectOpts());
559
612
  await deps.updateEnvKey(ctx.configPaths.env, key, value);
613
+ previewEnvOverrides[key] = value;
560
614
  pendingRestart = true;
561
- const snapshot = await collectDashboardSnapshot(inspectOpts, deps);
615
+ const snapshot = await collectDashboardSnapshot(configInspectOpts(), deps);
562
616
  respondJson(res, 200, withLiveSnapshot(withStartupMcpSnapshot({ ok: true, message: `Updated ${key}. Restart the service to apply.`, snapshot }, opts.startupMcpStatus, opts.startupMcpWarnings), opts.liveSnapshotProvider, pendingRestart));
563
617
  return;
564
618
  }
@@ -600,7 +654,7 @@ export async function startDashboardServer(opts = {}) {
600
654
  return;
601
655
  }
602
656
  if (method === 'GET' && pathname === '/api/settings') {
603
- respondJson(res, 200, buildSettingsGetResponse(inspectOpts.env));
657
+ respondJson(res, 200, buildSettingsGetResponse(configInspectOpts().env));
604
658
  return;
605
659
  }
606
660
  if (pathname === '/api/settings') {
@@ -615,11 +669,20 @@ export async function startDashboardServer(opts = {}) {
615
669
  const body = await readJsonBody(req);
616
670
  const key = typeof body.key === 'string' ? body.key.trim() : '';
617
671
  const value = typeof body.value === 'string' ? body.value.trim() : '';
618
- const response = await buildSettingsPostResponse(inspectOpts, deps, key, value);
672
+ const response = await buildSettingsPostResponse(configInspectOpts(), deps, key, value);
673
+ previewEnvOverrides[key] = value;
619
674
  pendingRestart = true;
620
675
  respondJson(res, 200, response);
621
676
  return;
622
677
  }
678
+ if (method === 'GET' && pathname === '/api/traces') {
679
+ respondJson(res, 200, buildTracesResponse(parsedUrl.searchParams.get('limit')));
680
+ return;
681
+ }
682
+ if (method === 'GET' && pathname === '/api/metrics') {
683
+ respondJson(res, 200, buildMetricsResponse());
684
+ return;
685
+ }
623
686
  respondJson(res, 404, { ok: false, message: 'Not found' });
624
687
  }
625
688
  catch (err) {
@@ -762,7 +762,6 @@ describe('startDashboardServer', () => {
762
762
  const ctx = makeDoctorContext({
763
763
  runtimeOverrides: {
764
764
  fastRuntime: 'openrouter',
765
- ttsVoice: 'alloy',
766
765
  },
767
766
  runtimeOverridesFile: {
768
767
  exists: true,
@@ -770,15 +769,22 @@ describe('startDashboardServer', () => {
770
769
  raw: {},
771
770
  values: {
772
771
  fastRuntime: 'openrouter',
773
- ttsVoice: 'alloy',
774
772
  },
775
773
  },
776
774
  });
777
775
  const updateEnvKeyMock = vi.fn(async () => undefined);
778
776
  const saveModelConfigMock = vi.fn(async () => undefined);
779
777
  const saveOverridesMock = vi.fn(async () => undefined);
778
+ const loadDoctorContextMock = vi.fn(async (opts) => makeDoctorContext({
779
+ runtimeOverrides: ctx.runtimeOverrides,
780
+ runtimeOverridesFile: ctx.runtimeOverridesFile,
781
+ env: {
782
+ ...ctx.env,
783
+ ...(opts?.env ?? {}),
784
+ },
785
+ }));
780
786
  const { port } = await startServer({
781
- loadDoctorContext: vi.fn(async () => ctx),
787
+ loadDoctorContext: loadDoctorContextMock,
782
788
  updateEnvKey: updateEnvKeyMock,
783
789
  saveModelConfig: saveModelConfigMock,
784
790
  saveOverrides: saveOverridesMock,
@@ -793,11 +799,11 @@ describe('startDashboardServer', () => {
793
799
  expect(body.ok).toBe(true);
794
800
  expect(body.message).toContain('codex');
795
801
  expect(body.message).toContain('tier defaults');
796
- expect(updateEnvKeyMock).toHaveBeenCalledWith('/repo/.env', 'PRIMARY_RUNTIME', 'codex');
797
- expect(saveOverridesMock).toHaveBeenCalledWith('/repo/data/runtime-overrides.json', { ttsVoice: 'alloy' });
802
+ expect(updateEnvKeyMock).toHaveBeenCalledWith('/repo/.env', 'PRIMARY_RUNTIME', 'codex-cli');
803
+ expect(saveOverridesMock).toHaveBeenCalledWith('/repo/data/runtime-overrides.json', {});
798
804
  expect(saveModelConfigMock).toHaveBeenCalledWith('/repo/data/models.json', expect.objectContaining({}));
799
805
  expect(body.snapshot).toBeDefined();
800
- expect(body.snapshot.primaryRuntime).toBe('claude');
806
+ expect(body.snapshot.primaryRuntime).toBe('codex');
801
807
  });
802
808
  it('applies claude preset correctly on /api/preset', async () => {
803
809
  const ctx = makeDoctorContext();
@@ -819,14 +825,13 @@ describe('startDashboardServer', () => {
819
825
  expect(response.status).toBe(200);
820
826
  expect(body.ok).toBe(true);
821
827
  expect(body.message).toContain('claude');
822
- expect(updateEnvKeyMock).toHaveBeenCalledWith('/repo/.env', 'PRIMARY_RUNTIME', 'claude');
828
+ expect(updateEnvKeyMock).toHaveBeenCalledWith('/repo/.env', 'PRIMARY_RUNTIME', 'claude-cli');
823
829
  });
824
- it('preserves ttsVoice when clearing overrides via /api/preset', async () => {
830
+ it('clears legacy voice overrides when applying /api/preset', async () => {
825
831
  const ctx = makeDoctorContext({
826
832
  runtimeOverrides: {
827
833
  fastRuntime: 'openrouter',
828
834
  voiceRuntime: 'anthropic',
829
- ttsVoice: 'shimmer',
830
835
  },
831
836
  });
832
837
  const saveOverridesMock = vi.fn(async () => undefined);
@@ -840,7 +845,7 @@ describe('startDashboardServer', () => {
840
845
  method: 'POST',
841
846
  body: JSON.stringify({ preset: 'codex' }),
842
847
  });
843
- expect(saveOverridesMock).toHaveBeenCalledWith('/repo/data/runtime-overrides.json', { ttsVoice: 'shimmer' });
848
+ expect(saveOverridesMock).toHaveBeenCalledWith('/repo/data/runtime-overrides.json', {});
844
849
  });
845
850
  it('rejects GET requests on /api/model', async () => {
846
851
  const { port } = await startServer();
@@ -1125,6 +1130,114 @@ describe('startDashboardServer', () => {
1125
1130
  expect(body.snapshot).toBeDefined();
1126
1131
  expect(liveModelHandler).toHaveBeenCalledWith('chat', 'opus');
1127
1132
  });
1133
+ it('persists chat runtime changes requested from /api/live-model', async () => {
1134
+ const ctx = makeDoctorContext({
1135
+ models: {
1136
+ chat: 'claude-opus-4-6',
1137
+ },
1138
+ modelsFile: {
1139
+ exists: true,
1140
+ values: {
1141
+ chat: 'claude-opus-4-6',
1142
+ },
1143
+ },
1144
+ });
1145
+ const liveModelHandler = vi.fn(() => ({
1146
+ ok: true,
1147
+ summary: 'Model updated: runtime → codex-cli, chat → gpt-5.4 (adapter default)',
1148
+ }));
1149
+ const updateEnvKeyMock = vi.fn(async () => undefined);
1150
+ const saveModelConfigMock = vi.fn(async () => undefined);
1151
+ const loadDoctorContextMock = vi.fn(async (opts) => makeDoctorContext({
1152
+ models: ctx.models,
1153
+ modelsFile: ctx.modelsFile,
1154
+ env: {
1155
+ ...ctx.env,
1156
+ ...(opts?.env ?? {}),
1157
+ },
1158
+ }));
1159
+ const { port } = await startServer({
1160
+ loadDoctorContext: loadDoctorContextMock,
1161
+ updateEnvKey: updateEnvKeyMock,
1162
+ saveModelConfig: saveModelConfigMock,
1163
+ }, {
1164
+ liveModelHandler,
1165
+ liveSnapshotProvider: () => ({
1166
+ chatRuntime: 'codex-cli',
1167
+ chatModel: 'gpt-5.4',
1168
+ chatThinking: 'high',
1169
+ availableRuntimes: ['claude-cli', 'codex-cli'],
1170
+ pendingRestart: false,
1171
+ imagegenProvider: undefined,
1172
+ imagegenModel: undefined,
1173
+ imagegenOptions: [],
1174
+ imagegenHasGeminiKey: false,
1175
+ imagegenHasOpenaiKey: false,
1176
+ }),
1177
+ });
1178
+ const response = await makeRequest(port, {
1179
+ path: '/api/live-model',
1180
+ method: 'POST',
1181
+ body: JSON.stringify({ role: 'chat', model: 'codex-cli', persist: true }),
1182
+ });
1183
+ const body = parseJson(response.text);
1184
+ expect(response.status).toBe(200);
1185
+ expect(body.ok).toBe(true);
1186
+ expect(body.message).toContain('runtime → codex-cli');
1187
+ expect(body.message).toContain('Saved startup chat runtime: codex-cli.');
1188
+ expect(body.snapshot.primaryRuntime).toBe('codex');
1189
+ expect(body.snapshot.live?.pendingRestart).toBe(true);
1190
+ expect(updateEnvKeyMock).toHaveBeenCalledWith('/repo/.env', 'PRIMARY_RUNTIME', 'codex-cli');
1191
+ expect(saveModelConfigMock).toHaveBeenCalledWith('/repo/data/models.json', {});
1192
+ });
1193
+ it('persists chat model changes requested from /api/live-model', async () => {
1194
+ const ctx = makeDoctorContext({
1195
+ env: {
1196
+ DISCOCLAW_SERVICE_NAME: 'discoclaw-beta',
1197
+ PRIMARY_RUNTIME: 'codex-cli',
1198
+ },
1199
+ models: {},
1200
+ modelsFile: {
1201
+ exists: true,
1202
+ values: {},
1203
+ },
1204
+ });
1205
+ const liveModelHandler = vi.fn(() => ({
1206
+ ok: true,
1207
+ summary: 'Model updated: chat → gpt-5.4',
1208
+ }));
1209
+ const saveModelConfigMock = vi.fn(async () => undefined);
1210
+ const { port } = await startServer({
1211
+ loadDoctorContext: vi.fn(async () => ctx),
1212
+ saveModelConfig: saveModelConfigMock,
1213
+ }, {
1214
+ liveModelHandler,
1215
+ liveSnapshotProvider: () => ({
1216
+ chatRuntime: 'codex-cli',
1217
+ chatModel: 'gpt-5.4',
1218
+ chatThinking: 'high',
1219
+ availableRuntimes: ['claude-cli', 'codex-cli'],
1220
+ pendingRestart: false,
1221
+ imagegenProvider: undefined,
1222
+ imagegenModel: undefined,
1223
+ imagegenOptions: [],
1224
+ imagegenHasGeminiKey: false,
1225
+ imagegenHasOpenaiKey: false,
1226
+ }),
1227
+ });
1228
+ const response = await makeRequest(port, {
1229
+ path: '/api/live-model',
1230
+ method: 'POST',
1231
+ body: JSON.stringify({ role: 'chat', model: 'gpt-5.4', persist: true }),
1232
+ });
1233
+ const body = parseJson(response.text);
1234
+ expect(response.status).toBe(200);
1235
+ expect(body.ok).toBe(true);
1236
+ expect(body.message).toContain('Model updated: chat → gpt-5.4');
1237
+ expect(body.message).toContain('Saved chat override: gpt-5.4. Changes take effect on next service restart.');
1238
+ expect(body.snapshot.live?.pendingRestart).toBe(true);
1239
+ expect(saveModelConfigMock).toHaveBeenCalledWith('/repo/data/models.json', { chat: 'gpt-5.4' });
1240
+ });
1128
1241
  it('returns 400 when live model handler rejects the change', async () => {
1129
1242
  const liveModelHandler = vi.fn(() => ({
1130
1243
  ok: false,
@@ -534,21 +534,127 @@ export function buildAllResultLines(results) {
534
534
  return results.map((r) => r.ok ? `Done: ${r.summary}` : `Failed: ${r.error}`);
535
535
  }
536
536
  /**
537
- * Cap a single result line to approximately `maxChars` characters.
537
+ * Cap a single result line to `maxChars` characters.
538
538
  * If truncated, appends a visible `...[truncated]` suffix.
539
539
  */
540
540
  export function capResultLine(line, maxChars = 1500) {
541
541
  if (line.length <= maxChars)
542
542
  return line;
543
- return `${line.slice(0, maxChars)}...[truncated]`;
543
+ const suffix = '...[truncated]';
544
+ if (maxChars <= suffix.length)
545
+ return suffix.slice(0, maxChars);
546
+ return `${line.slice(0, maxChars - suffix.length)}${suffix}`;
547
+ }
548
+ const RESULT_LINE_PREFIX_RE = /^(Done|Failed):\s*/;
549
+ const RESULT_LINE_IMPORTANT_FIELD_RE = /^(Status|Thread|Model|Next run|Last error|State):\s/i;
550
+ const RESULT_LINE_GENERIC_FIELD_RE = /^[A-Z][A-Za-z0-9 /_-]{1,24}:\s/;
551
+ const RESULT_LINE_SECTION_HEADER_RE = /^\*\*[^*\n]+:\*\*$/;
552
+ const RESULT_LINE_ERROR_RE = /\b(error|failed|failure|missing|invalid|denied|not found|cannot|unable|exception|timeout|timed out)\b/i;
553
+ const RESULT_LINE_PATH_RE = /(?:^|[\s(])(?:\/[^\s)`]+|\.{1,2}\/[^\s)`]+|[A-Za-z]:\\\S+)/;
554
+ const RESULT_LINE_ID_RE = /\b(?:id[:=][^\s,)]+|[a-z]+-\d+\b|\d{8,})/i;
555
+ const RESULT_LINE_NEXT_ACTION_RE = /\b(?:retry|rerun|re-run|resume|check|open|use)\b/i;
556
+ const RESULT_LINE_MICROCOMPACT_TRIGGER_LINES = 6;
557
+ const RESULT_LINE_MICROCOMPACT_TRIGGER_CHARS = 500;
558
+ const RESULT_LINE_MAX_RETAINED_LINES = 8;
559
+ const RESULT_LINE_ID_REPRESENTATIVE_COUNT = 4;
560
+ const RESULT_LINE_REMAINDER_REPRESENTATIVE_COUNT = 2;
561
+ function splitResultLine(line) {
562
+ const match = RESULT_LINE_PREFIX_RE.exec(line);
563
+ if (!match)
564
+ return { prefix: '', body: line };
565
+ return { prefix: match[0], body: line.slice(match[0].length) };
566
+ }
567
+ function parseResultLineBody(body) {
568
+ return body
569
+ .split(/\r?\n/)
570
+ .map((line) => line.trimEnd())
571
+ .filter((line) => line.trim().length > 0)
572
+ .map((text, index) => ({
573
+ index,
574
+ text,
575
+ isImportantField: RESULT_LINE_IMPORTANT_FIELD_RE.test(text),
576
+ isGenericField: RESULT_LINE_GENERIC_FIELD_RE.test(text),
577
+ isSectionHeader: RESULT_LINE_SECTION_HEADER_RE.test(text),
578
+ hasErrorText: RESULT_LINE_ERROR_RE.test(text),
579
+ hasPath: RESULT_LINE_PATH_RE.test(text),
580
+ hasId: RESULT_LINE_ID_RE.test(text),
581
+ hasNextAction: RESULT_LINE_NEXT_ACTION_RE.test(text),
582
+ }));
583
+ }
584
+ function takeRepresentativeIndexes(indexes, count) {
585
+ if (indexes.length <= count)
586
+ return indexes;
587
+ const headCount = Math.ceil(count / 2);
588
+ const tailCount = Math.floor(count / 2);
589
+ return [...indexes.slice(0, headCount), ...indexes.slice(-tailCount)];
590
+ }
591
+ function appendUniqueIndexes(target, indexes, maxCount) {
592
+ for (const index of indexes) {
593
+ if (target.includes(index))
594
+ continue;
595
+ target.push(index);
596
+ if (target.length >= maxCount)
597
+ return;
598
+ }
599
+ }
600
+ function collectSectionValueIndexes(lines) {
601
+ const indexes = [];
602
+ for (let i = 0; i < lines.length - 1; i += 1) {
603
+ if (!lines[i]?.isSectionHeader)
604
+ continue;
605
+ indexes.push(lines[i + 1].index);
606
+ }
607
+ return indexes;
608
+ }
609
+ function buildResultLineOmissionMarker(omittedCount) {
610
+ return `...[omitted ${omittedCount} line${omittedCount === 1 ? '' : 's'}]`;
611
+ }
612
+ function selectInformativeResultBodyIndexes(lines) {
613
+ if (lines.length <= RESULT_LINE_MAX_RETAINED_LINES)
614
+ return lines.map((line) => line.index);
615
+ const maxCount = Math.min(RESULT_LINE_MAX_RETAINED_LINES, lines.length);
616
+ const selected = [];
617
+ appendUniqueIndexes(selected, [0], maxCount);
618
+ appendUniqueIndexes(selected, lines.filter((line) => line.isImportantField).map((line) => line.index), maxCount);
619
+ appendUniqueIndexes(selected, lines
620
+ .filter((line) => line.hasErrorText || line.hasPath || line.hasNextAction)
621
+ .map((line) => line.index), maxCount);
622
+ appendUniqueIndexes(selected, lines.filter((line) => line.isSectionHeader).map((line) => line.index), maxCount);
623
+ appendUniqueIndexes(selected, collectSectionValueIndexes(lines), maxCount);
624
+ appendUniqueIndexes(selected, takeRepresentativeIndexes(lines.filter((line) => line.hasId).map((line) => line.index), RESULT_LINE_ID_REPRESENTATIVE_COUNT), maxCount);
625
+ appendUniqueIndexes(selected, [lines.length - 1], maxCount);
626
+ appendUniqueIndexes(selected, takeRepresentativeIndexes(lines
627
+ .filter((line) => !line.isGenericField && !line.isSectionHeader && !line.hasId)
628
+ .map((line) => line.index), RESULT_LINE_REMAINDER_REPRESENTATIVE_COUNT), maxCount);
629
+ return selected.sort((a, b) => a - b);
630
+ }
631
+ function microcompactResultLine(line, maxChars) {
632
+ const { prefix, body } = splitResultLine(line);
633
+ const parsedLines = parseResultLineBody(body);
634
+ if (parsedLines.length <= 1)
635
+ return capResultLine(line, maxChars);
636
+ const shouldCompact = parsedLines.length > RESULT_LINE_MICROCOMPACT_TRIGGER_LINES
637
+ || body.length > Math.min(maxChars, RESULT_LINE_MICROCOMPACT_TRIGGER_CHARS);
638
+ if (!shouldCompact)
639
+ return capResultLine(line, maxChars);
640
+ const selectedIndexes = selectInformativeResultBodyIndexes(parsedLines);
641
+ if (selectedIndexes.length >= parsedLines.length)
642
+ return capResultLine(line, maxChars);
643
+ const selectedIndexSet = new Set(selectedIndexes);
644
+ const retainedLines = parsedLines
645
+ .filter((lineInfo) => selectedIndexSet.has(lineInfo.index))
646
+ .map((lineInfo) => lineInfo.text);
647
+ const omittedCount = parsedLines.length - retainedLines.length;
648
+ const compactedBody = `${retainedLines.join('\n')}\n${buildResultLineOmissionMarker(omittedCount)}`;
649
+ return capResultLine(`${prefix}${compactedBody}`, maxChars);
544
650
  }
545
651
  /**
546
- * Build result lines for follow-up prompts with per-line length capping.
547
- * Each line is capped at `maxChars` characters to prevent oversized payloads
548
- * from crowding out reasoning and action blocks in follow-up prompts.
652
+ * Build result lines for follow-up prompts with microcompaction before
653
+ * the final hard cap so oversized payloads preserve continuation-critical
654
+ * details without crowding out reasoning and action blocks.
549
655
  */
550
656
  export function buildCappedResultLines(results, maxChars = 1500) {
551
- return buildAllResultLines(results).map((line) => capResultLine(line, maxChars));
657
+ return buildAllResultLines(results).map((line) => microcompactResultLine(line, maxChars));
552
658
  }
553
659
  /**
554
660
  * Append display result lines to body text, automatically closing any