@songsid/agend 2.3.0-beta.7 → 2.3.0-beta.8

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 (55) hide show
  1. package/dist/channel/mcp-tools.js +1 -1
  2. package/dist/channel/mcp-tools.js.map +1 -1
  3. package/dist/daemon.d.ts +2 -0
  4. package/dist/daemon.js +29 -9
  5. package/dist/daemon.js.map +1 -1
  6. package/dist/general-knowledge/skills/web-appearance/SKILL.md +13 -10
  7. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +15 -8
  8. package/dist/instance-config-impact.js +1 -0
  9. package/dist/instance-config-impact.js.map +1 -1
  10. package/dist/outbound-handlers.js +4 -1
  11. package/dist/outbound-handlers.js.map +1 -1
  12. package/dist/outbound-schemas.js +1 -1
  13. package/dist/outbound-schemas.js.map +1 -1
  14. package/dist/settings-api.js +4 -0
  15. package/dist/settings-api.js.map +1 -1
  16. package/dist/settings-change.js +4 -0
  17. package/dist/settings-change.js.map +1 -1
  18. package/dist/settings-effect.js +2 -0
  19. package/dist/settings-effect.js.map +1 -1
  20. package/dist/ui/app-effects.js +5 -5
  21. package/dist/ui/chat-ambient.js +8 -7
  22. package/dist/ui/chat-effect-posters.js +3 -2
  23. package/dist/ui/chat-effects.js +19 -13
  24. package/dist/ui/chat-strings.js +4 -2
  25. package/dist/ui/chat-thread.js +1 -1
  26. package/dist/ui/fleet-usage.js +401 -0
  27. package/dist/ui/panel-chat.js +9 -4
  28. package/dist/ui/panel-fleet.js +6 -5
  29. package/dist/ui/panel-settings.js +6 -1
  30. package/dist/ui/settings-owner-effects.js +1 -1
  31. package/dist/ui/settings-strings.js +2 -0
  32. package/dist/ui/shared/app-route.js +1 -1
  33. package/dist/ui/shared/app.css +67 -0
  34. package/dist/ui/shared/effect-app-runtime.js +3 -3
  35. package/dist/ui/shared/effect-chat-runtime.js +1 -1
  36. package/dist/ui/shared/effect-policy.js +3 -4
  37. package/dist/ui/shared/effect-profiler.js +31 -9
  38. package/dist/ui/shared/effect-registry.js +9 -26
  39. package/dist/ui/shared/effect-stock-trust.js +2 -2
  40. package/dist/ui/shared/fx-motion.js +12 -3
  41. package/dist/ui/shared/usage-dialog.js +8 -0
  42. package/dist/ui/shared/view-strings.js +4 -0
  43. package/dist/usage-report.d.ts +12 -1
  44. package/dist/usage-report.js +39 -8
  45. package/dist/usage-report.js.map +1 -1
  46. package/dist/web-api.d.ts +1 -0
  47. package/dist/web-api.js +7 -2
  48. package/dist/web-api.js.map +1 -1
  49. package/dist/web-settings-patch.d.ts +3 -1
  50. package/dist/web-settings-patch.js +4 -1
  51. package/dist/web-settings-patch.js.map +1 -1
  52. package/dist/web-shell-routes.d.ts +1 -1
  53. package/dist/web-shell-routes.js +1 -1
  54. package/dist/web-shell-routes.js.map +1 -1
  55. package/package.json +1 -1
@@ -1341,6 +1341,73 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
1341
1341
  .ch-bar .btn { min-height: var(--tap); }
1342
1342
  }
1343
1343
 
