glm-coding-router 1.1.2 → 2.0.0
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 +113 -5
- package/dist/bin/glm-review.js +28 -3
- package/dist/bin/glm-worker.js +30 -4
- package/dist/budget/estimator.js +218 -0
- package/dist/budget/manager.js +223 -0
- package/dist/cli.js +38 -0
- package/dist/commands/dashboard.js +348 -0
- package/dist/commands/runs.js +568 -0
- package/dist/commands/usage.js +1 -40
- package/dist/commands/watch.js +289 -0
- package/dist/core/config.js +61 -0
- package/dist/core/errors.js +24 -0
- package/dist/core/paths.js +32 -0
- package/dist/core/process.js +83 -0
- package/dist/core/prompt.js +18 -5
- package/dist/core/routing-flags.js +59 -0
- package/dist/core/zai-quota.js +46 -0
- package/dist/events/bus.js +64 -0
- package/dist/events/claude-adapter.js +416 -0
- package/dist/events/types.js +9 -0
- package/dist/handoff/bundle.js +203 -0
- package/dist/handoff/parent-handoff.js +48 -0
- package/dist/mcp/server.js +45 -1
- package/dist/routing/glm-routing.js +131 -0
- package/dist/runs/checkpoint.js +204 -0
- package/dist/runs/drain.js +165 -0
- package/dist/runs/heartbeat.js +45 -0
- package/dist/runs/registry.js +350 -0
- package/dist/runs/store.js +186 -0
- package/dist/runs/ulid.js +112 -0
- package/dist/runs/worker-run.js +672 -0
- package/dist/templates/agents-block.js +9 -0
- package/dist/templates/claude-block.js +9 -0
- package/dist/templates/glm-delegation-skill.js +76 -65
- package/dist/tui/progress.js +338 -0
- package/dist/tui/render.js +78 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,6 +41,10 @@ Claude / Codex → shell → glm-worker → claude.exe harness → Z.ai endpoint
|
|
|
41
41
|
GLM-5.3 / GLM-5.3-Flash
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
For headless worker/review runs, the router also consumes Claude Code's stream-json output,
|
|
45
|
+
records a provider-neutral event history, and renders progress on stderr. Interactive
|
|
46
|
+
`glm-chat` / `glm-fast` sessions keep the direct pass-through path shown above.
|
|
47
|
+
|
|
44
48
|
## Requirements
|
|
45
49
|
|
|
46
50
|
- Windows 10/11 or Linux (both verified); macOS is experimental — the suite has not been
|
|
@@ -122,10 +126,17 @@ go test ./internal/auth/...
|
|
|
122
126
|
"@ | glm-worker
|
|
123
127
|
```
|
|
124
128
|
|
|
125
|
-
Input priority: **
|
|
129
|
+
Input priority: **arguments → stdin → error**. Arguments win, and stdin is not even
|
|
130
|
+
read when they carry a prompt — waiting for EOF on a pipe that never closes (an agent
|
|
131
|
+
harness, CI, `nohup`) would hang the run before it started. The worker runs with
|
|
126
132
|
`--max-turns 20 --permission-mode acceptEdits --tools Read,Glob,Grep,Edit,Write,Bash`.
|
|
127
133
|
It never uses `--dangerously-skip-permissions`.
|
|
128
134
|
|
|
135
|
+
Routing flags (v2): `--model main|fast` pins the config slot for this run (it does not
|
|
136
|
+
bypass an enforced refusal), `--force` overrides one, `--refresh-quota` re-reads the
|
|
137
|
+
Z.ai quota instead of the 60 s cache — see [Quota-aware routing](#quota-aware-routing-v2).
|
|
138
|
+
Like `--profile`, they belong to the wrapper and are consumed before the prompt is read.
|
|
139
|
+
|
|
129
140
|
## glm-review
|
|
130
141
|
|
|
131
142
|
Read-only worker for repository exploration, call-graph discovery, duplicate detection,
|
|
@@ -190,7 +201,7 @@ your work; the footer prints the path and the merge command:
|
|
|
190
201
|
[glm-router] next: inspect it, then merge glm/delegate/backend (or discard with git worktree remove)
|
|
191
202
|
```
|
|
192
203
|
|
|
193
|
-
- Prompt priority is
|
|
204
|
+
- Prompt priority is arguments → stdin, same as `glm-worker`.
|
|
194
205
|
- Profiles: `--profile test` explicitly, or — when omitted — a profile literally
|
|
195
206
|
named after the delegate (`delegate test` → the `test` profile) if one exists.
|
|
196
207
|
- `--remove` deletes the worktree **after a successful run only**; plain
|
|
@@ -261,6 +272,93 @@ glm-router usage
|
|
|
261
272
|
|
|
262
273
|
`--json` emits the same data machine-readably. No key configured → `ERROR [10]`.
|
|
263
274
|
|
|
275
|
+
## Run observability (v2)
|
|
276
|
+
|
|
277
|
+
Every `glm-worker` / `glm-review` run — and every MCP `glm_worker` / `glm_review` call —
|
|
278
|
+
is instrumented: the child runs with `--output-format stream-json`, events are recorded
|
|
279
|
+
under `<configDir>/runs/`, and progress renders live on **stderr**. Stdout stays exactly
|
|
280
|
+
the final assistant text, so pipes, orchestrators, and `benchmark` keep working unchanged.
|
|
281
|
+
|
|
282
|
+
- `runs/history/YYYY-MM-DD/<runId>/` holds `events.jsonl` (one JSON event per line) and
|
|
283
|
+
`summary.json`; `runs/active/` registers live runs with a heartbeat.
|
|
284
|
+
- Progress modes: `rich` (box + turn tree, TTY only), `nested` (one `[GLM] …` line per
|
|
285
|
+
significant event — the default when stderr is piped), `off`. `--no-progress`,
|
|
286
|
+
`--quiet`, or `CI=true` force `off`; `GLM_ROUTER_PROGRESS=off|rich|nested` and
|
|
287
|
+
`GLM_ROUTER_NESTED=1` override config; `ui.mode` is the standing default.
|
|
288
|
+
- `GLM_ROUTER_OBSERVE=off` restores the exact v1 path (also automatic when the caller
|
|
289
|
+
passes its own `--output-format`, as `benchmark` does).
|
|
290
|
+
|
|
291
|
+
```powershell
|
|
292
|
+
glm-router runs # id, state, model, started, duration, turns, files
|
|
293
|
+
glm-router runs --active --limit 5
|
|
294
|
+
glm-router runs show <runId> # metadata, summary, per-turn tool tree
|
|
295
|
+
glm-router runs logs <runId> # events.jsonl, one line per event (--json = raw)
|
|
296
|
+
glm-router runs clean --dry-run --orphans # preview retention prune + orphan reap
|
|
297
|
+
glm-router watch # attach to the newest active run, follow live
|
|
298
|
+
glm-router dashboard # quota + active runs + recent runs
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
- `runs show` accepts a unique id suffix; the whole family supports `--json`.
|
|
302
|
+
- `runs clean --older-than 30d` prunes by age, `--orphans` reaps active runs whose
|
|
303
|
+
process is gone; history is also pruned at run start (`history.retentionDays: 30`,
|
|
304
|
+
`history.maxRuns: 1000` by default).
|
|
305
|
+
- `watch [run-id] [--from-start]` renders through the same renderer as a live run; no active
|
|
306
|
+
run → a message, exit 0.
|
|
307
|
+
- `dashboard` repaints every `--interval` seconds (default 2) on a TTY; piped, it
|
|
308
|
+
prints one snapshot and exits. Ctrl+C quits the live view.
|
|
309
|
+
|
|
310
|
+
**Checkpoints and handoff bundles.** A run that dies with work on disk — child failure,
|
|
311
|
+
crash, kill — always leaves a bundle in `<runDir>/handoff/`: `checkpoint.json` (phase,
|
|
312
|
+
completed turns, pending work, files changed, validations owed), `diff.patch` (the real
|
|
313
|
+
`git diff`; the router never runs `git add`, so untracked files are listed separately),
|
|
314
|
+
`handoff.md`, and `handoff.json`. The bundle path is printed to stderr. Outside a git
|
|
315
|
+
repo the bundle is still written, minus the patch.
|
|
316
|
+
|
|
317
|
+
## Quota-aware routing (v2)
|
|
318
|
+
|
|
319
|
+
Before spawning, the router reads the Z.ai quota (cached 60 s), classifies the task, and
|
|
320
|
+
estimates its cost (p90 from `cost-samples.jsonl` history, else a built-in baseline).
|
|
321
|
+
The binding window (5-hour vs weekly, whichever is lower) picks a zone: HEALTHY runs the
|
|
322
|
+
main model; CONSERVE, HANDOFF_READY, and CRITICAL prefer the fast one. If main does not
|
|
323
|
+
fit the usable budget but fast does, the run is downgraded — never the reverse. Endpoint
|
|
324
|
+
unreachable, no key, or an empty payload → `confidence: "unknown"` → run normally and
|
|
325
|
+
warn once on stderr: a monitoring outage never blocks work.
|
|
326
|
+
|
|
327
|
+
Defaults in 2.0.0: `quotaAware: true`, but `refuseOnCritical: false` and
|
|
328
|
+
`handoffOnLowQuota: false` — the shipped router observes, downgrades, and warns; it
|
|
329
|
+
never refuses a run and never kills a live child. Every `summary.json` records
|
|
330
|
+
`routingAdvice` (`zone`, `wouldRefuse`, `estimatedCost`, `actualCredits`), the evidence
|
|
331
|
+
for revisiting those switches later.
|
|
332
|
+
|
|
333
|
+
```json
|
|
334
|
+
{
|
|
335
|
+
"routing": {
|
|
336
|
+
"quotaAware": true, "refuseOnCritical": false, "handoffOnLowQuota": false,
|
|
337
|
+
"reserveRatio": 0.10, "safetyFactor": 1.3,
|
|
338
|
+
"preferFlashBelow": 0.30, "handoffReadyBelow": 0.15, "criticalBelow": 0.08,
|
|
339
|
+
"pollIntervalSec": 60, "quotaCacheTtlSec": 60
|
|
340
|
+
},
|
|
341
|
+
"history": { "retentionDays": 30, "maxRuns": 1000 },
|
|
342
|
+
"ui": { "mode": "auto", "color": true }
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Ratio fields must satisfy `0 < x < 1` and stay ordered
|
|
347
|
+
(`criticalBelow < handoffReadyBelow < preferFlashBelow`), else `ERROR [11]`.
|
|
348
|
+
|
|
349
|
+
**Exit 41 / 42 — unfinished, not crashed.** Both mean "work preserved", and both print a
|
|
350
|
+
`HandoffResult` JSON on stdout:
|
|
351
|
+
|
|
352
|
+
- **41 `QUOTA_INSUFFICIENT`** — preflight refused to spawn anything (reachable only
|
|
353
|
+
with `refuseOnCritical: true`). Nothing ran, and no run-history or repository files
|
|
354
|
+
were written; `--model fast` may fit the budget, `--force` overrides the refusal.
|
|
355
|
+
- **42 `HANDOFF_REQUIRED`** — a live run was stopped at a safe tool boundary and handed
|
|
356
|
+
back (reachable only with `handoffOnLowQuota: true`); the JSON carries `handoff_path`.
|
|
357
|
+
|
|
358
|
+
An orchestrator reads 41/42 as "continue in the same worktree", never as "the worker
|
|
359
|
+
broke". A child that fails on its own still exits 40 — the handoff bundle is written
|
|
360
|
+
anyway.
|
|
361
|
+
|
|
264
362
|
## Agent skills (Claude Code + Codex)
|
|
265
363
|
|
|
266
364
|
`glm-router skill install` writes the `glm-delegation` SKILL.md into **both**
|
|
@@ -292,7 +390,9 @@ glm-router mcp remove # claude mcp remove -s user glm-coding-router
|
|
|
292
390
|
|
|
293
391
|
Tool-level failures return `isError` results (missing key, no claude, outside
|
|
294
392
|
a git repo, unreachable endpoint); the server never prints anything to stdout
|
|
295
|
-
except JSON-RPC frames.
|
|
393
|
+
except JSON-RPC frames. MCP-driven runs are recorded like any other (registry on,
|
|
394
|
+
progress renderer off), so they appear in `glm-router runs` and `dashboard` while
|
|
395
|
+
the protocol channel stays clean.
|
|
296
396
|
|
|
297
397
|
## CLI reference
|
|
298
398
|
|
|
@@ -307,6 +407,9 @@ glm-router config set models.main glm-5.3
|
|
|
307
407
|
glm-router delegate <name> run a GLM worker in an isolated git worktree
|
|
308
408
|
glm-router benchmark measure the Claude+GLM stack on built-in tasks
|
|
309
409
|
glm-router usage Z.ai quota snapshot + local benchmark totals
|
|
410
|
+
glm-router runs inspect recorded runs (show / logs / clean subcommands)
|
|
411
|
+
glm-router watch [run-id] attach to an active run and follow its progress
|
|
412
|
+
glm-router dashboard quota + active runs + recent runs
|
|
310
413
|
glm-router mcp optional MCP server registration (glm-mcp)
|
|
311
414
|
glm-router project init CLAUDE.md / AGENTS.md managed blocks (--dry-run supported)
|
|
312
415
|
glm-router project remove
|
|
@@ -382,6 +485,10 @@ The key is never cached to disk.
|
|
|
382
485
|
| The worker creates files but never runs the tests | Its Bash allowlist is empty. `glm-router config show` → `worker.allowedBash`; the default list covers common test commands |
|
|
383
486
|
| `glm-*` not on PATH after install | Reopen the terminal; check `npm config get prefix` is on PATH |
|
|
384
487
|
| `ERROR [MANAGED_BLOCK_CORRUPT]` | Fix the marker pair in the named file manually, then re-run |
|
|
488
|
+
| Exit 41 `QUOTA_INSUFFICIENT` | Preflight refused the run (only with `routing.refuseOnCritical: true`). Wait for the window to reset, use `--model fast`, or `--force` |
|
|
489
|
+
| Exit 42 `HANDOFF_REQUIRED` | Not a crash — the run handed off with a bundle. Read `handoff_path` in the stdout JSON and continue in the same worktree |
|
|
490
|
+
| A run lists as FAILED with no summary | It died mid-run; `runs show <id>` rebuilds the summary from `events.jsonl`, `runs clean --orphans` reaps stale active entries |
|
|
491
|
+
| `runs/` history grows large | `glm-router runs clean --older-than 30d`, or tune `history.retentionDays` / `history.maxRuns` |
|
|
385
492
|
|
|
386
493
|
Run `glm-router doctor` (add `--network` to probe the Z.ai endpoint) for a full diagnosis.
|
|
387
494
|
|
|
@@ -418,8 +525,9 @@ npm test
|
|
|
418
525
|
npm publish
|
|
419
526
|
```
|
|
420
527
|
|
|
421
|
-
`prepublishOnly` runs build + tests. The package ships only `dist/`; the
|
|
422
|
-
(`glm-router`, `glm-chat`, `glm-fast`, `glm-worker`, `glm-review`) are declared
|
|
528
|
+
`prepublishOnly` runs build + tests. The package ships only `dist/`; the six binaries
|
|
529
|
+
(`glm-router`, `glm-chat`, `glm-fast`, `glm-worker`, `glm-review`, `glm-mcp`) are declared
|
|
530
|
+
in `bin`.
|
|
423
531
|
|
|
424
532
|
## License
|
|
425
533
|
|
package/dist/bin/glm-review.js
CHANGED
|
@@ -7,9 +7,11 @@ import { Errors, formatGlmError, GlmRouterError } from "../core/errors.js";
|
|
|
7
7
|
import { isMainModule } from "../core/main-guard.js";
|
|
8
8
|
import { logger, redact } from "../core/logging.js";
|
|
9
9
|
import { applyProfile, extractProfileFlag } from "../core/profile.js";
|
|
10
|
+
import { extractRoutingFlags } from "../core/routing-flags.js";
|
|
10
11
|
import { readStdin, resolvePrompt } from "../core/prompt.js";
|
|
11
12
|
import { spawnAgent } from "../core/process.js";
|
|
12
13
|
import { resolveZaiApiKey } from "../core/zai-key.js";
|
|
14
|
+
import { runInstrumented, shouldObserve } from "../runs/worker-run.js";
|
|
13
15
|
/** Read-only review surface (spec §17) — no Edit, Write, or Bash. */
|
|
14
16
|
export const REVIEW_TOOLS = "Read,Glob,Grep";
|
|
15
17
|
/**
|
|
@@ -36,7 +38,10 @@ export function buildReviewArgs(prompt, config) {
|
|
|
36
38
|
* discovery, duplicate detection, dependency inspection, and review.
|
|
37
39
|
*/
|
|
38
40
|
export async function runReview(argv) {
|
|
39
|
-
const { rest, profile } = extractProfileFlag(argv);
|
|
41
|
+
const { rest: withoutProfile, profile } = extractProfileFlag(argv);
|
|
42
|
+
// Phase E flags come off before resolvePrompt: whatever is still in
|
|
43
|
+
// `rest` at that point becomes the prompt.
|
|
44
|
+
const { rest, model, force, refreshQuota } = extractRoutingFlags(withoutProfile);
|
|
40
45
|
const prompt = await resolvePrompt(rest, readStdin, "glm-review");
|
|
41
46
|
const config = applyProfile(loadConfig(), profile);
|
|
42
47
|
const resolved = resolveZaiApiKey();
|
|
@@ -47,12 +52,32 @@ export async function runReview(argv) {
|
|
|
47
52
|
const args = buildReviewArgs(prompt, config);
|
|
48
53
|
const env = createGlmEnv(config, resolved.key);
|
|
49
54
|
logger.debug(redact(`spawning ${claudePath} ${args.join(" ")}`, [resolved.key]));
|
|
50
|
-
|
|
55
|
+
// v2 spec Phase D (C4): observe unless the caller opted out or already asked
|
|
56
|
+
// for a specific --output-format. The legacy path stays byte-identical.
|
|
57
|
+
if (!shouldObserve(args, process.env)) {
|
|
58
|
+
return spawnAgent(claudePath, {
|
|
59
|
+
args,
|
|
60
|
+
cwd: process.cwd(),
|
|
61
|
+
env,
|
|
62
|
+
interactive: false,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const observed = await runInstrumented({
|
|
66
|
+
kind: "review",
|
|
67
|
+
prompt,
|
|
51
68
|
args,
|
|
69
|
+
claudePath,
|
|
70
|
+
config,
|
|
71
|
+
secrets: [resolved.key],
|
|
52
72
|
cwd: process.cwd(),
|
|
53
73
|
env,
|
|
54
|
-
|
|
74
|
+
// Phase E; instrumented runs only, same as glm-worker.
|
|
75
|
+
zaiKey: resolved.key,
|
|
76
|
+
requestedModel: model,
|
|
77
|
+
force,
|
|
78
|
+
refreshQuota,
|
|
55
79
|
});
|
|
80
|
+
return observed.code;
|
|
56
81
|
}
|
|
57
82
|
if (isMainModule(import.meta.url)) {
|
|
58
83
|
runReview(process.argv.slice(2)).then((code) => process.exit(code), (error) => {
|
package/dist/bin/glm-worker.js
CHANGED
|
@@ -7,9 +7,11 @@ import { Errors, formatGlmError, GlmRouterError } from "../core/errors.js";
|
|
|
7
7
|
import { isMainModule } from "../core/main-guard.js";
|
|
8
8
|
import { logger, redact } from "../core/logging.js";
|
|
9
9
|
import { applyProfile, extractProfileFlag } from "../core/profile.js";
|
|
10
|
+
import { extractRoutingFlags } from "../core/routing-flags.js";
|
|
10
11
|
import { resolvePrompt } from "../core/prompt.js";
|
|
11
12
|
import { spawnAgent } from "../core/process.js";
|
|
12
13
|
import { resolveZaiApiKey } from "../core/zai-key.js";
|
|
14
|
+
import { runInstrumented, shouldObserve } from "../runs/worker-run.js";
|
|
13
15
|
/** Worker tool surface (spec §16). */
|
|
14
16
|
export const WORKER_TOOLS = "Read,Glob,Grep,Edit,Write,Bash";
|
|
15
17
|
/** Same surface minus Bash, used when no Bash command is allowed. */
|
|
@@ -61,12 +63,15 @@ export function extractNoBashFlag(argv) {
|
|
|
61
63
|
}
|
|
62
64
|
/**
|
|
63
65
|
* glm-worker (spec §15, §16): headless implementation worker.
|
|
64
|
-
* Prompt priority:
|
|
66
|
+
* Prompt priority: arguments → stdin → error. Never uses
|
|
65
67
|
* --dangerously-skip-permissions.
|
|
66
68
|
*/
|
|
67
69
|
export async function runWorker(argv) {
|
|
68
70
|
const { rest: withoutProfile, profile } = extractProfileFlag(argv);
|
|
69
|
-
const { rest, noBash } = extractNoBashFlag(withoutProfile);
|
|
71
|
+
const { rest: withoutBashFlag, noBash } = extractNoBashFlag(withoutProfile);
|
|
72
|
+
// Phase E flags come off last, and before resolvePrompt: everything still in
|
|
73
|
+
// `rest` at that point becomes the prompt.
|
|
74
|
+
const { rest, model, force, refreshQuota } = extractRoutingFlags(withoutBashFlag);
|
|
70
75
|
const prompt = await resolvePrompt(rest);
|
|
71
76
|
const loaded = applyProfile(loadConfig(), profile);
|
|
72
77
|
const config = noBash
|
|
@@ -80,12 +85,33 @@ export async function runWorker(argv) {
|
|
|
80
85
|
const args = buildWorkerArgs(prompt, config);
|
|
81
86
|
const env = createGlmEnv(config, resolved.key);
|
|
82
87
|
logger.debug(redact(`spawning ${claudePath} ${args.join(" ")}`, [resolved.key]));
|
|
83
|
-
|
|
88
|
+
// v2 spec Phase D (C4): observe unless the caller opted out or already asked
|
|
89
|
+
// for a specific --output-format. The legacy path stays byte-identical.
|
|
90
|
+
if (!shouldObserve(args, process.env)) {
|
|
91
|
+
return spawnAgent(claudePath, {
|
|
92
|
+
args,
|
|
93
|
+
cwd: process.cwd(),
|
|
94
|
+
env,
|
|
95
|
+
interactive: false,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const observed = await runInstrumented({
|
|
99
|
+
kind: "worker",
|
|
100
|
+
prompt,
|
|
84
101
|
args,
|
|
102
|
+
claudePath,
|
|
103
|
+
config,
|
|
104
|
+
secrets: [resolved.key],
|
|
85
105
|
cwd: process.cwd(),
|
|
86
106
|
env,
|
|
87
|
-
|
|
107
|
+
// Phase E. The legacy path above never reaches here, so these flags apply
|
|
108
|
+
// to instrumented runs only — routing needs the event stream it observes.
|
|
109
|
+
zaiKey: resolved.key,
|
|
110
|
+
requestedModel: model,
|
|
111
|
+
force,
|
|
112
|
+
refreshQuota,
|
|
88
113
|
});
|
|
114
|
+
return observed.code;
|
|
89
115
|
}
|
|
90
116
|
if (isMainModule(import.meta.url)) {
|
|
91
117
|
runWorker(process.argv.slice(2)).then((code) => process.exit(code), (error) => {
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { logger } from "../core/logging.js";
|
|
4
|
+
import { costSamplesPath } from "../core/paths.js";
|
|
5
|
+
/**
|
|
6
|
+
* Priority-ordered keyword table: the first row with a case-insensitive WHOLE
|
|
7
|
+
* WORD match wins, which is what makes "fix the failing test" a bugfix rather
|
|
8
|
+
* than a tests task — repair beats coverage when both match.
|
|
9
|
+
*
|
|
10
|
+
* Whole words, not substrings: a substring table reads "docker" as docs and
|
|
11
|
+
* "fixture" as bugfix, and this classifier is not cosmetic — it picks the cost
|
|
12
|
+
* row that decides `wouldRefuse`, which is the evidence D3 says 2.1's
|
|
13
|
+
* refuse-by-default decision will be argued from. Noise here becomes a wrong
|
|
14
|
+
* answer to "how often would the refusal have been wrong?". Word forms are
|
|
15
|
+
* therefore listed explicitly; an unlisted form falls through to a later row
|
|
16
|
+
* or to "other", and guessing low is the conservative failure.
|
|
17
|
+
*/
|
|
18
|
+
const KEYWORDS = [
|
|
19
|
+
[
|
|
20
|
+
"bugfix",
|
|
21
|
+
["fix", "fixes", "fixed", "fixing", "bug", "bugs", "broken", "regression", "regressions",
|
|
22
|
+
"crash", "crashes", "crashing", "error", "errors", "defect", "defects", "repair"],
|
|
23
|
+
],
|
|
24
|
+
["tests", ["test", "tests", "testing", "spec", "specs", "coverage", "vitest", "jest", "pytest"]],
|
|
25
|
+
[
|
|
26
|
+
"docs",
|
|
27
|
+
["doc", "docs", "document", "documents", "documentation", "docstring", "docstrings",
|
|
28
|
+
"readme", "comment", "comments", "changelog"],
|
|
29
|
+
],
|
|
30
|
+
[
|
|
31
|
+
"refactor",
|
|
32
|
+
["refactor", "refactors", "refactoring", "rename", "renames", "renaming", "extract",
|
|
33
|
+
"cleanup", "clean up", "restructure", "simplify", "migrate", "migration"],
|
|
34
|
+
],
|
|
35
|
+
[
|
|
36
|
+
"explore",
|
|
37
|
+
["explore", "investigate", "find", "search", "understand", "audit", "review", "analyze",
|
|
38
|
+
"analyse", "analysis"],
|
|
39
|
+
],
|
|
40
|
+
[
|
|
41
|
+
"crud",
|
|
42
|
+
["add", "create", "implement", "implements", "implementing", "endpoint", "endpoints",
|
|
43
|
+
"model", "models", "schema", "schemas", "crud", "build", "write"],
|
|
44
|
+
],
|
|
45
|
+
];
|
|
46
|
+
/** Whole-word matcher for one row; `clean up` shows why a plain split() is not enough. */
|
|
47
|
+
const MATCHERS = KEYWORDS.map(([kind, words]) => [
|
|
48
|
+
kind,
|
|
49
|
+
new RegExp(`\\b(?:${words.map(escapeRegExp).join("|")})\\b`, "i"),
|
|
50
|
+
]);
|
|
51
|
+
function escapeRegExp(text) {
|
|
52
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Deterministic keyword classifier — no model call, so preflight stays free,
|
|
56
|
+
* instant and reproducible. The prompt itself is never persisted here (C3);
|
|
57
|
+
* only the resulting kind reaches `cost-samples.jsonl`.
|
|
58
|
+
*/
|
|
59
|
+
export function classifyTask(prompt) {
|
|
60
|
+
for (const [kind, matcher] of MATCHERS) {
|
|
61
|
+
if (matcher.test(prompt)) {
|
|
62
|
+
return kind;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return "other";
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Appends one JSON line to cost-samples.jsonl. Never throws: cost history is
|
|
69
|
+
* an optimization the next run can live without, and the run that earned the
|
|
70
|
+
* sample has already done its work by the time this is called.
|
|
71
|
+
*/
|
|
72
|
+
export function recordSample(home, sample) {
|
|
73
|
+
const file = costSamplesPath(home);
|
|
74
|
+
try {
|
|
75
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
76
|
+
fs.appendFileSync(file, JSON.stringify(sample) + "\n", "utf8");
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
logger.debug(`estimator: could not record cost sample (${errorMessage(error)})`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Reads the cost history. A missing file is "no history yet" and a malformed
|
|
84
|
+
* line is skipped, not fatal — the same tolerate-the-wreckage contract as the
|
|
85
|
+
* run store, because a truncated final line is normal crash wreckage.
|
|
86
|
+
*/
|
|
87
|
+
export function readSamples(home) {
|
|
88
|
+
let text;
|
|
89
|
+
try {
|
|
90
|
+
text = fs.readFileSync(costSamplesPath(home), "utf8");
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
const samples = [];
|
|
96
|
+
for (const [index, line] of text.split(/\r?\n/).entries()) {
|
|
97
|
+
if (line.trim() === "") {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const parsed = JSON.parse(line);
|
|
102
|
+
if (isSampleLike(parsed)) {
|
|
103
|
+
samples.push(parsed);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
logger.debug(`estimator: line ${index + 1} in ${costSamplesPath(home)} is not a sample`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
logger.debug(`estimator: skipping malformed line ${index + 1} in ${costSamplesPath(home)}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return samples;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* True only when the quota delta between two snapshots can be attributed to
|
|
117
|
+
* exactly one run. All four rules must hold:
|
|
118
|
+
*
|
|
119
|
+
* - both snapshots have confidence "exact" or "cached" — an "unknown" side
|
|
120
|
+
* makes the delta fiction;
|
|
121
|
+
* - fiveHour.resetAt is unchanged — a window reset mid-run makes the delta
|
|
122
|
+
* meaningless (used drops to 0 and the difference goes negative);
|
|
123
|
+
* - the credit delta (end.used - start.used) is >= 0 — a negative delta
|
|
124
|
+
* means a reset or a server correction, not a cost;
|
|
125
|
+
* - activeRunCount is exactly 1 — concurrent runs make credit attribution
|
|
126
|
+
* meaningless, so those runs record nothing rather than a wrong number.
|
|
127
|
+
*/
|
|
128
|
+
export function isCleanMeasurement(input) {
|
|
129
|
+
const trusted = (confidence) => confidence === "exact" || confidence === "cached";
|
|
130
|
+
return (trusted(input.startSnapshot.confidence) &&
|
|
131
|
+
trusted(input.endSnapshot.confidence) &&
|
|
132
|
+
input.startSnapshot.fiveHour.resetAt === input.endSnapshot.fiveHour.resetAt &&
|
|
133
|
+
input.endSnapshot.fiveHour.used - input.startSnapshot.fiveHour.used >= 0 &&
|
|
134
|
+
input.activeRunCount === 1);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Main-model baseline, p50/p90 in plan credits. Transcribed from doc §13 and
|
|
138
|
+
* NEVER MEASURED on this stack — decision D3 keeps preflight refusal off in
|
|
139
|
+
* 2.0.0 precisely because this table is unmeasured; a wrongly-high row would
|
|
140
|
+
* refuse runs the quota could have afforded.
|
|
141
|
+
*/
|
|
142
|
+
const BASELINE_MAIN = {
|
|
143
|
+
explore: { p50: 15, p90: 28 },
|
|
144
|
+
crud: { p50: 42, p90: 66 },
|
|
145
|
+
tests: { p50: 30, p90: 50 },
|
|
146
|
+
docs: { p50: 12, p90: 22 },
|
|
147
|
+
refactor: { p50: 45, p90: 75 },
|
|
148
|
+
bugfix: { p50: 35, p90: 60 },
|
|
149
|
+
other: { p50: 35, p90: 60 },
|
|
150
|
+
};
|
|
151
|
+
/** Fast models are assumed to cost 40% of the main row, rounded. */
|
|
152
|
+
const FAST_MODEL_RATIO = 0.4;
|
|
153
|
+
/** History starts winning at this many samples; below it the noise would outrank the baseline. */
|
|
154
|
+
const MIN_HISTORY_SAMPLES = 5;
|
|
155
|
+
/**
|
|
156
|
+
* Estimated credits for one run of a task kind on a model. History wins when
|
|
157
|
+
* at least MIN_HISTORY_SAMPLES samples match BOTH the task kind and the model;
|
|
158
|
+
* otherwise the baseline table answers, with `samples: 0` and
|
|
159
|
+
* `source: "baseline"`.
|
|
160
|
+
*
|
|
161
|
+
* Main vs fast is decided by comparing `model` with the `fastModel`
|
|
162
|
+
* ARGUMENT — this module is deliberately config-free so it stays pure and
|
|
163
|
+
* testable, and callers (preflight, part 2) pass config.models.fast in.
|
|
164
|
+
*/
|
|
165
|
+
export function estimateCost(home, taskKind, model, fastModel) {
|
|
166
|
+
const credits = readSamples(home)
|
|
167
|
+
.filter((entry) => entry.taskKind === taskKind && entry.model === model)
|
|
168
|
+
.map((entry) => entry.credits);
|
|
169
|
+
if (credits.length >= MIN_HISTORY_SAMPLES) {
|
|
170
|
+
const sorted = [...credits].sort((a, b) => a - b);
|
|
171
|
+
return {
|
|
172
|
+
p50: nearestRank(sorted, 0.5),
|
|
173
|
+
p90: nearestRank(sorted, 0.9),
|
|
174
|
+
samples: sorted.length,
|
|
175
|
+
source: "history",
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const main = BASELINE_MAIN[taskKind];
|
|
179
|
+
if (model === fastModel) {
|
|
180
|
+
return {
|
|
181
|
+
p50: Math.round(main.p50 * FAST_MODEL_RATIO),
|
|
182
|
+
p90: Math.round(main.p90 * FAST_MODEL_RATIO),
|
|
183
|
+
samples: 0,
|
|
184
|
+
source: "baseline",
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
return { p50: main.p50, p90: main.p90, samples: 0, source: "baseline" };
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Nearest-rank percentile, deliberately NOT interpolation: sort ascending,
|
|
191
|
+
* take index ceil(p * n) - 1, clamped into [0, n-1]. For p90 with n = 5 that
|
|
192
|
+
* is literally the maximum sample. Interpolating would invent costs between
|
|
193
|
+
* samples that never happened; the rank is the honest, conservative reading.
|
|
194
|
+
* Do not "fix" this into interpolation later.
|
|
195
|
+
*/
|
|
196
|
+
function nearestRank(sortedAsc, p) {
|
|
197
|
+
const index = Math.min(sortedAsc.length - 1, Math.max(0, Math.ceil(p * sortedAsc.length) - 1));
|
|
198
|
+
return sortedAsc[index];
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Same philosophy as the run store's isEventLike: "parses and has the fields
|
|
202
|
+
* estimateCost reads" (taskKind, model, credits). A line that parses but
|
|
203
|
+
* carries none of those cannot feed the estimator and is dropped; anything
|
|
204
|
+
* richer is history written by a future version and is kept.
|
|
205
|
+
*/
|
|
206
|
+
function isSampleLike(value) {
|
|
207
|
+
if (typeof value !== "object" || value === null) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
const candidate = value;
|
|
211
|
+
return (typeof candidate.taskKind === "string" &&
|
|
212
|
+
typeof candidate.model === "string" &&
|
|
213
|
+
typeof candidate.credits === "number" &&
|
|
214
|
+
Number.isFinite(candidate.credits));
|
|
215
|
+
}
|
|
216
|
+
function errorMessage(error) {
|
|
217
|
+
return error instanceof Error ? error.message : String(error);
|
|
218
|
+
}
|