mixdog 1.0.4 → 1.0.6

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 (158) hide show
  1. package/README.md +117 -434
  2. package/package.json +18 -17
  3. package/scripts/gate-local.mjs +93 -0
  4. package/scripts/lib/gate-scheduler.mjs +24 -0
  5. package/scripts/lib/run-node-tests.mjs +5 -1
  6. package/scripts/release-paths.mjs +22 -1
  7. package/scripts/test-direct.mjs +2 -1
  8. package/scripts/test.mjs +3 -1
  9. package/src/defaults/agents.json +0 -12
  10. package/src/defaults/mixdog-config.template.json +1 -2
  11. package/src/defaults/skills/docx/SKILL.md +7 -4
  12. package/src/defaults/skills/docx/references/html.md +46 -0
  13. package/src/defaults/skills/docx/references/native-authoring.md +18 -4
  14. package/src/defaults/skills/pdf/SKILL.md +10 -2
  15. package/src/defaults/skills/pdf/references/html.md +64 -0
  16. package/src/defaults/skills/pptx/references/html.md +1 -1
  17. package/src/defaults/skills/xlsx/SKILL.md +7 -3
  18. package/src/defaults/skills/xlsx/references/html.md +40 -0
  19. package/src/defaults/skills/xlsx/references/report-design.md +21 -4
  20. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch/preset.mjs +1 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +2 -3
  22. package/src/runtime/agent/orchestrator/internal-agents.mjs +1 -1
  23. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +6 -5
  24. package/src/runtime/agent/orchestrator/session/compact/execution-tail.mjs +6 -3
  25. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +6 -2
  26. package/src/runtime/agent/orchestrator/session/loop/fresh-context.mjs +50 -9
  27. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +28 -1
  28. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +4 -1
  29. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +34 -8
  30. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +10 -2
  31. package/src/runtime/agent/orchestrator/session/transcript-restore/agent-envelope.mjs +1 -1
  32. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +1 -0
  33. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +3 -1
  34. package/src/runtime/attachments/pdf-extract.mjs +7 -3
  35. package/src/runtime/computer-bridge/client.mjs +1 -1
  36. package/src/runtime/memory/index.mjs +4 -15
  37. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  38. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +0 -2
  39. package/src/runtime/memory/lib/cycle-scheduler/backlog-probe.mjs +3 -11
  40. package/src/runtime/memory/lib/cycle-scheduler/health-ledger.mjs +1 -3
  41. package/src/runtime/memory/lib/cycle-scheduler/scheduled-enqueue.mjs +1 -11
  42. package/src/runtime/memory/lib/cycle-scheduler/tick-loop.mjs +0 -2
  43. package/src/runtime/memory/lib/cycle-scheduler.mjs +4 -13
  44. package/src/runtime/memory/lib/cycle-signatures.mjs +0 -6
  45. package/src/runtime/memory/lib/cycle1/cycle1-rows.mjs +1 -1
  46. package/src/runtime/memory/lib/cycle1/cycle1-window.mjs +2 -2
  47. package/src/runtime/memory/lib/embedding-reindex.mjs +1 -1
  48. package/src/runtime/memory/lib/http-router/lifecycle-routes.mjs +0 -1
  49. package/src/runtime/memory/lib/memory-action-handlers/cycle-actions.mjs +7 -52
  50. package/src/runtime/memory/lib/memory-action-handlers/cycle-backfill-action.mjs +6 -22
  51. package/src/runtime/memory/lib/memory-action-handlers/cycle-rebuild-action.mjs +10 -17
  52. package/src/runtime/memory/lib/memory-action-handlers/maintenance-actions.mjs +1 -3
  53. package/src/runtime/memory/lib/memory-action-handlers/manage-actions.mjs +2 -4
  54. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -4
  55. package/src/runtime/memory/lib/memory-cycle-requests.mjs +1 -1
  56. package/src/runtime/memory/lib/{memory-cycle2-shared.mjs → memory-cycle-shared.mjs} +14 -16
  57. package/src/runtime/memory/lib/memory-cycle.mjs +3 -2
  58. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  59. package/src/runtime/memory/lib/memory-embed.mjs +14 -1
  60. package/src/runtime/memory/lib/memory-ops-policy.mjs +8 -11
  61. package/src/runtime/memory/lib/memory-schema/entries.mjs +0 -1
  62. package/src/runtime/memory/lib/memory.mjs +2 -6
  63. package/src/runtime/memory/lib/pg/adapter.mjs +1 -1
  64. package/src/runtime/memory/lib/query-handlers.mjs +1 -1
  65. package/src/runtime/memory/lib/query-hybrid-search.mjs +1 -1
  66. package/src/runtime/memory/lib/query-maintenance-handlers.mjs +0 -6
  67. package/src/runtime/memory/lib/recall-format.mjs +1 -1
  68. package/src/runtime/office/authoring/docx-html-build.mjs +375 -0
  69. package/src/runtime/office/authoring/docx-html-measure.mjs +561 -0
  70. package/src/runtime/office/authoring/docx-html-runner.mjs +102 -0
  71. package/src/runtime/office/authoring/html-browser.mjs +61 -0
  72. package/src/runtime/office/authoring/html-document-author.mjs +181 -0
  73. package/src/runtime/office/authoring/html-source-drift.mjs +36 -0
  74. package/src/runtime/office/authoring/pdf-author-action.mjs +266 -0
  75. package/src/runtime/office/authoring/pdf-html-charts.mjs +379 -0
  76. package/src/runtime/office/authoring/pdf-html-runner.mjs +317 -0
  77. package/src/runtime/office/authoring/pptx-author-action.mjs +44 -22
  78. package/src/runtime/office/authoring/pptx-author-session.mjs +12 -6
  79. package/src/runtime/office/authoring/pptx-html-build.mjs +23 -4
  80. package/src/runtime/office/authoring/pptx-html-measure.mjs +38 -44
  81. package/src/runtime/office/authoring/pptx-html-runner.mjs +18 -3
  82. package/src/runtime/office/authoring/pptx-receipt.mjs +39 -31
  83. package/src/runtime/office/authoring/pptx-review-artifacts.mjs +9 -1
  84. package/src/runtime/office/authoring/pptx-script-runner.mjs +6 -2
  85. package/src/runtime/office/authoring/xlsx-html-build.mjs +365 -0
  86. package/src/runtime/office/authoring/xlsx-html-measure.mjs +257 -0
  87. package/src/runtime/office/authoring/xlsx-html-runner.mjs +139 -0
  88. package/src/runtime/office/capabilities-catalog.mjs +8 -0
  89. package/src/runtime/office/capabilities-signatures.mjs +18 -6
  90. package/src/runtime/office/capabilities.mjs +26 -1
  91. package/src/runtime/office/com/office-com-host.ps1 +133 -17
  92. package/src/runtime/office/core/office-actions-batch.mjs +16 -1
  93. package/src/runtime/office/core/office-actions-inspect.mjs +3 -2
  94. package/src/runtime/office/core/office-actions-lifecycle.mjs +6 -2
  95. package/src/runtime/office/core/office-actions-render.mjs +4 -1
  96. package/src/runtime/office/core/office-finalize/review-stage.mjs +15 -2
  97. package/src/runtime/office/core/office-qa/design-review-stage.mjs +9 -0
  98. package/src/runtime/office/core/office-session-dispatch.mjs +3 -0
  99. package/src/runtime/office/core/office-sessionless-actions.mjs +7 -2
  100. package/src/runtime/office/core/office-sessions.mjs +19 -2
  101. package/src/runtime/office/pdf/pdf-writer.mjs +1 -1
  102. package/src/runtime/office/portable/docx-formatting.mjs +21 -0
  103. package/src/runtime/office/portable/ooxml-validator.mjs +22 -1
  104. package/src/runtime/office/portable/portable-chart.mjs +10 -2
  105. package/src/runtime/office/portable/portable-docx-edits.mjs +12 -6
  106. package/src/runtime/office/portable/portable-docx-operations.mjs +75 -1
  107. package/src/runtime/office/portable/portable-docx-xml.mjs +49 -6
  108. package/src/runtime/office/portable/portable-docx.mjs +2 -0
  109. package/src/runtime/office/portable/portable-snapshot-docx.mjs +2 -0
  110. package/src/runtime/office/portable/portable-validation.mjs +6 -3
  111. package/src/runtime/office/portable/portable-xlsx-cell-values.mjs +87 -0
  112. package/src/runtime/office/portable/portable-xlsx-charts.mjs +55 -13
  113. package/src/runtime/office/portable/portable-xlsx-sheet-edits.mjs +1 -1
  114. package/src/runtime/office/portable/xlsx-contract.mjs +82 -3
  115. package/src/runtime/office/quality/assurance-checklist.mjs +2 -2
  116. package/src/runtime/office/quality/assurance-rendered.mjs +27 -1
  117. package/src/runtime/office/quality/assurance-structure-docx.mjs +44 -0
  118. package/src/runtime/office/quality/assurance-structure-pptx.mjs +24 -6
  119. package/src/runtime/office/quality/assurance-structure-xlsx.mjs +50 -1
  120. package/src/runtime/office/quality/design-review-critique.mjs +3 -2
  121. package/src/runtime/office/quality/document-brief.mjs +44 -0
  122. package/src/runtime/office/quality/inline-audit.mjs +7 -2
  123. package/src/runtime/office/quality/quality-pipeline.mjs +9 -1
  124. package/src/runtime/office/tool-defs.mjs +2 -2
  125. package/src/runtime/shared/llm/quota-value-estimate.mjs +317 -0
  126. package/src/runtime/shared/llm/usage-ledger-quota.mjs +266 -63
  127. package/src/runtime/shared/llm/usage-ledger-rollup.mjs +35 -0
  128. package/src/runtime/shared/llm/usage-ledger-worker.mjs +2 -1
  129. package/src/runtime/shared/llm/usage-ledger.mjs +10 -1
  130. package/src/runtime/shared/provider-accounts.mjs +11 -1
  131. package/src/runtime/shared/statusline/statusline-segments.mjs +4 -4
  132. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  133. package/src/runtime/shared/wait-until.test-support.mjs +33 -0
  134. package/src/session-runtime/boot/apis.mjs +1 -1
  135. package/src/session-runtime/services/channel-admin.mjs +4 -3
  136. package/src/session-runtime/services/usage-stats-model.mjs +22 -0
  137. package/src/session-runtime/setup-tool/settings-contract.mjs +6 -1
  138. package/src/session-runtime/turn/session-ops.mjs +15 -9
  139. package/src/session-runtime/usage-stats-api.mjs +34 -2
  140. package/src/standalone/agent-dispatch-broker.mjs +1 -1
  141. package/src/standalone/local-session-runtime.mjs +488 -0
  142. package/src/standalone/session-protocol.mjs +1 -0
  143. package/src/standalone/session-runtime-inline-host.mjs +1 -1
  144. package/src/tui/hooks/useSession.mjs +1 -1
  145. package/src/tui/session/session-api/commands.mjs +1 -1
  146. package/src/tui/session/session-api/integrations.mjs +3 -0
  147. package/src/tui/session/session-flow/auto-clear.mjs +6 -7
  148. package/src/tui/session/tool-card-results.mjs +1 -1
  149. package/src/tui/session/turn.mjs +5 -0
  150. package/src/tui/session-local.mjs +3 -488
  151. package/src/tui/session.mjs +1 -1
  152. package/src/ui/statusline-agents.mjs +0 -1
  153. package/src/rules/agent/41-cycle2-agent.md +0 -24
  154. package/src/runtime/memory/lib/cycle-scheduler/cycle2-runs.mjs +0 -120
  155. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +0 -110
  156. package/src/runtime/memory/lib/memory-cycle2-quarantine.mjs +0 -99
  157. package/src/runtime/memory/lib/memory-cycle2-review.mjs +0 -240
  158. package/src/runtime/memory/lib/memory-cycle2.mjs +0 -191
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { normalizeUsageMeasurement } from './usage-measurement.mjs';
16
16
  import { usageRollupDayKey } from './usage-rollup.mjs';
