claude-token-saver 3.0.0 → 3.2.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.en.md +37 -25
- package/README.md +37 -25
- package/bin/cli.js +102 -110
- package/package.json +2 -1
- package/presets/ratchet-rules.md +16 -0
- package/src/harness-templates.js +4 -0
- package/src/harness.js +38 -58
- package/src/model-rules.js +174 -0
- package/src/route-scan.js +206 -58
- package/src/session-records.js +112 -0
- package/src/frugon-export.js +0 -211
package/src/harness.js
CHANGED
|
@@ -278,79 +278,58 @@ export function harnessPromote(ruleText, { root = findProjectRoot(), scope = 'pr
|
|
|
278
278
|
}
|
|
279
279
|
|
|
280
280
|
/**
|
|
281
|
-
* harness pull —
|
|
282
|
-
* into the
|
|
283
|
-
* install/init never auto-injects rules; this is the explicit "땡겨오기" verb.
|
|
281
|
+
* harness pull — register the CURATED ratchet rules bundled with this package
|
|
282
|
+
* (presets/ratchet-rules.md) into the user's ratchet, global by default.
|
|
284
283
|
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
284
|
+
* Rationale: a project ratchet already inherits the global one (global is the
|
|
285
|
+
* upper layer of the hierarchy), so there is nothing to copy between the
|
|
286
|
+
* user's own scopes. What CAN'T reach the user any other way is the package
|
|
287
|
+
* author's field-tested rules — pull ships those, strictly opt-in:
|
|
288
|
+
* install/init never auto-injects anything.
|
|
289
289
|
*
|
|
290
|
-
*
|
|
290
|
+
* Deduped by rule text (ignoring the YYYY-MM-DD stamp) — idempotent.
|
|
291
|
+
* Returns { path, scope, added, skippedRules, presets }.
|
|
291
292
|
*/
|
|
292
|
-
export function harnessPull({ root = findProjectRoot(),
|
|
293
|
-
const
|
|
293
|
+
export function harnessPull({ root = findProjectRoot(), scope = 'global' } = {}) {
|
|
294
|
+
const presets = presetRules();
|
|
295
|
+
const rmPath = resolveRatchetPath(scope, root);
|
|
296
|
+
const result = { path: rmPath, scope, added: [], skippedRules: 0, presets: presets.length };
|
|
294
297
|
const stripDate = (t) => t.replace(/^\d{4}-\d{2}-\d{2}:\s*/, '').trim();
|
|
295
298
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const projPath = ratchetMdPath(root);
|
|
299
|
-
let content = existsSync(projPath)
|
|
300
|
-
? readFileSync(projPath, 'utf8')
|
|
299
|
+
let content = existsSync(rmPath)
|
|
300
|
+
? readFileSync(rmPath, 'utf8')
|
|
301
301
|
: harnessRatchetMdInitial();
|
|
302
302
|
const have = new Set(
|
|
303
|
-
harnessListRules({ root, scope
|
|
303
|
+
harnessListRules({ root, scope }).rules.map((r) => stripDate(r.text)),
|
|
304
304
|
);
|
|
305
|
-
for (const
|
|
306
|
-
|
|
307
|
-
if (have.has(key)) {
|
|
305
|
+
for (const rule of presets) {
|
|
306
|
+
if (have.has(rule)) {
|
|
308
307
|
result.skippedRules += 1;
|
|
309
308
|
continue;
|
|
310
309
|
}
|
|
311
|
-
content = appendRatchetRule(content,
|
|
312
|
-
have.add(
|
|
313
|
-
result.added.push(
|
|
310
|
+
content = appendRatchetRule(content, rule);
|
|
311
|
+
have.add(rule);
|
|
312
|
+
result.added.push(rule);
|
|
314
313
|
}
|
|
315
314
|
if (result.added.length) {
|
|
316
|
-
mkdirSync(dirname(
|
|
317
|
-
writeFileSync(
|
|
318
|
-
result.wrote.push(projPath);
|
|
315
|
+
mkdirSync(dirname(rmPath), { recursive: true });
|
|
316
|
+
writeFileSync(rmPath, content);
|
|
319
317
|
}
|
|
318
|
+
return result;
|
|
319
|
+
}
|
|
320
320
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
result.skipped.push(`${gPath} (no global harness block to pull)`);
|
|
333
|
-
} else {
|
|
334
|
-
const block = m[0];
|
|
335
|
-
const pPath = claudeMdPath(root);
|
|
336
|
-
if (existsSync(pPath)) {
|
|
337
|
-
const pc = readFileSync(pPath, 'utf8');
|
|
338
|
-
if (pc.includes(HARNESS_BLOCK_BEGIN)) {
|
|
339
|
-
writeFileSync(pPath, pc.replace(blockRe, block));
|
|
340
|
-
result.wrote.push(`${pPath} (harness block replaced with global copy)`);
|
|
341
|
-
} else {
|
|
342
|
-
const sep = pc.endsWith('\n') ? '\n' : '\n\n';
|
|
343
|
-
writeFileSync(pPath, pc + sep + block);
|
|
344
|
-
result.wrote.push(`${pPath} (harness block appended from global)`);
|
|
345
|
-
}
|
|
346
|
-
} else {
|
|
347
|
-
writeFileSync(pPath, block);
|
|
348
|
-
result.wrote.push(pPath);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
321
|
+
/** Parse the bundled preset rules (markdown bullets under presets/). */
|
|
322
|
+
export function presetRules() {
|
|
323
|
+
try {
|
|
324
|
+
const path = join(dirname(new URL(import.meta.url).pathname), '..', 'presets', 'ratchet-rules.md');
|
|
325
|
+
return readFileSync(path, 'utf8')
|
|
326
|
+
.split('\n')
|
|
327
|
+
.filter((l) => /^\s*-\s+/.test(l))
|
|
328
|
+
.map((l) => l.replace(/^\s*-\s+/, '').trim())
|
|
329
|
+
.filter(Boolean);
|
|
330
|
+
} catch {
|
|
331
|
+
return [];
|
|
351
332
|
}
|
|
352
|
-
|
|
353
|
-
return result;
|
|
354
333
|
}
|
|
355
334
|
|
|
356
335
|
/**
|
|
@@ -365,7 +344,8 @@ export function harnessListRules({ root = findProjectRoot(), scope = 'project' }
|
|
|
365
344
|
for (let i = 0; i < lines.length; i++) {
|
|
366
345
|
const line = lines[i];
|
|
367
346
|
// A "rule line" starts with "- " (markdown bullet). Header lines, blanks,
|
|
368
|
-
// and the "## Rules" anchor are ignored.
|
|
347
|
+
// and the "## Rules" anchor are ignored. (Model-fitting rules live in a
|
|
348
|
+
// separate tool-owned file, ratchet-model.md — never listed here.)
|
|
369
349
|
if (/^\s*-\s+/.test(line)) {
|
|
370
350
|
rules.push({ index: rules.length + 1, lineNo: i, text: line.replace(/^\s*-\s+/, '') });
|
|
371
351
|
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model-rules — the MODEL-FITTING ratchet registry.
|
|
3
|
+
*
|
|
4
|
+
* Model-fitting rules (tier-delegation rules promoted from route-scan) are
|
|
5
|
+
* managed SEPARATELY from user-authored ratchet rules, for two reasons the
|
|
6
|
+
* user set as requirements:
|
|
7
|
+
* 1. They must never tangle with hand-written rules — so they live in a
|
|
8
|
+
* fully tool-owned FILE (ratchet-model.md) next to ratchet.md,
|
|
9
|
+
* regenerated wholesale. A separate file (rather than a managed block
|
|
10
|
+
* inside ratchet.md) keeps auto-refresh churn out of the user's file:
|
|
11
|
+
* per-scan stat updates only ever touch ratchet-model.md, which can be
|
|
12
|
+
* gitignored, and there are no block markers a hand edit could corrupt.
|
|
13
|
+
* 2. They must keep updating from subsequent logs — recurrence counts and
|
|
14
|
+
* post-promotion error rates are refreshed on every route-scan, and a
|
|
15
|
+
* rule whose delegated episodes start failing gets flagged for review
|
|
16
|
+
* (rule-health, per docs/TIER_CRITERIA.md).
|
|
17
|
+
*
|
|
18
|
+
* Registry file (source of truth): <stateDir>/model-rules.json
|
|
19
|
+
* { rules: [ { signature, tier, category, label, agent, scope, // 'project'|'global'
|
|
20
|
+
* targetRoot, // project root path (project scope)
|
|
21
|
+
* rule, example, count, errRate, promotedAt, lastSeen,
|
|
22
|
+
* status } ] } // 'active' | 'review'
|
|
23
|
+
*
|
|
24
|
+
* Rendered files (regenerated from the registry, never edited in place):
|
|
25
|
+
* project scope → <root>/.claude/ratchet-model.md
|
|
26
|
+
* global scope → ~/.claude/ratchet-model.md
|
|
27
|
+
* The harness CLAUDE.md block points Claude at these files alongside
|
|
28
|
+
* ratchet.md.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
32
|
+
import { join, dirname } from 'node:path';
|
|
33
|
+
import { homedir } from 'node:os';
|
|
34
|
+
|
|
35
|
+
// Post-promotion delegated-category error rate above this flags the rule
|
|
36
|
+
// for review (rule-health). Calibrated against local T0 avg error incidence.
|
|
37
|
+
export const HEALTH_ERR_RATE = 0.2;
|
|
38
|
+
|
|
39
|
+
function stateDir() {
|
|
40
|
+
if (process.platform === 'win32') {
|
|
41
|
+
return join(process.env.APPDATA || homedir(), 'claude-token-saver');
|
|
42
|
+
}
|
|
43
|
+
if (process.platform === 'darwin') {
|
|
44
|
+
return join(homedir(), 'Library', 'Application Support', 'claude-token-saver');
|
|
45
|
+
}
|
|
46
|
+
const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
47
|
+
return join(xdg, 'claude-token-saver');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function modelRulesPath() {
|
|
51
|
+
return join(stateDir(), 'model-rules.json');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function loadModelRules() {
|
|
55
|
+
try {
|
|
56
|
+
const data = JSON.parse(readFileSync(modelRulesPath(), 'utf8'));
|
|
57
|
+
return Array.isArray(data.rules) ? data : { rules: [] };
|
|
58
|
+
} catch {
|
|
59
|
+
return { rules: [] };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function saveModelRules(data) {
|
|
64
|
+
const dir = stateDir();
|
|
65
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
66
|
+
writeFileSync(modelRulesPath(), JSON.stringify(data, null, 2) + '\n');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Add (or re-activate) a promoted rule; returns the stored entry. */
|
|
70
|
+
export function addModelRule(entry) {
|
|
71
|
+
const data = loadModelRules();
|
|
72
|
+
const existing = data.rules.find((r) => r.signature === entry.signature && r.scope === entry.scope);
|
|
73
|
+
if (existing) {
|
|
74
|
+
Object.assign(existing, entry, { status: 'active' });
|
|
75
|
+
saveModelRules(data);
|
|
76
|
+
return existing;
|
|
77
|
+
}
|
|
78
|
+
const stored = { status: 'active', errRate: 0, ...entry };
|
|
79
|
+
data.rules.push(stored);
|
|
80
|
+
saveModelRules(data);
|
|
81
|
+
return stored;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function removeModelRule(index1) {
|
|
85
|
+
const data = loadModelRules();
|
|
86
|
+
if (index1 < 1 || index1 > data.rules.length) return null;
|
|
87
|
+
const [removed] = data.rules.splice(index1 - 1, 1);
|
|
88
|
+
saveModelRules(data);
|
|
89
|
+
return removed;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Render the full ratchet-model.md for one target (scope+root). */
|
|
93
|
+
export function renderModelRatchet(rules) {
|
|
94
|
+
const lines = [
|
|
95
|
+
'# Model-Fitting Ratchet (claude-token-saver 자동 관리)',
|
|
96
|
+
'',
|
|
97
|
+
'로그 기반 티어 위임 룰. 이 파일은 route-scan이 매 스캔마다 통째로 재생성하므로',
|
|
98
|
+
'직접 수정하지 마세요 — 목록/제거: `claude-token-saver route-scan rules [rm <N>]`.',
|
|
99
|
+
'',
|
|
100
|
+
'## Rules',
|
|
101
|
+
'',
|
|
102
|
+
];
|
|
103
|
+
for (const r of rules) {
|
|
104
|
+
const health = r.status === 'review'
|
|
105
|
+
? ` ⚠ rule-health: 최근 위임 대상 에러율 ${Math.round((r.errRate || 0) * 100)}% — 조건을 좁히거나 제거 검토`
|
|
106
|
+
: '';
|
|
107
|
+
const stats = ` <!-- ×${r.count || 0}, err ${Math.round((r.errRate || 0) * 100)}%, seen ${r.lastSeen || r.promotedAt} -->`;
|
|
108
|
+
lines.push(`- ${r.rule}${health}${stats}`);
|
|
109
|
+
}
|
|
110
|
+
return lines.join('\n') + '\n';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function modelRatchetPathFor(scope, targetRoot) {
|
|
114
|
+
return scope === 'global'
|
|
115
|
+
? join(homedir(), '.claude', 'ratchet-model.md')
|
|
116
|
+
: join(targetRoot, '.claude', 'ratchet-model.md');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Regenerate ratchet-model.md for every target that carries model rules.
|
|
121
|
+
* A target whose rules are all gone gets its file removed (it's fully
|
|
122
|
+
* tool-owned, so deletion is safe).
|
|
123
|
+
*/
|
|
124
|
+
export function syncAllFiles({ previousPaths = [] } = {}) {
|
|
125
|
+
const data = loadModelRules();
|
|
126
|
+
const byPath = new Map();
|
|
127
|
+
for (const r of data.rules) {
|
|
128
|
+
const p = modelRatchetPathFor(r.scope, r.targetRoot);
|
|
129
|
+
if (!byPath.has(p)) byPath.set(p, []);
|
|
130
|
+
byPath.get(p).push(r);
|
|
131
|
+
}
|
|
132
|
+
const written = [];
|
|
133
|
+
for (const [p, rules] of byPath) {
|
|
134
|
+
try {
|
|
135
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
136
|
+
writeFileSync(p, renderModelRatchet(rules));
|
|
137
|
+
written.push(p);
|
|
138
|
+
} catch { /* unwritable target — skip, registry stays authoritative */ }
|
|
139
|
+
}
|
|
140
|
+
for (const p of previousPaths) {
|
|
141
|
+
if (!byPath.has(p) && existsSync(p)) {
|
|
142
|
+
try { unlinkSync(p); } catch { /* leave stale file; regenerated next sync */ }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return written;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Continuous update from logs (route-scan calls this on every refresh):
|
|
150
|
+
* for each registered rule, recompute recurrence count and the error rate
|
|
151
|
+
* of episodes in its (tier-eligible) category — the rule-health signal.
|
|
152
|
+
*
|
|
153
|
+
* `episodeStats`: Map "category|project" → { count, errCount, epCount }
|
|
154
|
+
* where errCount/epCount measure post-promotion delegated-category episodes.
|
|
155
|
+
*/
|
|
156
|
+
export function refreshModelRules(episodeStats, { now } = {}) {
|
|
157
|
+
const data = loadModelRules();
|
|
158
|
+
let changed = false;
|
|
159
|
+
for (const r of data.rules) {
|
|
160
|
+
const s = episodeStats.get(`${r.category}|${r.project}`)
|
|
161
|
+
|| (r.scope === 'global' ? episodeStats.get(`${r.category}|*`) : null);
|
|
162
|
+
if (!s) continue;
|
|
163
|
+
r.count = s.count;
|
|
164
|
+
r.errRate = s.epCount > 0 ? s.errCount / s.epCount : 0;
|
|
165
|
+
r.lastSeen = now || r.lastSeen;
|
|
166
|
+
r.status = r.errRate > HEALTH_ERR_RATE ? 'review' : 'active';
|
|
167
|
+
changed = true;
|
|
168
|
+
}
|
|
169
|
+
if (changed) {
|
|
170
|
+
saveModelRules(data);
|
|
171
|
+
syncAllFiles();
|
|
172
|
+
}
|
|
173
|
+
return data;
|
|
174
|
+
}
|
package/src/route-scan.js
CHANGED
|
@@ -2,38 +2,74 @@
|
|
|
2
2
|
* route-scan — detect recurring "easy" work running on expensive models and
|
|
3
3
|
* propose model-delegation ratchet rules.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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.
|
|
10
11
|
*
|
|
11
12
|
* Fully local, zero token cost. Results are cached (24h) so the SessionStart
|
|
12
13
|
* hook can read them without re-parsing a month of transcripts.
|
|
13
14
|
*
|
|
14
|
-
* Pipeline position (per design discussion):
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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`).
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
20
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
|
|
20
21
|
import { join } from 'node:path';
|
|
21
22
|
import { homedir } from 'node:os';
|
|
22
23
|
import { discoverSessionFiles } from './parser.js';
|
|
23
|
-
import { collectSessionRecords } from './
|
|
24
|
+
import { collectSessionRecords } from './session-records.js';
|
|
24
25
|
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
// ── Tier bands (docs/TIER_CRITERIA.md §3) ────────────────────────────────
|
|
27
|
+
// T2 (haiku): finished in few calls, tiny output, near-zero mutation, no
|
|
28
|
+
// errors. T1 (sonnet): moderate output/mutation, at most one tool error.
|
|
29
|
+
// T0: everything else stays on the session model. Output-token thresholds
|
|
30
|
+
// are calibrated per-user from their own 14-day distribution (fixed
|
|
31
|
+
// thresholds drift with workload — RouteLLM's stated limitation), clamped
|
|
32
|
+
// to sane ranges so a skewed window can't stretch them absurdly.
|
|
33
|
+
export const T2_MAX_CALLS = 6;
|
|
34
|
+
export const T2_MAX_MUTATING = 2;
|
|
35
|
+
export const T2_OUT_CLAMP = [1000, 3000]; // default 1500 pre-calibration
|
|
36
|
+
export const T1_MAX_MUTATING = 6;
|
|
37
|
+
export const T1_MAX_ERRORS = 1;
|
|
38
|
+
export const T1_OUT_CLAMP = [5000, 15000]; // default 8000 pre-calibration
|
|
39
|
+
export const T0_MIN_ERRORS = 2; // repeated tool errors = hard, by outcome
|
|
40
|
+
export const T0_MIN_MUTATING = 7;
|
|
41
|
+
// Episodes below this output size carry no delegable work (conversational
|
|
42
|
+
// acks, feedback) — skip entirely.
|
|
43
|
+
export const MIN_DELEGABLE_OUT = 100;
|
|
44
|
+
// Escalation keywords: design/analysis judgement stays on the top tier.
|
|
45
|
+
export const ESCALATE_RE = /설계|아키텍처|리팩토링|원인 분석|개선할|검토해보|비교|왜 |analyze|compare|evaluate|architect|refactor/i;
|
|
29
46
|
// A pattern must recur this often before we nag about it.
|
|
30
47
|
export const MIN_RECURRENCE = 3;
|
|
31
|
-
// Cache is fresh for a day — the SessionStart hook never rescans inline.
|
|
32
|
-
export const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
33
48
|
|
|
34
|
-
//
|
|
35
|
-
//
|
|
49
|
+
// ── Rescan gate (data-driven, not time-driven) ───────────────────────────
|
|
50
|
+
// A scan over unchanged transcripts is deterministic — identical output —
|
|
51
|
+
// so time alone is a bad trigger: it wastes scans on idle days and lags a
|
|
52
|
+
// full day behind heavy ones. Instead we rescan when enough NEW transcript
|
|
53
|
+
// data accumulated (~5MB ≈ 20-40 episodes on measured data — enough for a
|
|
54
|
+
// pattern to newly cross MIN_RECURRENCE), with guardrails: a minimum
|
|
55
|
+
// interval against session-churn thrash, a daily fallback so small trickles
|
|
56
|
+
// still refresh rule-health, and a hard skip when nothing changed at all.
|
|
57
|
+
export const RESCAN_MIN_INTERVAL_MS = 60 * 60 * 1000; // never more than hourly
|
|
58
|
+
export const RESCAN_BIG_DELTA_BYTES = 5 * 1024 * 1024; // this much new data → rescan now
|
|
59
|
+
export const RESCAN_MAX_AGE_MS = 24 * 60 * 60 * 1000; // any new data + a day old → rescan
|
|
60
|
+
|
|
61
|
+
// Category → recommended subagent. First match wins; order matters
|
|
62
|
+
// (paste before everything: keywords inside pasted UI/log text would
|
|
63
|
+
// otherwise mislabel the episode; translate before read: "번역해줘" also
|
|
64
|
+
// matches the read keywords).
|
|
65
|
+
const PASTE_MIN_LEN = 400;
|
|
36
66
|
const CATEGORIES = [
|
|
67
|
+
{
|
|
68
|
+
id: 'paste',
|
|
69
|
+
label: '붙여넣은 화면·로그 질문',
|
|
70
|
+
agent: 'haiku-explore',
|
|
71
|
+
re: null, // matched by length, see categorize()
|
|
72
|
+
},
|
|
37
73
|
{
|
|
38
74
|
id: 'translate',
|
|
39
75
|
label: '배치 번역·정형 텍스트 변환',
|
|
@@ -92,7 +128,8 @@ export function mungeProjectPath(p) {
|
|
|
92
128
|
}
|
|
93
129
|
|
|
94
130
|
function categorize(text) {
|
|
95
|
-
|
|
131
|
+
if (text.length >= PASTE_MIN_LEN) return CATEGORIES.find((c) => c.id === 'paste');
|
|
132
|
+
for (const c of CATEGORIES) if (c.re && c.re.test(text)) return c;
|
|
96
133
|
return null;
|
|
97
134
|
}
|
|
98
135
|
|
|
@@ -109,11 +146,14 @@ function toEpisodes(records) {
|
|
|
109
146
|
for (const r of records) {
|
|
110
147
|
const text = (r.userText || '').trim();
|
|
111
148
|
if (!cur || cur.text !== text) {
|
|
112
|
-
cur = { text, calls: 0, out: 0, models: new Set(), cwd: '' };
|
|
149
|
+
cur = { text, calls: 0, out: 0, mutating: 0, errors: 0, delegated: 0, models: new Set(), cwd: '' };
|
|
113
150
|
episodes.push(cur);
|
|
114
151
|
}
|
|
115
152
|
cur.calls += 1;
|
|
116
153
|
cur.out += r.completion_tokens;
|
|
154
|
+
cur.mutating += r.mutatingToolCalls || 0;
|
|
155
|
+
cur.errors += r.toolErrors || 0;
|
|
156
|
+
cur.delegated += r.delegationCalls || 0;
|
|
117
157
|
cur.models.add(r.model);
|
|
118
158
|
if (!cur.cwd && r.cwd) cur.cwd = r.cwd;
|
|
119
159
|
}
|
|
@@ -124,67 +164,139 @@ function isExpensiveModel(model) {
|
|
|
124
164
|
return !/haiku/i.test(model);
|
|
125
165
|
}
|
|
126
166
|
|
|
167
|
+
const clamp = (v, [lo, hi]) => Math.min(hi, Math.max(lo, v));
|
|
168
|
+
const percentile = (sorted, p) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Per-user output-token thresholds from this window's episode distribution.
|
|
172
|
+
* Falls back to mid-clamp defaults when the sample is too small to trust.
|
|
173
|
+
*/
|
|
174
|
+
export function calibrateThresholds(episodeOuts) {
|
|
175
|
+
if (episodeOuts.length < 100) return { t2Out: 1500, t1Out: 8000, calibrated: false };
|
|
176
|
+
const sorted = [...episodeOuts].sort((a, b) => a - b);
|
|
177
|
+
return {
|
|
178
|
+
t2Out: clamp(Math.max(percentile(sorted, 0.25), 1500), T2_OUT_CLAMP),
|
|
179
|
+
t1Out: clamp(percentile(sorted, 0.75), T1_OUT_CLAMP),
|
|
180
|
+
calibrated: true,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Tier classification (docs/TIER_CRITERIA.md §3). Returns 'T0'|'T1'|'T2',
|
|
186
|
+
* or null when the episode carries nothing delegable. Order matters: hard
|
|
187
|
+
* evidence (errors, heavy mutation, big output, judgement keywords) wins
|
|
188
|
+
* before any cheap-band check.
|
|
189
|
+
*/
|
|
190
|
+
export function tierOf(ep, category, th) {
|
|
191
|
+
if (ep.out < MIN_DELEGABLE_OUT) return null;
|
|
192
|
+
if (
|
|
193
|
+
ep.errors >= T0_MIN_ERRORS ||
|
|
194
|
+
ep.mutating >= T0_MIN_MUTATING ||
|
|
195
|
+
ep.out > th.t1Out ||
|
|
196
|
+
ESCALATE_RE.test(ep.text)
|
|
197
|
+
) return 'T0';
|
|
198
|
+
if (!category || ep.delegated > 0) return 'T0';
|
|
199
|
+
if (ep.calls <= T2_MAX_CALLS && ep.out <= th.t2Out && ep.mutating <= T2_MAX_MUTATING && ep.errors === 0) return 'T2';
|
|
200
|
+
if (ep.out <= th.t1Out && ep.mutating <= T1_MAX_MUTATING && ep.errors <= T1_MAX_ERRORS) return 'T1';
|
|
201
|
+
return 'T0';
|
|
202
|
+
}
|
|
203
|
+
|
|
127
204
|
/**
|
|
128
205
|
* Scan transcripts and build delegation candidates.
|
|
129
206
|
* Returns the cache object (also written to disk).
|
|
130
207
|
*/
|
|
131
208
|
export async function runRouteScan({ days = 14 } = {}) {
|
|
132
209
|
const files = await discoverSessionFiles({ days });
|
|
133
|
-
const groups = new Map(); // "category|project" → aggregate
|
|
134
|
-
let totalEpisodes = 0;
|
|
135
|
-
let easyEpisodes = 0;
|
|
136
210
|
|
|
211
|
+
// Pass 1 — collect episodes (needed up front: thresholds are calibrated
|
|
212
|
+
// from the full window's output distribution before any tiering).
|
|
213
|
+
const all = []; // { ep, projectDir }
|
|
214
|
+
let dataBytes = 0; // window size snapshot — the rescan gate diffs against it
|
|
137
215
|
for (const f of files) {
|
|
138
216
|
let records;
|
|
139
217
|
try {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
records = await collectSessionRecords(f.path, { cacheWeighted: false, includeContent: true });
|
|
218
|
+
dataBytes += statSync(f.path).size;
|
|
219
|
+
records = await collectSessionRecords(f.path, { includeContent: true });
|
|
143
220
|
} catch {
|
|
144
221
|
continue;
|
|
145
222
|
}
|
|
146
223
|
for (const ep of toEpisodes(records)) {
|
|
147
224
|
if (!ep.text) continue;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
225
|
+
all.push({ ep, projectDir: f.projectDir });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const totalEpisodes = all.length;
|
|
229
|
+
const thresholds = calibrateThresholds(all.map((x) => x.ep.out));
|
|
230
|
+
|
|
231
|
+
// Pass 2 — tier, group by tier×category×project, and accumulate the
|
|
232
|
+
// per-category outcome stats that keep promoted model rules fresh.
|
|
233
|
+
const groups = new Map(); // "tier|category|project" → aggregate
|
|
234
|
+
const episodeStats = new Map(); // "category|project" (+ "category|*") → outcome stats
|
|
235
|
+
let tieredEpisodes = 0;
|
|
236
|
+
const bumpStats = (key, ep) => {
|
|
237
|
+
const s = episodeStats.get(key) || { count: 0, errCount: 0, epCount: 0 };
|
|
238
|
+
s.count += 1;
|
|
239
|
+
s.epCount += 1;
|
|
240
|
+
if (ep.errors > 0) s.errCount += 1;
|
|
241
|
+
episodeStats.set(key, s);
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
for (const { ep, projectDir } of all) {
|
|
245
|
+
if (isSkippable(ep.text)) continue;
|
|
246
|
+
if (![...ep.models].some(isExpensiveModel)) continue; // already cheap
|
|
247
|
+
const cat = categorize(ep.text);
|
|
248
|
+
if (cat) {
|
|
249
|
+
// rule-health denominator: episodes that LOOK delegable by shape
|
|
250
|
+
// (tier judged with the error signal zeroed — using real errors here
|
|
251
|
+
// would be circular, since T2 requires errors=0 by definition). The
|
|
252
|
+
// numerator is those that still hit errors: exactly the "light-looking
|
|
253
|
+
// work in this category keeps failing" risk a delegation rule cares about.
|
|
254
|
+
const shapeTier = tierOf({ ...ep, errors: 0 }, cat, thresholds);
|
|
255
|
+
if (shapeTier === 'T1' || shapeTier === 'T2') {
|
|
256
|
+
bumpStats(`${cat.id}|${projectDir}`, ep);
|
|
257
|
+
bumpStats(`${cat.id}|*`, ep);
|
|
172
258
|
}
|
|
173
|
-
groups.set(key, g);
|
|
174
259
|
}
|
|
260
|
+
const tier = tierOf(ep, cat, thresholds);
|
|
261
|
+
if (tier !== 'T1' && tier !== 'T2') continue;
|
|
262
|
+
tieredEpisodes += 1;
|
|
263
|
+
const key = `${tier}|${cat.id}|${projectDir}`;
|
|
264
|
+
const g = groups.get(key) || {
|
|
265
|
+
tier,
|
|
266
|
+
category: cat.id,
|
|
267
|
+
label: cat.label,
|
|
268
|
+
agent: tier === 'T2' ? cat.agent : 'sonnet',
|
|
269
|
+
project: projectDir,
|
|
270
|
+
projectPath: '',
|
|
271
|
+
count: 0,
|
|
272
|
+
models: new Set(),
|
|
273
|
+
example: '',
|
|
274
|
+
};
|
|
275
|
+
g.count += 1;
|
|
276
|
+
for (const m of ep.models) g.models.add(m);
|
|
277
|
+
if (!g.projectPath && ep.cwd) g.projectPath = ep.cwd;
|
|
278
|
+
if (!g.example || (ep.text.length < g.example.length && ep.text.length > 10)) {
|
|
279
|
+
g.example = ep.text.slice(0, 80).replace(/\s+/g, ' ');
|
|
280
|
+
}
|
|
281
|
+
groups.set(key, g);
|
|
175
282
|
}
|
|
176
283
|
|
|
177
284
|
// Keep prior dismissed/promoted signatures across rescans.
|
|
178
285
|
const prev = readRouteScan();
|
|
179
286
|
const resolved = new Set(prev?.resolved || []);
|
|
180
287
|
|
|
288
|
+
const ruleText = (g) => g.tier === 'T2'
|
|
289
|
+
? `"${g.label}" 유형의 단순 요청(예: "${g.example}")은 ${g.agent}(haiku) 서브에이전트로 위임한다`
|
|
290
|
+
: `"${g.label}" 유형의 중간 난도 요청(예: "${g.example}")은 model: sonnet 서브에이전트로 위임한다 (설계 판단·반복 에러 발생 시 메인 모델이 이어받음)`;
|
|
291
|
+
|
|
181
292
|
const candidates = [...groups.values()]
|
|
182
293
|
.filter((g) => g.count >= MIN_RECURRENCE)
|
|
183
294
|
.sort((a, b) => b.count - a.count)
|
|
184
|
-
.slice(0,
|
|
295
|
+
.slice(0, 8)
|
|
185
296
|
.map((g, i) => ({
|
|
186
297
|
id: i + 1,
|
|
187
|
-
signature: `${g.category}|${g.project}`,
|
|
298
|
+
signature: `${g.tier}|${g.category}|${g.project}`,
|
|
299
|
+
tier: g.tier,
|
|
188
300
|
category: g.category,
|
|
189
301
|
label: g.label,
|
|
190
302
|
agent: g.agent,
|
|
@@ -200,7 +312,7 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
200
312
|
// project already, so scope suggestion is per-candidate 'project' unless
|
|
201
313
|
// the same category recurs across 2+ projects (then 'global').
|
|
202
314
|
suggestedScope: 'project',
|
|
203
|
-
rule:
|
|
315
|
+
rule: ruleText(g),
|
|
204
316
|
}));
|
|
205
317
|
|
|
206
318
|
// Same category appearing in 2+ projects → suggest global for each.
|
|
@@ -216,7 +328,10 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
216
328
|
scannedAt: new Date().toISOString(),
|
|
217
329
|
days,
|
|
218
330
|
totalEpisodes,
|
|
219
|
-
|
|
331
|
+
dataBytes,
|
|
332
|
+
// kept as `easyEpisodes` for statusline/back-compat; now counts T1+T2.
|
|
333
|
+
easyEpisodes: tieredEpisodes,
|
|
334
|
+
thresholds,
|
|
220
335
|
candidates,
|
|
221
336
|
resolved: [...resolved],
|
|
222
337
|
};
|
|
@@ -227,6 +342,15 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
227
342
|
} catch {
|
|
228
343
|
// best-effort — scan results are still returned
|
|
229
344
|
}
|
|
345
|
+
|
|
346
|
+
// Continuous update (user requirement): every rescan refreshes promoted
|
|
347
|
+
// model-fitting rules from the new window — recurrence counts, error
|
|
348
|
+
// rates, and rule-health flags — and rewrites their managed blocks.
|
|
349
|
+
try {
|
|
350
|
+
const { refreshModelRules } = await import('./model-rules.js');
|
|
351
|
+
refreshModelRules(episodeStats, { now: cache.scannedAt });
|
|
352
|
+
} catch { /* registry unwritable — scan result still valid */ }
|
|
353
|
+
|
|
230
354
|
return cache;
|
|
231
355
|
}
|
|
232
356
|
|
|
@@ -239,10 +363,34 @@ export function readRouteScan() {
|
|
|
239
363
|
}
|
|
240
364
|
}
|
|
241
365
|
|
|
242
|
-
|
|
243
|
-
|
|
366
|
+
/**
|
|
367
|
+
* Data-driven rescan gate (see constants above). Cheap: one stat() per
|
|
368
|
+
* transcript file (~32 files on measured data) — a few milliseconds.
|
|
369
|
+
*/
|
|
370
|
+
export async function shouldRescan(cache, { days = 14 } = {}) {
|
|
371
|
+
if (!cache?.scannedAt) return true;
|
|
244
372
|
const ts = Date.parse(cache.scannedAt);
|
|
245
|
-
|
|
373
|
+
if (!Number.isFinite(ts)) return true;
|
|
374
|
+
const age = Date.now() - ts;
|
|
375
|
+
if (age < RESCAN_MIN_INTERVAL_MS) return false;
|
|
376
|
+
|
|
377
|
+
let total = 0;
|
|
378
|
+
let anyNew = false;
|
|
379
|
+
try {
|
|
380
|
+
for (const f of await discoverSessionFiles({ days })) {
|
|
381
|
+
const s = statSync(f.path);
|
|
382
|
+
total += s.size;
|
|
383
|
+
if (s.mtimeMs > ts) anyNew = true;
|
|
384
|
+
}
|
|
385
|
+
} catch {
|
|
386
|
+
return age >= RESCAN_MAX_AGE_MS; // can't stat — degrade to daily
|
|
387
|
+
}
|
|
388
|
+
if (!anyNew) return false; // nothing changed → identical scan, skip forever
|
|
389
|
+
// Append-only transcripts: window growth ≈ new data. Files aging out of
|
|
390
|
+
// the window shrink the total, making this estimate conservative.
|
|
391
|
+
const newBytes = Math.max(0, total - (cache.dataBytes || 0));
|
|
392
|
+
if (newBytes >= RESCAN_BIG_DELTA_BYTES) return true;
|
|
393
|
+
return age >= RESCAN_MAX_AGE_MS;
|
|
246
394
|
}
|
|
247
395
|
|
|
248
396
|
/** Candidates not yet promoted/dismissed. */
|