claude-token-saver 3.26.1 → 3.26.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -382,18 +382,23 @@ Installs with nobody attached — npm `postinstall`, CI, piped stdin — skip th
382
382
 
383
383
  `Read` a pptx, xlsx, pdf or docx and the raw bytes go into the context window, where the model cannot read them. This intercepts that `Read`, converts the file once, and hands over the Markdown instead.
384
384
 
385
+ **This is opt-in.** Installing the CLI does not turn it on: both commands below are required, and a registered hook with no converter behind it does nothing at all.
386
+
385
387
  ```bash
386
- pip install "markitdown[pptx,pdf,xlsx,docx]" # the converter is a Python package
387
- claude-token-saver doc2md on # register the Read hook
388
- claude-token-saver doc2md report.pptx # convert by hand and see the result
388
+ claude-token-saver doc2md install-converter # markitdown into a dedicated venv
389
+ claude-token-saver doc2md on # register the Read hook
390
+ claude-token-saver doc2md # check converter + hook registration
391
+ claude-token-saver doc2md report.pptx # convert by hand and see the result
389
392
  ```
390
393
 
394
+ The converter goes into a venv this tool owns (`<state dir>/doc2md-venv`): no system interpreter is touched, and uninstalling the CLI takes it along. An existing markitdown on `uv tool` or `PATH` is preferred over building a new one.
395
+
391
396
  Conversion is [markitdown](https://github.com/microsoft/markitdown). Slide numbers, heading levels, tables, speaker notes and per-sheet headings all survive, and non-Latin text comes through intact.
392
397
 
393
398
  Several things it deliberately does not do:
394
399
 
395
400
  - **Images are not converted.** markitdown returns nothing for them, and OCR misread resource names in testing (`c5.xlarge` as `c.xlarge`). In a document where those names *are* the content, wrong text is worse than none. The model reads images natively anyway.
396
- - **A missing markitdown never fails silently.** The install command is shown once, then the original `Read` proceeds untouched. Repeating the notice on every read would be its own nuisance; saying nothing is how a broken converter hides.
401
+ - **A missing converter never fails silently.** The install command is shown once, then the original `Read` proceeds untouched. Repeating the notice on every read would be its own nuisance; saying nothing is how a broken converter hides. Run `doc2md` with no arguments to see the converter and hook registration together.
397
402
  - **Conversions never land in your project.** They go under the tool's own state directory with mode `0700`, so there is nothing to add to `.gitignore`. Filenames matching payroll/contract/secret patterns are skipped entirely.
398
403
  - **Zip bombs are refused.** pptx/xlsx/docx are zip containers: the declared sizes are checked first, and since those are written by whoever built the file, the real decompressed bytes are counted against a ceiling too.
399
404
  - **Spreadsheets are capped by rows, not bytes.** Conversion time tracks row count (measured: a 6.3MB PDF in 0.9s, a 5.8MB workbook in 47.75s). Past 50,000 rows only the head is converted, and **the truncation and the true row count are both stated** in what the model is told.
@@ -497,6 +502,11 @@ Also update `statusLine.command` in `~/.claude/settings.json` to `claude-token-s
497
502
 
498
503
  ## Release notes
499
504
 
505
+ ### v3.26.2 (2026-09-04)
506
+ - **The converter installs itself.** The old instruction was `pip install`, which asks the user to modify a system interpreter — and if they skipped it, the hook sat registered and did nothing. `doc2md install-converter` builds a dedicated venv and puts markitdown in it.
507
+ - **`doc2md` status now reports hook registration too.** Showing only the converter made "hook but no converter" and "converter but no hook" look identical, and both look like a broken feature.
508
+ - **An unknown subcommand under `--hook` prints nothing.** A 3.25.0 global install meeting a settings.json written by 3.26.0 did not recognise `doc2md`, fell through to the default report, and pushed a full statistics table into the hook stream on every `Read`.
509
+
500
510
  ### v3.26.0 (2026-09-04)
501
511
  - **Attached documents are converted to Markdown before the model reads them.** Reading a pptx/xlsx/pdf/docx put unreadable bytes into the context window. `doc2md on` registers a `Read` hook that converts the file once, caches it outside your project, and points the model at the Markdown. A missing converter is announced once and then gets out of the way, zip bombs are refused, and workbooks past 50,000 rows are converted head-first with the truncation stated. See [doc2md](#-doc2md--attached-documents-become-markdown-before-the-model-reads-them).
502
512
  - **TTL display fixed for Bedrock/Vertex sessions.** Gateways never report the per-bucket split, and the fallback assumed an hour — twelvefold too long for a 5-minute-only backend. The gateway is now detected from the model ids, the fallback follows that evidence, and the label reads `5m?` to mark it as inferred. Pin it manually with `mode ttl=5m` if the detection is wrong.
package/README.md CHANGED
@@ -372,18 +372,23 @@ npm의 `postinstall`이나 CI처럼 사람이 붙어 있지 않은 설치에서
372
372
 
373
373
  pptx·xlsx·pdf·docx 를 그대로 `Read` 하면 모델이 읽지 못하는 바이트가 컨텍스트에 그대로 올라갑니다. 이 기능은 그 `Read` 를 가로채 파일을 한 번 변환해 두고, 원본 대신 변환본을 읽게 합니다.
374
374
 
375
+ **이 기능은 옵트인입니다.** 설치만으로는 켜지지 않고, 아래 두 명령을 모두 실행해야 동작합니다. 훅만 등록하고 변환기가 없으면 아무 일도 일어나지 않습니다.
376
+
375
377
  ```bash
376
- pip install "markitdown[pptx,pdf,xlsx,docx]" # 변환기는 파이썬 패키지입니다
377
- claude-token-saver doc2md on # Read 훅 등록
378
- claude-token-saver doc2md 보고서.pptx # 직접 변환해 결과 확인
378
+ claude-token-saver doc2md install-converter # 전용 venv에 markitdown 설치
379
+ claude-token-saver doc2md on # Read 훅 등록
380
+ claude-token-saver doc2md # 변환기·훅 등록 상태 확인
381
+ claude-token-saver doc2md 보고서.pptx # 직접 변환해 결과 확인
379
382
  ```
380
383
 
384
+ 변환기는 도구 전용 venv(`<상태 디렉터리>/doc2md-venv`)에 설치합니다. 시스템 파이썬을 건드리지 않고, CLI를 지우면 함께 사라집니다. 이미 `uv tool` 이나 다른 경로에 markitdown 이 있으면 그쪽을 먼저 씁니다.
385
+
381
386
  변환은 [markitdown](https://github.com/microsoft/markitdown)이 담당하며, 슬라이드 번호와 제목 계층, 표, 발표자 노트, 시트 구분이 모두 남습니다. 한글도 깨지지 않습니다.
382
387
 
383
388
  몇 가지는 의도적으로 하지 않습니다.
384
389
 
385
390
  - **이미지는 변환하지 않습니다.** markitdown 이 빈 결과를 돌려주고, OCR 은 실측에서 리소스 이름을 틀리게 읽었습니다(`c5.xlarge` 를 `c.xlarge` 로). 이름 자체가 내용인 문서에서는 텍스트가 없느니만 못합니다. 모델이 이미지는 직접 읽습니다.
386
- - **markitdown 없으면 조용히 실패하지 않습니다.** 설치 명령을 한 번 안내한 뒤 원본 `Read` 를 그대로 통과시킵니다. 매번 알리면 그것대로 방해가 되고, 아무 말도 하지 않으면 고장을 숨기게 됩니다.
391
+ - **변환기가 없으면 조용히 실패하지 않습니다.** 설치 명령을 한 번 안내한 뒤 원본 `Read` 를 그대로 통과시킵니다. 매번 알리면 그것대로 방해가 되고, 아무 말도 하지 않으면 고장을 숨기게 됩니다. `doc2md` 를 인자 없이 실행하면 변환기와 훅 등록 상태를 한 번에 확인할 수 있습니다.
387
392
  - **변환본은 프로젝트 안에 남기지 않습니다.** 도구의 상태 디렉터리 아래 권한 `0700` 으로 저장하므로 `.gitignore` 에 무엇을 추가할 필요가 없습니다. 파일명이 급여·계약·개인정보 같은 패턴에 걸리면 아예 변환하지 않습니다.
388
393
  - **압축 폭탄은 막습니다.** pptx·xlsx·docx 는 zip 컨테이너입니다. 선언된 크기를 먼저 걸러 내고, 선언은 조작될 수 있으므로 실제 해제 바이트도 상한과 대조합니다.
389
394
  - **엑셀은 행 수로 자릅니다.** 변환 시간은 파일 크기가 아니라 행 수를 따릅니다(실측: PDF 6.3MB 0.9초, 엑셀 5.8MB 47.75초). 5만 행을 넘으면 앞부분만 변환하고, **잘랐다는 사실과 전체 행 수를 안내에 함께 적습니다.**
@@ -463,6 +468,11 @@ npm uninstall -g claude-cache-monitor && npm i -g claude-token-saver
463
468
 
464
469
  ## 릴리스 노트
465
470
 
471
+ ### v3.26.2 (2026-09-04)
472
+ - **doc2md 변환기를 도구가 직접 설치합니다.** 지금까지의 안내는 `pip install` 이었는데, 시스템 파이썬을 건드리라는 요구인 데다 실행하지 않으면 훅만 등록된 채 아무 일도 일어나지 않았습니다. `doc2md install-converter` 가 전용 venv 를 만들어 markitdown 을 넣습니다.
473
+ - **`doc2md` 상태 출력에 훅 등록 여부를 함께 적습니다.** 변환기만 알려 주면 "훅은 있는데 변환기가 없다"와 "변환기는 있는데 훅이 없다"가 똑같이 아무 일도 안 하는 상태로 보여서, 어느 쪽이 빠졌는지 알 수 없었습니다.
474
+ - **모르는 서브커맨드를 `--hook` 으로 부르면 아무것도 출력하지 않습니다.** 3.25.0 전역 설치본이 3.26.0 이 쓴 `settings.json` 을 만나면 `doc2md` 를 인식하지 못하고 기본 리포트로 흘러가, `Read` 할 때마다 통계 표 전문을 훅 스트림에 밀어 넣었습니다.
475
+
466
476
  ### v3.26.0 (2026-09-04)
467
477
  - **첨부 문서를 읽기 전에 Markdown 으로 변환합니다.** pptx·xlsx·pdf·docx 를 그대로 `Read` 하면 모델이 읽지 못하는 바이트가 컨텍스트에 올라갑니다. `doc2md on` 으로 `Read` 훅을 등록하면 파일을 한 번 변환해 캐시에 두고 변환본을 읽게 합니다. 변환기가 없으면 안내를 한 번만 하고 원본 `Read` 를 통과시키며, 압축 폭탄은 막고, 5만 행이 넘는 엑셀은 앞부분만 변환한 뒤 잘랐다는 사실을 함께 알립니다. 자세한 내용은 [doc2md](#-doc2md-첨부-문서를-읽기-전에-markdown-으로-바꿉니다) 절을 참고하십시오.
468
478
  - **Bedrock·Vertex 경유 환경의 TTL 표시를 바로잡았습니다.** 게이트웨이는 버킷별 분해 값을 내려보내지 않는데, 판정 불가일 때 1시간을 기본값으로 잡고 있었습니다. 5분 버킷만 제공하는 환경에서 남은 시간이 최대 12배로 부풀어 보였습니다. 이제 모델 ID로 게이트웨이를 감지해 5분을 기본값으로 쓰고, 라벨을 `5m?` 로 적어 추정임을 밝힙니다. `mode ttl=5m` 으로 직접 지정할 수도 있습니다.
package/bin/cli.js CHANGED
@@ -83,7 +83,23 @@ function readUpdateChip() {
83
83
  }
84
84
  }
85
85
 
86
+ // Subcommands this build knows how to run. Used only by the guard below.
87
+ const KNOWN_SUBCOMMANDS = new Set([
88
+ 'last', 'brief', 'history', 'handoff', 'install', 'uninstall', 'mode', 'korean',
89
+ 'doc2md', 'harness', 'route-scan', 'compact-window', 'update-check', 'upgrade',
90
+ ]);
91
+
86
92
  async function main() {
93
+ // A hook invocation names a subcommand and expects either silence or that
94
+ // subcommand's own protocol on stdout. If this build does not have the
95
+ // subcommand — an older global install against a newer settings.json, which
96
+ // is exactly what a mid-upgrade machine looks like — falling through to the
97
+ // default report would push a full table into the hook stream on every
98
+ // matching tool call. Say nothing instead.
99
+ if (hasFlag('--hook') && args[0] && !KNOWN_SUBCOMMANDS.has(args[0])) {
100
+ return;
101
+ }
102
+
87
103
  // Subcommand: last — print the most recent warning + how to handle it.
88
104
  // Designed for the auto-trigger skill so the user immediately sees
89
105
  // "what just fired and how to fix it" without having to read the whole
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "3.26.1",
3
+ "version": "3.26.2",
4
4
  "description": "Route the easy work your expensive Claude model keeps repeating down to haiku/sonnet — post-hoc session analysis, no realtime router, no extra LLM calls.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,6 +15,27 @@ import { join } from 'node:path';
15
15
 
16
16
  const require = createRequire(import.meta.url);
17
17
 
18
+ /**
19
+ * Whether the Read hook is actually in settings.json.
20
+ *
21
+ * Status output that reports only the converter is misleading: a working
22
+ * converter with no hook, and a hook with no converter, both add up to
23
+ * "nothing happens", and the user has no way to tell which half is missing.
24
+ */
25
+ function hookRegistered() {
26
+ try {
27
+ const { homedir } = require('node:os');
28
+ const settings = JSON.parse(
29
+ require('node:fs').readFileSync(join(homedir(), '.claude', 'settings.json'), 'utf8'),
30
+ );
31
+ return (settings?.hooks?.PreToolUse || []).some((m) =>
32
+ (m.hooks || []).some((h) => typeof h.command === 'string' && h.command.includes('doc2md --hook')),
33
+ );
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
18
39
  export async function run({ args, hasFlag }) {
19
40
  const doc2md = require('../doc2md.cjs');
20
41
  const sub = args[1];
@@ -37,16 +58,30 @@ export async function run({ args, hasFlag }) {
37
58
  return;
38
59
  }
39
60
 
61
+ if (sub === 'install-converter') {
62
+ const res = doc2md.installConverter({ onProgress: (m) => console.log(` ${m}`) });
63
+ if (res.ok) {
64
+ console.log(`✓ converter ready: ${res.python}`);
65
+ return;
66
+ }
67
+ console.error(`✗ ${res.reason}: ${res.detail}`);
68
+ process.exitCode = 1;
69
+ return;
70
+ }
71
+
40
72
  if (sub === 'on') {
41
73
  const { installDoc2mdHook } = await import('../installer.js');
42
74
  const res = installDoc2mdHook();
43
75
  console.log(res.action === 'skipped'
44
76
  ? `✗ ${res.reason}`
45
77
  : `✓ Read hook ${res.action} (${res.path})`);
78
+ // A registered hook with no converter behind it does nothing at all, and
79
+ // says nothing about it either, which reads as a broken feature. Offer the
80
+ // one command that closes the gap right where the gap is visible.
46
81
  const python = doc2md.findInterpreter();
47
82
  console.log(python
48
83
  ? ` converter: markitdown via ${python}`
49
- : ` converter: not installed yet — ${doc2md.INSTALL_HINT}`);
84
+ : ` converter: missing run \`${doc2md.INSTALL_HINT}\` or the hook will do nothing`);
50
85
  return;
