crewforth 3.1.0-rc.3 → 3.1.0-rc.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -9
- package/README.md +1 -1
- package/kit/AGENT_TEMPLATE.md +26 -17
- package/kit/CLAUDE.md +11 -0
- package/kit/agents/crew-backend-expert.md +5 -0
- package/kit/agents/crew-commit-agent.md +8 -3
- package/kit/agents/crew-database-expert.md +5 -0
- package/kit/agents/crew-devops-expert.md +5 -0
- package/kit/agents/crew-frontend-expert.md +5 -0
- package/kit/agents/crew-performance-expert.md +5 -0
- package/kit/agents/crew-planner.md +5 -0
- package/kit/agents/crew-privacy-agent.md +7 -2
- package/kit/agents/crew-review-agent.md +5 -0
- package/kit/agents/crew-security-expert.md +8 -4
- package/kit/agents/crew-session-manager.md +7 -3
- package/kit/agents/crew-test-expert.md +5 -0
- package/kit/eval/doctor.sh +23 -7
- package/kit/eval/smoke-test.sh +377 -27
- package/kit/hooks/context-usage.sh +1 -1
- package/kit/hooks/guard-agent-model.sh +392 -0
- package/kit/hooks/guard-bash.sh +61 -5
- package/kit/hooks/guard-schedule.sh +92 -0
- package/kit/hooks/prompt-approval.sh +68 -21
- package/kit/settings.json +22 -0
- package/kit/skills/crew-approve/SKILL.md +26 -0
- package/kit/studio/server/index.js +1 -1
- package/kit/studio/server/lib/graph.js +127 -10
- package/kit/studio/server/lib/usage.js +96 -25
- package/kit/studio/web/app.js +49 -6
- package/kit/studio/web/canvas.js +195 -30
- package/kit/studio/web/chat.js +8 -1
- package/kit/studio/web/convo.js +5 -1
- package/kit/studio/web/graph-plan.js +139 -6
- package/kit/studio/web/inspect.js +33 -1
- package/kit/studio/web/list.js +8 -2
- package/kit/studio/web/names.js +65 -0
- package/kit/studio/web/style.css +65 -6
- package/kit/studio/web/timeline-plan.js +8 -0
- package/kit/studio/web/timeline.js +10 -3
- package/kit/studio/web/usage.js +53 -12
- package/package.json +1 -1
|
@@ -22,9 +22,12 @@ import { typeOf, roleName, shownType } from './names.js';
|
|
|
22
22
|
export const SIZE = {
|
|
23
23
|
padX: 24, padY: 16, // the canvas's own margin
|
|
24
24
|
colGap: 56, // between one depth and the next
|
|
25
|
+
waveGap: 148, // between one wave and the next: room for the words on an inferred wire
|
|
25
26
|
rowGap: 12, // between siblings
|
|
26
|
-
session: { w: 200, h: 84 },
|
|
27
|
-
card: { w: 248, h:
|
|
27
|
+
session: { w: 200, h: 84, line: 20 }, // `line`: the extra line a session with background commands has
|
|
28
|
+
card: { w: 248, h: 84 }, // an agent: its name, its task, and a line of facts (model, time, tokens)
|
|
29
|
+
stack: { w: 248, h: 116, tight: 64 }, // a group drawn as one card: its name, its bar, its totals, its models;
|
|
30
|
+
// `tight` at compact density, where the two lines of totals go
|
|
28
31
|
chip: { w: 248, h: 36 }, // an agent at compact density
|
|
29
32
|
group: { // an open group: a container holding its members
|
|
30
33
|
padX: 8, top: 10, head: 20, gap: 6, bar: 4, bottom: 8,
|
|
@@ -48,7 +51,7 @@ export const AUTO = {
|
|
|
48
51
|
stackAt: 3,
|
|
49
52
|
};
|
|
50
53
|
|
|
51
|
-
export const GROUPINGS = ['run', 'type', 'parent', 'none'];
|
|
54
|
+
export const GROUPINGS = ['run', 'type', 'parent', 'order', 'none'];
|
|
52
55
|
export const DENSITIES = ['auto', 'comfortable', 'compact'];
|
|
53
56
|
|
|
54
57
|
// The handful of things a status means to a reader. `killed` and `stopped` come off the transcript verbatim and
|
|
@@ -152,7 +155,10 @@ function itemsOf(nodes, { group, density, seq, stack, aside }) {
|
|
|
152
155
|
const session = nodes.find((n) => n.kind === 'session');
|
|
153
156
|
if (session) items.push({ id: session.id, kind: 'session', node: session, parent: null, depth: 0 });
|
|
154
157
|
|
|
155
|
-
|
|
158
|
+
// Agents of one type under one parent are one card under `type` and under `run`, from two and wherever they
|
|
159
|
+
// stand in the order: three Privacy agents called fifth, eighth and ninth are the same thing three times.
|
|
160
|
+
// Under `parent` they fold only when the picture is too tall; under `none`, never.
|
|
161
|
+
const stackAt = group === 'type' || group === 'run' ? 2 : (density === 'auto' && group !== 'none' && stack ? AUTO.stackAt : Infinity);
|
|
156
162
|
|
|
157
163
|
// `anchor` is the item a node's wire comes out of; `via` is the member row inside it, when the parent is one.
|
|
158
164
|
const place = (parentId, anchor, via, depth) => {
|
|
@@ -215,6 +221,23 @@ function itemsOf(nodes, { group, density, seq, stack, aside }) {
|
|
|
215
221
|
return items;
|
|
216
222
|
}
|
|
217
223
|
|
|
224
|
+
/**
|
|
225
|
+
* What a group's members add up to: time, new tokens, tokens read from the cache, and the estimate. A member with
|
|
226
|
+
* no usage read adds nothing; the cost is null when any member's is, so a sum never passes for the whole.
|
|
227
|
+
*/
|
|
228
|
+
export function sumOf(members) {
|
|
229
|
+
const sum = { durationMs: 0, fresh: 0, cacheRead: 0, cost: 0, timed: 0, counted: 0 };
|
|
230
|
+
for (const m of members ?? []) {
|
|
231
|
+
if (m.durationMs != null) { sum.durationMs += m.durationMs; sum.timed += 1; }
|
|
232
|
+
if (!m.usage) continue;
|
|
233
|
+
sum.counted += 1;
|
|
234
|
+
sum.fresh += m.usage.tokens?.fresh ?? 0;
|
|
235
|
+
sum.cacheRead += m.usage.tokens?.cacheRead ?? 0;
|
|
236
|
+
sum.cost = sum.cost === null || m.usage.cost == null ? null : sum.cost + m.usage.cost;
|
|
237
|
+
}
|
|
238
|
+
return sum;
|
|
239
|
+
}
|
|
240
|
+
|
|
218
241
|
function groupItem(id, type, members, anchor, via, depth, seq, extra) {
|
|
219
242
|
return {
|
|
220
243
|
id,
|
|
@@ -227,10 +250,16 @@ function groupItem(id, type, members, anchor, via, depth, seq, extra) {
|
|
|
227
250
|
via,
|
|
228
251
|
depth,
|
|
229
252
|
seq: Math.min(...members.map((m) => seq.get(m.id) ?? 0), Infinity),
|
|
253
|
+
// The group stands where its first member does in the order the session called its agents.
|
|
254
|
+
first: members.filter((m) => m.order != null).sort((a, b) => a.order - b.order)[0] ?? null,
|
|
255
|
+
sum: sumOf(members),
|
|
230
256
|
...extra,
|
|
231
257
|
};
|
|
232
258
|
}
|
|
233
259
|
|
|
260
|
+
/** A session's card: one line taller while it has commands running in the background, and never wider. */
|
|
261
|
+
export const sessionHeight = (n) => SIZE.session.h + (n?.backgroundNow?.length ? SIZE.session.line : 0);
|
|
262
|
+
|
|
234
263
|
/* ----------------------------------------------------------------- plan --- */
|
|
235
264
|
|
|
236
265
|
/**
|
|
@@ -248,6 +277,7 @@ function groupItem(id, type, members, anchor, via, depth, seq, extra) {
|
|
|
248
277
|
export function plan(nodes, opts = {}) {
|
|
249
278
|
const group = GROUPINGS.includes(opts.group) ? opts.group : 'run';
|
|
250
279
|
const asked = DENSITIES.includes(opts.density) ? opts.density : 'auto';
|
|
280
|
+
if (group === 'order') return planOrder(nodes, asked, opts);
|
|
251
281
|
const seq = opts.seq ?? sequence(nodes);
|
|
252
282
|
const open = opts.open ?? new Map();
|
|
253
283
|
const pinned = opts.pinned ?? new Map();
|
|
@@ -280,7 +310,7 @@ export function plan(nodes, opts = {}) {
|
|
|
280
310
|
/** Size and place everything drawn, at one density. Returns the box the picture needs. */
|
|
281
311
|
const lay = (drawnItems, compact) => {
|
|
282
312
|
for (const it of drawnItems) {
|
|
283
|
-
if (it.kind === 'session') { it.w = SIZE.session.w; it.h =
|
|
313
|
+
if (it.kind === 'session') { it.w = SIZE.session.w; it.h = sessionHeight(it.node); continue; }
|
|
284
314
|
if (it.kind !== 'group') {
|
|
285
315
|
const sz = compact ? SIZE.chip : SIZE.card;
|
|
286
316
|
it.w = sz.w; it.h = sz.h; it.compact = compact;
|
|
@@ -289,7 +319,8 @@ export function plan(nodes, opts = {}) {
|
|
|
289
319
|
it.open = isOpen(it);
|
|
290
320
|
// `boxed` is the group drawn as a container with its members inside. Open beside itself it stays a card.
|
|
291
321
|
it.boxed = it.open && !it.aside;
|
|
292
|
-
|
|
322
|
+
it.compact = compact;
|
|
323
|
+
if (!it.boxed) { it.w = SIZE.stack.w; it.h = compact ? SIZE.stack.tight : SIZE.stack.h; it.cells = null; continue; }
|
|
293
324
|
const n = it.members.length;
|
|
294
325
|
const grid = compact || n > G.rowsMax;
|
|
295
326
|
const cols = grid ? Math.min(G.colsMax, Math.max(1, Math.ceil(n / G.rowsMax))) : 1;
|
|
@@ -422,6 +453,108 @@ export function plan(nodes, opts = {}) {
|
|
|
422
453
|
};
|
|
423
454
|
}
|
|
424
455
|
|
|
456
|
+
/* ---------------------------------------------------------------- order --- */
|
|
457
|
+
|
|
458
|
+
// What the wire from an agent to a later wave is called, and what it is: a reading of the order of events.
|
|
459
|
+
export const INFERRED_NOTE = 'Inferred from the order of events: this wave was called after that agent reported. It is not a call from one agent to another; the session calls every agent.';
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* The same agents by when the session called them.
|
|
463
|
+
*
|
|
464
|
+
* Every agent is called by the session, so the tree is one level deep and says nothing of sequence. Here a
|
|
465
|
+
* column is a WAVE: the agents called in one assistant message, which is what "started together" means. Waves
|
|
466
|
+
* run left to right in the order the messages were written, and a wave's agents stack in the order of the calls.
|
|
467
|
+
* Agents the session's transcript did not call (a workflow's) have no wave and take a last column of their own.
|
|
468
|
+
*
|
|
469
|
+
* Two kinds of wire. The session is wired to the first wave: it called it. A later wave is wired, dotted, from
|
|
470
|
+
* each agent that reported after the wave before it was called and before this one was — an inference from the
|
|
471
|
+
* order of events, drawn as one and labelled as one. A wave nothing reported before has no wire.
|
|
472
|
+
*/
|
|
473
|
+
function planOrder(nodes, asked, opts) {
|
|
474
|
+
const pinned = opts.pinned ?? new Map();
|
|
475
|
+
const session = nodes.find((n) => n.kind === 'session') ?? null;
|
|
476
|
+
const sessionH = sessionHeight(session);
|
|
477
|
+
const agents = nodes.filter((n) => n.kind === 'agent');
|
|
478
|
+
const called = agents.filter((n) => n.wave != null);
|
|
479
|
+
const waveIds = [...new Set(called.map((n) => n.wave))].sort((a, b) => a - b);
|
|
480
|
+
const byOrder = (a, b) => (a.order ?? 0) - (b.order ?? 0) || String(a.id).localeCompare(String(b.id));
|
|
481
|
+
const columns = waveIds.map((w) => ({ wave: w, members: called.filter((n) => n.wave === w).sort(byOrder) }));
|
|
482
|
+
for (const c of columns) c.calledAt = Math.min(...c.members.map((m) => m.calledAt ?? Infinity));
|
|
483
|
+
const rest = agents.filter((n) => n.wave == null)
|
|
484
|
+
.sort((a, b) => (a.startedAt ?? Infinity) - (b.startedAt ?? Infinity) || String(a.id).localeCompare(String(b.id)));
|
|
485
|
+
if (rest.length) columns.push({ wave: null, members: rest, calledAt: Infinity });
|
|
486
|
+
|
|
487
|
+
const lay = (compact) => {
|
|
488
|
+
const sz = compact ? SIZE.chip : SIZE.card;
|
|
489
|
+
const items = [];
|
|
490
|
+
const x0 = SIZE.padX + (session ? SIZE.session.w + SIZE.colGap : 0);
|
|
491
|
+
let height = 0;
|
|
492
|
+
columns.forEach((col, i) => {
|
|
493
|
+
col.members.forEach((n, j) => {
|
|
494
|
+
items.push({
|
|
495
|
+
id: n.id, kind: 'agent', node: n, parent: null, via: null, depth: i + 1, seq: n.order ?? j, compact,
|
|
496
|
+
wave: col.wave, w: sz.w, h: sz.h, x: x0 + i * (sz.w + SIZE.waveGap), y: SIZE.padY + j * (sz.h + SIZE.rowGap),
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
height = Math.max(height, SIZE.padY * 2 + col.members.length * (sz.h + SIZE.rowGap) - SIZE.rowGap);
|
|
500
|
+
});
|
|
501
|
+
const width = x0 + Math.max(0, columns.length * (sz.w + SIZE.waveGap) - SIZE.waveGap) + SIZE.padX;
|
|
502
|
+
return { items, width, height };
|
|
503
|
+
};
|
|
504
|
+
let compact = asked === 'compact';
|
|
505
|
+
let box = lay(compact);
|
|
506
|
+
if (asked === 'auto' && box.height > AUTO.tallest) { compact = true; box = lay(true); }
|
|
507
|
+
const { items } = box;
|
|
508
|
+
let { height } = box;
|
|
509
|
+
|
|
510
|
+
let sessionY = opts.sessionY ?? null;
|
|
511
|
+
if (session) {
|
|
512
|
+
const first = items.filter((it) => it.depth === 1);
|
|
513
|
+
if (sessionY == null) {
|
|
514
|
+
const bottom = first.length ? Math.max(...first.map((it) => it.y + it.h)) : SIZE.padY + sessionH;
|
|
515
|
+
sessionY = Math.max(SIZE.padY, Math.round((SIZE.padY + bottom) / 2 - sessionH / 2));
|
|
516
|
+
}
|
|
517
|
+
items.unshift({ id: session.id, kind: 'session', node: session, parent: null, depth: 0, w: SIZE.session.w, h: sessionH, x: SIZE.padX, y: sessionY });
|
|
518
|
+
height = Math.max(height, sessionY + sessionH + SIZE.padY);
|
|
519
|
+
}
|
|
520
|
+
for (const it of items) {
|
|
521
|
+
const p = pinned.get(it.id);
|
|
522
|
+
if (p) { it.x = p.x; it.y = p.y; it.pinned = true; }
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const at = new Map(items.map((it) => [it.id, it]));
|
|
526
|
+
const edges = [];
|
|
527
|
+
const wire = (from, to, extra) => edges.push({
|
|
528
|
+
key: `${from.id}\u0000${to.id}`, source: from.id, target: to.id,
|
|
529
|
+
from: { x: from.x + from.w, y: from.y + from.h / 2 }, to: { x: to.x, y: to.y + to.h / 2 },
|
|
530
|
+
status: to.node.status ?? 'unknown', state: stateOf(to.node.status), ...extra,
|
|
531
|
+
});
|
|
532
|
+
const waves = columns.filter((c) => c.wave != null);
|
|
533
|
+
if (session && waves.length) for (const m of waves[0].members) wire(at.get(session.id), at.get(m.id), {});
|
|
534
|
+
// An agent's report belongs to the first wave called after it: the one that could have been waiting for it.
|
|
535
|
+
for (let i = 1; i < waves.length; i += 1) {
|
|
536
|
+
const since = waves[i - 1].calledAt;
|
|
537
|
+
const reporters = called.filter((n) => n.reportedAt != null && n.reportedAt > since && n.reportedAt <= waves[i].calledAt
|
|
538
|
+
&& n.wave < waves[i].wave).sort(byOrder);
|
|
539
|
+
waves[i].after = reporters;
|
|
540
|
+
for (const r of reporters) {
|
|
541
|
+
waves[i].members.forEach((m, k) => wire(at.get(r.id), at.get(m.id), {
|
|
542
|
+
kind: 'inferred', state: 'inferred',
|
|
543
|
+
// Said once for a reporter, on the wire to the wave's first agent; the others carry it on hover.
|
|
544
|
+
label: k === 0 ? `after ${shownType(r)} reported` : null,
|
|
545
|
+
title: `after ${shownType(r)} reported\n${INFERRED_NOTE}`,
|
|
546
|
+
}));
|
|
547
|
+
}
|
|
548
|
+
for (const m of waves[i].members) at.get(m.id).after = reporters.map((r) => shownType(r));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return {
|
|
552
|
+
items, edges, width: Math.max(box.width, SIZE.padX * 2), height: Math.max(height, SIZE.padY * 2),
|
|
553
|
+
drawn: agents.length, density: compact ? 'compact' : 'comfortable', group: 'order', stacked: false, aside: false,
|
|
554
|
+
waves: waves.length, sessionY,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
425
558
|
/**
|
|
426
559
|
* Which open groups to fold when a session first opens too tall even as chips: the largest first, until the
|
|
427
560
|
* picture is short enough to read or there is nothing left to fold. Returns the ids to fold; an empty list when
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Functions of the server's data, with no page in them: the four tiles, what the agent is doing right now, which
|
|
4
4
|
// skills it applied, who delegated to it. The rule they share is the panel's own: a value that was not read is
|
|
5
5
|
// written "Not measured", with why — never as 0 and never as an empty list.
|
|
6
|
-
import { roleName } from './names.js';
|
|
6
|
+
import { roleName, modelName, modelFamily, modelOf } from './names.js';
|
|
7
7
|
|
|
8
8
|
const NOT_MEASURED = 'Not measured';
|
|
9
9
|
|
|
@@ -100,6 +100,38 @@ export function backgroundLines(session, now) {
|
|
|
100
100
|
}));
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* What the inspector says about an agent's model, as lines: { label, text, goto }.
|
|
105
|
+
*
|
|
106
|
+
* Two facts are kept apart: what the call ASKED for (its own `model` field; none means the agent's default) and
|
|
107
|
+
* what the agent RAN on (the model its own transcript names). Then, when the transcript shows them: the call the
|
|
108
|
+
* model gate refused first, and the same work repeated one model up after a report that closed `confidence: low`.
|
|
109
|
+
* `goto` is the id of the other agent a line is about, for a line that leads somewhere.
|
|
110
|
+
*/
|
|
111
|
+
export function modelLines(n, nodes) {
|
|
112
|
+
if (n?.kind !== 'agent') return [];
|
|
113
|
+
const m = modelOf(n);
|
|
114
|
+
const lines = [
|
|
115
|
+
{ label: 'Ran on', text: n.model ? `${m.ran} \u00b7 ${n.model}` : 'not recorded' },
|
|
116
|
+
{ label: 'Asked for', text: n.modelAsked ? `${n.modelAsked}${m.differs ? ' \u2014 differs from what ran' : ''}` : 'no model named (the agent\'s default)' },
|
|
117
|
+
];
|
|
118
|
+
// The family is the word the choice is made in (haiku, sonnet, opus); a model with none keeps its own name.
|
|
119
|
+
const short = (model) => (model ? (modelFamily(model) ?? modelName(model).toLowerCase()) : 'no model');
|
|
120
|
+
if (n.firstTry) {
|
|
121
|
+
lines.push({
|
|
122
|
+
label: 'Model gate',
|
|
123
|
+
text: `first asked: ${short(n.firstTry.asked)} \u2192 refused${n.firstTry.floor ? ` (floor: ${n.firstTry.floor})` : ''} \u2192 ${short(n.modelAsked ?? n.model)}`,
|
|
124
|
+
note: n.firstTry.text,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
const tagOf = (id) => { const o = (nodes ?? []).find((x) => x.id === id); return o?.order != null ? ` (#${o.order})` : ''; };
|
|
128
|
+
if (n.escalatedFrom) {
|
|
129
|
+
lines.push({ label: 'Escalated', text: `escalated: ${short(n.escalatedFrom.from)} \u2192 ${short(n.escalatedFrom.to)}, after${tagOf(n.escalatedFrom.id)} closed "confidence: low"`, goto: n.escalatedFrom.id });
|
|
130
|
+
}
|
|
131
|
+
if (n.escalatedTo) lines.push({ label: 'Escalated', text: `closed "confidence: low"; repeated one model up${tagOf(n.escalatedTo)}`, goto: n.escalatedTo });
|
|
132
|
+
return lines;
|
|
133
|
+
}
|
|
134
|
+
|
|
103
135
|
/** Who handed this agent its work: the node it hangs from. */
|
|
104
136
|
export function delegatedBy(n, nodes) {
|
|
105
137
|
const p = (nodes ?? []).find((x) => x.id === n.parentId) ?? null;
|
package/kit/studio/web/list.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// own three answers, so in this view the approval dock is not needed and is not shown.
|
|
5
5
|
import { sections, wants } from './list-plan.js';
|
|
6
6
|
import { Press } from './press.js';
|
|
7
|
-
import { hoverOf } from './names.js';
|
|
7
|
+
import { hoverOf, modelOf } from './names.js';
|
|
8
8
|
import { remaining, asker, allowSessionLabel, VERDICTS } from './approvals.js';
|
|
9
9
|
|
|
10
10
|
function mk(tag, cls, text) {
|
|
@@ -147,8 +147,14 @@ export class List {
|
|
|
147
147
|
if (tile) row.append(tile);
|
|
148
148
|
const text = mk('span', 'ls-text');
|
|
149
149
|
text.append(mk('span', 'ls-name', r.type), mk('span', 'ls-line', r.line));
|
|
150
|
-
|
|
150
|
+
const mdl = modelOf(r.node);
|
|
151
|
+
row.title = [hoverOf(r.node), mdl.ran ? mdl.title : null].filter(Boolean).join('\n');
|
|
151
152
|
row.append(text);
|
|
153
|
+
if (mdl.ran) {
|
|
154
|
+
const badge = mk('span', 'sub ls-model', `${mdl.ran}${mdl.differs ? ' \u2260' : ''}`);
|
|
155
|
+
badge.title = mdl.title;
|
|
156
|
+
row.append(badge);
|
|
157
|
+
}
|
|
152
158
|
if (r.aside) row.append(mk('span', 'sub ls-aside', r.aside));
|
|
153
159
|
const chev = mk('span', 'ls-chev');
|
|
154
160
|
chev.innerHTML = '<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M6 4l4 4-4 4"/></svg>';
|
package/kit/studio/web/names.js
CHANGED
|
@@ -31,3 +31,68 @@ export const shownType = (n) => roleName(typeOf(n));
|
|
|
31
31
|
|
|
32
32
|
/** What hovering an agent says: the type as it is declared, and its task in full, since both are cut on screen. */
|
|
33
33
|
export const hoverOf = (n) => [n?.agentType ?? null, n?.description ?? null].filter(Boolean).join('\n');
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Where an agent stands among the ones the session called, as a tag: "#3 · 09:14". The number is the order of
|
|
37
|
+
* the calls; the time is when the call was made, in the viewer's own zone. An agent the session's transcript did
|
|
38
|
+
* not call has no place to state, and gets no tag.
|
|
39
|
+
* @param clock (ms) => "HH:MM"
|
|
40
|
+
*/
|
|
41
|
+
export function orderTag(n, clock = null) {
|
|
42
|
+
if (n?.order == null) return '';
|
|
43
|
+
return `#${n.order}${clock && n.calledAt != null ? ` · ${clock(n.calledAt)}` : ''}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* ---------------------------------------------------------------- models --- */
|
|
47
|
+
|
|
48
|
+
const MODEL_FAMILIES = ['haiku', 'sonnet', 'opus', 'fable', 'mythos'];
|
|
49
|
+
|
|
50
|
+
/** The family a model belongs to, from its id or from the short name a call asks with: 'sonnet'. Null when none. */
|
|
51
|
+
export function modelFamily(model) {
|
|
52
|
+
if (typeof model !== 'string') return null;
|
|
53
|
+
return model.toLowerCase().split(/[^a-z]+/).find((w) => MODEL_FAMILIES.includes(w)) ?? null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A model as a badge says it: `claude-haiku-5-5` → Haiku 5.5 · `claude-haiku-4-5-20251001` → Haiku 4.5 ·
|
|
58
|
+
* `sonnet` → Sonnet. The vendor prefix and a snapshot date go; the words lead and the numbers follow with a dot.
|
|
59
|
+
* A name that is not made of such parts is returned as it came.
|
|
60
|
+
*/
|
|
61
|
+
export function modelName(model) {
|
|
62
|
+
if (typeof model !== 'string' || !model) return '';
|
|
63
|
+
const parts = model.replace(/^claude-/, '').replace(/-\d{8}$/, '').split('-');
|
|
64
|
+
if (!parts.every((p) => /^[a-z]+$/.test(p) || /^\d+$/.test(p))) return model;
|
|
65
|
+
const words = parts.filter((p) => /^[a-z]+$/.test(p)).map((w) => w[0].toUpperCase() + w.slice(1));
|
|
66
|
+
const nums = parts.filter((p) => /^\d+$/.test(p));
|
|
67
|
+
return [words.join(' '), nums.join('.')].filter(Boolean).join(' ');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What a node ran on and what its call asked for, and whether the two agree.
|
|
72
|
+
* A call asks with a family ("sonnet") or with an id; it agrees with what ran when the family is the same and,
|
|
73
|
+
* if it named an id, the id is. A call that named nothing asked for the agent's default, and agrees with anything.
|
|
74
|
+
* @returns { ran, asked, differs, title } — `ran` and `asked` are badge words, '' when not known
|
|
75
|
+
*/
|
|
76
|
+
export function modelOf(n) {
|
|
77
|
+
const ran = modelName(n?.model);
|
|
78
|
+
const asked = n?.modelAsked ? modelName(n.modelAsked) : '';
|
|
79
|
+
const differs = Boolean(n?.model && n?.modelAsked)
|
|
80
|
+
&& (modelFamily(n.model) !== modelFamily(n.modelAsked) || (n.modelAsked.includes('-') && n.modelAsked !== n.model));
|
|
81
|
+
const title = [
|
|
82
|
+
`Ran on: ${n?.model ?? 'not recorded'}`,
|
|
83
|
+
`Asked for: ${n?.modelAsked ?? 'no model named (the agent\'s default)'}`,
|
|
84
|
+
differs ? 'The two differ.' : null,
|
|
85
|
+
].filter(Boolean).join('\n');
|
|
86
|
+
return { ran, asked, differs, title };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** "2 Sonnet, 1 Opus": how many of a group's agents ran on each family, most first. */
|
|
90
|
+
export function modelMix(members) {
|
|
91
|
+
const counts = new Map();
|
|
92
|
+
for (const m of members ?? []) {
|
|
93
|
+
const f = modelFamily(m.model);
|
|
94
|
+
const word = f ? f[0].toUpperCase() + f.slice(1) : (m.model ? modelName(m.model) : null);
|
|
95
|
+
if (word) counts.set(word, (counts.get(word) ?? 0) + 1);
|
|
96
|
+
}
|
|
97
|
+
return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([w, c]) => `${c} ${w}`).join(', ');
|
|
98
|
+
}
|
package/kit/studio/web/style.css
CHANGED
|
@@ -836,6 +836,42 @@ button.tl-lab { cursor: pointer; }
|
|
|
836
836
|
}
|
|
837
837
|
|
|
838
838
|
/* Working. The brightest and the only one that travels. */
|
|
839
|
+
/* A wire that is an inference, not a call: dotted, still, and it can be hovered for what it means. A working wire
|
|
840
|
+
is dashed and moves; this one is neither. */
|
|
841
|
+
.cv-edge[data-kind="inferred"] {
|
|
842
|
+
stroke: var(--ink-faint);
|
|
843
|
+
stroke-dasharray: 1.5 5;
|
|
844
|
+
pointer-events: stroke;
|
|
845
|
+
}
|
|
846
|
+
.cv-edge-label { font-size: 10px; fill: var(--ink-faint); pointer-events: none; }
|
|
847
|
+
/* "#3 · 09:14": where a card stands in the order the session called its agents. */
|
|
848
|
+
.cv-under { display: flex; align-items: baseline; min-width: 0; }
|
|
849
|
+
/* The model a card ran on: a small badge, whole. What follows it gives way. */
|
|
850
|
+
.cv-model {
|
|
851
|
+
flex: none; padding: 0 5px; border-radius: var(--radius-sm); background: var(--surface-raised);
|
|
852
|
+
font-size: 10px; line-height: 16px; color: var(--ink-dim); white-space: nowrap;
|
|
853
|
+
}
|
|
854
|
+
.cv-model[hidden] { display: none; }
|
|
855
|
+
.cv-model[data-differs="true"] { color: var(--status-waiting); }
|
|
856
|
+
.cv-third { gap: 6px; }
|
|
857
|
+
.cv-facts { flex: 1 1 auto; min-width: 0; font-size: 11px; line-height: 16px; color: var(--ink-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
858
|
+
.cv-totals { font-variant-numeric: tabular-nums; }
|
|
859
|
+
/* The same badge outside the graph: a Timeline row, a List row, a delegation card. */
|
|
860
|
+
.tl-model, .ls-model, .deleg-model {
|
|
861
|
+
flex: none; padding: 0 5px; margin-right: 6px; border-radius: var(--radius-sm); background: var(--surface-raised);
|
|
862
|
+
font-size: 10px; line-height: 16px; color: var(--ink-dim); white-space: nowrap;
|
|
863
|
+
}
|
|
864
|
+
.tl-model[hidden] { display: none; }
|
|
865
|
+
.imodel { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 12px; }
|
|
866
|
+
.imodel dt { color: var(--ink-faint); }
|
|
867
|
+
.imodel dd { margin: 0; word-break: break-word; }
|
|
868
|
+
.ilink { border: 0; padding: 0; background: none; font: inherit; color: var(--studio-accent); cursor: pointer; text-decoration: underline; }
|
|
869
|
+
.cv-mix { color: var(--ink-faint); }
|
|
870
|
+
.cv-under .cv-task { flex: 1 1 auto; min-width: 0; }
|
|
871
|
+
.cv-order { flex: none; margin-left: 6px; font-family: var(--font-mono); font-size: 10px; color: var(--ink-faint); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
872
|
+
.cv-order[hidden] { display: none; }
|
|
873
|
+
.cv-chip .cv-order, .cv-member .cv-order { margin-left: 0; margin-right: 6px; }
|
|
874
|
+
|
|
839
875
|
.cv-edge[data-state="live"] {
|
|
840
876
|
stroke: var(--status-busy);
|
|
841
877
|
opacity: var(--cv-edge-live);
|
|
@@ -946,7 +982,7 @@ button.tl-lab { cursor: pointer; }
|
|
|
946
982
|
.cv-row { display: flex; align-items: center; gap: var(--space-2); min-width: 0; }
|
|
947
983
|
.cv-fill { flex: 1; min-width: 0; }
|
|
948
984
|
.cv-indent { padding-left: 32px; }
|
|
949
|
-
.cv-title { font-size: 13px; font-weight: 600; }
|
|
985
|
+
.cv-title { font-size: 13px; font-weight: 600; flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
950
986
|
.cv-name {
|
|
951
987
|
min-width: 0; font-family: var(--font-mono); font-size: 12px; line-height: 16px; color: var(--ink);
|
|
952
988
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
@@ -955,7 +991,12 @@ button.tl-lab { cursor: pointer; }
|
|
|
955
991
|
min-width: 0; font-size: 13px; line-height: 18px; color: var(--ink-dim);
|
|
956
992
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
957
993
|
}
|
|
958
|
-
.
|
|
994
|
+
/* Nothing a card says may run out of it. Every piece of text in a card is one line that gives way with an
|
|
995
|
+
ellipsis; the pieces that must stay whole (the status pill, the tile, the order tag) are short by construction
|
|
996
|
+
and do not shrink. */
|
|
997
|
+
.cv-sub { font-size: 12px; line-height: 16px; color: var(--ink-dim); white-space: nowrap; flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
998
|
+
.cv-sub[hidden] { display: none; }
|
|
999
|
+
.cv-node > .cv-row, .cv-node > .cv-under, .cv-node > .cv-name, .cv-node > .cv-sub { max-width: 100%; }
|
|
959
1000
|
.cv-word { font-size: 11px; color: var(--ink-dim); white-space: nowrap; flex: none; }
|
|
960
1001
|
|
|
961
1002
|
/* The tile says whose agent it is. Crewforth's mark keeps its own colours; the
|
|
@@ -1075,10 +1116,27 @@ button.tl-lab { cursor: pointer; }
|
|
|
1075
1116
|
}
|
|
1076
1117
|
.cv-legend[hidden] { display: none; }
|
|
1077
1118
|
/* The session's summary is the same kind of box, in the opposite corner. */
|
|
1078
|
-
.cv-spend { right: auto; bottom: auto; left: var(--space-6); top: var(--space-6); width:
|
|
1079
|
-
.cv-spend-rows {
|
|
1080
|
-
.cv-spend-
|
|
1081
|
-
|
|
1119
|
+
.cv-spend { right: auto; bottom: auto; left: var(--space-6); top: var(--space-6); width: 240px; }
|
|
1120
|
+
.cv-spend-rows { display: flex; flex-direction: column; gap: 2px; font-size: 12px; min-width: 0; }
|
|
1121
|
+
.cv-spend-row {
|
|
1122
|
+
display: flex; align-items: baseline; gap: var(--space-3); min-width: 0; width: 100%;
|
|
1123
|
+
padding: 2px 4px; margin: 0 -4px; border: 0; border-radius: var(--radius-sm);
|
|
1124
|
+
background: none; font: inherit; color: inherit; text-align: left; cursor: default;
|
|
1125
|
+
}
|
|
1126
|
+
.cv-spend-label { flex: 1 1 auto; min-width: 0; color: var(--ink-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1127
|
+
.cv-spend-value { flex: none; font-family: var(--font-mono); font-variant-numeric: tabular-nums; color: var(--ink); white-space: nowrap; }
|
|
1128
|
+
/* A row that opens: it looks like something to press, and says so with a mark that turns. */
|
|
1129
|
+
.cv-spend-more { cursor: pointer; }
|
|
1130
|
+
.cv-spend-more:hover { background: var(--surface-raised); }
|
|
1131
|
+
.cv-spend-more .cv-spend-label::before { content: '\203A'; display: inline-block; width: 10px; color: var(--ink-faint); }
|
|
1132
|
+
.cv-spend-more[aria-expanded="true"] .cv-spend-label::before { rotate: 90deg; }
|
|
1133
|
+
.cv-spend-detail {
|
|
1134
|
+
margin: 0 0 var(--space-1) 10px; padding-left: var(--space-2); border-left: 1px solid var(--line);
|
|
1135
|
+
display: grid; grid-template-columns: 1fr auto; gap: 2px var(--space-3);
|
|
1136
|
+
}
|
|
1137
|
+
.cv-spend-detail[hidden] { display: none; }
|
|
1138
|
+
.cv-spend-detail dt { color: var(--ink-faint); }
|
|
1139
|
+
.cv-spend-detail dd { margin: 0; font-family: var(--font-mono); font-variant-numeric: tabular-nums; text-align: right; color: var(--ink-dim); }
|
|
1082
1140
|
.cv-legend-head { display: flex; align-items: center; justify-content: space-between; }
|
|
1083
1141
|
.cv-legend-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-1) var(--space-2); }
|
|
1084
1142
|
.cv-legend-item { display: flex; align-items: center; gap: var(--space-2); font-size: 12px; color: var(--ink-dim); white-space: nowrap; }
|
|
@@ -1086,6 +1144,7 @@ button.tl-lab { cursor: pointer; }
|
|
|
1086
1144
|
.cv-legend-line[data-state="live"] { border-top: 1.5px dashed var(--status-busy); }
|
|
1087
1145
|
.cv-legend-line[data-state="waiting"] { border-top: 1.5px dashed var(--status-waiting); }
|
|
1088
1146
|
.cv-legend-line[data-state="failed"] { border-top-color: var(--cv-fail); }
|
|
1147
|
+
.cv-legend-line[data-state="inferred"] { border-top: 1.5px dotted var(--ink-faint); }
|
|
1089
1148
|
.cv-x {
|
|
1090
1149
|
width: 20px; height: 20px; padding: 0; display: inline-flex; align-items: center; justify-content: center;
|
|
1091
1150
|
background: none; border: 0; border-radius: var(--radius-sm); color: var(--ink-dim); cursor: pointer;
|
|
@@ -120,6 +120,8 @@ export function groupsOf(nodes, grouping) {
|
|
|
120
120
|
return { id: `parent:${p.id}`, label: p.kind === 'workflow' ? (p.workflowId ?? p.id) : `under ${shownType(p)}` };
|
|
121
121
|
}
|
|
122
122
|
if (grouping === 'none') return { id: 'all', label: null };
|
|
123
|
+
// `order`: the agents called in one assistant message are a wave, and a wave is a group.
|
|
124
|
+
if (grouping === 'order') return n.wave != null ? { id: `wave:${n.wave}`, label: `Wave ${n.wave}` } : { id: 'wave:none', label: 'Not called by the session' };
|
|
123
125
|
// `run`: a workflow run is a group; everything the session started itself is another.
|
|
124
126
|
if (n.workflow) return { id: `run:${n.workflow}`, label: String(n.workflow) };
|
|
125
127
|
return { id: 'direct', label: 'Session · direct' };
|
|
@@ -135,6 +137,12 @@ export function groupsOf(nodes, grouping) {
|
|
|
135
137
|
for (const g of out) g.members.sort((a, b) => (a.startedAt ?? Infinity) - (b.startedAt ?? Infinity) || String(a.id).localeCompare(String(b.id)));
|
|
136
138
|
// What the session did itself comes first; the rest in the order they began.
|
|
137
139
|
out.sort((a, b) => Number(b.id === 'direct') - Number(a.id === 'direct') || first(a) - first(b) || a.id.localeCompare(b.id));
|
|
140
|
+
// Waves are in the order they were called, which is not always the order their agents first wrote.
|
|
141
|
+
if (grouping === 'order') {
|
|
142
|
+
const wave = (g) => g.members[0].wave ?? Infinity;
|
|
143
|
+
out.sort((a, b) => wave(a) - wave(b));
|
|
144
|
+
for (const g of out) g.members.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity));
|
|
145
|
+
}
|
|
138
146
|
return out;
|
|
139
147
|
}
|
|
140
148
|
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
rows, groupsOf, extent, windowOf, place, ticks, clock, stepRange, waitNote, factsOf, xOf, RANGES, DEFAULT_RANGE,
|
|
7
7
|
} from './timeline-plan.js';
|
|
8
8
|
import { Press } from './press.js';
|
|
9
|
-
import { shownType, hoverOf } from './names.js';
|
|
9
|
+
import { shownType, hoverOf, modelOf, modelFamily } from './names.js';
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
function mk(tag, cls, text) {
|
|
@@ -116,6 +116,7 @@ export class Timeline {
|
|
|
116
116
|
setGroup(g) { if (g === this.group) return; this.group = g; this.folded = new Set(); this.expanded = new Set(); this.render(); }
|
|
117
117
|
setWaiting(ids) { this.waiting = new Set(ids ?? []); this.render(); }
|
|
118
118
|
setFilter(statuses, { keepWaiting = false } = {}) { this.filter = statuses ? new Set(statuses) : null; this.keepWaiting = keepWaiting; this.render(); }
|
|
119
|
+
setModelFilter(family) { this.modelFilter = family ?? null; this.render(); }
|
|
119
120
|
setOwned(summary) { this.owned = summary ?? null; this.render(); }
|
|
120
121
|
setLive(live) { if (live === this.live) return; this.live = live; this.render(); this.#changed(); }
|
|
121
122
|
|
|
@@ -159,6 +160,7 @@ export class Timeline {
|
|
|
159
160
|
}
|
|
160
161
|
|
|
161
162
|
dimmed(n) {
|
|
163
|
+
if (this.modelFilter && modelFamily(n.model) !== this.modelFilter) return true;
|
|
162
164
|
if (!this.filter) return false;
|
|
163
165
|
if (this.keepWaiting && this.waiting.has(n.id)) return false;
|
|
164
166
|
return !this.filter.has(n.status);
|
|
@@ -320,8 +322,13 @@ export class Timeline {
|
|
|
320
322
|
if (tile) lab.append(tile);
|
|
321
323
|
const sub = mk('span', 'sub', r.sub);
|
|
322
324
|
if (r.tone) sub.dataset.tone = r.tone;
|
|
323
|
-
|
|
324
|
-
|
|
325
|
+
// The model the row's agent ran on, before its state.
|
|
326
|
+
const mdl = modelOf(r.node);
|
|
327
|
+
const badge = mk('span', 'tl-model', `${mdl.ran}${mdl.differs ? ' \u2260' : ''}`);
|
|
328
|
+
badge.hidden = !mdl.ran;
|
|
329
|
+
badge.title = mdl.title;
|
|
330
|
+
lab.append(mk('span', 'tl-name', r.label), mk('span', 'row-fill'), badge, sub);
|
|
331
|
+
lab.title = [hoverOf(r.node), mdl.ran ? mdl.title : null].filter(Boolean).join('\n');
|
|
325
332
|
// One listener, on the row: the label is a button inside it, and its click arrives here too. Two
|
|
326
333
|
// listeners chose the agent and un-chose it in the same click.
|
|
327
334
|
row.addEventListener('click', () => this.#choose(r.id));
|
package/kit/studio/web/usage.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// "~", and a note that says what it is. A session with tokens from a model the price table does not have gets
|
|
6
6
|
// a dash, not a smaller number.
|
|
7
7
|
|
|
8
|
+
import { modelName } from './names.js';
|
|
9
|
+
|
|
8
10
|
export const COST_NOTE = 'Estimated at API list price; on a subscription it is not what you are billed';
|
|
9
11
|
export const NO_COST = '—';
|
|
10
12
|
|
|
@@ -54,24 +56,63 @@ export function costNote(u) {
|
|
|
54
56
|
return `No estimate: the price table has no row for ${u.unpriced.join(', ')}`;
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
/**
|
|
58
|
-
export function
|
|
59
|
+
/** "40s", "4m 40s", "3h 5m": a span that is being counted, so the seconds show while they matter. */
|
|
60
|
+
export function fmtRunning(ms) {
|
|
61
|
+
if (ms == null || !Number.isFinite(ms) || ms < 0) return null;
|
|
62
|
+
const s = Math.floor(ms / 1000);
|
|
63
|
+
if (s < 60) return `${s}s`;
|
|
64
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
65
|
+
return fmtSpan(ms);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The navigator's one line: time · new tokens · ~cost. Null when nothing was read.
|
|
70
|
+
* @param live the session is running: its time is counted from its first record to now
|
|
71
|
+
*/
|
|
72
|
+
export function usageLine(u, { live = false, now = null } = {}) {
|
|
59
73
|
if (!u) return null;
|
|
60
74
|
const t = timeOf(u);
|
|
61
|
-
const
|
|
62
|
-
|
|
75
|
+
const span = live && u.startedAt != null && now != null ? fmtSpan(now - u.startedAt) : (t ? fmtSpan(t.ms) : null);
|
|
76
|
+
const tokens = typeof u.tokens === 'number' ? u.tokens : u.tokens?.fresh;
|
|
77
|
+
return [span, tokens ? fmtCount(tokens) : null, tokens ? fmtCost(u.cost) : null].filter(Boolean).join(' · ') || null;
|
|
63
78
|
}
|
|
64
79
|
|
|
65
|
-
|
|
66
|
-
|
|
80
|
+
const kinds = (k, cached = true) => [
|
|
81
|
+
['Input', fmtCount(k?.input ?? 0)], ['Output', fmtCount(k?.output ?? 0)], ['Cache write', fmtCount(k?.cacheWrite ?? 0)],
|
|
82
|
+
...(cached ? [['Cache read', fmtCount(k?.cacheRead ?? 0)]] : []),
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The summary box's rows: { label, value, note, detail }. `detail` is what a click on the row opens: the same
|
|
87
|
+
* number broken down, as [name, value] pairs.
|
|
88
|
+
*
|
|
89
|
+
* "Tokens" is what was new: input, output and cache writes. What was read back from the cache is its own row —
|
|
90
|
+
* it is the same context counted again on every response, and added in it made a session that had just opened
|
|
91
|
+
* read as millions.
|
|
92
|
+
*
|
|
93
|
+
* @param opts.live the session is running: its time is counted from its first record to `opts.now`
|
|
94
|
+
*/
|
|
95
|
+
export function summaryRows(u, { live = false, now = null } = {}) {
|
|
67
96
|
if (!u) return [];
|
|
68
|
-
const t = timeOf(u);
|
|
69
97
|
const k = u.tokens ?? {};
|
|
70
98
|
const rows = [];
|
|
71
|
-
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
99
|
+
const t = timeOf(u);
|
|
100
|
+
if (live && u.startedAt != null && now != null) {
|
|
101
|
+
rows.push({ label: 'Running for', value: fmtRunning(now - u.startedAt), note: 'From the session\'s first record to now' });
|
|
102
|
+
} else if (t) {
|
|
103
|
+
rows.push({ label: t.kind === 'worked' ? 'Working time' : 'First to last record', value: fmtSpan(t.ms), note: t.note });
|
|
104
|
+
if (t.kind === 'worked' && u.durationMs != null) rows.push({ label: 'First to last record', value: fmtSpan(u.durationMs), note: 'Pauses included; the agents\' transcripts too' });
|
|
105
|
+
}
|
|
106
|
+
rows.push({ label: 'Tokens', value: fmtCount(k.fresh ?? 0), note: 'New tokens: input, output and cache writes. Agents included, each response counted once', detail: kinds(k, false) });
|
|
107
|
+
rows.push({ label: 'From cache', value: fmtCount(k.cacheRead ?? 0), note: 'Read back from the prompt cache: the same context, counted on every response that reused it' });
|
|
108
|
+
const part = (label, p, note) => {
|
|
109
|
+
if (!p?.tokens?.total) return;
|
|
110
|
+
rows.push({ label, value: `${fmtCount(p.tokens.fresh)} · ${fmtCost(p.cost)}`, note: `${note}: new tokens and estimated cost\n${costNote(p)}`, detail: kinds(p.tokens) });
|
|
111
|
+
};
|
|
112
|
+
part('Session', u.parts?.session, 'The session\'s own transcript');
|
|
113
|
+
part('Agents', u.parts?.agents, 'Everything its agents did');
|
|
114
|
+
// Behind the cost: each model's new tokens and what they come to. A model with no price says so with a dash.
|
|
115
|
+
const perModel = Object.entries(u.byModel ?? {}).map(([model, t]) => [modelName(model), `${fmtCount(t.tokens.fresh)} \u00b7 ${fmtCost(t.cost)}`]);
|
|
116
|
+
rows.push({ label: 'Cost', value: k.total ? fmtCost(u.cost) : NO_COST, note: costNote(u), detail: perModel.length ? perModel : undefined });
|
|
76
117
|
return rows;
|
|
77
118
|
}
|
package/package.json
CHANGED