toolroll 0.9.83 → 0.9.85
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 +69 -0
- package/README.md +94 -1192
- package/dist/agent-fence.d.ts +3 -3
- package/dist/agent-fence.js +3 -3
- package/dist/agent-onboard.d.ts +2 -0
- package/dist/agent-onboard.js +3 -0
- package/dist/assignment-ui.js +5 -1
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +31 -31
- package/dist/browser-crew.js +5 -1
- package/dist/browser-workspace.d.ts +12 -0
- package/dist/chat-channel.d.ts +10 -1
- package/dist/chat-channel.js +35 -5
- package/dist/chat-decide.d.ts +16 -4
- package/dist/chat-decide.js +239 -31
- package/dist/chat-messages.d.ts +5 -0
- package/dist/chat-messages.js +1 -1
- package/dist/chat-quiet.js +3 -2
- package/dist/chat-review.d.ts +4 -0
- package/dist/chat-review.js +13 -4
- package/dist/chat-terms.d.ts +9 -0
- package/dist/chat-terms.js +46 -0
- package/dist/contracts/console-api.d.ts +4 -2
- package/dist/contracts/console-api.js +7 -2
- package/dist/contracts/lead-tools.d.ts +30 -0
- package/dist/contracts/lead-tools.js +3 -0
- package/dist/contracts/youiyoux-request.d.ts +248 -0
- package/dist/contracts/youiyoux-request.js +152 -0
- package/dist/control-setup.d.ts +9 -0
- package/dist/control-setup.js +31 -4
- package/dist/control-ui.d.ts +7 -1
- package/dist/control-ui.js +3 -2
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispatch.d.ts +15 -0
- package/dist/dispatch.js +26 -4
- package/dist/first-run.d.ts +5 -2
- package/dist/first-run.js +13 -2
- package/dist/flow-cadence.d.ts +8 -2
- package/dist/flow-cadence.js +16 -3
- package/dist/flow-card-task.d.ts +9 -0
- package/dist/flow-card-task.js +15 -6
- package/dist/folder-look.d.ts +3 -0
- package/dist/folder-look.js +8 -4
- package/dist/guides.js +4 -3
- package/dist/integrations-ui.js +9 -4
- package/dist/integrations.d.ts +9 -1
- package/dist/integrations.js +43 -2
- package/dist/landed-cleanup.d.ts +4 -0
- package/dist/landed-cleanup.js +3 -2
- package/dist/lead-capabilities.js +2 -1
- package/dist/lead-cli.js +3 -1
- package/dist/lead-tools.d.ts +2 -0
- package/dist/lead-tools.js +30 -4
- package/dist/lead.d.ts +2 -0
- package/dist/lead.js +3 -3
- package/dist/model-catalog.d.ts +5 -0
- package/dist/model-catalog.js +11 -2
- package/dist/model-choices.d.ts +188 -0
- package/dist/model-choices.js +405 -0
- package/dist/model-names.d.ts +6 -0
- package/dist/model-names.js +34 -0
- package/dist/models-ui.d.ts +69 -5
- package/dist/models-ui.js +53 -10
- package/dist/needs-you.d.ts +8 -0
- package/dist/needs-you.js +8 -0
- package/dist/operate.d.ts +22 -2
- package/dist/operate.js +107 -41
- package/dist/project-activity.d.ts +20 -0
- package/dist/project-activity.js +23 -0
- package/dist/project-admission.d.ts +4 -1
- package/dist/project-admission.js +23 -2
- package/dist/project-cli.d.ts +1 -0
- package/dist/project-cli.js +36 -19
- package/dist/project-identity.d.ts +9 -2
- package/dist/project-identity.js +80 -33
- package/dist/project-knowledge.d.ts +9 -9
- package/dist/project-outcomes.d.ts +119 -0
- package/dist/project-outcomes.js +79 -0
- package/dist/result-actions.d.ts +4 -0
- package/dist/result-actions.js +41 -9
- package/dist/result-review.d.ts +9 -0
- package/dist/result-review.js +45 -5
- package/dist/serve.js +60 -13
- package/dist/server/chat.js +5 -4
- package/dist/server/chrome.d.ts +3 -0
- package/dist/server/chrome.js +5 -2
- package/dist/server/console.css +17 -2
- package/dist/server/flows.js +4 -0
- package/dist/server/http.d.ts +1 -0
- package/dist/server/http.js +6 -0
- package/dist/server/pages.js +53 -7
- package/dist/server/people-tokens.js +40 -20
- package/dist/server/render-chat.js +3 -2
- package/dist/server/render-pages.d.ts +12 -2
- package/dist/server/render-pages.js +36 -16
- package/dist/server/render-people.d.ts +14 -2
- package/dist/server/render-people.js +35 -12
- package/dist/server/render-results.d.ts +2 -0
- package/dist/server/render-results.js +20 -7
- package/dist/server/render-settings.js +1 -0
- package/dist/server/render-tasks.d.ts +1 -0
- package/dist/server/render-tasks.js +22 -19
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/route-table.js +9 -1
- package/dist/server/settings.d.ts +58 -1
- package/dist/server/settings.js +216 -70
- package/dist/server/signup-gate.d.ts +9 -0
- package/dist/server/signup-gate.js +40 -0
- package/dist/server/task-views.js +6 -0
- package/dist/server/tasks.js +54 -8
- package/dist/settings-kit-script.d.ts +21 -0
- package/dist/settings-kit-script.js +67 -0
- package/dist/settings-kit.d.ts +137 -0
- package/dist/settings-kit.js +186 -0
- package/dist/settings-proofs.d.ts +64 -0
- package/dist/settings-proofs.js +101 -0
- package/dist/setup-guide.js +4 -2
- package/dist/spend-report.d.ts +87 -0
- package/dist/spend-report.js +265 -0
- package/dist/spend-ui.d.ts +29 -8
- package/dist/spend-ui.js +74 -39
- package/dist/store.d.ts +46 -3
- package/dist/store.js +165 -6
- package/dist/task-sizing.d.ts +5 -3
- package/dist/task-sizing.js +4 -4
- package/dist/task-source.d.ts +13 -1
- package/dist/task-source.js +17 -1
- package/dist/telegram-lead.js +7 -5
- package/dist/telegram.js +1 -1
- package/dist/toolroll-update.js +2 -2
- package/dist/webhooks.d.ts +6 -0
- package/dist/webhooks.js +25 -0
- package/dist/work-index.d.ts +4 -0
- package/dist/work-index.js +32 -0
- package/dist/youiyoux-intake.d.ts +234 -0
- package/dist/youiyoux-intake.js +908 -0
- package/package.json +11 -8
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings proof inventory (docs/plans/settings-revamp.md §4): for every settings action, the proof its handler
|
|
3
|
+
* already asks for, as data. Only rendering code reads it (settings-kit.ts draws the step-up dialog from it); no
|
|
4
|
+
* handler does, and nothing here changes what a handler proves. `src/settings-kit.test.ts` pins every row against the
|
|
5
|
+
* form contracts (console-api.ts), the route table, and — for one row of each kind — the handler itself over HTTP.
|
|
6
|
+
*
|
|
7
|
+
* Proof kinds, as the handlers implement them (scope.ts, server/guards.ts):
|
|
8
|
+
* - `none`: the session and the form token (CSRF) only.
|
|
9
|
+
* - `step-up`: `authenticateApprover` — a password typed now; an empty field stands in under Passwordless (the
|
|
10
|
+
* person's own live browser session) and for a one-use identity-provider re-check.
|
|
11
|
+
* - `credential`: `authenticateCredential` — a password typed now, or a one-use identity-provider re-check. Passwordless
|
|
12
|
+
* never covers it.
|
|
13
|
+
* - `typed-password`: the handler checks the typed password itself (`authenticateAccount`, or an empty field refused
|
|
14
|
+
* before the check). Neither Passwordless nor an identity-provider re-check stands in.
|
|
15
|
+
* - `typed-now-operator`: a password typed now by an instance operator (widening check access); not even a re-check.
|
|
16
|
+
*/
|
|
17
|
+
import { type ConsoleFormName } from "./contracts/console-api.js";
|
|
18
|
+
export type ProofKind = "none" | "step-up" | "credential" | "typed-password" | "typed-now-operator";
|
|
19
|
+
/** Who the route table admits (route-table.ts `role`); `handler` notes a narrower check the handler makes itself. */
|
|
20
|
+
export type ProofRole = "any" | "approver" | "operator";
|
|
21
|
+
export type ProofRow = {
|
|
22
|
+
/** Stable id the kit is called with. */
|
|
23
|
+
readonly id: string;
|
|
24
|
+
/** What the person is doing, in words. */
|
|
25
|
+
readonly action: string;
|
|
26
|
+
/** The exact POST paths. */
|
|
27
|
+
readonly routes: readonly string[];
|
|
28
|
+
/** The form contract its handler reads through. */
|
|
29
|
+
readonly form: ConsoleFormName;
|
|
30
|
+
/** The proof field's name, or null when none is read. */
|
|
31
|
+
readonly field: "password" | "token" | null;
|
|
32
|
+
readonly proof: ProofKind;
|
|
33
|
+
/** Only some submissions need the proof (the rest are `none`), in words. */
|
|
34
|
+
readonly when?: string;
|
|
35
|
+
/** Rows that share a route are variants of one handler, each with its own exact proof. A variant with `select`
|
|
36
|
+
* applies exactly when the form carries that hidden value (the kit refuses a form whose hidden terms pick another
|
|
37
|
+
* variant); a variant with `explicit` is named by the caller (a choice the server decides, like widening). */
|
|
38
|
+
readonly select?: {
|
|
39
|
+
readonly field: string;
|
|
40
|
+
readonly value: string;
|
|
41
|
+
};
|
|
42
|
+
readonly explicit?: true;
|
|
43
|
+
/** Under Passwordless, does an empty proof field pass? (`accepted` only for step-up.) */
|
|
44
|
+
readonly passwordless: "accepted" | "refused" | "n/a";
|
|
45
|
+
/** Does a one-use identity-provider re-check (empty field after "Confirm with …") pass? */
|
|
46
|
+
readonly sso: "accepted" | "refused" | "n/a";
|
|
47
|
+
readonly role: ProofRole;
|
|
48
|
+
/** A narrower standing the handler checks itself, in words. */
|
|
49
|
+
readonly handlerRole?: string;
|
|
50
|
+
/** Fields that hold a secret that is not the person's proof (never echoed, password managers kept out). */
|
|
51
|
+
readonly secrets: readonly string[];
|
|
52
|
+
/** Fields whose name reads like a credential but hold something else (an id, a bot token…). */
|
|
53
|
+
readonly notProof?: readonly string[];
|
|
54
|
+
/** Every term the handler requires besides the proof: hidden fields carried back unchanged (nonce, digest,
|
|
55
|
+
* fingerprint, the shown project, what a preview showed) and the fields that pick the target (repo, act). */
|
|
56
|
+
readonly terms: readonly string[];
|
|
57
|
+
/** Anything else the handler requires, in words. */
|
|
58
|
+
readonly note?: string;
|
|
59
|
+
};
|
|
60
|
+
export declare const PROOF_INVENTORY: readonly ProofRow[];
|
|
61
|
+
/** One row by id; throws on an unknown id (a rendering bug, never a request's input). */
|
|
62
|
+
export declare function proofRow(id: string): ProofRow;
|
|
63
|
+
/** Whether the field a row names is one its form contract reads (used by the kit's own guard and the tests). */
|
|
64
|
+
export declare function contractReads(row: ProofRow, field: string): boolean;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings proof inventory (docs/plans/settings-revamp.md §4): for every settings action, the proof its handler
|
|
3
|
+
* already asks for, as data. Only rendering code reads it (settings-kit.ts draws the step-up dialog from it); no
|
|
4
|
+
* handler does, and nothing here changes what a handler proves. `src/settings-kit.test.ts` pins every row against the
|
|
5
|
+
* form contracts (console-api.ts), the route table, and — for one row of each kind — the handler itself over HTTP.
|
|
6
|
+
*
|
|
7
|
+
* Proof kinds, as the handlers implement them (scope.ts, server/guards.ts):
|
|
8
|
+
* - `none`: the session and the form token (CSRF) only.
|
|
9
|
+
* - `step-up`: `authenticateApprover` — a password typed now; an empty field stands in under Passwordless (the
|
|
10
|
+
* person's own live browser session) and for a one-use identity-provider re-check.
|
|
11
|
+
* - `credential`: `authenticateCredential` — a password typed now, or a one-use identity-provider re-check. Passwordless
|
|
12
|
+
* never covers it.
|
|
13
|
+
* - `typed-password`: the handler checks the typed password itself (`authenticateAccount`, or an empty field refused
|
|
14
|
+
* before the check). Neither Passwordless nor an identity-provider re-check stands in.
|
|
15
|
+
* - `typed-now-operator`: a password typed now by an instance operator (widening check access); not even a re-check.
|
|
16
|
+
*/
|
|
17
|
+
import { CONSOLE_FORMS } from "./contracts/console-api.js";
|
|
18
|
+
const ACCEPT = { passwordless: "accepted", sso: "accepted" };
|
|
19
|
+
const CREDENTIAL = { passwordless: "refused", sso: "accepted" };
|
|
20
|
+
const TYPED = { passwordless: "refused", sso: "refused" };
|
|
21
|
+
const NONE = { passwordless: "n/a", sso: "n/a" };
|
|
22
|
+
/** /mode/sign re-derives the digest from the posted terms, so every one rides back. */
|
|
23
|
+
const MODE_TERMS = ["nonce", "digest", "expiry", "name", "days", "publication", "auto-approve", "plan-auto", "chat-approve"];
|
|
24
|
+
const CHAT_APPS = [["slack", "Slack", "slack", ["app-token", "bot-token"]], ["teams", "Teams", "teams", ["secret"]], ["discord", "Discord", "discord", ["bot-token"]]];
|
|
25
|
+
export const PROOF_INVENTORY = [
|
|
26
|
+
// CSRF only.
|
|
27
|
+
{ id: "quality-default", action: "Set the default quality for new tasks", routes: ["/settings/quality-default"], form: "qualityDefault", field: null, proof: "none", ...NONE, role: "any", secrets: [], terms: [] },
|
|
28
|
+
{ id: "notifications", action: "Choose what reaches you", routes: ["/settings/notifications"], form: "notifications", field: null, proof: "none", ...NONE, role: "any", secrets: [], terms: [] },
|
|
29
|
+
{ id: "notifications-mute", action: "Mute a project", routes: ["/settings/notifications/mute"], form: "notificationsMute", field: null, proof: "none", ...NONE, role: "any", secrets: [], terms: [] },
|
|
30
|
+
{ id: "messaging", action: "Choose the chat app that receives alerts", routes: ["/settings/messaging"], form: "messaging", field: null, proof: "none", ...NONE, role: "any", secrets: [], terms: [] },
|
|
31
|
+
{ id: "telegram-digest", action: "Set the Telegram digest", routes: ["/settings/telegram-digest"], form: "telegramDigest", field: null, proof: "none", ...NONE, role: "any", secrets: [], terms: [] },
|
|
32
|
+
{ id: "provider-key", action: "Save or clear an AI provider key", routes: ["/settings/provider-key", "/settings/provider-key-clear"], form: "providerKey", field: null, proof: "none", ...NONE, role: "any", secrets: ["value"], terms: [] },
|
|
33
|
+
{ id: "telegram-token", action: "Save the Telegram bot token", routes: ["/settings/telegram-token"], form: "telegramToken", field: null, proof: "none", ...NONE, role: "any", secrets: ["token"], notProof: ["token"], terms: [] },
|
|
34
|
+
{ id: "email", action: "Set up or test email", routes: ["/settings/email", "/settings/email-test", "/settings/email-read-test"], form: "email", field: null, proof: "none", ...NONE, role: "any", secrets: ["password"], notProof: ["password"], terms: [], note: "`password` is the mail server's password; its wire name stays" },
|
|
35
|
+
{ id: "google", action: "Connect or disconnect Google", routes: ["/settings/google", "/settings/google/disconnect"], form: "google", field: null, proof: "none", ...NONE, role: "any", secrets: ["clientSecret"], terms: [] },
|
|
36
|
+
{ id: "appearance", action: "Change the theme or accent", routes: ["/settings/appearance"], form: "appearance", field: null, proof: "none", ...NONE, role: "any", secrets: [], terms: [] },
|
|
37
|
+
{ id: "chat-approval-confirm", action: "Review approving from chat", routes: ["/settings/chat-approval/confirm", "/settings/chat-approval/off"], form: "chatApproval", field: null, proof: "none", ...NONE, role: "approver", secrets: [], terms: [] },
|
|
38
|
+
...CHAT_APPS.map(([app, name, form]) => ({ id: `${app}-alerts`, action: `Send alerts to ${name}`, routes: [`/settings/${app}/alerts`], form, field: null, proof: "none", ...NONE, role: "approver", secrets: [], terms: [] })),
|
|
39
|
+
// Step-up: a password typed now, the Passwordless session, or a one-use re-check.
|
|
40
|
+
{ id: "permission-default", action: "Start new tasks with Full access", routes: ["/settings/permission-default"], form: "permissionDefault", field: "password", proof: "step-up", when: "only when widening to Full access", ...ACCEPT, role: "any", secrets: [], terms: [] },
|
|
41
|
+
{ id: "spend-budget", action: "Set a monthly limit", routes: ["/spend/budget"], form: "spendBudget", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: [] },
|
|
42
|
+
{ id: "models-agent", action: "Change which model does each job", routes: ["/settings/models/agent"], form: "modelsChange", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: ["scope", "digest"] },
|
|
43
|
+
{ id: "models-apply", action: "Apply a model preset or switch", routes: ["/settings/models/apply"], form: "modelsChange", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: ["scope", "digest", "change"], note: "plus the previewed preset, or switch and to" },
|
|
44
|
+
...[
|
|
45
|
+
["policy", "Change the policy", "policy", "operator"],
|
|
46
|
+
["approval", "Change approval rules", "approval", "operator"],
|
|
47
|
+
["request-limits", "Change request limits", "requestLimits", "operator"],
|
|
48
|
+
["retention", "Change retention", "retention", "operator"],
|
|
49
|
+
["backups", "Change backups", "backups", "operator"],
|
|
50
|
+
["data", "Download everything", "data", "operator"],
|
|
51
|
+
].map(([path, action, form, role]) => ({ id: path, action, routes: [`/settings/${path}`], form, field: "password", proof: "step-up", ...ACCEPT, role, secrets: [], terms: path === "approval" ? ["repo"] : [] })),
|
|
52
|
+
{ id: "pull-requests", action: "Change pull-request settings, or turn them off", routes: ["/settings/pull-requests"], form: "pullRequests", field: "password", proof: "step-up", ...ACCEPT, role: "any", handlerRole: "an approver of the project", secrets: [], terms: ["repo", "act"] },
|
|
53
|
+
{ id: "pull-requests-on", action: "Turn pull requests on", routes: ["/settings/pull-requests"], select: { field: "act", value: "on" }, form: "pullRequests", field: "password", proof: "step-up", ...ACCEPT, role: "any", handlerRole: "an approver of the project", secrets: [], terms: ["repo", "act", "github", "base"], note: "`github` and `base` are what the page showed; the handler checks again and refuses a difference" },
|
|
54
|
+
{ id: "checks", action: "Change checks", routes: ["/settings/checks"], form: "checks", field: "password", proof: "step-up", ...ACCEPT, role: "any", handlerRole: "an approver of the project", secrets: [], terms: ["repo", "act"] },
|
|
55
|
+
{ id: "monitoring", action: "Change monitoring", routes: ["/settings/monitoring"], form: "monitoring", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: ["header-value"], terms: [] },
|
|
56
|
+
{ id: "updates", action: "Update Toolroll", routes: ["/settings/updates", "/settings/updates/seen", "/settings/updates/cancel"], form: "updates", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: [] },
|
|
57
|
+
{ id: "storage-cleanup", action: "Choose when checkouts are removed", routes: ["/settings/storage"], form: "storage", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: [] },
|
|
58
|
+
{ id: "storage-clean", action: "Clean up checkouts", routes: ["/settings/storage/clean"], form: "storage", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: ["preview"], note: "`preview` is the digest of what the page showed; a changed list is refused" },
|
|
59
|
+
{ id: "storage-discard", action: "Discard a checkout", routes: ["/settings/storage/discard"], form: "storage", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: ["path"] },
|
|
60
|
+
{ id: "project-live-view", action: "Show a project's commands in the live view", routes: ["/settings/project/live-view"], form: "projectLiveView", field: "password", proof: "step-up", ...ACCEPT, role: "any", handlerRole: "an approver of the project", secrets: [], terms: ["repo", "nonce"] },
|
|
61
|
+
{ id: "project-delete", action: "Delete a project", routes: ["/settings/project/delete"], form: "projectDelete", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: ["repo", "step"], note: "the project's name typed too" },
|
|
62
|
+
{ id: "tools-connect", action: "Connect a tool", routes: ["/settings/tools/connect"], form: "toolsConnect", field: "password", proof: "step-up", ...ACCEPT, role: "approver", secrets: [], terms: ["repo", "shown"], note: "`shown` is the project the page showed (a post from another project is refused), not a digest" },
|
|
63
|
+
{ id: "tools-change", action: "Change a project's tools", routes: ["/settings/tools/change"], form: "toolsChange", field: "password", proof: "step-up", when: "only to add a tool (catalog, custom, import) or set a secret", ...ACCEPT, role: "approver", secrets: ["value"], terms: ["repo", "shown", "action"] },
|
|
64
|
+
...CHAT_APPS.map(([app, name, form]) => ({ id: `${app}-close`, action: `Unpair or disconnect ${name}`, routes: [`/settings/${app}/unpair`, `/settings/${app}/disconnect`], form, field: "password", proof: "step-up", ...ACCEPT, role: "approver", secrets: [], terms: [] })),
|
|
65
|
+
{ id: "telegram-unpair", action: "Unpair your phone from Telegram", routes: ["/settings/telegram/unpair"], form: "telegramPair", field: "password", proof: "step-up", ...ACCEPT, role: "approver", secrets: [], terms: [] },
|
|
66
|
+
{ id: "people-close", action: "Cancel an invite or remove a person", routes: ["/people/invite-revoke", "/people/revoke"], form: "peopleRevoke", field: "token", proof: "step-up", ...ACCEPT, role: "any", handlerRole: "an approver", secrets: [], terms: [] },
|
|
67
|
+
{ id: "project-setup", action: "Approve project setup", routes: ["/control/setup-approve"], form: "projectSetup", field: "token", proof: "step-up", ...ACCEPT, role: "any", handlerRole: "an approver of the project; models for a new installation need an instance operator", secrets: [], terms: ["repo", "nonce", "fingerprint", "provider", "model", "command", "seconds"], note: "the nonce is spent before the password is checked: after a refusal, review again; the reviewed inputs are previewed again and must match the fingerprint" },
|
|
68
|
+
{ id: "project-instructions", action: "Add the agent instructions", routes: ["/control/instructions-approve"], form: "projectSetup", field: "token", proof: "step-up", ...ACCEPT, role: "any", handlerRole: "an approver of the project", secrets: [], terms: ["repo", "nonce", "fingerprint"], note: "the nonce is spent before the password is checked" },
|
|
69
|
+
{ id: "mode-sign", action: "Sign an operating mode", routes: ["/mode/sign"], form: "mode", field: "token", proof: "step-up", ...ACCEPT, role: "any", handlerRole: "an approver of the project", secrets: [], terms: MODE_TERMS },
|
|
70
|
+
{ id: "mode-sign-chat-approve", action: "Sign an operating mode that lets your chat app approve", routes: ["/mode/sign"], select: { field: "chat-approve", value: "1" }, form: "mode", field: "token", proof: "credential", ...CREDENTIAL, role: "any", handlerRole: "an approver of the project", secrets: [], terms: MODE_TERMS },
|
|
71
|
+
// Credential acts: a password typed now or a one-use re-check; never Passwordless.
|
|
72
|
+
{ id: "passwordless-on", action: "Turn Passwordless on", routes: ["/settings/passwordless"], form: "passwordless", field: "password", proof: "credential", when: "only to turn it on (off needs nothing)", ...CREDENTIAL, role: "operator", secrets: [], terms: [] },
|
|
73
|
+
{ id: "sign-in", action: "Change sign-in (SSO)", routes: ["/settings/sign-in"], form: "signIn", field: "password", proof: "credential", when: "every action except Test", ...CREDENTIAL, role: "operator", secrets: ["client-secret"], terms: [] },
|
|
74
|
+
{ id: "release-approval", action: "Approve releases automatically", routes: ["/settings/release-approval"], form: "releaseApproval", field: "password", proof: "credential", when: "only to approve automatically (Ask me needs nothing)", ...CREDENTIAL, role: "approver", handlerRole: "an approver of the project", secrets: [], terms: ["repo"] },
|
|
75
|
+
{ id: "project-network", action: "Change what builds may reach", routes: ["/settings/project/network"], form: "projectNetwork", field: "password", proof: "credential", ...CREDENTIAL, role: "any", handlerRole: "an approver of the project", secrets: [], terms: ["repo"] },
|
|
76
|
+
{ id: "project-writes", action: "Change where builds may write", routes: ["/settings/project/writes"], form: "projectWrites", field: "password", proof: "credential", ...CREDENTIAL, role: "any", handlerRole: "an approver of the project", secrets: [], terms: ["repo"] },
|
|
77
|
+
{ id: "project-sandbox", action: "Change a project's sandbox", routes: ["/settings/project/sandbox"], form: "projectSandbox", field: "password", proof: "credential", ...CREDENTIAL, role: "any", handlerRole: "an approver of the project", secrets: [], terms: ["repo"] },
|
|
78
|
+
{ id: "project-check-access", action: "Narrow how far checks reach", routes: ["/settings/project/check-access"], form: "projectCheckAccess", field: "password", proof: "credential", ...CREDENTIAL, role: "any", handlerRole: "an approver of the project", secrets: [], terms: ["repo"], note: "for a form that can only narrow; one that offers a wider level uses project-check-access-widen" },
|
|
79
|
+
{ id: "project-check-access-widen", action: "Widen how far checks reach", routes: ["/settings/project/check-access"], explicit: true, form: "projectCheckAccess", field: "password", proof: "typed-now-operator", ...TYPED, role: "any", handlerRole: "an instance operator", secrets: [], terms: ["repo"], note: "the server decides whether a choice widens; a form offering any wider level asks for this proof" },
|
|
80
|
+
...CHAT_APPS.map(([app, name, form, secrets]) => ({ id: `${app}-open`, action: `Connect or pair ${name}`, routes: [`/settings/${app}/connect`, `/settings/${app}/pair`], form, field: "password", proof: "credential", ...CREDENTIAL, role: "approver", secrets, terms: [] })),
|
|
81
|
+
{ id: "sessions-token", action: "Make an API token", routes: ["/settings/sessions"], form: "sessions", field: "password", proof: "credential", when: "only to make a token (ending sessions and revoking tokens need nothing)", ...CREDENTIAL, role: "any", secrets: [], notProof: ["token"], terms: [], note: "`token` here names an API token to revoke" },
|
|
82
|
+
{ id: "people-invite", action: "Invite someone", routes: ["/people/invite"], form: "peopleInvite", field: "token", proof: "credential", ...CREDENTIAL, role: "any", handlerRole: "an approver", secrets: [], terms: [] },
|
|
83
|
+
{ id: "people-projects", action: "Change a person's projects", routes: ["/people/projects"], form: "peopleProjects", field: "token", proof: "credential", ...CREDENTIAL, role: "operator", secrets: [], terms: ["name"] },
|
|
84
|
+
// A typed password, checked by the handler itself.
|
|
85
|
+
{ id: "telegram-pair", action: "Pair your phone with Telegram", routes: ["/settings/telegram/pair"], form: "telegramPair", field: "password", proof: "typed-password", ...TYPED, role: "approver", secrets: [], terms: [] },
|
|
86
|
+
{ id: "chat-approval-save", action: "Turn on approving from chat", routes: ["/settings/chat-approval/save"], form: "chatApproval", field: "token", proof: "typed-password", ...TYPED, role: "approver", secrets: [], terms: ["nonce", "digest", "scope", "full-access", "cap-usd"] },
|
|
87
|
+
{ id: "push-subscribe", action: "Get alerts on this device", routes: ["/push/subscribe"], form: "pushSubscribe", field: "token", proof: "typed-password", ...TYPED, role: "any", handlerRole: "an approver", secrets: [], terms: ["endpoint", "p256dh", "auth"] },
|
|
88
|
+
{ id: "lead-config", action: "Set up the lead's model and its own key", routes: ["/chat/config"], form: "chatConfig", field: "token", proof: "typed-password", ...TYPED, role: "any", handlerRole: "an approver", secrets: ["key"], terms: [] },
|
|
89
|
+
];
|
|
90
|
+
const BY_ID = new Map(PROOF_INVENTORY.map(row => [row.id, row]));
|
|
91
|
+
/** One row by id; throws on an unknown id (a rendering bug, never a request's input). */
|
|
92
|
+
export function proofRow(id) {
|
|
93
|
+
const row = BY_ID.get(id);
|
|
94
|
+
if (row === undefined)
|
|
95
|
+
throw new Error(`no proof inventory row ${id}`);
|
|
96
|
+
return row;
|
|
97
|
+
}
|
|
98
|
+
/** Whether the field a row names is one its form contract reads (used by the kit's own guard and the tests). */
|
|
99
|
+
export function contractReads(row, field) {
|
|
100
|
+
return CONSOLE_FORMS[row.form].fields.includes(field);
|
|
101
|
+
}
|
package/dist/setup-guide.js
CHANGED
|
@@ -15,8 +15,10 @@ export const ASSISTANTS = withAcp({
|
|
|
15
15
|
export function modelChoices(provider, configured, home = homedir()) {
|
|
16
16
|
// Aliases documented at https://code.claude.com/docs/en/model-config.
|
|
17
17
|
let choices = provider === "claude"
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
// Exact ids first: Sonnet 5.5 builds routine tasks under "Sized by job" (Settings → Models); the short names float.
|
|
19
|
+
? [{ value: "claude-sonnet-5-5", label: "Sonnet 5.5 — routine tasks" }, { value: "claude-opus-5-5", label: "Opus 5.5 — large or risky tasks" },
|
|
20
|
+
{ value: "claude-haiku-5-5", label: "Haiku 5.5 — small tasks" }, { value: "sonnet", label: "Sonnet (floating) — moves to the newest version" },
|
|
21
|
+
{ value: "opus", label: "Opus (floating) — moves to the newest version" }, { value: "haiku", label: "Haiku (floating) — moves to the newest version" }]
|
|
20
22
|
: [];
|
|
21
23
|
if (provider === "codex") {
|
|
22
24
|
// Use the local CLI's own catalog, including account-specific availability.
|
|
@@ -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
|
+
}
|
package/dist/spend-ui.d.ts
CHANGED
|
@@ -1,17 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Spend page (
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
38
|
+
/** Each role's model at the installation, in words. */
|
|
39
|
+
models: {
|
|
40
|
+
role: string;
|
|
41
|
+
model: string;
|
|
42
|
+
}[];
|
|
23
43
|
};
|
|
24
|
-
|
|
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;
|