claude-token-saver 2.17.0 → 3.0.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 +57 -3
- package/README.md +57 -3
- package/bin/cli.js +254 -4
- package/package.json +1 -1
- package/src/advice.js +49 -34
- package/src/demo.js +11 -4
- package/src/formatters/statusline.js +29 -8
- package/src/formatters/table.js +2 -2
- package/src/frugon-export.js +211 -0
- package/src/harness.js +85 -0
- package/src/history.js +5 -1
- package/src/installer.js +37 -0
- package/src/route-scan.js +286 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* route-scan — detect recurring "easy" work running on expensive models and
|
|
3
|
+
* propose model-delegation ratchet rules.
|
|
4
|
+
*
|
|
5
|
+
* Runs the frugon-style difficulty idea at the EPISODE level (one user
|
|
6
|
+
* request = the consecutive API calls it triggered), because per-call scoring
|
|
7
|
+
* saturates on Claude Code's large session contexts. An episode is easy when
|
|
8
|
+
* the whole request finished in few calls with little generation — exactly
|
|
9
|
+
* the work a haiku subagent could take.
|
|
10
|
+
*
|
|
11
|
+
* Fully local, zero token cost. Results are cached (24h) so the SessionStart
|
|
12
|
+
* hook can read them without re-parsing a month of transcripts.
|
|
13
|
+
*
|
|
14
|
+
* Pipeline position (per design discussion): frugon/this scan is NOT a
|
|
15
|
+
* real-time router — it is a session-boundary calibrator that feeds the
|
|
16
|
+
* existing ratchet promote flow (`harness promote R<N> --project|--global`).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { homedir } from 'node:os';
|
|
22
|
+
import { discoverSessionFiles } from './parser.js';
|
|
23
|
+
import { collectSessionRecords } from './frugon-export.js';
|
|
24
|
+
|
|
25
|
+
// Episode is "easy" when the whole user request finished within these bounds.
|
|
26
|
+
// Calibrated on real data (2026-07): 27% of episodes, 3-6% of tokens.
|
|
27
|
+
export const EASY_MAX_CALLS = 6;
|
|
28
|
+
export const EASY_MAX_OUT_TOKENS = 1500;
|
|
29
|
+
// A pattern must recur this often before we nag about it.
|
|
30
|
+
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
|
+
|
|
34
|
+
// Category → recommended haiku subagent. First match wins; order matters
|
|
35
|
+
// (translate before read: "번역해줘" also matches the read keywords).
|
|
36
|
+
const CATEGORIES = [
|
|
37
|
+
{
|
|
38
|
+
id: 'translate',
|
|
39
|
+
label: '배치 번역·정형 텍스트 변환',
|
|
40
|
+
agent: 'haiku-translate',
|
|
41
|
+
re: /번역|translate|변환해|표로 정리|포맷팅/i,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'explore',
|
|
45
|
+
label: '탐색·조회 (파일/값 찾기)',
|
|
46
|
+
agent: 'haiku-explore',
|
|
47
|
+
re: /grep|검색|찾아|search|find|어디|위치|목록|살펴/i,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'read',
|
|
51
|
+
label: '읽기·요약·설명',
|
|
52
|
+
agent: 'haiku-explore',
|
|
53
|
+
re: /읽어|요약|설명|정리해|summar|explain|보여줘|알려줘|뭐야|what/i,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'run',
|
|
57
|
+
label: '명령 실행 (빌드·테스트·git)',
|
|
58
|
+
agent: 'haiku-runner',
|
|
59
|
+
re: /git |commit|push|실행|돌려|run |build|빌드|테스트|npm |pip|설치/i,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: 'check',
|
|
63
|
+
label: '상태 확인·검증',
|
|
64
|
+
agent: 'haiku-explore',
|
|
65
|
+
re: /확인|맞아\?|되나|됐나|괜찮|체크|check|verify|status|점검/i,
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
// Episodes that are not user-delegable requests: bare continuations, injected
|
|
70
|
+
// notifications, image pastes. These are easy but there is nothing to route.
|
|
71
|
+
const SKIP_RE = /^(계속|이어서|continue|다음|proceed|진행|응|네|넵|ok|okay|yes|ㄱ+|고고)\b/i;
|
|
72
|
+
const SKIP_PREFIX = ['<task-notification', '<system', '[Image:', '<local-command'];
|
|
73
|
+
|
|
74
|
+
function stateDir() {
|
|
75
|
+
if (process.platform === 'win32') {
|
|
76
|
+
return join(process.env.APPDATA || homedir(), 'claude-token-saver');
|
|
77
|
+
}
|
|
78
|
+
if (process.platform === 'darwin') {
|
|
79
|
+
return join(homedir(), 'Library', 'Application Support', 'claude-token-saver');
|
|
80
|
+
}
|
|
81
|
+
const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
82
|
+
return join(xdg, 'claude-token-saver');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function routeScanCachePath() {
|
|
86
|
+
return join(stateDir(), 'route-scan.json');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Munge an absolute path the way Claude Code names project dirs. */
|
|
90
|
+
export function mungeProjectPath(p) {
|
|
91
|
+
return String(p).replace(/[^a-zA-Z0-9-]/g, '-');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function categorize(text) {
|
|
95
|
+
for (const c of CATEGORIES) if (c.re.test(text)) return c;
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isSkippable(text) {
|
|
100
|
+
if (!text) return true;
|
|
101
|
+
if (SKIP_RE.test(text.trim())) return true;
|
|
102
|
+
return SKIP_PREFIX.some((p) => text.startsWith(p));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Group a session's records into episodes (consecutive same trigger prompt). */
|
|
106
|
+
function toEpisodes(records) {
|
|
107
|
+
const episodes = [];
|
|
108
|
+
let cur = null;
|
|
109
|
+
for (const r of records) {
|
|
110
|
+
const text = (r.userText || '').trim();
|
|
111
|
+
if (!cur || cur.text !== text) {
|
|
112
|
+
cur = { text, calls: 0, out: 0, models: new Set(), cwd: '' };
|
|
113
|
+
episodes.push(cur);
|
|
114
|
+
}
|
|
115
|
+
cur.calls += 1;
|
|
116
|
+
cur.out += r.completion_tokens;
|
|
117
|
+
cur.models.add(r.model);
|
|
118
|
+
if (!cur.cwd && r.cwd) cur.cwd = r.cwd;
|
|
119
|
+
}
|
|
120
|
+
return episodes;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isExpensiveModel(model) {
|
|
124
|
+
return !/haiku/i.test(model);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Scan transcripts and build delegation candidates.
|
|
129
|
+
* Returns the cache object (also written to disk).
|
|
130
|
+
*/
|
|
131
|
+
export async function runRouteScan({ days = 14 } = {}) {
|
|
132
|
+
const files = await discoverSessionFiles({ days });
|
|
133
|
+
const groups = new Map(); // "category|project" → aggregate
|
|
134
|
+
let totalEpisodes = 0;
|
|
135
|
+
let easyEpisodes = 0;
|
|
136
|
+
|
|
137
|
+
for (const f of files) {
|
|
138
|
+
let records;
|
|
139
|
+
try {
|
|
140
|
+
// Raw counts are irrelevant here (we classify by output size), and
|
|
141
|
+
// content is required for categorization.
|
|
142
|
+
records = await collectSessionRecords(f.path, { cacheWeighted: false, includeContent: true });
|
|
143
|
+
} catch {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
for (const ep of toEpisodes(records)) {
|
|
147
|
+
if (!ep.text) continue;
|
|
148
|
+
totalEpisodes += 1;
|
|
149
|
+
const easy = ep.calls <= EASY_MAX_CALLS && ep.out <= EASY_MAX_OUT_TOKENS;
|
|
150
|
+
if (!easy) continue;
|
|
151
|
+
easyEpisodes += 1;
|
|
152
|
+
if (isSkippable(ep.text)) continue;
|
|
153
|
+
if (![...ep.models].some(isExpensiveModel)) continue; // already cheap
|
|
154
|
+
const cat = categorize(ep.text);
|
|
155
|
+
if (!cat) continue;
|
|
156
|
+
const key = `${cat.id}|${f.projectDir}`;
|
|
157
|
+
const g = groups.get(key) || {
|
|
158
|
+
category: cat.id,
|
|
159
|
+
label: cat.label,
|
|
160
|
+
agent: cat.agent,
|
|
161
|
+
project: f.projectDir,
|
|
162
|
+
projectPath: '',
|
|
163
|
+
count: 0,
|
|
164
|
+
models: new Set(),
|
|
165
|
+
example: '',
|
|
166
|
+
};
|
|
167
|
+
g.count += 1;
|
|
168
|
+
for (const m of ep.models) g.models.add(m);
|
|
169
|
+
if (!g.projectPath && ep.cwd) g.projectPath = ep.cwd;
|
|
170
|
+
if (!g.example || (ep.text.length < g.example.length && ep.text.length > 10)) {
|
|
171
|
+
g.example = ep.text.slice(0, 80).replace(/\s+/g, ' ');
|
|
172
|
+
}
|
|
173
|
+
groups.set(key, g);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Keep prior dismissed/promoted signatures across rescans.
|
|
178
|
+
const prev = readRouteScan();
|
|
179
|
+
const resolved = new Set(prev?.resolved || []);
|
|
180
|
+
|
|
181
|
+
const candidates = [...groups.values()]
|
|
182
|
+
.filter((g) => g.count >= MIN_RECURRENCE)
|
|
183
|
+
.sort((a, b) => b.count - a.count)
|
|
184
|
+
.slice(0, 5)
|
|
185
|
+
.map((g, i) => ({
|
|
186
|
+
id: i + 1,
|
|
187
|
+
signature: `${g.category}|${g.project}`,
|
|
188
|
+
category: g.category,
|
|
189
|
+
label: g.label,
|
|
190
|
+
agent: g.agent,
|
|
191
|
+
project: g.project,
|
|
192
|
+
// Real session cwd for the project (munged `project` is lossy) — lets
|
|
193
|
+
// `harness promote R<N> --project` write the rule into the project the
|
|
194
|
+
// pattern was detected in, not whatever directory the CLI runs from.
|
|
195
|
+
projectPath: g.projectPath || null,
|
|
196
|
+
count: g.count,
|
|
197
|
+
models: [...g.models],
|
|
198
|
+
example: g.example,
|
|
199
|
+
// Concentrated in one project dir → project rule; the scan groups by
|
|
200
|
+
// project already, so scope suggestion is per-candidate 'project' unless
|
|
201
|
+
// the same category recurs across 2+ projects (then 'global').
|
|
202
|
+
suggestedScope: 'project',
|
|
203
|
+
rule: `"${g.label}" 유형의 단순 요청(예: "${g.example}")은 ${g.agent}(haiku) 서브에이전트로 위임한다`,
|
|
204
|
+
}));
|
|
205
|
+
|
|
206
|
+
// Same category appearing in 2+ projects → suggest global for each.
|
|
207
|
+
const catProjects = new Map();
|
|
208
|
+
for (const c of candidates) {
|
|
209
|
+
catProjects.set(c.category, (catProjects.get(c.category) || 0) + 1);
|
|
210
|
+
}
|
|
211
|
+
for (const c of candidates) {
|
|
212
|
+
if ((catProjects.get(c.category) || 0) >= 2) c.suggestedScope = 'global';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const cache = {
|
|
216
|
+
scannedAt: new Date().toISOString(),
|
|
217
|
+
days,
|
|
218
|
+
totalEpisodes,
|
|
219
|
+
easyEpisodes,
|
|
220
|
+
candidates,
|
|
221
|
+
resolved: [...resolved],
|
|
222
|
+
};
|
|
223
|
+
try {
|
|
224
|
+
const dir = stateDir();
|
|
225
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
226
|
+
writeFileSync(routeScanCachePath(), JSON.stringify(cache, null, 2) + '\n');
|
|
227
|
+
} catch {
|
|
228
|
+
// best-effort — scan results are still returned
|
|
229
|
+
}
|
|
230
|
+
return cache;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Read the cached scan (null when absent/corrupt). */
|
|
234
|
+
export function readRouteScan() {
|
|
235
|
+
try {
|
|
236
|
+
return JSON.parse(readFileSync(routeScanCachePath(), 'utf8'));
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function isCacheFresh(cache) {
|
|
243
|
+
if (!cache?.scannedAt) return false;
|
|
244
|
+
const ts = Date.parse(cache.scannedAt);
|
|
245
|
+
return Number.isFinite(ts) && Date.now() - ts < CACHE_TTL_MS;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Candidates not yet promoted/dismissed. */
|
|
249
|
+
export function openCandidates(cache) {
|
|
250
|
+
if (!cache?.candidates) return [];
|
|
251
|
+
const resolved = new Set(cache.resolved || []);
|
|
252
|
+
return cache.candidates.filter((c) => !resolved.has(c.signature));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Mark a candidate resolved (promoted or dismissed) so the chip stops and
|
|
257
|
+
* rescans don't resurface it. Returns the candidate or null.
|
|
258
|
+
*/
|
|
259
|
+
export function resolveCandidate(id) {
|
|
260
|
+
const cache = readRouteScan();
|
|
261
|
+
if (!cache) return null;
|
|
262
|
+
const cand = (cache.candidates || []).find((c) => c.id === id);
|
|
263
|
+
if (!cand) return null;
|
|
264
|
+
cache.resolved = [...new Set([...(cache.resolved || []), cand.signature])];
|
|
265
|
+
try {
|
|
266
|
+
writeFileSync(routeScanCachePath(), JSON.stringify(cache, null, 2) + '\n');
|
|
267
|
+
} catch {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
return cand;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Statusline helper — cheapest possible check (one small JSON read).
|
|
275
|
+
* Returns `route? #N` for the top open candidate relevant to this project
|
|
276
|
+
* (its own project dir, or a global-scoped suggestion), else null.
|
|
277
|
+
*/
|
|
278
|
+
export function routeWarningForStatusline(projectRoot) {
|
|
279
|
+
const cache = readRouteScan();
|
|
280
|
+
if (!cache) return null;
|
|
281
|
+
const open = openCandidates(cache);
|
|
282
|
+
if (open.length === 0) return null;
|
|
283
|
+
const munged = mungeProjectPath(projectRoot || '');
|
|
284
|
+
const hit = open.find((c) => c.project === munged || c.suggestedScope === 'global');
|
|
285
|
+
return hit ? `route? R${hit.id}` : null;
|
|
286
|
+
}
|