sprag-cli 3.55.0 → 3.57.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.ko.md +5 -1
- package/README.md +5 -1
- package/bin/cli.js +60 -19
- package/bin/postinstall.js +22 -0
- package/docs/CODEX.ko.md +44 -5
- package/docs/CODEX.md +74 -12
- package/package.json +2 -2
- package/src/agents.js +16 -2
- package/src/brief.js +17 -8
- package/src/claude-price.js +49 -0
- package/src/codex-brief.js +55 -2
- package/src/codex-cache-policy.js +26 -41
- package/src/codex-delegation.js +56 -8
- package/src/codex-doctor.js +1 -1
- package/src/codex-harness.js +63 -18
- package/src/codex-hooks.js +129 -20
- package/src/codex-ledger.js +150 -16
- package/src/codex-panel.js +51 -26
- package/src/codex-parser.js +41 -4
- package/src/codex-route-scan.js +14 -7
- package/src/codex-rule-health.js +41 -0
- package/src/commands/brief.js +14 -3
- package/src/commands/codex-delegate.js +17 -5
- package/src/commands/codex.js +20 -10
- package/src/commands/harness.js +6 -0
- package/src/commands/route-scan.js +4 -3
- package/src/compact-window.js +12 -4
- package/src/config.js +3 -3
- package/src/cost.js +62 -5
- package/src/ctx-window-cache.js +61 -0
- package/src/delegation-guard.js +15 -9
- package/src/doc2md.cjs +22 -8
- package/src/fig2md.cjs +7 -1
- package/src/formatters/statusline.js +22 -11
- package/src/formatters/table.js +19 -10
- package/src/gateway-auth.js +31 -3
- package/src/gateway-prices.js +39 -0
- package/src/harness.js +43 -12
- package/src/history.js +6 -2
- package/src/hook-manager.js +54 -32
- package/src/installer.js +286 -153
- package/src/korean-lint.cjs +40 -7
- package/src/litellm-budget.js +10 -1
- package/src/litellm-models.js +72 -10
- package/src/model-rules.js +90 -42
- package/src/money.js +9 -0
- package/src/month-spend.js +23 -12
- package/src/openai-prices.js +33 -0
- package/src/parser.js +232 -56
- package/src/postinstall.js +32 -0
- package/src/route-caps.js +74 -0
- package/src/route-inject.js +31 -7
- package/src/route-scan.js +93 -33
- package/src/savings-ledger.js +43 -11
- package/src/seed-rules.js +3 -4
- package/src/session-cache.js +35 -1
- package/src/state-file.js +43 -0
- package/src/stats.js +20 -5
- package/src/stdin-payload.js +18 -4
package/src/route-scan.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* ratchet promote flow (`harness promote R<N> --global|--project`).
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync
|
|
20
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
21
21
|
import { join } from 'node:path';
|
|
22
22
|
import { userDataDir } from './paths.js';
|
|
23
23
|
import { discoverSessionFiles } from './parser.js';
|
|
@@ -25,7 +25,8 @@ import { collectSessionRecords } from './session-records.js';
|
|
|
25
25
|
import {
|
|
26
26
|
collectSubagentRuns, indexRuns, exactRunsForEpisode, fallbackRunsForEpisode,
|
|
27
27
|
} from './subagent-records.js';
|
|
28
|
-
import {
|
|
28
|
+
import { modelRank, isRecognizedModelId, TIER_TARGET_RANK, tierForRank } from './cost.js';
|
|
29
|
+
import { sessionCost } from './claude-price.js';
|
|
29
30
|
import { learnProfileMapping, resetModelAliasCache } from './model-alias.js';
|
|
30
31
|
import { modelRuleBaseText } from './model-rules.js';
|
|
31
32
|
|
|
@@ -54,7 +55,16 @@ export const MIN_DELEGABLE_OUT = 100;
|
|
|
54
55
|
// Measured 2026-09-20 on 1,276 local prompts: '퍼블리시' (npm publish, 15 hits)
|
|
55
56
|
// and '이유' (root-cause asks) were slipping through as run/translate, and a
|
|
56
57
|
// bare '머지' matched inside '나머지' ("나머지 진행하자" ×7), hence the lookbehind.
|
|
57
|
-
|
|
58
|
+
//
|
|
59
|
+
// Measured 2026-10-01 on 10,958 local prompts: reviews, security checks and
|
|
60
|
+
// root-cause asks were hinted to haiku because they are phrased as a check
|
|
61
|
+
// ("보안 취약점 있는지 확인해줘", "개선점 확인해보자", "버그 원인 확인"). Of the
|
|
62
|
+
// 300 short prompts that got a hint, the added terms and the whole-word
|
|
63
|
+
// keywords further down take it away from 18: judgement requests, and
|
|
64
|
+
// questions that merely contained "statusline". None gained a hint. Bare
|
|
65
|
+
// '리뷰' and '보안' are left out on purpose: they are product nouns in ordinary
|
|
66
|
+
// status checks ("리뷰 허브 배치 확인"), so only the request-shaped forms count.
|
|
67
|
+
export const ESCALATE_RE = /설계|아키텍처|리팩토링|원인\s*(?:분석|파악|확인|규명|조사)|원인을?\s*찾|버그|개선할|개선점|개선\s*(?:사항|방안)|검토해보|검수|코드\s*리뷰|리뷰\s*해|취약|보안\s*(?:점검|검토|감사|리뷰|이슈)|비교|왜(?=[\s?]|$)|이유|제출|배포|출시|퍼블리시|릴리[스즈](?!\s*노트)|(?<!나)머지|analyze|compare|evaluate|architect|refactor|submit|deploy|release|publish|merge|root cause|code review|security (?:review|audit)|vulnerabilit/i;
|
|
58
68
|
|
|
59
69
|
// Implementation asks phrased around a run/check verb ("설치와 동시에 설정되도록
|
|
60
70
|
// 하자", "피드백 확인해주고 아이콘도 만들자"). Editing is not delegable, and at
|
|
@@ -115,30 +125,39 @@ const CATEGORIES = [
|
|
|
115
125
|
label: '탐색·조회 (파일/값 찾기)',
|
|
116
126
|
labelEn: 'lookup (finding files/values)',
|
|
117
127
|
agent: 'haiku-explore',
|
|
118
|
-
kw: [[/
|
|
128
|
+
kw: [[/\bgrep\b|검색|\bsearch|\bfind\b/i, 2], [/찾아|어디|위치|목록|살펴/i, 1],
|
|
129
|
+
[/정의(?:돼|되어)\s*있|어느\s*파일|어떤\s*파일에|\bwhich\s+file\b|\bwhere\s+(?:is|are)\b[^?\n]*\bdefined\b/i, 1]],
|
|
119
130
|
},
|
|
120
131
|
{
|
|
121
132
|
id: 'read',
|
|
122
133
|
label: '읽기·요약·설명',
|
|
123
134
|
labelEn: 'reading / summarizing / explaining',
|
|
124
135
|
agent: 'haiku-explore',
|
|
125
|
-
kw: [[/요약|summar|explain/i, 2], [
|
|
136
|
+
kw: [[/요약|summar|explain/i, 2], [/읽어|설명|정리해|보여줘|알려줘|뭐야|\bwhat\b/i, 1]],
|
|
126
137
|
},
|
|
127
138
|
{
|
|
128
139
|
id: 'check',
|
|
129
140
|
label: '상태 확인·검증',
|
|
130
141
|
labelEn: 'status checks / verification',
|
|
131
142
|
agent: 'haiku-explore',
|
|
132
|
-
kw: [[
|
|
143
|
+
kw: [[/확인|검증|\bverif|점검/i, 2], [/맞아\?|되나|됐나|됐어|되는지|괜찮|체크|\bcheck\b|\bstatus\b/i, 1]],
|
|
133
144
|
},
|
|
134
145
|
{
|
|
135
146
|
id: 'run',
|
|
136
147
|
label: '명령 실행 (빌드·테스트·git)',
|
|
137
148
|
labelEn: 'running commands (build/test/git)',
|
|
138
149
|
agent: 'haiku-runner',
|
|
139
|
-
kw: [[
|
|
150
|
+
kw: [[/\bgit |\bcommit(?:s|ted)?\b|\bpush(?:ed|es)?\b|\bnpm |\bpip3? |빌드해|빌드 돌/i, 2], [/실행|돌려|\brun\b|\bbuild\b|빌드|테스트|설치/i, 1]],
|
|
140
151
|
},
|
|
141
152
|
];
|
|
153
|
+
// English keywords are matched as whole words. As bare substrings, `pip`
|
|
154
|
+
// claimed "pipeline", `commit` "commitment", `find` "findings" and `status`
|
|
155
|
+
// "statusline", which filed questions about those under run, explore or check.
|
|
156
|
+
// `\b` works next to Hangul here because the keyword itself is ASCII: the
|
|
157
|
+
// boundary falls between its last letter and the syllable that follows.
|
|
158
|
+
// `git`, `npm` and `pip` keep the trailing space they always had: followed by
|
|
159
|
+
// a subcommand they are a command to run, while "npm, github 쪽에도" and
|
|
160
|
+
// "git은 나중에" only mention the tool.
|
|
142
161
|
|
|
143
162
|
// ── Behavior signal (1st) — what the episode actually DID ────────────────
|
|
144
163
|
// The tool-call histogram is ground truth the prompt's wording is not:
|
|
@@ -333,9 +352,13 @@ export function worthDelegating(tier, rank) {
|
|
|
333
352
|
* Approximation, deliberately stated as one: it holds token counts constant,
|
|
334
353
|
* which a cheaper model would not reproduce exactly. Directionally right and
|
|
335
354
|
* enough to rank rules by value, so it is rendered as "~$X".
|
|
355
|
+
*
|
|
356
|
+
* Signed: a run that moved to a pricier model is a loss and comes back
|
|
357
|
+
* negative. null means a side has no price (a gateway session whose /model/info
|
|
358
|
+
* table lacks the model), which is unpriced, not zero.
|
|
336
359
|
*/
|
|
337
360
|
export function runSaving(run, mainModel) {
|
|
338
|
-
if (!run.model || !mainModel) return
|
|
361
|
+
if (!run.model || !mainModel) return null;
|
|
339
362
|
const totals = {
|
|
340
363
|
input: run.input,
|
|
341
364
|
cacheCreation: run.cacheCreation,
|
|
@@ -344,9 +367,10 @@ export function runSaving(run, mainModel) {
|
|
|
344
367
|
ephemeral1h: run.ephemeral1h,
|
|
345
368
|
output: run.out,
|
|
346
369
|
};
|
|
347
|
-
const actual =
|
|
348
|
-
const counterfactual =
|
|
349
|
-
|
|
370
|
+
const actual = sessionCost(totals, run.model);
|
|
371
|
+
const counterfactual = sessionCost(totals, mainModel);
|
|
372
|
+
if (!actual || !counterfactual) return null;
|
|
373
|
+
return counterfactual.actual - actual.actual;
|
|
350
374
|
}
|
|
351
375
|
|
|
352
376
|
const clamp = (v, [lo, hi]) => Math.min(hi, Math.max(lo, v));
|
|
@@ -404,6 +428,42 @@ export function ruleCouldHaveRouted(rule, run) {
|
|
|
404
428
|
return run.startedAt >= promoted;
|
|
405
429
|
}
|
|
406
430
|
|
|
431
|
+
/**
|
|
432
|
+
* Number the candidates of a scan, in place, and return the highest number in
|
|
433
|
+
* use. A candidate keeps its number for as long as it stays listed. Numbering
|
|
434
|
+
* by rank let a rescan between "R2 is a candidate" and `promote R2` hand the
|
|
435
|
+
* number to another pattern, and the wrong rule was registered. A new candidate
|
|
436
|
+
* takes a number no listed candidate has held; numbering starts over only once
|
|
437
|
+
* the list has been empty.
|
|
438
|
+
*
|
|
439
|
+
* @param {Array} candidates this scan's candidates
|
|
440
|
+
* @param {object|null} prev the previous scan's cache
|
|
441
|
+
*/
|
|
442
|
+
export function assignCandidateIds(candidates, prev) {
|
|
443
|
+
const prevIds = new Map((prev?.candidates || [])
|
|
444
|
+
.filter((c) => Number.isInteger(c?.id) && c.id > 0).map((c) => [c.signature, c.id]));
|
|
445
|
+
let last = prevIds.size ? Math.max(Number(prev?.lastCandidateId) || 0, ...prevIds.values()) : 0;
|
|
446
|
+
for (const c of candidates) c.id = prevIds.get(c.signature) ?? ++last;
|
|
447
|
+
return last;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Bytes in the scan window: each transcript and the subagent transcripts under
|
|
452
|
+
* it. The scan records this figure and the rescan gate diffs a fresh one
|
|
453
|
+
* against it, so both take it from here. Counted separately, the scan included
|
|
454
|
+
* subagent bytes and the gate did not: the gate's total came out smaller than
|
|
455
|
+
* the recorded one by that amount, and the "enough new data" clause could not
|
|
456
|
+
* fire for a workload made mostly of delegations.
|
|
457
|
+
*/
|
|
458
|
+
export function windowBytes(files) {
|
|
459
|
+
let total = 0;
|
|
460
|
+
for (const f of files || []) {
|
|
461
|
+
total += f.size || 0;
|
|
462
|
+
for (const a of f.subagentFiles || []) total += a.size || 0;
|
|
463
|
+
}
|
|
464
|
+
return total;
|
|
465
|
+
}
|
|
466
|
+
|
|
407
467
|
export async function runRouteScan({ days = 14 } = {}) {
|
|
408
468
|
const files = await discoverSessionFiles({ days });
|
|
409
469
|
|
|
@@ -420,12 +480,11 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
420
480
|
// Pass 1 — collect episodes (needed up front: thresholds are calibrated
|
|
421
481
|
// from the full window's output distribution before any tiering).
|
|
422
482
|
const all = []; // { ep, projectDir, sessionPath }
|
|
423
|
-
|
|
483
|
+
const dataBytes = windowBytes(files); // window size snapshot — the rescan gate diffs against it
|
|
424
484
|
const runIndexBySession = new Map(); // sessionPath → indexRuns() result
|
|
425
485
|
for (const f of files) {
|
|
426
486
|
let records;
|
|
427
487
|
try {
|
|
428
|
-
dataBytes += statSync(f.path).size;
|
|
429
488
|
records = await collectSessionRecords(f.path, { includeContent: true });
|
|
430
489
|
} catch {
|
|
431
490
|
continue;
|
|
@@ -438,12 +497,7 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
438
497
|
// delegation it made. Best-effort: sessions that never delegated have no
|
|
439
498
|
// directory and cost one failed readdir.
|
|
440
499
|
const runs = await collectSubagentRuns(f.path);
|
|
441
|
-
if (runs.length > 0)
|
|
442
|
-
runIndexBySession.set(f.path, indexRuns(runs));
|
|
443
|
-
// Subagent bytes count toward the window size so the rescan gate stays
|
|
444
|
-
// accurate for delegation-heavy workloads.
|
|
445
|
-
for (const r of runs) dataBytes += r.bytes || 0;
|
|
446
|
-
}
|
|
500
|
+
if (runs.length > 0) runIndexBySession.set(f.path, indexRuns(runs));
|
|
447
501
|
}
|
|
448
502
|
const totalEpisodes = all.length;
|
|
449
503
|
const thresholds = calibrateThresholds(all.map((x) => x.ep.out));
|
|
@@ -549,7 +603,7 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
549
603
|
d.runs += 1;
|
|
550
604
|
if (isFailedRun(run)) d.errRuns += 1;
|
|
551
605
|
d.outTokens += run.out || 0;
|
|
552
|
-
d.savedUsd += saved;
|
|
606
|
+
if (Number.isFinite(saved)) d.savedUsd += saved; // unpriced runs add nothing
|
|
553
607
|
delegatedStats.set(key, d);
|
|
554
608
|
};
|
|
555
609
|
// Ledger events feed the statusline's weekly/monthly "Routing saved"
|
|
@@ -566,9 +620,11 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
566
620
|
// savings here would credit the tool for work it did not route.
|
|
567
621
|
const ledgerEvents = [];
|
|
568
622
|
let ledgerRules = [];
|
|
623
|
+
let ruleForProject = () => undefined;
|
|
569
624
|
try {
|
|
570
|
-
const
|
|
571
|
-
ledgerRules = loadModelRules().rules.filter((r) => r.status !== 'off');
|
|
625
|
+
const rules = await import('./model-rules.js');
|
|
626
|
+
ledgerRules = rules.loadModelRules().rules.filter((r) => r.status !== 'off');
|
|
627
|
+
ruleForProject = rules.ruleForProject;
|
|
572
628
|
} catch { /* no registry → no attributable savings, which is the honest zero */ }
|
|
573
629
|
// A rule's baseline: what it stored at promotion, else what this scan still
|
|
574
630
|
// observes handling the category directly (a rule promoted before baselines
|
|
@@ -579,9 +635,9 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
579
635
|
|| (rule.scope === 'global' ? episodeStats.get(`${tier}|${catId}|*`) : null);
|
|
580
636
|
return s?.baselineModel || null;
|
|
581
637
|
};
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
638
|
+
// The same choice rule health makes (model-rules.js), so the ledger and the
|
|
639
|
+
// registry credit a run to the same rule.
|
|
640
|
+
const ruleForRun = (tier, catId, projectDir) => ruleForProject(ledgerRules, tier, catId, projectDir);
|
|
585
641
|
for (const [sessionPath, index] of runIndexBySession) {
|
|
586
642
|
const used = new Set();
|
|
587
643
|
const eps = all.filter((x) => x.sessionPath === sessionPath);
|
|
@@ -674,8 +730,8 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
674
730
|
.filter((g) => g.count >= MIN_RECURRENCE && !hasRule(g))
|
|
675
731
|
.sort((a, b) => b.count - a.count)
|
|
676
732
|
.slice(0, 8)
|
|
677
|
-
.map((g
|
|
678
|
-
id:
|
|
733
|
+
.map((g) => ({
|
|
734
|
+
id: null, // assigned below, from the previous scan where there was one
|
|
679
735
|
signature: `${g.tier}|${g.category}|${g.project}`,
|
|
680
736
|
tier: g.tier,
|
|
681
737
|
category: g.category,
|
|
@@ -702,6 +758,8 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
702
758
|
ruleEn: ruleTextEn(g),
|
|
703
759
|
}));
|
|
704
760
|
|
|
761
|
+
const lastCandidateId = assignCandidateIds(candidates, prev);
|
|
762
|
+
|
|
705
763
|
// Same category appearing in 2+ projects → suggest global for each.
|
|
706
764
|
const catProjects = new Map();
|
|
707
765
|
for (const c of candidates) {
|
|
@@ -720,6 +778,7 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
720
778
|
easyEpisodes: tieredEpisodes,
|
|
721
779
|
thresholds,
|
|
722
780
|
candidates,
|
|
781
|
+
lastCandidateId,
|
|
723
782
|
resolved: [...resolved],
|
|
724
783
|
unresolvedRuns,
|
|
725
784
|
unresolvedModels: [...unresolvedModels].slice(0, 5),
|
|
@@ -759,7 +818,8 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
759
818
|
// profile-map.json's `modelAliases` to bring these runs back in.
|
|
760
819
|
if (!baseline || !isRecognizedModelId(baseline)) continue;
|
|
761
820
|
const usd = runSaving(e.run, baseline);
|
|
762
|
-
|
|
821
|
+
// A loss is recorded too; only an unpriced run (null) has nothing to record.
|
|
822
|
+
if (!Number.isFinite(usd)) continue;
|
|
763
823
|
priced.push({
|
|
764
824
|
key: e.run.path,
|
|
765
825
|
ts: e.run.endedAt ?? e.run.startedAt ?? Date.now(),
|
|
@@ -811,11 +871,11 @@ export async function shouldRescan(cache, { days = 14, afterDelegation = false }
|
|
|
811
871
|
let total = 0;
|
|
812
872
|
let anyNew = false;
|
|
813
873
|
try {
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
874
|
+
const files = await discoverSessionFiles({ days });
|
|
875
|
+
total = windowBytes(files);
|
|
876
|
+
// A subagent transcript is new data too: a delegation writes there and may
|
|
877
|
+
// leave the parent transcript untouched until the run returns.
|
|
878
|
+
anyNew = files.some((f) => f.mtime > ts || (f.subagentFiles || []).some((a) => a.mtime > ts));
|
|
819
879
|
} catch {
|
|
820
880
|
return age >= RESCAN_MAX_AGE_MS; // can't stat — degrade to daily
|
|
821
881
|
}
|
package/src/savings-ledger.js
CHANGED
|
@@ -22,19 +22,36 @@
|
|
|
22
22
|
* empty, and the statusline renders totals of 0 as "no chip".
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import { readFileSync
|
|
25
|
+
import { readFileSync } from 'node:fs';
|
|
26
26
|
import { join } from 'node:path';
|
|
27
27
|
import { userDataDir } from './paths.js';
|
|
28
|
+
import { writeStateFile } from './state-file.js';
|
|
28
29
|
|
|
29
30
|
const WEEK_MS = 7 * 24 * 3600 * 1000;
|
|
30
31
|
const MONTH_MS = 30 * 24 * 3600 * 1000;
|
|
31
32
|
|
|
33
|
+
/** Stored USD is kept to 4 decimals so re-scans compare equal and files stay small. */
|
|
34
|
+
export const roundUsd = (usd) => Math.round(Number(usd) * 10000) / 10000;
|
|
35
|
+
|
|
32
36
|
export function ledgerPath() {
|
|
33
37
|
return join(userDataDir(), 'delegation-ledger.json');
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
export const LEDGER_VERSION = 2;
|
|
37
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Which run a ledger key names, wherever its transcript sits:
|
|
44
|
+
* `<session id>/subagents/<agent file>`. A project folder that was renamed or
|
|
45
|
+
* copied holds the same run under two paths, and keyed by full path alone the
|
|
46
|
+
* ledger counted its saving twice. Keys that are not subagent transcript paths
|
|
47
|
+
* identify themselves.
|
|
48
|
+
*/
|
|
49
|
+
export function runIdentity(key) {
|
|
50
|
+
const parts = String(key).split(/[\\/]/);
|
|
51
|
+
const i = parts.lastIndexOf('subagents');
|
|
52
|
+
return i > 0 ? parts.slice(i - 1).join('/') : String(key);
|
|
53
|
+
}
|
|
54
|
+
|
|
38
55
|
export function loadLedger() {
|
|
39
56
|
try {
|
|
40
57
|
const data = JSON.parse(readFileSync(ledgerPath(), 'utf8'));
|
|
@@ -45,6 +62,15 @@ export function loadLedger() {
|
|
|
45
62
|
// instead of mixing two meanings into one total. The next scan rebuilds
|
|
46
63
|
// whatever is still attributable.
|
|
47
64
|
if (data.version !== LEDGER_VERSION) return { version: LEDGER_VERSION, events: {} };
|
|
65
|
+
// One event per run: duplicates recorded from a second copy of the same
|
|
66
|
+
// transcript are dropped here, so every reader sums each run once and the
|
|
67
|
+
// next write persists the cleanup.
|
|
68
|
+
const seen = new Set();
|
|
69
|
+
for (const key of Object.keys(data.events)) {
|
|
70
|
+
const id = runIdentity(key);
|
|
71
|
+
if (seen.has(id)) delete data.events[key];
|
|
72
|
+
else seen.add(id);
|
|
73
|
+
}
|
|
48
74
|
return data;
|
|
49
75
|
} catch {
|
|
50
76
|
return { version: LEDGER_VERSION, events: {} };
|
|
@@ -55,19 +81,27 @@ export function loadLedger() {
|
|
|
55
81
|
* Upsert saving events. `events` is an array of
|
|
56
82
|
* { key, ts, usd, rule, from, to } where `key` is the run's transcript path
|
|
57
83
|
* (unique per subagent run) and `from`/`to` name the models the routing
|
|
58
|
-
* decision moved between.
|
|
59
|
-
*
|
|
84
|
+
* decision moved between. `usd` is signed: a run on a pricier model is a loss
|
|
85
|
+
* and is recorded as a negative amount so it reduces the totals. Only
|
|
86
|
+
* non-finite amounts (unpriced runs) are skipped.
|
|
60
87
|
*/
|
|
61
88
|
export function recordDelegationEvents(events) {
|
|
62
89
|
if (!Array.isArray(events) || events.length === 0) return;
|
|
63
90
|
const data = loadLedger();
|
|
64
91
|
data.version = LEDGER_VERSION;
|
|
65
92
|
let changed = false;
|
|
93
|
+
const keyOf = new Map(Object.keys(data.events).map((k) => [runIdentity(k), k]));
|
|
66
94
|
for (const e of events) {
|
|
67
|
-
if (!e || !e.key || !
|
|
68
|
-
|
|
69
|
-
|
|
95
|
+
if (!e || !e.key || typeof e.usd !== 'number' || !Number.isFinite(e.usd) || !Number.isFinite(e.ts)) continue;
|
|
96
|
+
// The run may already be recorded under the path of another copy of its
|
|
97
|
+
// transcript; that entry is the one to compare with and to replace.
|
|
98
|
+
const id = runIdentity(e.key);
|
|
99
|
+
const prevKey = keyOf.get(id) ?? e.key;
|
|
100
|
+
const prev = data.events[prevKey];
|
|
101
|
+
const usd = roundUsd(e.usd);
|
|
70
102
|
if (prev && prev.ts === e.ts && prev.usd === usd) continue;
|
|
103
|
+
if (prevKey !== e.key) delete data.events[prevKey];
|
|
104
|
+
keyOf.set(id, e.key);
|
|
71
105
|
data.events[e.key] = {
|
|
72
106
|
ts: e.ts,
|
|
73
107
|
usd,
|
|
@@ -79,9 +113,7 @@ export function recordDelegationEvents(events) {
|
|
|
79
113
|
}
|
|
80
114
|
if (!changed) return;
|
|
81
115
|
try {
|
|
82
|
-
|
|
83
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
84
|
-
writeFileSync(ledgerPath(), JSON.stringify(data) + '\n');
|
|
116
|
+
writeStateFile(ledgerPath(), JSON.stringify(data) + '\n');
|
|
85
117
|
} catch {
|
|
86
118
|
// statusline totals just stay stale until the next successful scan
|
|
87
119
|
}
|
|
@@ -115,8 +147,8 @@ export function delegationSavedTotals(now = Date.now()) {
|
|
|
115
147
|
const byPair = new Map();
|
|
116
148
|
try {
|
|
117
149
|
for (const e of Object.values(loadLedger().events)) {
|
|
118
|
-
const usd = Number(e.usd)
|
|
119
|
-
if (usd
|
|
150
|
+
const usd = Number(e.usd);
|
|
151
|
+
if (!Number.isFinite(usd)) continue;
|
|
120
152
|
totals.total += usd;
|
|
121
153
|
if (Number.isFinite(e.ts)) {
|
|
122
154
|
if (now - e.ts <= WEEK_MS) totals.week += usd;
|
package/src/seed-rules.js
CHANGED
|
@@ -23,13 +23,14 @@
|
|
|
23
23
|
* { decided: { "<id>": { action: 'accepted' | 'skipped', at, scope? } } }
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import { readFileSync
|
|
26
|
+
import { readFileSync } from 'node:fs';
|
|
27
27
|
import { CLI_NAME } from './cli-name.js';
|
|
28
28
|
import { join, dirname } from 'node:path';
|
|
29
29
|
import { fileURLToPath } from 'node:url';
|
|
30
30
|
import { createHash } from 'node:crypto';
|
|
31
31
|
import { userDataDir } from './paths.js';
|
|
32
32
|
import { userLanguage } from './config.js';
|
|
33
|
+
import { writeStateFile } from './state-file.js';
|
|
33
34
|
import {
|
|
34
35
|
addModelRule,
|
|
35
36
|
composeRuleText,
|
|
@@ -62,9 +63,7 @@ export function loadSeedState(opts = {}) {
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
export function saveSeedState(state, opts = {}) {
|
|
65
|
-
|
|
66
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
67
|
-
writeFileSync(seedStatePath(opts), JSON.stringify(state, null, 2) + '\n');
|
|
66
|
+
writeStateFile(seedStatePath(opts), JSON.stringify(state, null, 2) + '\n');
|
|
68
67
|
}
|
|
69
68
|
|
|
70
69
|
/** Bundled tier-delegation presets (empty array when the file is unreadable). */
|
package/src/session-cache.js
CHANGED
|
@@ -31,7 +31,11 @@ const CACHE_PATH = join(userDataDir(), 'session-cache.json');
|
|
|
31
31
|
// the users the flag exists for.
|
|
32
32
|
// 3: version 2's serialize() never actually wrote `gatewayObserved`, so every
|
|
33
33
|
// v2 entry lacks the flag it was bumped for. Bumped again to discard them.
|
|
34
|
-
|
|
34
|
+
// 4: sessions that used more than one model carry `modelTotals`, and
|
|
35
|
+
// zero-token `<synthetic>` stubs no longer count as requests. A v3 entry has
|
|
36
|
+
// neither, so a mixed-model session cached before the bump would go on being
|
|
37
|
+
// priced at one rate until its transcript changed.
|
|
38
|
+
const CACHE_VERSION = 4;
|
|
35
39
|
// Entries for transcripts this old are pruned on write. Keeps the file
|
|
36
40
|
// bounded without an existence check per entry (which would cost the syscalls
|
|
37
41
|
// the cache exists to avoid).
|
|
@@ -47,6 +51,7 @@ const PRUNE_AFTER_MS = 90 * 24 * 60 * 60 * 1000;
|
|
|
47
51
|
* @property {object} totals
|
|
48
52
|
* @property {number} maxContextPerRequest
|
|
49
53
|
* @property {string} model
|
|
54
|
+
* @property {Record<string, object>} [modelTotals] totals per model, present only when more than one model spent tokens
|
|
50
55
|
* @property {string} [projectDir]
|
|
51
56
|
*/
|
|
52
57
|
|
|
@@ -60,6 +65,7 @@ function serialize(session) {
|
|
|
60
65
|
totals: session.totals,
|
|
61
66
|
maxContextPerRequest: session.maxContextPerRequest,
|
|
62
67
|
model: session.model,
|
|
68
|
+
...(session.modelTotals ? { modelTotals: session.modelTotals } : {}),
|
|
63
69
|
gatewayObserved: !!session.gatewayObserved,
|
|
64
70
|
};
|
|
65
71
|
}
|
|
@@ -75,6 +81,7 @@ function deserialize(stored, filePath, projectDir) {
|
|
|
75
81
|
totals: stored.totals,
|
|
76
82
|
maxContextPerRequest: stored.maxContextPerRequest || 0,
|
|
77
83
|
model: stored.model || 'unknown',
|
|
84
|
+
...(stored.modelTotals && typeof stored.modelTotals === 'object' ? { modelTotals: stored.modelTotals } : {}),
|
|
78
85
|
gatewayObserved: !!stored.gatewayObserved,
|
|
79
86
|
};
|
|
80
87
|
}
|
|
@@ -132,6 +139,33 @@ export function putCached(cache, file, session) {
|
|
|
132
139
|
};
|
|
133
140
|
}
|
|
134
141
|
|
|
142
|
+
/**
|
|
143
|
+
* A session re-aggregated from a fixed point in time (the start of the month),
|
|
144
|
+
* stored beside the full summary of the same file. One slot per entry: the
|
|
145
|
+
* point moves once a month, and an entry whose file changed is replaced whole
|
|
146
|
+
* by putCached, which drops the slot with it.
|
|
147
|
+
*
|
|
148
|
+
* @returns {SessionSummary|null} null when the file changed or the slot is for another point
|
|
149
|
+
*/
|
|
150
|
+
export function getCachedSince(cache, file, sinceMs) {
|
|
151
|
+
const e = cache.entries[file.path];
|
|
152
|
+
if (!e || e.mtimeMs !== file.mtime || e.size !== file.size || !e.t || e.t.since !== sinceMs || !e.t.s) return null;
|
|
153
|
+
try {
|
|
154
|
+
return deserialize(e.t.s, file.path, file.projectDir);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
debug('session-cache:deserialize-since', err);
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Returns false when there is no current entry for the file to attach the slot to. */
|
|
162
|
+
export function putCachedSince(cache, file, sinceMs, session) {
|
|
163
|
+
const e = cache.entries[file.path];
|
|
164
|
+
if (!e || e.mtimeMs !== file.mtime || e.size !== file.size) return false;
|
|
165
|
+
e.t = { since: sinceMs, s: serialize(session) };
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
135
169
|
/**
|
|
136
170
|
* Atomically persist the cache (tmp + rename), pruning long-dead transcripts.
|
|
137
171
|
* Concurrent statusline refreshes are expected — rename is atomic on POSIX and
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes for the state files this tool owns: the rule registry, the savings
|
|
3
|
+
* ledger, the seed answers, the config.
|
|
4
|
+
*
|
|
5
|
+
* Their loaders read a file that fails to parse as empty state, which is the
|
|
6
|
+
* right thing for a report but meant the next save replaced the whole file
|
|
7
|
+
* with that empty state. The savings ledger is a lifetime total, so one
|
|
8
|
+
* half-written file (two statuslines saving at once, a full disk) erased it
|
|
9
|
+
* for good. Two things stop that here: the write goes to a temp file that is
|
|
10
|
+
* renamed over the target, so a reader never sees half a file, and a target
|
|
11
|
+
* that does not parse is copied aside before it is replaced.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { writeFileSync, renameSync, readFileSync, existsSync, mkdirSync, copyFileSync, rmSync } from 'node:fs';
|
|
15
|
+
import { dirname } from 'node:path';
|
|
16
|
+
|
|
17
|
+
/** Where an unreadable state file is kept. One slot: the first copy holds the data worth recovering. */
|
|
18
|
+
export const corruptCopyPath = (file) => `${file}.corrupt`;
|
|
19
|
+
|
|
20
|
+
function keepUnreadable(file) {
|
|
21
|
+
let raw;
|
|
22
|
+
try { raw = readFileSync(file, 'utf8'); } catch { return; }
|
|
23
|
+
if (!raw.trim()) return;
|
|
24
|
+
try { JSON.parse(raw); return; } catch { /* does not parse: keep it */ }
|
|
25
|
+
try {
|
|
26
|
+
if (!existsSync(corruptCopyPath(file))) copyFileSync(file, corruptCopyPath(file));
|
|
27
|
+
} catch { /* best effort; the write below still goes ahead */ }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function writeStateFile(file, text) {
|
|
31
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
32
|
+
keepUnreadable(file);
|
|
33
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
34
|
+
try {
|
|
35
|
+
writeFileSync(tmp, text);
|
|
36
|
+
renameSync(tmp, file);
|
|
37
|
+
} catch {
|
|
38
|
+
// Windows refuses the rename while another process holds the target open.
|
|
39
|
+
// Writing in place is what this did before, and better than not saving.
|
|
40
|
+
try { rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
|
|
41
|
+
writeFileSync(file, text);
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/stats.js
CHANGED
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
* Aggregate session data into daily trends, TTL breakdown, and anomalies.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
// The local calendar day. toISOString() gives the UTC day, which put a session
|
|
6
|
+
// started at 08:00 in Seoul under the previous date, while the month total and
|
|
7
|
+
// the history files next to this trend count in local time.
|
|
5
8
|
function dateKey(date) {
|
|
6
|
-
|
|
9
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
10
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
7
11
|
}
|
|
8
12
|
|
|
9
13
|
function hitRate(read, creation, input) {
|
|
@@ -167,9 +171,17 @@ function percentile(values, p) {
|
|
|
167
171
|
* we're diagnosing — if we let them into the baseline they'd drag the baseline
|
|
168
172
|
* toward themselves and never register as anomalies.
|
|
169
173
|
*/
|
|
174
|
+
// A session this short is a one-shot call, not a conversation: `claude -p`
|
|
175
|
+
// from a script, a hook, a batch job. They are tiny and can outnumber real
|
|
176
|
+
// sessions a hundred to one (measured: 1,924 of 1,941 sessions in 7 days were
|
|
177
|
+
// single-request), which put the "normal" p95 at 51k tokens and reported an
|
|
178
|
+
// ordinary 13M session as 252x p95. The baseline describes conversations, so
|
|
179
|
+
// only conversations go into it.
|
|
180
|
+
export const BASELINE_MIN_REQUESTS = 3;
|
|
181
|
+
|
|
170
182
|
export function computeBaseline(sessions, recentWindowMs = 24 * 60 * 60 * 1000) {
|
|
171
183
|
const cutoff = Date.now() - recentWindowMs;
|
|
172
|
-
const older = sessions.filter((s) => s.startTime && s.startTime.getTime() < cutoff && s.requestCount
|
|
184
|
+
const older = sessions.filter((s) => s.startTime && s.startTime.getTime() < cutoff && s.requestCount >= BASELINE_MIN_REQUESTS);
|
|
173
185
|
if (older.length < 3) {
|
|
174
186
|
return { enough: false, sampleSize: older.length };
|
|
175
187
|
}
|
|
@@ -295,15 +307,18 @@ export function detectSpikes(sessions, { recentHours = 24, multiplier = 3 } = {}
|
|
|
295
307
|
? m.totalInput / baseline.p95TotalInput
|
|
296
308
|
: null;
|
|
297
309
|
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
m.maxContextPerRequest > 250_000;
|
|
310
|
+
const byRatio = ratio !== null && ratio >= multiplier;
|
|
311
|
+
const isSpike = byRatio || m.maxContextPerRequest > 250_000;
|
|
301
312
|
|
|
302
313
|
if (!isSpike) continue;
|
|
303
314
|
|
|
304
315
|
spikes.push({
|
|
305
316
|
metrics: m,
|
|
306
317
|
ratio,
|
|
318
|
+
// Which of the two tests it met. A session listed for one large request
|
|
319
|
+
// alone has a ratio too, and printing that ratio ("0.1x p95") as the
|
|
320
|
+
// reason would name the test it did not meet.
|
|
321
|
+
byRatio,
|
|
307
322
|
issues: diagnoseSession(m, baseline),
|
|
308
323
|
});
|
|
309
324
|
}
|
package/src/stdin-payload.js
CHANGED
|
@@ -59,11 +59,16 @@ export function extractCaps(stdinJson) {
|
|
|
59
59
|
if (!value || typeof value !== 'object') continue;
|
|
60
60
|
const usedPct = normalizePct(value.used_percentage);
|
|
61
61
|
if (usedPct === null) continue;
|
|
62
|
-
|
|
62
|
+
// Same trap as the percentage: Number(null) is 0, and a reset "at 0" is
|
|
63
|
+
// 1970, which renders as a window that reset long ago. Only a positive
|
|
64
|
+
// number is a time.
|
|
65
|
+
const rawReset = typeof value.resets_at === 'string' && value.resets_at.trim() !== ''
|
|
66
|
+
? Number(value.resets_at)
|
|
67
|
+
: value.resets_at;
|
|
63
68
|
windows.push({
|
|
64
69
|
key,
|
|
65
70
|
usedPct,
|
|
66
|
-
resetsAt: Number.isFinite(
|
|
71
|
+
resetsAt: typeof rawReset === 'number' && Number.isFinite(rawReset) && rawReset > 0 ? rawReset : null,
|
|
67
72
|
});
|
|
68
73
|
}
|
|
69
74
|
return windows.length ? { windows } : null;
|
|
@@ -84,10 +89,13 @@ export function extractCaps(stdinJson) {
|
|
|
84
89
|
// model in use (Opus 4.7 vs 4.6 matters a lot for token budgeting).
|
|
85
90
|
export function bedrockDisplayFromId(id) {
|
|
86
91
|
if (typeof id !== 'string') return null;
|
|
87
|
-
|
|
92
|
+
// The minor version is one or two digits. An id with no minor goes straight
|
|
93
|
+
// to its date stamp (`claude-opus-4-20250514`), and reading that stamp as the
|
|
94
|
+
// minor printed "Opus 4.20250514".
|
|
95
|
+
const m = id.match(/claude[-_](opus|sonnet|haiku)[-_](\d+)(?:[-_](\d{1,2})(?!\d))?/i);
|
|
88
96
|
if (!m) return null;
|
|
89
97
|
const family = m[1].charAt(0).toUpperCase() + m[1].slice(1).toLowerCase();
|
|
90
|
-
return `${family} ${m[2]}.${m[3]}`;
|
|
98
|
+
return m[3] === undefined ? `${family} ${m[2]}` : `${family} ${m[2]}.${m[3]}`;
|
|
91
99
|
}
|
|
92
100
|
|
|
93
101
|
/**
|
|
@@ -108,6 +116,12 @@ export function extractContextUsage(stdinJson) {
|
|
|
108
116
|
};
|
|
109
117
|
}
|
|
110
118
|
|
|
119
|
+
/** The session the payload belongs to, or null when the field is absent. */
|
|
120
|
+
export function extractSessionId(stdinJson) {
|
|
121
|
+
const id = stdinJson?.session_id;
|
|
122
|
+
return typeof id === 'string' && id !== '' ? id : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
111
125
|
export function extractModel(stdinJson) {
|
|
112
126
|
if (!stdinJson || !stdinJson.model) return null;
|
|
113
127
|
const m = stdinJson.model;
|