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.
@@ -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
+ }
@@ -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, // 'project'|'global'
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
- /** Render the full ratchet-model.md for one target (scope+root). */
97
- export function renderModelRatchet(rules) {
98
- const lines = [
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 === 'review'
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
- `여러 단계·여러 파일 수정이 얽힌 중간 난도 요청(예: "${t1.example}")은 model: sonnet 서브에이전트로 위임한다. ` +
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
- files.push({ path: fp, projectDir: projDir, mtime: s.mtimeMs });
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
- return session;
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.