toolroll 0.9.82 → 0.9.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +4 -3
  3. package/dist/assignment-ui.js +5 -1
  4. package/dist/browser/workspace.css +1 -1
  5. package/dist/browser/workspace.js +22 -22
  6. package/dist/browser-crew.js +5 -1
  7. package/dist/browser-workspace.d.ts +7 -0
  8. package/dist/contracts/console-api.d.ts +2 -1
  9. package/dist/contracts/console-api.js +5 -1
  10. package/dist/control-setup.d.ts +9 -0
  11. package/dist/control-setup.js +31 -4
  12. package/dist/control-ui.d.ts +7 -1
  13. package/dist/control-ui.js +3 -2
  14. package/dist/dispatch.d.ts +15 -0
  15. package/dist/dispatch.js +26 -4
  16. package/dist/first-run.d.ts +5 -2
  17. package/dist/first-run.js +13 -2
  18. package/dist/first-tasks-child.d.ts +1 -0
  19. package/dist/first-tasks-child.js +11 -0
  20. package/dist/folder-look.d.ts +110 -0
  21. package/dist/folder-look.js +306 -0
  22. package/dist/guides.js +4 -3
  23. package/dist/learning-child.d.ts +1 -0
  24. package/dist/learning-child.js +34 -0
  25. package/dist/learning-look.d.ts +25 -0
  26. package/dist/learning-look.js +72 -0
  27. package/dist/model-catalog.d.ts +5 -0
  28. package/dist/model-catalog.js +11 -2
  29. package/dist/model-choices.d.ts +188 -0
  30. package/dist/model-choices.js +405 -0
  31. package/dist/model-names.d.ts +6 -0
  32. package/dist/model-names.js +34 -0
  33. package/dist/models-ui.d.ts +69 -5
  34. package/dist/models-ui.js +53 -10
  35. package/dist/needs-you.d.ts +8 -0
  36. package/dist/needs-you.js +8 -0
  37. package/dist/operate.d.ts +53 -0
  38. package/dist/operate.js +277 -49
  39. package/dist/project-admission.d.ts +4 -1
  40. package/dist/project-admission.js +23 -2
  41. package/dist/project-identity.d.ts +44 -0
  42. package/dist/project-identity.js +153 -0
  43. package/dist/project-knowledge.d.ts +6 -6
  44. package/dist/project-tools.d.ts +4 -1
  45. package/dist/project-tools.js +7 -2
  46. package/dist/project.d.ts +4 -1
  47. package/dist/project.js +11 -4
  48. package/dist/repos.d.ts +7 -0
  49. package/dist/repos.js +57 -0
  50. package/dist/serve.js +91 -17
  51. package/dist/server/chat.js +5 -4
  52. package/dist/server/chrome.d.ts +3 -0
  53. package/dist/server/chrome.js +5 -2
  54. package/dist/server/console.css +5 -2
  55. package/dist/server/flows.js +13 -3
  56. package/dist/server/guards.d.ts +4 -0
  57. package/dist/server/guards.js +12 -3
  58. package/dist/server/http.d.ts +12 -0
  59. package/dist/server/http.js +9 -0
  60. package/dist/server/pages.js +102 -92
  61. package/dist/server/people-tokens.js +40 -20
  62. package/dist/server/render-pages.d.ts +12 -16
  63. package/dist/server/render-pages.js +30 -69
  64. package/dist/server/render-people.d.ts +14 -2
  65. package/dist/server/render-people.js +35 -12
  66. package/dist/server/render-settings.js +1 -0
  67. package/dist/server/render-tasks.js +7 -14
  68. package/dist/server/request-context.d.ts +2 -0
  69. package/dist/server/route-table.js +8 -1
  70. package/dist/server/runtime.d.ts +4 -0
  71. package/dist/server/runtime.js +1 -0
  72. package/dist/server/settings.d.ts +58 -1
  73. package/dist/server/settings.js +211 -71
  74. package/dist/server/signup-gate.d.ts +9 -0
  75. package/dist/server/signup-gate.js +40 -0
  76. package/dist/server/tasks.js +19 -5
  77. package/dist/settings-kit-script.d.ts +21 -0
  78. package/dist/settings-kit-script.js +67 -0
  79. package/dist/settings-kit.d.ts +137 -0
  80. package/dist/settings-kit.js +186 -0
  81. package/dist/settings-proofs.d.ts +64 -0
  82. package/dist/settings-proofs.js +101 -0
  83. package/dist/setup-guide.js +4 -2
  84. package/dist/spend-report.d.ts +87 -0
  85. package/dist/spend-report.js +265 -0
  86. package/dist/spend-ui.d.ts +29 -8
  87. package/dist/spend-ui.js +74 -39
  88. package/dist/task-sizing.d.ts +5 -3
  89. package/dist/task-sizing.js +4 -4
  90. package/dist/up-processes.d.ts +13 -0
  91. package/dist/up-processes.js +6 -2
  92. package/package.json +1 -1
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The Spend page's reader (docs/plans/spend-dashboard.md, slice 1): one row per piece of agent work in a month, with
3
+ * three things kept apart that today's budget sum folds together:
4
+ *
5
+ * - `billing`: what the work's own records prove about how it was billed. A machine default, a login mode or a
6
+ * setting is never proof: an unproved login is `unproved`, never a confirmed subscription.
7
+ * - `amount`: API dollars, subscription usage or unknown, exactly as the eval collector reads it (reported-usage.ts
8
+ * `runAmountOf` / `chatTurnAmountOf`, the same inputs `runAccounting` / `chatTurnAccounting` read).
9
+ * - `charge`: the saved amount a limit could count, with its basis (reported, settled or estimated). It is what was
10
+ * saved when the work settled, never re-priced at today's catalogue, and a guessed-subscription zero is never one.
11
+ *
12
+ * Attribution follows spend.ts `spendItems`: runs at their start, a lead turn's requests at the turn's creation,
13
+ * everything else at its own time. Evals are read from their own tables, per invocation, and never count toward a
14
+ * limit. This module reads only; it changes nothing the budget gate counts.
15
+ */
16
+ import type { Database } from "./store.js";
17
+ import { type ReportedAmount } from "./reported-usage.js";
18
+ import { type Billing } from "./spend.js";
19
+ export type SpendSource = "run" | "chat" | "lead" | "subagent" | "draft" | "sort" | "eval";
20
+ export type SpendRole = "builder" | "repair" | "planner" | "reviewer" | "scout" | "lead" | "chat" | "subagent" | "flow" | "evals";
21
+ /** What the work's own records prove about its billing. */
22
+ export type BillingEvidence = Billing | "unproved";
23
+ export type ChargeBasis = "reported" | "settled" | "estimated";
24
+ export type SpendCharge = {
25
+ microusd: number;
26
+ basis: ChargeBasis;
27
+ };
28
+ export type SpendRow = {
29
+ /** The record behind the row, as `table:id` (run:12, chat_turn:5, mate_turn:3, eval_invocation:9, ...). */
30
+ ref: string;
31
+ at: string;
32
+ source: SpendSource;
33
+ role: SpendRole;
34
+ project: string | null;
35
+ person: string | null;
36
+ subagent: number | null;
37
+ taskId: string | null;
38
+ runId: number | null;
39
+ provider: string;
40
+ modelChosen: string | null;
41
+ modelReported: string | null;
42
+ billing: BillingEvidence;
43
+ /** Null only for a lead turn's held reservation beyond its requests' own charges (a saved charge, no request). */
44
+ amount: ReportedAmount | null;
45
+ charge: SpendCharge | null;
46
+ };
47
+ /** Every piece of work in [from, to), one row each. */
48
+ export declare function spendRows(db: Database, from: string, to: string): SpendRow[];
49
+ /** One line of a breakdown: the three amounts kept apart. */
50
+ export type SpendLine = {
51
+ key: string;
52
+ count: number;
53
+ apiMicrousd: number;
54
+ /** Subscription usage the providers reported (at API prices), and how many subscription pieces reported none. */
55
+ usageMicrousd: number;
56
+ usageUnreported: number;
57
+ usageCount: number;
58
+ unknown: number;
59
+ /** By model: how each piece chose the model it ran on ("opus", the exact id), sorted; empty elsewhere. */
60
+ chosen: string[];
61
+ /** Saved charges on work that isn't measured API dollars (estimates). */
62
+ estimatedMicrousd: number;
63
+ };
64
+ export type SpendReport = {
65
+ totals: SpendLine;
66
+ byProject: SpendLine[];
67
+ byModel: SpendLine[];
68
+ byRole: SpendLine[];
69
+ byPerson: SpendLine[];
70
+ /** The runs with the most API dollars, biggest first. */
71
+ topRuns: {
72
+ runId: number;
73
+ taskId: string;
74
+ role: SpendRole;
75
+ project: string | null;
76
+ provider: string;
77
+ model: string;
78
+ microusd: number;
79
+ }[];
80
+ };
81
+ /** The model a row ran on, in words: the reported model; a floating choice beside what it ran as (no verdict); the
82
+ * chosen model "not reported" when the provider said nothing. */
83
+ export declare function modelLabel(row: Pick<SpendRow, "modelChosen" | "modelReported">): string;
84
+ /** The month's totals, breakdowns and most expensive runs, from `spendRows`. Pure. */
85
+ export declare function spendReport(rows: readonly SpendRow[], top?: number): SpendReport;
86
+ /** One CSV row per piece of work, the same rows the page adds up. */
87
+ export declare function spendReportCsv(rows: readonly SpendRow[], subagentNames: Map<number, string>): string;
@@ -0,0 +1,265 @@
1
+ import { chatTurnAmountOf, reportedCostUsdOf, runAmountOf } from "./reported-usage.js";
2
+ import { filersOf, subagentFilers } from "./spend.js";
3
+ const RUN_ROLES = new Set(["builder", "repair", "planner", "reviewer", "scout"]);
4
+ const DIRECT_CHAT = new Set(["anthropic-api", "openrouter-api"]);
5
+ const text = (value) => value === null || value === undefined ? null : String(value);
6
+ const count = (value) => value === null || value === undefined ? null : Number(value);
7
+ const micro = (value) => {
8
+ const n = count(value);
9
+ return n !== null && Number.isSafeInteger(n) && n >= 0 ? n : null;
10
+ };
11
+ const usdMicro = (value) => {
12
+ const usd = reportedCostUsdOf(typeof value === "number" ? value : value === null || value === undefined ? null : Number(value));
13
+ return usd === null ? null : Math.round(usd * 1_000_000);
14
+ };
15
+ /** A charge on work that isn't measured API dollars must be a positive saved amount; a zero there proves nothing. */
16
+ const chargeOf = (amount, microusd, basis) => microusd === null || (amount?.kind !== "api" && microusd <= 0) ? null : { microusd, basis };
17
+ /** Every piece of work in [from, to), one row each. */
18
+ export function spendRows(db, from, to) {
19
+ const rows = [];
20
+ const subagentOf = subagentFilers(db);
21
+ const filerOf = filersOf(db);
22
+ // A task's project as its newest record names it. task_ref's index leads with its backend, so the lookup walks the
23
+ // few backends (each step an index seek) instead of scanning every task, once per task.
24
+ const backends = db.prepare(`WITH RECURSIVE b(x) AS (SELECT (SELECT MIN(backend) FROM task_ref) UNION ALL SELECT (SELECT MIN(backend) FROM task_ref WHERE backend > b.x) FROM b WHERE b.x IS NOT NULL)
25
+ SELECT x FROM b WHERE x IS NOT NULL`).all().map(row => String(row["x"]));
26
+ const repoOfTask = backends.length === 0 ? null
27
+ : db.prepare(`SELECT repo FROM task_ref WHERE backend IN (${backends.map(() => "?").join(", ")}) AND external_id = ? ORDER BY id DESC LIMIT 1`);
28
+ const taskRepos = new Map();
29
+ const taskRepo = (taskId) => {
30
+ if (!taskRepos.has(taskId))
31
+ taskRepos.set(taskId, repoOfTask === null ? null : text(repoOfTask.get(...backends, taskId)?.["repo"]));
32
+ return taskRepos.get(taskId) ?? null;
33
+ };
34
+ // Runs: the columns runAccounting reads, with their task's project and filer.
35
+ for (const row of db.prepare(`SELECT run.id, run.task_ref, run.role, run.provider, run.model, run.model_reported, run.cost_usd, run.started_at, run.billing_basis, run.cost_unknown,
36
+ r.external_id AS task, r.repo, r.filed_by, r.filed_by_kind, r.revision_of, s.microusd, s.source, s.billing
37
+ FROM run JOIN task_ref r ON r.id = run.task_ref LEFT JOIN run_spend s ON s.run = run.id
38
+ WHERE run.started_at >= ? AND run.started_at < ? ORDER BY run.id`).all(from, to)) {
39
+ const settledBilling = row["billing"] === "subscription" || row["billing"] === "api-key" ? row["billing"] : null;
40
+ const basis = row["billing_basis"] === "evidence" || row["billing_basis"] === "default" ? row["billing_basis"] : null;
41
+ const costUnknown = Number(row["cost_unknown"] ?? 1) !== 0;
42
+ const amount = runAmountOf({ billing: settledBilling, spendMicrousd: count(row["microusd"]), costUsd: count(row["cost_usd"]), basis, costUnknown });
43
+ // Only the run's own evidence proves its billing; a guess (`default`) or no basis at all is unproved.
44
+ const billing = basis === "evidence" && settledBilling !== null ? settledBilling : "unproved";
45
+ let charge = null;
46
+ if (billing !== "subscription") {
47
+ const settled = micro(row["microusd"]);
48
+ if (settled !== null && (row["source"] === "reported" || row["source"] === "estimated")) {
49
+ // A run whose cost wasn't reported holds its cap in cost_usd, settled as "reported": that is a conservative estimate.
50
+ charge = chargeOf(amount, settled, billing === "unproved" || costUnknown || row["source"] === "estimated" ? "estimated" : "reported");
51
+ }
52
+ else {
53
+ charge = chargeOf(amount, usdMicro(row["cost_usd"]), "estimated");
54
+ }
55
+ }
56
+ const filer = row["filed_by"] == null && row["revision_of"] != null ? filerOf(Number(row["task_ref"]))
57
+ : { repo: text(row["repo"]), filedBy: text(row["filed_by"]), kind: text(row["filed_by_kind"]) };
58
+ const role = String(row["role"]);
59
+ rows.push({
60
+ ref: `run:${Number(row["id"])}`, at: String(row["started_at"]), source: "run", role: RUN_ROLES.has(role) ? role : "builder",
61
+ project: text(row["repo"]), person: filer.kind === "person" || filer.kind === "coordinator" ? filer.filedBy : null,
62
+ subagent: subagentOf(filer.repo, filer.filedBy, filer.kind), taskId: String(row["task"]), runId: Number(row["id"]),
63
+ provider: String(row["provider"]), modelChosen: text(row["model"]), modelReported: text(row["model_reported"]), billing, amount, charge,
64
+ });
65
+ }
66
+ // One chat request (fleet chat, or one request of a lead turn), as chatTurnAccounting reads it.
67
+ const chatRequest = (row) => {
68
+ const provider = String(row["provider"]);
69
+ const reportedCostUsd = count(row["reported_cost_usd"]);
70
+ const billingReported = row["billing_reported"] === "subscription" || row["billing_reported"] === "api-key" ? row["billing_reported"] : null;
71
+ const unknownSpend = Number(row["unknown_spend"]) === 1;
72
+ const amount = chatTurnAmountOf({ provider, settledMicrousd: count(row["settled_microusd"]), unknownSpend, reportedCostUsd, billingReported });
73
+ if (DIRECT_CHAT.has(provider)) {
74
+ // Toolroll sent its own key: billed to it. The settlement is the charge (a held reservation when the cost is unknown).
75
+ return { amount, billing: "api-key", charge: chargeOf(amount, micro(row["settled_microusd"]), unknownSpend ? "estimated" : "settled") };
76
+ }
77
+ // A subscription harness settles at zero whatever it billed; its own events are the evidence.
78
+ const billing = billingReported ?? "unproved";
79
+ const reported = usdMicro(reportedCostUsd);
80
+ return { amount, billing, charge: billing === "subscription" ? null : chargeOf(amount, reported, billing === "api-key" ? "reported" : "estimated") };
81
+ };
82
+ for (const row of db.prepare(`SELECT id, created_at, approver, provider, model, model_reported, settled_microusd, unknown_spend, reported_cost_usd, billing_reported
83
+ FROM chat_turn WHERE mate_turn IS NULL AND created_at >= ? AND created_at < ? ORDER BY id`).all(from, to)) {
84
+ const request = chatRequest(row);
85
+ rows.push({ ref: `chat_turn:${Number(row["id"])}`, at: String(row["created_at"]), source: "chat", role: "chat", project: null, person: text(row["approver"]), subagent: null,
86
+ taskId: null, runId: null, provider: String(row["provider"]), modelChosen: text(row["model"]), modelReported: text(row["model_reported"]), ...request });
87
+ }
88
+ // A lead turn: each request at the turn's creation, and whatever the turn settled beyond its requests' charges (a held
89
+ // reservation) once, as an estimate attributed to the turn.
90
+ const turns = new Map();
91
+ // The month's turns (mate_turn_created), each thread by its key; a task thread's project resolved once per task.
92
+ let until = null;
93
+ for (const row of db.prepare(`SELECT t.id, t.created_at, t.approver, t.settled_microusd, th.scope_kind, th.scope_key,
94
+ MAX(t.deadline_at, COALESCE(t.finished_at, t.created_at), t.created_at) AS until
95
+ FROM mate_turn t LEFT JOIN lead_thread th ON th.id = t.thread WHERE t.created_at >= ? AND t.created_at < ? ORDER BY t.id`).all(from, to)) {
96
+ const project = row["scope_kind"] === "project" ? text(row["scope_key"]) : row["scope_kind"] === "task" && row["scope_key"] != null ? taskRepo(String(row["scope_key"])) : null;
97
+ turns.set(Number(row["id"]), { at: String(row["created_at"]), person: text(row["approver"]), project, settled: micro(row["settled_microusd"]), provider: null, model: null, charged: 0, unknown: false });
98
+ if (until === null || String(row["until"]) > until)
99
+ until = String(row["until"]);
100
+ }
101
+ // Their requests: written after their turn began and before it ended or ran out of time, so a bounded range of
102
+ // chat_turn_created (an hour's margin either side) holds every one; each is kept only when its turn is this month's.
103
+ const stepsFrom = new Date(Date.parse(from) - 3_600_000).toISOString();
104
+ const stepsUntil = until === null ? from : new Date(Date.parse(until) + 3_600_000).toISOString();
105
+ for (const row of until === null ? [] : db.prepare(`SELECT c.id, c.mate_turn AS turn, c.provider, c.model, c.model_reported, c.settled_microusd, c.unknown_spend, c.reported_cost_usd, c.billing_reported
106
+ FROM chat_turn c WHERE c.created_at >= ? AND c.created_at < ? AND c.mate_turn IS NOT NULL ORDER BY c.mate_turn, c.id`).all(stepsFrom, stepsUntil)) {
107
+ const turn = turns.get(Number(row["turn"]));
108
+ if (turn === undefined)
109
+ continue;
110
+ const request = chatRequest(row);
111
+ turn.provider ??= String(row["provider"]);
112
+ turn.model ??= text(row["model"]);
113
+ turn.charged += micro(row["settled_microusd"]) ?? 0;
114
+ if (request.amount.kind === "unknown")
115
+ turn.unknown = true;
116
+ rows.push({ ref: `chat_turn:${Number(row["id"])}`, at: turn.at, source: "lead", role: "lead", project: turn.project, person: turn.person, subagent: null,
117
+ taskId: null, runId: null, provider: String(row["provider"]), modelChosen: text(row["model"]), modelReported: text(row["model_reported"]), ...request });
118
+ }
119
+ for (const [id, turn] of turns) {
120
+ const held = turn.settled === null ? 0 : turn.settled - turn.charged;
121
+ if (held <= 0)
122
+ continue;
123
+ // The turn's uncertainty is counted once: by its unknown request when it has one, otherwise by this row.
124
+ const amount = turn.unknown ? null : { kind: "unknown" };
125
+ rows.push({ ref: `mate_turn:${id}`, at: turn.at, source: "lead", role: "lead", project: turn.project, person: turn.person, subagent: null, taskId: null, runId: null,
126
+ provider: turn.provider ?? "lead", modelChosen: turn.model, modelReported: null, billing: "api-key", amount, charge: { microusd: held, basis: "estimated" } });
127
+ }
128
+ // A subagent's turns and a flow's drafts run on this computer's Claude sign-in with no key from Toolroll: their billing
129
+ // stamp is a machine default, so it is unproved, and a cost they report is an estimate.
130
+ for (const row of db.prepare(`SELECT t.id, t.at, t.subagent, t.model, t.cost_usd, m.repo FROM subagent_turn t JOIN subagent m ON m.id = t.subagent
131
+ WHERE t.at >= ? AND t.at < ? ORDER BY t.id`).all(from, to)) {
132
+ const amount = { kind: "unknown" };
133
+ rows.push({ ref: `subagent_turn:${Number(row["id"])}`, at: String(row["at"]), source: "subagent", role: "subagent", project: text(row["repo"]), person: null,
134
+ subagent: Number(row["subagent"]), taskId: null, runId: null, provider: "claude", modelChosen: text(row["model"]), modelReported: null, billing: "unproved",
135
+ amount, charge: chargeOf(amount, usdMicro(row["cost_usd"]), "estimated") });
136
+ }
137
+ for (const row of db.prepare("SELECT id, at, repo, provider, model, cost_usd FROM side_spend WHERE at >= ? AND at < ? ORDER BY id").all(from, to)) {
138
+ const amount = { kind: "unknown" };
139
+ rows.push({ ref: `side_spend:${Number(row["id"])}`, at: String(row["at"]), source: "draft", role: "flow", project: text(row["repo"]), person: null, subagent: null,
140
+ taskId: null, runId: null, provider: String(row["provider"]), modelChosen: text(row["model"]), modelReported: null, billing: "unproved",
141
+ amount, charge: chargeOf(amount, usdMicro(row["cost_usd"]), "estimated") });
142
+ }
143
+ // A flow's Sort step asks Jev on OpenRouter with Toolroll's key: its reported cost, or unknown.
144
+ for (const row of db.prepare(`SELECT fs.card, fs.entry, fs.stage, fs.started_at AS at, json_extract(fs.decision_json, '$.cost') AS cost, json_extract(fs.decision_json, '$.model') AS model, f.repo
145
+ FROM flow_step_run fs JOIN flow_card c ON c.id = fs.card JOIN flow f ON f.id = c.flow
146
+ WHERE fs.kind = 'sort' AND fs.decision_json IS NOT NULL AND fs.started_at >= ? AND fs.started_at < ? ORDER BY fs.started_at, fs.card, fs.entry`).all(from, to)) {
147
+ const cost = typeof row["cost"] === "number" ? usdMicro(row["cost"]) : null;
148
+ const amount = cost === null ? { kind: "unknown" } : { kind: "api", microusd: cost };
149
+ rows.push({ ref: `flow_step_run:${Number(row["card"])}:${Number(row["entry"])}:${String(row["stage"])}`, at: String(row["at"]), source: "sort", role: "flow", project: text(row["repo"]), person: null, subagent: null,
150
+ taskId: null, runId: null, provider: "openrouter", modelChosen: text(row["model"]), modelReported: null, billing: "api-key", amount, charge: chargeOf(amount, cost, "reported") });
151
+ }
152
+ // Evals, from their own records: one row per provider invocation, kinds kept apart; a campaign whose spending was
153
+ // left unknown adds one unknown. Never counted toward a limit, so never a charge.
154
+ // The month's attempts first, then each one's invocations through their (attempt, world_ref) key.
155
+ for (const row of db.prepare(`SELECT i.id, i.amount, i.api_microusd, i.subscription_microusd, i.model_reported, a.started_at, a.model_pinned, c.started_by
156
+ FROM eval_attempt a CROSS JOIN eval_invocation i ON i.attempt = a.id JOIN eval_campaign c ON c.id = a.campaign
157
+ WHERE a.started_at >= ? AND a.started_at < ? ORDER BY i.id`).all(from, to)) {
158
+ const api = micro(row["api_microusd"]);
159
+ const subscription = micro(row["subscription_microusd"]);
160
+ const amount = row["amount"] === "api" && api !== null ? { kind: "api", microusd: api }
161
+ : row["amount"] === "subscription" ? { kind: "subscription", reportedUsd: subscription === null ? null : subscription / 1_000_000 } : { kind: "unknown" };
162
+ rows.push({ ref: `eval_invocation:${Number(row["id"])}`, at: String(row["started_at"]), source: "eval", role: "evals", project: null, person: text(row["started_by"]), subagent: null,
163
+ taskId: null, runId: null, provider: "evals", modelChosen: text(row["model_pinned"]), modelReported: text(row["model_reported"]),
164
+ billing: row["amount"] === "api" ? "api-key" : row["amount"] === "subscription" ? "subscription" : "unproved", amount, charge: null });
165
+ }
166
+ for (const row of db.prepare("SELECT id, admitted_at, started_by FROM eval_campaign WHERE spending_unknown = 1 AND admitted_at >= ? AND admitted_at < ? ORDER BY admitted_at, id").all(from, to)) {
167
+ rows.push({ ref: `eval_campaign:${String(row["id"])}`, at: String(row["admitted_at"]), source: "eval", role: "evals", project: null, person: text(row["started_by"]), subagent: null,
168
+ taskId: null, runId: null, provider: "evals", modelChosen: null, modelReported: null, billing: "unproved", amount: { kind: "unknown" }, charge: null });
169
+ }
170
+ return rows;
171
+ }
172
+ const empty = (key) => ({ key, count: 0, apiMicrousd: 0, usageMicrousd: 0, usageUnreported: 0, usageCount: 0, unknown: 0, chosen: [], estimatedMicrousd: 0 });
173
+ function add(line, row) {
174
+ line.count++;
175
+ const amount = row.amount;
176
+ if (amount?.kind === "api")
177
+ line.apiMicrousd += amount.microusd;
178
+ else if (row.charge !== null)
179
+ line.estimatedMicrousd += row.charge.microusd;
180
+ if (amount?.kind === "subscription") {
181
+ line.usageCount++;
182
+ if (amount.reportedUsd === null)
183
+ line.usageUnreported++;
184
+ else
185
+ line.usageMicrousd += Math.round(amount.reportedUsd * 1_000_000);
186
+ }
187
+ if (amount?.kind === "unknown")
188
+ line.unknown++;
189
+ }
190
+ /** A floating choice (an alias such as `opus`, or the CLI's default) names no version: it has no digit. */
191
+ const floating = (model) => !/\d/.test(model);
192
+ /** The model a row ran on, in words: the reported model; a floating choice beside what it ran as (no verdict); the
193
+ * chosen model "not reported" when the provider said nothing. */
194
+ export function modelLabel(row) {
195
+ const chosen = row.modelChosen === null || row.modelChosen === "" ? null : row.modelChosen;
196
+ if (row.modelReported === null)
197
+ return `${chosen ?? "default"} (not reported)`;
198
+ if (chosen === null || chosen === row.modelReported)
199
+ return row.modelReported;
200
+ return floating(chosen) ? `floating choice ${chosen} → ${row.modelReported}` : `${row.modelReported} (chose ${chosen})`;
201
+ }
202
+ /** What a row ran on, for grouping: the reported model; the chosen one, "not reported", when the provider said nothing. */
203
+ const ranOn = (row) => `${row.provider} · ${row.modelReported ?? `${row.modelChosen === null || row.modelChosen === "" ? "default" : row.modelChosen} (not reported)`}`;
204
+ /** The model breakdown, grouped by the model the work ran on; how it was chosen is detail on the line. */
205
+ function byModel(rows) {
206
+ const lines = new Map();
207
+ for (const row of rows) {
208
+ const key = ranOn(row);
209
+ const line = lines.get(key) ?? empty(key);
210
+ add(line, row);
211
+ const chosen = row.modelChosen === null || row.modelChosen === "" ? "default" : row.modelChosen;
212
+ if (row.modelReported !== null && chosen !== row.modelReported && !line.chosen.includes(chosen))
213
+ line.chosen = [...line.chosen, chosen].sort();
214
+ lines.set(key, line);
215
+ }
216
+ return sortLines([...lines.values()]);
217
+ }
218
+ const sortLines = (lines) => lines.sort((a, b) => b.apiMicrousd - a.apiMicrousd || b.estimatedMicrousd - a.estimatedMicrousd || b.usageMicrousd - a.usageMicrousd || b.count - a.count || a.key.localeCompare(b.key));
219
+ /** The month's totals, breakdowns and most expensive runs, from `spendRows`. Pure. */
220
+ export function spendReport(rows, top = 10) {
221
+ const totals = empty("");
222
+ const by = (keyOf) => {
223
+ const lines = new Map();
224
+ for (const row of rows) {
225
+ const key = keyOf(row);
226
+ if (key === null)
227
+ continue;
228
+ const line = lines.get(key) ?? empty(key);
229
+ add(line, row);
230
+ lines.set(key, line);
231
+ }
232
+ return sortLines([...lines.values()]);
233
+ };
234
+ for (const row of rows)
235
+ add(totals, row);
236
+ const topRuns = rows.filter(row => row.source === "run" && row.amount?.kind === "api" && row.runId !== null && row.taskId !== null)
237
+ .map(row => ({ runId: row.runId, taskId: row.taskId, role: row.role, project: row.project, provider: row.provider, model: modelLabel(row), microusd: row.amount.microusd }))
238
+ .sort((a, b) => b.microusd - a.microusd || b.runId - a.runId).slice(0, top);
239
+ return {
240
+ totals,
241
+ byProject: by(row => row.project),
242
+ byModel: byModel(rows),
243
+ byRole: by(row => row.role),
244
+ byPerson: by(row => row.person),
245
+ topRuns,
246
+ };
247
+ }
248
+ /** One CSV row per piece of work, the same rows the page adds up. */
249
+ export function spendReportCsv(rows, subagentNames) {
250
+ const cell = (value) => {
251
+ const value_ = value === null || value === undefined ? "" : String(value);
252
+ const safe = /^[=+\-@\t\r]/.test(value_) ? `'${value_}` : value_;
253
+ return /[",\n\r]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
254
+ };
255
+ const dollars = (microusd) => microusd === null ? null : (microusd / 1_000_000).toFixed(6);
256
+ const head = ["time_utc", "source", "role", "project", "person", "subagent", "task", "run", "provider", "model", "model_reported", "billing", "amount_kind", "api_usd", "usage_usd", "charge_usd", "charge_basis"];
257
+ const lines = rows.map(row => [
258
+ row.at, row.source, row.role, row.project, row.person, row.subagent === null ? null : subagentNames.get(row.subagent) ?? row.subagent, row.taskId, row.runId,
259
+ row.provider, row.modelChosen, row.modelReported, row.billing, row.amount?.kind ?? "held",
260
+ row.amount?.kind === "api" ? dollars(row.amount.microusd) : null,
261
+ row.amount?.kind === "subscription" && row.amount.reportedUsd !== null ? row.amount.reportedUsd.toFixed(6) : null,
262
+ dollars(row.charge?.microusd ?? null), row.charge?.basis ?? null,
263
+ ].map(cell).join(","));
264
+ return `${head.join(",")}\r\n${lines.map(line => `${line}\r\n`).join("")}`;
265
+ }
@@ -1,17 +1,33 @@
1
1
  /**
2
- * The Spend page (v105): what agent work cost this month, where it went, and
3
- * the monthly budgets that alert at 50/80/100 % and (unless set to alert
4
- * only) stop new work at 100 %. An instance operator's page; budgets change
5
- * with a step-up.
2
+ * The Spend page (docs/plans/spend-dashboard.md, slice 1): what agent work cost this month, with API dollars, plan
3
+ * usage and unknown cost kept apart and never added together, broken down by project, model, role and person, the
4
+ * runs that cost most, and what isn't recorded here. An instance operator's page. Monthly limits show read-only here
5
+ * and change on Settings → Spend, with a step-up.
6
6
  */
7
7
  import { type Html } from "./html.js";
8
8
  import { type BudgetState, type SpendItem } from "./spend.js";
9
+ import type { SpendReport } from "./spend-report.js";
9
10
  export declare const SPEND_CSS: string;
11
+ /** What this page doesn't record, in plain words (docs/plans/spend-dashboard.md §2.2). */
12
+ export declare const NOT_COUNTED: readonly string[];
10
13
  export type SpendView = {
11
14
  month: string;
12
15
  previous: string;
13
16
  next: string | null;
14
- items: SpendItem[];
17
+ report: SpendReport;
18
+ /** This month's limits; null for a past month (a limit is this month's). */
19
+ budgets: BudgetState[] | null;
20
+ /** The plan windows as their providers last reported them (limits-ui.ts), or nothing. */
21
+ windows: Html;
22
+ subagentNames: Map<number, string>;
23
+ };
24
+ /** Dollars for the page: a positive amount under a cent reads "<$0.01", never a false $0. */
25
+ export declare const money: (microusd: number) => string;
26
+ export declare function spendHtml(view: SpendView, notice: {
27
+ said?: string | null;
28
+ problem?: string | null;
29
+ }): Html;
30
+ export type SpendSettingsView = {
15
31
  budgets: BudgetState[];
16
32
  targets: {
17
33
  value: string;
@@ -19,11 +35,16 @@ export type SpendView = {
19
35
  group: "Everything" | "Projects" | "People" | "Subagents";
20
36
  }[];
21
37
  subagentNames: Map<number, string>;
22
- csrf: string;
38
+ /** Each role's model at the installation, in words. */
39
+ models: {
40
+ role: string;
41
+ model: string;
42
+ }[];
23
43
  };
24
- export declare function spendHtml(view: SpendView, notice: {
44
+ /** Settings → Spend: the monthly limits editor (moved from the Spend page) and a summary of the models in use. */
45
+ export declare function spendSettingsHtml(view: SpendSettingsView, notice: {
25
46
  said?: string | null;
26
47
  problem?: string | null;
27
48
  }): Html;
28
- /** One row per piece of spend, for a spreadsheet. */
49
+ /** One row per piece of spend, for a spreadsheet (the command line's `spend --csv`). */
29
50
  export declare function spendCsv(items: readonly SpendItem[], subagentNames: Map<number, string>): string;
package/dist/spend-ui.js CHANGED
@@ -1,64 +1,99 @@
1
1
  /**
2
- * The Spend page (v105): what agent work cost this month, where it went, and
3
- * the monthly budgets that alert at 50/80/100 % and (unless set to alert
4
- * only) stop new work at 100 %. An instance operator's page; budgets change
5
- * with a step-up.
2
+ * The Spend page (docs/plans/spend-dashboard.md, slice 1): what agent work cost this month, with API dollars, plan
3
+ * usage and unknown cost kept apart and never added together, broken down by project, model, role and person, the
4
+ * runs that cost most, and what isn't recorded here. An instance operator's page. Monthly limits show read-only here
5
+ * and change on Settings → Spend, with a step-up.
6
6
  */
7
7
  import { html, postForm, reauth } from "./html.js";
8
8
  import { budgetLabel, usd } from "./spend.js";
9
+ import { runHref, taskHref } from "./server/http.js";
9
10
  const projectName = (repo) => repo.split("/").filter(Boolean).pop() ?? repo;
10
11
  export const SPEND_CSS = `.spend{max-width:960px;min-width:0}.spend-head{display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap}.spend-head h1{margin:0}` +
11
- `.spend-month{display:flex;gap:10px;align-items:baseline;font-size:.875rem}.spend-total{font-size:1.5rem;font-weight:600;margin:12px 0 2px;font-variant-numeric:tabular-nums}` +
12
+ `.spend-month{display:flex;gap:10px;align-items:baseline;flex-wrap:wrap;font-size:.875rem;margin:0}` +
12
13
  `.spend h2{font-size:.9375rem;margin:24px 0 8px}.spend table{width:100%;border-collapse:collapse;font-size:.8125rem}.spend th,.spend td{text-align:left;padding:6px 8px 6px 0;border-bottom:1px solid var(--border);overflow-wrap:anywhere}` +
13
- `.spend th{font-weight:500;color:var(--muted-foreground)}.spend td.n{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}.spend-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:0 24px}` +
14
+ `.spend th{font-weight:500;color:var(--muted-foreground)}.spend td.n,.spend th.n{text-align:right;font-variant-numeric:tabular-nums}.spend td.n,.spend th.n{white-space:nowrap}.spend table.lines td:first-child{overflow-wrap:break-word;width:40%}` +
15
+ `.spend-figures{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:16px 0 4px}.spend-figure{border:1px solid var(--border);border-radius:10px;padding:12px 14px;min-width:0}` +
16
+ `.spend-figure h2{margin:0;font-size:.8125rem;font-weight:500;color:var(--muted-foreground)}.spend-figure .value{margin:6px 0 0;font-size:1.5rem;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:-.01em}` +
17
+ `.spend-figure .meta{margin:4px 0 0}.spend-not-counted ul{margin:0;padding-left:20px;font-size:.8125rem}.spend-not-counted li{margin:2px 0}` +
14
18
  `.budget{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;padding:10px 0;border-bottom:1px solid var(--border)}.budget .name{font-weight:500;font-size:.875rem}` +
15
19
  `.budget .figures{font-size:.8125rem;font-variant-numeric:tabular-nums;text-align:right}.budget .bar{grid-column:1/-1;height:6px;border-radius:3px;background:var(--muted);overflow:hidden}` +
16
20
  `.budget .bar span{display:block;height:100%;background:var(--foreground)}.budget.over .bar span{background:var(--danger)}.budget.over .figures{color:var(--danger)}.budget .meta{grid-column:1/-1}` +
17
21
  `.budget details{grid-column:1/-1}.spend form.budget-form{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px 12px;align-items:end;margin:8px 0}` +
18
- `.spend form.budget-form label{display:grid;gap:4px;font-size:.8125rem}.spend form.budget-form .choice{display:flex;gap:8px;align-items:center}.spend form.budget-form button{justify-self:start}`;
19
- /** The rows of a breakdown: spend and count by a key, biggest first. */
20
- function breakdown(items, keyOf, top = 12) {
21
- const rows = new Map();
22
- for (const item of items) {
23
- const key = keyOf(item);
24
- if (key === null)
25
- continue;
26
- const row = rows.get(key) ?? { key, microusd: 0, count: 0, unpriced: 0 };
27
- row.microusd += item.microusd ?? 0;
28
- row.count++;
29
- if (item.microusd === null && item.source === "unpriced")
30
- row.unpriced++;
31
- rows.set(key, row);
32
- }
33
- return [...rows.values()].sort((a, b) => b.microusd - a.microusd || b.count - a.count).slice(0, top);
34
- }
35
- function table(title, rows, name) {
36
- if (rows.length === 0)
22
+ `.spend form.budget-form label{display:grid;gap:4px;font-size:.8125rem}.spend form.budget-form .choice{display:flex;gap:8px;align-items:center}.spend form.budget-form button{justify-self:start}` +
23
+ `.spend-models dl{display:grid;grid-template-columns:auto minmax(0,1fr);gap:4px 16px;margin:0 0 8px;font-size:.875rem}.spend-models dt{color:var(--muted-foreground)}.spend-models dd{margin:0;overflow-wrap:anywhere}` +
24
+ `@media(max-width:640px){.spend-figures{grid-template-columns:minmax(0,1fr)}.spend table.lines thead{display:none}.spend table.lines tr{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:2px 12px;padding:8px 0;border-bottom:1px solid var(--border)}` +
25
+ `.spend table.lines td{border:0;padding:0;white-space:normal}.spend table.lines td:first-child{grid-column:1/-1;font-weight:500;width:auto}.spend table.lines td.n{text-align:left;color:var(--muted-foreground)}.spend table.lines td.n::before{content:attr(data-label);display:block;font-size:.6875rem}}`;
26
+ const ROLE_WORDS = {
27
+ builder: "Builds", repair: "Repairs", planner: "Plans", reviewer: "Reviews", scout: "Scouting",
28
+ lead: "Lead chat", chat: "Chat", subagent: "Subagents", flow: "Flows", evals: "Evals",
29
+ };
30
+ /** What this page doesn't record, in plain words (docs/plans/spend-dashboard.md §2.2). */
31
+ export const NOT_COUNTED = [
32
+ "Sizing a new task (Claude or OpenRouter)",
33
+ "Memory checks that read finished chat sessions",
34
+ "Coding sessions in Code",
35
+ "A flow draft that failed",
36
+ "A Sort step that failed before saving its answer",
37
+ ];
38
+ /** Dollars for the page: a positive amount under a cent reads "<$0.01", never a false $0. */
39
+ export const money = (microusd) => microusd > 0 && microusd < 10_000 ? "<$0.01" : usd(microusd);
40
+ const each = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
41
+ /** A line's plan usage in words: the reported usage, or that none was reported. Never $0 for unreported usage. */
42
+ const usageWords = (line) => line.usageCount === 0 ? "—" : line.usageMicrousd > 0 ? `${money(line.usageMicrousd)}${line.usageUnreported > 0 ? ` + ${line.usageUnreported} not reported` : ""}` : "not reported";
43
+ /** A line's unknown cost in words: how many, and a saved charge as an estimate. Never $0. */
44
+ const unknownWords = (line) => {
45
+ const counted = line.estimatedMicrousd > 0 ? `${money(line.estimatedMicrousd)} estimated` : "";
46
+ return line.unknown > 0 ? `${line.unknown}${counted === "" ? "" : ` · ${counted}`}` : counted === "" ? "—" : counted;
47
+ };
48
+ const apiWords = (line) => line.apiMicrousd > 0 ? money(line.apiMicrousd) : "—";
49
+ function linesTable(title, lines, name, top = 12, detail = () => "") {
50
+ if (lines.length === 0)
37
51
  return "";
38
- return html `<section><h2>${title}</h2><table><tbody>${rows.map(row => html `<tr><td>${name(row.key)}</td><td class="n">${row.count}</td><td class="n">${usd(row.microusd)}${row.unpriced > 0 ? html ` <span class="meta">+${row.unpriced} unpriced</span>` : ""}</td></tr>`)}</tbody></table></section>`;
52
+ return html `<section><h2>${title}</h2><table class="lines"><thead><tr><th></th><th class="n">API dollars</th><th class="n">Plan usage</th><th class="n">Unknown</th></tr></thead><tbody>${lines.slice(0, top).map(line => html `<tr><td>${name(line.key)}${detail(line) === "" ? "" : html ` <span class="meta">${detail(line)}</span>`}</td><td class="n" data-label="API dollars" data-amount-kind="api">${apiWords(line)}</td><td class="n" data-label="Plan usage" data-amount-kind="subscription">${usageWords(line)}</td><td class="n" data-label="Unknown" data-amount-kind="unknown">${unknownWords(line)}</td></tr>`)}</tbody></table></section>`;
53
+ }
54
+ /** A monthly limit's row; `editor` is Settings → Spend's change form, nothing on the Spend page. */
55
+ function limitRow(budget, nameOfSubagent, editor) {
56
+ const width = Math.min(100, Math.max(0, budget.percent));
57
+ const over = budget.spentMicrousd >= budget.limitMicrousd;
58
+ const label = budgetLabel(budget, budget.scope === "subagent" ? nameOfSubagent(budget.key) : undefined).replace(/'s$/, "");
59
+ return html `<div class="budget${over ? " over" : ""}" data-budget="${budget.id}"><span class="name">${label}</span><span class="figures">${money(budget.spentMicrousd)} of ${usd(budget.limitMicrousd)} · ${budget.percent}%</span><span class="bar" aria-hidden="true"><span style="width:${width}%"></span></span><span class="meta">${over && budget.hardStop ? "Used up: new API work waits until next month or a higher limit." : budget.hardStop ? "Pauses new API work at 100%." : "Alerts only."}${budget.unpriced > 0 ? ` ${budget.unpriced} without a price.` : ""}</span>${editor}</div>`;
39
60
  }
40
61
  export function spendHtml(view, notice) {
41
62
  const note = notice.problem ? html `<p class="problem" role="alert">${notice.problem}</p>` : notice.said ? html `<p role="status">${notice.said}</p>` : "";
42
- const total = view.items.reduce((sum, item) => sum + (item.microusd ?? 0), 0);
43
- const unpriced = view.items.filter(item => item.microusd === null && (item.tokensIn !== null || item.kind !== "run")).length;
44
- const counts = { run: view.items.filter(item => item.kind === "run").length, subagent: view.items.filter(item => item.kind === "subagent").length, chat: view.items.filter(item => item.kind === "chat").length, sort: view.items.filter(item => item.kind === "sort").length };
45
- const parts = [`${counts.run} ${counts.run === 1 ? "run" : "runs"}`, ...(counts.subagent > 0 ? [`${counts.subagent} subagent turns`] : []), ...(counts.chat > 0 ? [`${counts.chat} chat turns`] : []), ...(counts.sort > 0 ? [`${counts.sort} ${counts.sort === 1 ? "sort" : "sorts"}`] : [])];
63
+ const { totals } = view.report;
46
64
  const nameOfSubagent = (key) => view.subagentNames.get(Number(key)) ?? `Subagent ${key}`;
47
- const budgets = view.budgets.map(budget => {
48
- const width = Math.min(100, Math.max(0, budget.percent));
49
- const over = budget.spentMicrousd >= budget.limitMicrousd;
50
- const label = budgetLabel(budget, budget.scope === "subagent" ? nameOfSubagent(budget.key) : undefined).replace(/'s$/, "");
51
- return html `<div class="budget${over ? " over" : ""}" data-budget="${budget.id}"><span class="name">${label}</span><span class="figures">${usd(budget.spentMicrousd)} of ${usd(budget.limitMicrousd)} · ${budget.percent}%</span><span class="bar" aria-hidden="true"><span style="width:${width}%"></span></span><span class="meta">${over && budget.hardStop ? "Used up: new API work waits until next month or a higher budget." : budget.hardStop ? "Stops API work at 100%." : "Alerts only."}${budget.unpriced > 0 ? ` ${budget.unpriced} unpriced` : ""}</span><details><summary>Change</summary>${postForm("/spend/budget", html `<label>Monthly limit (US dollars)<input type="number" name="usd" min="1" step="1" value="${Math.round(budget.limitMicrousd / 1_000_000)}" required></label><label class="choice"><input type="checkbox" name="stop" value="1"${budget.hardStop ? html ` checked` : ""}> Stop new work at 100%</label>${reauth(html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label>`)}<button type="submit" name="action" value="save">Save</button> <button type="submit" name="action" value="remove">Remove</button>`, { attrs: { class: "budget-form" }, hidden: { target: `${budget.scope}:${budget.key}` } })}</details></div>`;
52
- });
65
+ const usageValue = totals.usageCount === 0 ? "None" : totals.usageMicrousd > 0 ? money(totals.usageMicrousd) : "Not reported";
66
+ const usageMeta = totals.usageCount === 0 ? "No plan usage recorded"
67
+ : totals.usageMicrousd > 0 ? `of usage at API prices${totals.usageUnreported > 0 ? ` · ${each(totals.usageUnreported, "piece")} of work not reported` : ""}` : `${each(totals.usageCount, "piece")} of work`;
68
+ const unknownValue = totals.unknown === 0 ? "None" : String(totals.unknown);
69
+ const unknownMeta = [totals.unknown > 0 ? `${totals.unknown === 1 ? "piece" : "pieces"} of work with billing not proved or cost not recorded` : "Nothing with an unknown cost",
70
+ ...(totals.estimatedMicrousd > 0 ? [`counted as ${money(totals.estimatedMicrousd)} (estimated)`] : [])].join(" · ");
71
+ const figures = html `<section class="spend-figures" aria-label="This month">\
72
+ <div class="spend-figure" data-amount-kind="api" data-microusd="${totals.apiMicrousd}"><h2>API dollars</h2><p class="value">${money(totals.apiMicrousd)}</p><p class="meta">Recorded charges to API keys</p></div>\
73
+ <div class="spend-figure" data-amount-kind="subscription" data-microusd="${totals.usageMicrousd}" data-unreported="${totals.usageUnreported}"><h2>Plan usage</h2><p class="value">${usageValue}</p><p class="meta">${usageMeta}</p></div>\
74
+ <div class="spend-figure" data-amount-kind="unknown" data-count="${totals.unknown}" data-estimated-microusd="${totals.estimatedMicrousd}"><h2>Unknown</h2><p class="value">${unknownValue}</p><p class="meta">${unknownMeta}</p></div></section>`;
75
+ const limits = view.budgets === null ? "" : html `<section><h2>Monthly limits</h2>${view.budgets.length === 0 ? html `<p class="meta">No monthly limits. <a href="/settings/spend">Set a limit</a></p>`
76
+ : html `${view.budgets.map(budget => limitRow(budget, nameOfSubagent, ""))}<p class="meta"><a href="/settings/spend">Change limits</a> · Evals never count toward a limit.</p>`}${view.windows}</section>`;
77
+ const runs = view.report.topRuns.length === 0 ? "" : html `<section><h2>Most expensive runs</h2><table><thead><tr><th>Task</th><th>Run</th><th>Model</th><th class="n">API dollars</th></tr></thead><tbody>${view.report.topRuns.map(run => html `<tr data-run="${run.runId}"><td><a href="${taskHref(run.taskId)}">${run.taskId}</a>${run.project === null ? "" : html ` <span class="meta">${projectName(run.project)}</span>`}</td><td><a href="${runHref(run.runId)}">${ROLE_WORDS[run.role]} ${run.runId}</a></td><td>${run.provider} · ${run.model}</td><td class="n">${money(run.microusd)}</td></tr>`)}</tbody></table></section>`;
78
+ const notCounted = html `<section class="spend-not-counted" data-not-counted><h2>Not counted here</h2><ul>${NOT_COUNTED.map(one => html `<li>${one}</li>`)}</ul></section>`;
79
+ return html `<article class="spend"><div class="spend-head"><h1>Spend</h1><p class="spend-month"><a href="/spend?month=${view.previous}">← ${view.previous}</a><strong>${view.month}</strong>${view.next === null ? "" : html `<a href="/spend?month=${view.next}">${view.next} →</a>`}<span class="meta">UTC month</span><a href="/spend?month=${view.month}&amp;format=csv" download>CSV</a></p></div>${note}${figures}${limits}${linesTable("By project", view.report.byProject, projectName)}${linesTable("By model", view.report.byModel, key => key, 12, line => line.chosen.length === 0 ? "" : `chosen as ${line.chosen.join(", ")}`)}${linesTable("By role", view.report.byRole, key => ROLE_WORDS[key] ?? key)}${linesTable("By person", view.report.byPerson, key => key)}${runs}${notCounted}</article>`;
80
+ }
81
+ /** Settings → Spend: the monthly limits editor (moved from the Spend page) and a summary of the models in use. */
82
+ export function spendSettingsHtml(view, notice) {
83
+ const note = notice.problem ? html `<p class="problem" role="alert">${notice.problem}</p>` : notice.said ? html `<p role="status">${notice.said}</p>` : "";
84
+ const nameOfSubagent = (key) => view.subagentNames.get(Number(key)) ?? `Subagent ${key}`;
85
+ const password = reauth(html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label>`);
86
+ const budgets = view.budgets.map(budget => limitRow(budget, nameOfSubagent, html `<details><summary>Change</summary>${postForm("/spend/budget", html `<label>Monthly limit (US dollars)<input type="number" name="usd" min="1" step="1" value="${Math.round(budget.limitMicrousd / 1_000_000)}" required></label><label class="choice"><input type="checkbox" name="stop" value="1"${budget.hardStop ? html ` checked` : ""}> Pause new API work at 100%</label>${password}<button type="submit" name="action" value="save">Save</button> <button type="submit" name="action" value="remove">Remove</button>`, { attrs: { class: "budget-form" }, hidden: { target: `${budget.scope}:${budget.key}` } })}</details>`));
53
87
  const groups = ["Everything", "Projects", "People", "Subagents"];
54
88
  const options = groups.map(group => {
55
89
  const members = view.targets.filter(one => one.group === group);
56
90
  return members.length === 0 ? "" : html `<optgroup label="${group}">${members.map(one => html `<option value="${one.value}">${one.label}</option>`)}</optgroup>`;
57
91
  });
58
- const add = html `<details${view.budgets.length === 0 ? html ` open` : ""}><summary>Add a budget</summary>${postForm("/spend/budget", html `<label>For<select name="target" required>${options}</select></label><label>Monthly limit (US dollars)<input type="number" name="usd" min="1" step="1" required></label><label class="choice"><input type="checkbox" name="stop" value="1" checked> Stop new work at 100%</label>${reauth(html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label>`)}<button type="submit" name="action" value="save">Add budget</button>`, { attrs: { class: "budget-form" } })}</details>`;
59
- return html `<article class="spend"><div class="spend-head"><h1>Spend</h1><p class="spend-month"><a href="/spend?month=${view.previous}">← ${view.previous}</a><strong>${view.month}</strong>${view.next === null ? "" : html `<a href="/spend?month=${view.next}">${view.next} →</a>`}<a href="/spend?month=${view.month}&amp;format=csv" download>CSV</a></p></div>${note}<p class="spend-total" data-spend-total="${total}">${usd(total)}</p><p class="meta">${parts.join(" · ")}${unpriced > 0 ? ` · ${unpriced} unpriced (no reported cost or catalogue price)` : ""} · UTC month</p><section><h2>Budgets</h2>${budgets.length === 0 ? html `<p class="meta">No budgets yet.</p>` : budgets}${add}</section><div class="spend-grid">${table("By project", breakdown(view.items, item => item.project), projectName)}${table("By person", breakdown(view.items, item => item.person), key => key)}${table("By subagent", breakdown(view.items, item => item.subagent === null ? null : String(item.subagent)), nameOfSubagent)}${table("By model", breakdown(view.items, item => `${item.provider}${item.model === null ? "" : ` · ${item.model}`}`), key => key)}</div><p class="meta">Subscription work is $0; its limits are on Tasks. API work is what the provider reported, or its tokens at the prices in Settings → Models.</p></article>`;
92
+ const add = html `<details${view.budgets.length === 0 ? html ` open` : ""}><summary>Add a limit</summary>${postForm("/spend/budget", html `<label>For<select name="target" required>${options}</select></label><label>Monthly limit (US dollars)<input type="number" name="usd" min="1" step="1" required></label><label class="choice"><input type="checkbox" name="stop" value="1" checked> Pause new API work at 100%</label>${password}<button type="submit" name="action" value="save">Add limit</button>`, { attrs: { class: "budget-form" } })}</details>`;
93
+ const models = html `<section class="spend-models"><h2>Models</h2>${view.models.length === 0 ? "" : html `<dl>${view.models.map(one => html `<dt>${one.role}</dt><dd>${one.model}</dd>`)}</dl>`}<p><a href="/settings/models">Change models</a></p></section>`;
94
+ return html `<article class="spend"><div class="spend-head"><h1>Spend</h1><p class="spend-month"><a href="/spend">See this month's spend</a></p></div>${note}<section><h2>Monthly limits</h2>${budgets.length === 0 ? html `<p class="meta">No monthly limits yet.</p>` : budgets}${add}</section>${models}</article>`;
60
95
  }
61
- /** One row per piece of spend, for a spreadsheet. */
96
+ /** One row per piece of spend, for a spreadsheet (the command line's `spend --csv`). */
62
97
  export function spendCsv(items, subagentNames) {
63
98
  const cell = (value) => {
64
99
  const text = value === null || value === undefined ? "" : String(value);
@@ -1,11 +1,11 @@
1
1
  import { type SizeAnswer } from "./contracts/task-sizing.js";
2
2
  import { type CommandRunner } from "./claude-print.js";
3
- import { type TaskSizing } from "./phase-routing.js";
3
+ import { type TaskSize, type TaskSizing } from "./phase-routing.js";
4
4
  import type { Store } from "./store.js";
5
5
  /** The classifier's whole budget, from asking to answer. */
6
6
  export declare const SIZING_BUDGET_MS = 5000;
7
- /** The model `claude -p` sizes with: small and fast. */
8
- export declare const SIZING_CLAUDE_MODEL = "haiku";
7
+ /** The model `claude -p` sizes with: small and fast, by its exact id (an alias would float to whatever is newest). */
8
+ export declare const SIZING_CLAUDE_MODEL = "claude-haiku-5-5";
9
9
  export type SizingInput = {
10
10
  title: string;
11
11
  goal?: string | undefined;
@@ -22,6 +22,8 @@ export declare const SUBSTANTIAL_WORK: RegExp;
22
22
  export declare function planningSignals(input: SizingInput): boolean;
23
23
  /** The size the description alone gives — today's planning signals, never risky. */
24
24
  export declare function heuristicSizing(input: SizingInput, why?: string | null): TaskSizing;
25
+ export declare const SIZE_MEANS: Record<TaskSize, string>;
26
+ export declare const RISKY_MEANS = "Could a mistake here lose data, break security, sign-in, payments or permissions, change a database schema or migration, or be hard to undo?";
25
27
  /** What the classifier reads: the task as data, bounded, keys blanked. */
26
28
  export declare function sizingText(input: SizingInput): string;
27
29
  /** A small Claude model on this computer's sign-in: no tools, no MCP servers, no repository, credential keys stripped.