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
|
@@ -36,55 +36,115 @@ export function usageOfRecord(r) {
|
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
const KINDS = ['input', 'output', 'cacheWrite', 'cacheRead'];
|
|
40
|
+
|
|
41
|
+
/** One running sum: tokens by kind, what they cost, and which models had no price. */
|
|
42
|
+
class Tally {
|
|
40
43
|
constructor() {
|
|
41
|
-
this.seen = new Set();
|
|
42
44
|
this.tokens = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 };
|
|
43
45
|
this.usd = 0;
|
|
44
46
|
this.responses = 0;
|
|
45
|
-
this.priced = new Set();
|
|
46
47
|
this.unpriced = new Set();
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
take(u, usd) {
|
|
51
|
+
this.responses += 1;
|
|
52
|
+
this.tokens.input += u.input;
|
|
53
|
+
this.tokens.output += u.output;
|
|
54
|
+
this.tokens.cacheWrite += u.cacheWrite5m + u.cacheWrite1h;
|
|
55
|
+
this.tokens.cacheRead += u.cacheRead;
|
|
56
|
+
if (usd === null) this.unpriced.add(u.model ?? 'unknown model');
|
|
57
|
+
else this.usd += usd;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `fresh` is what was sent and written for the first time: input, output and cache writes. `cacheRead` is the
|
|
62
|
+
* same context read back from the cache on every later response, which is why it dwarfs the rest in a long
|
|
63
|
+
* session and is kept out of the headline. `cost` is null when any of the tokens came from a model the price
|
|
64
|
+
* table has no row for — a sum that leaves some of them out would read as the whole.
|
|
65
|
+
*/
|
|
66
|
+
result() {
|
|
67
|
+
const t = this.tokens;
|
|
68
|
+
return {
|
|
69
|
+
tokens: { ...t, fresh: t.input + t.output + t.cacheWrite, total: KINDS.reduce((a, k) => a + t[k], 0) },
|
|
70
|
+
responses: this.responses,
|
|
71
|
+
cost: this.unpriced.size ? null : this.usd,
|
|
72
|
+
unpriced: [...this.unpriced].sort(),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class Usage {
|
|
78
|
+
constructor() {
|
|
79
|
+
this.seen = new Set();
|
|
80
|
+
this.all = new Tally();
|
|
81
|
+
this.who = new Map(); // 'session', or an agent's id -> Tally
|
|
82
|
+
this.model = new Map(); // model id -> Tally
|
|
83
|
+
this.first = null; // the earliest and the latest record of any file added
|
|
84
|
+
this.last = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#tally(map, key) {
|
|
88
|
+
if (!map.has(key)) map.set(key, new Tally());
|
|
89
|
+
return map.get(key);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Add a transcript's records. A response already counted, from this file or another, is not counted again.
|
|
94
|
+
* @param who 'session' for the session's own transcript, or the id of the agent whose transcript this is.
|
|
95
|
+
* A record the session's transcript marks as an agent's (`isSidechain`) is counted as an agent's.
|
|
96
|
+
*/
|
|
97
|
+
add(records, who = 'session') {
|
|
51
98
|
for (const r of records ?? []) {
|
|
99
|
+
const t = r?.timestamp ? Date.parse(r.timestamp) : NaN;
|
|
100
|
+
if (Number.isFinite(t)) {
|
|
101
|
+
if (this.first === null || t < this.first) this.first = t;
|
|
102
|
+
if (this.last === null || t > this.last) this.last = t;
|
|
103
|
+
}
|
|
52
104
|
const u = usageOfRecord(r);
|
|
53
105
|
if (!u) continue;
|
|
54
106
|
if (u.id) { if (this.seen.has(u.id)) continue; this.seen.add(u.id); }
|
|
55
|
-
|
|
56
|
-
if (!total) continue; // a record the harness made up for itself carries no tokens
|
|
57
|
-
this.responses += 1;
|
|
58
|
-
this.tokens.input += u.input;
|
|
59
|
-
this.tokens.output += u.output;
|
|
60
|
-
this.tokens.cacheWrite += u.cacheWrite5m + u.cacheWrite1h;
|
|
61
|
-
this.tokens.cacheRead += u.cacheRead;
|
|
107
|
+
if (!(u.input + u.output + u.cacheWrite5m + u.cacheWrite1h + u.cacheRead)) continue; // a record the harness made up carries no tokens
|
|
62
108
|
const usd = costOf(u);
|
|
63
|
-
|
|
64
|
-
|
|
109
|
+
this.all.take(u, usd);
|
|
110
|
+
this.#tally(this.who, who === 'session' && r.isSidechain === true ? 'agents:inline' : who).take(u, usd);
|
|
111
|
+
this.#tally(this.model, u.model ?? 'unknown model').take(u, usd);
|
|
65
112
|
}
|
|
66
113
|
return this;
|
|
67
114
|
}
|
|
68
115
|
|
|
116
|
+
/** One agent's own share, or null when its transcript carried no usage. */
|
|
117
|
+
of(who) { return this.who.get(who)?.result() ?? null; }
|
|
118
|
+
|
|
69
119
|
/**
|
|
70
|
-
* @returns
|
|
71
|
-
*
|
|
120
|
+
* @returns the whole session's tally, and the same split two ways: `parts.session` is what the session's own
|
|
121
|
+
* transcript spent and `parts.agents` everything its agents did; `byModel` is one tally per model.
|
|
72
122
|
*/
|
|
73
123
|
result() {
|
|
74
|
-
const
|
|
124
|
+
const agents = new Tally();
|
|
125
|
+
for (const [who, t] of this.who) {
|
|
126
|
+
if (who === 'session') continue;
|
|
127
|
+
for (const k of KINDS) agents.tokens[k] += t.tokens[k];
|
|
128
|
+
agents.usd += t.usd;
|
|
129
|
+
agents.responses += t.responses;
|
|
130
|
+
for (const m of t.unpriced) agents.unpriced.add(m);
|
|
131
|
+
}
|
|
132
|
+
const all = this.all.result();
|
|
75
133
|
return {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
134
|
+
...all,
|
|
135
|
+
models: [...this.model.keys()].filter((m) => !all.unpriced.includes(m)).sort(),
|
|
136
|
+
parts: { session: (this.who.get('session') ?? new Tally()).result(), agents: agents.result() },
|
|
137
|
+
byModel: Object.fromEntries([...this.model].map(([m, t]) => [m, t.result()]).sort((a, b) => b[1].tokens.total - a[1].tokens.total)),
|
|
138
|
+
startedAt: this.first,
|
|
139
|
+
lastAt: this.last,
|
|
81
140
|
priceSource: SOURCE,
|
|
82
141
|
};
|
|
83
142
|
}
|
|
84
143
|
}
|
|
85
144
|
|
|
86
145
|
/**
|
|
87
|
-
* How long a session
|
|
146
|
+
* How long a session's own transcript says it took. `durationMs` is from its first record to its last, pauses
|
|
147
|
+
* included; with its agents' transcripts counted too, that span is `Usage`'s `startedAt` to `lastAt`.
|
|
88
148
|
* `workedMs` is the turns Claude Code timed itself (`system` / `turn_duration` records), added up; zero when it
|
|
89
149
|
* recorded none, and a turn that is still running is not in it.
|
|
90
150
|
*/
|
|
@@ -107,6 +167,16 @@ export function timesOf(records) {
|
|
|
107
167
|
return { durationMs: first !== null && last !== null ? last - first : null, workedMs, turns };
|
|
108
168
|
}
|
|
109
169
|
|
|
170
|
+
/**
|
|
171
|
+
* The usage with the times beside it. The span runs to the last record of ANY of the session's files: an agent
|
|
172
|
+
* can be writing long after the session's own transcript last moved.
|
|
173
|
+
*/
|
|
174
|
+
export function withTimes(usage, mainRecords) {
|
|
175
|
+
const u = usage.result();
|
|
176
|
+
const t = timesOf(mainRecords);
|
|
177
|
+
return { ...u, workedMs: t.workedMs, turns: t.turns, durationMs: u.startedAt !== null && u.lastAt !== null ? u.lastAt - u.startedAt : null };
|
|
178
|
+
}
|
|
179
|
+
|
|
110
180
|
/** A session's usage with its agents', and how long it took. */
|
|
111
181
|
export async function sessionUsage(session) {
|
|
112
182
|
const usage = new Usage();
|
|
@@ -114,9 +184,10 @@ export async function sessionUsage(session) {
|
|
|
114
184
|
usage.add(records);
|
|
115
185
|
for (const meta of await agentMetaFiles(session.subagentsDir)) {
|
|
116
186
|
const jsonl = meta.replace(/\.meta\.json$/, '.jsonl');
|
|
117
|
-
|
|
187
|
+
const agentId = path.basename(jsonl).slice('agent-'.length, -'.jsonl'.length);
|
|
188
|
+
try { usage.add((await readAll(jsonl)).records, agentId); } catch { /* an agent that has not written yet */ }
|
|
118
189
|
}
|
|
119
|
-
return
|
|
190
|
+
return withTimes(usage, records);
|
|
120
191
|
}
|
|
121
192
|
|
|
122
193
|
// One answer per session, kept until one of its files changes. The key is what the stream's own signature
|
package/kit/studio/web/app.js
CHANGED
|
@@ -24,8 +24,8 @@ import { Timeline } from './timeline.js';
|
|
|
24
24
|
import { List } from './list.js';
|
|
25
25
|
import { defaultView, narrowWarning } from './list-plan.js';
|
|
26
26
|
import { fitLevel, runsOver } from './toolbar-fit.js';
|
|
27
|
-
import { roleName, shownType } from './names.js';
|
|
28
|
-
import { fmtDuration, fmtTokens, tiles, timeLine, rightNow, skillsOf, delegatedBy, reportOf, backgroundLines, TABS as INSPECTOR_TABS, TAB_WORD } from './inspect.js';
|
|
27
|
+
import { roleName, shownType, modelFamily } from './names.js';
|
|
28
|
+
import { fmtDuration, fmtTokens, tiles, timeLine, rightNow, skillsOf, delegatedBy, reportOf, backgroundLines, modelLines, TABS as INSPECTOR_TABS, TAB_WORD } from './inspect.js';
|
|
29
29
|
|
|
30
30
|
const FLEET_POLL_MS = 2000;
|
|
31
31
|
const SESSION_POLL_MS = 5000;
|
|
@@ -506,7 +506,25 @@ pickFrom(el.tbGroup, GROUP_WORD, () => canvas.state().group, (g) => canvas.setGr
|
|
|
506
506
|
canvas.setAside(!railBand.matches);
|
|
507
507
|
railBand.addEventListener('change', () => canvas.setAside(!railBand.matches));
|
|
508
508
|
pickFrom(el.tbDensity, DENSITY_WORD, () => canvas.state().density, (d) => canvas.setDensity(d));
|
|
509
|
-
|
|
509
|
+
// Show: by state, and by the model the work ran on. The two are separate choices and both hold at once.
|
|
510
|
+
let showModel = null;
|
|
511
|
+
function setShowModel(family) {
|
|
512
|
+
showModel = family;
|
|
513
|
+
canvas.setModelFilter(family);
|
|
514
|
+
timeline.setModelFilter(family);
|
|
515
|
+
list.render?.();
|
|
516
|
+
tbValue.show.textContent = [SHOW[show].word, family ? `${family[0].toUpperCase()}${family.slice(1)}` : null].filter(Boolean).join(' \u00b7 ');
|
|
517
|
+
}
|
|
518
|
+
el.tbShow.addEventListener('click', (e) => {
|
|
519
|
+
e.stopPropagation();
|
|
520
|
+
const items = Object.entries(SHOW).map(([key, w]) => ({ label: w.word, checked: show === key, run: () => { setShow(key); setShowModel(showModel); } }));
|
|
521
|
+
const families = [...new Set((lastNodes ?? []).filter((n) => n.kind === 'agent').map((n) => modelFamily(n.model)).filter(Boolean))].sort();
|
|
522
|
+
if (families.length) {
|
|
523
|
+
items.push({ note: 'Model' }, { label: 'Every model', checked: showModel === null, run: () => setShowModel(null) },
|
|
524
|
+
...families.map((f) => ({ label: `Only ${f[0].toUpperCase()}${f.slice(1)}`, checked: showModel === f, run: () => setShowModel(f) })));
|
|
525
|
+
}
|
|
526
|
+
openMenu(el.tbShow, items);
|
|
527
|
+
});
|
|
510
528
|
// The toolbar is measured, not assumed: it gives up a step at a time until what it shows fits (toolbar-fit.js).
|
|
511
529
|
// Every step is tried from the first, so a toolbar that has room again takes its words back.
|
|
512
530
|
function fitToolbar() {
|
|
@@ -849,6 +867,24 @@ function paintAgentOverview(body, n, detail) {
|
|
|
849
867
|
}
|
|
850
868
|
body.append(grid);
|
|
851
869
|
|
|
870
|
+
// What it ran on and what its call asked for, and what the model gate or a low-confidence report did to it.
|
|
871
|
+
const models = modelLines(n, lastNodes);
|
|
872
|
+
if (models.length) {
|
|
873
|
+
const dl = node('dl', 'imodel');
|
|
874
|
+
for (const m of models) {
|
|
875
|
+
const dd = node('dd', null, m.text);
|
|
876
|
+
if (m.note) dd.title = m.note;
|
|
877
|
+
if (m.goto) {
|
|
878
|
+
const go = node('button', 'ilink', 'show');
|
|
879
|
+
go.type = 'button';
|
|
880
|
+
go.addEventListener('click', () => goTo(m.goto));
|
|
881
|
+
dd.append(' ', go);
|
|
882
|
+
}
|
|
883
|
+
dl.append(node('dt', null, m.label), dd);
|
|
884
|
+
}
|
|
885
|
+
body.append(isection('Model', dl));
|
|
886
|
+
}
|
|
887
|
+
|
|
852
888
|
const gap = n.kind === 'agent' ? detailGap(n, detail) : null;
|
|
853
889
|
const read = n.kind === 'agent' && !gap ? detail : null;
|
|
854
890
|
|
|
@@ -1500,7 +1536,7 @@ function sessionRow(project, sn) {
|
|
|
1500
1536
|
if (subText) row.append(marked(subText, 'sub srow-sub'));
|
|
1501
1537
|
// What it spent, once the server has read it: time · tokens · ~cost. Asked for a few rows at a time.
|
|
1502
1538
|
const spent = usageFor(sn);
|
|
1503
|
-
const line = usageLine(spent);
|
|
1539
|
+
const line = usageLine(spent, { live: sessionStatus(sn.sessionId, fleetData).known && sessionStatus(sn.sessionId, fleetData).key !== 'ended', now: serverNow() });
|
|
1504
1540
|
if (line) {
|
|
1505
1541
|
const u = node('span', 'sub srow-usage', line);
|
|
1506
1542
|
u.title = costNote(spent);
|
|
@@ -2278,6 +2314,7 @@ function paintStageNote() {
|
|
|
2278
2314
|
stageNote.button.hidden = !off;
|
|
2279
2315
|
// The fleet can say a session ended, or came back, with no transcript changing.
|
|
2280
2316
|
if (lastGraph && sessionIsLive() !== lastLive) showGraph();
|
|
2317
|
+
else if (lastLive === true) paintSpend();
|
|
2281
2318
|
paintTerminalWait();
|
|
2282
2319
|
}
|
|
2283
2320
|
|
|
@@ -2285,13 +2322,19 @@ function paintStageNote() {
|
|
|
2285
2322
|
* Draw the graph the stream last sent. What the server read from files is settled against what the machine says
|
|
2286
2323
|
* of the session first, so this is called again when that changes with no file changing.
|
|
2287
2324
|
*/
|
|
2325
|
+
/** The summary box, from the session on screen. A live session's time is counted to now, so this runs on the tick. */
|
|
2326
|
+
function paintSpend() {
|
|
2327
|
+
const usage = lastNodes?.find((n) => n.kind === 'session')?.usage ?? null;
|
|
2328
|
+
canvas.setSpend(summaryRows(usage, { live: lastLive === true, now: serverNow() }));
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2288
2331
|
function showGraph() {
|
|
2289
2332
|
if (!lastGraph) return;
|
|
2290
2333
|
lastLive = sessionIsLive();
|
|
2291
2334
|
const g = settle(lastGraph, lastLive);
|
|
2292
2335
|
canvas.render(g);
|
|
2293
|
-
canvas.setSpend(summaryRows(g.nodes.find((n) => n.kind === 'session')?.usage ?? null));
|
|
2294
2336
|
lastNodes = g.nodes;
|
|
2337
|
+
paintSpend();
|
|
2295
2338
|
timeline.setNodes(g.nodes);
|
|
2296
2339
|
list.setNodes(g.nodes);
|
|
2297
2340
|
paintAttention(g.nodes);
|
package/kit/studio/web/canvas.js
CHANGED
|
@@ -15,7 +15,8 @@ import {
|
|
|
15
15
|
} from './graph-plan.js';
|
|
16
16
|
import { clock } from './timeline-plan.js';
|
|
17
17
|
import { agentStatus } from './nav.js';
|
|
18
|
-
import { roleName, shownType, hoverOf, orderTag } from './names.js';
|
|
18
|
+
import { roleName, shownType, hoverOf, orderTag, modelOf, modelMix, modelFamily } from './names.js';
|
|
19
|
+
import { fmtCount, fmtCost, COST_NOTE } from './usage.js';
|
|
19
20
|
|
|
20
21
|
// How long an arriving wire takes to draw itself toward its new card. Short on purpose: this is a status panel,
|
|
21
22
|
// and anything a viewer has to wait through is a cost they pay on every spawn.
|
|
@@ -93,6 +94,7 @@ export class Canvas {
|
|
|
93
94
|
this.palette = { map: {} };
|
|
94
95
|
this.icons = {};
|
|
95
96
|
this.filter = null; // statuses the page asked to isolate
|
|
97
|
+
this.modelFilter = null; // a family of model the page asked to isolate
|
|
96
98
|
this.waiting = new Set(); // agents parked on an approval
|
|
97
99
|
this.sessionState = null; // { word, tone } for the session card's pill
|
|
98
100
|
this.focusId = null; // show only this branch
|
|
@@ -162,7 +164,7 @@ export class Canvas {
|
|
|
162
164
|
close.innerHTML = MARK.close;
|
|
163
165
|
close.addEventListener('click', () => this.showSpend(false));
|
|
164
166
|
head.append(close);
|
|
165
|
-
this.spendRows = mk('
|
|
167
|
+
this.spendRows = mk('div', 'cv-spend-rows');
|
|
166
168
|
box.append(head, this.spendRows);
|
|
167
169
|
this.spendOpen = readFlag('crewforth-studio-spend') !== 'closed';
|
|
168
170
|
box.hidden = true; // until there is a session's usage to show
|
|
@@ -175,19 +177,61 @@ export class Canvas {
|
|
|
175
177
|
this.spendEl.hidden = !on || !this.spendCount;
|
|
176
178
|
}
|
|
177
179
|
|
|
178
|
-
/**
|
|
180
|
+
/**
|
|
181
|
+
* The rows to show, from usage.js `summaryRows`: { label, value, note, detail }. An empty list hides the box.
|
|
182
|
+
*
|
|
183
|
+
* A row with a `detail` is a control: a click opens the breakdown under it, and it stays open. Rows are kept and
|
|
184
|
+
* updated, never rebuilt — a live session's time changes every second, and a row replaced under a press would
|
|
185
|
+
* lose the click.
|
|
186
|
+
*/
|
|
179
187
|
setSpend(rows) {
|
|
180
|
-
const
|
|
188
|
+
const list = rows ?? [];
|
|
189
|
+
const sig = JSON.stringify(list);
|
|
181
190
|
if (sig === this.spendSig) return;
|
|
182
191
|
this.spendSig = sig;
|
|
183
|
-
this.spendCount =
|
|
184
|
-
this.
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
192
|
+
this.spendCount = list.length;
|
|
193
|
+
this.spendParts ??= new Map();
|
|
194
|
+
this.spendShown ??= new Set();
|
|
195
|
+
const alive = new Set();
|
|
196
|
+
for (const r of list) {
|
|
197
|
+
alive.add(r.label);
|
|
198
|
+
let p = this.spendParts.get(r.label);
|
|
199
|
+
if (!p) {
|
|
200
|
+
p = { row: mk('button', 'cv-spend-row'), label: mk('span', 'cv-spend-label'), value: mk('span', 'cv-spend-value'), detail: mk('dl', 'cv-spend-detail') };
|
|
201
|
+
p.row.type = 'button';
|
|
202
|
+
p.row.append(p.label, p.value);
|
|
203
|
+
p.row.addEventListener('click', () => {
|
|
204
|
+
if (!p.row.classList.contains('cv-spend-more')) return;
|
|
205
|
+
if (this.spendShown.has(r.label)) this.spendShown.delete(r.label); else this.spendShown.add(r.label);
|
|
206
|
+
p.detail.hidden = !this.spendShown.has(r.label);
|
|
207
|
+
p.row.setAttribute('aria-expanded', String(!p.detail.hidden));
|
|
208
|
+
});
|
|
209
|
+
this.spendParts.set(r.label, p);
|
|
210
|
+
}
|
|
211
|
+
const more = Boolean(r.detail?.length);
|
|
212
|
+
p.label.textContent = r.label;
|
|
213
|
+
p.value.textContent = r.value ?? '';
|
|
214
|
+
p.row.title = more ? `${r.note ?? ''}\nClick for the breakdown` : (r.note ?? '');
|
|
215
|
+
p.row.classList.toggle('cv-spend-more', more);
|
|
216
|
+
if (more) p.row.setAttribute('aria-expanded', String(this.spendShown.has(r.label))); else p.row.removeAttribute?.('aria-expanded');
|
|
217
|
+
p.detail.hidden = !more || !this.spendShown.has(r.label);
|
|
218
|
+
const dsig = JSON.stringify(r.detail ?? []);
|
|
219
|
+
if (p.dsig !== dsig) {
|
|
220
|
+
p.dsig = dsig;
|
|
221
|
+
p.detail.replaceChildren(...(r.detail ?? []).flatMap(([k, v]) => [mk('dt', null, k), mk('dd', null, v)]));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
for (const [label, p] of this.spendParts) {
|
|
225
|
+
if (alive.has(label)) continue;
|
|
226
|
+
this.spendParts.delete(label);
|
|
227
|
+
}
|
|
228
|
+
// Which rows there are changes rarely (a session's first agent, its first timed turn); what they say changes
|
|
229
|
+
// every second. Only the first puts elements back into the box, and it puts the same elements back.
|
|
230
|
+
const order = list.map((r) => r.label).join('\u0000');
|
|
231
|
+
if (order !== this.spendOrder) {
|
|
232
|
+
this.spendOrder = order;
|
|
233
|
+
this.spendRows.replaceChildren(...list.flatMap((r) => { const p = this.spendParts.get(r.label); return [p.row, p.detail]; }));
|
|
234
|
+
}
|
|
191
235
|
this.spendEl.hidden = !this.spendOpen || !this.spendCount;
|
|
192
236
|
}
|
|
193
237
|
|
|
@@ -401,7 +445,17 @@ export class Canvas {
|
|
|
401
445
|
this.#redraw();
|
|
402
446
|
}
|
|
403
447
|
|
|
448
|
+
/** Show only the agents that ran on one family of model (`'opus'`), stepping the rest back. `null` shows all. */
|
|
449
|
+
setModelFilter(family) {
|
|
450
|
+
if ((family ?? null) === this.modelFilter) return;
|
|
451
|
+
this.modelFilter = family ?? null;
|
|
452
|
+
for (const el of this.els.values()) el.sig = null;
|
|
453
|
+
for (const el of this.cellEls.values()) el.sig = null;
|
|
454
|
+
if (this.lastPlan) this.#redraw();
|
|
455
|
+
}
|
|
456
|
+
|
|
404
457
|
dimmed(n) {
|
|
458
|
+
if (n?.kind === 'agent' && this.modelFilter && modelFamily(n.model) !== this.modelFilter) return true;
|
|
405
459
|
if (!this.filter || n?.kind !== 'agent') return false;
|
|
406
460
|
if (this.filterKeepsWaiting && this.waiting.has(n.id)) return false;
|
|
407
461
|
return !this.filter.has(n.status);
|
|
@@ -667,7 +721,11 @@ export class Canvas {
|
|
|
667
721
|
row.append(p.tile, mk('span', 'cv-title', 'Session'), mk('span', 'cv-fill'), p.pill.el);
|
|
668
722
|
p.name = mk('div', 'cv-name cv-indent');
|
|
669
723
|
p.sub = mk('div', 'cv-sub cv-indent');
|
|
670
|
-
|
|
724
|
+
// What it has running in the background takes a line of its own, when there is any: on the line above it
|
|
725
|
+
// ran out of the card.
|
|
726
|
+
p.bg = mk('div', 'cv-sub cv-indent cv-bg');
|
|
727
|
+
p.bg.hidden = true;
|
|
728
|
+
el.append(row, p.name, p.sub, p.bg);
|
|
671
729
|
} else if (it.kind === 'group') {
|
|
672
730
|
el.classList.add('cv-group');
|
|
673
731
|
p.tile = mk('span', 'cv-tile');
|
|
@@ -685,7 +743,10 @@ export class Canvas {
|
|
|
685
743
|
} else {
|
|
686
744
|
const row = mk('div', 'cv-row cv-indent');
|
|
687
745
|
row.append(p.bar, p.count);
|
|
688
|
-
|
|
746
|
+
// What its members add up to, and what they ran on. At compact density the card has no room for them.
|
|
747
|
+
p.totals = mk('div', 'cv-sub cv-indent cv-totals');
|
|
748
|
+
p.mix = mk('div', 'cv-sub cv-indent cv-mix');
|
|
749
|
+
el.append(p.head, row, p.totals, p.mix);
|
|
689
750
|
}
|
|
690
751
|
p.fold.addEventListener('click', (e) => { e.stopPropagation(); this.toggle(it.id); });
|
|
691
752
|
} else if (it.compact) {
|
|
@@ -694,8 +755,9 @@ export class Canvas {
|
|
|
694
755
|
p.order = mk('span', 'cv-order');
|
|
695
756
|
p.name = mk('span', 'cv-name');
|
|
696
757
|
p.task = mk('span', 'cv-task');
|
|
758
|
+
p.model = mk('span', 'cv-model');
|
|
697
759
|
p.word = mk('span', 'cv-word');
|
|
698
|
-
el.append(p.dot, p.order, p.name, p.task, p.word);
|
|
760
|
+
el.append(p.dot, p.order, p.name, p.task, p.model, p.word);
|
|
699
761
|
} else {
|
|
700
762
|
p.tile = mk('span', 'cv-tile');
|
|
701
763
|
p.name = mk('span', 'cv-name');
|
|
@@ -708,7 +770,12 @@ export class Canvas {
|
|
|
708
770
|
p.task = mk('span', 'cv-task');
|
|
709
771
|
const under = mk('div', 'cv-under cv-indent');
|
|
710
772
|
under.append(p.task, p.order);
|
|
711
|
-
|
|
773
|
+
// The third line: what it ran on, how long it took and what it spent.
|
|
774
|
+
p.model = mk('span', 'cv-model');
|
|
775
|
+
p.facts = mk('span', 'cv-facts');
|
|
776
|
+
const facts = mk('div', 'cv-under cv-indent cv-third');
|
|
777
|
+
facts.append(p.model, p.facts);
|
|
778
|
+
el.append(row, under, facts);
|
|
712
779
|
}
|
|
713
780
|
el.parts = p;
|
|
714
781
|
}
|
|
@@ -769,12 +836,16 @@ export class Canvas {
|
|
|
769
836
|
p.pill.dot.dataset.tone = st?.tone ?? 'none';
|
|
770
837
|
p.pill.word.textContent = st?.word ?? '';
|
|
771
838
|
p.name.textContent = n.gitBranch || shortPath(n.cwd) || n.sessionId || '';
|
|
839
|
+
// Cut to the card's width; hovering says it whole.
|
|
840
|
+
p.name.title = p.name.textContent;
|
|
772
841
|
const bits = [`${n.turns ?? 0} ${n.turns === 1 ? 'turn' : 'turns'}`];
|
|
773
842
|
if (n.tokens != null) bits.push(`${fmtTokens(n.tokens)} ctx`);
|
|
774
|
-
// What the session has running that is not an agent: commands sent to the background.
|
|
775
|
-
if (bg) bits.push(`${bg} in background`);
|
|
776
843
|
p.sub.textContent = bits.join(' · ');
|
|
777
|
-
p.sub.title =
|
|
844
|
+
p.sub.title = p.sub.textContent;
|
|
845
|
+
// What the session has running that is not an agent: commands sent to the background.
|
|
846
|
+
p.bg.hidden = !bg;
|
|
847
|
+
p.bg.textContent = bg ? `${bg} in background` : '';
|
|
848
|
+
p.bg.title = bg ? n.backgroundNow.map((c) => `In the background: ${c.toolName}${c.detail ? ` · ${c.detail}` : ''}`).join('\n') : '';
|
|
778
849
|
el.title = 'Click to inspect this session';
|
|
779
850
|
el.setAttribute('aria-label', `Session, ${st?.word ?? 'state not measured'}, ${p.name.textContent}, ${p.sub.textContent}`);
|
|
780
851
|
}
|
|
@@ -787,7 +858,9 @@ export class Canvas {
|
|
|
787
858
|
const dim = this.dimmed(n);
|
|
788
859
|
// Its place in the order the session called its agents, and when: "#3 · 09:14". A chip has room for the number.
|
|
789
860
|
const tag = orderTag(n, compact ? null : clock);
|
|
790
|
-
const
|
|
861
|
+
const mdl = modelOf(n);
|
|
862
|
+
const facts = compact ? '' : factsOf(n);
|
|
863
|
+
const sig = JSON.stringify([type, task, st.word, st.tone, st.state, who.source, dim, n.status, tag, after, mdl.ran, mdl.differs, facts]);
|
|
791
864
|
if (el.sig === sig) return;
|
|
792
865
|
el.sig = sig;
|
|
793
866
|
const p = el.parts;
|
|
@@ -804,6 +877,13 @@ export class Canvas {
|
|
|
804
877
|
p.task.title = task;
|
|
805
878
|
p.order.textContent = tag;
|
|
806
879
|
p.order.hidden = !tag;
|
|
880
|
+
// The model it ran on, as a badge; marked when the call asked for another. A chip has room for the family.
|
|
881
|
+
const family = modelFamily(n.model);
|
|
882
|
+
p.model.textContent = `${compact ? (family ? family[0].toUpperCase() + family.slice(1) : mdl.ran) : mdl.ran}${mdl.differs ? ' \u2260' : ''}`;
|
|
883
|
+
p.model.hidden = !mdl.ran;
|
|
884
|
+
p.model.title = mdl.title;
|
|
885
|
+
p.model.dataset.differs = String(mdl.differs);
|
|
886
|
+
if (!compact) { p.facts.textContent = facts; p.facts.title = factsTitle(n); }
|
|
807
887
|
p.order.title = tag ? `Called ${orderTag(n, clock)} by the session` : '';
|
|
808
888
|
// In the Order view: what had reported before this wave was called. A reading of the order of events.
|
|
809
889
|
el.title = after?.length ? `Called after ${after.join(', ')} reported.\n${INFERRED_NOTE}` : '';
|
|
@@ -824,7 +904,7 @@ export class Canvas {
|
|
|
824
904
|
const p = el.parts;
|
|
825
905
|
const counts = it.counts;
|
|
826
906
|
const says = Object.entries(counts).map(([s, c]) => `${c} ${agentStatus(s).word}`).join(' · ');
|
|
827
|
-
const sig = JSON.stringify([it.label, says, it.open, it.boxed, it.type, it.status, it.first?.order ?? null]);
|
|
907
|
+
const sig = JSON.stringify([it.label, says, it.open, it.boxed, it.type, it.status, it.first?.order ?? null, it.compact, it.sum, modelMix(it.members)]);
|
|
828
908
|
if (el.sig !== sig) {
|
|
829
909
|
el.sig = sig;
|
|
830
910
|
el.dataset.state = stateOf(it.status);
|
|
@@ -858,6 +938,7 @@ export class Canvas {
|
|
|
858
938
|
}));
|
|
859
939
|
p.bar.title = says;
|
|
860
940
|
if (!it.boxed) p.count.textContent = says;
|
|
941
|
+
if (!it.boxed) this.#fillTotals(p, it);
|
|
861
942
|
el.title = it.boxed ? '' : (it.open ? 'Click to fold' : `Click to show ${it.members.length} agents`);
|
|
862
943
|
el.setAttribute('aria-label', `${said}, ${says}, ${it.open ? 'open' : 'folded'}`);
|
|
863
944
|
}
|
|
@@ -911,13 +992,30 @@ export class Canvas {
|
|
|
911
992
|
c.parts.name.textContent = text;
|
|
912
993
|
c.parts.order.textContent = ctag;
|
|
913
994
|
c.parts.order.hidden = !ctag;
|
|
914
|
-
c.title = hoverOf(n);
|
|
995
|
+
c.title = [hoverOf(n), [modelOf(n).ran, factsOf(n)].filter(Boolean).join(' \u00b7 ')].filter(Boolean).join('\n');
|
|
915
996
|
c.parts.dot.dataset.tone = st.tone ?? 'none';
|
|
916
997
|
c.parts.word.textContent = st.word;
|
|
917
998
|
c.setAttribute('aria-label', `${shownType(n)}, ${st.word}${n.description ? `, ${n.description}` : ''} (${who.title})`);
|
|
918
999
|
}
|
|
919
1000
|
}
|
|
920
1001
|
|
|
1002
|
+
/** A group as one card: the time, the tokens and the estimate its members add up to, and the models they ran on. */
|
|
1003
|
+
#fillTotals(p, it) {
|
|
1004
|
+
const s = it.sum ?? {};
|
|
1005
|
+
p.totals.hidden = Boolean(it.compact);
|
|
1006
|
+
p.mix.hidden = Boolean(it.compact);
|
|
1007
|
+
if (it.compact) return;
|
|
1008
|
+
p.totals.textContent = [
|
|
1009
|
+
s.timed ? fmtDuration(s.durationMs) : null,
|
|
1010
|
+
s.counted ? `${fmtCount(s.fresh)} new` : null,
|
|
1011
|
+
s.counted ? `${fmtCount(s.cacheRead)} cache` : null,
|
|
1012
|
+
s.counted ? fmtCost(s.cost) : null,
|
|
1013
|
+
].filter(Boolean).join(' \u00b7 ');
|
|
1014
|
+
p.totals.title = `${it.members.length} calls. Time, new tokens, tokens read from the cache, and cost: ${COST_NOTE}`;
|
|
1015
|
+
p.mix.textContent = modelMix(it.members);
|
|
1016
|
+
p.mix.title = 'The models its agents ran on';
|
|
1017
|
+
}
|
|
1018
|
+
|
|
921
1019
|
/** What an inferred wire says: on hover that it is an inference, and once per reporter, in words beside it. */
|
|
922
1020
|
#edgeWords(path, e, c) {
|
|
923
1021
|
const title = e.title ?? '';
|
|
@@ -1330,6 +1428,20 @@ function readPref(key, allowed, fallback) {
|
|
|
1330
1428
|
return allowed.includes(v) ? v : fallback;
|
|
1331
1429
|
}
|
|
1332
1430
|
|
|
1431
|
+
/** An agent's third line: how long it took and what it spent. */
|
|
1432
|
+
function factsOf(n) {
|
|
1433
|
+
return [
|
|
1434
|
+
n.durationMs != null ? fmtDuration(n.durationMs) : null,
|
|
1435
|
+
n.usage ? `${fmtCount(n.usage.tokens.fresh)} new` : null,
|
|
1436
|
+
n.usage ? fmtCost(n.usage.cost) : null,
|
|
1437
|
+
].filter(Boolean).join(' \u00b7 ');
|
|
1438
|
+
}
|
|
1439
|
+
function factsTitle(n) {
|
|
1440
|
+
if (!n.usage) return '';
|
|
1441
|
+
const t = n.usage.tokens;
|
|
1442
|
+
return `New tokens ${fmtCount(t.fresh)} (input ${fmtCount(t.input)}, output ${fmtCount(t.output)}, cache write ${fmtCount(t.cacheWrite)}); read from cache ${fmtCount(t.cacheRead)}\n${COST_NOTE}`;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1333
1445
|
function fmtTokens(t) {
|
|
1334
1446
|
if (t == null) return '';
|
|
1335
1447
|
if (t < 1000) return `${t}`;
|
package/kit/studio/web/chat.js
CHANGED
|
@@ -370,7 +370,7 @@ class Pane {
|
|
|
370
370
|
|
|
371
371
|
paintCard({ block, node }) {
|
|
372
372
|
const card = delegationCard(block, this.hooks.agentsOf?.(this.id));
|
|
373
|
-
const sig = `${card.type}|${card.task}|${card.agentId}|${card.status}`;
|
|
373
|
+
const sig = `${card.type}|${card.task}|${card.agentId}|${card.status}|${card.model.ran}|${card.model.asked}`;
|
|
374
374
|
if (node.sig === sig) return;
|
|
375
375
|
node.sig = sig;
|
|
376
376
|
const text = el('span', 'deleg-text');
|
|
@@ -380,6 +380,13 @@ class Pane {
|
|
|
380
380
|
const tile = card.agentId ? this.hooks.tileOf?.(this.id, card.agentId) : null;
|
|
381
381
|
if (tile) parts.push(tile);
|
|
382
382
|
parts.push(text);
|
|
383
|
+
// The model: what it ran on, or what the call asks for while the agent has not been seen yet.
|
|
384
|
+
const mword = card.model.ran || card.model.asked;
|
|
385
|
+
if (mword) {
|
|
386
|
+
const badge = el('span', 'deleg-model', `${mword}${card.model.differs ? ' \u2260' : ''}`);
|
|
387
|
+
badge.title = card.model.title;
|
|
388
|
+
parts.push(badge);
|
|
389
|
+
}
|
|
383
390
|
// The agent's state is the graph's. Before the graph has seen the agent there is none to show.
|
|
384
391
|
const st = card.agentId ? this.hooks.statusOf?.(this.id, card.agentId) : null;
|
|
385
392
|
if (st?.word) {
|
package/kit/studio/web/convo.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// The same shapes come from two places: the server's reading of a transcript (history, and sessions Studio only
|
|
4
4
|
// watches) and the live stream of a session started here. Both go through these functions, so a tool row reads
|
|
5
5
|
// the same whichever way it arrived.
|
|
6
|
-
import { roleName } from './names.js';
|
|
6
|
+
import { roleName, modelOf } from './names.js';
|
|
7
7
|
|
|
8
8
|
// How much of a tool's output the page keeps for a live result: the same tail the server keeps for history.
|
|
9
9
|
const RESULT_TAIL = 4096;
|
|
@@ -20,6 +20,8 @@ export function toolBlock(c) {
|
|
|
20
20
|
label: i.description ?? i.file_path ?? i.command ?? i.pattern ?? i.query ?? null,
|
|
21
21
|
id: typeof c?.id === 'string' ? c.id : null,
|
|
22
22
|
subagentType: typeof i.subagent_type === 'string' ? i.subagent_type : null,
|
|
23
|
+
// The model a delegation asks for; null when the call names none.
|
|
24
|
+
model: typeof i.model === 'string' ? i.model : null,
|
|
23
25
|
result: null,
|
|
24
26
|
};
|
|
25
27
|
}
|
|
@@ -82,6 +84,8 @@ export function delegationCard(block, nodes) {
|
|
|
82
84
|
task: block.label ?? node?.description ?? '',
|
|
83
85
|
agentId: node?.id ?? null,
|
|
84
86
|
status: node?.status ?? null,
|
|
87
|
+
// What it ran on once the graph knows the agent; before that, what the call asked for.
|
|
88
|
+
model: modelOf({ model: node?.model ?? null, modelAsked: block.model ?? node?.modelAsked ?? null }),
|
|
85
89
|
};
|
|
86
90
|
}
|
|
87
91
|
|