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/README.md +48 -0
- package/bin/cli.js +30 -0
- package/package.json +8 -3
- package/presets/doc2md/convert.py +188 -0
- package/src/advice.js +34 -2
- package/src/commands/doc2md.js +100 -0
- package/src/commands/mode.js +1 -0
- package/src/commands/route-scan.js +15 -1
- package/src/config.js +15 -0
- package/src/cost.js +6 -0
- package/src/doc2md.cjs +378 -0
- package/src/formatters/statusline.js +37 -7
- package/src/formatters/table.js +8 -1
- package/src/installer.js +69 -0
- package/src/korean-style.js +11 -1
- package/src/model-alias.js +15 -3
- package/src/parser.js +24 -1
- package/src/session-cache.js +4 -1
- package/src/stats.js +22 -1
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
|
|
285
|
-
const
|
|
286
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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"
|
package/src/formatters/table.js
CHANGED
|
@@ -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
|
-
|
|
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 }),
|
package/src/korean-style.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/model-alias.js
CHANGED
|
@@ -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
|
|
73
|
-
* an ARN (resolving
|
|
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
|
|
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
|
|
package/src/session-cache.js
CHANGED
|
@@ -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
|
-
|
|
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).
|