@worca/app 1.0.0 → 1.1.1
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/README.md +22 -9
- package/agents/clarify.meta.json +4 -4
- package/agents/decomposer.meta.json +5 -5
- package/agents/implementer.meta.json +15 -5
- package/agents/manualTestsChecklist.meta.json +5 -4
- package/agents/manualWebUiTesting.meta.json +9 -4
- package/agents/planReviewer.meta.json +12 -4
- package/agents/planner.meta.json +12 -5
- package/agents/refiner.meta.json +15 -4
- package/agents/reviewer.meta.json +14 -4
- package/agents/worca-cc-clarify.md +7 -0
- package/agents/worca-cc-code-reviewer.md +11 -6
- package/agents/worca-cc-decomposer.md +7 -0
- package/agents/worca-cc-implementer.md +9 -0
- package/agents/worca-cc-manual-tests-checklist.md +8 -5
- package/agents/worca-cc-manual-web-ui-testing.md +10 -6
- package/agents/worca-cc-plan-refiner.md +11 -6
- package/agents/worca-cc-plan-reviewer.md +10 -7
- package/agents/worca-cc-planner.md +9 -0
- package/agents/worca-cc-workspace-reviewer.md +11 -4
- package/agents/worca-cc-workspace-scanner.md +8 -4
- package/agents/workspaceReviewer.meta.json +15 -4
- package/agents/workspaceScanner.meta.json +5 -4
- package/package.json +8 -2
- package/skills/worca/SKILL.md +5 -5
- package/src/cli/render.mjs +148 -0
- package/src/cli/worca-cc.mjs +319 -45
- package/src/core/agent-gen.mjs +69 -31
- package/src/core/agent-registry.mjs +124 -144
- package/src/core/agent-store.mjs +164 -4
- package/src/core/artifacts.mjs +189 -21
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +506 -0
- package/src/core/ask/follow.mjs +107 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +54 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +261 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +153 -0
- package/src/core/ask/store.mjs +360 -0
- package/src/core/ask/tool-deps.mjs +63 -0
- package/src/core/ask/tools.mjs +848 -0
- package/src/core/ask/turn.mjs +416 -0
- package/src/core/ask/worktree-deps.mjs +27 -0
- package/src/core/ask/worktrees.mjs +285 -0
- package/src/core/chat/command-router.mjs +20 -3
- package/src/core/claude-runner.mjs +434 -57
- package/src/core/config.mjs +264 -41
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +684 -47
- package/src/core/diff-anchor.mjs +213 -0
- package/src/core/diff-comments.mjs +273 -0
- package/src/core/engine-select.mjs +32 -0
- package/src/core/git-info.mjs +49 -10
- package/src/core/graph/builtin-workflows.mjs +51 -0
- package/src/core/graph/executor.mjs +894 -0
- package/src/core/graph/registry-ports.mjs +12 -0
- package/src/core/graph/scheduler.mjs +1065 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/model-env.mjs +112 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +902 -4098
- package/src/core/overview-agent.mjs +15 -3
- package/src/core/phases.mjs +208 -537
- package/src/core/pipeline-delete.mjs +13 -2
- package/src/core/plugin-api.mjs +8 -3
- package/src/core/plugin-config.mjs +178 -28
- package/src/core/plugin-inventory.mjs +6 -2
- package/src/core/plugin-manifest.mjs +199 -11
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-repo.mjs +16 -4
- package/src/core/plugin-shim-child.mjs +9 -3
- package/src/core/plugin-shim.mjs +77 -14
- package/src/core/plugin-store.mjs +236 -29
- package/src/core/plugin-workflows.mjs +90 -41
- package/src/core/preflight.mjs +135 -3
- package/src/core/projects.mjs +7 -5
- package/src/core/protocol.mjs +8 -35
- package/src/core/recoverable-error.mjs +1 -1
- package/src/core/run-harness.mjs +3585 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +109 -13
- package/src/core/skills.mjs +10 -3
- package/src/core/source-bindings.mjs +175 -0
- package/src/core/sources.mjs +87 -25
- package/src/core/stats.mjs +25 -6
- package/src/core/title.mjs +51 -4
- package/src/core/workflows.mjs +358 -259
- package/src/core/workspace-scan.mjs +4 -0
- package/src/core/worktree.mjs +98 -7
- package/src/shared/graph/agent-meta.mjs +278 -0
- package/src/shared/graph/constants.mjs +105 -0
- package/src/shared/graph/geometry.mjs +157 -0
- package/src/shared/graph/layout.mjs +134 -0
- package/src/shared/graph/loops.mjs +130 -0
- package/src/shared/graph/manifest.mjs +257 -0
- package/src/shared/graph/ports.mjs +153 -0
- package/src/shared/graph/route.mjs +397 -0
- package/src/shared/graph/template.mjs +165 -0
- package/src/shared/graph/thumbnail.mjs +67 -0
- package/src/shared/graph/validate.mjs +491 -0
- package/src/shared/graph/verdict.mjs +41 -0
- package/ui/public/app.js +4008 -1670
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +264 -0
- package/ui/public/ask-panel.mjs +1880 -0
- package/ui/public/chat-settings-view.mjs +6 -2
- package/ui/public/diff-view.mjs +66 -11
- package/ui/public/file-tree.mjs +305 -0
- package/ui/public/graph/composer.mjs +889 -0
- package/ui/public/graph/inspector.mjs +183 -0
- package/ui/public/graph/model.mjs +37 -0
- package/ui/public/graph/palette.mjs +144 -0
- package/ui/public/graph/run-decor.mjs +410 -0
- package/ui/public/graph/run-hosts.mjs +201 -0
- package/ui/public/graph/save-dialog.mjs +56 -0
- package/ui/public/graph/view.mjs +858 -0
- package/ui/public/guardrails-view.mjs +4 -2
- package/ui/public/hljs-loader.mjs +180 -0
- package/ui/public/index.html +269 -265
- package/ui/public/log-filter.mjs +22 -4
- package/ui/public/log-line.mjs +45 -19
- package/ui/public/models-view.mjs +171 -9
- package/ui/public/plugins-view.mjs +106 -4
- package/ui/public/source-pane.mjs +190 -8
- package/ui/public/stats-view.mjs +81 -1
- package/ui/public/style.css +1459 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1667 -98
- package/src/core/channels.mjs +0 -302
- package/src/core/runners.mjs +0 -167
- package/src/core/workflow-validator.mjs +0 -185
- package/ui/public/composer-core.mjs +0 -211
package/src/core/config.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { getDb, prepare, tx } from './db.mjs';
|
|
16
16
|
import { projectKey } from './store.mjs';
|
|
17
17
|
import { loadAgentRegistry, registryToSteps } from './agent-registry.mjs';
|
|
18
|
-
import { EFFORTS, prepareModelEnv } from './model-env.mjs';
|
|
18
|
+
import { EFFORTS, prepareModelEnv, isSubagentModelValue, subagentModelIssue } from './model-env.mjs';
|
|
19
19
|
import { listGlobalModels, addGlobalModel, removeGlobalModel } from './settings.mjs';
|
|
20
20
|
import { listPluginModels, allPluginModels, flattenPluginModelEnv } from './plugin-models.mjs';
|
|
21
21
|
|
|
@@ -59,7 +59,8 @@ export { EFFORTS };
|
|
|
59
59
|
* is intentionally omitted — the CLI rejects it ("long context beta is not yet
|
|
60
60
|
* available for this subscription"). Fable 5 needs no `[1m]` suffix: its context
|
|
61
61
|
* window is 1M by default (verified to resolve via `claude --model`). Opus 5
|
|
62
|
-
* (`claude-opus-5`)
|
|
62
|
+
* (`claude-opus-5`) and Sonnet 5 (`claude-sonnet-5`) are likewise 1M-only and
|
|
63
|
+
* carry no `[1m]` twin.
|
|
63
64
|
*/
|
|
64
65
|
export const PREDEFINED_MODELS = [
|
|
65
66
|
{ id: 'claude-opus-5', label: 'Opus 5', efforts: ['medium', 'high', 'xhigh', 'max'] },
|
|
@@ -70,6 +71,7 @@ export const PREDEFINED_MODELS = [
|
|
|
70
71
|
{ id: 'claude-opus-4-7[1m]', label: 'Opus 4.7 (1M)', efforts: ['medium', 'high', 'xhigh', 'max'] },
|
|
71
72
|
{ id: 'claude-opus-4-6', label: 'Opus 4.6', efforts: ['medium', 'high', 'max'] },
|
|
72
73
|
{ id: 'claude-opus-4-6[1m]', label: 'Opus 4.6 (1M)', efforts: ['medium', 'high', 'max'] },
|
|
74
|
+
{ id: 'claude-sonnet-5', label: 'Sonnet 5', efforts: ['medium', 'high', 'xhigh', 'max'] },
|
|
73
75
|
{ id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', efforts: ['medium', 'high', 'max'] },
|
|
74
76
|
{ id: 'claude-sonnet-4-6[1m]', label: 'Sonnet 4.6 (1M)', efforts: ['medium', 'high', 'max'] },
|
|
75
77
|
{ id: 'claude-haiku-4-5', label: 'Haiku 4.5', efforts: ['medium', 'high'] },
|
|
@@ -84,24 +86,17 @@ function defaultConfig() {
|
|
|
84
86
|
return { steps: {}, customModels: [] };
|
|
85
87
|
}
|
|
86
88
|
|
|
87
|
-
/** Keep only known step keys
|
|
89
|
+
/** Keep only known step keys whose entry survives cleanNodeSel — ONE coercion
|
|
90
|
+
* rule for both scopes (a per-step entry IS a node selection; a second
|
|
91
|
+
* line-for-line copy here is how the two silently diverge). An unknown
|
|
92
|
+
* subagentModel is dropped, never stored. */
|
|
88
93
|
function sanitizeSteps(steps) {
|
|
89
94
|
const out = {};
|
|
90
95
|
const keys = stepKeys();
|
|
91
96
|
for (const [k, v] of Object.entries(steps || {})) {
|
|
92
97
|
if (!keys.has(k) || !v || typeof v !== 'object') continue;
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
const fanOut = typeof v.fanOut === 'boolean' ? v.fanOut : undefined;
|
|
96
|
-
const askQuestions = typeof v.askQuestions === 'boolean' ? v.askQuestions : undefined;
|
|
97
|
-
if (model || effort || fanOut !== undefined || askQuestions !== undefined) {
|
|
98
|
-
out[k] = {
|
|
99
|
-
...(model && { model }),
|
|
100
|
-
...(effort && { effort }),
|
|
101
|
-
...(fanOut !== undefined && { fanOut }),
|
|
102
|
-
...(askQuestions !== undefined && { askQuestions }),
|
|
103
|
-
};
|
|
104
|
-
}
|
|
98
|
+
const sel = cleanNodeSel(v); // hoisted declaration (defined below)
|
|
99
|
+
if (sel) out[k] = sel;
|
|
105
100
|
}
|
|
106
101
|
return out;
|
|
107
102
|
}
|
|
@@ -172,7 +167,10 @@ export async function readConfig(projectDir) {
|
|
|
172
167
|
* `custom` is false | 'global' | 'plugin' | 'project' (strings truthy, so
|
|
173
168
|
* existing `m.custom` checks keep working); plugin entries also carry
|
|
174
169
|
* `plugin: '<name>'`; `hasEnv` advertises routing env WITHOUT the values
|
|
175
|
-
* (this shape feeds UI dropdowns).
|
|
170
|
+
* (this shape feeds UI dropdowns). `routed` advertises an ANTHROPIC_BASE_URL
|
|
171
|
+
* override the same way (key presence, values never leaked) — computed from the
|
|
172
|
+
* env objects already in scope here, NEVER by calling modelHasBaseUrlRouting per
|
|
173
|
+
* entry (that re-reads settings + the plugins lock from disk on every row).
|
|
176
174
|
*/
|
|
177
175
|
function composeCatalog(projectCustom = []) {
|
|
178
176
|
const globals = listGlobalModels();
|
|
@@ -183,26 +181,27 @@ function composeCatalog(projectCustom = []) {
|
|
|
183
181
|
const out = [];
|
|
184
182
|
const seen = new Set();
|
|
185
183
|
const unreliable = (lc) => (flagged.has(lc) ? { costUnreliable: true } : {});
|
|
184
|
+
const routedOf = (env) => !!(env && 'ANTHROPIC_BASE_URL' in env);
|
|
186
185
|
const pluginShape = (id, m, lc) => ({
|
|
187
186
|
id, label: m.label, efforts: [...m.efforts], custom: 'plugin', plugin: m.plugin,
|
|
188
|
-
hasEnv: !!m.env, ...unreliable(lc),
|
|
187
|
+
hasEnv: !!m.env, routed: routedOf(m.env), ...unreliable(lc),
|
|
189
188
|
});
|
|
190
189
|
for (const m of PREDEFINED_MODELS) {
|
|
191
190
|
const lc = m.id.toLowerCase();
|
|
192
191
|
const shadow = globalByIdLc.get(lc);
|
|
193
192
|
const pshadow = pluginByIdLc.get(lc);
|
|
194
193
|
out.push(shadow
|
|
195
|
-
? { id: m.id, label: shadow.label, efforts: [...shadow.efforts], custom: 'global', hasEnv: !!shadow.env, ...unreliable(lc) }
|
|
194
|
+
? { id: m.id, label: shadow.label, efforts: [...shadow.efforts], custom: 'global', hasEnv: !!shadow.env, routed: routedOf(shadow.env), ...unreliable(lc) }
|
|
196
195
|
: pshadow
|
|
197
196
|
? pluginShape(m.id, pshadow, lc)
|
|
198
|
-
: { ...m, custom: false, hasEnv: false });
|
|
197
|
+
: { ...m, custom: false, hasEnv: false, routed: false });
|
|
199
198
|
seen.add(lc);
|
|
200
199
|
}
|
|
201
200
|
for (const m of globals) {
|
|
202
201
|
const lc = m.id.toLowerCase();
|
|
203
202
|
if (seen.has(lc)) continue; // predefined shadow, already emitted
|
|
204
203
|
seen.add(lc);
|
|
205
|
-
out.push({ id: m.id, label: m.label, efforts: [...m.efforts], custom: 'global', hasEnv: !!m.env, ...unreliable(lc) });
|
|
204
|
+
out.push({ id: m.id, label: m.label, efforts: [...m.efforts], custom: 'global', hasEnv: !!m.env, routed: routedOf(m.env), ...unreliable(lc) });
|
|
206
205
|
}
|
|
207
206
|
for (const m of plugins) {
|
|
208
207
|
const lc = m.id.toLowerCase();
|
|
@@ -213,7 +212,7 @@ function composeCatalog(projectCustom = []) {
|
|
|
213
212
|
for (const m of projectCustom) {
|
|
214
213
|
if (seen.has(m.id.toLowerCase())) continue; // predefined/global/plugin wins
|
|
215
214
|
seen.add(m.id.toLowerCase());
|
|
216
|
-
out.push({ id: m.id, label: m.label, efforts: [...EFFORTS], custom: 'project', hasEnv: false });
|
|
215
|
+
out.push({ id: m.id, label: m.label, efforts: [...EFFORTS], custom: 'project', hasEnv: false, routed: false });
|
|
217
216
|
}
|
|
218
217
|
return out;
|
|
219
218
|
}
|
|
@@ -258,11 +257,24 @@ export function costUnreliableModelIds() {
|
|
|
258
257
|
* @param {string} modelId
|
|
259
258
|
* @param {number|null} costUsd the reported cost (null when absent)
|
|
260
259
|
* @param {object} [usage]
|
|
260
|
+
* @param {object|null} [costCfg] this model's already-looked-up cost override
|
|
261
|
+
* (modelCostConfig). Pass it when the caller has one in hand — every lookup is
|
|
262
|
+
* a fresh settings.json read (settings.mjs is deliberately uncached), and the
|
|
263
|
+
* result path needs the same answer for resolveModelCost. `undefined` = look
|
|
264
|
+
* it up here; `null` = "checked, there is none".
|
|
261
265
|
* @returns {'flagged'|'cleared'|null} what changed — 'flagged' asks the caller
|
|
262
266
|
* to surface its one-per-run warning; null = no observation recorded
|
|
263
267
|
*/
|
|
264
|
-
export function observeModelCost(modelId, costUsd, usage) {
|
|
268
|
+
export function observeModelCost(modelId, costUsd, usage, costCfg = undefined) {
|
|
265
269
|
if (!modelHasBaseUrlRouting(modelId)) return null;
|
|
270
|
+
// An explicit per-model cost override GOVERNS this model's spend (resolveModelCost
|
|
271
|
+
// below) — the CLI's own figure is never trusted for it, so the "unreliable"
|
|
272
|
+
// badge is meaningless. Never flag it, and lift any flag left from before the
|
|
273
|
+
// override existed. Derived state: a DB hiccup here must never fail the run.
|
|
274
|
+
if (costCfg !== undefined ? costCfg : modelCostConfig(modelId)) {
|
|
275
|
+
try { prepare('DELETE FROM model_cost_flags WHERE model_id = ?').run(String(modelId).trim()); } catch { /* derived */ }
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
266
278
|
const u = usage && typeof usage === 'object' ? usage : {};
|
|
267
279
|
const tokens = ['input_tokens', 'output_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens']
|
|
268
280
|
.reduce((n, k) => n + (Number(u[k]) || 0), 0);
|
|
@@ -284,6 +296,123 @@ export function observeModelCost(modelId, costUsd, usage) {
|
|
|
284
296
|
return null; // no cost AND no tokens (e.g. an errored run) — no signal either way
|
|
285
297
|
}
|
|
286
298
|
|
|
299
|
+
// ── per-model cost override (opt-in) ──────────────────────────────────────────
|
|
300
|
+
// The Claude CLI computes total_cost_usd from its OWN per-model price table keyed
|
|
301
|
+
// on the model NAME — so an on-prem/proxied endpoint (even one that returns no
|
|
302
|
+
// cost) still gets a fabricated dollar figure, which observeModelCost cannot
|
|
303
|
+
// distinguish from a real one once it is positive. A user who KNOWS a model's
|
|
304
|
+
// real price (or that it is free) can pin it in the GLOBAL catalog; that override
|
|
305
|
+
// then wins over whatever the CLI reports. Inspired by worca 0.x's cost_alias /
|
|
306
|
+
// worca.pricing.models mechanism. Opt-in: with no override the CLI value stands.
|
|
307
|
+
//
|
|
308
|
+
// It governs EVERY surface that books spend, because they share one windowed
|
|
309
|
+
// budget (cost-budget.mjs combinedWindowedSpendUsd): the orchestrator's result
|
|
310
|
+
// intake and sub-agent telemetry (orchestrator.mjs), an Ask Worca turn (the
|
|
311
|
+
// `resolveCost` hook ask/turn.mjs injects into the reducer), and the overview
|
|
312
|
+
// agent's telemetry row. Re-pricing only some of them would leave the phantom
|
|
313
|
+
// spend this exists to remove still inflating the budget from the others.
|
|
314
|
+
|
|
315
|
+
/** The explicit cost override governing a model, or null. Shape: {free:true} |
|
|
316
|
+
* {perMtok:{input?,output?,cacheRead?,cacheWrite?,cacheWrite1h?}} (USD per
|
|
317
|
+
* million tokens). Resolved with the SAME precedence as the rest of a model's
|
|
318
|
+
* configuration (§9.3, mirroring modelHasBaseUrlRouting): the user's GLOBAL
|
|
319
|
+
* entry wins outright, else the winning PLUGIN entry's manifest price — a
|
|
320
|
+
* plugin shipping a model on its own endpoint is exactly the case that needs
|
|
321
|
+
* one. Note a global entry shadows the plugin's price even when it pins none:
|
|
322
|
+
* taking over a model id means owning its pricing too, so the two layers can
|
|
323
|
+
* never half-merge. Built-ins carry none. Never throws. */
|
|
324
|
+
export function modelCostConfig(modelId) {
|
|
325
|
+
const id = typeof modelId === 'string' ? modelId.trim() : '';
|
|
326
|
+
if (!id) return null;
|
|
327
|
+
const lc = id.toLowerCase();
|
|
328
|
+
const entry = listGlobalModels().find((m) => m.id.toLowerCase() === lc);
|
|
329
|
+
if (entry) return entry.cost ?? null;
|
|
330
|
+
const pm = listPluginModels().find((m) => m.id.toLowerCase() === lc);
|
|
331
|
+
return pm?.cost ?? null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** The four token classes read off a usage object, accepting BOTH spellings in
|
|
335
|
+
* play here: the RAW Claude result usage (`input_tokens`, …) that the pipeline
|
|
336
|
+
* path carries, and Ask Worca's normalized persisted shape (`input`, `output`,
|
|
337
|
+
* `cacheRead`, `cacheCreation` — ask/events.mjs normalizeUsage). Same tokens,
|
|
338
|
+
* two names; a missing field counts as 0. Only the raw shape ever carries the
|
|
339
|
+
* ephemeral cache-creation breakdown. */
|
|
340
|
+
function usageTokens(u) {
|
|
341
|
+
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
|
|
342
|
+
const pick = (snake, camel) => (u[snake] != null ? num(u[snake]) : num(u[camel]));
|
|
343
|
+
const cc = u.cache_creation && typeof u.cache_creation === 'object' ? u.cache_creation : null;
|
|
344
|
+
return {
|
|
345
|
+
input: pick('input_tokens', 'input'),
|
|
346
|
+
output: pick('output_tokens', 'output'),
|
|
347
|
+
cacheRead: pick('cache_read_input_tokens', 'cacheRead'),
|
|
348
|
+
cacheWrite: pick('cache_creation_input_tokens', 'cacheCreation'),
|
|
349
|
+
eph1h: cc ? num(cc.ephemeral_1h_input_tokens) : 0,
|
|
350
|
+
eph5m: cc ? num(cc.ephemeral_5m_input_tokens) : 0,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** True when `usage` is an object that actually reports token counts — i.e. it
|
|
355
|
+
* can be priced. A result event that carried NO usage at all is unpriceable and
|
|
356
|
+
* must not be silently booked at $0 (resolveModelCost returns NaN for it); an
|
|
357
|
+
* object whose counts are genuinely all zero IS priceable, at $0. */
|
|
358
|
+
export function isPriceableUsage(usage) {
|
|
359
|
+
if (!usage || typeof usage !== 'object') return false;
|
|
360
|
+
return ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens',
|
|
361
|
+
'cache_creation', 'input', 'output', 'cacheRead', 'cacheCreation'].some((k) => usage[k] != null);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Estimate USD from a `usage` object (either spelling, see usageTokens) and a
|
|
365
|
+
* per-million-token rate table (mirrors worca 0.x estimate_cost). Absent rates/
|
|
366
|
+
* fields count as 0. When the CLI breaks cache-creation into ephemeral 1h/5m
|
|
367
|
+
* buckets they are priced separately (1h falls back to the cacheWrite rate);
|
|
368
|
+
* otherwise the flat cache_creation_input_tokens total is priced at cacheWrite. */
|
|
369
|
+
export function estimateCost(usage, perMtok) {
|
|
370
|
+
if (!perMtok || typeof perMtok !== 'object') return 0;
|
|
371
|
+
const u = usage && typeof usage === 'object' ? usage : {};
|
|
372
|
+
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
|
|
373
|
+
const rate = (k) => num(perMtok[k]);
|
|
374
|
+
const t = usageTokens(u);
|
|
375
|
+
const cacheWriteCost = (t.eph1h || t.eph5m)
|
|
376
|
+
? (t.eph5m * rate('cacheWrite')
|
|
377
|
+
+ t.eph1h * (perMtok.cacheWrite1h != null ? rate('cacheWrite1h') : rate('cacheWrite'))) / 1e6
|
|
378
|
+
: t.cacheWrite * rate('cacheWrite') / 1e6;
|
|
379
|
+
return (
|
|
380
|
+
t.input * rate('input')
|
|
381
|
+
+ t.output * rate('output')
|
|
382
|
+
+ t.cacheRead * rate('cacheRead')
|
|
383
|
+
) / 1e6 + cacheWriteCost;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The AUTHORITATIVE cost for a dispatched model: the CLI's own figure unless the
|
|
388
|
+
* model carries an explicit override, which then wins. {free} → $0; {perMtok} →
|
|
389
|
+
* recomputed from `usage`. This is what stops a CLI that prices an on-prem model
|
|
390
|
+
* by name from inflating the ledger. With no override, `cliCostUsd` is returned
|
|
391
|
+
* verbatim (finite or not — the caller already gates on Number.isFinite).
|
|
392
|
+
*
|
|
393
|
+
* ONLY call this on a genuinely cost-bearing event. A {free} model answers 0 for
|
|
394
|
+
* ANY input, so feeding it a non-result stream frame (whose `cliCostUsd` is NaN)
|
|
395
|
+
* would turn "nothing to record" into a real $0 the caller then books.
|
|
396
|
+
*
|
|
397
|
+
* A {perMtok} model is priced from tokens ALONE, so a result that carried no
|
|
398
|
+
* usage object at all is UNPRICEABLE: NaN comes back rather than a silent $0, so
|
|
399
|
+
* the caller's existing "no cost estimate" branch reports it instead of the
|
|
400
|
+
* operator quietly under-billing a model they explicitly asked to be priced.
|
|
401
|
+
* @param {string} modelId the dispatched model id
|
|
402
|
+
* @param {number} cliCostUsd the cost the CLI reported (may be NaN)
|
|
403
|
+
* @param {object} [usage] the result event's usage object (either spelling)
|
|
404
|
+
* @param {object|null} [costCfg] this model's already-looked-up cost override;
|
|
405
|
+
* `undefined` = look it up here (see observeModelCost's note on sharing it)
|
|
406
|
+
* @returns {number}
|
|
407
|
+
*/
|
|
408
|
+
export function resolveModelCost(modelId, cliCostUsd, usage, costCfg = undefined) {
|
|
409
|
+
const cost = costCfg !== undefined ? costCfg : modelCostConfig(modelId);
|
|
410
|
+
if (!cost) return cliCostUsd;
|
|
411
|
+
if (cost.free) return 0;
|
|
412
|
+
if (cost.perMtok) return isPriceableUsage(usage) ? estimateCost(usage, cost.perMtok) : NaN;
|
|
413
|
+
return cliCostUsd;
|
|
414
|
+
}
|
|
415
|
+
|
|
287
416
|
/**
|
|
288
417
|
* All selectable models for a project = the effective catalog (predefined ⊕
|
|
289
418
|
* global ⊕ this project's legacy custom models). Legacy custom models
|
|
@@ -388,6 +517,27 @@ function inheritOr(next, prev) {
|
|
|
388
517
|
return typeof prev === 'boolean' ? prev : undefined;
|
|
389
518
|
}
|
|
390
519
|
|
|
520
|
+
/**
|
|
521
|
+
* Tri-state resolution for the STRING tunable (subagentModel), shared by setStep
|
|
522
|
+
* and setNodeModel. It is preserve-on-absent rather than replace-like model/effort
|
|
523
|
+
* on purpose: the field arrived after the write APIs shipped, so an older client
|
|
524
|
+
* (or any caller that only means to change the model) POSTs without it and must
|
|
525
|
+
* not silently wipe a configured sub-agent policy.
|
|
526
|
+
* a valid value -> that value
|
|
527
|
+
* '' or null -> undefined = cleared (an explicit "inherit again")
|
|
528
|
+
* absent -> the previous value
|
|
529
|
+
* An unknown non-empty string is treated as absent: the enum is validated at the
|
|
530
|
+
* API boundary, and a typo must not clear a working setting.
|
|
531
|
+
* @param {unknown} next
|
|
532
|
+
* @param {unknown} prev
|
|
533
|
+
* @returns {string|undefined}
|
|
534
|
+
*/
|
|
535
|
+
function inheritOrSubagentModel(next, prev) {
|
|
536
|
+
if (isSubagentModelValue(next)) return next;
|
|
537
|
+
if (next === null || next === '') return undefined;
|
|
538
|
+
return isSubagentModelValue(prev) ? prev : undefined;
|
|
539
|
+
}
|
|
540
|
+
|
|
391
541
|
/**
|
|
392
542
|
* Set (or clear) the model + effort for one agent step. An empty model => inherit
|
|
393
543
|
* the global/CLI default; an empty effort => model default. Effort must be supported
|
|
@@ -411,6 +561,11 @@ export async function setStep(projectDir, step, selection = {}) {
|
|
|
411
561
|
}
|
|
412
562
|
}
|
|
413
563
|
|
|
564
|
+
{
|
|
565
|
+
const issue = subagentModelIssue(selection.subagentModel);
|
|
566
|
+
if (issue) throw new Error(issue);
|
|
567
|
+
}
|
|
568
|
+
|
|
414
569
|
const key = projectKey(projectDir);
|
|
415
570
|
const cfg = readRaw(projectDir);
|
|
416
571
|
const prev = cfg.steps[step] || {};
|
|
@@ -422,12 +577,16 @@ export async function setStep(projectDir, step, selection = {}) {
|
|
|
422
577
|
// askQuestions mirrors fanOut: preserved when omitted (only the toggle sends
|
|
423
578
|
// it), set when a boolean (spec 2026-07-11 §4), cleared on null.
|
|
424
579
|
const askQuestions = inheritOr(selection.askQuestions, prev.askQuestions);
|
|
580
|
+
// subagentModel: the sub-agent model policy for a fan-out node. Preserved when
|
|
581
|
+
// omitted (see inheritOrSubagentModel), cleared on '' / null.
|
|
582
|
+
const subagentModel = inheritOrSubagentModel(selection.subagentModel, prev.subagentModel);
|
|
425
583
|
|
|
426
584
|
const steps = { ...cfg.steps };
|
|
427
|
-
if (!model && !effort && fanOut === undefined && askQuestions === undefined) delete steps[step];
|
|
585
|
+
if (!model && !effort && !subagentModel && fanOut === undefined && askQuestions === undefined) delete steps[step];
|
|
428
586
|
else steps[step] = {
|
|
429
587
|
...(model && { model }),
|
|
430
588
|
...(effort && { effort }),
|
|
589
|
+
...(subagentModel && { subagentModel }),
|
|
431
590
|
...(fanOut !== undefined && { fanOut }),
|
|
432
591
|
...(askQuestions !== undefined && { askQuestions }),
|
|
433
592
|
};
|
|
@@ -523,16 +682,19 @@ export async function promoteCustomModel(projectDir, id) {
|
|
|
523
682
|
// nested shape from those rows. activeWorkflowId is project_config.active_workflow_id;
|
|
524
683
|
// unknown top-level keys (e.g. webUiTesting) round-trip via project_config.extra.
|
|
525
684
|
|
|
526
|
-
/** Coerce a per-node selection to a clean {model?,effort?,fanOut?,askQuestions?}
|
|
685
|
+
/** Coerce a per-node selection to a clean {model?,effort?,subagentModel?,fanOut?,askQuestions?}
|
|
686
|
+
* or null (all empty). */
|
|
527
687
|
function cleanNodeSel(selection) {
|
|
528
688
|
const model = typeof selection?.model === 'string' ? selection.model.trim() : '';
|
|
529
689
|
const effort = typeof selection?.effort === 'string' ? selection.effort.trim() : '';
|
|
690
|
+
const subagentModel = isSubagentModelValue(selection?.subagentModel) ? selection.subagentModel : '';
|
|
530
691
|
const fanOut = typeof selection?.fanOut === 'boolean' ? selection.fanOut : undefined;
|
|
531
692
|
const askQuestions = typeof selection?.askQuestions === 'boolean' ? selection.askQuestions : undefined;
|
|
532
|
-
if (!model && !effort && fanOut === undefined && askQuestions === undefined) return null;
|
|
693
|
+
if (!model && !effort && !subagentModel && fanOut === undefined && askQuestions === undefined) return null;
|
|
533
694
|
return {
|
|
534
695
|
...(model && { model }),
|
|
535
696
|
...(effort && { effort }),
|
|
697
|
+
...(subagentModel && { subagentModel }),
|
|
536
698
|
...(fanOut !== undefined && { fanOut }),
|
|
537
699
|
...(askQuestions !== undefined && { askQuestions }),
|
|
538
700
|
};
|
|
@@ -547,17 +709,23 @@ function cleanNodeSel(selection) {
|
|
|
547
709
|
*/
|
|
548
710
|
function readWorkflowsMap(key) {
|
|
549
711
|
getDb();
|
|
550
|
-
|
|
712
|
+
// NULL-prototype accumulator, deliberately: a stored workflow_id of
|
|
713
|
+
// '__proto__' would resolve truthy to Object.prototype on a plain `{}` and the
|
|
714
|
+
// next `.wires[id] =` would throw FOREVER for that project (MAJ-1). With no
|
|
715
|
+
// prototype, `workflows['__proto__'] = ...` is an ordinary own property, so a
|
|
716
|
+
// poisoned row degrades to a visible junk entry instead of a permanent 500.
|
|
717
|
+
const workflows = Object.create(null);
|
|
551
718
|
const ensure = (wf) => {
|
|
552
|
-
if (!workflows[wf]) workflows[wf] = { nodes: {}, feedbacks: {} };
|
|
719
|
+
if (!workflows[wf]) workflows[wf] = { nodes: {}, feedbacks: {}, wires: {} };
|
|
553
720
|
return workflows[wf];
|
|
554
721
|
};
|
|
555
722
|
for (const r of prepare(
|
|
556
|
-
'SELECT workflow_id, node_id, model, effort, fan_out, ask_questions FROM config_workflow_nodes WHERE project_key = ?'
|
|
723
|
+
'SELECT workflow_id, node_id, model, effort, fan_out, ask_questions, subagent_model FROM config_workflow_nodes WHERE project_key = ?'
|
|
557
724
|
).all(key)) {
|
|
558
725
|
const sel = {};
|
|
559
726
|
if (r.model) sel.model = r.model;
|
|
560
727
|
if (r.effort) sel.effort = r.effort;
|
|
728
|
+
if (isSubagentModelValue(r.subagent_model)) sel.subagentModel = r.subagent_model;
|
|
561
729
|
if (r.fan_out !== null && r.fan_out !== undefined) sel.fanOut = !!r.fan_out;
|
|
562
730
|
if (r.ask_questions !== null && r.ask_questions !== undefined) sel.askQuestions = !!r.ask_questions;
|
|
563
731
|
// Only attach a node entry that carries something (matches cleanNodeSel output).
|
|
@@ -568,7 +736,17 @@ function readWorkflowsMap(key) {
|
|
|
568
736
|
).all(key)) {
|
|
569
737
|
ensure(r.workflow_id).feedbacks[r.fb_id] = { maxCycles: r.max_cycles };
|
|
570
738
|
}
|
|
571
|
-
|
|
739
|
+
// v23: per-loop-wire budgets (the graph twin of config_workflow_feedbacks).
|
|
740
|
+
for (const r of prepare(
|
|
741
|
+
'SELECT workflow_id, wire_id, max_cycles FROM config_workflow_wires WHERE project_key = ?'
|
|
742
|
+
).all(key)) {
|
|
743
|
+
ensure(r.workflow_id).wires[r.wire_id] = { maxCycles: r.max_cycles };
|
|
744
|
+
}
|
|
745
|
+
// Hand callers an ORDINARY object: spread copies with CreateDataProperty, so a
|
|
746
|
+
// '__proto__' key stays an own enumerable property (a plain assignment would
|
|
747
|
+
// have hit the setter and silently vanished) while the public shape — what
|
|
748
|
+
// JSON.stringify and every deepEqual in the suite see — is unchanged.
|
|
749
|
+
return { ...workflows };
|
|
572
750
|
}
|
|
573
751
|
|
|
574
752
|
/**
|
|
@@ -598,8 +776,8 @@ export async function readRunConfig(projectDir) {
|
|
|
598
776
|
}
|
|
599
777
|
|
|
600
778
|
/**
|
|
601
|
-
* Set (or clear) the model+effort+fanOut+askQuestions for one node
|
|
602
|
-
* workflow. A cleaned selection of null (all blank) deletes the row. fanOut and
|
|
779
|
+
* Set (or clear) the model+effort+subagentModel+fanOut+askQuestions for one node
|
|
780
|
+
* instance of a workflow. A cleaned selection of null (all blank) deletes the row. fanOut and
|
|
603
781
|
* askQuestions are preserved when the caller omits them (read from the existing
|
|
604
782
|
* row), set when a boolean, and cleared on an explicit null. Writes only the config_workflow_nodes table
|
|
605
783
|
* (legacy view + extra untouched). Model/effort validate against the effective
|
|
@@ -609,7 +787,7 @@ export async function readRunConfig(projectDir) {
|
|
|
609
787
|
* @param {string} projectDir
|
|
610
788
|
* @param {string} workflowId
|
|
611
789
|
* @param {string} nodeId
|
|
612
|
-
* @param {{model?:string,effort?:string,fanOut?:boolean,askQuestions?:boolean}} selection
|
|
790
|
+
* @param {{model?:string,effort?:string,subagentModel?:string,fanOut?:boolean,askQuestions?:boolean}} selection
|
|
613
791
|
* @returns {Promise<void>}
|
|
614
792
|
*/
|
|
615
793
|
export async function setNodeModel(projectDir, workflowId, nodeId, selection = {}) {
|
|
@@ -626,16 +804,22 @@ export async function setNodeModel(projectDir, workflowId, nodeId, selection = {
|
|
|
626
804
|
}
|
|
627
805
|
}
|
|
628
806
|
|
|
807
|
+
{
|
|
808
|
+
const issue = subagentModelIssue(selection.subagentModel);
|
|
809
|
+
if (issue) throw new Error(issue);
|
|
810
|
+
}
|
|
811
|
+
|
|
629
812
|
const key = projectKey(projectDir);
|
|
630
813
|
getDb();
|
|
631
814
|
const prev = prepare(
|
|
632
|
-
'SELECT fan_out, ask_questions FROM config_workflow_nodes WHERE project_key = ? AND workflow_id = ? AND node_id = ?'
|
|
815
|
+
'SELECT fan_out, ask_questions, subagent_model FROM config_workflow_nodes WHERE project_key = ? AND workflow_id = ? AND node_id = ?'
|
|
633
816
|
).get(key, workflowId, nodeId);
|
|
634
817
|
const prevFanOut = prev && prev.fan_out !== null && prev.fan_out !== undefined ? !!prev.fan_out : undefined;
|
|
635
818
|
const fanOut = inheritOr(selection.fanOut, prevFanOut);
|
|
636
819
|
const prevAsk = prev && prev.ask_questions !== null && prev.ask_questions !== undefined ? !!prev.ask_questions : undefined;
|
|
637
820
|
const askQuestions = inheritOr(selection.askQuestions, prevAsk);
|
|
638
|
-
const
|
|
821
|
+
const subagentModel = inheritOrSubagentModel(selection.subagentModel, prev && prev.subagent_model);
|
|
822
|
+
const sel = cleanNodeSel({ model: selection.model, effort: selection.effort, subagentModel, fanOut, askQuestions });
|
|
639
823
|
|
|
640
824
|
tx(() => {
|
|
641
825
|
if (!sel) {
|
|
@@ -645,17 +829,19 @@ export async function setNodeModel(projectDir, workflowId, nodeId, selection = {
|
|
|
645
829
|
return;
|
|
646
830
|
}
|
|
647
831
|
prepare(`
|
|
648
|
-
INSERT INTO config_workflow_nodes (project_key, workflow_id, node_id, model, effort, fan_out, ask_questions)
|
|
649
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
832
|
+
INSERT INTO config_workflow_nodes (project_key, workflow_id, node_id, model, effort, fan_out, ask_questions, subagent_model)
|
|
833
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
650
834
|
ON CONFLICT(project_key, workflow_id, node_id)
|
|
651
835
|
DO UPDATE SET model = excluded.model, effort = excluded.effort,
|
|
652
|
-
fan_out = excluded.fan_out, ask_questions = excluded.ask_questions
|
|
836
|
+
fan_out = excluded.fan_out, ask_questions = excluded.ask_questions,
|
|
837
|
+
subagent_model = excluded.subagent_model
|
|
653
838
|
`).run(
|
|
654
839
|
key, workflowId, nodeId,
|
|
655
840
|
sel.model ?? null,
|
|
656
841
|
sel.effort ?? null,
|
|
657
842
|
sel.fanOut === undefined ? null : (sel.fanOut ? 1 : 0),
|
|
658
843
|
sel.askQuestions === undefined ? null : (sel.askQuestions ? 1 : 0),
|
|
844
|
+
sel.subagentModel ?? null,
|
|
659
845
|
);
|
|
660
846
|
});
|
|
661
847
|
}
|
|
@@ -681,6 +867,23 @@ export async function setFeedbackCycles(projectDir, workflowId, fbId, maxCycles)
|
|
|
681
867
|
});
|
|
682
868
|
}
|
|
683
869
|
|
|
870
|
+
/**
|
|
871
|
+
* Set the cycle budget for ONE loop wire of a v2 workflow. Coerced to an integer
|
|
872
|
+
* >= 1 (a loop runs at least once), exactly like setFeedbackCycles — this never
|
|
873
|
+
* throws, so a stale UI value cannot 500 a save. Writes only config_workflow_wires.
|
|
874
|
+
*/
|
|
875
|
+
export async function setWireCycles(projectDir, workflowId, wireId, maxCycles) {
|
|
876
|
+
const n = Math.max(1, Math.floor(Number(maxCycles) || 0) || 1);
|
|
877
|
+
const key = projectKey(projectDir);
|
|
878
|
+
tx(() => {
|
|
879
|
+
prepare(`
|
|
880
|
+
INSERT INTO config_workflow_wires (project_key, workflow_id, wire_id, max_cycles)
|
|
881
|
+
VALUES (?, ?, ?, ?)
|
|
882
|
+
ON CONFLICT(project_key, workflow_id, wire_id) DO UPDATE SET max_cycles = excluded.max_cycles
|
|
883
|
+
`).run(key, workflowId, wireId, n);
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
|
|
684
887
|
/**
|
|
685
888
|
* Drop every per-project override for one workflow — the New-Pipeline accordion's
|
|
686
889
|
* "Reset to defaults" (newpipeline-ux-design.md §4.5). Deletes the workflow's
|
|
@@ -706,6 +909,7 @@ export async function resetWorkflowConfig(projectDir, workflowId) {
|
|
|
706
909
|
tx(() => {
|
|
707
910
|
prepare('DELETE FROM config_workflow_nodes WHERE project_key = ? AND workflow_id = ?').run(key, id);
|
|
708
911
|
prepare('DELETE FROM config_workflow_feedbacks WHERE project_key = ? AND workflow_id = ?').run(key, id);
|
|
912
|
+
prepare('DELETE FROM config_workflow_wires WHERE project_key = ? AND workflow_id = ?').run(key, id);
|
|
709
913
|
if (clearLegacy) {
|
|
710
914
|
prepare(`
|
|
711
915
|
INSERT INTO project_config (project_key, steps, custom_models, active_workflow_id, extra)
|
|
@@ -746,6 +950,7 @@ export async function resolveRunConfig(projectDir, workflowId) {
|
|
|
746
950
|
const wf = readWorkflowsMap(projectKey(projectDir))[workflowId] || {};
|
|
747
951
|
return {
|
|
748
952
|
nodes: wf.nodes && typeof wf.nodes === 'object' ? wf.nodes : {},
|
|
953
|
+
wires: wf.wires && typeof wf.wires === 'object' ? wf.wires : {},
|
|
749
954
|
feedbacks: wf.feedbacks && typeof wf.feedbacks === 'object' ? wf.feedbacks : {},
|
|
750
955
|
};
|
|
751
956
|
}
|
|
@@ -864,16 +1069,34 @@ export async function removeGlobalModelAndRefs(id) {
|
|
|
864
1069
|
if (!stepKeysByProject.has(r.projectKey)) continue;
|
|
865
1070
|
const filtered = {};
|
|
866
1071
|
for (const [k, v] of Object.entries(r.steps)) {
|
|
867
|
-
if (v?.model && v.model.toLowerCase() === lc) {
|
|
1072
|
+
if (v?.model && v.model.toLowerCase() === lc) {
|
|
1073
|
+
// Clear ONLY the dangling ref (and the effort that travels with its
|
|
1074
|
+
// model); fanOut/askQuestions/subagentModel are not the removed
|
|
1075
|
+
// model's business and must survive — mirroring cleanNodeSel's
|
|
1076
|
+
// emptiness rule, the entry itself goes only when nothing is left.
|
|
1077
|
+
clearedSteps += 1;
|
|
1078
|
+
const { model: _m, effort: _e, ...rest } = v;
|
|
1079
|
+
if (Object.keys(rest).length) filtered[k] = rest;
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
868
1082
|
filtered[k] = v;
|
|
869
1083
|
}
|
|
870
1084
|
prepare('UPDATE project_config SET steps = ? WHERE project_key = ?')
|
|
871
1085
|
.run(JSON.stringify(filtered), r.projectKey);
|
|
872
1086
|
}
|
|
873
1087
|
for (const n of refs.nodes) {
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
1088
|
+
// Same rule for node rows: NULL the model+effort, keep the other
|
|
1089
|
+
// tunables, and drop the row only once every column is NULL (matching
|
|
1090
|
+
// readWorkflowsMap, which would not surface an all-NULL row anyway).
|
|
1091
|
+
clearedNodes += prepare(`
|
|
1092
|
+
UPDATE config_workflow_nodes SET model = NULL, effort = NULL
|
|
1093
|
+
WHERE project_key = ? AND workflow_id = ? AND node_id = ?
|
|
1094
|
+
`).run(n.projectKey, n.workflowId, n.nodeId).changes;
|
|
1095
|
+
prepare(`
|
|
1096
|
+
DELETE FROM config_workflow_nodes
|
|
1097
|
+
WHERE project_key = ? AND workflow_id = ? AND node_id = ?
|
|
1098
|
+
AND fan_out IS NULL AND ask_questions IS NULL AND subagent_model IS NULL
|
|
1099
|
+
`).run(n.projectKey, n.workflowId, n.nodeId);
|
|
877
1100
|
}
|
|
878
1101
|
});
|
|
879
1102
|
}
|
package/src/core/cost-budget.mjs
CHANGED
|
@@ -54,6 +54,33 @@ export function windowedSpendUsd(windowStartMs) {
|
|
|
54
54
|
return roundUsd(row?.s || 0);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** Append one Ask Worca cost event (ask-cost-statistics-design.md §7.1). Same
|
|
58
|
+
* no-op gate as recordCostDelta: turns that ended before a `result` frame
|
|
59
|
+
* (amountUsd null, §6.2.8 of the ask spec) and $0 mock turns leave no row.
|
|
60
|
+
* messageId is the v20 backfill's idempotency key (db.mjs NOT EXISTS on
|
|
61
|
+
* l.message_id) — every live caller must pass it. */
|
|
62
|
+
export function recordAskCostDelta({ threadId, messageId = null, amountUsd,
|
|
63
|
+
tokens = null, model = null, tsMs = Date.now() }) {
|
|
64
|
+
if (!threadId || !Number.isFinite(amountUsd) || amountUsd <= 0) return;
|
|
65
|
+
prepare(`INSERT INTO ask_cost_ledger (thread_id, message_id, amount_usd, tokens, model, ts)
|
|
66
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(threadId, messageId, amountUsd, tokens, model, tsMs);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Windowed Ask Worca spend; toMs null = open-ended (budget windows), else ts < toMs. */
|
|
70
|
+
export function askWindowedSpendUsd(fromMs, toMs = null) {
|
|
71
|
+
const row = toMs == null
|
|
72
|
+
? prepare('SELECT SUM(amount_usd) AS s FROM ask_cost_ledger WHERE ts >= ?').get(fromMs)
|
|
73
|
+
: prepare('SELECT SUM(amount_usd) AS s FROM ask_cost_ledger WHERE ts >= ? AND ts < ?').get(fromMs, toMs);
|
|
74
|
+
return roundUsd(row?.s || 0);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Pipeline + Ask Worca spend since windowStartMs — THE enforcement figure
|
|
78
|
+
* (count-everywhere, ask-cost-statistics-design.md D3). windowedSpendUsd /
|
|
79
|
+
* allTimeTotals stay pipeline-only for the Statistics split. */
|
|
80
|
+
export function totalWindowSpendUsd(windowStartMs) {
|
|
81
|
+
return roundUsd(windowedSpendUsd(windowStartMs) + askWindowedSpendUsd(windowStartMs));
|
|
82
|
+
}
|
|
83
|
+
|
|
57
84
|
/** All-time spend + active time over ALL pipelines (archived included),
|
|
58
85
|
* falling back to per-step sums when the row total is 0. */
|
|
59
86
|
export function allTimeTotals() {
|
|
@@ -86,7 +113,7 @@ export function budgetStatus(now = new Date()) {
|
|
|
86
113
|
const totalLimitUsd = totalCostLimitUsd();
|
|
87
114
|
const windowStartMs = costWindowStart(now, resetPeriod).getTime();
|
|
88
115
|
const windowEndMs = costWindowEnd(now, resetPeriod).getTime();
|
|
89
|
-
const windowSpendUsd =
|
|
116
|
+
const windowSpendUsd = totalWindowSpendUsd(windowStartMs);
|
|
90
117
|
const blocked = totalLimitUsd != null && windowSpendUsd >= totalLimitUsd;
|
|
91
118
|
return {
|
|
92
119
|
pipelineLimitUsd,
|
|
@@ -96,7 +123,7 @@ export function budgetStatus(now = new Date()) {
|
|
|
96
123
|
windowEndMs,
|
|
97
124
|
msUntilReset: windowEndMs - now.getTime(),
|
|
98
125
|
windowSpendUsd,
|
|
99
|
-
allTimeSpendUsd: allTimeTotals().spendUsd,
|
|
126
|
+
allTimeSpendUsd: roundUsd(allTimeTotals().spendUsd + askWindowedSpendUsd(0)),
|
|
100
127
|
remainingUsd: totalLimitUsd == null ? null : Math.max(0, roundUsd(totalLimitUsd - windowSpendUsd)),
|
|
101
128
|
blocked,
|
|
102
129
|
};
|