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 { execa } from 'execa';
2
3
  import { resolveDefaultModel as resolveImagegenDefaultModel } from '../discord/actions-imagegen.js';
3
4
  import { acquireCronLock, releaseCronLock } from './job-lock.js';
@@ -9,6 +10,7 @@ import { sendChunks, appendUnavailableActionTypesNotice, appendParseFailureNotic
9
10
  import { buildPromptPreamble, loadWorkspacePaFiles, inlineContextFiles, resolveEffectiveTools } from '../discord/prompt-common.js';
10
11
  import { ensureStatusMessage } from './discord-sync.js';
11
12
  import { globalMetrics } from '../observability/metrics.js';
13
+ import { globalTraceStore } from '../observability/trace-store.js';
12
14
  import { mapRuntimeErrorToUserMessage } from '../discord/user-errors.js';
13
15
  import { resolveModel } from '../runtime/model-tiers.js';
14
16
  import { cliExecaEnv, stripAnsi } from '../runtime/cli-shared.js';
@@ -223,6 +225,10 @@ export async function executeCronJob(job, ctx) {
223
225
  return;
224
226
  }
225
227
  }
228
+ const traceId = `cron_${randomUUID()}`;
229
+ const sessionKey = `cron:${job.cronId || job.id}`;
230
+ let traceOutcome = 'success';
231
+ globalTraceStore.startTrace(traceId, sessionKey, 'cron', undefined);
226
232
  job.running = true;
227
233
  activeCronRunKeys.add(runKey);
228
234
  ctx.runControl?.register(job.id, requestCancel);