17
+ import { estimateOutsideQuota, estimateQuotaValue, outsideSpans } from './quota-value-estimate.mjs';
17
18
 
18
19
  export const QUOTA_SCHEMA = `
19
20
  CREATE TABLE IF NOT EXISTS quota_samples (
@@ -38,9 +39,13 @@ const SLOT_SIZES = [30, 60, 120, 180, 360, 720, 1440, 2880, 10080, 20160, 43200]
38
39
  const MAX_SLOTS = 48;
39
40
  const MAX_POINTS = 4000;
40
41
  // The window history is read a page at a time, and a page reads only the
41
- // records of its own windows.
42
+ // records of its own windows and of the earlier windows their values continue.
42
43
  const HISTORY_PAGE_SIZE = 10;
43
44
  const SERIES_LIMIT = 6;
45
+ // Each window's value continues from the windows just before it, back until
46
+ // they hold this many quota points (at most this many windows).
47
+ const PRIOR_POINTS = 100;
48
+ const PRIOR_WINDOWS = 24;
44
49
  // The by-model chart's series key for usage from outside Mixdog. Every
45
50
  // recorded model has a name, so the empty key cannot collide.
46
51
  const OUTSIDE_KEY = '';
@@ -361,9 +366,19 @@ function legacyAccountOwner(db, provider) {
361
366
  * account belong to the legacy owner only, or to every account while no
362
367
  * record names one.
363
368
  */
364
- function readQuotaEvents(db, { provider, account, fromMs, toMs }) {
369
+ function readQuotaEvents(db, { provider, account, label = '', fromMs, toMs }) {
365
370
  const owner = legacyAccountOwner(db, provider);
366
371
  const unlabeled = owner === null || owner === account ? '' : account;
372
+ // Anthropic's scoped weekly labels are model-family limits, not the whole
373
+ // subscription. An unrecognized scope must never borrow every model's cost.
374
+ const scope =
375
+ provider === 'anthropic-oauth' && label.startsWith('7D ')
376
+ ? `claude-${label
377
+ .slice(3)
378
+ .trim()
379
+ .toLowerCase()
380
+ .replace(/[\s_]+/g, '-')}`
381
+ : '';
367
382
  const rows = db
368
383
  .prepare(`
