sprag-cli 3.40.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/LICENSE +21 -0
- package/README.ko.md +637 -0
- package/README.md +758 -0
- package/bin/cli.js +801 -0
- package/examples/statusline-command.ps1 +43 -0
- package/examples/statusline-command.sh +36 -0
- package/package.json +62 -0
- package/presets/cohesion/cohesion-en.md +26 -0
- package/presets/doc2md/convert.py +363 -0
- package/presets/korean-style/LICENSE-fluent-korean +21 -0
- package/presets/korean-style/fluent-korean.md +52 -0
- package/presets/korean-style/supplement.md +93 -0
- package/presets/model-rules.json +115 -0
- package/presets/ratchet-rules.json +38 -0
- package/src/advice.js +564 -0
- package/src/agents.js +52 -0
- package/src/brief.js +264 -0
- package/src/caps-cache.js +84 -0
- package/src/cli-args.js +51 -0
- package/src/cohesion.js +70 -0
- package/src/commands/brief.js +31 -0
- package/src/commands/cohesion.js +59 -0
- package/src/commands/compact-window.js +93 -0
- package/src/commands/doc2md.js +166 -0
- package/src/commands/feedback.js +132 -0
- package/src/commands/handoff.js +33 -0
- package/src/commands/harness.js +459 -0
- package/src/commands/history.js +46 -0
- package/src/commands/install.js +358 -0
- package/src/commands/korean.js +220 -0
- package/src/commands/last.js +151 -0
- package/src/commands/mode.js +46 -0
- package/src/commands/route-scan.js +454 -0
- package/src/commands/seed.js +105 -0
- package/src/commands/uninstall.js +42 -0
- package/src/commands/update-check.js +77 -0
- package/src/commands/upgrade.js +68 -0
- package/src/compact-window.js +205 -0
- package/src/config.js +232 -0
- package/src/cost.js +253 -0
- package/src/debug.js +29 -0
- package/src/demo.js +331 -0
- package/src/doc2md-ledger.cjs +227 -0
- package/src/doc2md.cjs +997 -0
- package/src/fig2md-runner.cjs +21 -0
- package/src/fig2md.cjs +191 -0
- package/src/first-run-note.js +63 -0
- package/src/format-time.js +44 -0
- package/src/formatters/csv.js +8 -0
- package/src/formatters/json.js +3 -0
- package/src/formatters/statusline.js +750 -0
- package/src/formatters/table.js +299 -0
- package/src/handoff.js +161 -0
- package/src/harness-analyzer.cjs +264 -0
- package/src/harness-templates.js +153 -0
- package/src/harness.js +613 -0
- package/src/history.js +383 -0
- package/src/hook-manager.js +96 -0
- package/src/hook.cjs +196 -0
- package/src/installer.js +614 -0
- package/src/korean-lint.cjs +303 -0
- package/src/korean-style.js +187 -0
- package/src/litellm-budget.js +223 -0
- package/src/model-alias.js +484 -0
- package/src/model-rules.js +527 -0
- package/src/month-spend.js +47 -0
- package/src/parser.js +330 -0
- package/src/paths.js +41 -0
- package/src/prompt.js +52 -0
- package/src/route-scan.js +832 -0
- package/src/savings-ledger.js +137 -0
- package/src/seed-rules.js +280 -0
- package/src/session-cache.js +160 -0
- package/src/session-records.js +188 -0
- package/src/stats.js +380 -0
- package/src/stdin-payload.js +122 -0
- package/src/subagent-records.js +214 -0
- package/src/update-check.js +201 -0
- package/src/window-labels.js +64 -0
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* route-scan — detect recurring "easy" work running on expensive models and
|
|
3
|
+
* propose model-delegation ratchet rules.
|
|
4
|
+
*
|
|
5
|
+
* Difficulty is judged at the EPISODE level (one user request = the
|
|
6
|
+
* consecutive API calls it triggered), because per-call scoring saturates on
|
|
7
|
+
* Claude Code's large session contexts — prompt size carries no signal when
|
|
8
|
+
* every call ships a 100k+ cached prefix. An episode is easy when the whole
|
|
9
|
+
* request finished in few calls with little generation — exactly the work a
|
|
10
|
+
* haiku subagent could take.
|
|
11
|
+
*
|
|
12
|
+
* Fully local, zero token cost. Results are cached (24h) so the SessionStart
|
|
13
|
+
* hook can read them without re-parsing a month of transcripts.
|
|
14
|
+
*
|
|
15
|
+
* Pipeline position (per design discussion): this scan is NOT a real-time
|
|
16
|
+
* router — it is a session-boundary calibrator that feeds the existing
|
|
17
|
+
* ratchet promote flow (`harness promote R<N> --project|--global`).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { userDataDir } from './paths.js';
|
|
23
|
+
import { discoverSessionFiles } from './parser.js';
|
|
24
|
+
import { collectSessionRecords } from './session-records.js';
|
|
25
|
+
import {
|
|
26
|
+
collectSubagentRuns, indexRuns, exactRunsForEpisode, fallbackRunsForEpisode,
|
|
27
|
+
} from './subagent-records.js';
|
|
28
|
+
import { estimateCost, modelRank, isRecognizedModelId, TIER_TARGET_RANK, tierForRank } from './cost.js';
|
|
29
|
+
import { learnProfileMapping, resetModelAliasCache } from './model-alias.js';
|
|
30
|
+
import { modelRuleBaseText } from './model-rules.js';
|
|
31
|
+
|
|
32
|
+
// ── Tier bands (docs/TIER_CRITERIA.md §3) ────────────────────────────────
|
|
33
|
+
// T2 (haiku): finished in few calls, tiny output, near-zero mutation, no
|
|
34
|
+
// errors. T1 (sonnet): moderate output/mutation, at most one tool error.
|
|
35
|
+
// T0: everything else stays on the session model. Output-token thresholds
|
|
36
|
+
// are calibrated per-user from their own 14-day distribution (fixed
|
|
37
|
+
// thresholds drift with workload — RouteLLM's stated limitation), clamped
|
|
38
|
+
// to sane ranges so a skewed window can't stretch them absurdly.
|
|
39
|
+
export const T2_MAX_CALLS = 6;
|
|
40
|
+
export const T2_MAX_MUTATING = 2;
|
|
41
|
+
export const T2_OUT_CLAMP = [1000, 3000]; // default 1500 pre-calibration
|
|
42
|
+
export const T1_MAX_MUTATING = 6;
|
|
43
|
+
export const T1_MAX_ERRORS = 1;
|
|
44
|
+
export const T1_OUT_CLAMP = [5000, 15000]; // default 8000 pre-calibration
|
|
45
|
+
export const T0_MIN_ERRORS = 2; // repeated tool errors = hard, by outcome
|
|
46
|
+
export const T0_MIN_MUTATING = 7;
|
|
47
|
+
// Episodes below this output size carry no delegable work (conversational
|
|
48
|
+
// acks, feedback) — skip entirely.
|
|
49
|
+
export const MIN_DELEGABLE_OUT = 100;
|
|
50
|
+
// Escalation keywords: design/analysis judgement stays on the top tier.
|
|
51
|
+
// Irreversible/external actions (store submission, deploy, release, merge)
|
|
52
|
+
// are included — they may look like light "run" episodes in the logs, but
|
|
53
|
+
// delegating them defeats the harness's default-safe-path rule.
|
|
54
|
+
export const ESCALATE_RE = /설계|아키텍처|리팩토링|원인 분석|개선할|검토해보|비교|왜 |제출|배포|출시|analyze|compare|evaluate|architect|refactor|submit|deploy|release|publish|merge/i;
|
|
55
|
+
// A pattern must recur this often before we nag about it.
|
|
56
|
+
export const MIN_RECURRENCE = 3;
|
|
57
|
+
|
|
58
|
+
// ── Rescan gate (data-driven, not time-driven) ───────────────────────────
|
|
59
|
+
// A scan over unchanged transcripts is deterministic — identical output —
|
|
60
|
+
// so time alone is a bad trigger: it wastes scans on idle days and lags a
|
|
61
|
+
// full day behind heavy ones. Instead we rescan when enough NEW transcript
|
|
62
|
+
// data accumulated (~5MB ≈ 20-40 episodes on measured data — enough for a
|
|
63
|
+
// pattern to newly cross MIN_RECURRENCE), with guardrails: a minimum
|
|
64
|
+
// interval against session-churn thrash, a daily fallback so small trickles
|
|
65
|
+
// still refresh rule-health, and a hard skip when nothing changed at all.
|
|
66
|
+
export const RESCAN_MIN_INTERVAL_MS = 60 * 60 * 1000; // never more than hourly
|
|
67
|
+
export const RESCAN_BIG_DELTA_BYTES = 5 * 1024 * 1024; // this much new data → rescan now
|
|
68
|
+
export const RESCAN_MAX_AGE_MS = 24 * 60 * 60 * 1000; // any new data + a day old → rescan
|
|
69
|
+
|
|
70
|
+
// Categories → recommended subagent. Classification is behavior-first with
|
|
71
|
+
// weighted keyword scoring as fallback — see categorize().
|
|
72
|
+
const PASTE_MIN_LEN = 400;
|
|
73
|
+
const CATEGORIES = [
|
|
74
|
+
{
|
|
75
|
+
id: 'paste',
|
|
76
|
+
label: '붙여넣은 화면·로그 질문',
|
|
77
|
+
labelEn: 'questions about pasted screens/logs',
|
|
78
|
+
agent: 'haiku-explore',
|
|
79
|
+
kw: null, // matched by length, see categorize()
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: 'translate',
|
|
83
|
+
label: '배치 번역·정형 텍스트 변환',
|
|
84
|
+
labelEn: 'batch translation / mechanical text transforms',
|
|
85
|
+
agent: 'haiku-translate',
|
|
86
|
+
kw: [[/번역|translate/i, 2], [/변환해|표로 정리|포맷팅/i, 1]],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: 'explore',
|
|
90
|
+
label: '탐색·조회 (파일/값 찾기)',
|
|
91
|
+
labelEn: 'lookup (finding files/values)',
|
|
92
|
+
agent: 'haiku-explore',
|
|
93
|
+
kw: [[/grep|검색|search|find/i, 2], [/찾아|어디|위치|목록|살펴/i, 1]],
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: 'read',
|
|
97
|
+
label: '읽기·요약·설명',
|
|
98
|
+
labelEn: 'reading / summarizing / explaining',
|
|
99
|
+
agent: 'haiku-explore',
|
|
100
|
+
kw: [[/요약|summar|explain/i, 2], [/읽어|설명|정리해|보여줘|알려줘|뭐야|what/i, 1]],
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: 'check',
|
|
104
|
+
label: '상태 확인·검증',
|
|
105
|
+
labelEn: 'status checks / verification',
|
|
106
|
+
agent: 'haiku-explore',
|
|
107
|
+
kw: [[/확인|검증|verify|점검/i, 2], [/맞아\?|되나|됐나|됐어|되는지|괜찮|체크|check|status/i, 1]],
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: 'run',
|
|
111
|
+
label: '명령 실행 (빌드·테스트·git)',
|
|
112
|
+
labelEn: 'running commands (build/test/git)',
|
|
113
|
+
agent: 'haiku-runner',
|
|
114
|
+
kw: [[/git |commit|push|npm |pip|빌드해|빌드 돌/i, 2], [/실행|돌려|run |build|빌드|테스트|설치/i, 1]],
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
// ── Behavior signal (1st) — what the episode actually DID ────────────────
|
|
119
|
+
// The tool-call histogram is ground truth the prompt's wording is not:
|
|
120
|
+
// "테스트 통과했는지 확인해줘" that actually ran `npx playwright test` IS a
|
|
121
|
+
// run episode regardless of phrasing. Keyword scores only pick within (or,
|
|
122
|
+
// when behavior is inconclusive, across) the plausible pool.
|
|
123
|
+
const RUN_TOOLS = new Set(['Bash']);
|
|
124
|
+
const LOOKUP_TOOLS = new Set(['Read', 'Grep', 'Glob', 'LS', 'WebFetch', 'WebSearch']);
|
|
125
|
+
const WRITE_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit']);
|
|
126
|
+
const MIN_BEHAVIOR_CALLS = 2; // fewer calls than this → too little behavior to trust
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Narrow the candidate categories from the episode's tool mix.
|
|
130
|
+
* Returns { ids, fallback } — ids are the candidate categories, fallback is
|
|
131
|
+
* the id to use when keywords stay silent (null = keywords are REQUIRED, an
|
|
132
|
+
* id-less episode is not a delegation candidate). Returns null when behavior
|
|
133
|
+
* is inconclusive.
|
|
134
|
+
*/
|
|
135
|
+
export function behaviorPool(toolCounts) {
|
|
136
|
+
let run = 0, lookup = 0, write = 0, total = 0;
|
|
137
|
+
for (const [name, n] of Object.entries(toolCounts || {})) {
|
|
138
|
+
total += n;
|
|
139
|
+
if (RUN_TOOLS.has(name)) run += n;
|
|
140
|
+
else if (LOOKUP_TOOLS.has(name)) lookup += n;
|
|
141
|
+
else if (WRITE_TOOLS.has(name)) write += n;
|
|
142
|
+
}
|
|
143
|
+
if (total < MIN_BEHAVIOR_CALLS) return null;
|
|
144
|
+
if (run > lookup + write) return { ids: ['run'], fallback: 'run' };
|
|
145
|
+
if (lookup > run + write) return { ids: ['explore', 'read', 'check', 'translate'], fallback: 'explore' };
|
|
146
|
+
// Write-dominant episodes are EDITING work, not delegable lookups — without
|
|
147
|
+
// this they leak into read/explore via generic keywords ("설명이 필요해보이는데"
|
|
148
|
+
// + Edit×5 landed in read/T1). Only translate legitimately writes, so it is
|
|
149
|
+
// the sole candidate — and only with explicit translate keywords (no
|
|
150
|
+
// silent fallback: an editing episode is not a delegation candidate).
|
|
151
|
+
if (write > run + lookup) return { ids: ['translate'], fallback: null };
|
|
152
|
+
return null; // mixed — no reliable verdict, keywords decide
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Weighted keyword score for one category (0 when it has no kw table). */
|
|
156
|
+
function keywordScore(cat, text) {
|
|
157
|
+
if (!cat.kw) return 0;
|
|
158
|
+
let score = 0;
|
|
159
|
+
for (const [re, w] of cat.kw) if (re.test(text)) score += w;
|
|
160
|
+
return score;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Episodes that are not user-delegable requests: bare continuations, injected
|
|
164
|
+
// notifications, image pastes. These are easy but there is nothing to route.
|
|
165
|
+
const SKIP_RE = /^(계속|이어서|continue|다음|proceed|진행|응|네|넵|ok|okay|yes|ㄱ+|고고)\b/i;
|
|
166
|
+
const SKIP_PREFIX = ['<task-notification', '<system', '[Image:', '<local-command'];
|
|
167
|
+
|
|
168
|
+
// Single source of truth for the state dir (paths.js). A local copy used to
|
|
169
|
+
// live here and honored XDG_CONFIG_HOME on Linux only, so on macOS/Windows an
|
|
170
|
+
// XDG override split this cache away from config.json and the session cache.
|
|
171
|
+
const stateDir = userDataDir;
|
|
172
|
+
|
|
173
|
+
export function routeScanCachePath() {
|
|
174
|
+
return join(stateDir(), 'route-scan.json');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Munge an absolute path the way Claude Code names project dirs. */
|
|
178
|
+
export function mungeProjectPath(p) {
|
|
179
|
+
return String(p).replace(/[^a-zA-Z0-9-]/g, '-');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Classify an episode. Gates (paste by length) run first, then the tool-mix
|
|
184
|
+
* behavior signal narrows the candidate pool, then weighted keyword scores
|
|
185
|
+
* pick within it (highest score wins; ties fall back to CATEGORIES order).
|
|
186
|
+
* A behavior verdict without any keyword hit still classifies (pool's first
|
|
187
|
+
* id); no behavior AND no keyword hit → null (nothing delegable to name).
|
|
188
|
+
*/
|
|
189
|
+
export function categorize(text, toolCounts) {
|
|
190
|
+
if (text.length >= PASTE_MIN_LEN) return CATEGORIES.find((c) => c.id === 'paste');
|
|
191
|
+
const pool = behaviorPool(toolCounts);
|
|
192
|
+
const eligible = pool
|
|
193
|
+
? pool.ids.map((id) => CATEGORIES.find((c) => c.id === id))
|
|
194
|
+
: CATEGORIES.filter((c) => c.kw);
|
|
195
|
+
let best = null;
|
|
196
|
+
let bestScore = 0;
|
|
197
|
+
for (const c of eligible) {
|
|
198
|
+
const s = keywordScore(c, text);
|
|
199
|
+
if (s > bestScore) { best = c; bestScore = s; }
|
|
200
|
+
}
|
|
201
|
+
if (best) return best;
|
|
202
|
+
if (pool?.fallback) return CATEGORIES.find((c) => c.id === pool.fallback);
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isSkippable(text) {
|
|
207
|
+
if (!text) return true;
|
|
208
|
+
if (SKIP_RE.test(text.trim())) return true;
|
|
209
|
+
return SKIP_PREFIX.some((p) => text.startsWith(p));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Group a session's records into episodes (consecutive same trigger prompt). */
|
|
213
|
+
function toEpisodes(records) {
|
|
214
|
+
const episodes = [];
|
|
215
|
+
let cur = null;
|
|
216
|
+
for (const r of records) {
|
|
217
|
+
const text = (r.userText || '').trim();
|
|
218
|
+
if (!cur || cur.text !== text) {
|
|
219
|
+
cur = {
|
|
220
|
+
text, calls: 0, out: 0, mutating: 0, errors: 0, delegated: 0,
|
|
221
|
+
models: new Set(), cwd: '', tools: {},
|
|
222
|
+
// Delegation attribution (subagent-records): exact join key, plus the
|
|
223
|
+
// episode's time span for the fallback when a run has no meta file.
|
|
224
|
+
delegationToolUseIds: [], startedAt: null, endedAt: null,
|
|
225
|
+
};
|
|
226
|
+
episodes.push(cur);
|
|
227
|
+
}
|
|
228
|
+
cur.calls += 1;
|
|
229
|
+
cur.out += r.completion_tokens;
|
|
230
|
+
cur.mutating += r.mutatingToolCalls || 0;
|
|
231
|
+
cur.errors += r.toolErrors || 0;
|
|
232
|
+
cur.delegated += r.delegationCalls || 0;
|
|
233
|
+
for (const [name, n] of Object.entries(r.toolCounts || {})) {
|
|
234
|
+
cur.tools[name] = (cur.tools[name] || 0) + n;
|
|
235
|
+
}
|
|
236
|
+
for (const id of r.delegationToolUseIds || []) cur.delegationToolUseIds.push(id);
|
|
237
|
+
if (r.timestamp) {
|
|
238
|
+
const t = Date.parse(r.timestamp);
|
|
239
|
+
if (Number.isFinite(t)) {
|
|
240
|
+
if (cur.startedAt === null || t < cur.startedAt) cur.startedAt = t;
|
|
241
|
+
if (cur.endedAt === null || t > cur.endedAt) cur.endedAt = t;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
cur.models.add(r.model);
|
|
245
|
+
if (!cur.cwd && r.cwd) cur.cwd = r.cwd;
|
|
246
|
+
}
|
|
247
|
+
return episodes;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Price rank of the model that actually handled the episode (the most
|
|
252
|
+
* expensive one, when a session switched models mid-episode).
|
|
253
|
+
*/
|
|
254
|
+
export function episodeRank(ep) {
|
|
255
|
+
let rank = -1;
|
|
256
|
+
for (const m of ep.models) rank = Math.max(rank, modelRank(m));
|
|
257
|
+
return rank;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Delegation only pays when the target tier is strictly cheaper than what ran
|
|
262
|
+
* the work. Without this a Sonnet session produced "delegate to sonnet" T1
|
|
263
|
+
* rules — a subagent rebuilding context for zero price difference, which is a
|
|
264
|
+
* net loss. (Replaces the old boolean "is it haiku?" test, which could not
|
|
265
|
+
* tell a Sonnet session from a Fable one.)
|
|
266
|
+
*/
|
|
267
|
+
/**
|
|
268
|
+
* The model a category was mostly handled by, from a { model → episodes } map.
|
|
269
|
+
* Ties break toward the pricier model: with no majority either way, the more
|
|
270
|
+
* expensive reading of "what this used to cost" is the one worth stating.
|
|
271
|
+
* Returns null for an empty map, which callers read as "no baseline yet".
|
|
272
|
+
*/
|
|
273
|
+
export function dominantModel(counts) {
|
|
274
|
+
let best = null;
|
|
275
|
+
let bestN = 0;
|
|
276
|
+
for (const [model, n] of Object.entries(counts || {})) {
|
|
277
|
+
if (n > bestN || (n === bestN && best && modelRank(model) > modelRank(best))) {
|
|
278
|
+
best = model;
|
|
279
|
+
bestN = n;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return best;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// A delegated run counts as failed on error DENSITY, not on the presence of a
|
|
286
|
+
// single is_error. Binary counting scored a 218-call run that finished its task
|
|
287
|
+
// identically to one that died on its first call. The floor keeps very short
|
|
288
|
+
// runs honest: 1 error in 3 calls is still a failure.
|
|
289
|
+
export const DELEGATED_ERR_DENSITY = 0.1;
|
|
290
|
+
|
|
291
|
+
export function isFailedRun(run) {
|
|
292
|
+
if (!run || !(run.toolErrors > 0)) return false;
|
|
293
|
+
const calls = run.calls > 0 ? run.calls : 1;
|
|
294
|
+
return run.toolErrors / calls > DELEGATED_ERR_DENSITY;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function worthDelegating(tier, rank) {
|
|
298
|
+
const target = TIER_TARGET_RANK[tier];
|
|
299
|
+
return target !== undefined && rank > target;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* USD a delegated run saved versus the session model doing the same work.
|
|
304
|
+
* Approximation, deliberately stated as one: it holds token counts constant,
|
|
305
|
+
* which a cheaper model would not reproduce exactly. Directionally right and
|
|
306
|
+
* enough to rank rules by value, so it is rendered as "~$X".
|
|
307
|
+
*/
|
|
308
|
+
export function runSaving(run, mainModel) {
|
|
309
|
+
if (!run.model || !mainModel) return 0;
|
|
310
|
+
const totals = {
|
|
311
|
+
input: run.input,
|
|
312
|
+
cacheCreation: run.cacheCreation,
|
|
313
|
+
cacheRead: run.cacheRead,
|
|
314
|
+
ephemeral5m: run.ephemeral5m,
|
|
315
|
+
ephemeral1h: run.ephemeral1h,
|
|
316
|
+
output: run.out,
|
|
317
|
+
};
|
|
318
|
+
const actual = estimateCost(totals, run.model).actual;
|
|
319
|
+
const counterfactual = estimateCost(totals, mainModel).actual;
|
|
320
|
+
return Math.max(0, counterfactual - actual);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const clamp = (v, [lo, hi]) => Math.min(hi, Math.max(lo, v));
|
|
324
|
+
const percentile = (sorted, p) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Per-user output-token thresholds from this window's episode distribution.
|
|
328
|
+
* Falls back to mid-clamp defaults when the sample is too small to trust.
|
|
329
|
+
*/
|
|
330
|
+
export function calibrateThresholds(episodeOuts) {
|
|
331
|
+
if (episodeOuts.length < 100) return { t2Out: 1500, t1Out: 8000, calibrated: false };
|
|
332
|
+
const sorted = [...episodeOuts].sort((a, b) => a - b);
|
|
333
|
+
return {
|
|
334
|
+
t2Out: clamp(Math.max(percentile(sorted, 0.25), 1500), T2_OUT_CLAMP),
|
|
335
|
+
t1Out: clamp(percentile(sorted, 0.75), T1_OUT_CLAMP),
|
|
336
|
+
calibrated: true,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Tier classification (docs/TIER_CRITERIA.md §3). Returns 'T0'|'T1'|'T2',
|
|
342
|
+
* or null when the episode carries nothing delegable. Order matters: hard
|
|
343
|
+
* evidence (errors, heavy mutation, big output, judgement keywords) wins
|
|
344
|
+
* before any cheap-band check.
|
|
345
|
+
*/
|
|
346
|
+
export function tierOf(ep, category, th) {
|
|
347
|
+
if (ep.out < MIN_DELEGABLE_OUT) return null;
|
|
348
|
+
if (
|
|
349
|
+
ep.errors >= T0_MIN_ERRORS ||
|
|
350
|
+
ep.mutating >= T0_MIN_MUTATING ||
|
|
351
|
+
ep.out > th.t1Out ||
|
|
352
|
+
ESCALATE_RE.test(ep.text)
|
|
353
|
+
) return 'T0';
|
|
354
|
+
if (!category || ep.delegated > 0) return 'T0';
|
|
355
|
+
if (ep.calls <= T2_MAX_CALLS && ep.out <= th.t2Out && ep.mutating <= T2_MAX_MUTATING && ep.errors === 0) return 'T2';
|
|
356
|
+
if (ep.out <= th.t1Out && ep.mutating <= T1_MAX_MUTATING && ep.errors <= T1_MAX_ERRORS) return 'T1';
|
|
357
|
+
return 'T0';
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Scan transcripts and build delegation candidates.
|
|
362
|
+
* Returns the cache object (also written to disk).
|
|
363
|
+
*/
|
|
364
|
+
export async function runRouteScan({ days = 14 } = {}) {
|
|
365
|
+
const files = await discoverSessionFiles({ days });
|
|
366
|
+
|
|
367
|
+
// Behind a Bedrock/LiteLLM gateway the transcripts carry an inference-profile
|
|
368
|
+
// ARN where the model id belongs, which reads as Sonnet and rejects every T1
|
|
369
|
+
// rule. Refresh the profile→role mapping before parsing so this scan resolves
|
|
370
|
+
// those ids; on a direct-API machine it finds nothing and writes nothing.
|
|
371
|
+
resetModelAliasCache();
|
|
372
|
+
try {
|
|
373
|
+
await learnProfileMapping({ sessionPaths: files.map((f) => f.path) });
|
|
374
|
+
} catch { /* learning is an optimization — the scan still runs without it */ }
|
|
375
|
+
resetModelAliasCache();
|
|
376
|
+
|
|
377
|
+
// Pass 1 — collect episodes (needed up front: thresholds are calibrated
|
|
378
|
+
// from the full window's output distribution before any tiering).
|
|
379
|
+
const all = []; // { ep, projectDir, sessionPath }
|
|
380
|
+
let dataBytes = 0; // window size snapshot — the rescan gate diffs against it
|
|
381
|
+
const runIndexBySession = new Map(); // sessionPath → indexRuns() result
|
|
382
|
+
for (const f of files) {
|
|
383
|
+
let records;
|
|
384
|
+
try {
|
|
385
|
+
dataBytes += statSync(f.path).size;
|
|
386
|
+
records = await collectSessionRecords(f.path, { includeContent: true });
|
|
387
|
+
} catch {
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
for (const ep of toEpisodes(records)) {
|
|
391
|
+
if (!ep.text) continue;
|
|
392
|
+
all.push({ ep, projectDir: f.projectDir, sessionPath: f.path });
|
|
393
|
+
}
|
|
394
|
+
// Subagent transcripts of this session — the real outcome of every
|
|
395
|
+
// delegation it made. Best-effort: sessions that never delegated have no
|
|
396
|
+
// directory and cost one failed readdir.
|
|
397
|
+
const runs = await collectSubagentRuns(f.path);
|
|
398
|
+
if (runs.length > 0) {
|
|
399
|
+
runIndexBySession.set(f.path, indexRuns(runs));
|
|
400
|
+
// Subagent bytes count toward the window size so the rescan gate stays
|
|
401
|
+
// accurate for delegation-heavy workloads.
|
|
402
|
+
for (const r of runs) dataBytes += r.bytes || 0;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
const totalEpisodes = all.length;
|
|
406
|
+
const thresholds = calibrateThresholds(all.map((x) => x.ep.out));
|
|
407
|
+
|
|
408
|
+
// Pass 2 — tier, group by tier×category×project, and accumulate the
|
|
409
|
+
// per-category outcome stats that keep promoted model rules fresh.
|
|
410
|
+
const groups = new Map(); // "tier|category|project" → aggregate
|
|
411
|
+
const episodeStats = new Map(); // "category|project" (+ "category|*") → outcome stats
|
|
412
|
+
let tieredEpisodes = 0;
|
|
413
|
+
const bumpStats = (key, ep) => {
|
|
414
|
+
const s = episodeStats.get(key) || { count: 0, errCount: 0, epCount: 0, baselineModel: null, modelCounts: {} };
|
|
415
|
+
s.count += 1;
|
|
416
|
+
s.epCount += 1;
|
|
417
|
+
if (ep.errors > 0) s.errCount += 1;
|
|
418
|
+
// Baseline model: what actually handled this category BEFORE any rule sent
|
|
419
|
+
// it elsewhere. This is the only honest counterfactual for "routing saved
|
|
420
|
+
// money" — the session's priciest model is not, since it may never have
|
|
421
|
+
// touched work of this shape.
|
|
422
|
+
//
|
|
423
|
+
// Counted, not maxed. A transcript routinely carries more than one model
|
|
424
|
+
// (the user switches mid-session, a compaction pass runs elsewhere), and
|
|
425
|
+
// taking the priciest of them would let a single Fable record set the
|
|
426
|
+
// baseline for a category that Opus handled thirty times — inflating every
|
|
427
|
+
// later saving. The model that handled the most episodes is the one the
|
|
428
|
+
// rule actually replaced; price breaks a tie.
|
|
429
|
+
for (const m of ep.models) {
|
|
430
|
+
// Only ids the pricing table really recognizes may become a baseline —
|
|
431
|
+
// an unresolved gateway id or a house alias would be priced as Sonnet
|
|
432
|
+
// and quietly rewrite every saving computed against it.
|
|
433
|
+
if (!isRecognizedModelId(m)) continue;
|
|
434
|
+
s.modelCounts[m] = (s.modelCounts[m] || 0) + 1;
|
|
435
|
+
}
|
|
436
|
+
s.baselineModel = dominantModel(s.modelCounts);
|
|
437
|
+
episodeStats.set(key, s);
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
for (const { ep, projectDir } of all) {
|
|
441
|
+
if (isSkippable(ep.text)) continue;
|
|
442
|
+
const epRank = episodeRank(ep);
|
|
443
|
+
if (!worthDelegating('T2', epRank)) continue; // already at the cheapest tier
|
|
444
|
+
const cat = categorize(ep.text, ep.tools);
|
|
445
|
+
if (cat) {
|
|
446
|
+
// rule-health denominator: episodes that LOOK delegable by shape
|
|
447
|
+
// (tier judged with the error signal zeroed — using real errors here
|
|
448
|
+
// would be circular, since T2 requires errors=0 by definition). The
|
|
449
|
+
// numerator is those that still hit errors: exactly the "light-looking
|
|
450
|
+
// work in this category keeps failing" risk a delegation rule cares about.
|
|
451
|
+
// Keyed by tier as well: a category can carry both a T2 and a T1 rule,
|
|
452
|
+
// and sharing one category-wide stat would double-count every episode
|
|
453
|
+
// into both rules (identical ×N / err% on unrelated tiers).
|
|
454
|
+
const shapeTier = tierOf({ ...ep, errors: 0 }, cat, thresholds);
|
|
455
|
+
// Same rank gate as the candidate path below — a denominator counting
|
|
456
|
+
// episodes that can't produce a rule would skew that rule's error rate.
|
|
457
|
+
if ((shapeTier === 'T1' || shapeTier === 'T2') && worthDelegating(shapeTier, epRank)) {
|
|
458
|
+
bumpStats(`${shapeTier}|${cat.id}|${projectDir}`, ep);
|
|
459
|
+
bumpStats(`${shapeTier}|${cat.id}|*`, ep);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const tier = tierOf(ep, cat, thresholds);
|
|
463
|
+
if (tier !== 'T1' && tier !== 'T2') continue;
|
|
464
|
+
if (!worthDelegating(tier, epRank)) continue;
|
|
465
|
+
tieredEpisodes += 1;
|
|
466
|
+
const key = `${tier}|${cat.id}|${projectDir}`;
|
|
467
|
+
const g = groups.get(key) || {
|
|
468
|
+
tier,
|
|
469
|
+
category: cat.id,
|
|
470
|
+
label: cat.label,
|
|
471
|
+
labelEn: cat.labelEn,
|
|
472
|
+
agent: tier === 'T2' ? cat.agent : 'sonnet',
|
|
473
|
+
project: projectDir,
|
|
474
|
+
projectPath: '',
|
|
475
|
+
count: 0,
|
|
476
|
+
models: new Set(),
|
|
477
|
+
example: '',
|
|
478
|
+
};
|
|
479
|
+
g.count += 1;
|
|
480
|
+
for (const m of ep.models) g.models.add(m);
|
|
481
|
+
if (!g.projectPath && ep.cwd) g.projectPath = ep.cwd;
|
|
482
|
+
if (!g.example || (ep.text.length < g.example.length && ep.text.length > 10)) {
|
|
483
|
+
g.example = ep.text.slice(0, 80).replace(/\s+/g, ' ');
|
|
484
|
+
}
|
|
485
|
+
groups.set(key, g);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Pass 3 — measured delegation outcomes (rule-health v2). Episodes that
|
|
489
|
+
// DID delegate are excluded from tiering by design (tierOf returns T0 when
|
|
490
|
+
// ep.delegated > 0: there is nothing left to route). But they are exactly
|
|
491
|
+
// where a promoted rule's real success rate lives, so they get their own
|
|
492
|
+
// pass: join each episode to the subagent transcripts it spawned, and file
|
|
493
|
+
// the outcome under the tier that run's model represents — a haiku run is a
|
|
494
|
+
// T2 rule firing, a sonnet run a T1 one. Runs that were not a downgrade
|
|
495
|
+
// (same tier or higher) carry no delegation saving and are skipped.
|
|
496
|
+
const delegatedStats = new Map(); // "tier|category|project" → outcome aggregate
|
|
497
|
+
let unresolvedRuns = 0; // delegated runs dropped for an unpriceable model id
|
|
498
|
+
const unresolvedModels = new Set();
|
|
499
|
+
const bumpDelegated = (key, run, saved) => {
|
|
500
|
+
const d = delegatedStats.get(key) || { runs: 0, errRuns: 0, outTokens: 0, savedUsd: 0 };
|
|
501
|
+
d.runs += 1;
|
|
502
|
+
if (isFailedRun(run)) d.errRuns += 1;
|
|
503
|
+
d.outTokens += run.out || 0;
|
|
504
|
+
d.savedUsd += saved;
|
|
505
|
+
delegatedStats.set(key, d);
|
|
506
|
+
};
|
|
507
|
+
// Ledger events feed the statusline's weekly/monthly "Routing saved"
|
|
508
|
+
// totals. Keyed by run transcript path so overlapping re-scans upsert the
|
|
509
|
+
// same event instead of double-counting it.
|
|
510
|
+
//
|
|
511
|
+
// What counts as a routing saving is narrower than what counts for
|
|
512
|
+
// rule-health above. A saving is the price difference a REGISTERED RULE
|
|
513
|
+
// caused: the model that used to handle this category (the rule's baseline,
|
|
514
|
+
// learned from episodes an expensive model handled directly) versus the
|
|
515
|
+
// model the run actually used. Subagent runs that no rule covers —
|
|
516
|
+
// Explore, a hand-written agent, a plugin's own subagent — would have gone
|
|
517
|
+
// to the same cheap model with or without this tool, so attributing their
|
|
518
|
+
// savings here would credit the tool for work it did not route.
|
|
519
|
+
const ledgerEvents = [];
|
|
520
|
+
let ledgerRules = [];
|
|
521
|
+
try {
|
|
522
|
+
const { loadModelRules } = await import('./model-rules.js');
|
|
523
|
+
ledgerRules = loadModelRules().rules.filter((r) => r.status !== 'off');
|
|
524
|
+
} catch { /* no registry → no attributable savings, which is the honest zero */ }
|
|
525
|
+
// A rule's baseline: what it stored at promotion, else what this scan still
|
|
526
|
+
// observes handling the category directly (a rule promoted before baselines
|
|
527
|
+
// existed backfills on the next refresh).
|
|
528
|
+
const baselineFor = (rule, tier, catId, projectDir) => {
|
|
529
|
+
if (rule.baselineModel) return rule.baselineModel;
|
|
530
|
+
const s = episodeStats.get(`${tier}|${catId}|${projectDir}`)
|
|
531
|
+
|| (rule.scope === 'global' ? episodeStats.get(`${tier}|${catId}|*`) : null);
|
|
532
|
+
return s?.baselineModel || null;
|
|
533
|
+
};
|
|
534
|
+
const ruleForRun = (tier, catId, projectDir) => ledgerRules.find((r) =>
|
|
535
|
+
r.tier === tier && r.category === catId &&
|
|
536
|
+
(r.scope === 'global' || r.project === projectDir));
|
|
537
|
+
for (const [sessionPath, index] of runIndexBySession) {
|
|
538
|
+
const used = new Set();
|
|
539
|
+
const eps = all.filter((x) => x.sessionPath === sessionPath);
|
|
540
|
+
// Two passes over the session, not one per episode: every exact tool_use
|
|
541
|
+
// join is settled first, so the timestamp fallback can only ever claim a
|
|
542
|
+
// run that no episode was able to prove was its own.
|
|
543
|
+
const runsByEp = new Map();
|
|
544
|
+
for (const item of eps) runsByEp.set(item, exactRunsForEpisode(index, item.ep, used));
|
|
545
|
+
for (const item of eps) {
|
|
546
|
+
runsByEp.get(item).push(...fallbackRunsForEpisode(index, item.ep, used));
|
|
547
|
+
}
|
|
548
|
+
for (const item of eps) {
|
|
549
|
+
const { ep, projectDir } = item;
|
|
550
|
+
const runs = runsByEp.get(item);
|
|
551
|
+
if (runs.length === 0) continue;
|
|
552
|
+
const cat = categorize(ep.text, ep.tools);
|
|
553
|
+
if (!cat) continue;
|
|
554
|
+
// Counterfactual = the priciest model on the episode, i.e. what would
|
|
555
|
+
// have done the work had it not been handed off.
|
|
556
|
+
let mainModel = null;
|
|
557
|
+
let mainRank = -1;
|
|
558
|
+
for (const m of ep.models) {
|
|
559
|
+
const r = modelRank(m);
|
|
560
|
+
if (r > mainRank) { mainRank = r; mainModel = m; }
|
|
561
|
+
}
|
|
562
|
+
for (const run of runs) {
|
|
563
|
+
const runTier = tierForRank(modelRank(run.model));
|
|
564
|
+
// Dropping unresolved model ids is deliberate (see cost.js) — pricing
|
|
565
|
+
// a gateway id as Sonnet would poison every number here. But dropping
|
|
566
|
+
// them SILENTLY is what made a whole tier of rules report zero
|
|
567
|
+
// delegations with no way to tell why, so keep a count to surface.
|
|
568
|
+
if (!isRecognizedModelId(run.model)) {
|
|
569
|
+
unresolvedRuns += 1;
|
|
570
|
+
if (run.model) unresolvedModels.add(run.model);
|
|
571
|
+
}
|
|
572
|
+
if (!runTier || !worthDelegating(runTier, mainRank)) continue;
|
|
573
|
+
const saved = runSaving(run, mainModel);
|
|
574
|
+
bumpDelegated(`${runTier}|${cat.id}|${projectDir}`, run, saved);
|
|
575
|
+
bumpDelegated(`${runTier}|${cat.id}|*`, run, saved);
|
|
576
|
+
// Routing saving: priced against the rule's baseline (before → after),
|
|
577
|
+
// not against whatever the session's priciest model happened to be.
|
|
578
|
+
const rule = ruleForRun(runTier, cat.id, projectDir);
|
|
579
|
+
if (!rule) continue; // no rule routed this run — not our saving to claim
|
|
580
|
+
// Priced below, after this scan's baselines have been written back to
|
|
581
|
+
// the registry — see the note at the ledger write.
|
|
582
|
+
if (!isRecognizedModelId(run.model)) continue;
|
|
583
|
+
ledgerEvents.push({ run, rule, tier: runTier, catId: cat.id, projectDir });
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Keep prior dismissed/promoted signatures across rescans.
|
|
589
|
+
const prev = readRouteScan();
|
|
590
|
+
const resolved = new Set(prev?.resolved || []);
|
|
591
|
+
|
|
592
|
+
// Never re-propose a pattern that already has a registered model-fitting
|
|
593
|
+
// rule (a global rule covers the category in every project). Without this,
|
|
594
|
+
// rules that entered the registry outside the promote flow — migrations,
|
|
595
|
+
// future imports — would resurface as candidates forever.
|
|
596
|
+
let registered = [];
|
|
597
|
+
try {
|
|
598
|
+
const { loadModelRules } = await import('./model-rules.js');
|
|
599
|
+
registered = loadModelRules().rules;
|
|
600
|
+
} catch { /* registry unreadable — candidates may repeat until promote */ }
|
|
601
|
+
const hasRule = (g) => registered.some((r) =>
|
|
602
|
+
r.tier === g.tier && r.category === g.category &&
|
|
603
|
+
(r.scope === 'global' || r.project === g.project));
|
|
604
|
+
|
|
605
|
+
// Both languages are computed at scan time and stored on the candidate, so
|
|
606
|
+
// switching `language` later re-renders (and promotes) correctly without
|
|
607
|
+
// waiting for a rescan.
|
|
608
|
+
// The probe-then-commit budget is deliberately NOT baked into this text:
|
|
609
|
+
// model-rules composes it on (composeRuleText), so rules promoted before
|
|
610
|
+
// budgets existed gain the clause too, and the promote preview can never
|
|
611
|
+
// drift from what lands in the file. What the candidate carries is the
|
|
612
|
+
// calibrated budget itself — the user's own thresholds, snapshotted at scan
|
|
613
|
+
// time rather than hardcoded downstream.
|
|
614
|
+
const budgetOf = (g) => ({
|
|
615
|
+
calls: g.tier === 'T2' ? T2_MAX_CALLS + 2 : null,
|
|
616
|
+
out: g.tier === 'T2' ? thresholds.t2Out : thresholds.t1Out,
|
|
617
|
+
});
|
|
618
|
+
// Shared with the seed presets (src/seed-rules.js) through model-rules.js —
|
|
619
|
+
// one template, so a reworded rule cannot mean two different things
|
|
620
|
+
// depending on which producer wrote it.
|
|
621
|
+
const ruleText = (g) => modelRuleBaseText(g, 'ko');
|
|
622
|
+
const ruleTextEn = (g) => modelRuleBaseText(g, 'en');
|
|
623
|
+
|
|
624
|
+
const candidates = [...groups.values()]
|
|
625
|
+
.filter((g) => g.count >= MIN_RECURRENCE && !hasRule(g))
|
|
626
|
+
.sort((a, b) => b.count - a.count)
|
|
627
|
+
.slice(0, 8)
|
|
628
|
+
.map((g, i) => ({
|
|
629
|
+
id: i + 1,
|
|
630
|
+
signature: `${g.tier}|${g.category}|${g.project}`,
|
|
631
|
+
tier: g.tier,
|
|
632
|
+
category: g.category,
|
|
633
|
+
label: g.label,
|
|
634
|
+
labelEn: g.labelEn,
|
|
635
|
+
agent: g.agent,
|
|
636
|
+
project: g.project,
|
|
637
|
+
// Real session cwd for the project (munged `project` is lossy) — lets
|
|
638
|
+
// `harness promote R<N> --project` write the rule into the project the
|
|
639
|
+
// pattern was detected in, not whatever directory the CLI runs from.
|
|
640
|
+
projectPath: g.projectPath || null,
|
|
641
|
+
count: g.count,
|
|
642
|
+
models: [...g.models],
|
|
643
|
+
example: g.example,
|
|
644
|
+
// Snapshot of the calibrated budget this rule was written against, so
|
|
645
|
+
// the merged T2+T1 rendering in ratchet-model.md can restate it without
|
|
646
|
+
// re-running a scan.
|
|
647
|
+
budget: budgetOf(g),
|
|
648
|
+
// Concentrated in one project dir → project rule; the scan groups by
|
|
649
|
+
// project already, so scope suggestion is per-candidate 'project' unless
|
|
650
|
+
// the same category recurs across 2+ projects (then 'global').
|
|
651
|
+
suggestedScope: 'project',
|
|
652
|
+
rule: ruleText(g),
|
|
653
|
+
ruleEn: ruleTextEn(g),
|
|
654
|
+
}));
|
|
655
|
+
|
|
656
|
+
// Same category appearing in 2+ projects → suggest global for each.
|
|
657
|
+
const catProjects = new Map();
|
|
658
|
+
for (const c of candidates) {
|
|
659
|
+
catProjects.set(c.category, (catProjects.get(c.category) || 0) + 1);
|
|
660
|
+
}
|
|
661
|
+
for (const c of candidates) {
|
|
662
|
+
if ((catProjects.get(c.category) || 0) >= 2) c.suggestedScope = 'global';
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const cache = {
|
|
666
|
+
scannedAt: new Date().toISOString(),
|
|
667
|
+
days,
|
|
668
|
+
totalEpisodes,
|
|
669
|
+
dataBytes,
|
|
670
|
+
// kept as `easyEpisodes` for statusline/back-compat; now counts T1+T2.
|
|
671
|
+
easyEpisodes: tieredEpisodes,
|
|
672
|
+
thresholds,
|
|
673
|
+
candidates,
|
|
674
|
+
resolved: [...resolved],
|
|
675
|
+
unresolvedRuns,
|
|
676
|
+
unresolvedModels: [...unresolvedModels].slice(0, 5),
|
|
677
|
+
};
|
|
678
|
+
try {
|
|
679
|
+
const dir = stateDir();
|
|
680
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
681
|
+
writeFileSync(routeScanCachePath(), JSON.stringify(cache, null, 2) + '\n');
|
|
682
|
+
} catch {
|
|
683
|
+
// best-effort — scan results are still returned
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Continuous update (user requirement): every rescan refreshes promoted
|
|
687
|
+
// model-fitting rules from the new window — recurrence counts, error
|
|
688
|
+
// rates, and rule-health flags — and rewrites their managed blocks.
|
|
689
|
+
try {
|
|
690
|
+
const { refreshModelRules } = await import('./model-rules.js');
|
|
691
|
+
refreshModelRules(episodeStats, delegatedStats, { now: cache.scannedAt });
|
|
692
|
+
} catch { /* registry unwritable — scan result still valid */ }
|
|
693
|
+
|
|
694
|
+
// Ledger last, so savings are priced against the baselines this scan just
|
|
695
|
+
// wrote. Pricing before the refresh made a changed baseline take two scans
|
|
696
|
+
// to show up: the first wrote the new baseline but billed against the old
|
|
697
|
+
// one, and the totals only settled on the second.
|
|
698
|
+
try {
|
|
699
|
+
const { recordDelegationEvents } = await import('./savings-ledger.js');
|
|
700
|
+
const { loadModelRules } = await import('./model-rules.js');
|
|
701
|
+
const fresh = loadModelRules().rules;
|
|
702
|
+
const priced = [];
|
|
703
|
+
for (const e of ledgerEvents) {
|
|
704
|
+
const rule = fresh.find((r) => r.signature === e.rule.signature) || e.rule;
|
|
705
|
+
const baseline = baselineFor(rule, e.tier, e.catId, e.projectDir);
|
|
706
|
+
// Both sides of the comparison must be ids the pricing table really
|
|
707
|
+
// recognizes. A house alias from a company gateway prices as Sonnet by
|
|
708
|
+
// default, which would fabricate a saving against a cheap baseline or
|
|
709
|
+
// erase a real one — worse than showing nothing. Map such ids in
|
|
710
|
+
// profile-map.json's `modelAliases` to bring these runs back in.
|
|
711
|
+
if (!baseline || !isRecognizedModelId(baseline)) continue;
|
|
712
|
+
const usd = runSaving(e.run, baseline);
|
|
713
|
+
if (usd <= 0) continue;
|
|
714
|
+
priced.push({
|
|
715
|
+
key: e.run.path,
|
|
716
|
+
ts: e.run.endedAt ?? e.run.startedAt ?? Date.now(),
|
|
717
|
+
usd,
|
|
718
|
+
rule: rule.signature,
|
|
719
|
+
from: baseline,
|
|
720
|
+
to: e.run.model,
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
recordDelegationEvents(priced);
|
|
724
|
+
} catch {
|
|
725
|
+
// ledger write failure only delays the statusline totals, never the scan
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
return cache;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** Read the cached scan (null when absent/corrupt). */
|
|
732
|
+
export function readRouteScan() {
|
|
733
|
+
try {
|
|
734
|
+
return JSON.parse(readFileSync(routeScanCachePath(), 'utf8'));
|
|
735
|
+
} catch {
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Data-driven rescan gate (see constants above). Cheap: one stat() per
|
|
742
|
+
* transcript file (~32 files on measured data) — a few milliseconds.
|
|
743
|
+
*/
|
|
744
|
+
export async function shouldRescan(cache, { days = 14 } = {}) {
|
|
745
|
+
if (!cache?.scannedAt) return true;
|
|
746
|
+
const ts = Date.parse(cache.scannedAt);
|
|
747
|
+
if (!Number.isFinite(ts)) return true;
|
|
748
|
+
const age = Date.now() - ts;
|
|
749
|
+
if (age < RESCAN_MIN_INTERVAL_MS) return false;
|
|
750
|
+
|
|
751
|
+
let total = 0;
|
|
752
|
+
let anyNew = false;
|
|
753
|
+
try {
|
|
754
|
+
for (const f of await discoverSessionFiles({ days })) {
|
|
755
|
+
const s = statSync(f.path);
|
|
756
|
+
total += s.size;
|
|
757
|
+
if (s.mtimeMs > ts) anyNew = true;
|
|
758
|
+
}
|
|
759
|
+
} catch {
|
|
760
|
+
return age >= RESCAN_MAX_AGE_MS; // can't stat — degrade to daily
|
|
761
|
+
}
|
|
762
|
+
if (!anyNew) return false; // nothing changed → identical scan, skip forever
|
|
763
|
+
// Append-only transcripts: window growth ≈ new data. Files aging out of
|
|
764
|
+
// the window shrink the total, making this estimate conservative.
|
|
765
|
+
const newBytes = Math.max(0, total - (cache.dataBytes || 0));
|
|
766
|
+
if (newBytes >= RESCAN_BIG_DELTA_BYTES) return true;
|
|
767
|
+
return age >= RESCAN_MAX_AGE_MS;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Human-readable labels for the T0/T1/T2 codes and scope values — used by
|
|
772
|
+
* every user-facing listing so a bare code never appears without its meaning
|
|
773
|
+
* (first-time users can't be expected to know the tier vocabulary).
|
|
774
|
+
*/
|
|
775
|
+
export function tierLabel(tier, lang = 'ko') {
|
|
776
|
+
const ko = {
|
|
777
|
+
T2: '단순 작업 — haiku급이면 충분',
|
|
778
|
+
T1: '중간 난도 — sonnet급이면 충분',
|
|
779
|
+
T0: '고난도 — 지금 모델 유지',
|
|
780
|
+
};
|
|
781
|
+
const en = {
|
|
782
|
+
T2: 'simple — haiku-class is enough',
|
|
783
|
+
T1: 'moderate — sonnet-class is enough',
|
|
784
|
+
T0: 'hard — stays on the session model',
|
|
785
|
+
};
|
|
786
|
+
return (lang === 'ko' ? ko : en)[tier] || tier;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
export function scopeLabel(scope, lang = 'ko') {
|
|
790
|
+
if (lang === 'ko') return scope === 'global' ? '모든 프로젝트(글로벌)' : '이 프로젝트만';
|
|
791
|
+
return scope === 'global' ? 'all projects (global)' : 'this project only';
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** Candidates not yet promoted/dismissed. */
|
|
795
|
+
export function openCandidates(cache) {
|
|
796
|
+
if (!cache?.candidates) return [];
|
|
797
|
+
const resolved = new Set(cache.resolved || []);
|
|
798
|
+
return cache.candidates.filter((c) => !resolved.has(c.signature));
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Mark a candidate resolved (promoted or dismissed) so the chip stops and
|
|
803
|
+
* rescans don't resurface it. Returns the candidate or null.
|
|
804
|
+
*/
|
|
805
|
+
export function resolveCandidate(id) {
|
|
806
|
+
const cache = readRouteScan();
|
|
807
|
+
if (!cache) return null;
|
|
808
|
+
const cand = (cache.candidates || []).find((c) => c.id === id);
|
|
809
|
+
if (!cand) return null;
|
|
810
|
+
cache.resolved = [...new Set([...(cache.resolved || []), cand.signature])];
|
|
811
|
+
try {
|
|
812
|
+
writeFileSync(routeScanCachePath(), JSON.stringify(cache, null, 2) + '\n');
|
|
813
|
+
} catch {
|
|
814
|
+
return null;
|
|
815
|
+
}
|
|
816
|
+
return cand;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Statusline helper — cheapest possible check (one small JSON read).
|
|
821
|
+
* Returns `route? #N` for the top open candidate relevant to this project
|
|
822
|
+
* (its own project dir, or a global-scoped suggestion), else null.
|
|
823
|
+
*/
|
|
824
|
+
export function routeWarningForStatusline(projectRoot) {
|
|
825
|
+
const cache = readRouteScan();
|
|
826
|
+
if (!cache) return null;
|
|
827
|
+
const open = openCandidates(cache);
|
|
828
|
+
if (open.length === 0) return null;
|
|
829
|
+
const munged = mungeProjectPath(projectRoot || '');
|
|
830
|
+
const hit = open.find((c) => c.project === munged || c.suggestedScope === 'global');
|
|
831
|
+
return hit ? `route? R${hit.id}` : null;
|
|
832
|
+
}
|