1344
+ /* ── #2122: Fleet → Usage. A categorical palette kept apart from the state colours (Okabe–Ito hues, tuned per mode for
1345
+ contrast); every series also has a text label, so colour is never the only cue. ── */
1346
+ .us { --us-0: #56b4e9; --us-1: #e69f00; --us-2: #2bc48a; --us-3: #f0e442; --us-4: #e78ac3; --us-5: #94a3b8; --us-heat: #56b4e9;
1347
+ display: flex; flex-direction: column; gap: var(--s-3); min-width: 0; }
1348
+ @media (prefers-color-scheme: light) { :root:not([data-theme="dark"]) .us { --us-0: #0072b2; --us-1: #c25400; --us-2: #007a5a; --us-3: #8a6d00; --us-4: #b0457a; --us-5: #64748b; --us-heat: #0072b2; } }
1349
+ :root[data-theme="light"] .us { --us-0: #0072b2; --us-1: #c25400; --us-2: #007a5a; --us-3: #8a6d00; --us-4: #b0457a; --us-5: #64748b; --us-heat: #0072b2; }
1350
+ .us-bar-top { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-2) var(--s-3); margin-top: var(--s-2); }
1351
+ .us-group { display: inline-flex; align-items: center; gap: var(--s-2); font-size: var(--fs-sm); color: var(--text-2); }
1352
+ .us-group select, .us-custom input { min-height: 32px; padding: 0 var(--s-2); border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--bg); color: var(--text); }
1353
+ .us-group select:focus, .us-custom input:focus { border-color: var(--focus); box-shadow: 0 0 0 3px var(--accent-dim); outline: none; }
1354
+ .us-csv { margin-left: auto; }
1355
+ .us-custom { display: flex; flex-wrap: wrap; align-items: end; gap: var(--s-2) var(--s-3); font-size: var(--fs-sm); }
1356
+ .us-custom label { display: inline-flex; flex-direction: column; gap: 2px; color: var(--text-2); }
1357
+ .us-basis { font-size: var(--fs-sm); color: var(--text); padding: var(--s-2) var(--s-3); border-left: 3px solid var(--accent); background: var(--surface-2); border-radius: var(--r-sm); }
1358
+ .us-fresh { font-size: var(--fs-xs); }
1359
+ .us-partial { font-size: var(--fs-sm); padding: var(--s-2) var(--s-3); border-left: 3px solid var(--warn); background: var(--warn-bg); border-radius: var(--r-sm); }
1360
+ .us-unpriced { font-size: var(--fs-xs); }
1361
+ .us-scan { display: inline-flex; flex-wrap: wrap; align-items: center; gap: var(--s-2); }
1362
+ .us-totals { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--s-3); margin: 0; }
1363
+ .us-totals .ch-metric dd { font-size: var(--fs-lg); }
1364
+ .us-table { width: 100%; border-collapse: collapse; font-size: var(--fs-sm); font-variant-numeric: tabular-nums; }
1365
+ .us-table th, .us-table td { padding: var(--s-2); border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; }
1366
+ .us-table thead th { font-size: 11px; font-weight: var(--fw-regular); color: var(--text-3); }
1367
+ .us-table .num { text-align: right; white-space: nowrap; }
1368
+ .us-name { min-width: 0; max-width: 360px; font-weight: var(--fw-regular); }
1369
+ .us-name .mono { display: block; font-weight: var(--fw-semibold); overflow-wrap: anywhere; }
1370
+ .us-bar { display: block; width: 100%; height: 6px; margin: 4px 0; }
1371
+ .us-bar-bg { fill: var(--surface-2); } .us-bar-fg { fill: var(--us-0); }
1372
+ .us-meta { display: block; font-size: var(--fs-xs); color: var(--text-3); overflow-wrap: anywhere; }
1373
+ .us-flags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
1374
+ .us-trend, .us-heat { display: flex; flex-direction: column; gap: var(--s-2); min-width: 0; }
1375
+ .us-trend h3, .us-heat h3, .us-rest h3 { font-size: var(--fs-sm); font-weight: var(--fw-semibold); }
1376
+ .us-scroll { overflow-x: auto; max-width: 100%; }
1377
+ .us-chart, .us-heatmap { display: block; }
1378
+ .us-axis { font-size: 9px; fill: var(--text-3); }
1379
+ .us-s0 { fill: var(--us-0); background: var(--us-0); } .us-s1 { fill: var(--us-1); background: var(--us-1); } .us-s2 { fill: var(--us-2); background: var(--us-2); }
1380
+ .us-s3 { fill: var(--us-3); background: var(--us-3); } .us-s4 { fill: var(--us-4); background: var(--us-4); } .us-s5 { fill: var(--us-5); background: var(--us-5); }
1381
+ .us-legend { display: flex; flex-wrap: wrap; gap: var(--s-1) var(--s-4); list-style: none; padding: 0; margin: 0; font-size: var(--fs-xs); }
1382
+ .us-legend li { display: inline-flex; align-items: center; gap: var(--s-1); min-width: 0; overflow-wrap: anywhere; }
1383
+ .us-sw, .us-h-sw { display: inline-block; width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; }
1384
+ .us-h { fill: var(--us-heat); }
1385
+ .us-h.l1, .us-h-sw.l1 { opacity: 0.45; } .us-h.l2 { opacity: 0.65; } .us-h.l3 { opacity: 0.85; } .us-h.l4, .us-h-sw.l4 { opacity: 1; }
1386
+ .us-h-sw.l4 { background: var(--us-heat); }
1387
+ /* Read with nothing used: a solid grey cell. Not collected: an empty dashed outline. */
1388
+ .us-h.idle { fill: var(--text-3); opacity: 0.35; }
1389
+ .us-h-sw.idle { background: var(--text-3); opacity: 0.35; }
1390
+ .us-h.gap { fill: none; stroke: var(--text-3); stroke-width: 1; stroke-dasharray: 2 2; }
1391
+ .us-h-sw.gap { border: 1px dashed var(--text-3); }
1392
+ .us-grid, .us-base { stroke: var(--border); stroke-width: 1; }
1393
+ .us-grid { stroke-dasharray: 3 3; }
1394
+ .us-scale, .us-earlier { font-size: var(--fs-xs); }
1395
+ .us-heat-key { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-1); font-size: var(--fs-xs); }
1396
+ .us-rest .row-item { font-size: var(--fs-xs); }
1397
+ .u-by-agent { margin-left: auto; }
1398
+ @media (max-width: 699px) {
1399
+ .us-totals { grid-template-columns: repeat(2, minmax(0, 1fr)); }
1400
+ .us-csv { margin-left: 0; }
1401
+ .us-bar-top .btn { min-height: var(--tap); }
1402
+ /* Rows become cards: each value with its label. */
1403
+ .us-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
1404
+ .us-table, .us-table tbody, .us-table tr, .us-table th, .us-table td { display: block; width: 100%; }
1405
+ .us-table tr { margin-bottom: var(--s-3); padding: var(--s-3); border: 1px solid var(--border); border-radius: var(--r-lg); background: var(--bg); }
1406
+ .us-table th, .us-table td { border: 0; padding: 2px 0; max-width: none; }
1407
+ .us-table td { display: flex; justify-content: space-between; gap: var(--s-3); }
1408
+ .us-table td::before { content: attr(data-label); color: var(--text-3); text-align: left; }
1409
+ }
1410
+
1344
1411
  /* ── Chat commands and quick actions (#1269) ── */
1345
1412
  .hd-chip { margin-left: var(--s-2); padding: 0 var(--s-2); border: 1px solid var(--border); border-radius: var(--r-full); background: transparent; color: var(--text-2);
1346
1413
  font-size: var(--fs-xs); line-height: 20px; cursor: pointer; max-width: 16em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; }