369
384
  WITH routes AS (
@@ -371,13 +386,13 @@ function readQuotaEvents(db, { provider, account, fromMs, toMs }) {
371
386
  FROM usage_routes
372
387
  WHERE json_extract(signature,'$[0]')=? AND COALESCE(json_extract(signature,'$[8]'),'') IN (?,?)
373
388
  )
374
- SELECT (e.ts/60000)*60000 AS minute,e.day,r.model,r.rank,COUNT(*) AS turns,
389
+ SELECT e.ts,e.day,r.model,r.rank,COUNT(*) AS turns,
375
390
  SUM(e.input) AS input,SUM(e.output) AS output,SUM(e.cache_read) AS cacheRead,
376
391
  SUM(e.cache_write) AS cacheWrite,SUM(e.cost_usd) AS costUsd,COUNT(e.cost_usd) AS priced
377
392
  FROM usage_events e JOIN routes r ON r.id=e.route
378
393
  WHERE e.ts>=? AND e.ts<=?
379
- GROUP BY minute,e.day,r.model,r.rank
380
- ORDER BY minute`)
394
+ GROUP BY e.ts,e.day,r.model,r.rank
395
+ ORDER BY e.ts`)
381
396
  .all(provider, account, unlabeled, fromMs, toMs);
382
397
  const best = new Map();
