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
package/dist/dashboard/server.js
CHANGED
|
@@ -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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
507
|
-
|
|
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,
|
|
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,
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
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', {
|
|
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('
|
|
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('
|
|
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', {
|
|
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,
|
package/dist/discord/actions.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
547
|
-
*
|
|
548
|
-
*
|
|
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) =>
|
|
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
|