@@ -244,6 +250,13 @@ export async function executeCronJob(job, ctx) {
244
250
  const guild = ctx.client.guilds.cache.get(job.guildId);
245
251
  if (!guild) {
246
252
  ctx.log?.error({ jobId: job.id, guildId: job.guildId }, 'cron:exec guild not found');
253
+ traceOutcome = 'error';
254
+ globalTraceStore.addEvent(traceId, {
255
+ type: 'error',
256
+ at: Date.now(),
257
+ message: `guild ${job.guildId} not found`,
258
+ stage: 'cron_setup',
259
+ });
247
260
  await ctx.status?.runtimeError({ sessionKey: `cron:${job.id}` }, `Cron "${job.name}": guild ${job.guildId} not found`);
248
261
  await recordError(ctx, job, `guild ${job.guildId} not found`);
249
262
  return;
@@ -251,6 +264,13 @@ export async function executeCronJob(job, ctx) {
251
264
  const targetChannel = resolveChannel(guild, job.def.channel);
252
265
  if (!targetChannel) {
253
266
  ctx.log?.error({ jobId: job.id, channel: job.def.channel }, 'cron:exec target channel not found');
267
+ traceOutcome = 'error';
268
+ globalTraceStore.addEvent(traceId, {
269
+ type: 'error',
270
+ at: Date.now(),
271
+ message: `target channel "${job.def.channel}" not found`,
272
+ stage: 'cron_setup',
273
+ });
254
274
  await ctx.status?.runtimeError({ sessionKey: `cron:${job.id}`, channelName: job.def.channel }, `Cron "${job.name}": target channel "${job.def.channel}" not found`);
255
275
  await recordError(ctx, job, `target channel "${job.def.channel}" not found`);
256
276
  return;
@@ -264,6 +284,13 @@ export async function executeCronJob(job, ctx) {
264
284
  (parentId && ctx.allowChannelIds.has(parentId));
265
285
  if (!allowed) {
266
286
  ctx.log?.error({ jobId: job.id, channel: job.def.channel }, 'cron:exec target channel not allowlisted');
287
+ traceOutcome = 'error';
288
+ globalTraceStore.addEvent(traceId, {
289
+ type: 'error',
290
+ at: Date.now(),
291
+ message: `target channel "${job.def.channel}" not allowlisted`,
292
+ stage: 'cron_setup',
293
+ });
267
294
  await ctx.status?.runtimeError({ sessionKey: `cron:${job.id}`, channelName: job.def.channel }, `Cron "${job.name}": target channel "${job.def.channel}" is not allowlisted`);
268
295
  await recordError(ctx, job, `target channel "${job.def.channel}" not allowlisted`);
269
296
  return;
@@ -367,6 +394,12 @@ export async function executeCronJob(job, ctx) {
367
394
  }
368
395
  }
369
396
  metrics.recordInvokeStart('cron');
397
+ globalTraceStore.addEvent(traceId, {
398
+ type: 'invoke_start',
399
+ at: Date.now(),
400
+ summary: `cron job "${job.name}"`,
401
+ promptPreview: prompt.slice(0, 220),
402
+ });
370
403
  ctx.log?.info({ flow: 'cron', jobId: job.id, cronId: job.cronId }, 'obs.invoke.start');
371
404
  let finalText = '';
372
405
  let deltaText = '';
@@ -401,6 +434,13 @@ export async function executeCronJob(job, ctx) {
401
434
  collectedImages.push(evt.image);
402
435
  }
403
436
  else if (evt.type === 'error') {
437
+ traceOutcome = 'error';
438
+ globalTraceStore.addEvent(traceId, {
439
+ type: 'error',
440
+ at: Date.now(),
441
+ message: evt.message,
442
+ stage: 'runtime',
443
+ });
404
444
  metrics.recordInvokeResult('cron', Date.now() - t0, false, evt.message);
405
445
  metrics.increment('cron.run.error');
406
446
  ctx.log?.error({ jobId: job.id, error: evt.message }, 'cron:exec runtime error');
@@ -425,11 +465,24 @@ export async function executeCronJob(job, ctx) {
425
465
  if (runtimeIterator?.return) {
426
466
  await runtimeIterator.return();
427
467
  }
468
+ traceOutcome = 'canceled';
469
+ globalTraceStore.addEvent(traceId, {
470
+ type: 'error',
471
+ at: Date.now(),
472
+ message: cancelReason,
473
+ stage: 'runtime',
474
+ });
428
475
  metrics.increment('cron.run.canceled');
429
476
  ctx.log?.warn({ jobId: job.id, cronId: job.cronId }, 'cron:exec canceled');
430
477
  await recordError(ctx, job, cancelReason);
431
478
  return;
432
479
  }
480
+ globalTraceStore.addEvent(traceId, {
481
+ type: 'invoke_end',
482
+ at: Date.now(),
483
+ ok: true,
484
+ summary: `completed in ${Date.now() - t0}ms`,
485
+ });
433
486
  metrics.recordInvokeResult('cron', Date.now() - t0, true);
434
487
  ctx.log?.info({ flow: 'cron', jobId: job.id, ms: Date.now() - t0, ok: true }, 'obs.invoke.end');
435
488
  let output = finalText || deltaText;
@@ -518,8 +571,16 @@ export async function executeCronJob(job, ctx) {
518
571
  imagegenCtx: ctx.imagegenCtx,
519
572
  voiceCtx: ctx.voiceCtx,
520
573
  });
521
- for (const result of results) {
574
+ for (let i = 0; i < results.length; i++) {
575
+ const result = results[i];
522
576
  metrics.recordActionResult(result.ok);
577
+ globalTraceStore.addEvent(traceId, {
578
+ type: 'action_result',
579
+ at: Date.now(),
580
+ action: actions[i].type,
581
+ ok: result.ok,
582
+ detail: result.ok ? undefined : ('error' in result ? result.error : undefined),
583
+ });
523
584
  ctx.log?.info({ flow: 'cron', jobId: job.id, ok: result.ok }, 'obs.action.result');
524
585
  }
525
586
  const anyActionSucceeded = results.some((r) => r.ok);
@@ -604,6 +665,15 @@ export async function executeCronJob(job, ctx) {
604
665
  }
605
666
  catch (err) {
606
667
  const msg = err instanceof Error ? err.message : String(err);
668
+ traceOutcome = 'error';
669
+ globalTraceStore.addEvent(traceId, {
670
+ type: 'error',
671
+ at: Date.now(),
672
+ message: msg,
673
+ name: err instanceof Error ? err.name : undefined,
674
+ stage: 'cron_flow',
675
+ stack: err instanceof Error ? err.stack?.slice(0, 400) : undefined,
676
+ });
607
677
  metrics.increment('cron.run.error');
608
678
  ctx.log?.error({ err, jobId: job.id }, 'cron:exec failed');
609
679
  await ctx.status?.runtimeError({ sessionKey: `cron:${job.id}`, channelName: job.def.channel }, `Cron "${job.name}": ${msg}`);
@@ -623,6 +693,7 @@ export async function executeCronJob(job, ctx) {
623
693
  await recordError(ctx, job, msg);
624
694
  }
625
695
  finally {
696
+ globalTraceStore.endTrace(traceId, traceOutcome);
626
697
  const shouldRerun = queuedCronRerunKeys.delete(runKey);
627
698
  if (lockToken && ctx.lockDir && job.cronId) {
628
699
  await releaseCronLock(ctx.lockDir, job.cronId, lockToken).catch((err) => {
@@ -0,0 +1,7 @@
1
+ import { globalMetrics } from '../../observability/metrics.js';
2
+ export function buildMetricsResponse() {
3
+ return {
4
+ ok: true,
5
+ metrics: globalMetrics.snapshot(),
6
+ };
7
+ }
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildMetricsResponse } from './metrics.js';
3
+ describe('buildMetricsResponse', () => {
4
+ it('returns ok with a metrics snapshot', () => {
5
+ const response = buildMetricsResponse();
6
+ expect(response.ok).toBe(true);
7
+ expect(response.metrics).toBeDefined();
8
+ expect(typeof response.metrics.startedAt).toBe('number');
9
+ expect(response.metrics.counters).toBeDefined();
10
+ expect(response.metrics.latencies).toBeDefined();
11
+ expect(response.metrics.latencies).toHaveProperty('message');
12
+ expect(response.metrics.latencies).toHaveProperty('reaction');
13
+ expect(response.metrics.latencies).toHaveProperty('cron');
14
+ expect(response.metrics.latencies).toHaveProperty('defer');
15
+ });
16
+ });
@@ -0,0 +1,14 @@
1
+ import { globalTraceStore } from '../../observability/trace-store.js';
2
+ const DEFAULT_LIMIT = 50;
3
+ const MAX_LIMIT = 200;
4
+ export function buildTracesResponse(limitParam) {
5
+ const parsed = limitParam !== null ? Math.floor(Number(limitParam)) : DEFAULT_LIMIT;
6
+ const limit = Number.isFinite(parsed)
7
+ ? Math.max(1, Math.min(MAX_LIMIT, parsed))
8
+ : DEFAULT_LIMIT;
9
+ return {
10
+ ok: true,
11
+ summary: globalTraceStore.summary(),
12
+ recentTraces: globalTraceStore.listRecent(limit),
13
+ };
14
+ }
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildTracesResponse } from './traces.js';
3
+ import { globalTraceStore } from '../../observability/trace-store.js';
4
+ describe('buildTracesResponse', () => {
5
+ it('returns ok with summary and recent traces', () => {
6
+ const response = buildTracesResponse(null);
7
+ expect(response.ok).toBe(true);
8
+ expect(response.summary).toBeDefined();
9
+ expect(typeof response.summary.total).toBe('number');
10
+ expect(response.summary.byFlow).toBeDefined();
11
+ expect(Array.isArray(response.recentTraces)).toBe(true);
12
+ });
13
+ it('uses default limit of 50 when param is null', () => {
14
+ const response = buildTracesResponse(null);
15
+ expect(response.ok).toBe(true);
16
+ // With an empty store, recentTraces should be empty
17
+ expect(response.recentTraces.length).toBeLessThanOrEqual(50);
18
+ });
19
+ it('respects a custom limit param', () => {
20
+ // Seed a few traces
21
+ globalTraceStore.startTrace('t1', 'user:ch', 'message');
22
+ globalTraceStore.endTrace('t1', 'success');
23
+ globalTraceStore.startTrace('t2', 'user:ch', 'cron');
24
+ globalTraceStore.endTrace('t2', 'success');
25
+ globalTraceStore.startTrace('t3', 'user:ch', 'reaction');
26
+ globalTraceStore.endTrace('t3', 'success');
27
+ const response = buildTracesResponse('2');
28
+ expect(response.ok).toBe(true);
29
+ expect(response.recentTraces.length).toBeLessThanOrEqual(2);
30
+ });
31
+ it('clamps limit to max of 200', () => {
32
+ const response = buildTracesResponse('999');
33
+ expect(response.ok).toBe(true);
34
+ // Just verify it doesn't throw — the limit is clamped internally
35
+ });
36
+ it('falls back to default for non-numeric limit', () => {
37
+ const response = buildTracesResponse('abc');
38
+ expect(response.ok).toBe(true);
39
+ });
40
+ });
@@ -697,7 +697,7 @@ export function renderDashboardPage() {
697
697
  </label>
698
698
  </div>
699
699
  <div class="actions">
700
- <button id="chat-runtime-submit-btn" type="submit">Apply Runtime</button>
700
+ <button id="chat-runtime-submit-btn" type="submit">Apply Runtime + Save</button>
701
701
  <button id="chat-auth-btn" class="secondary" type="button">Check Auth</button>
702
702
  </div>
703
703
  </form>
@@ -709,9 +709,9 @@ export function renderDashboardPage() {
709
709
  <select id="chat-model-select" name="model" required></select>
710
710
  </label>
711
711
  </div>
712
- <div class="field-note">Tier options double as the practical thinking profile on runtimes that support explicit effort.</div>
712
+ <div class="field-note">Tier options double as the practical thinking profile on runtimes that support explicit effort. These chat controls also save the next-start default.</div>
713
713
  <div class="actions">
714
- <button id="chat-model-submit-btn" type="submit">Apply Model</button>
714
+ <button id="chat-model-submit-btn" type="submit">Apply Model + Save</button>
715
715
  </div>
716
716
  </form>
717
717
  </div>
@@ -839,6 +839,52 @@ export function renderDashboardPage() {
839
839
  </details>
840
840
  </section>
841
841
 
842
+ <section class="card span-12">
843
+ <div class="card-header">
844
+ <div>
845
+ <h2>Observability</h2>
846
+ </div>
847
+ <div class="actions">
848
+ <button id="traces-btn" class="secondary" type="button">Refresh Traces</button>
849
+ </div>
850
+ </div>
851
+ <div id="traces-summary" class="metrics"></div>
852
+ <details>
853
+ <summary>Runtime Metrics</summary>
854
+ <div class="details-body">
855
+ <div id="metrics-counters" class="metrics"></div>
856
+ <div id="metrics-latencies" class="metrics"></div>
857
+ <div id="metrics-memory" class="metrics"></div>
858
+ </div>
859
+ </details>
860
+ <details>
861
+ <summary>Recent Traces</summary>
862
+ <div class="details-body">
863
+ <div class="table-wrap">
864
+ <table>
865
+ <thead>
866
+ <tr>
867
+ <th>Flow</th>
868
+ <th>Outcome</th>
869
+ <th>Duration</th>
870
+ <th>Started</th>
871
+ <th>Events</th>
872
+ </tr>
873
+ </thead>
874
+ <tbody id="traces-body"></tbody>
875
+ </table>
876
+ </div>
877
+ </div>
878
+ </details>
879
+ <details>
880
+ <summary>Recent Errors</summary>
881
+ <div class="details-body">
882
+ <div id="traces-errors" class="checklist"></div>
883
+ </div>
884
+ </details>
885
+ <div id="traces-status" class="status"></div>
886
+ </section>
887
+
842
888
  <section class="card span-12">
843
889
  <div class="card-header">
844
890
  <div>
@@ -955,6 +1001,13 @@ export function renderDashboardPage() {
955
1001
  const secretValueInput = document.getElementById('secret-value-input');
956
1002
  const settingsContainer = document.getElementById('settings-container');
957
1003
  const settingsStatus = document.getElementById('settings-status');
1004
+ const tracesSummary = document.getElementById('traces-summary');
1005
+ const tracesBody = document.getElementById('traces-body');
1006
+ const tracesErrors = document.getElementById('traces-errors');
1007
+ const tracesStatus = document.getElementById('traces-status');
1008
+ const metricsCounters = document.getElementById('metrics-counters');
1009
+ const metricsLatencies = document.getElementById('metrics-latencies');
1010
+ const metricsMemory = document.getElementById('metrics-memory');
958
1011
  const ROLE_LABELS = {
959
1012
  chat: 'Chat',
960
1013
  'plan-run': 'Plan Run',
@@ -1265,7 +1318,9 @@ export function renderDashboardPage() {
1265
1318
  chatRuntimeSelect.value = live.chatRuntime || snapshot.primaryRuntime;
1266
1319
 
1267
1320
  clearNode(chatModelSelect);
1268
- (snapshot.modelOptions.chat || []).forEach(function (model) {
1321
+ (snapshot.modelOptions.chat || []).filter(function (model) {
1322
+ return model !== 'default';
1323
+ }).forEach(function (model) {
1269
1324
  appendSelectOption(chatModelSelect, model, formatModelOptionLabel('chat', model));
1270
1325
  });
1271
1326
  if ((snapshot.modelOptions.chat || []).indexOf(live.chatModel) >= 0) {
@@ -1303,6 +1358,121 @@ export function renderDashboardPage() {
1303
1358
  secretKeySelect.value = recommendSecretKey(snapshot);
1304
1359
  }
1305
1360
 
1361
+ function renderTraces(data) {
1362
+ var summary = data.summary || {};
1363
+ var recentTraces = data.recentTraces || [];
1364
+ var byFlow = summary.byFlow || {};
1365
+ clearNode(tracesSummary);
1366
+ appendMetric(tracesSummary, 'total traces', String(summary.total || 0));
1367
+ appendMetric(tracesSummary, 'in progress', String(summary.inProgress || 0));
1368
+ var flows = ['message', 'reaction', 'cron', 'defer'];
1369
+ flows.forEach(function (flow) {
1370
+ var fs = byFlow[flow];
1371
+ if (!fs || fs.total === 0) return;
1372
+ var avg = fs.avgDurationMs > 0 ? ' avg ' + fs.avgDurationMs + 'ms' : '';
1373
+ appendMetric(tracesSummary, flow, fs.succeeded + ' ok / ' + fs.failed + ' err / ' + fs.inProgress + ' running' + avg);
1374
+ });
1375
+
1376
+ clearNode(tracesBody);
1377
+ recentTraces.forEach(function (trace) {
1378
+ var tr = document.createElement('tr');
1379
+ var flowCell = document.createElement('td');
1380
+ flowCell.textContent = trace.flow;
1381
+ var outcomeCell = document.createElement('td');
1382
+ outcomeCell.textContent = trace.outcome;
1383
+ if (trace.outcome === 'success') outcomeCell.style.color = 'var(--green)';
1384
+ else if (trace.outcome === 'in_progress') outcomeCell.style.color = 'var(--amber)';
1385
+ else if (trace.outcome !== 'success') outcomeCell.style.color = 'var(--red)';
1386
+ var durationCell = document.createElement('td');
1387
+ durationCell.textContent = trace.outcome === 'in_progress' ? '\u2014' : trace.durationMs + 'ms';
1388
+ var startedCell = document.createElement('td');
1389
+ startedCell.textContent = new Date(trace.startedAt).toLocaleTimeString();
1390
+ var eventsCell = document.createElement('td');
1391
+ eventsCell.textContent = String((trace.events || []).length);
1392
+ tr.append(flowCell, outcomeCell, durationCell, startedCell, eventsCell);
1393
+ tracesBody.append(tr);
1394
+ });
1395
+
1396
+ clearNode(tracesErrors);
1397
+ var recentErrors = summary.recentErrors || [];
1398
+ if (recentErrors.length === 0) {
1399
+ var noErrors = document.createElement('div');
1400
+ noErrors.className = 'card-copy';
1401
+ noErrors.textContent = 'No recent errors.';
1402
+ tracesErrors.append(noErrors);
1403
+ } else {
1404
+ recentErrors.forEach(function (err) {
1405
+ var item = document.createElement('div');
1406
+ item.className = 'checklist-item';
1407
+ var top = document.createElement('div');
1408
+ top.className = 'checklist-top';
1409
+ var dot = document.createElement('div');
1410
+ dot.className = 'status-dot error';
1411
+ var label = document.createElement('div');
1412
+ label.className = 'checklist-label';
1413
+ label.textContent = err.flow + ': ' + err.message;
1414
+ top.append(dot, label);
1415
+ var body = document.createElement('div');
1416
+ body.className = 'checklist-body';
1417
+ body.textContent = new Date(err.at).toLocaleString();
1418
+ item.append(top, body);
1419
+ tracesErrors.append(item);
1420
+ });
1421
+ }
1422
+ }
1423
+
1424
+ async function refreshTraces() {
1425
+ var response = await fetchJson('/api/traces');
1426
+ renderTraces(response);
1427
+ }
1428
+
1429
+ function renderMetrics(data) {
1430
+ var m = data.metrics || {};
1431
+ var counters = m.counters || {};
1432
+ var latencies = m.latencies || {};
1433
+ var memory = m.memory;
1434
+
1435
+ clearNode(metricsCounters);
1436
+ var upSince = m.startedAt ? new Date(m.startedAt).toLocaleString() : 'unknown';
1437
+ appendMetric(metricsCounters, 'up since', upSince);
1438
+ var counterKeys = Object.keys(counters).sort();
1439
+ counterKeys.forEach(function (key) {
1440
+ appendMetric(metricsCounters, key, String(counters[key]));
1441
+ });
1442
+ if (counterKeys.length === 0) {
1443
+ appendMetric(metricsCounters, 'counters', 'none recorded yet');
1444
+ }
1445
+
1446
+ clearNode(metricsLatencies);
1447
+ var flows = ['message', 'reaction', 'cron', 'defer'];
1448
+ flows.forEach(function (flow) {
1449
+ var lat = latencies[flow];
1450
+ if (!lat || lat.count === 0) return;
1451
+ appendMetric(metricsLatencies, flow + ' latency',
1452
+ 'p50=' + lat.p50Ms + 'ms p95=' + lat.p95Ms + 'ms max=' + lat.maxMs + 'ms (n=' + lat.count + ')');
1453
+ });
1454
+ if (metricsLatencies.children.length === 0) {
1455
+ appendMetric(metricsLatencies, 'latencies', 'no samples yet');
1456
+ }
1457
+
1458
+ clearNode(metricsMemory);
1459
+ if (memory) {
1460
+ function fmtMB(bytes) { return bytes ? (bytes / 1048576).toFixed(1) + ' MB' : 'n/a'; }
1461
+ appendMetric(metricsMemory, 'rss', fmtMB(memory.rssBytes) + ' (hwm ' + fmtMB(memory.rssHwmBytes) + ')');
1462
+ appendMetric(metricsMemory, 'heap used', fmtMB(memory.heapUsedBytes) + ' (hwm ' + fmtMB(memory.heapUsedHwmBytes) + ')');
1463
+ appendMetric(metricsMemory, 'heap total', fmtMB(memory.heapTotalBytes));
1464
+ appendMetric(metricsMemory, 'external', fmtMB(memory.externalBytes));
1465
+ appendMetric(metricsMemory, 'samples', String(memory.sampleCount || 0));
1466
+ } else {
1467
+ appendMetric(metricsMemory, 'memory', 'sampler not active');
1468
+ }
1469
+ }
1470
+
1471
+ async function refreshMetrics() {
1472
+ var response = await fetchJson('/api/metrics');
1473
+ renderMetrics(response);
1474
+ }
1475
+
1306
1476
  function renderSnapshot(snapshot) {
1307
1477
  if (!snapshot.live) snapshot.live = {};
1308
1478
  const selectedRole = roleSelect.value;
@@ -1560,13 +1730,22 @@ export function renderDashboardPage() {
1560
1730
 
1561
1731
  document.getElementById('refresh-btn').addEventListener('click', async function () {
1562
1732
  try {
1563
- await Promise.all([refreshSnapshot(false), refreshDoctor(false)]);
1733
+ await Promise.all([refreshSnapshot(false), refreshDoctor(false), refreshTraces(), refreshMetrics()]);
1564
1734
  setStatus(heroStatus, 'Dashboard refreshed.', 'ok');
1565
1735
  } catch (error) {
1566
1736
  setStatus(heroStatus, String(error), 'error');
1567
1737
  }
1568
1738
  });
1569
1739
 
1740
+ document.getElementById('traces-btn').addEventListener('click', async function () {
1741
+ try {
1742
+ await Promise.all([refreshTraces(), refreshMetrics()]);
1743
+ setStatus(tracesStatus, 'Traces refreshed.', 'ok');
1744
+ } catch (error) {
1745
+ setStatus(tracesStatus, String(error), 'error');
1746
+ }
1747
+ });
1748
+
1570
1749
  document.getElementById('status-btn').addEventListener('click', async function () {
1571
1750
  try {
1572
1751
  const response = await fetchJson('/api/status');
@@ -1639,7 +1818,7 @@ export function renderDashboardPage() {
1639
1818
  const response = await fetchJson('/api/live-model', {
1640
1819
  method: 'POST',
1641
1820
  headers: { 'Content-Type': 'application/json' },
1642
- body: JSON.stringify({ role: 'chat', model: chatRuntimeSelect.value })
1821
+ body: JSON.stringify({ role: 'chat', model: chatRuntimeSelect.value, persist: true })
1643
1822
  });
1644
1823
  renderSnapshot(response.snapshot);
1645
1824
  setStatus(chatStatus, response.message, 'ok');
@@ -1655,7 +1834,7 @@ export function renderDashboardPage() {
1655
1834
  const response = await fetchJson('/api/live-model', {
1656
1835
  method: 'POST',
1657
1836
  headers: { 'Content-Type': 'application/json' },
1658
- body: JSON.stringify({ role: 'chat', model: chatModelSelect.value })
1837
+ body: JSON.stringify({ role: 'chat', model: chatModelSelect.value, persist: true })
1659
1838
  });
1660
1839
  renderSnapshot(response.snapshot);
1661
1840
  setStatus(chatStatus, response.message, 'ok');
@@ -1762,7 +1941,7 @@ export function renderDashboardPage() {
1762
1941
  syncSecondaryModelOptions(roleSelect.value, '');
1763
1942
  });
1764
1943
 
1765
- Promise.all([refreshSnapshot(false), refreshDoctor(false), loadSettings()]).then(function () {
1944
+ Promise.all([refreshSnapshot(false), refreshDoctor(false), loadSettings(), refreshTraces(), refreshMetrics()]).then(function () {
1766
1945
  setStatus(heroStatus, 'Dashboard ready.', 'ok');
1767
1946
  if (lastSnapshot) {
1768
1947
  populateSecondaryRoleForm('', '');