383
398
  const ranks = db.prepare(`SELECT day,model,MIN(rank) AS rank FROM daily
@@ -386,6 +401,8 @@ function readQuotaEvents(db, { provider, account, fromMs, toMs }) {
386
401
  best.set(`${row.day}\u0000${row.model}`, row.rank);
387
402
  }
388
403
  return rows.flatMap((row) => {
404
+ const model = String(row.model).toLowerCase();
405
+ if (scope && model !== scope && !model.startsWith(`${scope}-`)) return [];
389
406
  const day = String(row.day);
390
407
  if (best.get(`${day.slice(0, 4)}-${day.slice(4, 6)}-${day.slice(6)}\u0000${row.model}`) !== row.rank) return [];
391
408
  const usage = normalizeUsageMeasurement(provider, {
@@ -398,7 +415,7 @@ function readQuotaEvents(db, { provider, account, fromMs, toMs }) {
398
415
  costKnownTurns: row.priced,
399
416
  });
400
417
  const event = {
401
- ts: row.minute,
418
+ ts: row.ts,
402
419
  model: row.model,
403
420
  turns: row.turns,
404
421
  input: usage.input || 0,
@@ -414,58 +431,211 @@ function readQuotaEvents(db, { provider, account, fromMs, toMs }) {
414
431
  });
415
432
  }
416
433
 
434
+ /** quota_samples stores readings rounded to two decimal places. Respect the
435
+ * precision actually present: an integer-only series supplies whole-percent
436
+ * readings, not invented tenths. Preserve finer reported readings when present. */
437
+ function quotaReadingResolution(rows) {
438
+ const cents = rows.map((row) => Math.round(row.usedPct * 100));
439
+ if (cents.some((value) => value % 10 !== 0)) return 0.01;
440
+ return cents.some((value) => value % 100 !== 0) ? 0.1 : 1;
441
+ }
442
+
443
+ /** The windows just before `instance`, oldest first: the calibration its
444
+ * value continues from. Defined per window, so every view of one window
445
+ * computes the same value. `listed` is every non-idle window, oldest first. */
446
+ function earlierWindows(listed, instance) {
447
+ const earlier = [];
448
+ let points = 0;
449
+ for (let index = listed.length - 1; index >= 0; index -= 1) {
450
+ if (points >= PRIOR_POINTS || earlier.length >= PRIOR_WINDOWS) break;
451
+ const candidate = listed[index];
452
+ if (candidate.startMs >= instance.startMs) continue;
453
+ earlier.unshift(candidate);
454
+ points += candidate.peak;
455
+ }
456
+ return earlier;
457
+ }
458
+
459
+ /** Every window `shown` reads records for: itself and its calibration. */
460
+ function calibratedWindows(listed, shown) {
461
+ return [...new Set([...shown.flatMap((instance) => earlierWindows(listed, instance)), ...shown])];
462
+ }
463
+
464
+ /** One window's meter rises, each with the records behind it. */
465
+ function windowIntervals(instance, events, mixed) {
466
+ const resolution = quotaReadingResolution(instance.rows);
467
+ const intervals = [];
468
+ let fromMs = instance.startMs;
469
+ let fromPct = 0;
470
+ for (const [rowIndex, row] of instance.rows.entries()) {
471
+ const delta = row.usedPct - fromPct;
472
+ if (delta > 0) {
473
+ const start = firstAfter(events, fromMs);
474
+ const end = firstAfter(events, row.ts);
475
+ let cost = 0;
476
+ let tokens = 0;
477
+ let turns = 0;
478
+ let priced = 0;
479
+ for (let index = start; index < end; index += 1) {
480
+ cost += events[index].costUsd;
481
+ tokens += events[index].tokens;
482
+ turns += events[index].turns;
483
+ priced += events[index].costKnownTurns;
484
+ }
485
+ let weight = 'turns';
486
+ let total = turns;
487
+ if (cost > 0) {
488
+ weight = 'costUsd';
489
+ total = cost;
490
+ } else if (tokens > 0) {
491
+ weight = 'tokens';
492
+ total = tokens;
493
+ }
494
+ intervals.push({
495
+ fromMs,
496
+ toMs: row.ts,
497
+ points: delta,
498
+ costUsd: cost,
499
+ start,
500
+ end,
501
+ weight,
502
+ total,
503
+ resolution,
504
+ // Long reading gaps still carry cost and quota evidence. Only an
505
+ // inferred opening, a saturated meter, or the first rise from zero
506
+ // lowers its measurement quality: the first request of a window can
507
+ // show a whole step of a rounded-up meter, so that rise only bounds
508
+ // its use within one step.
509
+ measured: rowIndex > 0 && fromPct > 0 && row.usedPct < 100,
510
+ priced: turns > 0 && priced === turns,
511
+ mixed: mixed.some((span) => span.fromMs < row.ts && span.toMs > fromMs),
512
+ });
513
+ }
514
+ fromMs = row.ts;
515
+ fromPct = row.usedPct;
516
+ }
517
+ return intervals;
518
+ }
519
+
417
520
  /**
418
- * Split every rise of the meter over the records made since the reading
419
- * before it: by list-price value, else by tokens, else by count. A rise with
420
- * no record behind it is kept as outside usage over its interval.
521
+ * Keep observed quota and recorded dollars intact. Continuously estimate
522
+ * probable outside use from priced history before allocating the remainder
523
+ * over Mixdog's requests; recent observations outweigh older ones. Each
524
+ * window continues from its earlier windows in `listed` (every non-idle
525
+ * window, oldest first), whose records `events` must cover. Intervals
526
+ * overlapping `mixed` spans never calibrate the rate: their outside share is
527
+ * the rise that Mixdog's own dollars at that rate do not cover.
421
528
  */
422
- function allocateQuota(instances, events) {
529
+ function allocateQuota(instances, events, mixed = [], listed = []) {
423
530
  const shares = new Float64Array(events.length);
424
531
  const outside = [];
532
+ const calibration = new Map();
533
+ const calibrationOf = (instance) => {
534
+ if (!calibration.has(instance)) {
535
+ calibration.set(
536
+ instance,
537
+ windowIntervals(instance, events, mixed).filter((interval) => !interval.mixed)
538
+ );
539
+ }
540
+ return calibration.get(instance);
541
+ };
425
542
  for (const instance of instances) {
426
- instance.attributed = 0;
427
- instance.attributedCost = 0;
428
543
  instance.outside = 0;
429
- let fromMs = instance.startMs;
430
- let fromPct = 0;
431
- for (const row of instance.rows) {
432
- const delta = row.usedPct - fromPct;
433
- if (delta > 0) {
434
- const start = firstAfter(events, fromMs);
435
- const end = firstAfter(events, row.ts);
436
- let cost = 0;
437
- let tokens = 0;
438
- let turns = 0;
439
- for (let index = start; index < end; index += 1) {
440
- cost += events[index].costUsd;
441
- tokens += events[index].tokens;
442
- turns += events[index].turns;
443
- }
444
- let weight = 'turns';
445
- let total = turns;
446
- if (cost > 0) {
447
- weight = 'costUsd';
448
- total = cost;
449
- } else if (tokens > 0) {
450
- weight = 'tokens';
451
- total = tokens;
544
+ instance.outsideCostUsd = 0;
545
+ instance.outsideUnpriced = 0;
546
+ instance.intervals = windowIntervals(instance, events, mixed);
547
+ instance.estimate = estimateQuotaValue(
548
+ instance.intervals.filter((interval) => !interval.mixed),
549
+ earlierWindows(listed, instance).flatMap(calibrationOf)
550
+ );
551
+ for (const interval of instance.intervals) {
552
+ const { start, end, total, weight, points } = interval;
553
+ let external = points;
554
+ if (total > 0) {
555
+ external = 0;
556
+ if (interval.measured && interval.priced) {
557
+ external =
558
+ interval.mixed && instance.estimate
559
+ ? Math.min(points, Math.max(0, points - interval.costUsd / instance.estimate.costPerPercent))
560
+ : estimateOutsideQuota(interval, instance.estimate);
452
561
  }
453
- if (total > 0) {
454
- for (let index = start; index < end; index += 1) shares[index] += (delta * events[index][weight]) / total;
455
- instance.attributed += delta;
456
- instance.attributedCost += cost;
457
- } else {
458
- outside.push({ fromMs, toMs: row.ts, points: delta });
459
- instance.outside += delta;
562
+ }
563
+ if (total > 0) {
564
+ for (let index = start; index < end; index += 1) {
565
+ shares[index] += ((points - external) * events[index][weight]) / total;
460
566
  }
461
567
  }
462
- fromMs = row.ts;
463
- fromPct = row.usedPct;
568
+ if (external > 0) {
569
+ const costUsd = instance.estimate ? external * instance.estimate.costPerPercent : null;
570
+ outside.push({ fromMs: interval.fromMs, toMs: interval.toMs, points: external, costUsd });
571
+ instance.outside += external;
572
+ if (costUsd === null) instance.outsideUnpriced += external;
573
+ else instance.outsideCostUsd += costUsd;
574
+ }
464
575
  }
465
576
  }
466
577
  return { shares, outside };
467
578
  }
468
579
 
580
+ /**
581
+ * The weekly all-model limit's five-hour companion restarts from zero every
582
+ * few hours and moves in quarter-size steps, so it exposes outside use that a
583
+ * whole-percent weekly reading hides. Each week is judged by its own
584
+ * five-hour windows, so a plan change between weeks cannot make every later
585
+ * five-hour window look mixed. Scoped weekly limits share no five-hour meter.
586
+ */
587
+ function finerOutsideSpans(db, selection, events, weeks, now) {
588
+ if (selection.label !== '7D' || !events.length || !weeks.length) return [];
589
+ const prefix = [0];
590
+ for (const event of events) prefix.push(prefix.at(-1) + event.costUsd);
591
+ const costBetween = (from, to) => prefix[firstAfter(events, to)] - prefix[firstAfter(events, from)];
592
+ const fives = quotaInstances(readQuotaRows(db, { ...selection, label: '5H' }), '5H', now).filter(
593
+ (instance) => !instance.idle
594
+ );
595
+ return weeks.flatMap((week) =>
596
+ outsideSpans(
597
+ fives
598
+ .filter((five) => five.startMs >= week.startMs && five.startMs < week.endMs)
599
+ .map((five) => ({ startMs: five.startMs, readings: five.rows })),
600
+ costBetween
601
+ )
602
+ );
603
+ }
604
+
605
+ function intervalShare(interval, fromMs, toMs) {
606
+ if (interval.toMs <= interval.fromMs) return interval.toMs >= fromMs && interval.toMs <= toMs ? 1 : 0;
607
+ return (
608
+ Math.max(0, Math.min(interval.toMs, toMs) - Math.max(interval.fromMs, fromMs)) / (interval.toMs - interval.fromMs)
609
+ );
610
+ }
611
+
612
+ /** A period spanning different capacities averages by observed quota, not
613
+ * dollars. A missing calibration must not silently stand in as zero. */
614
+ function periodQuotaRate(instances, fromMs, toMs) {
615
+ let value = 0;
616
+ let points = 0;
617
+ for (const instance of instances) {
618
+ for (const interval of instance.intervals) {
619
+ const part = interval.points * intervalShare(interval, fromMs, toMs);
620
+ if (!(part > 0)) continue;
621
+ if (!instance.estimate) return null;
622
+ value += part * instance.estimate.costPerPercent;
623
+ points += part;
624
+ }
625
+ }
626
+ return points > 0 ? round(value / points, 6) : null;
627
+ }
628
+
629
+ function quotaCostFields(costUsd, outsideCostUsd, unpriced = false) {
630
+ const extra = outsideCostUsd === null ? null : round(outsideCostUsd, 6);
631
+ const recorded = round(costUsd, 6);
632
+ return {
633
+ costUsd: recorded,
634
+ outsideCostUsd: extra,
635
+ estimatedTotalCostUsd: extra === null || unpriced ? null : round(recorded + extra, 6),
636
+ };
637
+ }
638
+
469
639
  const USAGE_FIELDS = [
470
640
  'turns',
471
641
  'input',
@@ -575,22 +745,26 @@ function modelSeries(slots, instances, keys, now) {
575
745
  /** Outside usage is spread over the time its interval covers; returns its total within the domain. */
576
746
  function spreadOutsideUsage(outside, slots, slotAt, domainFrom, domainTo) {
577
747
  let total = 0;
748
+ const add = (slot, interval, share) => {
749
+ const part = interval.points * share;
750
+ slot.outside += part;
751
+ if (interval.costUsd === null) slot.outsideUnpriced += part;
752
+ else slot.outsideCostUsd += interval.costUsd * share;
753
+ total += part;
754
+ };
578
755
  for (const interval of outside) {
579
756
  const from = Math.max(interval.fromMs, domainFrom);
580
757
  const to = Math.min(interval.toMs, domainTo);
581
758
  if (interval.toMs <= interval.fromMs) {
582
759
  if (interval.toMs < domainFrom || interval.toMs > domainTo) continue;
583
- slots[slotAt(interval.toMs)].outside += interval.points;
584
- total += interval.points;
760
+ add(slots[slotAt(interval.toMs)], interval, 1);
585
761
  continue;
586
762
  }
587
763
  for (let index = slotAt(from); index < slots.length && slots[index].fromMs < to; index += 1) {
588
764
  const slot = slots[index];
589
765
  const overlap = Math.min(to, slot.toMs) - Math.max(from, slot.fromMs);
590
766
  if (overlap <= 0) continue;
591
- const part = (interval.points * overlap) / (interval.toMs - interval.fromMs);
592
- slot.outside += part;
593
- total += part;
767
+ add(slot, interval, overlap / (interval.toMs - interval.fromMs));
594
768
  }
595
769
  }
596
770
  return total;
@@ -602,10 +776,12 @@ function historyRow(instance, events) {
602
776
  let tokens = 0;
603
777
  let turns = 0;
604
778
  let costUsd = 0;
779
+ let unpriced = 0;
605
780
  for (let index = start; index < end; index += 1) {
606
781
  tokens += events[index].tokens;
607
782
  turns += events[index].turns;
608
783
  costUsd += events[index].costUsd;
784
+ unpriced += events[index].turns - events[index].costKnownTurns;
609
785
  }
610
786
  return {
611
787
  key: instance.key,
@@ -618,8 +794,13 @@ function historyRow(instance, events) {
618
794
  current: instance.current,
619
795
  tokens,
620
796
  turns,
621
- costUsd: round(costUsd, 6),
622
- costPerPercent: instance.attributed > 0.05 ? round(instance.attributedCost / instance.attributed, 6) : null,
797
+ ...quotaCostFields(
798
+ costUsd,
799
+ instance.estimate && !instance.outsideUnpriced ? instance.outsideCostUsd : null,
800
+ unpriced > 0
801
+ ),
802
+ costPerPercent: instance.estimate ? round(instance.estimate.costPerPercent, 6) : null,
803
+ estimateSamples: instance.estimate?.samples ?? 0,
623
804
  outside: round(instance.outside),
624
805
  };
625
806
  }
@@ -667,13 +848,20 @@ export function readQuotaHistory(
667
848
  const domainTo = Math.max(period.toMs, domainFrom + MINUTE);
668
849
  const overlapping = (instance) => instance.endMs > domainFrom && instance.startMs < domainTo;
669
850
  const shown = instances.filter(overlapping);
851
+ const calibrated = calibratedWindows(listed, shown);
670
852
  const events = readQuotaEvents(db, {
671
853
  provider: selection.provider,
672
854
  account: selection.account,
673
- fromMs: Math.min(domainFrom, ...shown.map((instance) => instance.startMs)),
855
+ label: selection.label,
856
+ fromMs: Math.min(domainFrom, ...calibrated.map((instance) => instance.startMs)),
674
857
  toMs: Math.max(domainTo, ...shown.map((instance) => instance.endMs)),
675
858
  });
676
- const { shares, outside } = allocateQuota(shown, events);
859
+ const { shares, outside } = allocateQuota(
860
+ shown,
861
+ events,
862
+ finerOutsideSpans(db, selection, events, calibrated, now),
863
+ listed
864
+ );
677
865
  const points = quotaPoints(shown);
678
866
 
679
867
  const slotMs =
@@ -688,17 +876,17 @@ export function readQuotaHistory(
688
876
  usage: emptyUsage(),
689
877
  models: new Map(),
690
878
  outside: 0,
879
+ outsideCostUsd: 0,
880
+ outsideUnpriced: 0,
691
881
  });
692
882
  }
693
883
  const slotAt = (time) => Math.min(slots.length - 1, Math.max(0, Math.floor((time - domainFrom) / slotMs)));
694
884
  const totals = emptyUsage();
695
885
  const models = new Map();
696
- let attributedCost = 0;
697
886
  events.forEach((event, index) => {
698
887
  if (event.ts < domainFrom || event.ts > domainTo) return;
699
888
  const share = shares[index];
700
889
  addUsage(totals, event, share);
701
- if (share > 0) attributedCost += event.costUsd;
702
890
  const model = models.get(event.model) || emptyUsage();
703
891
  models.set(event.model, model);
704
892
  addUsage(model, event, share);
@@ -709,6 +897,11 @@ export function readQuotaHistory(
709
897
  addUsage(slotModel, event, share);
710
898
  });
711
899
  const outsideTotal = spreadOutsideUsage(outside, slots, slotAt, domainFrom, domainTo);
900
+ const costPerPercent = periodQuotaRate(shown, domainFrom, domainTo);
901
+ const outsideCostUsd =
902
+ costPerPercent === null || slots.some((slot) => slot.outsideUnpriced > 0)
903
+ ? null
904
+ : slots.reduce((sum, slot) => sum + slot.outsideCostUsd, 0);
712
905
 
713
906
  const exportedModels = [...models].map(([model, usage]) => ({ model, ...exportUsage(usage) })).sort(byConsumption);
714
907
  const seriesKeys = exportedModels
@@ -743,8 +936,9 @@ export function readQuotaHistory(
743
936
  summary: {
744
937
  consumed: round(totals.consumed + outsideTotal),
745
938
  outside: round(outsideTotal),
746
- costUsd: round(totals.costUsd, 6),
747
- costPerPercent: totals.consumed > 0.05 ? round(attributedCost / totals.consumed, 6) : null,
939
+ ...quotaCostFields(totals.costUsd, outsideCostUsd, totals.costKnownTurns < totals.turns),
940
+ costPerPercent,
941
+ estimateSamples: shown.reduce((sum, instance) => sum + (instance.estimate?.samples ?? 0), 0),
748
942
  maxedOut: peaks.filter(
749
943
  (instance) =>
750
944
  instance.exhaustedAt !== null && instance.exhaustedAt >= domainFrom && instance.exhaustedAt <= domainTo
@@ -764,7 +958,13 @@ export function readQuotaHistory(
764
958
  outside: round(slot.outside),
765
959
  tokens: tokensOf(slot.usage),
766
960
  turns: slot.usage.turns,
767
- costUsd: round(slot.usage.costUsd, 6),
961
+ ...quotaCostFields(
962
+ slot.usage.costUsd,
963
+ periodQuotaRate(shown, slot.fromMs, slot.toMs) === null || slot.outsideUnpriced > 0
964
+ ? null
965
+ : slot.outsideCostUsd,
966
+ slot.usage.costKnownTurns < slot.usage.turns
967
+ ),
768
968
  models: [...slot.models]
769
969
  .map(([model, usage]) => ({
770
970
  model,
@@ -795,20 +995,23 @@ export function readQuotaWindows(
795
995
  const selection = pickSelection(listQuotaSeries(db), { provider, account, label, inUse });
796
996
  const base = { generatedAt: now, selection, page: 0, pageCount: 0, total: 0, windows: [] };
797
997
  if (!selection) return base;
798
- const listed = quotaInstances(readQuotaRows(db, selection), selection.label, now)
799
- .filter((instance) => !instance.idle)
800
- .reverse();
998
+ const chronological = quotaInstances(readQuotaRows(db, selection), selection.label, now).filter(
999
+ (instance) => !instance.idle
1000
+ );
1001
+ const listed = chronological.toReversed();
801
1002
  const pageCount = Math.ceil(listed.length / HISTORY_PAGE_SIZE);
802
1003
  if (!pageCount) return base;
803
1004
  const index = Math.min(Math.max(0, Math.floor(page) || 0), pageCount - 1);
804
1005
  const shown = listed.slice(index * HISTORY_PAGE_SIZE, (index + 1) * HISTORY_PAGE_SIZE);
1006
+ const calibrated = calibratedWindows(chronological, shown);
805
1007
  const events = readQuotaEvents(db, {
806
1008
  provider: selection.provider,
807
1009
  account: selection.account,
808
- fromMs: Math.min(...shown.map((instance) => instance.startMs)),
1010
+ label: selection.label,
1011
+ fromMs: Math.min(...calibrated.map((instance) => instance.startMs)),
809
1012
  toMs: Math.max(...shown.map((instance) => instance.endMs)),
810
1013
  });
811
- allocateQuota(shown, events);
1014
+ allocateQuota(shown, events, finerOutsideSpans(db, selection, events, calibrated, now), chronological);
812
1015
  return {
813
1016
  ...base,
814
1017
  page: index,
@@ -252,6 +252,41 @@ function mergeLegacyDays(db, { days, hourly, hourlyDay, fromDay, toDay }) {
252
252
  }
253
253
  }
254
254
 
255
+ /**
256
+ * One session's lifetime totals over every id its spend was recorded under.
257
+ * Compaction requests run under their own provider session id but are
258
+ * recorded under the source session, so this is the whole conversation
259
+ * across compactions. The same route choice as a rollup applies: a gateway
260
+ * summary never doubles a detail record.
261
+ */
262
+ export function readSessionUsage(db, sessionIds) {
263
+ const total = empty();
264
+ const rows = db
265
+ .prepare(`
266
+ WITH mine AS (
267
+ SELECT day,route,COUNT(*) AS turns,SUM(input) AS input,SUM(output) AS output,
268
+ SUM(cache_read) AS cache_read,SUM(cache_write) AS cache_write,
269
+ SUM(cost_usd) AS cost_usd,SUM(duration_ms) AS duration_ms
270
+ FROM usage_events WHERE session IN (
271
+ SELECT id FROM usage_sessions WHERE value IN (SELECT value FROM json_each(?)))
272
+ GROUP BY day,route
273
+ ), attributed AS (
274
+ SELECT printf('%04d-%02d-%02d',m.day/10000,(m.day/100)%100,m.day%100) AS day,
275
+ json_extract(r.signature,'$[0]') AS provider,
276
+ json_extract(r.signature,'$[1]') AS model,
277
+ json_extract(r.signature,'$[4]') AS cost_source,
278
+ json_extract(r.signature,'$[7]') AS rank,
279
+ m.turns,m.input,m.output,m.cache_read,m.cache_write,m.cost_usd,m.duration_ms
280
+ FROM mine m JOIN usage_routes r ON r.id=m.route
281
+ )
282
+ SELECT a.* FROM attributed a JOIN (${best}) b USING(day,provider,model,rank)
283
+ `)
284
+ .all(JSON.stringify(sessionIds), '0000-01-01', '9999-12-31');
285
+ for (const row of rows) add(total, row);
286
+ const { sessions, sessionsComplete, ...usage } = total;
287
+ return usage;
288
+ }
289
+
255
290
  /** Read cached amounts; group retained attribution separately for distinct sessions. */
256
291
  export function rollupUsage(
257
292
  db,
@@ -7,7 +7,7 @@
7
7
  import { DatabaseSync } from 'node:sqlite';
8
8
  import { serveWorkerRequests } from '../worker-requests.mjs';
9
9
  import { UsageLedger } from './usage-ledger.mjs';
10
- import { rollupUsage } from './usage-ledger-rollup.mjs';
10
+ import { readSessionUsage, rollupUsage } from './usage-ledger-rollup.mjs';
11
11
  import { readQuotaHistory, readQuotaWindows } from './usage-ledger-quota.mjs';
12
12
  import { applyUsageRepairIfUnchanged, selectUsageRepairCandidates } from './usage-ledger-repair.mjs';
13
13
  import { unpricedCountAfter, unpricedPendingCounts } from './usage-pricing-refresh.mjs';
@@ -59,6 +59,7 @@ function recordBatch(ledger, rows) {
59
59
 
60
60
  const operations = {
61
61
  rollup: ({ path, options }) => read(path, (db) => rollupUsage(db, options)),
62
+ sessionUsage: ({ path, sessionIds }) => read(path, (db) => readSessionUsage(db, sessionIds)),
62
63
  record: ({ path, rows }) => write(path, (ledger) => recordBatch(ledger, rows)),
63
64
  recordQuota: ({ path, samples }) => write(path, (ledger) => ledger.recordQuota(samples)),
64
65
  quotaHistory: ({ path, options }) => read(path, (db) => readQuotaHistory(db, options)),
@@ -11,7 +11,7 @@ import { createHash, randomUUID } from 'node:crypto';
11
11
  import { resolvePluginData } from '../plugin-paths.mjs';
12
12
  import { usageRollupDayKey, isConversationUsageSource, num } from './usage-rollup.mjs';
13
13
  import { priceUsage } from './cost.mjs';
14
- import { rollupUsage } from './usage-ledger-rollup.mjs';
14
+ import { readSessionUsage, rollupUsage } from './usage-ledger-rollup.mjs';
15
15
  import { QUOTA_SCHEMA, readQuotaHistory, readQuotaWindows, recordQuotaSamples } from './usage-ledger-quota.mjs';
16
16
  import { SHARE_ENV } from 'node:worker_threads';
17
17
  import { createWorkerRequestClient } from '../worker-requests.mjs';
@@ -67,6 +67,9 @@ const COMPACT_SCHEMA = `
67
67
  -- table made a week of real records take ~400 ms instead of ~100 ms.
68
68
  CREATE INDEX IF NOT EXISTS usage_events_time_usage ON usage_events(
69
69
  ts,route,day,session,input,output,cache_read,cache_write,cost_usd,duration_ms);
70
+ -- One session's lifetime totals (the /context footer) read that session's
71
+ -- records only instead of scanning every record ever written.
72
+ CREATE INDEX IF NOT EXISTS usage_events_session ON usage_events(session);
70
73
  `;
71
74
 
72
75
  // Preserve the old read surface. Only storage changes: every field, including
@@ -542,6 +545,12 @@ export class UsageLedger {
542
545
  return this.workerRequest('quotaWindows', { options });
543
546
  }
544
547
 
548
+ /** Every request one session made under any of its ids, compactions included, read on the ledger worker. */
549
+ sessionUsageAsync(sessionIds) {
550
+ if (this.path === ':memory:') return Promise.resolve().then(() => readSessionUsage(this.db, sessionIds));
551
+ return this.workerRequest('sessionUsage', { sessionIds });
552
+ }
553
+
545
554
  preserveLegacyDays(days) {
546
555
  const insert = this.db.prepare('INSERT OR IGNORE INTO legacy_days VALUES (?,?)');
547
556
  this.db.exec('BEGIN IMMEDIATE');
@@ -1,4 +1,4 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { updateJsonAtomicSync } from './atomic-file.mjs';
@@ -95,6 +95,16 @@ function update(provider, mutate) {
95
95
  return normalizeLabels(result.providers[provider]);
96
96
  }
97
97
 
98
+ /**
99
+ * The provider-side session one account sends under: account-bound
100
+ * connection and delta state stays apart per account without changing the
101
+ * visible Mixdog session. Usage recorded before spend kept the visible id
102
+ * carries this id, so a session's past spend is found through it as well.
103
+ */
104
+ export function accountScopedSessionId(provider, accountId, sessionId) {
105
+ return `account-${createHash('sha256').update(`${provider}:${accountId}:${sessionId}`).digest('hex')}`;
106
+ }
107
+
98
108
  export function providerAccountPath(provider, id, dataDir = resolvePluginData()) {
99
109
  requireAccountProvider(provider);
100
110
  requireAccountId(id);
@@ -64,8 +64,8 @@ export function shellJobsStatus({ clientHostPid, sessionId } = {}) {
64
64
  }
65
65
 
66
66
  // Memory cycle L2 segment source. The daemon-hosted memory runtime writes
67
- // data/memory-cycle-state.json on every cycle start/finish (index.mjs
68
- // _writeCycleStateFile) — no HTTP call from the statusline path. Cached at
67
+ // data/memory-cycle-state.json on every cycle start/finish through the
68
+ // cycle-scheduler health ledger — no HTTP call from the statusline path. Cached at
69
69
  // the same 1s cadence as the shell segment. Shows a single unified "Memory"
70
70
  // segment: running (spinner + elapsed) or backlog warning (yellow count).
71
71
  let _memoryCycleSegmentCache = { at: 0, value: null };
@@ -102,10 +102,10 @@ async function refreshMemoryCycleStatus() {
102
102
  // drop the spinner immediately instead of waiting out the 10-minute
103
103
  // window. Pid-less markers (older daemons) keep the time-based guard.
104
104
  const ownerAlive = !Number(running?.pid) || isPidAlive(Number(running.pid));
105
- if (fresh && ownerAlive && running?.cycle && Number(running.started_at) > 0) {
105
+ if (fresh && ownerAlive && running?.cycle === 'cycle1' && Number(running.started_at) > 0) {
106
106
  value = { kind: 'running', startedAt: Number(running.started_at) };
107
107
  } else if (fresh) {
108
- const pending = Math.max(Number(backlog?.unchunked) || 0, Number(backlog?.cycle2_pending) || 0);
108
+ const pending = Number(backlog?.unchunked) || 0;
109
109
  if (pending > MEMORY_CYCLE_BACKLOG_WARN) value = { kind: 'backlog', pending };
110
110
  }
111
111
  }
@@ -252,7 +252,7 @@ export function isTranscriptCancelledStatusText(text) {
252
252
  // The flexible Result separator covers legacy rows and command bodies whose
253
253
  // embedded newlines lost their quote prefix during persistence.
254
254
  // `lenientWrapper: false` drops the shape-only wrapper guess, matching the TUI
255
- // rule in session-local.mjs: a directly typed or pasted prompt that merely
255
+ // rule in local-session-runtime.mjs: a directly typed or pasted prompt that merely
256
256
  // LOOKS like "instruction + Result: + quoted body" is real chat and must stay
257
257
  // visible. Only runtime-injected rows may be hidden on the loose shape.
258
258
  export function isInternalTranscriptDisplayText(text, { lenientWrapper = true } = {}) {