claude-token-saver 3.5.3 → 3.6.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 +6 -0
- package/README.md +6 -0
- package/bin/cli.js +38 -921
- package/package.json +2 -1
- package/src/cli-args.js +51 -0
- package/src/commands/brief.js +31 -0
- package/src/commands/handoff.js +33 -0
- package/src/commands/harness.js +383 -0
- package/src/commands/history.js +46 -0
- package/src/commands/install.js +70 -0
- package/src/commands/last.js +151 -0
- package/src/commands/mode.js +45 -0
- package/src/commands/route-scan.js +201 -0
- package/src/debug.js +29 -0
- package/src/model-rules.js +34 -10
- package/src/parser.js +30 -3
- package/src/route-scan.js +15 -0
- package/src/session-cache.js +153 -0
- package/src/stdin-payload.js +106 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subcommand: last — print the most recent warning + how to handle it.
|
|
3
|
+
* Designed for the auto-trigger skill so the user immediately sees
|
|
4
|
+
* "what just fired and how to fix it" without having to read the whole
|
|
5
|
+
* history file.
|
|
6
|
+
* claude-token-saver last # search last 1 day
|
|
7
|
+
* claude-token-saver last --days 7 # widen the lookback
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Scan recent history file contents (newest day first) and return the most
|
|
13
|
+
* recent warning event — `{ time, chip, detail, codes, isCap, capLabel, capPct }`.
|
|
14
|
+
* Returns null when no warning is found in the window.
|
|
15
|
+
*
|
|
16
|
+
* Recognized event lines (from history.js appendDayLine output):
|
|
17
|
+
* - HH:MM:SS ⚠ Cache miss — session abc1: LOW_HIT_RATE
|
|
18
|
+
* - HH:MM:SS ⚠ A → ⚠ B — detail
|
|
19
|
+
* - HH:MM:SS 🚨 5H 94% cap warning (resets in ...)
|
|
20
|
+
* - HH:MM:SS ✓ resolved (was ...) ← skip
|
|
21
|
+
* - HH:MM:SS ✓ 5H cap warning resolved ← skip
|
|
22
|
+
* - HH:MM:SS 📝 handoff written: ... ← skip
|
|
23
|
+
*/
|
|
24
|
+
function findLatestWarning(historyEntries, chipToCodes) {
|
|
25
|
+
const warnings = [];
|
|
26
|
+
for (const { date, content } of historyEntries) {
|
|
27
|
+
const lines = content.split('\n');
|
|
28
|
+
for (const line of lines) {
|
|
29
|
+
// Skip non-event lines
|
|
30
|
+
const m = line.match(/^- (\d{2}:\d{2}:\d{2})\s+(.+)$/);
|
|
31
|
+
if (!m) continue;
|
|
32
|
+
const time = m[1];
|
|
33
|
+
const rest = m[2];
|
|
34
|
+
// Skip resolutions and handoff entries
|
|
35
|
+
if (rest.startsWith('✓ ') || rest.startsWith('📝 ')) continue;
|
|
36
|
+
// Cap-warn line: `🚨 5H 94% cap warning (...)`
|
|
37
|
+
const cap = rest.match(/^🚨\s+(\S+)\s+(\d+)%\s+cap warning(?:\s*\((.+)\))?$/);
|
|
38
|
+
if (cap) {
|
|
39
|
+
warnings.push({
|
|
40
|
+
date,
|
|
41
|
+
time,
|
|
42
|
+
chip: `🚨 ${cap[1]} ${cap[2]}%`,
|
|
43
|
+
isCap: true,
|
|
44
|
+
capLabel: cap[1],
|
|
45
|
+
capPct: parseInt(cap[2], 10),
|
|
46
|
+
capReset: cap[3] || null,
|
|
47
|
+
codes: [],
|
|
48
|
+
detail: null,
|
|
49
|
+
});
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
// Chip line — last token after the chip is `— detail` (optional). The
|
|
53
|
+
// chip itself can be a plain `⚠ X` or a `⚠ A → ⚠ B` transition; we want
|
|
54
|
+
// the *current* chip (right side of the arrow if present).
|
|
55
|
+
const arrowMatch = rest.match(/^(.+?)\s+→\s+(.+?)(?:\s+—\s+(.+))?$/);
|
|
56
|
+
let chip;
|
|
57
|
+
let detail = null;
|
|
58
|
+
if (arrowMatch) {
|
|
59
|
+
chip = arrowMatch[2].trim();
|
|
60
|
+
detail = arrowMatch[3] || null;
|
|
61
|
+
} else {
|
|
62
|
+
const plain = rest.match(/^(\S+(?:\s+\S+)*?)(?:\s+—\s+(.+))?$/);
|
|
63
|
+
if (!plain) continue;
|
|
64
|
+
chip = plain[1].trim();
|
|
65
|
+
detail = plain[2] || null;
|
|
66
|
+
}
|
|
67
|
+
// Resolve codes: detail "session ID: A, B" → codes; else CHIP_TO_CODES.
|
|
68
|
+
const codes = [];
|
|
69
|
+
if (detail) {
|
|
70
|
+
const dm = detail.match(/^session [^:]+:\s*(.+)$/);
|
|
71
|
+
if (dm) {
|
|
72
|
+
for (const c of dm[1].split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
73
|
+
if (!codes.includes(c)) codes.push(c);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (chipToCodes[chip]) {
|
|
78
|
+
for (const c of chipToCodes[chip]) if (!codes.includes(c)) codes.push(c);
|
|
79
|
+
}
|
|
80
|
+
warnings.push({ date, time, chip, isCap: false, codes, detail });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return warnings.length ? warnings[warnings.length - 1] : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function run({ numArg }) {
|
|
87
|
+
const { readRecent, historyDir } = await import('../history.js');
|
|
88
|
+
const { ISSUE_MESSAGES, CHIP_TO_CODES, CAP_TIPS } = await import('../advice.js');
|
|
89
|
+
const { userLanguage } = await import('../config.js');
|
|
90
|
+
const lang = userLanguage();
|
|
91
|
+
const days = numArg('--days', { dflt: 1, min: 0 });
|
|
92
|
+
const recent = readRecent(days);
|
|
93
|
+
const latest = findLatestWarning(recent, CHIP_TO_CODES);
|
|
94
|
+
if (!latest) {
|
|
95
|
+
if (lang === 'ko') {
|
|
96
|
+
console.log(`최근 ${days}일 내 경고가 없습니다.`);
|
|
97
|
+
console.log(`(히스토리 디렉터리: ${historyDir()})`);
|
|
98
|
+
} else {
|
|
99
|
+
console.log(`No warnings in the last ${days} day${days === 1 ? '' : 's'}.`);
|
|
100
|
+
console.log(`(History dir: ${historyDir()})`);
|
|
101
|
+
}
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
// Header
|
|
105
|
+
console.log(`Most recent warning — ${latest.date} ${latest.time}`);
|
|
106
|
+
console.log(` ${latest.chip}${latest.detail ? ` — ${latest.detail}` : ''}`);
|
|
107
|
+
console.log('');
|
|
108
|
+
// Cap-warn path: handoff is the recommendation. Print the bilingual tip
|
|
109
|
+
// and a one-line "how to back up" pointer.
|
|
110
|
+
if (latest.isCap) {
|
|
111
|
+
if (latest.capReset) console.log(` Cap window: ${latest.capReset}`);
|
|
112
|
+
console.log('');
|
|
113
|
+
console.log('💡 ' + (lang === 'ko' ? CAP_TIPS.ko : CAP_TIPS.en));
|
|
114
|
+
console.log('');
|
|
115
|
+
console.log(lang === 'ko' ? '실행:' : 'Run:');
|
|
116
|
+
console.log(' claude-token-saver handoff');
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
// Chip warning path: render full ISSUE_MESSAGES advice for each code,
|
|
120
|
+
// bilingual (English first, `└ Korean` continuation per line — matches
|
|
121
|
+
// the history.md format).
|
|
122
|
+
if (latest.codes.length === 0) {
|
|
123
|
+
console.log(lang === 'ko'
|
|
124
|
+
? '(진단 코드 없음 — 표 뷰를 열어보세요: `claude-token-saver --days 1`)'
|
|
125
|
+
: '(No diagnostic code attached — open the table view: `claude-token-saver --days 1`)');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
// Pick a single language per field; fall back to EN when KO is missing.
|
|
129
|
+
const pick = (en, ko) => (lang === 'ko' && ko ? ko : en);
|
|
130
|
+
for (const code of latest.codes) {
|
|
131
|
+
const msg = ISSUE_MESSAGES[code];
|
|
132
|
+
if (!msg) {
|
|
133
|
+
console.log(`Code: ${code} (no advice registered)`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
console.log(`▎ ${pick(msg.title, msg.titleKo)}`);
|
|
137
|
+
console.log(` ${pick(msg.explain, msg.explainKo)}`);
|
|
138
|
+
const actions = typeof msg.actions === 'function' ? msg.actions() : msg.actions || [];
|
|
139
|
+
for (const a of actions) {
|
|
140
|
+
console.log('');
|
|
141
|
+
console.log(` ${pick(a.label, a.labelKo)}:`);
|
|
142
|
+
const cmds = a.commands || [];
|
|
143
|
+
const cmdsKo = a.commandsKo || [];
|
|
144
|
+
for (let i = 0; i < cmds.length; i++) {
|
|
145
|
+
console.log(` - ${pick(cmds[i], cmdsKo[i])}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
console.log('');
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subcommand: mode — persist statusline preferences so future runs pick
|
|
3
|
+
* them up without flags or wrapper edits.
|
|
4
|
+
* claude-token-saver mode # show current config
|
|
5
|
+
* claude-token-saver mode icon verbose # set icon + verbose
|
|
6
|
+
* claude-token-saver mode reset # clear back to defaults
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
export async function run({ args }) {
|
|
11
|
+
const { applyMode, loadConfig, configPath, statuslineDefaults, userLanguage, VALID_KEYWORDS } =
|
|
12
|
+
await import('../config.js');
|
|
13
|
+
const words = args.slice(1);
|
|
14
|
+
if (words.length === 0) {
|
|
15
|
+
const eff = statuslineDefaults();
|
|
16
|
+
const raw = loadConfig();
|
|
17
|
+
console.log('Statusline (effective):');
|
|
18
|
+
console.log(` icon: ${eff.icon}`);
|
|
19
|
+
console.log(` verbose: ${eff.verbose}`);
|
|
20
|
+
console.log(` timer: ${eff.timer}`);
|
|
21
|
+
console.log(` color: ${eff.color}`);
|
|
22
|
+
console.log(` window: ${eff.windowLabel} (${eff.windowHours}h)`);
|
|
23
|
+
console.log('');
|
|
24
|
+
console.log('Output language (advice / history / last):');
|
|
25
|
+
console.log(` language: ${userLanguage()}`);
|
|
26
|
+
console.log('');
|
|
27
|
+
console.log(`Stored config file (${configPath()}):`);
|
|
28
|
+
console.log(` ${Object.keys(raw).length === 0 ? '(none — using defaults)' : JSON.stringify(raw)}`);
|
|
29
|
+
console.log('');
|
|
30
|
+
console.log('Change with: claude-token-saver mode <keywords...>');
|
|
31
|
+
console.log(`Keywords: ${VALID_KEYWORDS.join(', ')}`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const { applied, unknown } = applyMode(words);
|
|
35
|
+
if (unknown.length) {
|
|
36
|
+
console.error(`Unknown keyword${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}`);
|
|
37
|
+
console.error(`Valid: ${VALID_KEYWORDS.join(', ')}`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
const eff = statuslineDefaults();
|
|
41
|
+
console.log(`Updated: ${applied.join(', ')}`);
|
|
42
|
+
console.log(`Now: icon=${eff.icon} verbose=${eff.verbose} timer=${eff.timer} color=${eff.color} window=${eff.windowLabel} language=${userLanguage()}`);
|
|
43
|
+
console.log('Statusline picks up the change on the next refresh (~1s).');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readStdinJson } from '../stdin-payload.js';
|
|
6
|
+
import { debug } from '../debug.js';
|
|
7
|
+
|
|
8
|
+
export async function run({ args, hasFlag, numArg }) {
|
|
9
|
+
const rs = await import('../route-scan.js');
|
|
10
|
+
const { userLanguage } = await import('../config.js');
|
|
11
|
+
const lang = userLanguage();
|
|
12
|
+
|
|
13
|
+
// route-scan rules [rm <N>] — the model-fitting rule registry (rules
|
|
14
|
+
// promoted from candidates; auto-refreshed from logs on every rescan).
|
|
15
|
+
if (args[1] === 'rules') {
|
|
16
|
+
const mr = await import('../model-rules.js');
|
|
17
|
+
if (args[2] === 'rm') {
|
|
18
|
+
const n = parseInt(args[3], 10);
|
|
19
|
+
const removed = Number.isFinite(n) ? mr.removeModelRule(n) : null;
|
|
20
|
+
if (!removed) {
|
|
21
|
+
console.error('Usage: claude-token-saver route-scan rules rm <N> # N from `route-scan rules`');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
// A target whose last rule was removed gets its (tool-owned) file deleted.
|
|
25
|
+
mr.syncAllFiles({ previousPaths: [mr.modelRatchetPathFor(removed.scope, removed.targetRoot)] });
|
|
26
|
+
console.log(`Removed model-fitting rule #${n}: ${removed.rule}`);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const { rules } = mr.loadModelRules();
|
|
30
|
+
if (rules.length === 0) {
|
|
31
|
+
console.log(lang === 'ko' ? '등록된 모델 피팅 룰 없음.' : 'No model-fitting rules registered.');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
console.log(lang === 'ko' ? '📐 모델 피팅 룰 (로그 기반 자동 갱신):' : '📐 Model-fitting rules (auto-refreshed from logs):');
|
|
35
|
+
rules.forEach((r, i) => {
|
|
36
|
+
const health = r.status === 'review'
|
|
37
|
+
? (lang === 'ko' ? ' ⚠ 에러율 초과 — 재검토 필요' : ' ⚠ error rate over threshold — needs review')
|
|
38
|
+
: '';
|
|
39
|
+
const stat = lang === 'ko'
|
|
40
|
+
? `${r.tier} (${rs.tierLabel(r.tier)}) · ${rs.scopeLabel(r.scope)} · 반복 ${r.count || 0}회 · 에러율 ${Math.round((r.errRate || 0) * 100)}%`
|
|
41
|
+
: `${r.tier} (${rs.tierLabel(r.tier, 'en')}) · ${rs.scopeLabel(r.scope, 'en')} · seen ×${r.count || 0} · err ${Math.round((r.errRate || 0) * 100)}%`;
|
|
42
|
+
console.log(` #${i + 1} ${stat}${health}`);
|
|
43
|
+
console.log(` ${r.rule}`);
|
|
44
|
+
});
|
|
45
|
+
console.log(lang === 'ko'
|
|
46
|
+
? '\n제거: claude-token-saver route-scan rules rm <N>'
|
|
47
|
+
: '\nRemove with: claude-token-saver route-scan rules rm <N>');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (args[1] === 'dismiss') {
|
|
52
|
+
const n = parseInt(args[2], 10);
|
|
53
|
+
if (!Number.isFinite(n)) {
|
|
54
|
+
console.error('Usage: claude-token-saver route-scan dismiss <N> # N from `route? R<N>`');
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
const cand = rs.resolveCandidate(n);
|
|
58
|
+
if (!cand) {
|
|
59
|
+
console.error(`No route candidate R${n}. Run: claude-token-saver route-scan`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
console.log(lang === 'ko'
|
|
63
|
+
? `R${n} 무시 처리: ${cand.label} (${cand.project}) — 재스캔에도 다시 뜨지 않습니다.`
|
|
64
|
+
: `Dismissed R${n}: ${cand.label} (${cand.project}) — won't resurface on rescans.`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// --hook: SessionStart hook mode. Never scans inline (session start must
|
|
69
|
+
// stay fast) — reads the cache, kicks a detached refresh when stale, and
|
|
70
|
+
// prints delegation-candidate context for the new session.
|
|
71
|
+
if (hasFlag('--hook')) {
|
|
72
|
+
const hookCtx = readStdinJson() || {};
|
|
73
|
+
let cache = rs.readRouteScan();
|
|
74
|
+
if (await rs.shouldRescan(cache)) {
|
|
75
|
+
try {
|
|
76
|
+
const { spawn } = await import('node:child_process');
|
|
77
|
+
spawn(process.execPath, [process.argv[1], 'route-scan', '--refresh', '--quiet'],
|
|
78
|
+
{ detached: true, stdio: 'ignore' }).unref();
|
|
79
|
+
} catch (e) { debug('route-scan:spawn-refresh', e); /* stale cache is still usable below */ }
|
|
80
|
+
}
|
|
81
|
+
const open = rs.openCandidates(cache);
|
|
82
|
+
// Registered rules whose delegated-category error rate crossed the
|
|
83
|
+
// health threshold since promotion — the user approved these, so a
|
|
84
|
+
// status change must be briefed, not just written into the md file.
|
|
85
|
+
let reviewRules = [];
|
|
86
|
+
try {
|
|
87
|
+
const mr = await import('../model-rules.js');
|
|
88
|
+
reviewRules = mr.loadModelRules().rules
|
|
89
|
+
.map((r, i) => ({ ...r, n: i + 1 }))
|
|
90
|
+
.filter((r) => r.status === 'review');
|
|
91
|
+
} catch (e) { debug('route-scan:load-rules', e); /* candidate briefing still goes out */ }
|
|
92
|
+
if (open.length === 0 && reviewRules.length === 0) return; // silent — nothing to inject
|
|
93
|
+
// This text is injected straight into the model's context, so it must
|
|
94
|
+
// follow the user's configured language — a Korean-only briefing in an
|
|
95
|
+
// English session steers the whole first response into Korean.
|
|
96
|
+
const lines = [];
|
|
97
|
+
if (open.length > 0) {
|
|
98
|
+
if (lang === 'ko') {
|
|
99
|
+
lines.push(`[claude-token-saver route-scan] 최근 ${cache.days}일 세션에서 비싼 모델(opus/fable)이 반복 처리해 온, 더 싼 모델로 넘겨도 되는 작업이 감지되었습니다.`);
|
|
100
|
+
lines.push('(R<N>은 후보 번호, T2/T1은 난이도 등급입니다 — 사용자에게 전달할 때는 코드가 아니라 아래 풀어쓴 설명으로 브리핑하세요)');
|
|
101
|
+
} else {
|
|
102
|
+
lines.push(`[claude-token-saver route-scan] Over the last ${cache.days} days, expensive models (opus/fable) repeatedly handled work that a cheaper model could take.`);
|
|
103
|
+
lines.push('(R<N> is the candidate id, T2/T1 the difficulty tier — brief the user with the spelled-out wording below, not the codes.)');
|
|
104
|
+
}
|
|
105
|
+
for (const c of open) {
|
|
106
|
+
const tier = c.tier || 'T2';
|
|
107
|
+
const label = lang === 'ko' ? c.label : (c.labelEn || c.label);
|
|
108
|
+
const rule = lang === 'ko' ? c.rule : (c.ruleEn || c.rule);
|
|
109
|
+
if (lang === 'ko') {
|
|
110
|
+
lines.push(` 후보 R${c.id} — "${label}" 유형, ${c.count}회 반복 (프로젝트: ${c.project})`);
|
|
111
|
+
lines.push(` 판정: ${tier} (${rs.tierLabel(tier)}) → ${c.agent} 서브에이전트 위임 권장 · 적용 범위 제안: ${rs.scopeLabel(c.suggestedScope)}`);
|
|
112
|
+
lines.push(` 예시 요청: "${c.example}"`);
|
|
113
|
+
lines.push(` 등록 시 ratchet-model.md에 기록될 룰: "${rule}"`);
|
|
114
|
+
} else {
|
|
115
|
+
lines.push(` Candidate R${c.id} — "${label}", seen ×${c.count} (project: ${c.project})`);
|
|
116
|
+
lines.push(` verdict: ${tier} (${rs.tierLabel(tier, 'en')}) → delegate to the ${c.agent} subagent · suggested scope: ${rs.scopeLabel(c.suggestedScope, 'en')}`);
|
|
117
|
+
lines.push(` example request: "${c.example}"`);
|
|
118
|
+
lines.push(` rule that would be written to ratchet-model.md: "${rule}"`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (lang === 'ko') {
|
|
122
|
+
lines.push('등록하면 다음 세션부터 자동 위임됩니다. 사용자에게 등록 여부를 물을 때 위 룰 원문을 그대로 보여주고, 적용 범위까지 확인한 뒤 실행하세요:');
|
|
123
|
+
lines.push(' claude-token-saver harness promote R<N> --project|--global # 적용 범위는 반드시 사용자에게 확인');
|
|
124
|
+
lines.push(' claude-token-saver route-scan dismiss <N> # 사용자가 원치 않으면');
|
|
125
|
+
} else {
|
|
126
|
+
lines.push('Once registered, delegation happens automatically from the next session. Show the user the rule text verbatim, confirm the scope with them, then run:');
|
|
127
|
+
lines.push(' claude-token-saver harness promote R<N> --project|--global # ALWAYS confirm the scope with the user first');
|
|
128
|
+
lines.push(' claude-token-saver route-scan dismiss <N> # if they do not want it');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (reviewRules.length > 0) {
|
|
132
|
+
lines.push(lang === 'ko'
|
|
133
|
+
? '[claude-token-saver rule-health] 사용자가 승인한 위임 룰 중, 위임 대상 유형의 최근 에러율이 기준(20%)을 넘어 재검토가 필요한 룰이 있습니다 — 사용자에게 브리핑하고 조건 좁히기/제거를 상의하세요:'
|
|
134
|
+
: '[claude-token-saver rule-health] Some user-approved delegation rules now exceed the 20% error-rate threshold for their delegated category — brief the user and discuss narrowing or removing them:');
|
|
135
|
+
for (const r of reviewRules) {
|
|
136
|
+
lines.push(lang === 'ko'
|
|
137
|
+
? ` 룰 #${r.n} (${r.tier} ${rs.tierLabel(r.tier)} · ${rs.scopeLabel(r.scope)}) — 최근 에러율 ${Math.round((r.errRate || 0) * 100)}%`
|
|
138
|
+
: ` rule #${r.n} (${r.tier} ${rs.tierLabel(r.tier, 'en')} · ${rs.scopeLabel(r.scope, 'en')}) — recent error rate ${Math.round((r.errRate || 0) * 100)}%`);
|
|
139
|
+
lines.push(` "${r.rule}"`);
|
|
140
|
+
}
|
|
141
|
+
lines.push(lang === 'ko'
|
|
142
|
+
? ' 제거: claude-token-saver route-scan rules rm <N>'
|
|
143
|
+
: ' remove with: claude-token-saver route-scan rules rm <N>');
|
|
144
|
+
}
|
|
145
|
+
lines.push(lang === 'ko'
|
|
146
|
+
? '위 내용은 사용자의 첫 메시지 주제와 무관하더라도(단순 인사여도) 첫 응답 말미에 `※ [claude-token-saver]` 라벨을 달아 요약 브리핑하세요 — 사용자가 물어볼 때까지 기다리지 말 것.'
|
|
147
|
+
: 'Summarize the above at the end of your first response under a `※ [claude-token-saver]` label, even if the user\'s first message is unrelated (a bare greeting counts) — do not wait to be asked.');
|
|
148
|
+
console.log(lines.join('\n'));
|
|
149
|
+
// Record what was actually briefed so the UserPromptSubmit brief hook
|
|
150
|
+
// suppresses exactly these — a candidate landing after this read (e.g.
|
|
151
|
+
// the detached rescan above finishing) still gets briefed next prompt.
|
|
152
|
+
try {
|
|
153
|
+
const { seedSessionBriefed } = await import('../brief.js');
|
|
154
|
+
seedSessionBriefed(hookCtx.session_id, [
|
|
155
|
+
...open.map((c) => `route|${c.signature}`),
|
|
156
|
+
...reviewRules.map((r) => `health|${r.signature}|${r.scope}`),
|
|
157
|
+
]);
|
|
158
|
+
} catch (e) { debug('route-scan:seed-briefed', e); /* worst case is one duplicate brief */ }
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const days = numArg('--days', { dflt: 14, min: 0 });
|
|
163
|
+
let cache = rs.readRouteScan();
|
|
164
|
+
if (hasFlag('--refresh') || (cache && cache.days !== days) || await rs.shouldRescan(cache, { days })) {
|
|
165
|
+
cache = await rs.runRouteScan({ days });
|
|
166
|
+
}
|
|
167
|
+
if (hasFlag('--quiet')) return;
|
|
168
|
+
if (hasFlag('--json')) {
|
|
169
|
+
console.log(JSON.stringify(cache, null, 2));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const easyPct = cache.totalEpisodes ? Math.round(cache.easyEpisodes / cache.totalEpisodes * 100) : 0;
|
|
173
|
+
console.log(lang === 'ko'
|
|
174
|
+
? `route-scan — 최근 ${cache.days}일: 에피소드 ${cache.totalEpisodes}건 중 easy ${cache.easyEpisodes}건 (${easyPct}%) [스캔: ${cache.scannedAt}]`
|
|
175
|
+
: `route-scan — last ${cache.days}d: ${cache.easyEpisodes}/${cache.totalEpisodes} episodes easy (${easyPct}%) [scanned: ${cache.scannedAt}]`);
|
|
176
|
+
const open = rs.openCandidates(cache);
|
|
177
|
+
if (open.length === 0) {
|
|
178
|
+
console.log(lang === 'ko'
|
|
179
|
+
? '위임 후보 없음 (반복 3회 미만이거나 이미 처리됨).'
|
|
180
|
+
: 'No delegation candidates (below recurrence threshold or already resolved).');
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
console.log(lang === 'ko' ? '\n위임 후보 (R<N>=후보 번호, T2/T1=난이도 등급):' : '\nDelegation candidates (R<N> = candidate id, T2/T1 = difficulty tier):');
|
|
184
|
+
for (const c of open) {
|
|
185
|
+
const tier = c.tier || 'T2';
|
|
186
|
+
if (lang === 'ko') {
|
|
187
|
+
console.log(` R${c.id} "${c.label}" ×${c.count}회 [${c.project}]`);
|
|
188
|
+
console.log(` 판정: ${tier} (${rs.tierLabel(tier)}) → ${c.agent} 위임 권장 · 적용 범위 제안: ${rs.scopeLabel(c.suggestedScope)}`);
|
|
189
|
+
} else {
|
|
190
|
+
console.log(` R${c.id} "${c.labelEn || c.label}" ×${c.count} [${c.project}]`);
|
|
191
|
+
console.log(` verdict: ${tier} (${rs.tierLabel(tier, 'en')}) → delegate to ${c.agent} · suggested scope: ${rs.scopeLabel(c.suggestedScope, 'en')}`);
|
|
192
|
+
}
|
|
193
|
+
console.log(` ${lang === 'ko' ? '예시' : 'example'}: "${c.example}"`);
|
|
194
|
+
console.log(` ${lang === 'ko' ? '룰' : 'rule'}: ${lang === 'ko' ? c.rule : (c.ruleEn || c.rule)}`);
|
|
195
|
+
}
|
|
196
|
+
console.log('');
|
|
197
|
+
console.log(lang === 'ko' ? '등록 / 무시:' : 'Promote / dismiss:');
|
|
198
|
+
console.log(' claude-token-saver harness promote R<N> --project|--global');
|
|
199
|
+
console.log(' claude-token-saver route-scan dismiss <N>');
|
|
200
|
+
return;
|
|
201
|
+
}
|
package/src/debug.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in diagnostics for the tool's deliberately silent paths.
|
|
3
|
+
*
|
|
4
|
+
* Most catches here are best-effort by design: a failed history append or
|
|
5
|
+
* cache write must never break a statusline that renders every few seconds,
|
|
6
|
+
* and a hook that throws would disrupt the user's session. The cost is that
|
|
7
|
+
* a genuinely broken path (bad permissions on the state dir, a corrupt hook
|
|
8
|
+
* payload) is indistinguishable from "nothing to do".
|
|
9
|
+
*
|
|
10
|
+
* `CTS_DEBUG=1` makes those swallowed failures visible on stderr — which the
|
|
11
|
+
* statusline contract discards and hooks surface in Claude Code's debug
|
|
12
|
+
* output — without changing behavior in any way.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const ENABLED = !!process.env.CTS_DEBUG;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} scope short label for where the failure happened
|
|
19
|
+
* @param {unknown} err the swallowed error
|
|
20
|
+
*/
|
|
21
|
+
export function debug(scope, err) {
|
|
22
|
+
if (!ENABLED) return;
|
|
23
|
+
const msg = err && err.stack ? err.stack : String(err);
|
|
24
|
+
process.stderr.write(`[cts:${scope}] ${msg}\n`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function debugEnabled() {
|
|
28
|
+
return ENABLED;
|
|
29
|
+
}
|
package/src/model-rules.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* (rule-health, per docs/TIER_CRITERIA.md).
|
|
17
17
|
*
|
|
18
18
|
* Registry file (source of truth): <stateDir>/model-rules.json
|
|
19
|
-
* { rules: [ { signature, tier, category, label, agent, scope,
|
|
19
|
+
* { rules: [ { signature, tier, category, label, labelEn, agent, scope, // 'project'|'global'
|
|
20
20
|
* targetRoot, // project root path (project scope)
|
|
21
21
|
* rule, example, count, errRate, promotedAt, lastSeen,
|
|
22
22
|
* status } ] } // 'active' | 'review'
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
32
32
|
import { join, dirname } from 'node:path';
|
|
33
33
|
import { homedir } from 'node:os';
|
|
34
|
+
import { userLanguage } from './config.js';
|
|
34
35
|
|
|
35
36
|
// Post-promotion delegated-category error rate above this flags the rule
|
|
36
37
|
// for review (rule-health). Calibrated against local T0 avg error incidence.
|
|
@@ -93,9 +94,16 @@ export function removeModelRule(index1) {
|
|
|
93
94
|
return removed;
|
|
94
95
|
}
|
|
95
96
|
|
|
96
|
-
/**
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Render the full ratchet-model.md for one target (scope+root).
|
|
99
|
+
*
|
|
100
|
+
* The LLM reads this file as instructions, so it is written in the user's
|
|
101
|
+
* configured language — a Korean-only file would pull an English session's
|
|
102
|
+
* responses into Korean.
|
|
103
|
+
*/
|
|
104
|
+
export function renderModelRatchet(rules, lang = userLanguage()) {
|
|
105
|
+
const ko = lang === 'ko';
|
|
106
|
+
const lines = ko ? [
|
|
99
107
|
'# Model-Fitting Ratchet (claude-token-saver 자동 관리)',
|
|
100
108
|
'',
|
|
101
109
|
'로그 기반 티어 위임 룰. 이 파일은 route-scan이 매 스캔마다 통째로 재생성하므로',
|
|
@@ -107,10 +115,23 @@ export function renderModelRatchet(rules) {
|
|
|
107
115
|
'',
|
|
108
116
|
'## Rules',
|
|
109
117
|
'',
|
|
118
|
+
] : [
|
|
119
|
+
'# Model-Fitting Ratchet (managed by claude-token-saver)',
|
|
120
|
+
'',
|
|
121
|
+
'Log-derived tier delegation rules. route-scan regenerates this file in full',
|
|
122
|
+
'on every scan — do not edit it by hand. List / remove with:',
|
|
123
|
+
'`claude-token-saver route-scan rules [rm <N>]`.',
|
|
124
|
+
'',
|
|
125
|
+
'When delegating under a rule below, show the user this line first so it is',
|
|
126
|
+
'visible which tool is saving tokens:',
|
|
127
|
+
'`🔀 [claude-token-saver] model fitting: "<category>" → delegated to <agent>`',
|
|
128
|
+
'',
|
|
129
|
+
'## Rules',
|
|
130
|
+
'',
|
|
110
131
|
];
|
|
111
|
-
const healthOf = (r) => r.status
|
|
132
|
+
const healthOf = (r) => r.status !== 'review' ? '' : (ko
|
|
112
133
|
? ` ⚠ rule-health: 최근 위임 대상 에러율 ${Math.round((r.errRate || 0) * 100)}% — 조건을 좁히거나 제거 검토`
|
|
113
|
-
:
|
|
134
|
+
: ` ⚠ rule-health: recent error rate ${Math.round((r.errRate || 0) * 100)}% for the delegated category — narrow the condition or remove`);
|
|
114
135
|
const statsOf = (r) => `×${r.count || 0}, err ${Math.round((r.errRate || 0) * 100)}%, seen ${r.lastSeen || r.promotedAt}`;
|
|
115
136
|
|
|
116
137
|
// A category can carry both a T2 (haiku) and a T1 (sonnet) rule. Tier is
|
|
@@ -128,10 +149,13 @@ export function renderModelRatchet(rules) {
|
|
|
128
149
|
const t2 = group.find((r) => r.tier === 'T2');
|
|
129
150
|
const t1 = group.find((r) => r.tier === 'T1');
|
|
130
151
|
if (t2 && t1) {
|
|
131
|
-
const rule =
|
|
132
|
-
`"${t2.label}" 유형 요청은 기본적으로 ${t2.agent}(haiku) 서브에이전트로 위임한다(예: "${t2.example}"). ` +
|
|
133
|
-
|
|
134
|
-
|
|
152
|
+
const rule = ko
|
|
153
|
+
? `"${t2.label}" 유형 요청은 기본적으로 ${t2.agent}(haiku) 서브에이전트로 위임한다(예: "${t2.example}"). ` +
|
|
154
|
+
`여러 단계·여러 파일 수정이 얽힌 중간 난도 요청(예: "${t1.example}")은 model: sonnet 서브에이전트로 위임한다. ` +
|
|
155
|
+
`설계 판단·배포·스토어 제출 같은 비가역 작업이 섞이거나 위임 중 에러가 반복되면 위임하지 말고 메인 모델이 직접 처리한다`
|
|
156
|
+
: `Delegate "${t2.labelEn || t2.label}" requests to the ${t2.agent} (haiku) subagent by default (e.g. "${t2.example}"). ` +
|
|
157
|
+
`Escalate moderate ones that span multiple steps or file edits (e.g. "${t1.example}") to a model: sonnet subagent. ` +
|
|
158
|
+
`Do not delegate at all — handle it on the main model — when the request mixes in design judgement or irreversible work (deploy, release, store submission), or when errors repeat during delegation`;
|
|
135
159
|
lines.push(`- ${rule}${healthOf(t2)}${healthOf(t1)} <!-- T2 ${statsOf(t2)} / T1 ${statsOf(t1)} -->`);
|
|
136
160
|
for (const r of group) {
|
|
137
161
|
if (r !== t2 && r !== t1) lines.push(`- ${r.rule}${healthOf(r)} <!-- ${statsOf(r)} -->`);
|
package/src/parser.js
CHANGED
|
@@ -3,6 +3,7 @@ import { readdir, stat } from 'node:fs/promises';
|
|
|
3
3
|
import { createInterface } from 'node:readline';
|
|
4
4
|
import { join, isAbsolute } from 'node:path';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
|
+
import { loadCache, getCached, putCached, saveCache } from './session-cache.js';
|
|
6
7
|
|
|
7
8
|
const CLAUDE_DIR = join(homedir(), '.claude', 'projects');
|
|
8
9
|
|
|
@@ -158,7 +159,9 @@ export async function discoverSessionFiles(options = {}) {
|
|
|
158
159
|
try {
|
|
159
160
|
const s = await stat(fp);
|
|
160
161
|
if (s.mtimeMs >= cutoff) {
|
|
161
|
-
|
|
162
|
+
// `size` pairs with `mtime` as the session-cache key — transcripts
|
|
163
|
+
// are append-only, so the pair identifies a parse result exactly.
|
|
164
|
+
files.push({ path: fp, projectDir: projDir, mtime: s.mtimeMs, size: s.size });
|
|
162
165
|
}
|
|
163
166
|
} catch {
|
|
164
167
|
continue;
|
|
@@ -170,21 +173,43 @@ export async function discoverSessionFiles(options = {}) {
|
|
|
170
173
|
}
|
|
171
174
|
|
|
172
175
|
/**
|
|
173
|
-
* Parse all sessions with concurrency control
|
|
176
|
+
* Parse all sessions with concurrency control, backed by the (path, mtime,
|
|
177
|
+
* size) session cache so repeated runs — above all the statusline, which
|
|
178
|
+
* re-runs this every few seconds — only touch transcripts that changed.
|
|
179
|
+
*
|
|
180
|
+
* The returned sessions carry the aggregate summary WITHOUT the per-request
|
|
181
|
+
* array: it is an aggregation detail no consumer reads, and omitting it on
|
|
182
|
+
* both the cache-hit and fresh-parse paths keeps the two shapes identical.
|
|
183
|
+
* Call `parseSessionFile` directly if you need the raw requests.
|
|
184
|
+
*
|
|
185
|
+
* @param {object} [options] forwarded to discoverSessionFiles
|
|
186
|
+
* @param {boolean} [options.noCache=false] bypass the cache entirely
|
|
174
187
|
*/
|
|
175
188
|
export async function parseAllSessions(options = {}) {
|
|
176
189
|
const files = await discoverSessionFiles(options);
|
|
177
190
|
const concurrency = 10;
|
|
178
191
|
const results = [];
|
|
192
|
+
const useCache = !options.noCache;
|
|
193
|
+
const cache = useCache ? loadCache() : { entries: {} };
|
|
194
|
+
let misses = 0;
|
|
179
195
|
|
|
180
196
|
for (let i = 0; i < files.length; i += concurrency) {
|
|
181
197
|
const batch = files.slice(i, i + concurrency);
|
|
182
198
|
const parsed = await Promise.all(
|
|
183
199
|
batch.map(async (f) => {
|
|
200
|
+
if (useCache) {
|
|
201
|
+
const hit = getCached(cache, f);
|
|
202
|
+
if (hit) return hit;
|
|
203
|
+
}
|
|
184
204
|
try {
|
|
185
205
|
const session = await parseSessionFile(f.path);
|
|
186
206
|
session.projectDir = f.projectDir;
|
|
187
|
-
|
|
207
|
+
const { requests, ...summary } = session;
|
|
208
|
+
if (useCache) {
|
|
209
|
+
putCached(cache, f, session);
|
|
210
|
+
misses++;
|
|
211
|
+
}
|
|
212
|
+
return summary;
|
|
188
213
|
} catch {
|
|
189
214
|
return null;
|
|
190
215
|
}
|
|
@@ -193,5 +218,7 @@ export async function parseAllSessions(options = {}) {
|
|
|
193
218
|
results.push(...parsed.filter(Boolean));
|
|
194
219
|
}
|
|
195
220
|
|
|
221
|
+
if (useCache && misses > 0) saveCache(cache);
|
|
222
|
+
|
|
196
223
|
return results.filter((s) => s.requestCount > 0);
|
|
197
224
|
}
|
package/src/route-scan.js
CHANGED
|
@@ -68,36 +68,42 @@ const CATEGORIES = [
|
|
|
68
68
|
{
|
|
69
69
|
id: 'paste',
|
|
70
70
|
label: '붙여넣은 화면·로그 질문',
|
|
71
|
+
labelEn: 'questions about pasted screens/logs',
|
|
71
72
|
agent: 'haiku-explore',
|
|
72
73
|
kw: null, // matched by length, see categorize()
|
|
73
74
|
},
|
|
74
75
|
{
|
|
75
76
|
id: 'translate',
|
|
76
77
|
label: '배치 번역·정형 텍스트 변환',
|
|
78
|
+
labelEn: 'batch translation / mechanical text transforms',
|
|
77
79
|
agent: 'haiku-translate',
|
|
78
80
|
kw: [[/번역|translate/i, 2], [/변환해|표로 정리|포맷팅/i, 1]],
|
|
79
81
|
},
|
|
80
82
|
{
|
|
81
83
|
id: 'explore',
|
|
82
84
|
label: '탐색·조회 (파일/값 찾기)',
|
|
85
|
+
labelEn: 'lookup (finding files/values)',
|
|
83
86
|
agent: 'haiku-explore',
|
|
84
87
|
kw: [[/grep|검색|search|find/i, 2], [/찾아|어디|위치|목록|살펴/i, 1]],
|
|
85
88
|
},
|
|
86
89
|
{
|
|
87
90
|
id: 'read',
|
|
88
91
|
label: '읽기·요약·설명',
|
|
92
|
+
labelEn: 'reading / summarizing / explaining',
|
|
89
93
|
agent: 'haiku-explore',
|
|
90
94
|
kw: [[/요약|summar|explain/i, 2], [/읽어|설명|정리해|보여줘|알려줘|뭐야|what/i, 1]],
|
|
91
95
|
},
|
|
92
96
|
{
|
|
93
97
|
id: 'check',
|
|
94
98
|
label: '상태 확인·검증',
|
|
99
|
+
labelEn: 'status checks / verification',
|
|
95
100
|
agent: 'haiku-explore',
|
|
96
101
|
kw: [[/확인|검증|verify|점검/i, 2], [/맞아\?|되나|됐나|됐어|되는지|괜찮|체크|check|status/i, 1]],
|
|
97
102
|
},
|
|
98
103
|
{
|
|
99
104
|
id: 'run',
|
|
100
105
|
label: '명령 실행 (빌드·테스트·git)',
|
|
106
|
+
labelEn: 'running commands (build/test/git)',
|
|
101
107
|
agent: 'haiku-runner',
|
|
102
108
|
kw: [[/git |commit|push|npm |pip|빌드해|빌드 돌/i, 2], [/실행|돌려|run |build|빌드|테스트|설치/i, 1]],
|
|
103
109
|
},
|
|
@@ -335,6 +341,7 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
335
341
|
tier,
|
|
336
342
|
category: cat.id,
|
|
337
343
|
label: cat.label,
|
|
344
|
+
labelEn: cat.labelEn,
|
|
338
345
|
agent: tier === 'T2' ? cat.agent : 'sonnet',
|
|
339
346
|
project: projectDir,
|
|
340
347
|
projectPath: '',
|
|
@@ -368,9 +375,15 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
368
375
|
r.tier === g.tier && r.category === g.category &&
|
|
369
376
|
(r.scope === 'global' || r.project === g.project));
|
|
370
377
|
|
|
378
|
+
// Both languages are computed at scan time and stored on the candidate, so
|
|
379
|
+
// switching `language` later re-renders (and promotes) correctly without
|
|
380
|
+
// waiting for a rescan.
|
|
371
381
|
const ruleText = (g) => g.tier === 'T2'
|
|
372
382
|
? `"${g.label}" 유형의 단순 요청(예: "${g.example}")은 ${g.agent}(haiku) 서브에이전트로 위임한다 (설계 판단·배포·스토어 제출 같은 비가역 작업이 섞이면 위임하지 않음)`
|
|
373
383
|
: `"${g.label}" 유형의 중간 난도 요청(예: "${g.example}")은 model: sonnet 서브에이전트로 위임한다 (설계 판단·비가역 작업·반복 에러 발생 시 메인 모델이 이어받음)`;
|
|
384
|
+
const ruleTextEn = (g) => g.tier === 'T2'
|
|
385
|
+
? `Delegate simple "${g.labelEn}" requests (e.g. "${g.example}") to the ${g.agent} (haiku) subagent — never when the request mixes in design judgement or irreversible work like deploy/release/submission`
|
|
386
|
+
: `Delegate moderate "${g.labelEn}" requests (e.g. "${g.example}") to a model: sonnet subagent — hand back to the main model on design judgement, irreversible work, or repeated errors`;
|
|
374
387
|
|
|
375
388
|
const candidates = [...groups.values()]
|
|
376
389
|
.filter((g) => g.count >= MIN_RECURRENCE && !hasRule(g))
|
|
@@ -382,6 +395,7 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
382
395
|
tier: g.tier,
|
|
383
396
|
category: g.category,
|
|
384
397
|
label: g.label,
|
|
398
|
+
labelEn: g.labelEn,
|
|
385
399
|
agent: g.agent,
|
|
386
400
|
project: g.project,
|
|
387
401
|
// Real session cwd for the project (munged `project` is lossy) — lets
|
|
@@ -396,6 +410,7 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
396
410
|
// the same category recurs across 2+ projects (then 'global').
|
|
397
411
|
suggestedScope: 'project',
|
|
398
412
|
rule: ruleText(g),
|
|
413
|
+
ruleEn: ruleTextEn(g),
|
|
399
414
|
}));
|
|
400
415
|
|
|
401
416
|
// Same category appearing in 2+ projects → suggest global for each.
|