@worca/app 1.0.0 → 1.2.0-rc.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 +30 -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 +386 -56
- 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 +199 -23
- package/src/core/ask/attachment-kind.mjs +95 -0
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +545 -0
- package/src/core/ask/follow.mjs +113 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +57 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +286 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +156 -0
- package/src/core/ask/store.mjs +438 -0
- package/src/core/ask/tool-deps.mjs +87 -0
- package/src/core/ask/tools.mjs +879 -0
- package/src/core/ask/turn.mjs +462 -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 +28 -7
- package/src/core/chat/notifier.mjs +6 -1
- package/src/core/chat/renderers.mjs +15 -8
- package/src/core/claude-runner.mjs +541 -62
- package/src/core/config.mjs +310 -44
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +773 -53
- 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/failure-policy.mjs +201 -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 +1072 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/host-guard.mjs +271 -0
- package/src/core/model-env.mjs +180 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +994 -4097
- 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 +80 -17
- 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 +3934 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +184 -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 +4240 -1682
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +317 -0
- package/ui/public/ask-panel.mjs +2129 -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 +311 -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 +1487 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1894 -104
- 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
|
|
|
@@ -57,19 +57,23 @@ export { EFFORTS };
|
|
|
57
57
|
* The `[1m]` suffix selects the 1M-token long-context variant. Opus 4.6–4.8 and
|
|
58
58
|
* Sonnet 4.6 1M ids were verified to resolve via `claude --model`; Haiku 4.5 1M
|
|
59
59
|
* is intentionally omitted — the CLI rejects it ("long context beta is not yet
|
|
60
|
-
* available for this subscription"). Fable 5 needs no `[1m]` suffix: its context
|
|
61
|
-
* window is 1M by default (verified to resolve via `claude --model
|
|
62
|
-
* (`claude-
|
|
60
|
+
* available for this subscription"). Fable 5.1 needs no `[1m]` suffix: its context
|
|
61
|
+
* window is 1M by default (verified to resolve via `claude --model`, CLI 2.1.257).
|
|
62
|
+
* It replaced Fable 5 (`claude-fable-5`) on 2026-09-01; db.mjs V26 moves every
|
|
63
|
+
* stored pin on the retired id to the successor, so nothing keeps it here. Opus 5
|
|
64
|
+
* (`claude-opus-5`) and Sonnet 5 (`claude-sonnet-5`) are likewise 1M-only and
|
|
65
|
+
* carry no `[1m]` twin.
|
|
63
66
|
*/
|
|
64
67
|
export const PREDEFINED_MODELS = [
|
|
65
68
|
{ id: 'claude-opus-5', label: 'Opus 5', efforts: ['medium', 'high', 'xhigh', 'max'] },
|
|
66
|
-
{ id: 'claude-fable-5',
|
|
69
|
+
{ id: 'claude-fable-5-1', label: 'Fable 5.1 (1M)', efforts: ['medium', 'high', 'xhigh', 'max'] },
|
|
67
70
|
{ id: 'claude-opus-4-8', label: 'Opus 4.8', efforts: ['medium', 'high', 'xhigh', 'max'] },
|
|
68
71
|
{ id: 'claude-opus-4-8[1m]', label: 'Opus 4.8 (1M)', efforts: ['medium', 'high', 'xhigh', 'max'] },
|
|
69
72
|
{ id: 'claude-opus-4-7', label: 'Opus 4.7', efforts: ['medium', 'high', 'xhigh', 'max'] },
|
|
70
73
|
{ id: 'claude-opus-4-7[1m]', label: 'Opus 4.7 (1M)', efforts: ['medium', 'high', 'xhigh', 'max'] },
|
|
71
74
|
{ id: 'claude-opus-4-6', label: 'Opus 4.6', efforts: ['medium', 'high', 'max'] },
|
|
72
75
|
{ id: 'claude-opus-4-6[1m]', label: 'Opus 4.6 (1M)', efforts: ['medium', 'high', 'max'] },
|
|
76
|
+
{ id: 'claude-sonnet-5', label: 'Sonnet 5', efforts: ['medium', 'high', 'xhigh', 'max'] },
|
|
73
77
|
{ id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', efforts: ['medium', 'high', 'max'] },
|
|
74
78
|
{ id: 'claude-sonnet-4-6[1m]', label: 'Sonnet 4.6 (1M)', efforts: ['medium', 'high', 'max'] },
|
|
75
79
|
{ id: 'claude-haiku-4-5', label: 'Haiku 4.5', efforts: ['medium', 'high'] },
|
|
@@ -84,24 +88,17 @@ function defaultConfig() {
|
|
|
84
88
|
return { steps: {}, customModels: [] };
|
|
85
89
|
}
|
|
86
90
|
|
|
87
|
-
/** Keep only known step keys
|
|
91
|
+
/** Keep only known step keys whose entry survives cleanNodeSel — ONE coercion
|
|
92
|
+
* rule for both scopes (a per-step entry IS a node selection; a second
|
|
93
|
+
* line-for-line copy here is how the two silently diverge). An unknown
|
|
94
|
+
* subagentModel is dropped, never stored. */
|
|
88
95
|
function sanitizeSteps(steps) {
|
|
89
96
|
const out = {};
|
|
90
97
|
const keys = stepKeys();
|
|
91
98
|
for (const [k, v] of Object.entries(steps || {})) {
|
|
92
99
|
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
|
-
}
|
|
100
|
+
const sel = cleanNodeSel(v); // hoisted declaration (defined below)
|
|
101
|
+
if (sel) out[k] = sel;
|
|
105
102
|
}
|
|
106
103
|
return out;
|
|
107
104
|
}
|
|
@@ -172,7 +169,10 @@ export async function readConfig(projectDir) {
|
|
|
172
169
|
* `custom` is false | 'global' | 'plugin' | 'project' (strings truthy, so
|
|
173
170
|
* existing `m.custom` checks keep working); plugin entries also carry
|
|
174
171
|
* `plugin: '<name>'`; `hasEnv` advertises routing env WITHOUT the values
|
|
175
|
-
* (this shape feeds UI dropdowns).
|
|
172
|
+
* (this shape feeds UI dropdowns). `routed` advertises an ANTHROPIC_BASE_URL
|
|
173
|
+
* override the same way (key presence, values never leaked) — computed from the
|
|
174
|
+
* env objects already in scope here, NEVER by calling modelHasBaseUrlRouting per
|
|
175
|
+
* entry (that re-reads settings + the plugins lock from disk on every row).
|
|
176
176
|
*/
|
|
177
177
|
function composeCatalog(projectCustom = []) {
|
|
178
178
|
const globals = listGlobalModels();
|
|
@@ -183,26 +183,27 @@ function composeCatalog(projectCustom = []) {
|
|
|
183
183
|
const out = [];
|
|
184
184
|
const seen = new Set();
|
|
185
185
|
const unreliable = (lc) => (flagged.has(lc) ? { costUnreliable: true } : {});
|
|
186
|
+
const routedOf = (env) => !!(env && 'ANTHROPIC_BASE_URL' in env);
|
|
186
187
|
const pluginShape = (id, m, lc) => ({
|
|
187
188
|
id, label: m.label, efforts: [...m.efforts], custom: 'plugin', plugin: m.plugin,
|
|
188
|
-
hasEnv: !!m.env, ...unreliable(lc),
|
|
189
|
+
hasEnv: !!m.env, routed: routedOf(m.env), ...unreliable(lc),
|
|
189
190
|
});
|
|
190
191
|
for (const m of PREDEFINED_MODELS) {
|
|
191
192
|
const lc = m.id.toLowerCase();
|
|
192
193
|
const shadow = globalByIdLc.get(lc);
|
|
193
194
|
const pshadow = pluginByIdLc.get(lc);
|
|
194
195
|
out.push(shadow
|
|
195
|
-
? { id: m.id, label: shadow.label, efforts: [...shadow.efforts], custom: 'global', hasEnv: !!shadow.env, ...unreliable(lc) }
|
|
196
|
+
? { id: m.id, label: shadow.label, efforts: [...shadow.efforts], custom: 'global', hasEnv: !!shadow.env, routed: routedOf(shadow.env), ...unreliable(lc) }
|
|
196
197
|
: pshadow
|
|
197
198
|
? pluginShape(m.id, pshadow, lc)
|
|
198
|
-
: { ...m, custom: false, hasEnv: false });
|
|
199
|
+
: { ...m, custom: false, hasEnv: false, routed: false });
|
|
199
200
|
seen.add(lc);
|
|
200
201
|
}
|
|
201
202
|
for (const m of globals) {
|
|
202
203
|
const lc = m.id.toLowerCase();
|
|
203
204
|
if (seen.has(lc)) continue; // predefined shadow, already emitted
|
|
204
205
|
seen.add(lc);
|
|
205
|
-
out.push({ id: m.id, label: m.label, efforts: [...m.efforts], custom: 'global', hasEnv: !!m.env, ...unreliable(lc) });
|
|
206
|
+
out.push({ id: m.id, label: m.label, efforts: [...m.efforts], custom: 'global', hasEnv: !!m.env, routed: routedOf(m.env), ...unreliable(lc) });
|
|
206
207
|
}
|
|
207
208
|
for (const m of plugins) {
|
|
208
209
|
const lc = m.id.toLowerCase();
|
|
@@ -213,7 +214,7 @@ function composeCatalog(projectCustom = []) {
|
|
|
213
214
|
for (const m of projectCustom) {
|
|
214
215
|
if (seen.has(m.id.toLowerCase())) continue; // predefined/global/plugin wins
|
|
215
216
|
seen.add(m.id.toLowerCase());
|
|
216
|
-
out.push({ id: m.id, label: m.label, efforts: [...EFFORTS], custom: 'project', hasEnv: false });
|
|
217
|
+
out.push({ id: m.id, label: m.label, efforts: [...EFFORTS], custom: 'project', hasEnv: false, routed: false });
|
|
217
218
|
}
|
|
218
219
|
return out;
|
|
219
220
|
}
|
|
@@ -258,11 +259,24 @@ export function costUnreliableModelIds() {
|
|
|
258
259
|
* @param {string} modelId
|
|
259
260
|
* @param {number|null} costUsd the reported cost (null when absent)
|
|
260
261
|
* @param {object} [usage]
|
|
262
|
+
* @param {object|null} [costCfg] this model's already-looked-up cost override
|
|
263
|
+
* (modelCostConfig). Pass it when the caller has one in hand — every lookup is
|
|
264
|
+
* a fresh settings.json read (settings.mjs is deliberately uncached), and the
|
|
265
|
+
* result path needs the same answer for resolveModelCost. `undefined` = look
|
|
266
|
+
* it up here; `null` = "checked, there is none".
|
|
261
267
|
* @returns {'flagged'|'cleared'|null} what changed — 'flagged' asks the caller
|
|
262
268
|
* to surface its one-per-run warning; null = no observation recorded
|
|
263
269
|
*/
|
|
264
|
-
export function observeModelCost(modelId, costUsd, usage) {
|
|
270
|
+
export function observeModelCost(modelId, costUsd, usage, costCfg = undefined) {
|
|
265
271
|
if (!modelHasBaseUrlRouting(modelId)) return null;
|
|
272
|
+
// An explicit per-model cost override GOVERNS this model's spend (resolveModelCost
|
|
273
|
+
// below) — the CLI's own figure is never trusted for it, so the "unreliable"
|
|
274
|
+
// badge is meaningless. Never flag it, and lift any flag left from before the
|
|
275
|
+
// override existed. Derived state: a DB hiccup here must never fail the run.
|
|
276
|
+
if (costCfg !== undefined ? costCfg : modelCostConfig(modelId)) {
|
|
277
|
+
try { prepare('DELETE FROM model_cost_flags WHERE model_id = ?').run(String(modelId).trim()); } catch { /* derived */ }
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
266
280
|
const u = usage && typeof usage === 'object' ? usage : {};
|
|
267
281
|
const tokens = ['input_tokens', 'output_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens']
|
|
268
282
|
.reduce((n, k) => n + (Number(u[k]) || 0), 0);
|
|
@@ -284,6 +298,164 @@ export function observeModelCost(modelId, costUsd, usage) {
|
|
|
284
298
|
return null; // no cost AND no tokens (e.g. an errored run) — no signal either way
|
|
285
299
|
}
|
|
286
300
|
|
|
301
|
+
// ── per-model cost override (opt-in) ──────────────────────────────────────────
|
|
302
|
+
// The Claude CLI computes total_cost_usd from its OWN per-model price table keyed
|
|
303
|
+
// on the model NAME — so an on-prem/proxied endpoint (even one that returns no
|
|
304
|
+
// cost) still gets a fabricated dollar figure, which observeModelCost cannot
|
|
305
|
+
// distinguish from a real one once it is positive. A user who KNOWS a model's
|
|
306
|
+
// real price (or that it is free) can pin it in the GLOBAL catalog; that override
|
|
307
|
+
// then wins over whatever the CLI reports. Inspired by worca 0.x's cost_alias /
|
|
308
|
+
// worca.pricing.models mechanism. Opt-in: with no override the CLI value stands.
|
|
309
|
+
//
|
|
310
|
+
// It governs EVERY surface that books spend, because they share one windowed
|
|
311
|
+
// budget (cost-budget.mjs combinedWindowedSpendUsd): the orchestrator's result
|
|
312
|
+
// intake and sub-agent telemetry (orchestrator.mjs), an Ask Worca turn (the
|
|
313
|
+
// `resolveCost` hook ask/turn.mjs injects into the reducer), and the overview
|
|
314
|
+
// agent's telemetry row. Re-pricing only some of them would leave the phantom
|
|
315
|
+
// spend this exists to remove still inflating the budget from the others.
|
|
316
|
+
|
|
317
|
+
/** The explicit cost override governing a model, or null. Shape: {free:true} |
|
|
318
|
+
* {perMtok:{input?,output?,cacheRead?,cacheWrite?,cacheWrite1h?}} (USD per
|
|
319
|
+
* million tokens). Resolved with the SAME precedence as the rest of a model's
|
|
320
|
+
* configuration (§9.3, mirroring modelHasBaseUrlRouting): the user's GLOBAL
|
|
321
|
+
* entry wins outright, else the winning PLUGIN entry's manifest price — a
|
|
322
|
+
* plugin shipping a model on its own endpoint is exactly the case that needs
|
|
323
|
+
* one. Note a global entry shadows the plugin's price even when it pins none:
|
|
324
|
+
* taking over a model id means owning its pricing too, so the two layers can
|
|
325
|
+
* never half-merge. Built-ins carry none. Never throws. */
|
|
326
|
+
export function modelCostConfig(modelId) {
|
|
327
|
+
const id = typeof modelId === 'string' ? modelId.trim() : '';
|
|
328
|
+
if (!id) return null;
|
|
329
|
+
const lc = id.toLowerCase();
|
|
330
|
+
const entry = listGlobalModels().find((m) => m.id.toLowerCase() === lc);
|
|
331
|
+
if (entry) return entry.cost ?? null;
|
|
332
|
+
const pm = listPluginModels().find((m) => m.id.toLowerCase() === lc);
|
|
333
|
+
return pm?.cost ?? null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** The four token classes read off a usage object, accepting BOTH spellings in
|
|
337
|
+
* play here: the RAW Claude result usage (`input_tokens`, …) that the pipeline
|
|
338
|
+
* path carries, and Ask Worca's normalized persisted shape (`input`, `output`,
|
|
339
|
+
* `cacheRead`, `cacheCreation` — ask/events.mjs normalizeUsage). Same tokens,
|
|
340
|
+
* two names; a missing field counts as 0. Only the raw shape ever carries the
|
|
341
|
+
* ephemeral cache-creation breakdown. */
|
|
342
|
+
function usageTokens(u) {
|
|
343
|
+
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
|
|
344
|
+
const pick = (snake, camel) => (u[snake] != null ? num(u[snake]) : num(u[camel]));
|
|
345
|
+
const cc = u.cache_creation && typeof u.cache_creation === 'object' ? u.cache_creation : null;
|
|
346
|
+
return {
|
|
347
|
+
input: pick('input_tokens', 'input'),
|
|
348
|
+
output: pick('output_tokens', 'output'),
|
|
349
|
+
cacheRead: pick('cache_read_input_tokens', 'cacheRead'),
|
|
350
|
+
cacheWrite: pick('cache_creation_input_tokens', 'cacheCreation'),
|
|
351
|
+
eph1h: cc ? num(cc.ephemeral_1h_input_tokens) : 0,
|
|
352
|
+
eph5m: cc ? num(cc.ephemeral_5m_input_tokens) : 0,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** True when `usage` is an object that actually reports token counts — i.e. it
|
|
357
|
+
* can be priced. A result event that carried NO usage at all is unpriceable and
|
|
358
|
+
* must not be silently booked at $0 (resolveModelCost returns NaN for it); an
|
|
359
|
+
* object whose counts are genuinely all zero IS priceable, at $0. */
|
|
360
|
+
export function isPriceableUsage(usage) {
|
|
361
|
+
if (!usage || typeof usage !== 'object') return false;
|
|
362
|
+
return ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens',
|
|
363
|
+
'cache_creation', 'input', 'output', 'cacheRead', 'cacheCreation'].some((k) => usage[k] != null);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Estimate USD from a `usage` object (either spelling, see usageTokens) and a
|
|
367
|
+
* per-million-token rate table (mirrors worca 0.x estimate_cost). Absent rates/
|
|
368
|
+
* fields count as 0. When the CLI breaks cache-creation into ephemeral 1h/5m
|
|
369
|
+
* buckets they are priced separately (1h falls back to the cacheWrite rate);
|
|
370
|
+
* otherwise the flat cache_creation_input_tokens total is priced at cacheWrite. */
|
|
371
|
+
export function estimateCost(usage, perMtok) {
|
|
372
|
+
if (!perMtok || typeof perMtok !== 'object') return 0;
|
|
373
|
+
const u = usage && typeof usage === 'object' ? usage : {};
|
|
374
|
+
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
|
|
375
|
+
const rate = (k) => num(perMtok[k]);
|
|
376
|
+
const t = usageTokens(u);
|
|
377
|
+
const cacheWriteCost = (t.eph1h || t.eph5m)
|
|
378
|
+
? (t.eph5m * rate('cacheWrite')
|
|
379
|
+
+ t.eph1h * (perMtok.cacheWrite1h != null ? rate('cacheWrite1h') : rate('cacheWrite'))) / 1e6
|
|
380
|
+
: t.cacheWrite * rate('cacheWrite') / 1e6;
|
|
381
|
+
return (
|
|
382
|
+
t.input * rate('input')
|
|
383
|
+
+ t.output * rate('output')
|
|
384
|
+
+ t.cacheRead * rate('cacheRead')
|
|
385
|
+
) / 1e6 + cacheWriteCost;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* The AUTHORITATIVE cost for a dispatched model: the CLI's own figure unless the
|
|
390
|
+
* model carries an explicit override, which then wins. {free} → $0; {perMtok} →
|
|
391
|
+
* recomputed from `usage`. This is what stops a CLI that prices an on-prem model
|
|
392
|
+
* by name from inflating the ledger. With no override, `cliCostUsd` is returned
|
|
393
|
+
* verbatim (finite or not — the caller already gates on Number.isFinite).
|
|
394
|
+
*
|
|
395
|
+
* ONLY call this on a genuinely cost-bearing event. A {free} model answers 0 for
|
|
396
|
+
* ANY input, so feeding it a non-result stream frame (whose `cliCostUsd` is NaN)
|
|
397
|
+
* would turn "nothing to record" into a real $0 the caller then books.
|
|
398
|
+
*
|
|
399
|
+
* A {perMtok} model is priced from tokens ALONE, so a result that carried no
|
|
400
|
+
* usage object at all is UNPRICEABLE: NaN comes back rather than a silent $0, so
|
|
401
|
+
* the caller's existing "no cost estimate" branch reports it instead of the
|
|
402
|
+
* operator quietly under-billing a model they explicitly asked to be priced.
|
|
403
|
+
* @param {string} modelId the dispatched model id
|
|
404
|
+
* @param {number} cliCostUsd the cost the CLI reported (may be NaN)
|
|
405
|
+
* @param {object} [usage] the result event's usage object (either spelling)
|
|
406
|
+
* @param {object|null} [costCfg] this model's already-looked-up cost override;
|
|
407
|
+
* `undefined` = look it up here (see observeModelCost's note on sharing it)
|
|
408
|
+
* @returns {number}
|
|
409
|
+
*/
|
|
410
|
+
export function resolveModelCost(modelId, cliCostUsd, usage, costCfg = undefined) {
|
|
411
|
+
const cost = costCfg !== undefined ? costCfg : modelCostConfig(modelId);
|
|
412
|
+
if (!cost) return cliCostUsd;
|
|
413
|
+
if (cost.free) return 0;
|
|
414
|
+
if (cost.perMtok) return isPriceableUsage(usage) ? estimateCost(usage, cost.perMtok) : NaN;
|
|
415
|
+
return cliCostUsd;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ── display-only list prices ──────────────────────────────────────────────────
|
|
419
|
+
// USD per MILLION tokens for the built-in ids, from Anthropic's published
|
|
420
|
+
// pricing (platform.claude.com/docs/en/pricing — snapshot 2026-06-24). DISPLAY
|
|
421
|
+
// APPROXIMATION ONLY: it feeds the chat footer's live "≈" estimate while a turn
|
|
422
|
+
// streams (ask/events.mjs `estimatedCostUsd`). The CLI's result.total_cost_usd,
|
|
423
|
+
// re-priced by resolveModelCost, stays the ONLY figure any message row, thread
|
|
424
|
+
// total, ledger or budget ever books — nothing here is read by those paths.
|
|
425
|
+
// Ids missing here get no estimate (null), which is the pre-existing behaviour;
|
|
426
|
+
// `[1m]` twins and dated ids resolve to their base row (the long-context premium
|
|
427
|
+
// is not modelled). cacheWrite = 1.25× input (5-minute TTL), cacheWrite1h = 2×
|
|
428
|
+
// input, cacheRead = 0.1× input except Fable 5.1 (0.025×). Refresh by hand when
|
|
429
|
+
// Anthropic moves a price. PREDEFINED_MODELS itself stays untouched — its entry
|
|
430
|
+
// shape is pinned (test/config-models-global.test.mjs:205).
|
|
431
|
+
export const PREDEFINED_LIST_PRICES = Object.freeze({
|
|
432
|
+
'claude-fable-5-1': { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5, cacheWrite1h: 20 },
|
|
433
|
+
'claude-opus-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
|
|
434
|
+
'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
|
|
435
|
+
'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
|
|
436
|
+
'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
|
|
437
|
+
'claude-sonnet-5': { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5, cacheWrite1h: 4 },
|
|
438
|
+
'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite1h: 6 },
|
|
439
|
+
'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25, cacheWrite1h: 2 },
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const FREE_RATES = Object.freeze({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0 });
|
|
443
|
+
|
|
444
|
+
/** The per-Mtok rates a DISPLAY estimate may price `modelId` with: the operator's
|
|
445
|
+
* modelCostConfig override when one exists ({free} → all-zero rates, so a free
|
|
446
|
+
* model estimates $0 instead of a list price), else the built-in list price,
|
|
447
|
+
* else null (no estimate). Never throws. */
|
|
448
|
+
export function liveCostRates(modelId) {
|
|
449
|
+
const id = typeof modelId === 'string' ? modelId.trim() : '';
|
|
450
|
+
if (!id) return null;
|
|
451
|
+
let cfg = null;
|
|
452
|
+
try { cfg = modelCostConfig(id); } catch { cfg = null; }
|
|
453
|
+
if (cfg && cfg.free === true) return FREE_RATES;
|
|
454
|
+
if (cfg && cfg.perMtok && typeof cfg.perMtok === 'object') return cfg.perMtok;
|
|
455
|
+
const base = id.toLowerCase().replace(/\[1m\]$/, '').replace(/-\d{8}$/, '');
|
|
456
|
+
return PREDEFINED_LIST_PRICES[base] ?? null;
|
|
457
|
+
}
|
|
458
|
+
|
|
287
459
|
/**
|
|
288
460
|
* All selectable models for a project = the effective catalog (predefined ⊕
|
|
289
461
|
* global ⊕ this project's legacy custom models). Legacy custom models
|
|
@@ -388,6 +560,27 @@ function inheritOr(next, prev) {
|
|
|
388
560
|
return typeof prev === 'boolean' ? prev : undefined;
|
|
389
561
|
}
|
|
390
562
|
|
|
563
|
+
/**
|
|
564
|
+
* Tri-state resolution for the STRING tunable (subagentModel), shared by setStep
|
|
565
|
+
* and setNodeModel. It is preserve-on-absent rather than replace-like model/effort
|
|
566
|
+
* on purpose: the field arrived after the write APIs shipped, so an older client
|
|
567
|
+
* (or any caller that only means to change the model) POSTs without it and must
|
|
568
|
+
* not silently wipe a configured sub-agent policy.
|
|
569
|
+
* a valid value -> that value
|
|
570
|
+
* '' or null -> undefined = cleared (an explicit "inherit again")
|
|
571
|
+
* absent -> the previous value
|
|
572
|
+
* An unknown non-empty string is treated as absent: the enum is validated at the
|
|
573
|
+
* API boundary, and a typo must not clear a working setting.
|
|
574
|
+
* @param {unknown} next
|
|
575
|
+
* @param {unknown} prev
|
|
576
|
+
* @returns {string|undefined}
|
|
577
|
+
*/
|
|
578
|
+
function inheritOrSubagentModel(next, prev) {
|
|
579
|
+
if (isSubagentModelValue(next)) return next;
|
|
580
|
+
if (next === null || next === '') return undefined;
|
|
581
|
+
return isSubagentModelValue(prev) ? prev : undefined;
|
|
582
|
+
}
|
|
583
|
+
|
|
391
584
|
/**
|
|
392
585
|
* Set (or clear) the model + effort for one agent step. An empty model => inherit
|
|
393
586
|
* the global/CLI default; an empty effort => model default. Effort must be supported
|
|
@@ -411,6 +604,11 @@ export async function setStep(projectDir, step, selection = {}) {
|
|
|
411
604
|
}
|
|
412
605
|
}
|
|
413
606
|
|
|
607
|
+
{
|
|
608
|
+
const issue = subagentModelIssue(selection.subagentModel);
|
|
609
|
+
if (issue) throw new Error(issue);
|
|
610
|
+
}
|
|
611
|
+
|
|
414
612
|
const key = projectKey(projectDir);
|
|
415
613
|
const cfg = readRaw(projectDir);
|
|
416
614
|
const prev = cfg.steps[step] || {};
|
|
@@ -422,12 +620,16 @@ export async function setStep(projectDir, step, selection = {}) {
|
|
|
422
620
|
// askQuestions mirrors fanOut: preserved when omitted (only the toggle sends
|
|
423
621
|
// it), set when a boolean (spec 2026-07-11 §4), cleared on null.
|
|
424
622
|
const askQuestions = inheritOr(selection.askQuestions, prev.askQuestions);
|
|
623
|
+
// subagentModel: the sub-agent model policy for a fan-out node. Preserved when
|
|
624
|
+
// omitted (see inheritOrSubagentModel), cleared on '' / null.
|
|
625
|
+
const subagentModel = inheritOrSubagentModel(selection.subagentModel, prev.subagentModel);
|
|
425
626
|
|
|
426
627
|
const steps = { ...cfg.steps };
|
|
427
|
-
if (!model && !effort && fanOut === undefined && askQuestions === undefined) delete steps[step];
|
|
628
|
+
if (!model && !effort && !subagentModel && fanOut === undefined && askQuestions === undefined) delete steps[step];
|
|
428
629
|
else steps[step] = {
|
|
429
630
|
...(model && { model }),
|
|
430
631
|
...(effort && { effort }),
|
|
632
|
+
...(subagentModel && { subagentModel }),
|
|
431
633
|
...(fanOut !== undefined && { fanOut }),
|
|
432
634
|
...(askQuestions !== undefined && { askQuestions }),
|
|
433
635
|
};
|
|
@@ -523,16 +725,19 @@ export async function promoteCustomModel(projectDir, id) {
|
|
|
523
725
|
// nested shape from those rows. activeWorkflowId is project_config.active_workflow_id;
|
|
524
726
|
// unknown top-level keys (e.g. webUiTesting) round-trip via project_config.extra.
|
|
525
727
|
|
|
526
|
-
/** Coerce a per-node selection to a clean {model?,effort?,fanOut?,askQuestions?}
|
|
728
|
+
/** Coerce a per-node selection to a clean {model?,effort?,subagentModel?,fanOut?,askQuestions?}
|
|
729
|
+
* or null (all empty). */
|
|
527
730
|
function cleanNodeSel(selection) {
|
|
528
731
|
const model = typeof selection?.model === 'string' ? selection.model.trim() : '';
|
|
529
732
|
const effort = typeof selection?.effort === 'string' ? selection.effort.trim() : '';
|
|
733
|
+
const subagentModel = isSubagentModelValue(selection?.subagentModel) ? selection.subagentModel : '';
|
|
530
734
|
const fanOut = typeof selection?.fanOut === 'boolean' ? selection.fanOut : undefined;
|
|
531
735
|
const askQuestions = typeof selection?.askQuestions === 'boolean' ? selection.askQuestions : undefined;
|
|
532
|
-
if (!model && !effort && fanOut === undefined && askQuestions === undefined) return null;
|
|
736
|
+
if (!model && !effort && !subagentModel && fanOut === undefined && askQuestions === undefined) return null;
|
|
533
737
|
return {
|
|
534
738
|
...(model && { model }),
|
|
535
739
|
...(effort && { effort }),
|
|
740
|
+
...(subagentModel && { subagentModel }),
|
|
536
741
|
...(fanOut !== undefined && { fanOut }),
|
|
537
742
|
...(askQuestions !== undefined && { askQuestions }),
|
|
538
743
|
};
|
|
@@ -547,17 +752,23 @@ function cleanNodeSel(selection) {
|
|
|
547
752
|
*/
|
|
548
753
|
function readWorkflowsMap(key) {
|
|
549
754
|
getDb();
|
|
550
|
-
|
|
755
|
+
// NULL-prototype accumulator, deliberately: a stored workflow_id of
|
|
756
|
+
// '__proto__' would resolve truthy to Object.prototype on a plain `{}` and the
|
|
757
|
+
// next `.wires[id] =` would throw FOREVER for that project (MAJ-1). With no
|
|
758
|
+
// prototype, `workflows['__proto__'] = ...` is an ordinary own property, so a
|
|
759
|
+
// poisoned row degrades to a visible junk entry instead of a permanent 500.
|
|
760
|
+
const workflows = Object.create(null);
|
|
551
761
|
const ensure = (wf) => {
|
|
552
|
-
if (!workflows[wf]) workflows[wf] = { nodes: {}, feedbacks: {} };
|
|
762
|
+
if (!workflows[wf]) workflows[wf] = { nodes: {}, feedbacks: {}, wires: {} };
|
|
553
763
|
return workflows[wf];
|
|
554
764
|
};
|
|
555
765
|
for (const r of prepare(
|
|
556
|
-
'SELECT workflow_id, node_id, model, effort, fan_out, ask_questions FROM config_workflow_nodes WHERE project_key = ?'
|
|
766
|
+
'SELECT workflow_id, node_id, model, effort, fan_out, ask_questions, subagent_model FROM config_workflow_nodes WHERE project_key = ?'
|
|
557
767
|
).all(key)) {
|
|
558
768
|
const sel = {};
|
|
559
769
|
if (r.model) sel.model = r.model;
|
|
560
770
|
if (r.effort) sel.effort = r.effort;
|
|
771
|
+
if (isSubagentModelValue(r.subagent_model)) sel.subagentModel = r.subagent_model;
|
|
561
772
|
if (r.fan_out !== null && r.fan_out !== undefined) sel.fanOut = !!r.fan_out;
|
|
562
773
|
if (r.ask_questions !== null && r.ask_questions !== undefined) sel.askQuestions = !!r.ask_questions;
|
|
563
774
|
// Only attach a node entry that carries something (matches cleanNodeSel output).
|
|
@@ -568,7 +779,17 @@ function readWorkflowsMap(key) {
|
|
|
568
779
|
).all(key)) {
|
|
569
780
|
ensure(r.workflow_id).feedbacks[r.fb_id] = { maxCycles: r.max_cycles };
|
|
570
781
|
}
|
|
571
|
-
|
|
782
|
+
// v23: per-loop-wire budgets (the graph twin of config_workflow_feedbacks).
|
|
783
|
+
for (const r of prepare(
|
|
784
|
+
'SELECT workflow_id, wire_id, max_cycles FROM config_workflow_wires WHERE project_key = ?'
|
|
785
|
+
).all(key)) {
|
|
786
|
+
ensure(r.workflow_id).wires[r.wire_id] = { maxCycles: r.max_cycles };
|
|
787
|
+
}
|
|
788
|
+
// Hand callers an ORDINARY object: spread copies with CreateDataProperty, so a
|
|
789
|
+
// '__proto__' key stays an own enumerable property (a plain assignment would
|
|
790
|
+
// have hit the setter and silently vanished) while the public shape — what
|
|
791
|
+
// JSON.stringify and every deepEqual in the suite see — is unchanged.
|
|
792
|
+
return { ...workflows };
|
|
572
793
|
}
|
|
573
794
|
|
|
574
795
|
/**
|
|
@@ -598,8 +819,8 @@ export async function readRunConfig(projectDir) {
|
|
|
598
819
|
}
|
|
599
820
|
|
|
600
821
|
/**
|
|
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
|
|
822
|
+
* Set (or clear) the model+effort+subagentModel+fanOut+askQuestions for one node
|
|
823
|
+
* instance of a workflow. A cleaned selection of null (all blank) deletes the row. fanOut and
|
|
603
824
|
* askQuestions are preserved when the caller omits them (read from the existing
|
|
604
825
|
* row), set when a boolean, and cleared on an explicit null. Writes only the config_workflow_nodes table
|
|
605
826
|
* (legacy view + extra untouched). Model/effort validate against the effective
|
|
@@ -609,7 +830,7 @@ export async function readRunConfig(projectDir) {
|
|
|
609
830
|
* @param {string} projectDir
|
|
610
831
|
* @param {string} workflowId
|
|
611
832
|
* @param {string} nodeId
|
|
612
|
-
* @param {{model?:string,effort?:string,fanOut?:boolean,askQuestions?:boolean}} selection
|
|
833
|
+
* @param {{model?:string,effort?:string,subagentModel?:string,fanOut?:boolean,askQuestions?:boolean}} selection
|
|
613
834
|
* @returns {Promise<void>}
|
|
614
835
|
*/
|
|
615
836
|
export async function setNodeModel(projectDir, workflowId, nodeId, selection = {}) {
|
|
@@ -626,16 +847,22 @@ export async function setNodeModel(projectDir, workflowId, nodeId, selection = {
|
|
|
626
847
|
}
|
|
627
848
|
}
|
|
628
849
|
|
|
850
|
+
{
|
|
851
|
+
const issue = subagentModelIssue(selection.subagentModel);
|
|
852
|
+
if (issue) throw new Error(issue);
|
|
853
|
+
}
|
|
854
|
+
|
|
629
855
|
const key = projectKey(projectDir);
|
|
630
856
|
getDb();
|
|
631
857
|
const prev = prepare(
|
|
632
|
-
'SELECT fan_out, ask_questions FROM config_workflow_nodes WHERE project_key = ? AND workflow_id = ? AND node_id = ?'
|
|
858
|
+
'SELECT fan_out, ask_questions, subagent_model FROM config_workflow_nodes WHERE project_key = ? AND workflow_id = ? AND node_id = ?'
|
|
633
859
|
).get(key, workflowId, nodeId);
|
|
634
860
|
const prevFanOut = prev && prev.fan_out !== null && prev.fan_out !== undefined ? !!prev.fan_out : undefined;
|
|
635
861
|
const fanOut = inheritOr(selection.fanOut, prevFanOut);
|
|
636
862
|
const prevAsk = prev && prev.ask_questions !== null && prev.ask_questions !== undefined ? !!prev.ask_questions : undefined;
|
|
637
863
|
const askQuestions = inheritOr(selection.askQuestions, prevAsk);
|
|
638
|
-
const
|
|
864
|
+
const subagentModel = inheritOrSubagentModel(selection.subagentModel, prev && prev.subagent_model);
|
|
865
|
+
const sel = cleanNodeSel({ model: selection.model, effort: selection.effort, subagentModel, fanOut, askQuestions });
|
|
639
866
|
|
|
640
867
|
tx(() => {
|
|
641
868
|
if (!sel) {
|
|
@@ -645,17 +872,19 @@ export async function setNodeModel(projectDir, workflowId, nodeId, selection = {
|
|
|
645
872
|
return;
|
|
646
873
|
}
|
|
647
874
|
prepare(`
|
|
648
|
-
INSERT INTO config_workflow_nodes (project_key, workflow_id, node_id, model, effort, fan_out, ask_questions)
|
|
649
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
875
|
+
INSERT INTO config_workflow_nodes (project_key, workflow_id, node_id, model, effort, fan_out, ask_questions, subagent_model)
|
|
876
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
650
877
|
ON CONFLICT(project_key, workflow_id, node_id)
|
|
651
878
|
DO UPDATE SET model = excluded.model, effort = excluded.effort,
|
|
652
|
-
fan_out = excluded.fan_out, ask_questions = excluded.ask_questions
|
|
879
|
+
fan_out = excluded.fan_out, ask_questions = excluded.ask_questions,
|
|
880
|
+
subagent_model = excluded.subagent_model
|
|
653
881
|
`).run(
|
|
654
882
|
key, workflowId, nodeId,
|
|
655
883
|
sel.model ?? null,
|
|
656
884
|
sel.effort ?? null,
|
|
657
885
|
sel.fanOut === undefined ? null : (sel.fanOut ? 1 : 0),
|
|
658
886
|
sel.askQuestions === undefined ? null : (sel.askQuestions ? 1 : 0),
|
|
887
|
+
sel.subagentModel ?? null,
|
|
659
888
|
);
|
|
660
889
|
});
|
|
661
890
|
}
|
|
@@ -681,6 +910,23 @@ export async function setFeedbackCycles(projectDir, workflowId, fbId, maxCycles)
|
|
|
681
910
|
});
|
|
682
911
|
}
|
|
683
912
|
|
|
913
|
+
/**
|
|
914
|
+
* Set the cycle budget for ONE loop wire of a v2 workflow. Coerced to an integer
|
|
915
|
+
* >= 1 (a loop runs at least once), exactly like setFeedbackCycles — this never
|
|
916
|
+
* throws, so a stale UI value cannot 500 a save. Writes only config_workflow_wires.
|
|
917
|
+
*/
|
|
918
|
+
export async function setWireCycles(projectDir, workflowId, wireId, maxCycles) {
|
|
919
|
+
const n = Math.max(1, Math.floor(Number(maxCycles) || 0) || 1);
|
|
920
|
+
const key = projectKey(projectDir);
|
|
921
|
+
tx(() => {
|
|
922
|
+
prepare(`
|
|
923
|
+
INSERT INTO config_workflow_wires (project_key, workflow_id, wire_id, max_cycles)
|
|
924
|
+
VALUES (?, ?, ?, ?)
|
|
925
|
+
ON CONFLICT(project_key, workflow_id, wire_id) DO UPDATE SET max_cycles = excluded.max_cycles
|
|
926
|
+
`).run(key, workflowId, wireId, n);
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
|
|
684
930
|
/**
|
|
685
931
|
* Drop every per-project override for one workflow — the New-Pipeline accordion's
|
|
686
932
|
* "Reset to defaults" (newpipeline-ux-design.md §4.5). Deletes the workflow's
|
|
@@ -706,6 +952,7 @@ export async function resetWorkflowConfig(projectDir, workflowId) {
|
|
|
706
952
|
tx(() => {
|
|
707
953
|
prepare('DELETE FROM config_workflow_nodes WHERE project_key = ? AND workflow_id = ?').run(key, id);
|
|
708
954
|
prepare('DELETE FROM config_workflow_feedbacks WHERE project_key = ? AND workflow_id = ?').run(key, id);
|
|
955
|
+
prepare('DELETE FROM config_workflow_wires WHERE project_key = ? AND workflow_id = ?').run(key, id);
|
|
709
956
|
if (clearLegacy) {
|
|
710
957
|
prepare(`
|
|
711
958
|
INSERT INTO project_config (project_key, steps, custom_models, active_workflow_id, extra)
|
|
@@ -746,6 +993,7 @@ export async function resolveRunConfig(projectDir, workflowId) {
|
|
|
746
993
|
const wf = readWorkflowsMap(projectKey(projectDir))[workflowId] || {};
|
|
747
994
|
return {
|
|
748
995
|
nodes: wf.nodes && typeof wf.nodes === 'object' ? wf.nodes : {},
|
|
996
|
+
wires: wf.wires && typeof wf.wires === 'object' ? wf.wires : {},
|
|
749
997
|
feedbacks: wf.feedbacks && typeof wf.feedbacks === 'object' ? wf.feedbacks : {},
|
|
750
998
|
};
|
|
751
999
|
}
|
|
@@ -864,16 +1112,34 @@ export async function removeGlobalModelAndRefs(id) {
|
|
|
864
1112
|
if (!stepKeysByProject.has(r.projectKey)) continue;
|
|
865
1113
|
const filtered = {};
|
|
866
1114
|
for (const [k, v] of Object.entries(r.steps)) {
|
|
867
|
-
if (v?.model && v.model.toLowerCase() === lc) {
|
|
1115
|
+
if (v?.model && v.model.toLowerCase() === lc) {
|
|
1116
|
+
// Clear ONLY the dangling ref (and the effort that travels with its
|
|
1117
|
+
// model); fanOut/askQuestions/subagentModel are not the removed
|
|
1118
|
+
// model's business and must survive — mirroring cleanNodeSel's
|
|
1119
|
+
// emptiness rule, the entry itself goes only when nothing is left.
|
|
1120
|
+
clearedSteps += 1;
|
|
1121
|
+
const { model: _m, effort: _e, ...rest } = v;
|
|
1122
|
+
if (Object.keys(rest).length) filtered[k] = rest;
|
|
1123
|
+
continue;
|
|
1124
|
+
}
|
|
868
1125
|
filtered[k] = v;
|
|
869
1126
|
}
|
|
870
1127
|
prepare('UPDATE project_config SET steps = ? WHERE project_key = ?')
|
|
871
1128
|
.run(JSON.stringify(filtered), r.projectKey);
|
|
872
1129
|
}
|
|
873
1130
|
for (const n of refs.nodes) {
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
1131
|
+
// Same rule for node rows: NULL the model+effort, keep the other
|
|
1132
|
+
// tunables, and drop the row only once every column is NULL (matching
|
|
1133
|
+
// readWorkflowsMap, which would not surface an all-NULL row anyway).
|
|
1134
|
+
clearedNodes += prepare(`
|
|
1135
|
+
UPDATE config_workflow_nodes SET model = NULL, effort = NULL
|
|
1136
|
+
WHERE project_key = ? AND workflow_id = ? AND node_id = ?
|
|
1137
|
+
`).run(n.projectKey, n.workflowId, n.nodeId).changes;
|
|
1138
|
+
prepare(`
|
|
1139
|
+
DELETE FROM config_workflow_nodes
|
|
1140
|
+
WHERE project_key = ? AND workflow_id = ? AND node_id = ?
|
|
1141
|
+
AND fan_out IS NULL AND ask_questions IS NULL AND subagent_model IS NULL
|
|
1142
|
+
`).run(n.projectKey, n.workflowId, n.nodeId);
|
|
877
1143
|
}
|
|
878
1144
|
});
|
|
879
1145
|
}
|
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
|
};
|