51
86
  }
52
87
 
@@ -95,6 +130,8 @@ export async function run({ args, hasFlag }) {
95
130
  console.log(` formats: ${doc2md.TARGET_EXTENSIONS.join(' ')}`);
96
131
  console.log(` converter: ${python ? `markitdown via ${python}` : `not installed — ${doc2md.INSTALL_HINT}`}`);
97
132
  console.log(` cache: ${dir} (${cached} file(s))`);
133
+ console.log(` hook: ${hookRegistered() ? 'registered on Read' : 'not registered'}`);
98
134
  console.log('');
135
+ if (!python) console.log(`Install the converter: ${doc2md.INSTALL_HINT}`);
99
136
  console.log('Enable with: claude-token-saver doc2md on');
100
137
  }
package/src/doc2md.cjs CHANGED
@@ -158,6 +158,11 @@ function findInterpreter() {
158
158
  const candidates = [];
159
159
  if (process.env.CTS_DOC2MD_PYTHON) candidates.push(process.env.CTS_DOC2MD_PYTHON);
160
160
  candidates.push(
161
+ // The tool's own venv, created by `doc2md install-converter`. First
162
+ // because it is the only one this tool controls: telling people to
163
+ // `pip install` into the system interpreter is how a token-saving CLI
164
+ // ends up owning a break in someone else's project.
165
+ managedPython(),
161
166
  path.join(os.homedir(), '.local', 'share', 'uv', 'tools', 'markitdown', 'bin', 'python'),
162
167
  path.join(os.homedir(), '.local', 'bin', 'markitdown-python'),
163
168
  'python3',
@@ -178,6 +183,60 @@ function findInterpreter() {
178
183
 
179
184
  const CONVERTER = path.join(__dirname, '..', 'presets', 'doc2md', 'convert.py');
180
185
 
186
+ /** Path to the interpreter inside the venv this tool manages. */
187
+ function managedPython() {
188
+ const dir = path.join(userDataDir(), 'doc2md-venv');
189
+ return process.platform === 'win32'
190
+ ? path.join(dir, 'Scripts', 'python.exe')
191
+ : path.join(dir, 'bin', 'python');
192
+ }
193
+
194
+ const MARKITDOWN_SPEC = 'markitdown[pptx,pdf,xlsx,docx]';
195
+
196
+ /**
197
+ * Build the managed venv and install markitdown into it.
198
+ *
199
+ * Kept behind an explicit command: creating a 300MB virtualenv is not
200
+ * something to do because somebody opened a spreadsheet once. But once asked
201
+ * for, it goes somewhere this tool owns, so uninstalling the CLI takes the
202
+ * whole thing with it and no system interpreter is touched.
203
+ */
204
+ function installConverter({ onProgress = () => {} } = {}) {
205
+ const venv = path.join(userDataDir(), 'doc2md-venv');
206
+ const target = managedPython();
207
+
208
+ if (!fs.existsSync(target)) {
209
+ onProgress(`creating ${venv}`);
210
+ let created = false;
211
+ for (const base of ['python3', 'python']) {
212
+ const r = spawnSync(base, ['-m', 'venv', venv], { encoding: 'utf8', timeout: 180_000 });
213
+ if (r.status === 0) { created = true; break; }
214
+ }
215
+ if (!created) {
216
+ return { ok: false, reason: 'no-python', detail: 'no python3 with the venv module on PATH' };
217
+ }
218
+ }
219
+
220
+ onProgress(`installing ${MARKITDOWN_SPEC}`);
221
+ const install = spawnSync(target, ['-m', 'pip', 'install', '--quiet', MARKITDOWN_SPEC], {
222
+ encoding: 'utf8',
223
+ timeout: 900_000,
224
+ });
225
+ if (install.status !== 0) {
226
+ return { ok: false, reason: 'pip-failed', detail: (install.stderr || '').slice(0, 400) };
227
+ }
228
+
229
+ // The probe is the actual acceptance test: pip can exit 0 and still leave an
230
+ // interpreter that cannot import what was asked for.
231
+ const probe = spawnSync(target, ['-c', 'import markitdown'], { timeout: 60_000, stdio: 'ignore' });
232
+ if (probe.status !== 0) {
233
+ return { ok: false, reason: 'import-failed', detail: 'installed, but markitdown does not import' };
234
+ }
235
+ interpreterCache = target;
236
+ clearNotice();
237
+ return { ok: true, python: target };
238
+ }
239
+
181
240
  /**
182
241
  * Convert one file. Returns `{ ok: true, cacheFile, meta }`, or
183
242
  * `{ ok: false, reason, detail }` where reason is one of:
@@ -258,6 +317,18 @@ function noticeAlreadyShown() {
258
317
  }
259
318
  }
260
319
 
320
+ /**
321
+ * Forget that the notice was shown.
322
+ *
323
+ * Called after the converter is installed, so that if it later disappears the
324
+ * user is told once more instead of meeting permanent silence.
325
+ */
326
+ function clearNotice() {
327
+ try {
328
+ fs.rmSync(noticePath(), { force: true });
329
+ } catch { /* nothing to forget */ }
330
+ }
331
+
261
332
  function markNoticeShown() {
262
333
  try {
263
334
  fs.mkdirSync(userDataDir(), { recursive: true });
@@ -265,7 +336,7 @@ function markNoticeShown() {
265
336
  } catch { /* an unwritable state dir just means the notice repeats */ }
266
337
  }
267
338
 
268
- const INSTALL_HINT = 'pip install "markitdown[pptx,pdf,xlsx,docx]"';
339
+ const INSTALL_HINT = 'claude-token-saver doc2md install-converter';
269
340
 
270
341
  /**
271
342
  * Decide what to tell Claude Code about one PreToolUse(Read) payload.
@@ -364,6 +435,10 @@ module.exports = {
364
435
  TARGET_EXTENSIONS,
365
436
  MAX_SOURCE_BYTES,
366
437
  INSTALL_HINT,
438
+ MARKITDOWN_SPEC,
439
+ managedPython,
440
+ installConverter,
441
+ clearNotice,
367
442
  cacheDir,
368
443
  cachePathFor,
369
444
  metaPathFor,