claude-token-saver 3.30.1 → 3.34.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 +75 -4
- package/README.md +75 -4
- package/bin/cli.js +45 -4
- package/package.json +1 -1
- package/presets/model-rules.json +115 -0
- package/presets/ratchet-rules.json +38 -0
- package/src/advice.js +8 -5
- package/src/brief.js +1 -1
- package/src/commands/install.js +107 -2
- package/src/commands/route-scan.js +25 -2
- package/src/commands/seed.js +105 -0
- package/src/config.js +25 -0
- package/src/cost.js +6 -1
- package/src/demo.js +8 -7
- package/src/formatters/statusline.js +8 -2
- package/src/formatters/table.js +7 -5
- package/src/harness-analyzer.cjs +9 -4
- package/src/harness.js +21 -8
- package/src/history.js +6 -3
- package/src/hook-manager.js +31 -24
- package/src/hook.cjs +23 -11
- package/src/installer.js +54 -8
- package/src/korean-style.js +11 -3
- package/src/model-rules.js +55 -7
- package/src/parser.js +88 -6
- package/src/route-scan.js +6 -7
- package/src/seed-rules.js +280 -0
- package/src/session-cache.js +5 -1
- package/src/session-records.js +14 -2
- package/src/stats.js +17 -4
- package/src/stdin-payload.js +20 -4
- package/presets/ratchet-rules.md +0 -16
package/src/commands/install.js
CHANGED
|
@@ -16,9 +16,8 @@ class SkipStep extends Error {}
|
|
|
16
16
|
|
|
17
17
|
export async function run({ hasFlag }) {
|
|
18
18
|
const { installAll } = await import('../installer.js');
|
|
19
|
-
const { userLanguage } = await import('../config.js');
|
|
19
|
+
const { userLanguage, languageDecided, setUserLanguage } = await import('../config.js');
|
|
20
20
|
const { canPrompt, confirm } = await import('../prompt.js');
|
|
21
|
-
const lang = userLanguage();
|
|
22
21
|
const force = hasFlag('--force');
|
|
23
22
|
// Two features below write to ~/.claude and cost tokens in every session,
|
|
24
23
|
// so the install shows what they contain and asks before turning them on.
|
|
@@ -26,6 +25,45 @@ export async function run({ hasFlag }) {
|
|
|
26
25
|
// fall through to the previous automatic defaults so an unattended upgrade
|
|
27
26
|
// behaves exactly as it did before.
|
|
28
27
|
const interactive = canPrompt() && !hasFlag('--yes') && !hasFlag('--no-input');
|
|
28
|
+
|
|
29
|
+
// Output language, decided first because every line below it — and every
|
|
30
|
+
// briefing the hooks inject from here on — is written in it. Until now it
|
|
31
|
+
// fell back to English with no question asked, so a Korean user read English
|
|
32
|
+
// reports until they happened to find `mode ko`.
|
|
33
|
+
//
|
|
34
|
+
// The locale is the default, not the answer: a terminal user gets to
|
|
35
|
+
// overrule it either way. An unattended install records the detected locale
|
|
36
|
+
// rather than leaving the setting blank, because "blank" silently means
|
|
37
|
+
// English — the one outcome a Korean-locale machine should not get by
|
|
38
|
+
// default. Already decided means never asked again.
|
|
39
|
+
try {
|
|
40
|
+
if (!languageDecided() && !process.env.CTS_LANG) {
|
|
41
|
+
const { koreanLocaleDetected } = await import('../korean-style.js');
|
|
42
|
+
const detected = koreanLocaleDetected() ? 'ko' : 'en';
|
|
43
|
+
console.log('');
|
|
44
|
+
console.log(' language: reports, warnings and session briefings are written in this language.');
|
|
45
|
+
console.log(` detected locale: ${detected === 'ko' ? 'Korean' : 'not Korean (English)'}`);
|
|
46
|
+
let chosen = detected;
|
|
47
|
+
if (interactive) {
|
|
48
|
+
const ko = await confirm(' Use Korean? (no = English)', { defaultValue: detected === 'ko' });
|
|
49
|
+
chosen = ko ? 'ko' : 'en';
|
|
50
|
+
}
|
|
51
|
+
setUserLanguage(chosen);
|
|
52
|
+
console.log(` language: set to ${chosen === 'ko' ? '한국어' : 'English'} — change it any time with \`claude-token-saver mode lang=${chosen === 'ko' ? 'en' : 'ko'}\``);
|
|
53
|
+
} else if (process.env.CTS_LANG) {
|
|
54
|
+
// Escape hatch for scripted installs, which cannot answer a prompt but
|
|
55
|
+
// do know which language the machine's user reads.
|
|
56
|
+
const forced = setUserLanguage(process.env.CTS_LANG);
|
|
57
|
+
console.log('');
|
|
58
|
+
console.log(forced
|
|
59
|
+
? ` language: set to ${forced} (CTS_LANG)`
|
|
60
|
+
: ` language: ignored CTS_LANG=${process.env.CTS_LANG} — use 'ko' or 'en'`);
|
|
61
|
+
}
|
|
62
|
+
} catch (e) {
|
|
63
|
+
debug('install:language', e); // the English fallback still works
|
|
64
|
+
}
|
|
65
|
+
const lang = userLanguage();
|
|
66
|
+
|
|
29
67
|
const print = (kind, r) => {
|
|
30
68
|
const verb = r.action === 'exists' ? 'already exists' : r.action;
|
|
31
69
|
console.log(` ${kind}: ${r.path} (${verb})`);
|
|
@@ -214,6 +252,73 @@ export async function run({ hasFlag }) {
|
|
|
214
252
|
debug('install:korean-style', e); // optional feature; never fail install
|
|
215
253
|
}
|
|
216
254
|
|
|
255
|
+
// doc2md. The hook itself is two entries in settings.json and costs
|
|
256
|
+
// nothing until a document shows up, so it goes on with the rest of the
|
|
257
|
+
// install instead of waiting for the user to discover `doc2md on` — the
|
|
258
|
+
// people who would save the most tokens are the ones who never find it.
|
|
259
|
+
// The converter is the expensive half (a venv plus a pip install), so it
|
|
260
|
+
// is only built when a human is attached; an unattended postinstall just
|
|
261
|
+
// prints the command.
|
|
262
|
+
try {
|
|
263
|
+
const { createRequire } = await import('node:module');
|
|
264
|
+
const require = createRequire(import.meta.url);
|
|
265
|
+
const doc2md = require('../doc2md.cjs');
|
|
266
|
+
if (process.env.CTS_NO_DOC2MD === '1') {
|
|
267
|
+
console.log('');
|
|
268
|
+
console.log(lang === 'ko'
|
|
269
|
+
? ' doc2md: CTS_NO_DOC2MD=1 이므로 건너뜁니다 (나중에 `doc2md on`).'
|
|
270
|
+
: ' doc2md: skipped (CTS_NO_DOC2MD=1) — run `doc2md on` later.');
|
|
271
|
+
} else {
|
|
272
|
+
const { installDoc2mdHook } = await import('../installer.js');
|
|
273
|
+
const res = installDoc2mdHook();
|
|
274
|
+
console.log('');
|
|
275
|
+
console.log(res.action === 'skipped'
|
|
276
|
+
? ` doc2md: ${res.reason}`
|
|
277
|
+
: ` doc2md: Read/Write hook ${res.action} (${res.path})`);
|
|
278
|
+
console.log(lang === 'ko'
|
|
279
|
+
? ` 대상 형식: ${doc2md.TARGET_EXTENSIONS.join(' ')} — 모델이 읽기 전에 Markdown으로 변환합니다.`
|
|
280
|
+
: ` formats: ${doc2md.TARGET_EXTENSIONS.join(' ')} — converted to Markdown before the model reads them.`);
|
|
281
|
+
let python = doc2md.findInterpreter();
|
|
282
|
+
if (!python && interactive) {
|
|
283
|
+
const build = await confirm(lang === 'ko'
|
|
284
|
+
? ' 변환기(markitdown)를 지금 설치할까요? 몇 분 걸립니다.'
|
|
285
|
+
: ' Install the converter (markitdown) now? Takes a few minutes.', { defaultValue: true });
|
|
286
|
+
if (build) {
|
|
287
|
+
const cres = doc2md.installConverter({ onProgress: (m) => console.log(` ${m}`) });
|
|
288
|
+
if (cres.ok) python = cres.python;
|
|
289
|
+
else console.log(` ${cres.reason}: ${cres.detail || ''}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
console.log(python
|
|
293
|
+
? (lang === 'ko' ? ` 변환기: ${python}` : ` converter: ${python}`)
|
|
294
|
+
: (lang === 'ko'
|
|
295
|
+
? ` 변환기가 없습니다 — \`${doc2md.INSTALL_HINT}\` 를 실행해야 훅이 동작합니다.`
|
|
296
|
+
: ` converter missing — run \`${doc2md.INSTALL_HINT}\` or the hook does nothing.`));
|
|
297
|
+
}
|
|
298
|
+
} catch (e) {
|
|
299
|
+
debug('install:doc2md', e); // optional feature; never fail install
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Starter rules. The asking itself belongs to the first session — a
|
|
303
|
+
// postinstall cannot hold a conversation, and a prompt here would be
|
|
304
|
+
// answered by whoever happens to be at the terminal for a set of rules
|
|
305
|
+
// they have not read. All the install does is say what is waiting.
|
|
306
|
+
try {
|
|
307
|
+
const { pendingSeeds } = await import('../seed-rules.js');
|
|
308
|
+
const pending = pendingSeeds();
|
|
309
|
+
if (pending.length > 0) {
|
|
310
|
+
console.log('');
|
|
311
|
+
console.log(lang === 'ko'
|
|
312
|
+
? ` seed: 추천 룰 ${pending.length}건이 대기 중입니다 (모델 피팅 + 랫쳇 프리셋).`
|
|
313
|
+
: ` seed: ${pending.length} recommended rule(s) are waiting (model-fitting + ratchet presets).`);
|
|
314
|
+
console.log(lang === 'ko'
|
|
315
|
+
? ' 다음 Claude Code 세션에서 한 건씩 등록할지 물어봅니다. 지금 보려면: claude-token-saver seed'
|
|
316
|
+
: ' the next Claude Code session asks about them one at a time. See them now: claude-token-saver seed');
|
|
317
|
+
}
|
|
318
|
+
} catch (e) {
|
|
319
|
+
debug('install:seed-offer', e); // optional feature; never fail install
|
|
320
|
+
}
|
|
321
|
+
|
|
217
322
|
console.log('');
|
|
218
323
|
console.log('Open Claude Code in any directory and just mention:');
|
|
219
324
|
console.log(' "cache hit rate" / "1M context" / "5H cap" — the skill auto-activates.');
|
|
@@ -137,9 +137,17 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
137
137
|
const health = r.status === 'review'
|
|
138
138
|
? (lang === 'ko' ? ' ⚠ 에러율 초과 — 재검토 필요' : ' ⚠ error rate over threshold — needs review')
|
|
139
139
|
: '';
|
|
140
|
+
// A seeded rule has no recurrence of its own until a scan measures one,
|
|
141
|
+
// so "반복 0회 / seen ×0" would misreport a curated preset as work that
|
|
142
|
+
// never happens.
|
|
143
|
+
const evidence = r.origin === 'preset' && !r.count
|
|
144
|
+
? (lang === 'ko' ? '동봉 프리셋 (로그 누적 전)' : 'bundled preset (no local history yet)')
|
|
145
|
+
: (lang === 'ko'
|
|
146
|
+
? `반복 ${r.count || 0}회 · 에러율 ${Math.round((r.errRate || 0) * 100)}%`
|
|
147
|
+
: `seen ×${r.count || 0} · err ${Math.round((r.errRate || 0) * 100)}%`);
|
|
140
148
|
const stat = lang === 'ko'
|
|
141
|
-
? `${r.tier} (${rs.tierLabel(r.tier)}) · ${rs.scopeLabel(r.scope)} ·
|
|
142
|
-
: `${r.tier} (${rs.tierLabel(r.tier, 'en')}) · ${rs.scopeLabel(r.scope, 'en')} ·
|
|
149
|
+
? `${r.tier} (${rs.tierLabel(r.tier)}) · ${rs.scopeLabel(r.scope)} · ${evidence}`
|
|
150
|
+
: `${r.tier} (${rs.tierLabel(r.tier, 'en')}) · ${rs.scopeLabel(r.scope, 'en')} · ${evidence}`;
|
|
143
151
|
console.log(` #${i + 1} ${stat}${health}`);
|
|
144
152
|
// Measured outcome of the rule actually firing, plus what it saved.
|
|
145
153
|
// A rule with no measured delegations shows "—", never "$0.00": the
|
|
@@ -254,6 +262,19 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
254
262
|
if (doc2mdHookRegistered()) doc2mdBlock = require('../doc2md.cjs').sessionNote(lang);
|
|
255
263
|
} catch (e) { debug('route-scan:doc2md-note', e); /* the note is optional */ }
|
|
256
264
|
|
|
265
|
+
// Starter rules that the user has not answered yet (bundled presets, both
|
|
266
|
+
// model-fitting and ratchet). A fresh install has an empty model ratchet
|
|
267
|
+
// and an empty ratchet.md, so without this the tool delegates nothing
|
|
268
|
+
// until the user's own history is long enough to propose a candidate.
|
|
269
|
+
// The asking happens here because a CLI prompt cannot reach a user whose
|
|
270
|
+
// only interface is the chat: the model gets the list and asks one rule
|
|
271
|
+
// at a time. Silent — and free — once everything has an answer.
|
|
272
|
+
let seedBlock = null;
|
|
273
|
+
try {
|
|
274
|
+
const { seedOfferBlock } = await import('../seed-rules.js');
|
|
275
|
+
seedBlock = seedOfferBlock({ lang });
|
|
276
|
+
} catch (e) { debug('route-scan:seed-offer', e); /* the offer is optional */ }
|
|
277
|
+
|
|
257
278
|
// Upgrade offer. A statusline cannot open a dialog, so session start is
|
|
258
279
|
// where the *asking* happens: the model gets one line telling it a newer
|
|
259
280
|
// version exists and to ask before installing anything. Cached read only
|
|
@@ -288,11 +309,13 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
288
309
|
|
|
289
310
|
if (open.length === 0 && reviewRules.length === 0) {
|
|
290
311
|
if (updateBlock) console.log(updateBlock);
|
|
312
|
+
if (seedBlock) console.log(seedBlock);
|
|
291
313
|
if (doc2mdBlock) console.log(doc2mdBlock);
|
|
292
314
|
if (koreanBlock) console.log(koreanBlock);
|
|
293
315
|
return; // nothing else to inject
|
|
294
316
|
}
|
|
295
317
|
if (updateBlock) console.log(updateBlock);
|
|
318
|
+
if (seedBlock) console.log(seedBlock);
|
|
296
319
|
if (doc2mdBlock) console.log(doc2mdBlock);
|
|
297
320
|
// This text is injected straight into the model's context, so it must
|
|
298
321
|
// follow the user's configured language — a Korean-only briefing in an
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subcommand: seed — register the bundled starter rules, one answer at a time.
|
|
3
|
+
*
|
|
4
|
+
* claude-token-saver seed # what is still pending
|
|
5
|
+
* claude-token-saver seed accept <id> --global # register one rule
|
|
6
|
+
* claude-token-saver seed accept <id> --project # ... into this project only
|
|
7
|
+
* claude-token-saver seed accept all --global # when the user says "all of them"
|
|
8
|
+
* claude-token-saver seed skip <id> | skip all # never offer it again
|
|
9
|
+
* claude-token-saver seed reset # make every preset pending again
|
|
10
|
+
*
|
|
11
|
+
* Scope is explicit on purpose, exactly as `harness promote` requires it: the
|
|
12
|
+
* hook environment is non-TTY, so the model has to ask the user and pass the
|
|
13
|
+
* flag rather than let a default decide where a rule lands.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export async function run({ args, hasFlag }) {
|
|
17
|
+
const sub = args[1];
|
|
18
|
+
const seed = await import('../seed-rules.js');
|
|
19
|
+
const { userLanguage } = await import('../config.js');
|
|
20
|
+
const { findProjectRoot } = await import('../harness.js');
|
|
21
|
+
const lang = userLanguage();
|
|
22
|
+
const ko = lang === 'ko';
|
|
23
|
+
const root = findProjectRoot();
|
|
24
|
+
|
|
25
|
+
const scopeFlag = () => (hasFlag('--global') ? 'global' : hasFlag('--project') ? 'project' : null);
|
|
26
|
+
|
|
27
|
+
if (sub === 'reset') {
|
|
28
|
+
const n = seed.resetSeeds();
|
|
29
|
+
console.log(ko
|
|
30
|
+
? `기록된 응답 ${n}건을 지웠습니다 — 프리셋 전체가 다시 제안 대상이 됩니다.`
|
|
31
|
+
: `Cleared ${n} recorded answer(s) — every preset is pending again.`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (sub === 'accept' || sub === 'skip') {
|
|
36
|
+
const ids = args.slice(2).filter((a) => !a.startsWith('-'));
|
|
37
|
+
if (ids.length === 0) {
|
|
38
|
+
console.error(ko
|
|
39
|
+
? 'id를 지정하십시오 (목록: claude-token-saver seed). 전체는 `all`.'
|
|
40
|
+
: 'Pass an id (list them with `claude-token-saver seed`), or `all`.');
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
const pending = seed.pendingSeeds({ lang, root });
|
|
44
|
+
const targets = ids.includes('all') ? pending.map((s) => s.id) : ids;
|
|
45
|
+
|
|
46
|
+
if (sub === 'skip') {
|
|
47
|
+
for (const id of targets) {
|
|
48
|
+
const r = seed.skipSeed(id, { lang, root });
|
|
49
|
+
console.log(r
|
|
50
|
+
? (ko ? `건너뜀: ${id}` : `skipped: ${id}`)
|
|
51
|
+
: (ko ? `대기 중인 프리셋이 아닙니다: ${id}` : `not a pending preset: ${id}`));
|
|
52
|
+
}
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const scope = scopeFlag();
|
|
57
|
+
if (!scope) {
|
|
58
|
+
console.error(ko
|
|
59
|
+
? '적용 범위를 명시하십시오 (사용자에게 먼저 확인): --global 또는 --project'
|
|
60
|
+
: 'Scope required (confirm it with the user first): --global or --project');
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
for (const id of targets) {
|
|
64
|
+
const r = await seed.acceptSeed(id, { scope, root, lang });
|
|
65
|
+
if (!r) {
|
|
66
|
+
console.log(ko ? `대기 중인 프리셋이 아닙니다: ${id}` : `not a pending preset: ${id}`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
console.log(ko
|
|
70
|
+
? `✅ 등록: ${id} [${scope === 'global' ? '전체 프로젝트' : '이 프로젝트'}]`
|
|
71
|
+
: `✅ registered: ${id} [${scope}]`);
|
|
72
|
+
console.log(` ${r.rule}`);
|
|
73
|
+
for (const p of r.paths || (r.path ? [r.path] : [])) console.log(` → ${p}`);
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Default: status. Lists what is pending and what was already answered, so a
|
|
79
|
+
// user who said no can see that they did and reverse it with `reset`.
|
|
80
|
+
const pending = seed.pendingSeeds({ lang, root });
|
|
81
|
+
const { decided } = seed.loadSeedState();
|
|
82
|
+
const answered = Object.entries(decided);
|
|
83
|
+
console.log(ko
|
|
84
|
+
? `seed — 동봉 프리셋 중 대기 ${pending.length}건, 응답 완료 ${answered.length}건`
|
|
85
|
+
: `seed — ${pending.length} preset(s) pending, ${answered.length} already answered`);
|
|
86
|
+
for (const s of pending) {
|
|
87
|
+
const head = s.kind === 'model'
|
|
88
|
+
? `[${s.id}] ${s.tier} · "${s.label}" → ${s.agent}`
|
|
89
|
+
: `[${s.id}] ${ko ? '랫쳇 룰' : 'ratchet rule'}`;
|
|
90
|
+
console.log(` ${head}`);
|
|
91
|
+
console.log(` ${s.ruleText}`);
|
|
92
|
+
}
|
|
93
|
+
if (answered.length > 0) {
|
|
94
|
+
console.log(ko ? ' 응답 기록:' : ' answered:');
|
|
95
|
+
for (const [id, d] of answered) {
|
|
96
|
+
console.log(` ${id}: ${d.action}${d.scope ? ` (${d.scope})` : ''} — ${d.at}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (pending.length > 0) {
|
|
100
|
+
console.log('');
|
|
101
|
+
console.log(ko
|
|
102
|
+
? '등록: claude-token-saver seed accept <id> --global|--project · 거절: seed skip <id>'
|
|
103
|
+
: 'register: claude-token-saver seed accept <id> --global|--project · decline: seed skip <id>');
|
|
104
|
+
}
|
|
105
|
+
}
|
package/src/config.js
CHANGED
|
@@ -197,6 +197,31 @@ export function userLanguage() {
|
|
|
197
197
|
return v === 'ko' ? 'ko' : 'en';
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Whether the language is a recorded choice rather than the 'en' fallback.
|
|
202
|
+
*
|
|
203
|
+
* The install needs the distinction: a user who picked English must not be
|
|
204
|
+
* asked again on the next upgrade, and "nothing recorded" has to stay
|
|
205
|
+
* distinguishable from "chose en", which `userLanguage()` alone cannot say.
|
|
206
|
+
*/
|
|
207
|
+
export function languageDecided() {
|
|
208
|
+
const cfg = loadConfig();
|
|
209
|
+
const v = cfg.language || (cfg.statusline && cfg.statusline.language);
|
|
210
|
+
return v === 'ko' || v === 'en';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Record the output language ('ko' | 'en'). Anything else is ignored. */
|
|
214
|
+
export function setUserLanguage(v) {
|
|
215
|
+
const lang = v === 'ko' ? 'ko' : v === 'en' ? 'en' : null;
|
|
216
|
+
if (!lang) return null;
|
|
217
|
+
const cfg = loadConfig();
|
|
218
|
+
cfg.language = lang;
|
|
219
|
+
// The legacy slot would win on a later read for configs written by 2.9.x.
|
|
220
|
+
if (cfg.statusline) delete cfg.statusline.language;
|
|
221
|
+
saveConfig(cfg);
|
|
222
|
+
return lang;
|
|
223
|
+
}
|
|
224
|
+
|
|
200
225
|
/**
|
|
201
226
|
* Render hours as the most natural unit:
|
|
202
227
|
* 24h → "1d", 168h → "7d", 6h → "6h", 36h → "36h" (not whole days).
|
package/src/cost.js
CHANGED
|
@@ -125,7 +125,12 @@ const TIER_RANK = {
|
|
|
125
125
|
* from the ranking instead of guessed at.
|
|
126
126
|
*/
|
|
127
127
|
export function isUnknownModel(model) {
|
|
128
|
-
|
|
128
|
+
if (!model) return true;
|
|
129
|
+
const m = String(model).toLowerCase();
|
|
130
|
+
// "<synthetic>" is Claude Code's placeholder for locally-generated error
|
|
131
|
+
// stubs — no real API call happened, so pricing it as Sonnet would be a
|
|
132
|
+
// silent guess (session-records.js skips it for the same reason).
|
|
133
|
+
return m === 'unknown' || m === '<synthetic>';
|
|
129
134
|
}
|
|
130
135
|
|
|
131
136
|
/**
|
package/src/demo.js
CHANGED
|
@@ -99,15 +99,16 @@ const SCENARIOS = [
|
|
|
99
99
|
},
|
|
100
100
|
{
|
|
101
101
|
name: 'ctx-1m',
|
|
102
|
-
label: '⚠ Context past
|
|
102
|
+
label: '⚠ Context past 500k',
|
|
103
103
|
data: {
|
|
104
104
|
hitRate: 0.78,
|
|
105
105
|
pct1h: 0.92,
|
|
106
106
|
savings: 1340,
|
|
107
107
|
elapsedSec: 30,
|
|
108
108
|
contextSize: '1M',
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
ctxOverWarn: true,
|
|
110
|
+
ctxUsedPct: 62, // 62% of 1M ≈ 620k actually in context
|
|
111
|
+
spikeChip: '⚠ Ctx 500k+',
|
|
111
112
|
caps: HEALTHY_CAPS,
|
|
112
113
|
},
|
|
113
114
|
},
|
|
@@ -271,9 +272,9 @@ export function buildTableDemoData(options = {}) {
|
|
|
271
272
|
version: options.version ?? '',
|
|
272
273
|
},
|
|
273
274
|
spikeReport: { spikes, baseline: { p95: 940_000 } },
|
|
274
|
-
contextWindow: { size: '1M', maxContext:
|
|
275
|
+
contextWindow: { size: '1M', maxContext: 620_000, overWarn: true },
|
|
275
276
|
lastActivity: Date.now() - 60 * 1000,
|
|
276
|
-
spikeChip: '⚠ Ctx
|
|
277
|
+
spikeChip: '⚠ Ctx 500k+',
|
|
277
278
|
};
|
|
278
279
|
}
|
|
279
280
|
|
|
@@ -303,7 +304,7 @@ export function buildScenarioData(scenarioName, options) {
|
|
|
303
304
|
if (!scenario) return null;
|
|
304
305
|
}
|
|
305
306
|
|
|
306
|
-
const { hitRate, pct1h, pct5m, savings, elapsedSec, contextSize, ctxUsedPct, spikeChip, caps } = scenario.data;
|
|
307
|
+
const { hitRate, pct1h, pct5m, savings, elapsedSec, contextSize, ctxOverWarn, ctxUsedPct, spikeChip, caps } = scenario.data;
|
|
307
308
|
return {
|
|
308
309
|
summary: { hitRate },
|
|
309
310
|
ttl: { pct1h, pct5m: pct5m ?? (1 - pct1h) },
|
|
@@ -315,7 +316,7 @@ export function buildScenarioData(scenarioName, options) {
|
|
|
315
316
|
version: options.version ?? '',
|
|
316
317
|
},
|
|
317
318
|
lastActivity: Date.now() - elapsedSec * 1000,
|
|
318
|
-
contextWindow: { size: contextSize },
|
|
319
|
+
contextWindow: { size: contextSize, overWarn: Boolean(ctxOverWarn) },
|
|
319
320
|
// Live fill level (`📦 68%`) — scenarios that set ctxUsedPct exercise the
|
|
320
321
|
// stdin-driven segment; the rest fall back to the size-based chip.
|
|
321
322
|
ctxLive: ctxUsedPct != null
|
|
@@ -51,7 +51,11 @@ const GRAY = fg(100, 116, 139, '\x1b[90m');
|
|
|
51
51
|
const BOLD = '\x1b[1m';
|
|
52
52
|
|
|
53
53
|
function formatMoney(usd) {
|
|
54
|
-
|
|
54
|
+
// Boundaries sit at 999.5/999500 so a value that would round up into the
|
|
55
|
+
// next band's width ($999.9 → "$1000") jumps to that band's unit instead —
|
|
56
|
+
// otherwise "$1000" renders visually larger than "$1.0K".
|
|
57
|
+
if (usd >= 999500) return `$${(usd / 1e6).toFixed(1)}M`;
|
|
58
|
+
if (usd >= 999.5) return `$${(usd / 1000).toFixed(1)}K`;
|
|
55
59
|
if (usd >= 100) return `$${usd.toFixed(0)}`;
|
|
56
60
|
if (usd >= 10) return `$${usd.toFixed(1)}`;
|
|
57
61
|
return `$${usd.toFixed(2)}`;
|
|
@@ -541,7 +545,9 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
541
545
|
}
|
|
542
546
|
} else if (contextWindow && contextWindow.size && contextWindow.size !== 'unknown') {
|
|
543
547
|
const label = contextWindow.size === '1M' ? '1M' : '200k';
|
|
544
|
-
|
|
548
|
+
// Yellow is reserved for the 500k warn line. A 1M window by itself is the
|
|
549
|
+
// default on every current model, so colouring it yellow cried wolf.
|
|
550
|
+
const ctxColor = contextWindow.overWarn ? YELLOW : GREEN;
|
|
545
551
|
if (isIcon && verbose) {
|
|
546
552
|
ctxSeg = `${c(ctxColor)}📦 Ctx ${label}${c(RESET)}`;
|
|
547
553
|
} else if (isIcon) {
|
package/src/formatters/table.js
CHANGED
|
@@ -84,9 +84,9 @@ function renderSpikeSection(spikes, contextWindow) {
|
|
|
84
84
|
const lines = [];
|
|
85
85
|
lines.push(rbl(' ⚠ Token spike detected'));
|
|
86
86
|
lines.push(r(` ${'─'.repeat(50)}`));
|
|
87
|
-
if (contextWindow && contextWindow.
|
|
87
|
+
if (contextWindow && contextWindow.overWarn) {
|
|
88
88
|
lines.push(
|
|
89
|
-
r(` Context usage exceeded
|
|
89
|
+
r(` Context usage exceeded 500k (max recent single-request input ${formatContextSize(contextWindow.maxContext)} tokens)`),
|
|
90
90
|
);
|
|
91
91
|
lines.push('');
|
|
92
92
|
}
|
|
@@ -196,9 +196,11 @@ export function formatReport({ summary: sum, trend, ttl, anomalies, cost, option
|
|
|
196
196
|
// Context window chip for the normal case too
|
|
197
197
|
if (contextWindow && contextWindow.size !== 'unknown') {
|
|
198
198
|
const note =
|
|
199
|
-
contextWindow.
|
|
200
|
-
? '⚠ Context exceeded
|
|
201
|
-
:
|
|
199
|
+
contextWindow.overWarn
|
|
200
|
+
? '⚠ Context exceeded 500k in recent requests — big contexts re-bill every turn and drain the 5H/7D caps. Use /compact or /clear.'
|
|
201
|
+
: contextWindow.size === '1M'
|
|
202
|
+
? '✓ 1M window in use, under the 500k warn line'
|
|
203
|
+
: '✓ 200k context (standard)';
|
|
202
204
|
lines.push(` Context window: ${contextWindow.size} ${note}`);
|
|
203
205
|
lines.push(` (max recent single-request input ${formatContextSize(contextWindow.maxContext)} tokens)`);
|
|
204
206
|
lines.push('');
|
package/src/harness-analyzer.cjs
CHANGED
|
@@ -39,15 +39,20 @@ const PEV_TOOLUSE_THRESHOLD = 5;
|
|
|
39
39
|
// researching, which is exactly the behavior we don't want to punish.
|
|
40
40
|
const MUTATING_TOOL_RE = /^(edit|write|multiedit|notebookedit|bash)$/i;
|
|
41
41
|
|
|
42
|
+
// Mirrors src/paths.js userDataDir() exactly (same order as doc2md.cjs).
|
|
43
|
+
// Duplicated because this file is CommonJS and paths.js is ESM; the
|
|
44
|
+
// precedence must match or state lands where the rest of the tool won't look.
|
|
42
45
|
function stateDir() {
|
|
43
|
-
if (process.
|
|
44
|
-
return path.join(process.env.
|
|
46
|
+
if (process.env.XDG_CONFIG_HOME) {
|
|
47
|
+
return path.join(process.env.XDG_CONFIG_HOME, 'claude-token-saver');
|
|
48
|
+
}
|
|
49
|
+
if (process.platform === 'win32' && process.env.APPDATA) {
|
|
50
|
+
return path.join(process.env.APPDATA, 'claude-token-saver');
|
|
45
51
|
}
|
|
46
52
|
if (process.platform === 'darwin') {
|
|
47
53
|
return path.join(os.homedir(), 'Library', 'Application Support', 'claude-token-saver');
|
|
48
54
|
}
|
|
49
|
-
|
|
50
|
-
return path.join(xdg, 'claude-token-saver');
|
|
55
|
+
return path.join(os.homedir(), '.config', 'claude-token-saver');
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
function readJsonl(file) {
|
package/src/harness.js
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
import { routeWarningForStatusline } from './route-scan.js';
|
|
26
26
|
import { ruleHealthWarningForStatusline, modelRatchetPathFor, renderModelRatchet } from './model-rules.js';
|
|
27
27
|
import { compactWindowWarningForStatusline } from './compact-window.js';
|
|
28
|
+
import { userLanguage } from './config.js';
|
|
28
29
|
|
|
29
30
|
const require = createRequire(import.meta.url);
|
|
30
31
|
function readHarnessState() {
|
|
@@ -366,23 +367,35 @@ export function harnessPull({ root = findProjectRoot(), scope = 'global' } = {})
|
|
|
366
367
|
return result;
|
|
367
368
|
}
|
|
368
369
|
|
|
369
|
-
/**
|
|
370
|
-
|
|
370
|
+
/**
|
|
371
|
+
* The bundled preset rules, both languages per rule.
|
|
372
|
+
*
|
|
373
|
+
* These strings are injected into the model's context (by `seed`) and written
|
|
374
|
+
* into the user's ratchet (by `pull`), so a Korean-only set would pull an
|
|
375
|
+
* English session's responses into Korean — hence the pair. The `ko` text is
|
|
376
|
+
* canonical: it is what identifies a rule, so revising an English wording never
|
|
377
|
+
* turns a rule the user already answered into a new one.
|
|
378
|
+
*/
|
|
379
|
+
export function presetRuleEntries() {
|
|
371
380
|
try {
|
|
372
381
|
// fileURLToPath, not `.pathname`: the latter yields `/D:/repo/src` on
|
|
373
382
|
// Windows, where the read fails and the catch below turns a missing
|
|
374
383
|
// file into an empty rule set without ever saying so.
|
|
375
|
-
const path = join(dirname(fileURLToPath(import.meta.url)), '..', 'presets', 'ratchet-rules.
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
.filter((
|
|
379
|
-
.map((
|
|
380
|
-
.filter(Boolean);
|
|
384
|
+
const path = join(dirname(fileURLToPath(import.meta.url)), '..', 'presets', 'ratchet-rules.json');
|
|
385
|
+
const data = JSON.parse(readFileSync(path, 'utf8'));
|
|
386
|
+
return (Array.isArray(data.rules) ? data.rules : [])
|
|
387
|
+
.filter((r) => r && typeof r.ko === 'string' && r.ko.trim())
|
|
388
|
+
.map((r) => ({ ko: r.ko.trim(), en: (r.en || r.ko).trim() }));
|
|
381
389
|
} catch {
|
|
382
390
|
return [];
|
|
383
391
|
}
|
|
384
392
|
}
|
|
385
393
|
|
|
394
|
+
/** Preset rule text in the user's configured language. */
|
|
395
|
+
export function presetRules(lang = userLanguage()) {
|
|
396
|
+
return presetRuleEntries().map((r) => (lang === 'ko' ? r.ko : r.en));
|
|
397
|
+
}
|
|
398
|
+
|
|
386
399
|
/**
|
|
387
400
|
* harness list — return numbered ratchet rules from .claude/ratchet.md.
|
|
388
401
|
* Numbering is 1-based and matches `harness rm <N>`.
|
package/src/history.js
CHANGED
|
@@ -69,7 +69,8 @@ function saveState(state) {
|
|
|
69
69
|
function chipKo(chip) {
|
|
70
70
|
if (!chip) return chip;
|
|
71
71
|
const map = {
|
|
72
|
-
'⚠ Ctx
|
|
72
|
+
'⚠ Ctx 500k+': '⚠ 컨텍스트 500k 초과',
|
|
73
|
+
'⚠ Ctx 200k+': '⚠ 컨텍스트 200k 초과', // legacy (pre-v3.32)
|
|
73
74
|
'⚠ 1M ON': '⚠ 1M 컨텍스트 활성', // legacy (pre-v2.18)
|
|
74
75
|
'⚠ Cache miss': '⚠ 캐시 미스',
|
|
75
76
|
'⚠ Rebuild churn': '⚠ 캐시 재빌드 빈발',
|
|
@@ -93,8 +94,10 @@ function chipKo(chip) {
|
|
|
93
94
|
*/
|
|
94
95
|
function detailKo(detail) {
|
|
95
96
|
if (!detail) return detail;
|
|
96
|
-
|
|
97
|
-
|
|
97
|
+
// The threshold moved from 200k to 500k in v3.32, so both spellings have to
|
|
98
|
+
// resolve: history files written by older versions still carry the old one.
|
|
99
|
+
const m0 = detail.match(/^Single-request context exceeded (200|500)k \(max (\d+)k tokens\)$/);
|
|
100
|
+
if (m0) return `단일 요청 컨텍스트 ${m0[1]}k 초과 (최대 ${m0[2]}k 토큰)`;
|
|
98
101
|
// legacy detail shape (pre-v2.18)
|
|
99
102
|
const m1 = detail.match(/^Context auto-promoted to 1M \(max single-request (\d+)k tokens\)$/);
|
|
100
103
|
if (m1) return `1M 컨텍스트 자동 활성 (단일 요청 최대 ${m1[1]}k 토큰)`;
|
package/src/hook-manager.js
CHANGED
|
@@ -1,21 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Install/uninstall the
|
|
2
|
+
* Install/uninstall the cache-monitor PostToolUse hook in ~/.claude/settings.json
|
|
3
|
+
*
|
|
4
|
+
* Registered as a CLI subcommand (`claude-token-saver --hook-run`) like every
|
|
5
|
+
* other hook in this package — NOT as a copied file. The old copy-to-home
|
|
6
|
+
* approach pinned a stale hook.cjs in ~/.claude/ forever, never shipped
|
|
7
|
+
* harness-analyzer.cjs alongside it, and survived `uninstall` because its
|
|
8
|
+
* command line did not contain the CLI name. Registering the CLI itself
|
|
9
|
+
* removes the copy, the staleness, and the orphan in one move.
|
|
3
10
|
*/
|
|
4
11
|
|
|
5
|
-
import { readFile, writeFile,
|
|
6
|
-
import { join
|
|
12
|
+
import { readFile, writeFile, rm } from 'node:fs/promises';
|
|
13
|
+
import { join } from 'node:path';
|
|
7
14
|
import { homedir } from 'node:os';
|
|
8
|
-
import { fileURLToPath } from 'node:url';
|
|
9
15
|
|
|
10
16
|
const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
|
|
11
|
-
|
|
12
|
-
|
|
17
|
+
// Legacy copied-file location — removed on install/uninstall so machines that
|
|
18
|
+
// installed an older version don't keep a dead hook script around.
|
|
19
|
+
const LEGACY_HOOK_DEST = join(homedir(), '.claude', 'cache-monitor-hook.cjs');
|
|
13
20
|
const HOOK_MARKER = 'cache-monitor-hook';
|
|
14
21
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
22
|
+
function isCacheMonitorHook(nh) {
|
|
23
|
+
const cmd = nh?.command;
|
|
24
|
+
if (typeof cmd !== 'string') return false;
|
|
25
|
+
return cmd.includes(HOOK_MARKER) || cmd.includes('--hook-run');
|
|
26
|
+
}
|
|
18
27
|
|
|
28
|
+
export async function installHook({ threshold = 0.7 } = {}) {
|
|
19
29
|
let settings;
|
|
20
30
|
try {
|
|
21
31
|
const raw = await readFile(SETTINGS_PATH, 'utf8');
|
|
@@ -27,24 +37,18 @@ export async function installHook({ threshold = 0.7 } = {}) {
|
|
|
27
37
|
if (!settings.hooks) settings.hooks = {};
|
|
28
38
|
if (!Array.isArray(settings.hooks.PostToolUse)) settings.hooks.PostToolUse = [];
|
|
29
39
|
|
|
30
|
-
// Remove existing cache-monitor hook if present
|
|
40
|
+
// Remove existing cache-monitor hook if present — matches both the current
|
|
41
|
+
// subcommand form and the legacy copied-file form.
|
|
31
42
|
settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(
|
|
32
|
-
(h) =>
|
|
33
|
-
// Check nested hooks structure
|
|
34
|
-
const nested = h.hooks || [];
|
|
35
|
-
return !nested.some((nh) => nh.command?.includes(HOOK_MARKER));
|
|
36
|
-
},
|
|
43
|
+
(h) => !(h.hooks || []).some(isCacheMonitorHook),
|
|
37
44
|
);
|
|
38
45
|
|
|
39
|
-
// Add new hook (correct 3-level nested structure)
|
|
40
|
-
// Normalize path separators for Windows compatibility in shell commands
|
|
41
|
-
const hookPath = HOOK_DEST.replace(/\\/g, '/');
|
|
42
46
|
settings.hooks.PostToolUse.push({
|
|
43
47
|
matcher: 'Bash|Edit|Write',
|
|
44
48
|
hooks: [
|
|
45
49
|
{
|
|
46
50
|
type: 'command',
|
|
47
|
-
command: `
|
|
51
|
+
command: `claude-token-saver --hook-run --threshold ${threshold}`,
|
|
48
52
|
timeout: 10,
|
|
49
53
|
},
|
|
50
54
|
],
|
|
@@ -52,7 +56,10 @@ export async function installHook({ threshold = 0.7 } = {}) {
|
|
|
52
56
|
|
|
53
57
|
await writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
54
58
|
|
|
55
|
-
|
|
59
|
+
// Clean up the legacy copy left by older versions.
|
|
60
|
+
await rm(LEGACY_HOOK_DEST, { force: true }).catch(() => {});
|
|
61
|
+
|
|
62
|
+
console.log('✓ Hook installed (PostToolUse → claude-token-saver --hook-run)');
|
|
56
63
|
console.log(` Settings updated: ${SETTINGS_PATH}`);
|
|
57
64
|
console.log(` Threshold: ${(threshold * 100).toFixed(0)}%`);
|
|
58
65
|
console.log(` Stats file: ~/.claude/cache-stats.jsonl`);
|
|
@@ -71,10 +78,7 @@ export async function uninstallHook() {
|
|
|
71
78
|
if (settings.hooks?.PostToolUse) {
|
|
72
79
|
const before = settings.hooks.PostToolUse.length;
|
|
73
80
|
settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(
|
|
74
|
-
(h) =>
|
|
75
|
-
const nested = h.hooks || [];
|
|
76
|
-
return !nested.some((nh) => nh.command?.includes(HOOK_MARKER));
|
|
77
|
-
},
|
|
81
|
+
(h) => !(h.hooks || []).some(isCacheMonitorHook),
|
|
78
82
|
);
|
|
79
83
|
const removed = before - settings.hooks.PostToolUse.length;
|
|
80
84
|
|
|
@@ -86,4 +90,7 @@ export async function uninstallHook() {
|
|
|
86
90
|
} else {
|
|
87
91
|
console.log('No cache-monitor hook found in settings.');
|
|
88
92
|
}
|
|
93
|
+
|
|
94
|
+
// The legacy copied hook file is dead weight either way.
|
|
95
|
+
await rm(LEGACY_HOOK_DEST, { force: true }).catch(() => {});
|
|
89
96
|
}
|