@@ -31,7 +31,7 @@ export function createAppEffectRuntime({ window: host, current, observe, onChang
31
31
  const effects = s?.metadata?.effects ?? [], known = registry?.snapshot?.() ?? [];
32
32
  const rows = effects.map(p => {
33
33
  const b = s.bindings.get(p.id), row = known.find(r => b && r.key === b.key && r.id === p.id);
34
- if (row && !s.error && !playbackUnavailable) return row.reason == null && row.phase == null ? { ...row, reason: "calibrating" } : row;
34
+ if (row && !s.error && !playbackUnavailable) return row.reason == null && row.phase == null ? { ...row, phase: "loading" } : row;
35
35
  return { id: p.id, key: b?.key ?? null, verdict: "off", phase: null, quality: null,
36
36
  reason: s.error ?? playbackUnavailable ?? effectPlaybackReason(lastContext) ?? (!p.manifest.regions.includes("chat") ? "region_unavailable" : "runtime_unavailable") };
37
37
  });
@@ -141,7 +141,7 @@ export function createAppEffectRuntime({ window: host, current, observe, onChang
141
141
  if (identity.scope === "owner") trial = make("trialSource", createEffectTrialSource)({ window: host, current: () => {
142
142
  if (!alive()) return false; const v = observe(), c = v.context;
143
143
  return alive() && c.enabled === true && c.show === true && c.transportReady === true
144
- && c.safe === false && c.publicLink === false && c.reducedMotion === false && c.saveData === false && c.hidden === false;
144
+ && c.safe === false && c.publicLink === false && c.reducedMotion === false && c.hidden === false;
145
145
  }, ownerCurrent: (s, name) => sourceAlive(s) && s.name === name,
146
146
  stock: () => registry.frameRoot ? make("stock", stockEffectTrust)(host, { frameRoot: registry.frameRoot, ownerCss: observe().ownerCss }) : null,
147
147
  readResource: (spec, signal) => typeof loader.trialResourceSettled === "function" ? loader.trialResourceSettled(spec, signal) : Promise.resolve(null),
@@ -210,7 +210,7 @@ export function createAppEffectRuntime({ window: host, current, observe, onChang
210
210
  // Metadata describes placements, not executable resources. Its existing session/epoch/current and
211
211
  // shared settled-lane fences apply even when this viewer has declined playback. No code is fetched here.
212
212
  if (!pending) {
213
- const ownerNeeds = identity.scope === "owner" && !c.publicLink && c.enabled && c.show && c.transportReady && !c.safe && !c.reducedMotion && !c.saveData && !c.hidden && personal?.needsMetadata() === true, chatNeeds = sourceAlive(s) && !s.error && !s.metadata;
213
+ const ownerNeeds = identity.scope === "owner" && !c.publicLink && c.enabled && c.show && c.transportReady && !c.safe && !c.reducedMotion && !c.hidden && personal?.needsMetadata() === true, chatNeeds = sourceAlive(s) && !s.error && !s.metadata;
214
214
  if (!alive() || revision !== before || s !== source) { if (alive()) queueSync(); return; }
215
215
  if (ownerNeeds && (!chatNeeds || lastMetadata !== "owner")) void readPersonal();
216
216
  else if (chatNeeds) void read(s);
@@ -138,7 +138,7 @@ export function createChatEffectRuntime({ window: host, name, element, current,
138
138
  const stamp = JSON.stringify(ctx);
139
139
  if (stamp !== lastContext) { lastContext = stamp; registry.update(descriptors(), ctx); }
140
140
  emit();
141
- if (!metadata && !active && ctx.enabled && ctx.show && ctx.transportReady && !ctx.safe && !ctx.publicLink && !ctx.reducedMotion && !ctx.saveData && !ctx.hidden) void read();
141
+ if (!metadata && !active && ctx.enabled && ctx.show && ctx.transportReady && !ctx.safe && !ctx.publicLink && !ctx.reducedMotion && !ctx.hidden) void read();
142
142
  }
143
143
  async function read() {
144
144
  if (!alive() || error || active) return;
@@ -66,8 +66,8 @@ export const EFFECT_BLOCK_REASONS = Object.freeze([
66
66
 
67
67
  /** Diagnostic names only; the admission policy below still validates every required flag. */
68
68
  export function effectPlaybackReason(c) {
69
- if (!c || ["safe", "publicLink", "reducedMotion", "saveData", "hidden", "enabled", "show", "transportReady"].some(k => typeof c[k] !== "boolean")) return "gates_unknown";
70
- return c.safe ? "safe_mode" : c.reducedMotion ? "reduced_motion" : c.saveData ? "save_data"
69
+ if (!c || ["safe", "publicLink", "reducedMotion", "hidden", "enabled", "show", "transportReady"].some(k => typeof c[k] !== "boolean")) return "gates_unknown";
70
+ return c.safe ? "safe_mode" : c.reducedMotion ? "reduced_motion"
71
71
  : c.hidden ? "hidden" : !c.enabled ? "unsupported_host" : !c.show ? "disabled" : !c.transportReady ? "transport_unavailable" : null;
72
72
  }
73
73
 
@@ -78,12 +78,11 @@ export function allocateEffectFrames(descriptors, context) {
78
78
  const keys = new Set(), duplicate = new Set();
79
79
  for (const d of descriptors) if (d && typeof d.key === "string") { if (keys.has(d.key)) duplicate.add(d.key); keys.add(d.key); }
80
80
  const result = descriptors.map(d => ({ key: d?.key, verdict: "off", reason: "unknown", queuePos: null }));
81
- const gates = ["safe", "publicLink", "reducedMotion", "saveData", "hidden", "enabled", "show", "transportReady", "constrained", "modal"];
81
+ const gates = ["safe", "publicLink", "reducedMotion", "hidden", "enabled", "show", "transportReady", "constrained", "modal"];
82
82
  if (gates.some(k => typeof context[k] !== "boolean") || context.openInstance !== null && !instanceName(context.openInstance)) return result;
83
83
  let global = null;
84
84
  if (context.safe === true) global = "safe_mode";
85
85
  else if (context.reducedMotion === true) global = "reduced_motion";
86
- else if (context.saveData === true) global = "save_data";
87
86
  else if (context.hidden === true) global = "hidden";
88
87
  else if (context.enabled !== true) global = "unsupported_host";
89
88
  else if (context.show !== true) global = "disabled";
@@ -12,10 +12,14 @@ const id = v => typeof v === "string" && /^[0-9a-f]{32}$/.test(v);
12
12
  /** One host-owned mount. The registry must remove the frame when remove is true, never merely hide it. */
13
13
  export class EffectBudget {
14
14
  constructor({ device, baselineFps, clock = () => performance.now(), longTasksSupported = false } = {}) {
15
- if (!deviceClass(device) || !finite(baselineFps) || baselineFps <= 0 || baselineFps > 1000 || typeof clock !== "function" || typeof longTasksSupported !== "boolean") throw new TypeError("effect budget options");
15
+ if (!deviceClass(device) || baselineFps !== undefined && (!finite(baselineFps) || baselineFps <= 0 || baselineFps > 1000) || typeof clock !== "function" || typeof longTasksSupported !== "boolean") throw new TypeError("effect budget options");
16
16
  this.device = device;
17
- // Measured by the host while NO effects are mounted; never author-supplied or learned from a running effect.
18
- this.fpsFloor = Math.min(EFFECT_BUDGETS[device].fps, baselineFps * 0.9);
17
+ // Legacy host measurements may still be supplied by pure probes. Product playback starts
18
+ // immediately and learns cadence concurrently from host rAF, never Worker timing.
19
+ // Freeze before the first report: a mount's profile floor must remain coherent.
20
+ this.fpsFloor = baselineFps === undefined ? null : Math.min(EFFECT_BUDGETS[device].fps, baselineFps * 0.9);
21
+ this.cadence = [];
22
+ this.cadenceAt = null;
19
23
  this.clock = clock;
20
24
  this.longTasksSupported = longTasksSupported;
21
25
  this.last = null;
@@ -58,7 +62,16 @@ export class EffectBudget {
58
62
  frame() {
59
63
  const now = this.time();
60
64
  if (now === null) return this.stop("clock_unavailable");
61
- if (this.verdict === "paused" || this.hiddenAt !== null || now < this.epoch + 1000) return this.snapshot();
65
+ if (this.verdict === "paused" || this.hiddenAt !== null) return this.snapshot();
66
+ if (this.fpsFloor === null) {
67
+ const interval = this.cadenceAt === null ? null : now - this.cadenceAt;
68
+ this.cadenceAt = now;
69
+ if (interval !== null && interval >= 1 && interval <= 1000) {
70
+ if (this.cadence.length === 256) this.cadence.shift();
71
+ this.cadence.push(interval);
72
+ }
73
+ }
74
+ if (now < this.epoch + 1000) return this.snapshot();
62
75
  this.trim(now);
63
76
  if (this.frames.at(-1) !== now) {
64
77
  if (this.frames.length >= 2048) this.overflow = true;
@@ -88,10 +101,10 @@ export class EffectBudget {
88
101
  const now = this.time();
89
102
  if (now === null) return this.stop("clock_unavailable");
90
103
  if (!plain(raw)) return this.stop("gates_unknown");
91
- const { hidden, reducedMotion, saveData, show } = raw;
92
- if ([hidden, reducedMotion, saveData, show].some(v => typeof v !== "boolean")) return this.stop("gates_unknown");
104
+ const { hidden, reducedMotion, show } = raw;
105
+ if ([hidden, reducedMotion, show].some(v => typeof v !== "boolean")) return this.stop("gates_unknown");
93
106
  if (this.verdict === "paused") return this.snapshot();
94
- if (reducedMotion || saveData || !show) return this.stop(reducedMotion ? "reduced_motion" : saveData ? "save_data" : "viewer_off");
107
+ if (reducedMotion || !show) return this.stop(reducedMotion ? "reduced_motion" : "viewer_off");
95
108
  if (hidden && this.hiddenAt === null) this.hiddenAt = now;
96
109
  if (hidden && now - this.hiddenAt >= 10000) return this.stop("hidden");
97
110
  if (!hidden && this.hiddenAt !== null) {
@@ -105,6 +118,7 @@ export class EffectBudget {
105
118
  this.overflow = false;
106
119
  this.badWindows = 0;
107
120
  this.metrics = null;
121
+ this.cadence = []; this.cadenceAt = null;
108
122
  }
109
123
  return this.snapshot();
110
124
  }
@@ -123,8 +137,16 @@ export class EffectBudget {
123
137
  const hostMs = this.longTasksSupported ? sum(null) : null;
124
138
  const ownMs = this.longTasksSupported && this.tasks.some(t => t.attributed) ? sum(true) : null;
125
139
  const limits = EFFECT_BUDGETS[this.device];
126
- const bad = this.overflow || fps < this.fpsFloor || hostMs !== null && hostMs > limits.longTaskMsPerSecond;
127
- this.metrics = { at: now, hostFps: fps, hostLongTaskMsPerSecond: hostMs, attributedLongTaskMsPerSecond: ownMs,
140
+ if (this.fpsFloor === null && this.cadence.length >= 3) {
141
+ // Faster observed intervals distinguish intermittent jank from a genuinely slower
142
+ // display. This is a live reference, not an empty-page health certification.
143
+ const sorted = [...this.cadence].sort((a, b) => a - b);
144
+ const interval = sorted[Math.ceil(sorted.length * (sorted.length < 8 ? 0.5 : 0.2)) - 1];
145
+ this.fpsFloor = Math.min(limits.fps, 900 / interval);
146
+ this.cadence = []; this.cadenceAt = null;
147
+ }
148
+ const bad = this.overflow || this.fpsFloor === null || fps < this.fpsFloor || hostMs !== null && hostMs > limits.longTaskMsPerSecond;
149
+ this.metrics = this.fpsFloor === null ? null : { at: now, hostFps: fps, hostLongTaskMsPerSecond: hostMs, attributedLongTaskMsPerSecond: ownMs,
128
150
  attribution: ownMs === null ? "unknown" : "observed", fpsFloor: this.fpsFloor, windowMs: 2000 };
129
151
  this.overflow = false;
130
152
  if (bad) {
@@ -1,6 +1,5 @@
1
1
  /** #1629 B2: one host-owned Canvas Worker pool. Product adapters are deliberately separate. */
2
2
  import { allocateEffectFrames, effectEvent, effectPlaybackReason } from "./effect-policy.js";
3
- import { captureEffectBaseline } from "./effect-baseline.js";
4
3
  import { createOpaqueEffectFrame } from "./effect-frame.js";
5
4
  import { createEffectGeometry, hostEffectRect } from "./effect-geometry.js";
6
5
  import { effectStockSnapshot, effectStockAllows } from "./effect-stock-snapshot.js";
@@ -10,7 +9,7 @@ import { EffectBudget } from "./effect-profiler.js";
10
9
  const registries = new WeakMap();
11
10
  const plain = o => !!o && (Object.getPrototypeOf(o) === Object.prototype || Object.getPrototypeOf(o) === null);
12
11
  const finite = v => typeof v === "number" && Number.isFinite(v);
13
- const FLAGS = ["safe", "publicLink", "reducedMotion", "saveData", "hidden", "enabled", "show", "transportReady", "constrained", "modal"];
12
+ const FLAGS = ["safe", "publicLink", "reducedMotion", "hidden", "enabled", "show", "transportReady", "constrained", "modal"];
14
13
  const TYPES = ["state", "reply", "theme"];
15
14
  function unsafeSnapshotDescriptor(d) {
16
15
  if (!plain(d) || !plain(d.params ?? {})) return null;
@@ -41,7 +40,7 @@ function snapshotDescriptor(d) { try { return unsafeSnapshotDescriptor(d); } cat
41
40
  */
42
41
  export function createEffectRegistry({ window: host, origin, boot, load, safety, stockSnapshot, onChange = () => {}, onProfile = () => {}, onMount = () => {}, onRetire = () => {}, clock = () => host.performance.now() } = {}) {
43
42
  try { if (!host?.document || !host.document.body || registries.has(host) || typeof load !== "function" || typeof stockSnapshot !== "function" || typeof onChange !== "function" || typeof onProfile !== "function" || typeof onMount !== "function" || typeof onRetire !== "function" || typeof clock !== "function" || !safety || ["allowed", "begin", "finish", "pause", "resume"].some(k => typeof safety[k] !== "function") || ["requestAnimationFrame", "cancelAnimationFrame", "setTimeout", "clearTimeout", "MutationObserver", "ResizeObserver", "AbortController"].some(k => typeof host[k] !== "function")) return { ok: false, reason: "registry_unsupported" }; } catch { return { ok: false, reason: "registry_unsupported" }; }
44
- let dead = false, blocked = null, revision = 0, last = null, lastView = null, frameSerial = 0, context = null, descriptors = [], calibration = null, baseline = null, baselineDevice = null, serial = 0, raf = null, timer = null;
43
+ let dead = false, blocked = null, revision = 0, last = null, lastView = null, frameSerial = 0, context = null, descriptors = [], raf = null, timer = null;
45
44
  const slots = new Map(), loads = new Set(), paused = new Map(), offs = [], observed = new Set(), failures = new Map(), results = new Map();
46
45
  let readingStock = false;
47
46
  let root, geometry, observer, resize, longTasks = null, longTasksSupported = false;
@@ -119,7 +118,7 @@ export function createEffectRegistry({ window: host, origin, boot, load, safety,
119
118
  if (s.request) { s.request.controller.abort(); if (s.request.timer !== null) host.clearTimeout(s.request.timer); }
120
119
  // A closed/unknown viewer gate revokes paint, not the effect identity. Fresh known gates
121
120
  // may admit it again; resource failures and actual safety pauses retain their latches.
122
- if (!["disposed", "superseded", "hidden", "human_resume", "calibrating", "geometry_changed", "gates_unknown"].includes(why)) failures.set(s.d.key, { identity: signature(s.d), reason: why });
121
+ if (!["disposed", "superseded", "hidden", "human_resume", "geometry_changed", "gates_unknown"].includes(why)) failures.set(s.d.key, { identity: signature(s.d), reason: why });
123
122
  s.stageOff?.(); s.stageOff = null;
124
123
  s.frame?.remove(why); s.geometry?.remove();
125
124
  if (s.loadOff) s.loadOff();
@@ -138,12 +137,11 @@ export function createEffectRegistry({ window: host, origin, boot, load, safety,
138
137
  retire(s, why);
139
138
  if (revision !== proposed || dead !== wasDead) return false;
140
139
  }
141
- if (calibration) { calibration.cancel(); calibration = null; }
142
140
  return true;
143
141
  }
144
142
  function gatesKnown() { return context && FLAGS.every(k => typeof context[k] === "boolean") && (context.openInstance === null || typeof context.openInstance === "string" && context.openInstance.length >= 1 && context.openInstance.length <= 128); }
145
143
  function eligible(d = null) {
146
- return gatesKnown() && !context.safe && !context.reducedMotion && !context.saveData && context.enabled && context.show && context.transportReady
144
+ return gatesKnown() && !context.safe && !context.reducedMotion && context.enabled && context.show && context.transportReady
147
145
  && (!d || !context.publicLink || d.region === "chat" && !d.trialStage);
148
146
  }
149
147
  const globalAllowed = (d = null) => eligible(d) && host.document.visibilityState === "visible";
@@ -221,10 +219,9 @@ export function createEffectRegistry({ window: host, origin, boot, load, safety,
221
219
  if (!chosen.has(s.d.key) || !d || signature(d) !== signature(s.d) || d.element !== s.d.element || s.device !== device()) retire(s, "superseded");
222
220
  if (dead || proposal !== revision) return;
223
221
  }
224
- if (chosen.size === 0) { if (calibration) { calibration.cancel(); calibration = null; } baseline = null; baselineDevice = null; tell(); return; }
222
+ if (chosen.size === 0) { tell(); return; }
225
223
  if (hidden) {
226
224
  hideGeometry();
227
- if (calibration) { calibration.cancel(); calibration = null; }
228
225
  for (const s of [...slots.values()]) {
229
226
  if (!s.frame) retire(s, "hidden");
230
227
  else { if (s.hiddenSince === null) { s.hiddenSince = last; s.healthySince = null; } if (s.budget.gates({ hidden: true, reducedMotion: false, saveData: false, show: true }).remove) retire(s, "hidden"); }
@@ -238,20 +235,6 @@ export function createEffectRegistry({ window: host, origin, boot, load, safety,
238
235
  if (dead || proposal !== revision) return;
239
236
  }
240
237
  try { observe(); } catch { stopAll("trust_geometry_unknown"); tell(); return; }
241
- if (baseline === null || baselineDevice !== device()) {
242
- if (calibration && baselineDevice === device()) { tell(); return; }
243
- if (!stopAll("calibrating") || dead || proposal !== revision) return;
244
- const epoch = ++serial;
245
- const job = captureEffectBaseline({ window: host, empty: () => !dead && slots.size === 0 && host.document.querySelectorAll("iframe.agend-effect-frame").length === 0 && globalAllowed() && !context.hidden && !!coverage(), clock: nativeClock });
246
- calibration = job; baselineDevice = device();
247
- job.promise.then(r => {
248
- if (dead || calibration !== job || epoch !== serial) return;
249
- calibration = null;
250
- if (!r.ok) { blocked = r.reason; tell(); return; }
251
- baseline = r.baselineFps; reconcile();
252
- });
253
- tell(); return;
254
- }
255
238
  const max = context.constrained ? 1 : 3;
256
239
  for (const d of descriptors) {
257
240
  if (!chosen.has(d.key) || slots.has(d.key)) continue;
@@ -321,7 +304,7 @@ export function createEffectRegistry({ window: host, origin, boot, load, safety,
321
304
  if (d.trialStage && !local || !current(s)) { if (current(s)) retire(s, "admission_revoked"); return; }
322
305
  if (local) fresh = local.rect;
323
306
  s.rect = fresh;
324
- s.budget = new EffectBudget({ device: s.device, baselineFps: baseline, clock: nativeClock, longTasksSupported });
307
+ s.budget = new EffectBudget({ device: s.device, clock: nativeClock, longTasksSupported });
325
308
  const frame = createOpaqueEffectFrame({ window: host, root: local?.root ?? root, origin, boot, clock: nativeClock,
326
309
  render: { code: resource.code, params: resource.params, assets: resource.assets, wants: resource.wants, instance: d.instance, width: fresh.width, height: fresh.height, dpr: dpr(s.device) },
327
310
  onReady() { if (!current(s)) return; if (!globalAllowed(d) || context.hidden || !coverage(d)) { retire(s, "hidden"); tell(); return; } const n = time(); if (n === null) { blocked = "clock_unavailable"; stopAll(blocked); tell(); return; } s.ready = true; s.readyAt = n; s.healthySince = n; publishEnv(s); schedule(); tell(); },
@@ -511,11 +494,11 @@ export function createEffectRegistry({ window: host, origin, boot, load, safety,
511
494
  revalidateSafety,
512
495
  /** Called only by the host's explicit human Resume UI. A descriptor update never clears this latch. */
513
496
  resume(key) {
514
- if (dead || reentrantStock() || blocked && !blocked.startsWith("baseline_") || typeof key !== "string") return false;
497
+ if (dead || reentrantStock() || blocked || typeof key !== "string") return false;
515
498
  const proposed = revision, d = descriptors.find(d => d.key === key); if (!d || d.trialStage && !coverage(d) || revision !== proposed || receipt(safety.resume, d.id, device()) !== true || revision !== proposed) return false;
516
499
  revision++;
517
500
  if (!stopAll("human_resume")) return false;
518
- if (blocked && !blocked.startsWith("baseline_")) return false; blocked = null; paused.delete(key); failures.delete(key); baseline = null; serial++; reconcile(); return true;
501
+ if (blocked) return false; paused.delete(key); failures.delete(key); reconcile(); return true;
519
502
  },
520
503
  /** Host-only content-free signal API. Raw pointer publishing is refused; native pointer metadata is coalesced here. */
521
504
  publish(type, fields, instance = null) {
@@ -531,7 +514,7 @@ export function createEffectRegistry({ window: host, origin, boot, load, safety,
531
514
  return sent;
532
515
  },
533
516
  dispose() {
534
- if (dead) return; dead = true; revision++; serial++; stopAll("disposed");
517
+ if (dead) return; dead = true; revision++; stopAll("disposed");
535
518
  if (raf !== null) host.cancelAnimationFrame(raf); if (timer !== null) host.clearTimeout(timer);
536
519
  observer.disconnect(); resize.disconnect(); longTasks?.disconnect(); for (const off of offs) off();
537
520
  geometry.dispose(); root.remove(); observed.clear(); if (loads.size === 0 && registries.get(host) === api) registries.delete(host);
@@ -11,7 +11,7 @@ const SPECS = [
11
11
  ["confirmation", ".shell > .pending-stack, .shell .p-settings > .panel-body > .col > .pending-stack.inline"], ["preview", ".shell .chat-split > .pv-panel, .shell .chat-split > .pv-divider"],
12
12
  ["preview-menu", ".shell .thread details.pv-menu"], ["palette", ".shell .dock .cmd-palette"],
13
13
  ["drop", ".shell .p-chat > .drop-overlay"],
14
- ["thread-control", ".shell .p-chat .jump-latest, .shell .thread .msg-tools, .shell .thread .st-more, .shell .thread .cb-head, .shell .thread .cb-more, .shell .thread .tc-head, .shell .thread .tc-more, .shell .thread .pv-head, .shell .thread .pv-note, .shell .thread .pv-banner, .shell .thread .effect-trial-offer, .shell .thread .click-chip"],
14
+ ["thread-control", ".shell .chat-main > .amb-note, .shell .p-chat .jump-latest, .shell .thread .msg-tools, .shell .thread .st-more, .shell .thread .cb-head, .shell .thread .cb-more, .shell .thread .tc-head, .shell .thread .tc-more, .shell .thread .pv-head, .shell .thread .pv-note, .shell .thread .pv-banner, .shell .thread .effect-trial-offer, .shell .thread .click-chip"],
15
15
  ["link", ".shell .thread .md a, .shell .thread .st-text > a.st-link, .shell .dock .prompts .txt > a.st-link"], ["attachment", ".shell .thread .atts"], ["media", ".shell .thread .att-media"],
16
16
  ["panel-navigation", ".shell .p-fleet > .seg, .shell .p-settings > .seg"],
17
17
  ["panel-content", ".shell .p-fleet > .panel-body, .shell .p-needs > .panel-body, .shell .p-settings > .panel-body, .shell .p-log > .panel-body, .shell .p-details > .panel-body, .shell .p-view > .panel-body, .shell .p-view > .v-term, .shell .p-view > .v-id, .shell .p-view > .v-card"],
@@ -20,7 +20,7 @@ const SPECS = [
20
20
  ];
21
21
  export const STOCK_TRUST_INVENTORY = Object.freeze(SPECS.map(([kind, selector]) => Object.freeze({ kind, selector })));
22
22
  const STRUCTURAL = ["html", "body", "#app", "#app > .shell", ".shell > .main", ".main > .p-chat", ".p-chat > .chat-split", ".chat-split > .chat-main"];
23
- const CONTENT = [".main > .conn", ".p-chat > .panel-head", ".p-chat > .chat-card", ".p-chat > .aw-chat_header", ".chat-main > .scroller", ".chat-main > .dock", ".sidebar > .side-head", ".sidebar > .side-nav", ".sidebar > .side-section", ".sidebar > .side-widget", ".sidebar > .side-foot", ".u-rail > .u-rail-head", ".u-rail > .u-rail-body"];
23
+ const CONTENT = [".main > .conn", ".p-chat > .panel-head", ".p-chat > .chat-card", ".p-chat > .aw-chat_header", ".chat-main > .amb-note", ".chat-main > .scroller", ".chat-main > .dock", ".sidebar > .side-head", ".sidebar > .side-nav", ".sidebar > .side-section", ".sidebar > .side-widget", ".sidebar > .side-foot", ".u-rail > .u-rail-head", ".u-rail > .u-rail-body"];
24
24
  // Closed producer/topology inventory. Bare Outlet loading/error panels never become an effect surface.
25
25
  const PANELS = Object.freeze({
26
26
  fleet: { required: [".panel-head", ".seg", ".panel-body"], optional: [] },
@@ -5,19 +5,27 @@ export const FX_FOLLOW_REDUCED_MOTION_KEY = "agend_fx_follow_reduced_motion";
5
5
  /** The one-time chat note "your system asks for reduced motion; effects are on" was shown on this device. */
6
6
  export const FX_MOTION_NOTE_KEY = "agend_fx_motion_note_seen";
7
7
  const listeners = new Set();
8
+ let pageChoice = null, noteShown = false;
8
9
 
9
10
  /** Whether this device asked effects to follow the system's reduced-motion setting (default: no). */
10
11
  export function followsReducedMotion() {
12
+ if (pageChoice !== null) return pageChoice;
11
13
  try { return globalThis.localStorage.getItem(FX_FOLLOW_REDUCED_MOTION_KEY) === "1"; } catch { return false; }
12
14
  }
13
15
  export function setFollowsReducedMotion(on) {
16
+ pageChoice = !!on;
14
17
  try { if (on) globalThis.localStorage.setItem(FX_FOLLOW_REDUCED_MOTION_KEY, "1"); else globalThis.localStorage.removeItem(FX_FOLLOW_REDUCED_MOTION_KEY); } catch { /* this page only */ }
15
18
  for (const fn of [...listeners]) { try { fn(); } catch { /* a listener's own */ } }
16
19
  }
17
20
  /** `fn()` when the switch or the system's answer moves (this tab, another tab, the OS). Returns the unsubscribe. */
18
21
  export function onMotionChange(fn) {
19
22
  listeners.add(fn);
20
- const onStorage = (e) => { if (!e || e.key === FX_FOLLOW_REDUCED_MOTION_KEY || e.key === null) fn(); };
23
+ const onStorage = (e) => {
24
+ if (!e || e.key === FX_FOLLOW_REDUCED_MOTION_KEY || e.key === null) {
25
+ pageChoice = e?.key === FX_FOLLOW_REDUCED_MOTION_KEY ? e.newValue === "1" : null;
26
+ fn();
27
+ }
28
+ };
21
29
  let q = null;
22
30
  try { q = typeof globalThis.matchMedia === "function" ? globalThis.matchMedia("(prefers-reduced-motion: reduce)") : null; } catch { q = null; }
23
31
  try { globalThis.addEventListener && globalThis.addEventListener("storage", onStorage); } catch { /* none */ }
@@ -37,10 +45,11 @@ export function systemReducesMotion(win = globalThis) {
37
45
  * note was not shown here before. Showing it marks it shown.
38
46
  */
39
47
  export function motionNoteDue(win = globalThis) {
40
- if (followsReducedMotion() || !systemReducesMotion(win)) return false;
41
- try { return globalThis.localStorage.getItem(FX_MOTION_NOTE_KEY) !== "1"; } catch { return false; }
48
+ if (noteShown || followsReducedMotion() || !systemReducesMotion(win)) return false;
49
+ try { return globalThis.localStorage.getItem(FX_MOTION_NOTE_KEY) !== "1"; } catch { return true; }
42
50
  }
43
51
  export function markMotionNoteShown() {
52
+ noteShown = true;
44
53
  try { globalThis.localStorage.setItem(FX_MOTION_NOTE_KEY, "1"); } catch { /* shown again next time: harmless */ }
45
54
  }
46
55
 
@@ -4,6 +4,7 @@
4
4
  import { html, useEffect, useLayoutEffect, useMemo, useRef, useState } from "./app-html.js";
5
5
  import { t } from "./app-i18n.js";
6
6
  import { useLease } from "./app-ctx.js";
7
+ import { guestPage } from "./app-store.js";
7
8
  import { readStream } from "./read-stream.js";
8
9
  import { Dialog } from "./ui-dialog.js";
9
10
  import { ErrorState, Skeleton } from "./ui-states.js";
@@ -159,11 +160,18 @@ export function UsageBody({ owner }) {
159
160
  ${!p.stale && p.hint ? html`<p class="note">${usageText(p.hint, p.hintI18n)}</p>` : !(p.metrics || []).length ? html`<p class="note">${tn("usage.no_data")}</p>` : null}`}
160
161
  </section>`)}
161
162
  <div class="u-foot"><span class="note" title=${new Date(data.fetchedAt).toLocaleString()}>${updatedAgo(data.fetchedAt)}${data.refreshing ? html` · <span class="u-refreshing">${tn("usageRefreshing")}</span>` : null}</span>
163
+ ${byAgent() ? html`<a class="btn btn-ghost btn-sm u-by-agent" href="/ui/fleet/usage" title=${tn("usageByAgentTitle")}>${tn("usageByAgent")}</a>` : null}
162
164
  <button type="button" class="btn btn-sm" onClick=${() => load(true)}>${tn("usageRefresh")}</button></div>`;
163
165
  }
164
166
  return content;
165
167
  }
166
168
 
169
+ /**
170
+ * #2122: the plans above are per login; each agent's own tokens and API-equivalent are Fleet → Usage — linked from the
171
+ * app (not View's anonymous reader, not a guest, who has no Fleet).
172
+ */
173
+ const byAgent = () => !guestPage() && typeof location !== "undefined" && location.pathname.startsWith("/ui");
174
+
167
175
  /** `owner`: the navigation that opened it — its reads and the minute's timer live and end with it. */
168
176
  export function UsageDialog({ onClose, owner = "" }) {
169
177
  return html`<${Dialog} title=${tn("usage.title")} onClose=${onClose} wide=${true}><${UsageBody} owner=${`usage-dialog:${owner}`} /></${Dialog}>`;
@@ -40,6 +40,8 @@ register("view", {
40
40
  "usageMinutes": "{n} min",
41
41
  "usageHours": "{n} h",
42
42
  "usageRefresh": "Refresh",
43
+ "usageByAgent": "By agent →",
44
+ "usageByAgentTitle": "Each agent's tokens and API-equivalent (Fleet → Usage)",
43
45
  "usageHigh": "high",
44
46
  "usageNear": "near limit",
45
47
  "usageMoveUp": "Move up",
@@ -248,6 +250,8 @@ register("view", {
248
250
  "usageMinutes": "{n} 分鐘",
249
251
  "usageHours": "{n} 小時",
250
252
  "usageRefresh": "重新整理",
253
+ "usageByAgent": "依 agent →",
254
+ "usageByAgentTitle": "每個 agent 的 token 與 API 等值(Fleet → 用量)",
251
255
  "usageHigh": "偏高",
252
256
  "usageNear": "接近上限",
253
257
  "usageMoveUp": "上移",
@@ -22,7 +22,10 @@ export type UsageRange = {
22
22
  };
23
23
  /** What every amount is, in words — the page, the CSV and the agent tool all carry it. */
24
24
  export declare const usageBasis: (checked?: string) => string;
25
- /** Daily rows returned at most (the CSV and the trend); beyond it the report says it is truncated. */
25
+ /**
26
+ * Daily rows returned at most (the CSV and the trend): one per day, source and model. Beyond it the report keeps the
27
+ * latest whole days and says from which date (`dailyFrom`); the rows and totals always cover the whole range.
28
+ */
26
29
  export declare const USAGE_DAILY_MAX = 20000;
27
30
  export interface UsageInstanceInput {
28
31
  name: string;
@@ -54,6 +57,8 @@ export interface UsageInput {
54
57
  pendingBytes: number;
55
58
  pendingFiles: number;
56
59
  };
60
+ /** Daily rows at most (default USAGE_DAILY_MAX; tests lower it to reach the same path). */
61
+ dailyMax?: number;
57
62
  }
58
63
  export interface UsageTotals {
59
64
  /** Uncached input. */
@@ -111,7 +116,13 @@ export interface UsageReport {
111
116
  rows: UsageRow[];
112
117
  totals: UsageRow;
113
118
  daily: UsageDaily[];
119
+ /**
120
+ * The daily rows (and the messages by day) are not the whole range: they start at `dailyFrom` (earlier days were too
121
+ * many rows to send), or — `dailyFrom` null — there are none at all (the latest day alone was more than the cap: a
122
+ * part of a day is never sent as if it were the day). The rows and totals always cover the whole range.
123
+ */
114
124
  dailyTruncated: boolean;
125
+ dailyFrom: string | null;
115
126
  messagesDaily: Array<{
116
127
  date: string;
117
128
  instance: string;
@@ -4,7 +4,10 @@ export const USAGE_GROUPS = ["instance", "backend", "model", "profile", "team"];
4
4
  export const USAGE_RANGES = ["today", "7d", "month", "all"];
5
5
  /** What every amount is, in words — the page, the CSV and the agent tool all carry it. */
6
6
  export const usageBasis = (checked = PRICES_CHECKED) => `API-equivalent at list price (checked ${checked}) — not money spent`;
7
- /** Daily rows returned at most (the CSV and the trend); beyond it the report says it is truncated. */
7
+ /**
8
+ * Daily rows returned at most (the CSV and the trend): one per day, source and model. Beyond it the report keeps the
9
+ * latest whole days and says from which date (`dailyFrom`); the rows and totals always cover the whole range.
10
+ */
8
11
  export const USAGE_DAILY_MAX = 20_000;
9
12
  const M = 1_000_000;
10
13
  const DATE = /^\d{4}-\d{2}-\d{2}$/;
@@ -102,7 +105,8 @@ export function buildUsageReport(input) {
102
105
  const newRow = (key) => ({ key, ...zero(), total: 0, messages: null, amount: null, priced: "none", flags: [], unpriced: [], instances: [], backends: [], models: [], profiles: [], _amount: 0, _pricedAny: false, _unpricedAny: false });
103
106
  const uniq = (list, v) => { if (!list.includes(v))
104
107
  list.push(v); };
105
- const daily = [];
108
+ // Daily rows: one per day, zone, agent, backend, model, tier and profile (an hour's slices summed into their day).
109
+ const dailyBy = new Map();
106
110
  for (const s of slices) {
107
111
  const key = keyOf(s);
108
112
  const row = rows.get(key) ?? (rows.set(key, newRow(key)), rows.get(key));
@@ -124,11 +128,36 @@ export function buildUsageReport(input) {
124
128
  uniq(row.flags, "cache_write_not_reported");
125
129
  if (unknownSource(s))
126
130
  uniq(row.flags, "source_unknown");
127
- if (daily.length <= USAGE_DAILY_MAX) {
128
- const t = zero();
129
- addTotals(t, s.tot);
130
- daily.push({ date: s.date, zone: s.zone, instance: s.instance, backend: s.backend, model: s.model, tier: s.long ? "long" : "standard", profile: s.profile, ...t, amount, flags: [...(s.writesReported ? [] : ["cache_write_not_reported"]), ...(amount === null ? ["not_priced"] : []), ...(unknownSource(s) ? ["source_unknown"] : [])] });
131
+ const tier = s.long ? "long" : "standard";
132
+ const dk = [s.date, s.zone, s.instance, s.backend, s.model, tier, s.profile].join("\u0000");
133
+ let d = dailyBy.get(dk);
134
+ if (!d) {
135
+ d = { date: s.date, zone: s.zone, instance: s.instance, backend: s.backend, model: s.model, tier, profile: s.profile, ...zero(), amount: amount === null ? null : 0,
136
+ flags: [...(s.writesReported ? [] : ["cache_write_not_reported"]), ...(amount === null ? ["not_priced"] : []), ...(unknownSource(s) ? ["source_unknown"] : [])] };
137
+ dailyBy.set(dk, d);
138
+ }
139
+ addTotals(d, s.tot);
140
+ if (amount !== null && d.amount !== null)
141
+ d.amount += amount;
142
+ }
143
+ const allDaily = [...dailyBy.values()].sort((a, b) => a.date.localeCompare(b.date) || a.instance.localeCompare(b.instance) || a.model.localeCompare(b.model) || a.zone.localeCompare(b.zone));
144
+ // Too many: the latest whole days that fit — never part of a day (a missing row would read as nothing used). Not even
145
+ // the latest day fits: no daily rows at all, said so (dailyTruncated with no dailyFrom).
146
+ let dailyFrom = null, daily = allDaily, truncated = false;
147
+ const cap = input.dailyMax ?? USAGE_DAILY_MAX;
148
+ if (allDaily.length > cap) {
149
+ truncated = true;
150
+ const perDate = new Map();
151
+ for (const d of allDaily)
152
+ perDate.set(d.date, (perDate.get(d.date) ?? 0) + 1);
153
+ let kept = 0;
154
+ for (const date of [...perDate.keys()].sort().reverse()) {
155
+ if (kept + perDate.get(date) > cap)
156
+ break;
157
+ kept += perDate.get(date);
158
+ dailyFrom = date;
131
159
  }
160
+ daily = dailyFrom === null ? [] : allDaily.filter(d => d.date >= dailyFrom);
132
161
  }
133
162
  // Messages: delivered, per instance and hour (§5.4); placed on days the same way as tokens.
134
163
  const messagesDaily = new Map();
@@ -187,8 +216,10 @@ export function buildUsageReport(input) {
187
216
  return {
188
217
  range: { kind: input.range.kind, from, to }, zone: input.zone, granularity: "hour", basis: usageBasis(), pricesChecked: PRICES_CHECKED,
189
218
  group: input.group, rows: out, totals,
190
- daily: daily.slice(0, USAGE_DAILY_MAX), dailyTruncated: daily.length > USAGE_DAILY_MAX,
191
- messagesDaily: [...messagesDaily.values()].sort((a, b) => a.date.localeCompare(b.date) || a.instance.localeCompare(b.instance)),
219
+ daily, dailyTruncated: truncated, dailyFrom,
220
+ // Messages by day: the same window as the daily rows (none when there are none); their total stays in `totals`.
221
+ messagesDaily: [...messagesDaily.values()].filter(m => !truncated || (dailyFrom !== null && m.date >= dailyFrom))
222
+ .sort((a, b) => a.date.localeCompare(b.date) || a.instance.localeCompare(b.instance)),
192
223
  notMeasured, shared, coverage, scanning: input.scanning,
193
224
  };
194
225
  }