sprag-cli 3.56.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 +1 -1
- package/README.md +1 -1
- package/bin/cli.js +58 -17
- package/bin/postinstall.js +22 -0
- package/docs/CODEX.ko.md +5 -1
- package/docs/CODEX.md +5 -0
- package/package.json +2 -2
- package/src/agents.js +16 -2
- package/src/brief.js +17 -8
- package/src/claude-price.js +23 -2
- package/src/codex-brief.js +35 -2
- package/src/codex-delegation.js +12 -4
- package/src/codex-harness.js +43 -9
- package/src/codex-hooks.js +24 -3
- package/src/codex-ledger.js +61 -5
- package/src/codex-panel.js +42 -22
- package/src/codex-rule-health.js +24 -1
- package/src/commands/brief.js +14 -3
- package/src/commands/codex-delegate.js +7 -4
- package/src/commands/codex.js +2 -0
- package/src/commands/harness.js +6 -0
- package/src/compact-window.js +12 -4
- package/src/config.js +3 -3
- package/src/cost.js +5 -2
- 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 +4 -4
- package/src/formatters/table.js +18 -10
- package/src/gateway-auth.js +31 -3
- 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/model-rules.js +88 -41
- package/src/month-spend.js +17 -11
- 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 +78 -26
- package/src/savings-ledger.js +33 -5
- 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';
|
|
@@ -55,7 +55,16 @@ export const MIN_DELEGABLE_OUT = 100;
|
|
|
55
55
|
// Measured 2026-09-20 on 1,276 local prompts: '퍼블리시' (npm publish, 15 hits)
|
|
56
56
|
// and '이유' (root-cause asks) were slipping through as run/translate, and a
|
|
57
57
|
// bare '머지' matched inside '나머지' ("나머지 진행하자" ×7), hence the lookbehind.
|
|
58
|
-
|
|
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;
|
|
59
68
|
|
|
60
69
|
// Implementation asks phrased around a run/check verb ("설치와 동시에 설정되도록
|
|
61
70
|
// 하자", "피드백 확인해주고 아이콘도 만들자"). Editing is not delegable, and at
|
|
@@ -116,7 +125,7 @@ const CATEGORIES = [
|
|
|
116
125
|
label: '탐색·조회 (파일/값 찾기)',
|
|
117
126
|
labelEn: 'lookup (finding files/values)',
|
|
118
127
|
agent: 'haiku-explore',
|
|
119
|
-
kw: [[/
|
|
128
|
+
kw: [[/\bgrep\b|검색|\bsearch|\bfind\b/i, 2], [/찾아|어디|위치|목록|살펴/i, 1],
|
|
120
129
|
[/정의(?:돼|되어)\s*있|어느\s*파일|어떤\s*파일에|\bwhich\s+file\b|\bwhere\s+(?:is|are)\b[^?\n]*\bdefined\b/i, 1]],
|
|
121
130
|
},
|
|
122
131
|
{
|
|
@@ -124,23 +133,31 @@ const CATEGORIES = [
|
|
|
124
133
|
label: '읽기·요약·설명',
|
|
125
134
|
labelEn: 'reading / summarizing / explaining',
|
|
126
135
|
agent: 'haiku-explore',
|
|
127
|
-
kw: [[/요약|summar|explain/i, 2], [
|
|
136
|
+
kw: [[/요약|summar|explain/i, 2], [/읽어|설명|정리해|보여줘|알려줘|뭐야|\bwhat\b/i, 1]],
|
|
128
137
|
},
|
|
129
138
|
{
|
|
130
139
|
id: 'check',
|
|
131
140
|
label: '상태 확인·검증',
|
|
132
141
|
labelEn: 'status checks / verification',
|
|
133
142
|
agent: 'haiku-explore',
|
|
134
|
-
kw: [[
|
|
143
|
+
kw: [[/확인|검증|\bverif|점검/i, 2], [/맞아\?|되나|됐나|됐어|되는지|괜찮|체크|\bcheck\b|\bstatus\b/i, 1]],
|
|
135
144
|
},
|
|
136
145
|
{
|
|
137
146
|
id: 'run',
|
|
138
147
|
label: '명령 실행 (빌드·테스트·git)',
|
|
139
148
|
labelEn: 'running commands (build/test/git)',
|
|
140
149
|
agent: 'haiku-runner',
|
|
141
|
-
kw: [[
|
|
150
|
+
kw: [[/\bgit |\bcommit(?:s|ted)?\b|\bpush(?:ed|es)?\b|\bnpm |\bpip3? |빌드해|빌드 돌/i, 2], [/실행|돌려|\brun\b|\bbuild\b|빌드|테스트|설치/i, 1]],
|
|
142
151
|
},
|
|
143
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.
|
|
144
161
|
|
|
145
162
|
// ── Behavior signal (1st) — what the episode actually DID ────────────────
|
|
146
163
|
// The tool-call histogram is ground truth the prompt's wording is not:
|
|
@@ -411,6 +428,42 @@ export function ruleCouldHaveRouted(rule, run) {
|
|
|
411
428
|
return run.startedAt >= promoted;
|
|
412
429
|
}
|
|
413
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
|
+
|
|
414
467
|
export async function runRouteScan({ days = 14 } = {}) {
|
|
415
468
|
const files = await discoverSessionFiles({ days });
|
|
416
469
|
|
|
@@ -427,12 +480,11 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
427
480
|
// Pass 1 — collect episodes (needed up front: thresholds are calibrated
|
|
428
481
|
// from the full window's output distribution before any tiering).
|
|
429
482
|
const all = []; // { ep, projectDir, sessionPath }
|
|
430
|
-
|
|
483
|
+
const dataBytes = windowBytes(files); // window size snapshot — the rescan gate diffs against it
|
|
431
484
|
const runIndexBySession = new Map(); // sessionPath → indexRuns() result
|
|
432
485
|
for (const f of files) {
|
|
433
486
|
let records;
|
|
434
487
|
try {
|
|
435
|
-
dataBytes += statSync(f.path).size;
|
|
436
488
|
records = await collectSessionRecords(f.path, { includeContent: true });
|
|
437
489
|
} catch {
|
|
438
490
|
continue;
|
|
@@ -445,12 +497,7 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
445
497
|
// delegation it made. Best-effort: sessions that never delegated have no
|
|
446
498
|
// directory and cost one failed readdir.
|
|
447
499
|
const runs = await collectSubagentRuns(f.path);
|
|
448
|
-
if (runs.length > 0)
|
|
449
|
-
runIndexBySession.set(f.path, indexRuns(runs));
|
|
450
|
-
// Subagent bytes count toward the window size so the rescan gate stays
|
|
451
|
-
// accurate for delegation-heavy workloads.
|
|
452
|
-
for (const r of runs) dataBytes += r.bytes || 0;
|
|
453
|
-
}
|
|
500
|
+
if (runs.length > 0) runIndexBySession.set(f.path, indexRuns(runs));
|
|
454
501
|
}
|
|
455
502
|
const totalEpisodes = all.length;
|
|
456
503
|
const thresholds = calibrateThresholds(all.map((x) => x.ep.out));
|
|
@@ -573,9 +620,11 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
573
620
|
// savings here would credit the tool for work it did not route.
|
|
574
621
|
const ledgerEvents = [];
|
|
575
622
|
let ledgerRules = [];
|
|
623
|
+
let ruleForProject = () => undefined;
|
|
576
624
|
try {
|
|
577
|
-
const
|
|
578
|
-
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;
|
|
579
628
|
} catch { /* no registry → no attributable savings, which is the honest zero */ }
|
|
580
629
|
// A rule's baseline: what it stored at promotion, else what this scan still
|
|
581
630
|
// observes handling the category directly (a rule promoted before baselines
|
|
@@ -586,9 +635,9 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
586
635
|
|| (rule.scope === 'global' ? episodeStats.get(`${tier}|${catId}|*`) : null);
|
|
587
636
|
return s?.baselineModel || null;
|
|
588
637
|
};
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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);
|
|
592
641
|
for (const [sessionPath, index] of runIndexBySession) {
|
|
593
642
|
const used = new Set();
|
|
594
643
|
const eps = all.filter((x) => x.sessionPath === sessionPath);
|
|
@@ -681,8 +730,8 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
681
730
|
.filter((g) => g.count >= MIN_RECURRENCE && !hasRule(g))
|
|
682
731
|
.sort((a, b) => b.count - a.count)
|
|
683
732
|
.slice(0, 8)
|
|
684
|
-
.map((g
|
|
685
|
-
id:
|
|
733
|
+
.map((g) => ({
|
|
734
|
+
id: null, // assigned below, from the previous scan where there was one
|
|
686
735
|
signature: `${g.tier}|${g.category}|${g.project}`,
|
|
687
736
|
tier: g.tier,
|
|
688
737
|
category: g.category,
|
|
@@ -709,6 +758,8 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
709
758
|
ruleEn: ruleTextEn(g),
|
|
710
759
|
}));
|
|
711
760
|
|
|
761
|
+
const lastCandidateId = assignCandidateIds(candidates, prev);
|
|
762
|
+
|
|
712
763
|
// Same category appearing in 2+ projects → suggest global for each.
|
|
713
764
|
const catProjects = new Map();
|
|
714
765
|
for (const c of candidates) {
|
|
@@ -727,6 +778,7 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
727
778
|
easyEpisodes: tieredEpisodes,
|
|
728
779
|
thresholds,
|
|
729
780
|
candidates,
|
|
781
|
+
lastCandidateId,
|
|
730
782
|
resolved: [...resolved],
|
|
731
783
|
unresolvedRuns,
|
|
732
784
|
unresolvedModels: [...unresolvedModels].slice(0, 5),
|
|
@@ -819,11 +871,11 @@ export async function shouldRescan(cache, { days = 14, afterDelegation = false }
|
|
|
819
871
|
let total = 0;
|
|
820
872
|
let anyNew = false;
|
|
821
873
|
try {
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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));
|
|
827
879
|
} catch {
|
|
828
880
|
return age >= RESCAN_MAX_AGE_MS; // can't stat — degrade to daily
|
|
829
881
|
}
|
package/src/savings-ledger.js
CHANGED
|
@@ -22,9 +22,10 @@
|
|
|
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;
|
|
@@ -38,6 +39,19 @@ export function ledgerPath() {
|
|
|
38
39
|
|
|
39
40
|
export const LEDGER_VERSION = 2;
|
|
40
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
|
+
|
|
41
55
|
export function loadLedger() {
|
|
42
56
|
try {
|
|
43
57
|
const data = JSON.parse(readFileSync(ledgerPath(), 'utf8'));
|
|
@@ -48,6 +62,15 @@ export function loadLedger() {
|
|
|
48
62
|
// instead of mixing two meanings into one total. The next scan rebuilds
|
|
49
63
|
// whatever is still attributable.
|
|
50
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
|
+
}
|
|
51
74
|
return data;
|
|
52
75
|
} catch {
|
|
53
76
|
return { version: LEDGER_VERSION, events: {} };
|
|
@@ -67,11 +90,18 @@ export function recordDelegationEvents(events) {
|
|
|
67
90
|
const data = loadLedger();
|
|
68
91
|
data.version = LEDGER_VERSION;
|
|
69
92
|
let changed = false;
|
|
93
|
+
const keyOf = new Map(Object.keys(data.events).map((k) => [runIdentity(k), k]));
|
|
70
94
|
for (const e of events) {
|
|
71
95
|
if (!e || !e.key || typeof e.usd !== 'number' || !Number.isFinite(e.usd) || !Number.isFinite(e.ts)) continue;
|
|
72
|
-
|
|
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];
|
|
73
101
|
const usd = roundUsd(e.usd);
|
|
74
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);
|
|
75
105
|
data.events[e.key] = {
|
|
76
106
|
ts: e.ts,
|
|
77
107
|
usd,
|
|
@@ -83,9 +113,7 @@ export function recordDelegationEvents(events) {
|
|
|
83
113
|
}
|
|
84
114
|
if (!changed) return;
|
|
85
115
|
try {
|
|
86
|
-
|
|
87
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
88
|
-
writeFileSync(ledgerPath(), JSON.stringify(data) + '\n');
|
|
116
|
+
writeStateFile(ledgerPath(), JSON.stringify(data) + '\n');
|
|
89
117
|
} catch {
|
|
90
118
|
// statusline totals just stay stale until the next successful scan
|
|
91
119
|
}
|
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;
|