crewforth 3.1.0-rc.4 → 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 +31 -0
- 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 +5 -0
- 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 +16 -0
- package/kit/eval/smoke-test.sh +168 -7
- package/kit/hooks/guard-agent-model.sh +392 -0
- package/kit/settings.json +11 -0
- package/kit/studio/server/index.js +1 -1
- package/kit/studio/server/lib/graph.js +100 -6
- package/kit/studio/server/lib/usage.js +96 -25
- package/kit/studio/web/app.js +48 -5
- package/kit/studio/web/canvas.js +134 -22
- package/kit/studio/web/chat.js +8 -1
- package/kit/studio/web/convo.js +5 -1
- package/kit/studio/web/graph-plan.js +37 -9
- package/kit/studio/web/inspect.js +33 -1
- package/kit/studio/web/list.js +8 -2
- package/kit/studio/web/names.js +54 -0
- package/kit/studio/web/style.css +49 -6
- package/kit/studio/web/timeline.js +10 -3
- package/kit/studio/web/usage.js +53 -12
- package/package.json +1 -1
|
@@ -24,8 +24,10 @@ export const SIZE = {
|
|
|
24
24
|
colGap: 56, // between one depth and the next
|
|
25
25
|
waveGap: 148, // between one wave and the next: room for the words on an inferred wire
|
|
26
26
|
rowGap: 12, // between siblings
|
|
27
|
-
session: { w: 200, h: 84 },
|
|
28
|
-
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
|
|
29
31
|
chip: { w: 248, h: 36 }, // an agent at compact density
|
|
30
32
|
group: { // an open group: a container holding its members
|
|
31
33
|
padX: 8, top: 10, head: 20, gap: 6, bar: 4, bottom: 8,
|
|
@@ -153,7 +155,10 @@ function itemsOf(nodes, { group, density, seq, stack, aside }) {
|
|
|
153
155
|
const session = nodes.find((n) => n.kind === 'session');
|
|
154
156
|
if (session) items.push({ id: session.id, kind: 'session', node: session, parent: null, depth: 0 });
|
|
155
157
|
|
|
156
|
-
|
|
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);
|
|
157
162
|
|
|
158
163
|
// `anchor` is the item a node's wire comes out of; `via` is the member row inside it, when the parent is one.
|
|
159
164
|
const place = (parentId, anchor, via, depth) => {
|
|
@@ -216,6 +221,23 @@ function itemsOf(nodes, { group, density, seq, stack, aside }) {
|
|
|
216
221
|
return items;
|
|
217
222
|
}
|
|
218
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
|
+
|
|
219
241
|
function groupItem(id, type, members, anchor, via, depth, seq, extra) {
|
|
220
242
|
return {
|
|
221
243
|
id,
|
|
@@ -230,10 +252,14 @@ function groupItem(id, type, members, anchor, via, depth, seq, extra) {
|
|
|
230
252
|
seq: Math.min(...members.map((m) => seq.get(m.id) ?? 0), Infinity),
|
|
231
253
|
// The group stands where its first member does in the order the session called its agents.
|
|
232
254
|
first: members.filter((m) => m.order != null).sort((a, b) => a.order - b.order)[0] ?? null,
|
|
255
|
+
sum: sumOf(members),
|
|
233
256
|
...extra,
|
|
234
257
|
};
|
|
235
258
|
}
|
|
236
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
|
+
|
|
237
263
|
/* ----------------------------------------------------------------- plan --- */
|
|
238
264
|
|
|
239
265
|
/**
|
|
@@ -284,7 +310,7 @@ export function plan(nodes, opts = {}) {
|
|
|
284
310
|
/** Size and place everything drawn, at one density. Returns the box the picture needs. */
|
|
285
311
|
const lay = (drawnItems, compact) => {
|
|
286
312
|
for (const it of drawnItems) {
|
|
287
|
-
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; }
|
|
288
314
|
if (it.kind !== 'group') {
|
|
289
315
|
const sz = compact ? SIZE.chip : SIZE.card;
|
|
290
316
|
it.w = sz.w; it.h = sz.h; it.compact = compact;
|
|
@@ -293,7 +319,8 @@ export function plan(nodes, opts = {}) {
|
|
|
293
319
|
it.open = isOpen(it);
|
|
294
320
|
// `boxed` is the group drawn as a container with its members inside. Open beside itself it stays a card.
|
|
295
321
|
it.boxed = it.open && !it.aside;
|
|
296
|
-
|
|
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; }
|
|
297
324
|
const n = it.members.length;
|
|
298
325
|
const grid = compact || n > G.rowsMax;
|
|
299
326
|
const cols = grid ? Math.min(G.colsMax, Math.max(1, Math.ceil(n / G.rowsMax))) : 1;
|
|
@@ -446,6 +473,7 @@ export const INFERRED_NOTE = 'Inferred from the order of events: this wave was c
|
|
|
446
473
|
function planOrder(nodes, asked, opts) {
|
|
447
474
|
const pinned = opts.pinned ?? new Map();
|
|
448
475
|
const session = nodes.find((n) => n.kind === 'session') ?? null;
|
|
476
|
+
const sessionH = sessionHeight(session);
|
|
449
477
|
const agents = nodes.filter((n) => n.kind === 'agent');
|
|
450
478
|
const called = agents.filter((n) => n.wave != null);
|
|
451
479
|
const waveIds = [...new Set(called.map((n) => n.wave))].sort((a, b) => a - b);
|
|
@@ -483,11 +511,11 @@ function planOrder(nodes, asked, opts) {
|
|
|
483
511
|
if (session) {
|
|
484
512
|
const first = items.filter((it) => it.depth === 1);
|
|
485
513
|
if (sessionY == null) {
|
|
486
|
-
const bottom = first.length ? Math.max(...first.map((it) => it.y + it.h)) : SIZE.padY +
|
|
487
|
-
sessionY = Math.max(SIZE.padY, Math.round((SIZE.padY + bottom) / 2 -
|
|
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));
|
|
488
516
|
}
|
|
489
|
-
items.unshift({ id: session.id, kind: 'session', node: session, parent: null, depth: 0, w: SIZE.session.w, h:
|
|
490
|
-
height = Math.max(height, sessionY +
|
|
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);
|
|
491
519
|
}
|
|
492
520
|
for (const it of items) {
|
|
493
521
|
const p = pinned.get(it.id);
|
|
@@ -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
|
@@ -42,3 +42,57 @@ export function orderTag(n, clock = null) {
|
|
|
42
42
|
if (n?.order == null) return '';
|
|
43
43
|
return `#${n.order}${clock && n.calledAt != null ? ` · ${clock(n.calledAt)}` : ''}`;
|
|
44
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
|
@@ -846,6 +846,27 @@ button.tl-lab { cursor: pointer; }
|
|
|
846
846
|
.cv-edge-label { font-size: 10px; fill: var(--ink-faint); pointer-events: none; }
|
|
847
847
|
/* "#3 · 09:14": where a card stands in the order the session called its agents. */
|
|
848
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); }
|
|
849
870
|
.cv-under .cv-task { flex: 1 1 auto; min-width: 0; }
|
|
850
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; }
|
|
851
872
|
.cv-order[hidden] { display: none; }
|
|
@@ -961,7 +982,7 @@ button.tl-lab { cursor: pointer; }
|
|
|
961
982
|
.cv-row { display: flex; align-items: center; gap: var(--space-2); min-width: 0; }
|
|
962
983
|
.cv-fill { flex: 1; min-width: 0; }
|
|
963
984
|
.cv-indent { padding-left: 32px; }
|
|
964
|
-
.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; }
|
|
965
986
|
.cv-name {
|
|
966
987
|
min-width: 0; font-family: var(--font-mono); font-size: 12px; line-height: 16px; color: var(--ink);
|
|
967
988
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
@@ -970,7 +991,12 @@ button.tl-lab { cursor: pointer; }
|
|
|
970
991
|
min-width: 0; font-size: 13px; line-height: 18px; color: var(--ink-dim);
|
|
971
992
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
972
993
|
}
|
|
973
|
-
.
|
|
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%; }
|
|
974
1000
|
.cv-word { font-size: 11px; color: var(--ink-dim); white-space: nowrap; flex: none; }
|
|
975
1001
|
|
|
976
1002
|
/* The tile says whose agent it is. Crewforth's mark keeps its own colours; the
|
|
@@ -1090,10 +1116,27 @@ button.tl-lab { cursor: pointer; }
|
|
|
1090
1116
|
}
|
|
1091
1117
|
.cv-legend[hidden] { display: none; }
|
|
1092
1118
|
/* The session's summary is the same kind of box, in the opposite corner. */
|
|
1093
|
-
.cv-spend { right: auto; bottom: auto; left: var(--space-6); top: var(--space-6); width:
|
|
1094
|
-
.cv-spend-rows {
|
|
1095
|
-
.cv-spend-
|
|
1096
|
-
|
|
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); }
|
|
1097
1140
|
.cv-legend-head { display: flex; align-items: center; justify-content: space-between; }
|
|
1098
1141
|
.cv-legend-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-1) var(--space-2); }
|
|
1099
1142
|
.cv-legend-item { display: flex; align-items: center; gap: var(--space-2); font-size: 12px; color: var(--ink-dim); white-space: nowrap; }
|
|
@@ -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