sprag-cli 3.40.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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +637 -0
  3. package/README.md +758 -0
  4. package/bin/cli.js +801 -0
  5. package/examples/statusline-command.ps1 +43 -0
  6. package/examples/statusline-command.sh +36 -0
  7. package/package.json +62 -0
  8. package/presets/cohesion/cohesion-en.md +26 -0
  9. package/presets/doc2md/convert.py +363 -0
  10. package/presets/korean-style/LICENSE-fluent-korean +21 -0
  11. package/presets/korean-style/fluent-korean.md +52 -0
  12. package/presets/korean-style/supplement.md +93 -0
  13. package/presets/model-rules.json +115 -0
  14. package/presets/ratchet-rules.json +38 -0
  15. package/src/advice.js +564 -0
  16. package/src/agents.js +52 -0
  17. package/src/brief.js +264 -0
  18. package/src/caps-cache.js +84 -0
  19. package/src/cli-args.js +51 -0
  20. package/src/cohesion.js +70 -0
  21. package/src/commands/brief.js +31 -0
  22. package/src/commands/cohesion.js +59 -0
  23. package/src/commands/compact-window.js +93 -0
  24. package/src/commands/doc2md.js +166 -0
  25. package/src/commands/feedback.js +132 -0
  26. package/src/commands/handoff.js +33 -0
  27. package/src/commands/harness.js +459 -0
  28. package/src/commands/history.js +46 -0
  29. package/src/commands/install.js +358 -0
  30. package/src/commands/korean.js +220 -0
  31. package/src/commands/last.js +151 -0
  32. package/src/commands/mode.js +46 -0
  33. package/src/commands/route-scan.js +454 -0
  34. package/src/commands/seed.js +105 -0
  35. package/src/commands/uninstall.js +42 -0
  36. package/src/commands/update-check.js +77 -0
  37. package/src/commands/upgrade.js +68 -0
  38. package/src/compact-window.js +205 -0
  39. package/src/config.js +232 -0
  40. package/src/cost.js +253 -0
  41. package/src/debug.js +29 -0
  42. package/src/demo.js +331 -0
  43. package/src/doc2md-ledger.cjs +227 -0
  44. package/src/doc2md.cjs +997 -0
  45. package/src/fig2md-runner.cjs +21 -0
  46. package/src/fig2md.cjs +191 -0
  47. package/src/first-run-note.js +63 -0
  48. package/src/format-time.js +44 -0
  49. package/src/formatters/csv.js +8 -0
  50. package/src/formatters/json.js +3 -0
  51. package/src/formatters/statusline.js +750 -0
  52. package/src/formatters/table.js +299 -0
  53. package/src/handoff.js +161 -0
  54. package/src/harness-analyzer.cjs +264 -0
  55. package/src/harness-templates.js +153 -0
  56. package/src/harness.js +613 -0
  57. package/src/history.js +383 -0
  58. package/src/hook-manager.js +96 -0
  59. package/src/hook.cjs +196 -0
  60. package/src/installer.js +614 -0
  61. package/src/korean-lint.cjs +303 -0
  62. package/src/korean-style.js +187 -0
  63. package/src/litellm-budget.js +223 -0
  64. package/src/model-alias.js +484 -0
  65. package/src/model-rules.js +527 -0
  66. package/src/month-spend.js +47 -0
  67. package/src/parser.js +330 -0
  68. package/src/paths.js +41 -0
  69. package/src/prompt.js +52 -0
  70. package/src/route-scan.js +832 -0
  71. package/src/savings-ledger.js +137 -0
  72. package/src/seed-rules.js +280 -0
  73. package/src/session-cache.js +160 -0
  74. package/src/session-records.js +188 -0
  75. package/src/stats.js +380 -0
  76. package/src/stdin-payload.js +122 -0
  77. package/src/subagent-records.js +214 -0
  78. package/src/update-check.js +201 -0
  79. package/src/window-labels.js +64 -0
