claude-token-saver 3.25.0 → 3.26.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/src/doc2md.cjs ADDED
@@ -0,0 +1,378 @@
1
+ /**
2
+ * doc2md — hand the model a Markdown rendering of an attached document
3
+ * instead of the binary.
4
+ *
5
+ * A pptx or xlsx read straight into the context window is close to the worst
6
+ * thing a token-saving tool can allow: the bytes are unreadable to the model,
7
+ * so it either gets nothing useful or spends a fortune finding that out. This
8
+ * intercepts the Read, converts the file once, caches the result, and points
9
+ * the model at the .md.
10
+ *
11
+ * CommonJS on purpose. It runs from ~/.claude/ through the copied hook script,
12
+ * where there is no package.json to declare `"type": "module"`, which is the
13
+ * same reason korean-lint.cjs is written this way.
14
+ *
15
+ * Conversion is markitdown, a Python package. It cannot be an npm dependency,
16
+ * so a missing install is an ordinary state rather than an error: say so once,
17
+ * then get out of the way and let the Read proceed untouched. Failing loudly
18
+ * on every Read would be worse than the problem being solved, and failing
19
+ * silently is how graphify's `except ImportError: return ""` hid a broken
20
+ * converter for months.
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ const fs = require('node:fs');
26
+ const os = require('node:os');
27
+ const path = require('node:path');
28
+ const crypto = require('node:crypto');
29
+ const { spawnSync } = require('node:child_process');
30
+
31
+ // Formats where the original is of no use to the model. Images are absent
32
+ // deliberately: markitdown returns nothing for them, and OCR misread resource
33
+ // names in testing (`c5.xlarge` as `c.xlarge`), which is worse than no text at
34
+ // all in a document where those names are the content. The model reads images
35
+ // natively anyway.
36
+ const TARGET_EXTENSIONS = ['.pptx', '.xlsx', '.xls', '.pdf', '.docx'];
37
+
38
+ // Big enough for real decks and reports, small enough that a hostile file
39
+ // cannot make the converter the expensive part of the turn.
40
+ const MAX_SOURCE_BYTES = 50 * 1024 * 1024;
41
+ // A conversion larger than this costs more to read than it saves.
42
+ const MAX_MARKDOWN_BYTES = 2 * 1024 * 1024;
43
+ // Cold `import markitdown` measured at ~12s; conversions after that are under
44
+ // two seconds except for very large workbooks, which the row cap handles.
45
+ const CONVERT_TIMEOUT_MS = 120_000;
46
+
47
+ // Names that suggest the file should not be left lying around as plain text.
48
+ // Deliberately blunt: the cost of skipping a payroll deck is one extra manual
49
+ // step, and the cost of caching it is not recoverable.
50
+ const SENSITIVE_PATTERNS = [
51
+ /secret/i, /password/i, /credential/i, /salary/i, /payroll/i, /confidential/i,
52
+ /개인정보/, /급여/, /계약/, /대외비/,
53
+ ];
54
+
55
+ // Mirrors src/paths.js userDataDir(). Duplicated rather than imported because
56
+ // this file is CommonJS and paths.js is ESM; the precedence order has to match
57
+ // it exactly, or conversions would land somewhere the rest of the tool does
58
+ // not look.
59
+ function userDataDir() {
60
+ if (process.env.XDG_CONFIG_HOME) {
61
+ return path.join(process.env.XDG_CONFIG_HOME, 'claude-token-saver');
62
+ }
63
+ if (process.platform === 'win32' && process.env.APPDATA) {
64
+ return path.join(process.env.APPDATA, 'claude-token-saver');
65
+ }
66
+ if (process.platform === 'darwin') {
67
+ return path.join(os.homedir(), 'Library', 'Application Support', 'claude-token-saver');
68
+ }
69
+ return path.join(os.homedir(), '.config', 'claude-token-saver');
70
+ }
71
+
72
+ /**
73
+ * Where conversions live.
74
+ *
75
+ * Not next to the original, and not in the project's own `.claude/`: either
76
+ * one drops a plain-text copy of a possibly confidential attachment into a
77
+ * directory people commit. Keeping it in the tool's own state directory means
78
+ * there is nothing for the user to remember to gitignore.
79
+ */
80
+ function cacheDir() {
81
+ return path.join(userDataDir(), 'doc2md-cache');
82
+ }
83
+
84
+ function ensureCacheDir() {
85
+ const dir = cacheDir();
86
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
87
+ // mkdir honours the mode only on creation, so an older directory made with
88
+ // the default mask is tightened here.
89
+ try { fs.chmodSync(dir, 0o700); } catch { /* best effort */ }
90
+ return dir;
91
+ }
92
+
93
+ function isTargetPath(filePath) {
94
+ if (typeof filePath !== 'string' || !filePath) return false;
95
+ return TARGET_EXTENSIONS.includes(path.extname(filePath).toLowerCase());
96
+ }
97
+
98
+ function isSensitivePath(filePath) {
99
+ const base = path.basename(filePath || '');
100
+ return SENSITIVE_PATTERNS.some((re) => re.test(base));
101
+ }
102
+
103
+ /**
104
+ * Cache path for a source file. The hash covers the absolute path, so two
105
+ * `report.pptx` files in different projects do not overwrite each other.
106
+ */
107
+ function cachePathFor(filePath) {
108
+ const abs = path.resolve(filePath);
109
+ const hash = crypto.createHash('sha256').update(abs).digest('hex').slice(0, 12);
110
+ const base = path.basename(abs).replace(/[^\w.\-]/g, '_');
111
+ return path.join(cacheDir(), `${base}.${hash}.md`);
112
+ }
113
+
114
+ function metaPathFor(cacheFile) {
115
+ return cacheFile.replace(/\.md$/, '.meta.json');
116
+ }
117
+
118
+ /** A cached conversion still matching the source's mtime and size, or null. */
119
+ function readCache(filePath) {
120
+ const cacheFile = cachePathFor(filePath);
121
+ try {
122
+ const src = fs.statSync(filePath);
123
+ const meta = JSON.parse(fs.readFileSync(metaPathFor(cacheFile), 'utf8'));
124
+ if (meta.size !== src.size || meta.mtimeMs !== src.mtimeMs) return null;
125
+ fs.statSync(cacheFile);
126
+ return { cacheFile, meta };
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ function writeCache(filePath, markdown, extra) {
133
+ ensureCacheDir();
134
+ const cacheFile = cachePathFor(filePath);
135
+ const src = fs.statSync(filePath);
136
+ fs.writeFileSync(cacheFile, markdown, { encoding: 'utf8', mode: 0o600 });
137
+ const meta = Object.assign({
138
+ source: path.resolve(filePath),
139
+ size: src.size,
140
+ mtimeMs: src.mtimeMs,
141
+ convertedAt: new Date().toISOString(),
142
+ }, extra || {});
143
+ fs.writeFileSync(metaPathFor(cacheFile), JSON.stringify(meta, null, 2), { encoding: 'utf8', mode: 0o600 });
144
+ return { cacheFile, meta };
145
+ }
146
+
147
+ /**
148
+ * A Python that can import markitdown, or null.
149
+ *
150
+ * Order matters: an explicit override first, then a `uv tool` install, then
151
+ * whatever is on PATH. Probing costs a process spawn each, so the answer is
152
+ * memoized for the life of this process, and the caller memoizes across
153
+ * processes through the notice file.
154
+ */
155
+ let interpreterCache;
156
+ function findInterpreter() {
157
+ if (interpreterCache !== undefined) return interpreterCache;
158
+ const candidates = [];
159
+ if (process.env.CTS_DOC2MD_PYTHON) candidates.push(process.env.CTS_DOC2MD_PYTHON);
160
+ candidates.push(
161
+ path.join(os.homedir(), '.local', 'share', 'uv', 'tools', 'markitdown', 'bin', 'python'),
162
+ path.join(os.homedir(), '.local', 'bin', 'markitdown-python'),
163
+ 'python3',
164
+ 'python',
165
+ );
166
+ for (const bin of candidates) {
167
+ try {
168
+ const probe = spawnSync(bin, ['-c', 'import markitdown'], { timeout: 20_000, stdio: 'ignore' });
169
+ if (probe.status === 0) {
170
+ interpreterCache = bin;
171
+ return bin;
172
+ }
173
+ } catch { /* candidate unusable, try the next */ }
174
+ }
175
+ interpreterCache = null;
176
+ return null;
177
+ }
178
+
179
+ const CONVERTER = path.join(__dirname, '..', 'presets', 'doc2md', 'convert.py');
180
+
181
+ /**
182
+ * Convert one file. Returns `{ ok: true, cacheFile, meta }`, or
183
+ * `{ ok: false, reason, detail }` where reason is one of:
184
+ * no-markitdown | too-large | sensitive | unsafe-archive | no-text |
185
+ * convert-failed | timeout
186
+ *
187
+ * Every failure is a reason to leave the original Read alone, never to break
188
+ * it. That is the whole contract with the hook.
189
+ */
190
+ function convert(filePath, { converter = CONVERTER, python: pythonOverride = null } = {}) {
191
+ if (!isTargetPath(filePath)) return { ok: false, reason: 'not-target' };
192
+ if (isSensitivePath(filePath)) return { ok: false, reason: 'sensitive' };
193
+
194
+ let stat;
195
+ try {
196
+ stat = fs.statSync(filePath);
197
+ } catch (e) {
198
+ return { ok: false, reason: 'missing', detail: String(e.message || e) };
199
+ }
200
+ if (stat.size > MAX_SOURCE_BYTES) {
201
+ return { ok: false, reason: 'too-large', detail: `${stat.size} bytes` };
202
+ }
203
+
204
+ const cached = readCache(filePath);
205
+ if (cached) return { ok: true, cached: true, cacheFile: cached.cacheFile, meta: cached.meta };
206
+
207
+ // The override exists so tests can drive a stub converter with any Python at
208
+ // all: the normal search insists the interpreter can import markitdown,
209
+ // which would make the whole path untestable without the real package.
210
+ const python = pythonOverride || findInterpreter();
211
+ if (!python) return { ok: false, reason: 'no-markitdown' };
212
+
213
+ const run = spawnSync(python, [converter, filePath], {
214
+ encoding: 'utf8',
215
+ timeout: CONVERT_TIMEOUT_MS,
216
+ maxBuffer: MAX_MARKDOWN_BYTES * 4,
217
+ });
218
+ if (run.error && run.error.code === 'ETIMEDOUT') return { ok: false, reason: 'timeout' };
219
+ if (run.status !== 0) {
220
+ return { ok: false, reason: 'convert-failed', detail: (run.stderr || '').slice(0, 300) };
221
+ }
222
+
223
+ let payload;
224
+ try {
225
+ payload = JSON.parse(run.stdout);
226
+ } catch {
227
+ return { ok: false, reason: 'convert-failed', detail: 'converter produced no JSON' };
228
+ }
229
+ if (!payload.ok) return { ok: false, reason: payload.reason, detail: payload.detail };
230
+
231
+ let markdown = payload.markdown || '';
232
+ let clipped = false;
233
+ if (Buffer.byteLength(markdown, 'utf8') > MAX_MARKDOWN_BYTES) {
234
+ // Reading a 20MB markdown file is the same waste in a different format.
235
+ markdown = markdown.slice(0, MAX_MARKDOWN_BYTES);
236
+ clipped = true;
237
+ }
238
+
239
+ const written = writeCache(filePath, markdown, {
240
+ note: payload.note || null,
241
+ truncated: !!payload.truncated || clipped,
242
+ rows: payload.rows || 0,
243
+ clipped,
244
+ });
245
+ return { ok: true, cached: false, cacheFile: written.cacheFile, meta: written.meta };
246
+ }
247
+
248
+ /** Where the "markitdown is not installed" notice records that it was shown. */
249
+ function noticePath() {
250
+ return path.join(userDataDir(), 'doc2md-notice.json');
251
+ }
252
+
253
+ function noticeAlreadyShown() {
254
+ try {
255
+ return JSON.parse(fs.readFileSync(noticePath(), 'utf8')).shown === true;
256
+ } catch {
257
+ return false;
258
+ }
259
+ }
260
+
261
+ function markNoticeShown() {
262
+ try {
263
+ fs.mkdirSync(userDataDir(), { recursive: true });
264
+ fs.writeFileSync(noticePath(), JSON.stringify({ shown: true, at: new Date().toISOString() }));
265
+ } catch { /* an unwritable state dir just means the notice repeats */ }
266
+ }
267
+
268
+ const INSTALL_HINT = 'pip install "markitdown[pptx,pdf,xlsx,docx]"';
269
+
270
+ /**
271
+ * Decide what to tell Claude Code about one PreToolUse(Read) payload.
272
+ *
273
+ * Returns null when the hook should stay out of the way, or a PreToolUse hook
274
+ * output object. Blocking is the right call for a successful conversion:
275
+ * allowing the Read and merely mentioning the .md would put the binary in the
276
+ * context window anyway, which is the cost this exists to avoid.
277
+ */
278
+ function decideForRead(context, opts = {}) {
279
+ if (!context || context.tool_name !== 'Read') return null;
280
+ const toolInput = context.tool_input;
281
+ const filePath = toolInput && typeof toolInput.file_path === 'string' ? toolInput.file_path : '';
282
+ if (!isTargetPath(filePath)) return null;
283
+
284
+ const result = convert(filePath, opts);
285
+ const name = path.basename(filePath);
286
+
287
+ if (result.ok) {
288
+ const bits = [`[doc2md] ${name} 는 Markdown 으로 변환했습니다.`];
289
+ bits.push(` 변환본: ${result.cacheFile}`);
290
+ if (result.meta && result.meta.note) bits.push(` ${result.meta.note}`);
291
+ if (result.meta && result.meta.clipped) {
292
+ bits.push(' 변환 결과가 너무 커서 뒷부분을 잘랐습니다. 전체가 필요하면 원본을 직접 다루십시오.');
293
+ }
294
+ bits.push(' 원본 대신 이 파일을 Read 하십시오. 원본을 직접 확인해야 한다면 그 이유를 밝히십시오.');
295
+ return { deny: true, reason: bits.join('\n') };
296
+ }
297
+
298
+ // From here down the Read is allowed through untouched. The only question is
299
+ // whether the model is told why nothing was converted.
300
+ if (result.reason === 'no-markitdown') {
301
+ if (noticeAlreadyShown()) return null;
302
+ markNoticeShown();
303
+ return {
304
+ deny: false,
305
+ reason: `[doc2md] ${name} 를 변환하려 했으나 markitdown 이 설치되어 있지 않습니다.\n`
306
+ + ` 설치: ${INSTALL_HINT}\n`
307
+ + ' 설치 전까지는 원본을 그대로 읽습니다. 이 안내는 한 번만 표시됩니다.',
308
+ };
309
+ }
310
+ if (result.reason === 'no-text') {
311
+ return {
312
+ deny: false,
313
+ reason: `[doc2md] ${name} 에서 본문 텍스트를 추출하지 못했습니다(스캔 PDF 로 보입니다). 원본을 직접 확인하십시오.`,
314
+ };
315
+ }
316
+ // The one case that blocks without converting. A file that expands to fill
317
+ // the disk is not something to hand on to the next reader with a shrug.
318
+ if (result.reason === 'unsafe-archive') {
319
+ return {
320
+ deny: true,
321
+ reason: `[doc2md] ${name} 는 압축 폭탄으로 보여 변환하지 않았습니다 (${result.detail}). 신뢰할 수 있는 파일인지 먼저 확인하십시오.`,
322
+ };
323
+ }
324
+ if (result.reason === 'bad-archive') {
325
+ return {
326
+ deny: false,
327
+ reason: `[doc2md] ${name} 는 압축 파일로 열리지 않습니다 (${result.detail}). 내려받다 끊겼을 수 있습니다.`,
328
+ };
329
+ }
330
+ if (result.reason === 'too-large') {
331
+ return {
332
+ deny: false,
333
+ reason: `[doc2md] ${name} 는 크기 상한(50MB)을 넘어 변환하지 않았습니다 (${result.detail}).`,
334
+ };
335
+ }
336
+ if (result.reason === 'sensitive') {
337
+ return {
338
+ deny: false,
339
+ reason: `[doc2md] ${name} 는 파일명이 민감 문서 패턴에 걸려 변환하지 않았습니다. 평문 사본을 남기지 않기 위한 조치입니다.`,
340
+ };
341
+ }
342
+ if (result.reason === 'timeout' || result.reason === 'convert-failed') {
343
+ return {
344
+ deny: false,
345
+ reason: `[doc2md] ${name} 변환에 실패했습니다(${result.reason}). 원본을 그대로 읽습니다.`,
346
+ };
347
+ }
348
+ return null;
349
+ }
350
+
351
+ /** The decision rendered as the JSON Claude Code expects on stdout. */
352
+ function formatHookOutput(decision) {
353
+ if (!decision) return null;
354
+ return JSON.stringify({
355
+ hookSpecificOutput: {
356
+ hookEventName: 'PreToolUse',
357
+ permissionDecision: decision.deny ? 'deny' : 'allow',
358
+ permissionDecisionReason: decision.reason,
359
+ },
360
+ });
361
+ }
362
+
363
+ module.exports = {
364
+ TARGET_EXTENSIONS,
365
+ MAX_SOURCE_BYTES,
366
+ INSTALL_HINT,
367
+ cacheDir,
368
+ cachePathFor,
369
+ metaPathFor,
370
+ isTargetPath,
371
+ isSensitivePath,
372
+ findInterpreter,
373
+ readCache,
374
+ writeCache,
375
+ convert,
376
+ decideForRead,
377
+ formatHookOutput,
378
+ };
@@ -280,10 +280,27 @@ export function formatReport(data, { color = true, verbose = false, timer = true
280
280
  // infer the bucket. Default to 1h-sized countdown rather than 5m so Max
281
281
  // users on idle don't see a misleading "Cache expires 5:00". The bucket
282
282
  // label is shown as "?" so the uncertainty is visible.
283
+ //
284
+ // That default is exactly backwards behind a gateway. Bedrock and Vertex
285
+ // never report the per-bucket split, so ttl.total stays 0 there forever, and
286
+ // they offer only the 5m bucket: the countdown opened at 59:59 for a window
287
+ // that was really 5:00, overstating it twelvefold. So the fallback now
288
+ // follows the evidence — gateway seen, assume 5m; otherwise keep 1h. An
289
+ // explicit `ttlBucket` setting outranks both, so a gateway that starts
290
+ // reporting the split correctly does not need a release to be believed.
283
291
  const hasTtlData = ttl.total > 0;
284
- const is1h = hasTtlData ? ttl.pct1h >= 0.5 : true;
285
- const bucketLabel = hasTtlData ? (is1h ? '1h' : '5m') : '?';
286
- const bucketColor = hasTtlData ? (is1h ? GREEN : YELLOW) : GRAY;
292
+ const override = data.ttlBucket === '5m' || data.ttlBucket === '1h' ? data.ttlBucket : null;
293
+ const is1h = override ? override === '1h' : (hasTtlData ? ttl.pct1h >= 0.5 : !ttl.gatewayObserved);
294
+ // Three grades of certainty, three labels: measured (`1h`/`5m`), inferred
295
+ // from a gateway model id (`5m?`), and unknown (`?`). Folding the middle
296
+ // case into `?` would hide a judgement the user could otherwise check.
297
+ const bucketKnown = hasTtlData || !!override;
298
+ const bucketLabel = bucketKnown
299
+ ? (is1h ? '1h' : '5m')
300
+ : (ttl.gatewayObserved ? '5m?' : '?');
301
+ const bucketColor = bucketKnown
302
+ ? (is1h ? GREEN : YELLOW)
303
+ : (ttl.gatewayObserved ? YELLOW : GRAY);
287
304
  const ttlSeconds = is1h ? 3600 : 300;
288
305
 
289
306
  const savings = cost?.savings ?? 0;
@@ -320,9 +337,18 @@ export function formatReport(data, { color = true, verbose = false, timer = true
320
337
  const delegateLabel = isIcon
321
338
  ? (verbose ? '🔀 Routing saved' : '🔀')
322
339
  : 'Routing saved';
340
+ // Zero savings has two very different causes and, until now, one appearance:
341
+ // nothing at all. "Never delegated" and "delegated plenty, but every run was
342
+ // dropped because the gateway model id could not be resolved" looked
343
+ // identical, so users in the second case had no reason to suspect anything
344
+ // was wrong. The count gets a chip; the explanation stays in `route-scan
345
+ // rules`, where there is room for it.
346
+ const unresolvedRuns = Number(data.unresolvedRuns) || 0;
323
347
  const delegateSeg = delegationSaved > 0
324
348
  ? `${c(GREEN)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}`
325
- : null;
349
+ : (unresolvedRuns > 0
350
+ ? `${c(YELLOW)}🔀 ${unresolvedRuns} unresolved${c(RESET)}`
351
+ : null);
326
352
 
327
353
  // Routing-savings headline line (multi-line layout). The lifetime sum from
328
354
  // the delegation ledger — the number the whole tool exists to grow, so it
@@ -397,11 +423,15 @@ export function formatReport(data, { color = true, verbose = false, timer = true
397
423
  const remaining = Math.min(ttlSeconds, ttlSeconds - elapsed);
398
424
  const text = formatTimer(remaining);
399
425
  const pct = remaining / ttlSeconds;
426
+ // Percentages are the wrong unit in a 5-minute bucket: 30% of it is 90
427
+ // seconds, and green there reads as comfort the user does not have. Below
428
+ // an hour the thresholds are absolute, so the color tracks whether there
429
+ // is time to finish a thought rather than a share of a short window.
400
430
  const timerColor =
401
431
  remaining <= 0 ? RED :
402
- pct > 0.30 ? GREEN :
403
- pct > 0.10 ? YELLOW :
404
- RED;
432
+ is1h
433
+ ? (pct > 0.30 ? GREEN : pct > 0.10 ? YELLOW : RED)
434
+ : (remaining > 60 ? GREEN : remaining > 30 ? YELLOW : RED);
405
435
 
406
436
  if (isIcon && verbose) {
407
437
  // Drop bucket here too — `⏳ Expires 1h 57:20` reads as "1h 57m 20s left"
@@ -241,7 +241,14 @@ export function formatReport({ summary: sum, trend, ttl, anomalies, cost, option
241
241
  lines.push(' ' + tableRow(['Without cache', `$${cost.noCacheCost}`], costW, costA));
242
242
  lines.push(' ' + tableSep(costW));
243
243
  lines.push(' ' + tableRow(['Savings', `$${cost.savings} (${pct(cost.savingsRate)})`], costW, costA));
244
- lines.push(' ' + tableRow(['Extra cost if 5m-only', `+$${cost.extraCostIf5m}`], costW, costA));
244
+ // Only worth asking of someone who has 1h writes to lose. Everyone else got
245
+ // a `+$0` that read as an endorsement of the 5m bucket they were already
246
+ // stuck in.
247
+ if (cost.extraCostIf5mApplicable === false) {
248
+ lines.push(' ' + tableRow(['Already 5m-only', ttl.gatewayObserved ? 'gateway' : 'yes'], costW, costA));
249
+ } else {
250
+ lines.push(' ' + tableRow(['Extra cost if 5m-only', `+$${cost.extraCostIf5m}`], costW, costA));
251
+ }
245
252
  lines.push(' ' + tableBot(costW));
246
253
  lines.push('');
247
254
 
package/src/installer.js CHANGED
@@ -346,6 +346,75 @@ export function removeKoreanLintHook() {
346
346
  return { path: file, action: 'removed' };
347
347
  }
348
348
 
349
+ // Registers the PreToolUse hook that converts attached documents to Markdown
350
+ // before the model reads them. PreToolUse rather than PostToolUse because the
351
+ // point is to intervene before a pptx lands in the context window; afterwards
352
+ // the tokens are already spent.
353
+ //
354
+ // No `timeout` is set, on purpose. Claude Code defaults command hooks to ten
355
+ // minutes, so naming a number here could only lower that ceiling, and a cold
356
+ // `import markitdown` measured at twelve seconds by itself, with a very large
357
+ // workbook adding a minute on top. The row cap inside the converter is what
358
+ // actually bounds the work; the timeout is only a backstop.
359
+ // Installed by `doc2md on`, removed by `doc2md off`. Idempotent.
360
+ const DOC2MD_HOOK_COMMAND = 'claude-token-saver doc2md --hook';
361
+
362
+ export function installDoc2mdHook() {
363
+ const dir = claudeUserDir();
364
+ const file = join(dir, 'settings.json');
365
+ mkdirSync(dir, { recursive: true });
366
+
367
+ let settings = {};
368
+ if (existsSync(file)) {
369
+ try {
370
+ settings = JSON.parse(readFileSync(file, 'utf8'));
371
+ } catch (e) {
372
+ return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
373
+ }
374
+ }
375
+
376
+ settings.hooks = settings.hooks || {};
377
+ if (settings.hooks.PreToolUse !== undefined && !Array.isArray(settings.hooks.PreToolUse)) {
378
+ return { path: file, action: 'skipped', reason: 'hooks.PreToolUse is not an array — fix settings.json manually' };
379
+ }
380
+ const list = Array.isArray(settings.hooks.PreToolUse) ? settings.hooks.PreToolUse : [];
381
+ const already = list.some((m) =>
382
+ Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('doc2md --hook')),
383
+ );
384
+ if (already) return { path: file, action: 'exists' };
385
+
386
+ list.push({
387
+ matcher: 'Read',
388
+ hooks: [{ type: 'command', command: DOC2MD_HOOK_COMMAND }],
389
+ });
390
+ settings.hooks.PreToolUse = list;
391
+ writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
392
+ return { path: file, action: 'created' };
393
+ }
394
+
395
+ export function removeDoc2mdHook() {
396
+ const file = join(claudeUserDir(), 'settings.json');
397
+ if (!existsSync(file)) return { path: file, action: 'absent' };
398
+ let settings;
399
+ try {
400
+ settings = JSON.parse(readFileSync(file, 'utf8'));
401
+ } catch (e) {
402
+ return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
403
+ }
404
+ const list = settings?.hooks?.PreToolUse;
405
+ if (!Array.isArray(list)) return { path: file, action: 'absent' };
406
+ // Only this tool's own entry goes; anything else registered under
407
+ // PreToolUse stays exactly where the user put it.
408
+ const kept = list.filter((m) =>
409
+ !(Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('doc2md --hook'))),
410
+ );
411
+ if (kept.length === list.length) return { path: file, action: 'absent' };
412
+ if (kept.length === 0) delete settings.hooks.PreToolUse;
413
+ else settings.hooks.PreToolUse = kept;
414
+ writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
415
+ return { path: file, action: 'removed' };
416
+ }
417
+
349
418
  export function installAll({ force = false } = {}) {
350
419
  return {
351
420
  skill: installSkill({ force }),
@@ -35,7 +35,10 @@ const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
35
35
 
36
36
  export const KOREAN_STYLE_PATH = join(packageRoot, 'presets', 'korean-style', 'fluent-korean.md');
37
37
  export const KOREAN_STYLE_LICENSE_PATH = join(packageRoot, 'presets', 'korean-style', 'LICENSE-fluent-korean');
38
- export const KOREAN_STYLE_SOURCE = 'fluent-korean by snflkd (MIT) — https://github.com/snflkd/fluent-korean';
38
+ // The separator here is a colon, not an em dash. The guidance this line cites
39
+ // bans em dashes in Korean prose, and shipping one inside its own attribution
40
+ // is the kind of contradiction that teaches the model the rule is negotiable.
41
+ export const KOREAN_STYLE_SOURCE = 'fluent-korean by snflkd (MIT): https://github.com/snflkd/fluent-korean';
39
42
 
40
43
  /** Whether session-start injection is enabled. Off unless the user asked. */
41
44
  export function koreanStyleEnabled(cfg = loadConfig()) {
@@ -134,6 +137,13 @@ export function koreanScopeLines(cfg = loadConfig()) {
134
137
  return [
135
138
  '적용 대상: 대화 답변, 그리고 세션이 쓰거나 고치는 모든 파일의 한국어를 포함합니다. 문서와 보고서는 물론이고 코드 주석, 화면에 나가는 문자열, 자막과 템플릿, 스크립트가 읽어 산출물을 만드는 데이터 파일까지 모두 해당합니다.',
136
139
  '적용 예외: 원문을 그대로 옮기는 인용과, 프로젝트의 기존 표기 관례를 따라야 하는 커밋 메시지와 로그 문자열입니다.',
140
+ // The vendored text says the opposite of the line above, and it says so
141
+ // twice. Left unaddressed the model has no way to decide which sentence
142
+ // governs a code comment, and in September 2026 that ambiguity shipped em
143
+ // dashes into comments and log strings while the guidance was active. The
144
+ // vendored wording stays untouched, so the conflict is resolved by naming
145
+ // which side wins rather than by editing the source.
146
+ '아래에 이어지는 fluent-korean 본문은 "인용, 코드, 코드 주석에는 이 지침들을 적용하지 않습니다"라고 적고 있으며 동작 범위 2번 조항에서 한 번 더 강조합니다. 그러나 이 세션에서는 바로 위의 적용 대상과 적용 예외가 그 문장보다 우선합니다. 즉 코드 주석에도 지침을 적용하십시오.',
137
147
  '쓰기 시점에 기계 검사가 함께 돌아갑니다. 위반을 알리면 그 파일을 고친 뒤에 다음 작업으로 넘어가십시오.',
138
148
  ];
139
149
  }
@@ -69,8 +69,14 @@ const ROLES = ['main', 'opus', 'sonnet', 'haiku', 'fable'];
69
69
 
70
70
  /**
71
71
  * Alias for a role, taken from the environment Claude Code itself uses to
72
- * pick subagent models. Returns null when the variable is absent or is itself
73
- * an ARN (resolving an ARN to another ARN would loop).
72
+ * pick subagent models. Returns null when the variable is absent, or when it
73
+ * is an opaque ARN that names no model (resolving one of those to another ARN
74
+ * would loop).
75
+ *
76
+ * A `foundation-model` ARN is not opaque: it spells the model out in its
77
+ * resource part, so it is unwrapped rather than rejected. Users who point
78
+ * these variables straight at an ARN — a normal way to configure a private
79
+ * gateway — used to get no delegation stats at all, and no hint as to why.
74
80
  */
75
81
  export function aliasForRole(role, env = process.env) {
76
82
  const candidates = {
@@ -81,7 +87,13 @@ export function aliasForRole(role, env = process.env) {
81
87
  fable: [env.ANTHROPIC_DEFAULT_FABLE_MODEL],
82
88
  }[role] || [];
83
89
  for (const v of candidates) {
84
- if (typeof v === 'string' && v && !isGatewayModelId(v)) return v;
90
+ if (typeof v !== 'string' || !v) continue;
91
+ if (!isGatewayModelId(v)) return v;
92
+ // `arn:…:foundation-model/anthropic.claude-haiku-4-5-…` → the model id.
93
+ // An `application-inference-profile` id is a random string and stays
94
+ // rejected: guessing at it is how wrong prices get into the ledger.
95
+ const inner = profileIdFrom(v);
96
+ if (inner && /claude/i.test(inner)) return inner;
85
97
  }
86
98
  return null;
87
99
  }
package/src/parser.js CHANGED
@@ -4,10 +4,23 @@ import { createInterface } from 'node:readline';
4
4
  import { join, isAbsolute } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import { loadCache, getCached, putCached, saveCache } from './session-cache.js';
7
- import { resolveModelAlias } from './model-alias.js';
7
+ import { resolveModelAlias, isGatewayModelId } from './model-alias.js';
8
8
 
9
9
  const CLAUDE_DIR = join(homedir(), '.claude', 'projects');
10
10
 
11
+ /**
12
+ * Keys LiteLLM adds when it rewrites a Bedrock response into Anthropic shape.
13
+ * A stock Anthropic `usage` object carries none of them, so their presence is
14
+ * evidence of a gateway even when the model id looks ordinary. It is weak
15
+ * evidence — another gateway may not add them — so it is only consulted after
16
+ * the model id has already failed to answer the question.
17
+ */
18
+ const GATEWAY_USAGE_KEYS = ['inference_geo', 'iterations', 'speed'];
19
+
20
+ function usageLooksGatewayShaped(usage) {
21
+ return GATEWAY_USAGE_KEYS.some((k) => Object.prototype.hasOwnProperty.call(usage, k));
22
+ }
23
+
11
24
  /**
12
25
  * Parse a single session JSONL file.
13
26
  * Deduplicates by requestId (last-write-wins for streaming chunks).
@@ -17,6 +30,7 @@ export async function parseSessionFile(filePath) {
17
30
  let sessionId = null;
18
31
  let firstTimestamp = null;
19
32
  let lastTimestamp = null;
33
+ let gatewayObserved = false;
20
34
 
21
35
  const rl = createInterface({
22
36
  input: createReadStream(filePath, { encoding: 'utf8' }),
@@ -48,6 +62,14 @@ export async function parseSessionFile(filePath) {
48
62
  const cc = usage.cache_creation || {};
49
63
  const reqId = entry.requestId || msg.id;
50
64
 
65
+ // Recorded from the RAW id, before resolveModelAlias() turns the ARN into
66
+ // a plain model name. Downstream this is the only thing that distinguishes
67
+ // "no cache writes yet" from "a gateway that never reports the TTL split",
68
+ // and those two states want opposite countdown defaults.
69
+ if (!gatewayObserved && (isGatewayModelId(msg.model) || usageLooksGatewayShaped(usage))) {
70
+ gatewayObserved = true;
71
+ }
72
+
51
73
  requests.set(reqId, {
52
74
  requestId: reqId,
53
75
  model: resolveModelAlias(msg.model),
@@ -87,6 +109,7 @@ export async function parseSessionFile(filePath) {
87
109
  totals,
88
110
  maxContextPerRequest,
89
111
  model: reqs[0]?.model || 'unknown',
112
+ gatewayObserved,
90
113
  };
91
114
  }
92
115
 
@@ -26,7 +26,10 @@ import { debug } from './debug.js';
26
26
  const CACHE_PATH = join(userDataDir(), 'session-cache.json');
27
27
  // Bump when the cached summary's shape changes — old entries are dropped
28
28
  // wholesale rather than migrated.
29
- const CACHE_VERSION = 1;
29
+ // 2: sessions carry `gatewayObserved`. Entries written by version 1 lack it,
30
+ // and a missing flag reads as "not a gateway" — the wrong default for exactly
31
+ // the users the flag exists for.
32
+ const CACHE_VERSION = 2;
30
33
  // Entries for transcripts this old are pruned on write. Keeps the file
31
34
  // bounded without an existence check per entry (which would cost the syscalls
32
35
  // the cache exists to avoid).