crewforth 3.1.0-rc.4 → 3.1.0-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/README.md +1 -1
- package/kit/AGENT_TEMPLATE.md +48 -19
- package/kit/CLAUDE.md +13 -0
- package/kit/agents/crew-backend-expert.md +8 -0
- package/kit/agents/crew-commit-agent.md +8 -0
- package/kit/agents/crew-database-expert.md +8 -0
- package/kit/agents/crew-devops-expert.md +8 -0
- package/kit/agents/crew-frontend-expert.md +8 -0
- package/kit/agents/crew-performance-expert.md +8 -0
- package/kit/agents/crew-planner.md +14 -0
- package/kit/agents/crew-privacy-agent.md +10 -2
- package/kit/agents/crew-review-agent.md +8 -0
- package/kit/agents/crew-security-expert.md +11 -4
- package/kit/agents/crew-session-manager.md +10 -3
- package/kit/agents/crew-test-expert.md +8 -0
- package/kit/eval/doctor.sh +16 -0
- package/kit/eval/smoke-test.sh +898 -11
- package/kit/hooks/agent-outcome.sh +416 -0
- package/kit/hooks/guard-agent-model.sh +819 -0
- package/kit/hooks/guard-bash.sh +26 -1
- package/kit/hooks/guard-schedule.sh +7 -2
- package/kit/hooks/guard-write.sh +31 -0
- package/kit/hooks/prompt-approval.sh +55 -5
- package/kit/settings.json +54 -0
- package/kit/skills/crew-loosen/SKILL.md +28 -0
- package/kit/studio/server/hooks/studio-gate.sh +90 -4
- package/kit/studio/server/hooks/studio-host.mjs +92 -0
- package/kit/studio/server/index.js +86 -7
- package/kit/studio/server/lib/agent-origin.js +44 -0
- package/kit/studio/server/lib/graph.js +142 -10
- package/kit/studio/server/lib/model-outcomes.js +287 -0
- package/kit/studio/server/lib/palette.js +5 -18
- package/kit/studio/server/lib/permissions.js +260 -17
- package/kit/studio/server/lib/session.js +41 -1
- package/kit/studio/server/lib/usage.js +103 -25
- package/kit/studio/web/app.js +251 -37
- package/kit/studio/web/approvals.js +53 -2
- package/kit/studio/web/ask.js +137 -0
- package/kit/studio/web/canvas.js +220 -59
- package/kit/studio/web/chat.js +19 -2
- package/kit/studio/web/convo.js +14 -1
- package/kit/studio/web/dock.js +30 -9
- package/kit/studio/web/graph-plan.js +66 -18
- package/kit/studio/web/icons/builtin.svg +1 -1
- package/kit/studio/web/index.html +11 -4
- package/kit/studio/web/inspect.js +33 -1
- package/kit/studio/web/keys.js +29 -0
- package/kit/studio/web/list-plan.js +8 -3
- package/kit/studio/web/list.js +35 -7
- package/kit/studio/web/models-plan.js +167 -0
- package/kit/studio/web/models.js +127 -0
- package/kit/studio/web/names.js +84 -0
- package/kit/studio/web/style.css +134 -15
- package/kit/studio/web/timeline-plan.js +8 -0
- package/kit/studio/web/timeline.js +36 -10
- package/kit/studio/web/usage.js +53 -12
- package/package.json +1 -1
package/kit/studio/web/list.js
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
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
|
+
import { AskForms } from './ask.js';
|
|
9
10
|
|
|
10
11
|
function mk(tag, cls, text) {
|
|
11
12
|
const n = document.createElement(tag);
|
|
@@ -34,11 +35,27 @@ export class List {
|
|
|
34
35
|
this.folded = new Map();
|
|
35
36
|
this.selected = null;
|
|
36
37
|
this.busy = new Set();
|
|
38
|
+
this.forms = new AskForms((item, verdict, extra) => {
|
|
39
|
+
if (this.busy.has(item.key)) return;
|
|
40
|
+
this.busy.add(item.key);
|
|
41
|
+
this.forms.setSent(item.key, true);
|
|
42
|
+
this.hooks.onDecide?.(item, verdict, extra);
|
|
43
|
+
});
|
|
37
44
|
this.now = () => Date.now();
|
|
38
45
|
this.root.classList.add('ls');
|
|
39
46
|
}
|
|
40
47
|
|
|
41
|
-
setSession(id) { if (id !== this.current) { this.current = id; this.nodes = []; this.folded = new Map(); this.selected = null; this.render(); } }
|
|
48
|
+
setSession(id) { if (id !== this.current) { this.current = id; this.nodes = []; this.folded = new Map(); this.all = null; this.selected = null; this.render(); } }
|
|
49
|
+
|
|
50
|
+
/** Open every section; one that appears later appears open. What is folded by hand afterwards stays folded. */
|
|
51
|
+
expandAll() { this.all = true; this.folded = new Map(); this.render(); this.hooks.onFold?.(); }
|
|
52
|
+
/** Fold every section that can fold. "Needs you" cannot: it is what the view is for. */
|
|
53
|
+
foldAll() { this.all = false; this.folded = new Map(); this.render(); this.hooks.onFold?.(); }
|
|
54
|
+
/** Whether there is anything left for each of the two to do. */
|
|
55
|
+
foldState() {
|
|
56
|
+
const can = (this.lastSections ?? []).filter((s) => s.foldable);
|
|
57
|
+
return { groups: can.length, canExpand: can.some((s) => s.folded), canFold: can.some((s) => !s.folded) };
|
|
58
|
+
}
|
|
42
59
|
setNodes(nodes) { this.nodes = nodes ?? []; this.render(); }
|
|
43
60
|
|
|
44
61
|
setQueue(queue) {
|
|
@@ -50,7 +67,7 @@ export class List {
|
|
|
50
67
|
select(id) { this.selected = id; this.render(); }
|
|
51
68
|
|
|
52
69
|
/** An answer did not reach the server: the buttons come back. */
|
|
53
|
-
release(key) { this.busy.delete(key); this.render(); }
|
|
70
|
+
release(key) { this.busy.delete(key); this.forms.setSent(key, false); this.render(); }
|
|
54
71
|
|
|
55
72
|
/** Once a second: the countdowns and the running times move. */
|
|
56
73
|
tick() { if (!this.root.hidden && (this.queue.length || this.nodes.some((n) => n.status === 'running'))) this.render(); }
|
|
@@ -59,7 +76,9 @@ export class List {
|
|
|
59
76
|
if (this.root.hidden) return;
|
|
60
77
|
if (this.press.defer()) return;
|
|
61
78
|
const now = this.now();
|
|
62
|
-
|
|
79
|
+
this.forms.keep(this.queue.map((r) => r.key));
|
|
80
|
+
const list = sections(this.nodes, { queue: this.queue, current: this.current, folded: this.folded, all: this.all ?? null, now });
|
|
81
|
+
this.lastSections = list;
|
|
63
82
|
if (!list.length) {
|
|
64
83
|
const empty = mk('div', 'ls-empty');
|
|
65
84
|
empty.append(mk('strong', null, 'No agents yet'),
|
|
@@ -86,7 +105,7 @@ export class List {
|
|
|
86
105
|
head.dataset.key = `sec:${s.key}`;
|
|
87
106
|
head.setAttribute('aria-expanded', String(!s.folded));
|
|
88
107
|
head.append(mk('span', 'row-fill'), mk('span', 'sub', s.folded ? 'show' : 'hide'));
|
|
89
|
-
head.addEventListener('click', () => { this.folded.set(s.key, !s.folded); this.render(); });
|
|
108
|
+
head.addEventListener('click', () => { this.folded.set(s.key, !s.folded); this.render(); this.hooks.onFold?.(); });
|
|
90
109
|
}
|
|
91
110
|
sec.append(head);
|
|
92
111
|
for (const item of s.cards) sec.append(this.#card(item, now));
|
|
@@ -106,7 +125,7 @@ export class List {
|
|
|
106
125
|
clock.setAttribute('aria-label', r.known ? `Auto-deny in ${r.left} seconds` : 'Time left not measured');
|
|
107
126
|
head.append(who, mk('span', 'row-fill'), clock);
|
|
108
127
|
card.append(head);
|
|
109
|
-
if (item.detail) card.append(mk('pre', 'ls-cmd', item.detail));
|
|
128
|
+
if (item.detail && !item.ask) card.append(mk('pre', 'ls-cmd', item.detail));
|
|
110
129
|
// A request from another session says which, and goes there.
|
|
111
130
|
if (item.sessionId !== this.current) {
|
|
112
131
|
const where = mk('button', 'ls-where', `in ${item.sessionName}`);
|
|
@@ -114,6 +133,9 @@ export class List {
|
|
|
114
133
|
where.addEventListener('click', () => this.hooks.onLocate?.(item));
|
|
115
134
|
card.append(where);
|
|
116
135
|
}
|
|
136
|
+
// A question or a plan carries its own form instead of the three answers.
|
|
137
|
+
const form = this.forms.get(item);
|
|
138
|
+
if (form) { card.append(form); return card; }
|
|
117
139
|
const sent = this.busy.has(item.key);
|
|
118
140
|
const acts = mk('div', 'ls-acts');
|
|
119
141
|
for (const [verdict, label, cls] of [
|
|
@@ -147,8 +169,14 @@ export class List {
|
|
|
147
169
|
if (tile) row.append(tile);
|
|
148
170
|
const text = mk('span', 'ls-text');
|
|
149
171
|
text.append(mk('span', 'ls-name', r.type), mk('span', 'ls-line', r.line));
|
|
150
|
-
|
|
172
|
+
const mdl = modelOf(r.node);
|
|
173
|
+
row.title = [hoverOf(r.node), mdl.ran ? mdl.title : null].filter(Boolean).join('\n');
|
|
151
174
|
row.append(text);
|
|
175
|
+
if (mdl.ran) {
|
|
176
|
+
const badge = mk('span', 'sub ls-model', `${mdl.ran}${mdl.differs ? ' \u2260' : ''}`);
|
|
177
|
+
badge.title = mdl.title;
|
|
178
|
+
row.append(badge);
|
|
179
|
+
}
|
|
152
180
|
if (r.aside) row.append(mk('span', 'sub ls-aside', r.aside));
|
|
153
181
|
const chev = mk('span', 'ls-chev');
|
|
154
182
|
chev.innerHTML = '<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M6 4l4 4-4 4"/></svg>';
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// What the Models view and the inspector say about which model did which work. No page in it.
|
|
2
|
+
//
|
|
3
|
+
// The numbers are Crewforth's own record (server/lib/model-outcomes.js reads it); this file turns them into words.
|
|
4
|
+
// A rate with nothing under it is not written as zero, and a call whose verify command gave no verdict is shown
|
|
5
|
+
// apart from the ones that passed or failed.
|
|
6
|
+
import { roleName, modelName } from './names.js';
|
|
7
|
+
import { fmtCost, fmtCount } from './usage.js';
|
|
8
|
+
|
|
9
|
+
const TIER = { haiku: 'Haiku', sonnet: 'Sonnet', opus: 'Opus', fable: 'Fable' };
|
|
10
|
+
export const tierWord = (t) => TIER[t] ?? (t ? String(t) : 'no model');
|
|
11
|
+
const pct = (r) => `${Math.round(r * 100)}%`;
|
|
12
|
+
const plural = (n, word, many = `${word}s`) => `${n} ${n === 1 ? word : many}`;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The task card a crew agent's work starts with: three lines at the top of its task text.
|
|
16
|
+
* files: what it may touch · change: the kind of change · verify: the command that checks it, or none
|
|
17
|
+
* Returns null when the text does not begin with a card; a card with a line missing keeps the others.
|
|
18
|
+
*/
|
|
19
|
+
export function cardOf(prompt) {
|
|
20
|
+
if (typeof prompt !== 'string') return null;
|
|
21
|
+
const card = {};
|
|
22
|
+
for (const line of prompt.split('\n').slice(0, 12)) {
|
|
23
|
+
const m = line.match(/^\s*(files|change|verify)\s*:\s*(.*)$/i);
|
|
24
|
+
if (m && card[m[1].toLowerCase()] === undefined) card[m[1].toLowerCase()] = m[2].trim();
|
|
25
|
+
}
|
|
26
|
+
return Object.keys(card).length ? { files: card.files ?? null, change: card.change ?? null, verify: card.verify ?? null } : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const VERIFY_TONE = { pass: 'pass', fail: 'fail' };
|
|
30
|
+
const VERIFY_SAYS = { none: 'no verify command', blocked: 'verify not run: the command was not allowed', timeout: 'verify not finished: the command ran out of time' };
|
|
31
|
+
|
|
32
|
+
/** The card as the inspector lists it: the three lines, each kept as it was written. */
|
|
33
|
+
export function cardLines(card, row) {
|
|
34
|
+
const out = [];
|
|
35
|
+
if (card?.files) out.push({ label: 'files', text: card.files });
|
|
36
|
+
if (card?.change || row?.change) out.push({ label: 'change', text: row?.change ?? card.change });
|
|
37
|
+
if (card?.verify) out.push({ label: 'verify', text: card.verify });
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function stepOf(r, again) {
|
|
42
|
+
const on = r.ranOn ? modelName(r.ranOn) : tierWord(r.model);
|
|
43
|
+
const bits = [again ? `Re-run on ${on}` : `Ran on ${on}`];
|
|
44
|
+
if (r.fixes > 0) bits.push(`fixed ${r.fixes === 1 ? 'once' : `${r.fixes} times`} inside the agent`);
|
|
45
|
+
// A verdict is a tag (pass, fail); anything else is said in words, because it is no verdict.
|
|
46
|
+
const tone = VERIFY_TONE[r.verify] ?? null;
|
|
47
|
+
if (!tone) bits.push(VERIFY_SAYS[r.verify] ?? `verify ${r.verify}`);
|
|
48
|
+
return { text: bits.join(' \u00b7 '), verify: tone ? r.verify : null, tone, escalated: again };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* How the work went, as steps: the earlier calls of the same card, then this one. `row` is Crewforth's line for
|
|
53
|
+
* the agent with `before` (see server/lib/model-outcomes.js), or null when it has none: it has not stopped, or it
|
|
54
|
+
* is not a crew agent.
|
|
55
|
+
* @returns { risk, steps: [{ text, verify, tone, escalated }], note }
|
|
56
|
+
*/
|
|
57
|
+
export function workSteps(row) {
|
|
58
|
+
if (!row) return { risk: null, steps: [], note: 'Not recorded yet: the line is written when the agent stops.' };
|
|
59
|
+
const steps = [...(row.before ?? []).map((r) => stepOf(r, false)), stepOf(row, Boolean(row.escalatedFrom))];
|
|
60
|
+
if (row.escalatedFrom && !(row.before ?? []).length) {
|
|
61
|
+
steps.unshift({ text: `An earlier call on ${tierWord(row.escalatedFrom)} did not pass; its line is not in the record`, verify: null, tone: null, escalated: false });
|
|
62
|
+
}
|
|
63
|
+
return { risk: row.risk ?? null, steps, note: row.escalatedFrom ? 'A re-run with the same or a lower model is refused by the gate.' : null };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What is said about a class's floor. Raised and standing: the tag `raised` and what raised it. Raised and then
|
|
68
|
+
* lowered by the user: the tag `lowered`, and both facts, because the raise is still on record and the floor that
|
|
69
|
+
* holds is the lowered one. `floorTag` is the tag's word, or null when the class has no floor on record.
|
|
70
|
+
*/
|
|
71
|
+
export function floorWords(floor) {
|
|
72
|
+
if (!floor) return { floorTag: null, raised: null, raisedWhy: '' };
|
|
73
|
+
const why = `Raised to ${tierWord(floor.model)} automatically: ${floor.notFirstTry} of ${floor.calls ?? '?'} calls one model down did not pass first time`;
|
|
74
|
+
if (!floor.lowered) return { floorTag: 'raised', raised: tierWord(floor.model), raisedWhy: why };
|
|
75
|
+
return {
|
|
76
|
+
floorTag: 'lowered',
|
|
77
|
+
raised: null,
|
|
78
|
+
raisedWhy: `${why}. You lowered it to ${tierWord(floor.lowered.to)} on ${String(floor.lowered.ts).slice(0, 10)}; its outcomes are counted afresh from then.`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The cost cell of a class. `cost` is { cost, costed, runs } for it, or null while costs have not been read.
|
|
84
|
+
* Every call priced: the estimate. Some priced: a floor, marked as one, with how many it is the sum of
|
|
85
|
+
* ("\u2265$0.75 \u00b7 2/7"), because a sum of two calls must not be read as the cost of seven. None: a dash.
|
|
86
|
+
*/
|
|
87
|
+
export function costCell(cost, runs) {
|
|
88
|
+
if (!cost) return { cost: '\u2026', costNote: 'Reading what this class cost\u2026', partial: false };
|
|
89
|
+
if (!cost.costed) return { cost: '\u2014', costNote: 'No agent of this class has a transcript on this machine that could be priced', partial: false };
|
|
90
|
+
const usd = fmtCost(cost.cost);
|
|
91
|
+
if (cost.costed >= runs) return { cost: usd, costNote: `All ${runs} ${runs === 1 ? 'task' : 'tasks'} priced, at API list price. An estimate: what a subscription is charged is not known here.`, partial: false };
|
|
92
|
+
return {
|
|
93
|
+
cost: `\u2265${usd.replace(/^~/, '')} \u00b7 ${cost.costed}/${runs}`,
|
|
94
|
+
costNote: `At least this much: ${cost.costed} of ${runs} tasks could be priced; the others have no transcript on this machine. At API list price.`,
|
|
95
|
+
partial: true,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** One class as the view's row: who and what kind of change, how risky, what ran it, and how it went. */
|
|
100
|
+
export function classRow(c, cost = null) {
|
|
101
|
+
const models = Object.entries(c.models ?? {}).sort((a, b) => b[1].runs - a[1].runs).map(([t, m]) => `${tierWord(t)} ${m.runs}`).join(' \u00b7 ');
|
|
102
|
+
const apart = [
|
|
103
|
+
c.notVerified?.none ? `${c.notVerified.none} with no verify command` : null,
|
|
104
|
+
c.notVerified?.blocked ? `${c.notVerified.blocked} not run` : null,
|
|
105
|
+
c.notVerified?.timeout ? `${c.notVerified.timeout} timed out` : null,
|
|
106
|
+
].filter(Boolean);
|
|
107
|
+
return {
|
|
108
|
+
key: c.key,
|
|
109
|
+
who: roleName(c.agent),
|
|
110
|
+
real: c.agent,
|
|
111
|
+
change: c.change,
|
|
112
|
+
risk: c.risk,
|
|
113
|
+
models,
|
|
114
|
+
ranOn: Object.keys(c.ranOn ?? {}).map(modelName).join(', '),
|
|
115
|
+
// Held one model up by the record of outcomes: said beside the model, with the count that did it.
|
|
116
|
+
...floorWords(c.floor),
|
|
117
|
+
tasks: String(c.runs),
|
|
118
|
+
// The rate and what it is a rate of: "83%" of five calls is not "83%" of five hundred.
|
|
119
|
+
firstTry: c.firstTryRate === null ? '\u2014' : pct(c.firstTryRate),
|
|
120
|
+
firstTryOf: c.firstTryRate === null ? 'No call of this class got a verdict from its verify command' : `${c.firstTry} of ${c.verified} verified calls passed the first time`,
|
|
121
|
+
rate: c.firstTryRate,
|
|
122
|
+
notVerified: apart.join(' \u00b7 '),
|
|
123
|
+
after: [c.fixed ? `${c.fixed} fixed by the agent` : null, c.failed ? `${c.failed} failed` : null].filter(Boolean).join(' \u00b7 '),
|
|
124
|
+
escalated: String(c.escalated ?? 0),
|
|
125
|
+
...costCell(cost, c.runs),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The command that lowers one hold: Crewforth's own, typed by the user in their own session. Studio copies it and
|
|
131
|
+
* runs nothing. Three words, the class as Crewforth's record names it; the model is not in it, because the command
|
|
132
|
+
* lowers by one step and works that out itself. Null when a word is not one the command could take.
|
|
133
|
+
*/
|
|
134
|
+
export function lowerCommand(s) {
|
|
135
|
+
const words = [s?.agent, s?.change, s?.risk];
|
|
136
|
+
return words.every((w) => typeof w === 'string' && /^[A-Za-z0-9._-]+$/.test(w)) ? `/crew-loosen ${words.join(' ')}` : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A hold that could be lowered, in words. */
|
|
140
|
+
export function suggestionText(s) {
|
|
141
|
+
return {
|
|
142
|
+
title: `${roleName(s.agent)} \u00b7 ${s.change}`,
|
|
143
|
+
says: `passed ${s.passed} of ${s.passed} on ${tierWord(s.from)}: try ${tierWord(s.to)} for this class?`,
|
|
144
|
+
how: 'Yours to decide: send the command in your own session. Studio does not write it.',
|
|
145
|
+
command: lowerCommand(s),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** What the view says when there is nothing to list. */
|
|
150
|
+
export function emptyNote(data) {
|
|
151
|
+
if (!data) return 'Reading the record…';
|
|
152
|
+
if (data.measured === false) return `Not measured — ${data.reason}`;
|
|
153
|
+
if (!data.classes?.length) return 'The record is there and has no line yet.';
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const SOURCE = 'Source: .claude/state/model-outcomes.tsv';
|
|
158
|
+
export const LOWER_RULE = 'Lowering a floor is never automatic. Raising it is.';
|
|
159
|
+
|
|
160
|
+
/** What was read for cost, for the header's tooltip; null before it has been. */
|
|
161
|
+
export const costLine = (costs) => (costs?.measured ? `${fmtCount(costs.sessionsRead ?? 0)} ${costs.sessionsRead === 1 ? 'session' : 'sessions'} read for cost` : null);
|
|
162
|
+
|
|
163
|
+
export const totalsLine = (data) => [
|
|
164
|
+
`${plural(data.rows ?? 0, 'task')} in ${plural(data.classes?.length ?? 0, 'class', 'classes')}`,
|
|
165
|
+
data.dropped ? `${data.dropped} ${data.dropped === 1 ? 'line' : 'lines'} not read (${Object.entries(data.droppedWhy ?? {}).map(([why, n]) => `${n}: ${why}`).join('; ') || 'no reason kept'})` : null,
|
|
166
|
+
data.truncated ? 'only the end of a long record was read' : null,
|
|
167
|
+
].filter(Boolean).join(' · ');
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// The Models view: which model each kind of task needs in this project, from Crewforth's record of outcomes.
|
|
2
|
+
//
|
|
3
|
+
// A class is an agent, a kind of change and a risk. Each line says what ran it, how many tasks, how many of the
|
|
4
|
+
// verified ones passed the first time, how many were repeated one model up, and what the agents' tokens come to.
|
|
5
|
+
// Under the table: the holds the record could let go of. They are shown, not acted on: the one button copies
|
|
6
|
+
// Crewforth's command for it and lowers nothing.
|
|
7
|
+
import { classRow, suggestionText, emptyNote, totalsLine, costLine, SOURCE, LOWER_RULE } from './models-plan.js';
|
|
8
|
+
|
|
9
|
+
function mk(tag, cls, text) {
|
|
10
|
+
const n = document.createElement(tag);
|
|
11
|
+
if (cls) n.className = cls;
|
|
12
|
+
if (text != null) n.textContent = text;
|
|
13
|
+
return n;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const COLUMNS = [['Agent · change', ''], ['Risk', ''], ['Model', ''], ['Tasks', 'mv-num'], ['First try', 'mv-num'], ['Escalated', 'mv-num'], ['Est. cost', 'mv-num']];
|
|
17
|
+
|
|
18
|
+
export class Models {
|
|
19
|
+
/** @param hooks.onCopy(text) put the command on the clipboard */
|
|
20
|
+
constructor(root, hooks = {}) {
|
|
21
|
+
this.root = root;
|
|
22
|
+
this.hooks = hooks;
|
|
23
|
+
this.data = null;
|
|
24
|
+
this.costs = null;
|
|
25
|
+
this.root.classList.add('mv');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
setData(data) { this.data = data; this.render(); }
|
|
29
|
+
/** What the classes cost, read apart and less often: { measured, costs: { classKey: { cost, costed, runs } } }. */
|
|
30
|
+
setCosts(costs) { this.costs = costs; this.render(); }
|
|
31
|
+
|
|
32
|
+
render() {
|
|
33
|
+
if (this.root.hidden) return;
|
|
34
|
+
const note = emptyNote(this.data);
|
|
35
|
+
if (note) { this.root.replaceChildren(mk('div', 'mv-empty', note)); return; }
|
|
36
|
+
const d = this.data;
|
|
37
|
+
const pane = mk('section', 'mv-pane');
|
|
38
|
+
const head = mk('header', 'mv-head');
|
|
39
|
+
const source = mk('span', 'sub', SOURCE);
|
|
40
|
+
source.title = [totalsLine(d), costLine(this.costs)].filter(Boolean).join(' \u00b7 ');
|
|
41
|
+
// Lines the hooks could not have written are not counted, and that is said where the count is.
|
|
42
|
+
if (d.dropped) source.textContent = `${SOURCE} \u00b7 ${d.dropped} ${d.dropped === 1 ? 'line' : 'lines'} not read`;
|
|
43
|
+
head.append(mk('h2', 'mv-title', 'Which model each kind of task needs'), mk('span', 'row-fill'), source);
|
|
44
|
+
pane.append(head);
|
|
45
|
+
|
|
46
|
+
const scroll = mk('div', 'mv-scroll');
|
|
47
|
+
const table = mk('table', 'mv-table');
|
|
48
|
+
const hr = mk('tr');
|
|
49
|
+
for (const [h, cls] of COLUMNS) { const th = mk('th', cls, h); th.scope = 'col'; hr.append(th); }
|
|
50
|
+
const thead = mk('thead'); thead.append(hr);
|
|
51
|
+
const tbody = mk('tbody');
|
|
52
|
+
const raisedNotes = [];
|
|
53
|
+
for (const c of d.classes) {
|
|
54
|
+
const r = classRow(c, this.costs?.measured ? (this.costs.costs?.[c.key] ?? { cost: 0, costed: 0, runs: c.runs }) : null);
|
|
55
|
+
const tr = mk('tr');
|
|
56
|
+
const who = mk('td');
|
|
57
|
+
const name = mk('span', null, `${r.who} · ${r.change}`);
|
|
58
|
+
name.title = r.real;
|
|
59
|
+
who.append(name);
|
|
60
|
+
const risk = mk('td');
|
|
61
|
+
const tag = mk('span', 'mv-tag', r.risk);
|
|
62
|
+
tag.dataset.risk = r.risk;
|
|
63
|
+
risk.append(tag);
|
|
64
|
+
const model = mk('td');
|
|
65
|
+
const ran = mk('span', null, r.models);
|
|
66
|
+
ran.title = r.ranOn ? `Ran on: ${r.ranOn}` : '';
|
|
67
|
+
model.append(ran);
|
|
68
|
+
if (r.floorTag) {
|
|
69
|
+
const raised = mk('span', 'mv-tag', r.floorTag);
|
|
70
|
+
raised.dataset.kind = r.floorTag;
|
|
71
|
+
raised.title = r.raisedWhy;
|
|
72
|
+
model.append(raised);
|
|
73
|
+
raisedNotes.push({ tag: r.floorTag, text: `${r.who} · ${r.change}: ${r.raisedWhy}` });
|
|
74
|
+
}
|
|
75
|
+
const first = mk('td', 'mv-num');
|
|
76
|
+
first.title = [r.firstTryOf, r.after, r.notVerified ? `Not verified: ${r.notVerified}` : null].filter(Boolean).join('\n');
|
|
77
|
+
first.append(mk('span', null, r.firstTry));
|
|
78
|
+
if (r.rate !== null) {
|
|
79
|
+
const meter = mk('span', 'mv-meter');
|
|
80
|
+
const fill = mk('span', 'mv-meter-fill');
|
|
81
|
+
fill.style.width = `${Math.round(r.rate * 100)}%`;
|
|
82
|
+
// Under four in five the bar is the colour of something to look at.
|
|
83
|
+
fill.dataset.tone = r.rate >= 0.8 ? 'good' : 'waiting';
|
|
84
|
+
meter.setAttribute('aria-hidden', 'true');
|
|
85
|
+
meter.append(fill);
|
|
86
|
+
first.append(meter);
|
|
87
|
+
}
|
|
88
|
+
const cost = mk('td', 'mv-num', r.cost);
|
|
89
|
+
cost.title = r.costNote;
|
|
90
|
+
if (r.partial) cost.dataset.partial = 'true';
|
|
91
|
+
tr.append(who, risk, model, mk('td', 'mv-num', r.tasks), first, mk('td', 'mv-num', r.escalated), cost);
|
|
92
|
+
tbody.append(tr);
|
|
93
|
+
}
|
|
94
|
+
table.append(thead, tbody);
|
|
95
|
+
scroll.append(table);
|
|
96
|
+
pane.append(scroll);
|
|
97
|
+
|
|
98
|
+
if (d.suggestions?.length) {
|
|
99
|
+
const box = mk('section', 'mv-suggest');
|
|
100
|
+
box.append(mk('h3', 'mv-suggest-h', 'Suggestion · needs your approval'));
|
|
101
|
+
for (const s of d.suggestions) {
|
|
102
|
+
const t = suggestionText(s);
|
|
103
|
+
box.append(mk('p', 'mv-suggest-text', `${t.title} ${t.says}`));
|
|
104
|
+
const row = mk('div', 'mv-suggest-row');
|
|
105
|
+
row.append(mk('span', 'sub', `${LOWER_RULE} ${t.how}`), mk('span', 'row-fill'));
|
|
106
|
+
if (t.command) {
|
|
107
|
+
// It copies the words and does nothing else: the hold is lowered where the user sends them.
|
|
108
|
+
const b = mk('button', 'btn sm', 'Copy command');
|
|
109
|
+
b.type = 'button';
|
|
110
|
+
b.title = 'Copies the command. Nothing is lowered until you send it in your own session.';
|
|
111
|
+
b.addEventListener('click', () => this.hooks.onCopy?.(t.command));
|
|
112
|
+
row.append(mk('code', 'mv-cmd', t.command), b);
|
|
113
|
+
}
|
|
114
|
+
box.append(row);
|
|
115
|
+
}
|
|
116
|
+
pane.append(box);
|
|
117
|
+
}
|
|
118
|
+
for (const note of raisedNotes) {
|
|
119
|
+
const row = mk('div', 'mv-raised');
|
|
120
|
+
const tag = mk('span', 'mv-tag', note.tag);
|
|
121
|
+
tag.dataset.kind = note.tag;
|
|
122
|
+
row.append(tag, mk('span', 'sub', note.text));
|
|
123
|
+
pane.append(row);
|
|
124
|
+
}
|
|
125
|
+
this.root.replaceChildren(pane);
|
|
126
|
+
}
|
|
127
|
+
}
|
package/kit/studio/web/names.js
CHANGED
|
@@ -30,6 +30,23 @@ export function roleName(type) {
|
|
|
30
30
|
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
|
+
/**
|
|
34
|
+
* Where a Claude Code agent is defined, in words, or null while that has not been read.
|
|
35
|
+
* `origins` is the server's answer: { project, user }, each a map of agent name to file name, or null where the
|
|
36
|
+
* directory was not read. A plugin's agent is told by its name. An agent in none of these is said to be in none:
|
|
37
|
+
* it is built in to Claude Code, or defined somewhere this panel does not look, and which is not known.
|
|
38
|
+
*/
|
|
39
|
+
export function originOf(type, origins) {
|
|
40
|
+
if (!origins || typeof type !== 'string' || !type) return null;
|
|
41
|
+
const at = type.indexOf(':');
|
|
42
|
+
if (at > 0 && at < type.length - 1) return `Defined in the plugin \u201c${type.slice(0, at)}\u201d`;
|
|
43
|
+
if (origins.project && Object.hasOwn(origins.project, type)) return `Defined in this project: .claude/agents/${origins.project[type]}`;
|
|
44
|
+
if (origins.user && Object.hasOwn(origins.user, type)) return `Defined in your own agents: ~/.claude/agents/${origins.user[type]}`;
|
|
45
|
+
const unread = [origins.project ? null : 'the project', origins.user ? null : 'your own'].filter(Boolean);
|
|
46
|
+
return unread.length === 2 ? 'Where it is defined was not read: no agents directory in the project or your own'
|
|
47
|
+
: `No definition file found${unread.length ? ` (${unread[0]} agents directory was not read)` : ''}: built in to Claude Code, or defined where Studio does not look`;
|
|
48
|
+
}
|
|
49
|
+
|
|
33
50
|
export const hoverOf = (n) => [n?.agentType ?? null, n?.description ?? null].filter(Boolean).join('\n');
|
|
34
51
|
|
|
35
52
|
/**
|
|
@@ -42,3 +59,70 @@ export function orderTag(n, clock = null) {
|
|
|
42
59
|
if (n?.order == null) return '';
|
|
43
60
|
return `#${n.order}${clock && n.calledAt != null ? ` · ${clock(n.calledAt)}` : ''}`;
|
|
44
61
|
}
|
|
62
|
+
|
|
63
|
+
/* ---------------------------------------------------------------- models --- */
|
|
64
|
+
|
|
65
|
+
const MODEL_FAMILIES = ['haiku', 'sonnet', 'opus', 'fable', 'mythos'];
|
|
66
|
+
|
|
67
|
+
/** The family a model belongs to, from its id or from the short name a call asks with: 'sonnet'. Null when none. */
|
|
68
|
+
export function modelFamily(model) {
|
|
69
|
+
if (typeof model !== 'string') return null;
|
|
70
|
+
return model.toLowerCase().split(/[^a-z]+/).find((w) => MODEL_FAMILIES.includes(w)) ?? null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A model as a badge says it: `claude-haiku-5-5` → Haiku 5.5 · `claude-haiku-4-5-20251001` → Haiku 4.5 ·
|
|
75
|
+
* `sonnet` → Sonnet. The vendor prefix and a snapshot date go; the words lead and the numbers follow with a dot.
|
|
76
|
+
* A name that is not made of such parts is returned as it came.
|
|
77
|
+
*/
|
|
78
|
+
export function modelName(model) {
|
|
79
|
+
if (typeof model !== 'string' || !model) return '';
|
|
80
|
+
const parts = model.replace(/^claude-/, '').replace(/-\d{8}$/, '').split('-');
|
|
81
|
+
if (!parts.every((p) => /^[a-z]+$/.test(p) || /^\d+$/.test(p))) return model;
|
|
82
|
+
const words = parts.filter((p) => /^[a-z]+$/.test(p)).map((w) => w[0].toUpperCase() + w.slice(1));
|
|
83
|
+
const nums = parts.filter((p) => /^\d+$/.test(p));
|
|
84
|
+
return [words.join(' '), nums.join('.')].filter(Boolean).join(' ');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* What a node ran on and what its call asked for, and whether the two agree.
|
|
89
|
+
* A call asks with a family ("sonnet") or with an id; it agrees with what ran when the family is the same and,
|
|
90
|
+
* if it named an id, the id is. A call that named nothing asked for the agent's default, and agrees with anything.
|
|
91
|
+
* @returns { ran, asked, differs, title } — `ran` and `asked` are badge words, '' when not known
|
|
92
|
+
*/
|
|
93
|
+
export function modelOf(n) {
|
|
94
|
+
const ran = modelName(n?.model);
|
|
95
|
+
const asked = n?.modelAsked ? modelName(n.modelAsked) : '';
|
|
96
|
+
const differs = Boolean(n?.model && n?.modelAsked)
|
|
97
|
+
&& (modelFamily(n.model) !== modelFamily(n.modelAsked) || (n.modelAsked.includes('-') && n.modelAsked !== n.model));
|
|
98
|
+
const title = [
|
|
99
|
+
`Ran on: ${n?.model ?? 'not recorded'}`,
|
|
100
|
+
`Asked for: ${n?.modelAsked ?? 'no model named (the agent\'s default)'}`,
|
|
101
|
+
differs ? 'The two differ.' : null,
|
|
102
|
+
].filter(Boolean).join('\n');
|
|
103
|
+
return { ran, asked, differs, title };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** "2 Sonnet, 1 Opus": how many of a group's agents ran on each family, most first. */
|
|
107
|
+
export function modelMix(members) {
|
|
108
|
+
const counts = new Map();
|
|
109
|
+
for (const m of members ?? []) {
|
|
110
|
+
const f = modelFamily(m.model);
|
|
111
|
+
const word = f ? f[0].toUpperCase() + f.slice(1) : (m.model ? modelName(m.model) : null);
|
|
112
|
+
if (word) counts.set(word, (counts.get(word) ?? 0) + 1);
|
|
113
|
+
}
|
|
114
|
+
return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([w, c]) => `${c} ${w}`).join(', ');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The badge a card carries for its model: the model in full ("Opus 5.5"), at every density. For work that was
|
|
119
|
+
* repeated one model up, the step that was taken ("Sonnet \u2192 Opus"); and a mark when the call asked for another
|
|
120
|
+
* model than the one that ran.
|
|
121
|
+
*/
|
|
122
|
+
export function modelBadge(n) {
|
|
123
|
+
const m = modelOf(n);
|
|
124
|
+
if (!m.ran) return '';
|
|
125
|
+
const word = (model) => { const f = modelFamily(model); return f ? f[0].toUpperCase() + f.slice(1) : modelName(model); };
|
|
126
|
+
const text = n?.escalatedFrom?.from ? `${word(n.escalatedFrom.from)} \u2192 ${word(n.model)}` : m.ran;
|
|
127
|
+
return `${text}${m.differs ? ' \u2260' : ''}`;
|
|
128
|
+
}
|