package/src/history.js ADDED
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Warning history — appends an entry whenever the active chip transitions
3
+ * (none → warning, warning A → warning B, warning → none). One markdown file
4
+ * per calendar day so users can pinpoint "when did this start" easily.
5
+ *
6
+ * Each event is written bilingually: the canonical English line first, the
7
+ * Korean translation as an indented "└" continuation right below. The chip
8
+ * text itself stays as-is (its symbol+English is part of the UX surface), but
9
+ * the diagnostic detail and resolved-status verbs are translated.
10
+ *
11
+ * Storage path is platform-aware (see paths.userDataDir):
12
+ * Windows: %APPDATA%\claude-token-saver\history\YYYY-MM-DD.md
13
+ * macOS: ~/Library/Application Support/claude-token-saver/history/YYYY-MM-DD.md
14
+ * Linux: ~/.config/claude-token-saver/history/YYYY-MM-DD.md
15
+ *
16
+ * State (last-seen chip, prevents duplicate appends every 1s refresh):
17
+ * <userDataDir>/last-chip.json
18
+ */
19
+
20
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { userDataDir } from './paths.js';
23
+ import { formatResetIn, formatResetClock } from './format-time.js';
24
+ import { labelForKey } from './window-labels.js';
25
+ import { ISSUE_TIPS, CHIP_TO_CODES, CAP_TIPS } from './advice.js';
26
+
27
+ const BASE_DIR = userDataDir();
28
+ const HISTORY_DIR = join(BASE_DIR, 'history');
29
+ const STATE_PATH = join(BASE_DIR, 'last-chip.json');
30
+
31
+ export function historyDir() {
32
+ return HISTORY_DIR;
33
+ }
34
+
35
+ function ensureDir(p) {
36
+ if (!existsSync(p)) mkdirSync(p, { recursive: true });
37
+ }
38
+
39
+ function pad(n) {
40
+ return String(n).padStart(2, '0');
41
+ }
42
+
43
+ function ymd(d = new Date()) {
44
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
45
+ }
46
+
47
+ function hms(d = new Date()) {
48
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
49
+ }
50
+
51
+ function loadState() {
52
+ try {
53
+ return JSON.parse(readFileSync(STATE_PATH, 'utf8'));
54
+ } catch {
55
+ return { chip: null, ts: null };
56
+ }
57
+ }
58
+
59
+ function saveState(state) {
60
+ ensureDir(BASE_DIR);
61
+ writeFileSync(STATE_PATH, JSON.stringify(state) + '\n');
62
+ }
63
+
64
+ /**
65
+ * Map a chip's English label to its Korean equivalent.
66
+ * Returns the input unchanged if no mapping is registered (forward-compat
67
+ * with chips added in advice.js after this map was last updated).
68
+ */
69
+ function chipKo(chip) {
70
+ if (!chip) return chip;
71
+ const map = {
72
+ '⚠ Ctx 500k+': '⚠ 컨텍스트 500k 초과',
73
+ '⚠ Ctx 200k+': '⚠ 컨텍스트 200k 초과', // legacy (pre-v3.32)
74
+ '⚠ 1M ON': '⚠ 1M 컨텍스트 활성', // legacy (pre-v2.18)
75
+ '⚠ Cache miss': '⚠ 캐시 미스',
76
+ '⚠ Rebuild churn': '⚠ 캐시 재빌드 빈발',
77
+ '⚠ Input spike': '⚠ 입력 급증',
78
+ '⚠ Output heavy': '⚠ 출력 과다',
79
+ '⚠ Call surge': '⚠ 호출 급증',
80
+ '⚠ 5m TTL': '⚠ 5분 TTL',
81
+ '⏳ Cache expires': '⏳ 캐시 만료 임박',
82
+ '💰 Cache saved': '💰 캐시 절약',
83
+ '🧠 Cache hit': '🧠 캐시 적중',
84
+ };
85
+ return map[chip] || chip;
86
+ }
87
+
88
+ /**
89
+ * Translate the diagnostic detail string to Korean. The detail is constructed
90
+ * in cli.js and follows two stable shapes:
91
+ * "Context auto-promoted to 1M (max single-request {N}k tokens)"
92
+ * "session {ID}: CODE_A, CODE_B"
93
+ * Anything else falls through unchanged.
94
+ */
95
+ function detailKo(detail) {
96
+ if (!detail) return detail;
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 토큰)`;
101
+ // legacy detail shape (pre-v2.18)
102
+ const m1 = detail.match(/^Context auto-promoted to 1M \(max single-request (\d+)k tokens\)$/);
103
+ if (m1) return `1M 컨텍스트 자동 활성 (단일 요청 최대 ${m1[1]}k 토큰)`;
104
+ const m2 = detail.match(/^session ([^:]+): (.+)$/);
105
+ if (m2) {
106
+ const codeKo = {
107
+ LOW_HIT_RATE: '캐시 적중률 낮음',
108
+ FREQUENT_CACHE_REBUILD: '캐시 재빌드 빈발',
109
+ OUTPUT_HEAVY: '출력 과다',
110
+ INPUT_SPIKE: '입력 급증',
111
+ CALL_SURGE: '호출 급증',
112
+ TTL_5M: '5분 TTL',
113
+ };
114
+ const codes = m2[2]
115
+ .split(',')
116
+ .map((c) => c.trim())
117
+ .map((c) => codeKo[c] || c)
118
+ .join(', ');
119
+ return `세션 ${m2[1]}: ${codes}`;
120
+ }
121
+ return detail;
122
+ }
123
+
124
+ /**
125
+ * Resolve the diagnostic codes that apply to a transition. We try, in order:
126
+ * 1. `detail` of shape "session ID: CODE_A, CODE_B" (chip transitions with
127
+ * explicit per-session codes — the richest source).
128
+ * 2. `chip` text mapped via CHIP_TO_CODES (covers 1M ON and chips that fire
129
+ * without a per-session detail).
130
+ * Returns an array of unique codes, possibly empty.
131
+ */
132
+ function codesForEvent(chip, detail) {
133
+ const out = [];
134
+ if (detail) {
135
+ const m = detail.match(/^session [^:]+:\s*(.+)$/);
136
+ if (m) {
137
+ for (const c of m[1].split(',').map((s) => s.trim()).filter(Boolean)) {
138
+ if (!out.includes(c)) out.push(c);
139
+ }
140
+ }
141
+ }
142
+ if (chip && CHIP_TO_CODES[chip]) {
143
+ for (const c of CHIP_TO_CODES[chip]) if (!out.includes(c)) out.push(c);
144
+ }
145
+ return out;
146
+ }
147
+
148
+ /**
149
+ * Build the "💡 ..." tip block for a given chip+detail pair. Returns
150
+ * { en: string, ko: string } where each may be empty if no tips apply
151
+ * (resolution events, unknown chips, etc.).
152
+ */
153
+ function tipsForEvent(chip, detail) {
154
+ const codes = codesForEvent(chip, detail);
155
+ const enLines = [];
156
+ const koLines = [];
157
+ for (const code of codes) {
158
+ const tip = ISSUE_TIPS[code];
159
+ if (!tip) continue;
160
+ enLines.push(` 💡 ${tip.en}`);
161
+ // KR tip carries the `└` marker so language-filtered renderers can
162
+ // pair it with its EN counterpart (mirrors the event-line continuation).
163
+ koLines.push(` └ 💡 ${tip.ko}`);
164
+ }
165
+ return { en: enLines.join('\n'), ko: koLines.join('\n') };
166
+ }
167
+
168
+ function appendDayLine(en, ko, date = new Date(), tips = null) {
169
+ ensureDir(HISTORY_DIR);
170
+ const path = join(HISTORY_DIR, `${ymd(date)}.md`);
171
+ // Bilingual event line. Tip lines (when present) follow on their own lines so
172
+ // the file reads as: event-en / └ event-ko / 💡 tip-en / 💡 tip-ko.
173
+ let block = ko && ko !== en ? `${en}\n └ ${ko}\n` : `${en}\n`;
174
+ if (tips && tips.en) block += `${tips.en}\n`;
175
+ if (tips && tips.ko) block += `${tips.ko}\n`;
176
+ if (!existsSync(path)) {
177
+ const header = `# Token Monitor / 토큰 모니터 — ${ymd(date)}\n\n## Events / 이벤트\n`;
178
+ writeFileSync(path, header + block);
179
+ } else {
180
+ const existing = readFileSync(path, 'utf8');
181
+ const sep = existing.endsWith('\n') ? '' : '\n';
182
+ writeFileSync(path, existing + sep + block);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Record a chip transition. Called from the statusline render path.
188
+ * Returns `true` if a transition was logged, `false` if duplicate (same chip
189
+ * as last call).
190
+ */
191
+ export function recordChip(chip, contextHints = {}) {
192
+ const state = loadState();
193
+ const now = new Date();
194
+ const current = chip || null;
195
+ const last = state.chip || null;
196
+
197
+ if (current === last) return false;
198
+
199
+ const detail = contextHints.detail || null;
200
+ const detailEn = detail ? ` — ${detail}` : '';
201
+ const detailKr = detail ? ` — ${detailKo(detail)}` : '';
202
+
203
+ let en, ko;
204
+ // Tips only on warning entry/transition (not resolution) — based on the
205
+ // *new* chip so users see how to handle what's currently active.
206
+ let tips = null;
207
+ if (current && !last) {
208
+ en = `- ${hms(now)} ${current}${detailEn}`;
209
+ ko = `${chipKo(current)}${detailKr}`;
210
+ tips = tipsForEvent(current, detail);
211
+ } else if (current && last) {
212
+ en = `- ${hms(now)} ${last} → ${current}${detailEn}`;
213
+ ko = `${chipKo(last)} → ${chipKo(current)}${detailKr}`;
214
+ tips = tipsForEvent(current, detail);
215
+ } else {
216
+ // current === null, last was something — warning resolved
217
+ en = `- ${hms(now)} ✓ resolved (was ${last})`;
218
+ ko = `✓ 해소됨 (이전: ${chipKo(last)})`;
219
+ }
220
+ appendDayLine(en, ko, now, tips);
221
+ saveState({ chip: current, ts: now.toISOString() });
222
+ return true;
223
+ }
224
+
225
+ /**
226
+ * Read history files for the most recent N days (oldest first).
227
+ * Returns array of { date, content } — empty content for days with no file.
228
+ */
229
+ export function readRecent(days = 7) {
230
+ ensureDir(HISTORY_DIR);
231
+ const out = [];
232
+ for (let i = days - 1; i >= 0; i--) {
233
+ const d = new Date();
234
+ d.setDate(d.getDate() - i);
235
+ const date = ymd(d);
236
+ const path = join(HISTORY_DIR, `${date}.md`);
237
+ if (existsSync(path)) {
238
+ out.push({ date, content: readFileSync(path, 'utf8') });
239
+ }
240
+ }
241
+ return out;
242
+ }
243
+
244
+ /**
245
+ * Record entering or exiting the cap-warn (>=90%) zone for a rate-limit
246
+ * window. Each window (keyed by its stdin name — e.g. `five_hour`, `seven_day`)
247
+ * has its own dedup slot, so the daily file gets two transitions max per
248
+ * window per warning episode.
249
+ *
250
+ * @param {{ key: string, usedPct: number, resetsAt: number|null } | null} window
251
+ * @returns {boolean} true when a line was appended
252
+ */
253
+ export function recordCapTransition(window) {
254
+ if (!window || typeof window.key !== 'string') return false;
255
+ const state = loadState();
256
+ const slotKey = `cap_${window.key}`;
257
+ const wasWarn = !!state[slotKey];
258
+ const isWarn = Number.isFinite(window.usedPct) && window.usedPct >= 90;
259
+ if (wasWarn === isWarn) return false;
260
+
261
+ const now = new Date();
262
+ const labels = labelForKey(window.key);
263
+ const labelEn = labels.short;
264
+ // Korean labels map only the well-known windows; everything else falls back
265
+ // to the English short label (still readable for the bilingual line).
266
+ const KO_OVERRIDES = {
267
+ five_hour: '5시간 윈도',
268
+ seven_day: '7일 윈도',
269
+ seven_day_sonnet: '7일 윈도 (Sonnet)',
270
+ seven_day_opus: '7일 윈도 (Opus)',
271
+ };
272
+ const labelKo = KO_OVERRIDES[window.key] || labels.short;
273
+ let en;
274
+ let ko;
275
+ if (isWarn) {
276
+ const pct = Math.round(window.usedPct);
277
+ const reset = formatResetIn(window.resetsAt, now);
278
+ const clock = formatResetClock(window.resetsAt, now);
279
+ const tail = reset && clock
280
+ ? ` (resets in ${reset}, at ${clock})`
281
+ : reset
282
+ ? ` (resets in ${reset})`
283
+ : clock
284
+ ? ` (resets at ${clock})`
285
+ : '';
286
+ const tailKo = reset && clock
287
+ ? ` (${clock}에 리셋, 남은 ${reset})`
288
+ : reset
289
+ ? ` (리셋까지 ${reset})`
290
+ : clock
291
+ ? ` (${clock}에 리셋)`
292
+ : '';
293
+ en = `- ${hms(now)} 🚨 ${labelEn} ${pct}% cap warning${tail}`;
294
+ ko = `🚨 ${labelKo} ${pct}% 캡 경고${tailKo}`;
295
+ } else {
296
+ en = `- ${hms(now)} ✓ ${labelEn} cap warning resolved`;
297
+ ko = `✓ ${labelKo} 캡 경고 해소`;
298
+ }
299
+ // Cap-warn entry → handoff tip; resolution → no tip (just the ✓ line).
300
+ const capTips = isWarn ? { en: ` 💡 ${CAP_TIPS.en}`, ko: ` └ 💡 ${CAP_TIPS.ko}` } : null;
301
+ appendDayLine(en, ko, now, capTips);
302
+ state[slotKey] = isWarn;
303
+ saveState(state);
304
+ return true;
305
+ }
306
+
307
+ /**
308
+ * Record a handoff write — invoked by the `handoff` subcommand so
309
+ * `claude-token-saver history` shows when work was backed up to a HANDOFF file.
310
+ *
311
+ * @param {string} filePath
312
+ * @returns {boolean}
313
+ */
314
+ export function recordHandoff(filePath) {
315
+ const now = new Date();
316
+ const en = `- ${hms(now)} 📝 handoff written: ${filePath}`;
317
+ const ko = `📝 핸드오프 백업 작성: ${filePath}`;
318
+ appendDayLine(en, ko, now);
319
+ return true;
320
+ }
321
+
322
+ /**
323
+ * Filter a daily-history file's content down to a single language.
324
+ *
325
+ * History files are written bilingual (EN line + ` └ KO` continuation +
326
+ * ` 💡 EN tip` + ` └ 💡 KO tip`). This helper renders just the chosen side
327
+ * for display, while the on-disk file stays bilingual for archival.
328
+ *
329
+ * en: drop every ` └ ...` line (KR continuations + KR tips).
330
+ * ko: replace each EN line with the immediately following ` └ KO` line
331
+ * (preserving the leading `- HH:MM:SS` from the EN line so timestamps
332
+ * still appear); drop unpaired EN-only events.
333
+ *
334
+ * Lines that don't match the bilingual pattern (headers, blank lines,
335
+ * legacy entries from older versions without the `└ 💡` marker) pass through
336
+ * unchanged.
337
+ */
338
+ export function formatHistoryForLanguage(content, lang) {
339
+ if (lang !== 'ko') {
340
+ // English: simply strip the `└` continuations.
341
+ return content
342
+ .split('\n')
343
+ .filter((line) => !/^\s*└\s/.test(line))
344
+ .join('\n');
345
+ }
346
+ // Korean: pair each line with its `└` continuation when present.
347
+ const lines = content.split('\n');
348
+ const out = [];
349
+ for (let i = 0; i < lines.length; i++) {
350
+ const line = lines[i];
351
+ const next = lines[i + 1] || '';
352
+ const cont = next.match(/^(\s*)└\s+(.*)$/);
353
+ // Event line: `- HH:MM:SS <english>` → keep the timestamp prefix, swap text.
354
+ const evt = line.match(/^(- \d{2}:\d{2}:\d{2}\s+)(.*)$/);
355
+ if (evt && cont) {
356
+ out.push(`${evt[1]}${cont[2]}`);
357
+ i++;
358
+ continue;
359
+ }
360
+ // Tip line: ` 💡 <english>` paired with ` └ 💡 <korean>`.
361
+ const tip = line.match(/^(\s*)💡\s+(.*)$/);
362
+ if (tip && cont && /^💡\s/.test(cont[2])) {
363
+ out.push(`${tip[1]}${cont[2]}`);
364
+ i++;
365
+ continue;
366
+ }
367
+ // Header / blank / unpaired line — pass through.
368
+ out.push(line);
369
+ }
370
+ return out.join('\n');
371
+ }
372
+
373
+ /**
374
+ * List all available history file dates (sorted newest first).
375
+ */
376
+ export function listDates() {
377
+ ensureDir(HISTORY_DIR);
378
+ return readdirSync(HISTORY_DIR)
379
+ .filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f))
380
+ .map((f) => f.replace(/\.md$/, ''))
381
+ .sort()
382
+ .reverse();
383
+ }
@@ -0,0 +1,96 @@
1
+ /**
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.
10
+ */
11
+
12
+ import { readFile, writeFile, rm } from 'node:fs/promises';
13
+ import { join } from 'node:path';
14
+ import { homedir } from 'node:os';
15
+
16
+ const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
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');
20
+ const HOOK_MARKER = 'cache-monitor-hook';
21
+
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
+ }
27
+
28
+ export async function installHook({ threshold = 0.7 } = {}) {
29
+ let settings;
30
+ try {
31
+ const raw = await readFile(SETTINGS_PATH, 'utf8');
32
+ settings = JSON.parse(raw);
33
+ } catch {
34
+ settings = {};
35
+ }
36
+
37
+ if (!settings.hooks) settings.hooks = {};
38
+ if (!Array.isArray(settings.hooks.PostToolUse)) settings.hooks.PostToolUse = [];
39
+
40
+ // Remove existing cache-monitor hook if present — matches both the current
41
+ // subcommand form and the legacy copied-file form.
42
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(
43
+ (h) => !(h.hooks || []).some(isCacheMonitorHook),
44
+ );
45
+
46
+ settings.hooks.PostToolUse.push({
47
+ matcher: 'Bash|Edit|Write',
48
+ hooks: [
49
+ {
50
+ type: 'command',
51
+ command: `claude-token-saver --hook-run --threshold ${threshold}`,
52
+ timeout: 10,
53
+ },
54
+ ],
55
+ });
56
+
57
+ await writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n', 'utf8');
58
+
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)');
63
+ console.log(` Settings updated: ${SETTINGS_PATH}`);
64
+ console.log(` Threshold: ${(threshold * 100).toFixed(0)}%`);
65
+ console.log(` Stats file: ~/.claude/cache-stats.jsonl`);
66
+ }
67
+
68
+ export async function uninstallHook() {
69
+ let settings;
70
+ try {
71
+ const raw = await readFile(SETTINGS_PATH, 'utf8');
72
+ settings = JSON.parse(raw);
73
+ } catch {
74
+ console.log('No settings.json found, nothing to uninstall.');
75
+ return;
76
+ }
77
+
78
+ if (settings.hooks?.PostToolUse) {
79
+ const before = settings.hooks.PostToolUse.length;
80
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(
81
+ (h) => !(h.hooks || []).some(isCacheMonitorHook),
82
+ );
83
+ const removed = before - settings.hooks.PostToolUse.length;
84
+
85
+ if (settings.hooks.PostToolUse.length === 0) delete settings.hooks.PostToolUse;
86
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
87
+
88
+ await writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n', 'utf8');
89
+ console.log(`✓ Removed ${removed} hook(s) from settings.json`);
90
+ } else {
91
+ console.log('No cache-monitor hook found in settings.');
92
+ }
93
+
94
+ // The legacy copied hook file is dead weight either way.
95
+ await rm(LEGACY_HOOK_DEST, { force: true }).catch(() => {});
96
+ }
package/src/hook.cjs ADDED
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Claude Code PostToolUse hook — standalone, zero dependencies, CommonJS.
4
+ * Appends per-session cache stats to ~/.claude/cache-stats.jsonl.
5
+ * Warns if hit rate drops below threshold.
6
+ *
7
+ * Written in CommonJS so it works standalone in ~/.claude/ without package.json.
8
+ *
9
+ * Receives hook context on stdin:
10
+ * { session_id, transcript_path, cwd, tool_name, tool_input, tool_response }
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ // CJS twin of src/debug.js \u2014 hook failures must stay silent for the session
16
+ // but be visible under CTS_DEBUG=1, otherwise a broken path is
17
+ // indistinguishable from "nothing to do".
18
+ var CTS_DEBUG = !!process.env.CTS_DEBUG;
19
+ function dbg(scope, err) {
20
+ if (!CTS_DEBUG) return;
21
+ process.stderr.write('[cts:' + scope + '] ' + ((err && err.stack) || String(err)) + '\n');
22
+ }
23
+
24
+ const fs = require('node:fs');
25
+ const path = require('node:path');
26
+ const os = require('node:os');
27
+
28
+ const STATS_FILE = path.join(os.homedir(), '.claude', 'cache-stats.jsonl');
29
+ const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
30
+
31
+ // Parse threshold from args
32
+ let threshold = 0.7;
33
+ const thIdx = process.argv.indexOf('--threshold');
34
+ if (thIdx !== -1 && process.argv[thIdx + 1]) {
35
+ threshold = parseFloat(process.argv[thIdx + 1]);
36
+ }
37
+
38
+ // Read stdin (hook context)
39
+ let stdin = '';
40
+ try {
41
+ stdin = fs.readFileSync(0, 'utf8');
42
+ } catch (e) {
43
+ dbg('hook:stdin', e);
44
+ }
45
+
46
+ let context;
47
+ try {
48
+ context = JSON.parse(stdin);
49
+ } catch (e) {
50
+ dbg('hook:parse-stdin', e);
51
+ process.exit(0);
52
+ }
53
+
54
+ const sessionId = context.session_id;
55
+ const cwd = context.cwd || '';
56
+ if (!sessionId) process.exit(0);
57
+
58
+ // Resolve session file: prefer transcript_path, fallback to directory scan
59
+ function resolveSessionFile() {
60
+ // Method 1: transcript_path (Claude Code v2.1.85+)
61
+ if (context.transcript_path) {
62
+ try {
63
+ fs.statSync(context.transcript_path);
64
+ return context.transcript_path;
65
+ } catch (e) {
66
+ dbg('hook:transcript-path', e);
67
+ }
68
+ }
69
+
70
+ // Method 2: scan projects directory (older versions)
71
+ try {
72
+ const dirs = fs.readdirSync(PROJECTS_DIR);
73
+ for (const d of dirs) {
74
+ const fp = path.join(PROJECTS_DIR, d, `${sessionId}.jsonl`);
75
+ try {
76
+ fs.statSync(fp);
77
+ return fp;
78
+ } catch {
79
+ // not here
80
+ }
81
+ }
82
+ } catch (e) {
83
+ dbg('hook:projects-dir', e);
84
+ }
85
+
86
+ return null;
87
+ }
88
+
89
+ const sessionFile = resolveSessionFile();
90
+ if (!sessionFile) process.exit(0);
91
+
92
+ // Parse session file
93
+ let content;
94
+ try {
95
+ content = fs.readFileSync(sessionFile, 'utf8');
96
+ } catch (e) {
97
+ dbg('hook:read-session', e);
98
+ process.exit(0);
99
+ }
100
+
101
+ const lines = content.trim().split('\n');
102
+ const requests = new Map();
103
+
104
+ for (const line of lines) {
105
+ let entry;
106
+ try {
107
+ entry = JSON.parse(line);
108
+ } catch {
109
+ continue;
110
+ }
111
+
112
+ const msg = entry.message;
113
+ if (!msg || !msg.usage || !msg.id) continue;
114
+
115
+ const u = msg.usage;
116
+ const cc = u.cache_creation || {};
117
+ const reqId = entry.requestId || msg.id;
118
+
119
+ requests.set(reqId, {
120
+ input: u.input_tokens || 0,
121
+ cacheCreation: u.cache_creation_input_tokens || 0,
122
+ cacheRead: u.cache_read_input_tokens || 0,
123
+ ephemeral5m: cc.ephemeral_5m_input_tokens || 0,
124
+ ephemeral1h: cc.ephemeral_1h_input_tokens || 0,
125
+ output: u.output_tokens || 0,
126
+ model: msg.model || 'unknown',
127
+ });
128
+ }
129
+
130
+ if (requests.size === 0) process.exit(0);
131
+
132
+ const reqs = Array.from(requests.values());
133
+ const totals = reqs.reduce(
134
+ function (a, r) {
135
+ a.input += r.input;
136
+ a.cacheCreation += r.cacheCreation;
137
+ a.cacheRead += r.cacheRead;
138
+ a.ephemeral5m += r.ephemeral5m;
139
+ a.ephemeral1h += r.ephemeral1h;
140
+ a.output += r.output;
141
+ return a;
142
+ },
143
+ { input: 0, cacheCreation: 0, cacheRead: 0, ephemeral5m: 0, ephemeral1h: 0, output: 0 },
144
+ );
145
+
146
+ const totalInput = totals.cacheRead + totals.cacheCreation + totals.input;
147
+ const hitRate = totalInput > 0 ? totals.cacheRead / totalInput : 0;
148
+
149
+ const record = {
150
+ timestamp: new Date().toISOString(),
151
+ sessionId: sessionId,
152
+ cwd: cwd,
153
+ apiCalls: requests.size,
154
+ hitRate: Math.round(hitRate * 10000) / 10000,
155
+ tokens: {
156
+ input: totals.input,
157
+ cacheCreation: totals.cacheCreation,
158
+ cacheRead: totals.cacheRead,
159
+ ephemeral5m: totals.ephemeral5m,
160
+ ephemeral1h: totals.ephemeral1h,
161
+ output: totals.output,
162
+ },
163
+ model: reqs[0] ? reqs[0].model : 'unknown',
164
+ };
165
+
166
+ // Append to stats file
167
+ try {
168
+ fs.appendFileSync(STATS_FILE, JSON.stringify(record) + '\n', 'utf8');
169
+ } catch (e) {
170
+ dbg('hook:append-stats', e);
171
+ }
172
+
173
+ // Alert if hit rate below threshold
174
+ if (hitRate < threshold && requests.size >= 5) {
175
+ var pct = (hitRate * 100).toFixed(1);
176
+ var ccTotal = totals.ephemeral5m + totals.ephemeral1h;
177
+ var pct5m = ccTotal > 0 ? ((totals.ephemeral5m / ccTotal) * 100).toFixed(0) : '0';
178
+ process.stdout.write(
179
+ '\u26a0 Cache hit rate: ' + pct + '% (threshold: ' + (threshold * 100).toFixed(0) + '%) | 5m TTL: ' + pct5m + '% | ' + requests.size + ' API calls\n',
180
+ );
181
+ }
182
+
183
+ // Harness analysis \u2014 best-effort, never throws into the hook stream. The
184
+ // statusline picks up the resulting state file (`harness-state.json`) on
185
+ // the next render, so warnings appear within ~1s of the triggering turn.
186
+ try {
187
+ var harnessAnalyzer = require('./harness-analyzer.cjs');
188
+ var state = harnessAnalyzer.analyzeTranscript(sessionFile, {
189
+ sessionId: sessionId,
190
+ cwd: cwd,
191
+ });
192
+ if (state) harnessAnalyzer.writeState(state);
193
+ } catch (e) {
194
+ dbg('hook:harness-analyzer', e);
195
+ // analyzer is purely advisory \u2014 never break the hook on failure
196
+ }