claude-token-saver 3.27.0 → 3.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -4,23 +4,26 @@
4
4
 
5
5
  # claude-token-saver
6
6
 
7
- **Moves the easy work your expensive model keeps repeating onto cheaper ones, and shows what that actually saved — measured, in your statusline.** Zero dependencies, one-line install.
7
+ **Shows what it saved, on two lines.** It moves the easy work your expensive model keeps repeating onto cheaper ones, and turns documents the model cannot read into Markdown. Both figures are ledger entries rather than estimates, and whichever saved more takes the top line. Zero dependencies, one-line install.
8
8
 
9
- ![statusline example — routing savings on row 1, diagnostics on row 2](./docs/statusline.png)
9
+ ![statusline example — routing savings on row 1, document conversion savings on row 2, diagnostics on row 3](./docs/statusline.png)
10
10
 
11
11
  ```bash
12
12
  npm i -g claude-token-saver
13
13
  ```
14
14
 
15
- ## Three parts, working together
15
+ ## Four parts, working together
16
16
 
17
17
  | | What it does | Effect |
18
18
  |---|---|---|
19
19
  | 🔀 **Routing** | Delegates recurring easy work to cheaper models | Savings recorded per run in a ledger |
20
+ | 📄 **Document conversion** | Turns pptx/xlsx/pdf/docx/fig into Markdown before the model reads them | **510,000 tokens** saved on one deck ([below](#-doc2md--documents-become-markdown-before-the-model-reads-them)) |
20
21
  | 🅷 **Harness** | Blocks the token-burning habits: unevidenced "done", skipped verification (5 principles) | **−18.6% cost** ([measured](#real-world-impact--beforeafter-report)) |
21
22
  | ⚙️ **Ratchet** | Freezes each error you hit into a rule | Same mistake stops recurring |
22
23
 
23
- One install sets up all three. The measured −18.6% comes from the harness and ratchet; routing savings sit on top of it.
24
+ One install sets up all four. The measured −18.6% comes from the harness and ratchet; routing and conversion savings sit on top of it.
25
+
26
+ The two savings figures are never added together, because they answer different questions. Routing says "the same work ran on a cheaper model". Conversion says "a file you could not read became readable, without pushing the original through the context window". The statusline gives each its own line and puts the larger one first.
24
27
 
25
28
  ## 🔀 The savings figure is a ledger entry, not an estimate
26
29
 
@@ -62,7 +65,6 @@ By run (newest first):
62
65
  | 🚨 **No surprise rate limits** | Warns when the 5H/7D window hits 90%; `handoff` backs up your work |
63
66
  | 🧠 **Cache waste detection** | Hit rate, TTL, 1M-context detection — spikes diagnosed with issue codes |
64
67
  | 🇰🇷 **Korean writing guidance** | Offered at install time, defaulting to your locale ([below](#-korean-writing-guidance)) |
65
- | 📄 **Document conversion** | pptx/xlsx/pdf/docx become Markdown before the model reads them, so unreadable bytes never reach the context window ([below](#-doc2md--attached-documents-become-markdown-before-the-model-reads-them)) |
66
68
 
67
69
  ## Not a router — 60 seconds
68
70
 
@@ -378,7 +380,7 @@ Installs with nobody attached — npm `postinstall`, CI, piped stdin — skip th
378
380
  > The guidance text comes from [fluent-korean](https://github.com/snflkd/fluent-korean). Copyright (c) 2026 snflkd, MIT License.
379
381
  > The wording is unmodified; only the output-style frontmatter was removed. The full license ships with the package at `presets/korean-style/LICENSE-fluent-korean`.
380
382
 
381
- ## 📄 doc2md — attached documents become Markdown before the model reads them
383
+ ## 📄 doc2md — documents become Markdown before the model reads them
382
384
 
383
385
  `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
386
 
@@ -389,13 +391,13 @@ Three situations, three different interception points:
389
391
  | Situation | Where it is caught |
390
392
  |---|---|
391
393
  | A document path typed in the prompt (`@path`, quoted, or relative) | `UserPromptSubmit`: converted, and the conversion's path is handed back as context |
392
- | A document opened with `Read` mid-task | PDFs are caught by `PreToolUse(Read)`. pptx/xlsx/docx are not: Claude Code refuses them as binary *before* any hook runs, so the session-start note tells the model to run `doc2md <path>` instead |
394
+ | A document opened with `Read` mid-task | PDFs are caught by `PreToolUse(Read)`. pptx/xlsx/docx/fig are not: Claude Code refuses them as binary *before* any hook runs, so the session-start note tells the model to run `doc2md <path>` instead |
393
395
  | A document attached to the message | **Not catchable.** No hook event receives attachment content. The session-start note has the model ask for a path next time |
394
396
 
395
397
  That second row is measured, not assumed: a `.pdf` Read fires the hook, and a `.pptx` Read in the same session leaves no hook log entry at all.
396
398
 
397
399
  ```bash
398
- claude-token-saver doc2md install-converter # markitdown into a dedicated venv
400
+ claude-token-saver doc2md install-converter # markitdown + editing libs + the .fig parser
399
401
  claude-token-saver doc2md on # register the Read hook
400
402
  claude-token-saver doc2md # check converter + hook registration
401
403
  claude-token-saver doc2md report.pptx # convert by hand and see the result
@@ -413,6 +415,46 @@ Several things it deliberately does not do:
413
415
  - **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.
414
416
  - **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.
415
417
 
418
+ ### What a conversion saves
419
+
420
+ Every conversion is stamped with a provenance header: which original, when, how many tokens. Savings show up on the statusline's own `📄 Doc2md saved` line.
421
+
422
+ The baseline is what you would have done without a converter, and that differs by format. Both were measured on 2026-09-06.
423
+
424
+ **PDF is priced against attaching it.** The same one-line prompt was sent through `claude --print --input-format stream-json` with and without the file as a document block. The control turn cost 42,204 tokens, twice, to the token.
425
+
426
+ | Attached file | Size | Extra tokens | Per page |
427
+ |---|---|---|---|
428
+ | Résumé PDF | 7 pages | +20,537 | 2,934 |
429
+ | Résumé PDF | 5 pages | +12,709 | 2,542 |
430
+
431
+ An attached PDF is read whole, but every page costs 2,500–2,900 tokens against 5,531 for the conversion. The coefficient used is 2,500 per page — below both measurements, so the figure understates rather than flatters.
432
+
433
+ **pptx/xlsx/docx are priced against unpacking the container.** These never reach the model as attachments at all: the same probe on a docx added 78 tokens and the model replied that it had no file, and `Read` refuses the format outright. What you actually do without a converter is unzip the archive and read its XML, where tags and style attributes outweigh the words.
434
+
435
+ | Original | Body XML | Conversion | Ratio |
436
+ |---|---|---|---|
437
+ | Deck, pptx (31.8MB) | ~540,429 tokens | ~22,610 tokens | 23.8× |
438
+ | Résumé, docx (189KB) | ~79,621 tokens | ~1,684 tokens | 47.3× |
439
+
440
+ This baseline is measured per file from the real XML size, not applied as a per-format ratio. `.xls` is not a zip container and has no markup to measure, so it claims nothing.
441
+
442
+ ### Figma `.fig` converts too
443
+
444
+ Planning documents are moving from PowerPoint to Figma, so the same hook catches `.fig`. A `.fig` is a zip, but the `canvas.fig` inside it is Figma's private binary (kiwi format), which markitdown cannot open — so this one format is converted in Node with [openfig-core](https://github.com/OpenFig-org/openfig-core) (MIT). `doc2md install-converter` places it beside markitdown in the tool's state directory; the package itself still ships zero dependencies.
445
+
446
+ The result is an outline: pages and frames become headings, text nodes become body lines, and shapes are counted rather than listed — in a planning document the words are the content, and two hundred `Rectangle 173` lines would drown them. A file with no text at all is refused rather than dressed up as an empty document.
447
+
448
+ Verified against real files: a community Bootstrap UI kit (8.1MB, 4,155 nodes, 1,312 of them text) and a 52MB Tailwind kit, each converting in under a second. Both `.fig` vintages parse — the current zip container and the older bare fig-kiwi stream. No savings are claimed: a `.fig` unzips to another binary, so there is no readable fallback to price against.
449
+
450
+ ### Editing a document: copy, then script
451
+
452
+ Conversion is one-way — editing the cached `.md` changes nothing in the source. The hook refuses `Edit`/`Write` on both the cache and the original binary, and points at the right path instead: copy the original, edit the copy with a script, re-convert the copy to verify.
453
+
454
+ `install-converter` puts the editing libraries (python-pptx, python-docx, openpyxl) in the same venv, so a structural request like "swap the chart on slide 23 for a line chart" is a short script the agent writes on the spot. `.fig` edits go through openfig-core, which encodes as well as parses.
455
+
456
+ All four formats were exercised end to end on 2026-09-06: 10 docx run replacements plus three consecutive re-saves, a pptx bar-to-line chart swap with an added data point, xlsx value edits and a new row, and a fig text edit with re-encode and re-parse. In every case the original was byte-identical afterwards and the re-converted copy showed the change. One caveat: removing a chart shape from a pptx leaves the old chart XML part orphaned — PowerPoint ignores it, but delete the part and its rels for a clean file. Charts and images never appear in a conversion, so visual edits must be confirmed in the application itself.
457
+
416
458
  `claude-token-saver doc2md --clean` empties the conversion cache; `doc2md off` removes the hook. Removal filters for this tool's own entry, so anything else you registered under `PreToolUse` stays.
417
459
 
418
460
  ## 🌐 Behind a gateway (Bedrock / Vertex)
@@ -523,7 +565,7 @@ Also update `statusLine.command` in `~/.claude/settings.json` to `claude-token-s
523
565
  - **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`.
524
566
 
525
567
  ### v3.26.0 (2026-09-04)
526
- - **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).
568
+ - **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--documents-become-markdown-before-the-model-reads-them).
527
569
  - **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.
528
570
  - **Delegated runs are no longer discarded in silence.** Runs excluded for an unpriceable model id surface as `🔀 N unresolved` on the statusline; previously that was indistinguishable from never having delegated, so an entire tier of rules could report zero with no way to find out why. Environment variables set to a `foundation-model` ARN now resolve as well.
529
571
  - **The Korean guidance stopped contradicting itself.** The injected scope claimed code comments while the vendored text disclaimed them twice, leaving the model nothing to decide on. The vendored wording is untouched; the block now states which side wins. The em dash in the attribution line — a mark that guidance itself forbids — became a colon.
package/README.md CHANGED
@@ -4,23 +4,26 @@
4
4
 
5
5
  # claude-token-saver
6
6
 
7
- **비싼 모델이 반복하던 쉬운 작업을 싼 모델로 내려보내고, 그렇게 아낀 금액을 statusline에 실측으로 보여 줍니다.** 의존성 0, 설치 한 줄.
7
+ **아낀 돈을 두 줄로 보여 줍니다.** 비싼 모델이 반복하던 쉬운 작업을 싼 모델로 내려보내고, 모델이 읽지 못하는 문서를 Markdown 으로 바꿉니다. 두 절감액 모두 추정이 아니라 원장 기록이고, 더 많이 아낀 쪽이 첫 줄을 차지합니다. 의존성 0, 설치 한 줄.
8
8
 
9
- ![statusline 예시. 첫째 줄은 라우팅 절감액이고 둘째 줄은 진단 칩입니다](./docs/statusline.png)
9
+ ![statusline 예시. 첫 줄은 라우팅 절감액, 둘째 줄은 문서 변환 절감액, 셋째 줄은 진단 칩입니다](./docs/statusline.png)
10
10
 
11
11
  ```bash
12
12
  npm i -g claude-token-saver
13
13
  ```
14
14
 
15
- ## 세 가지가 함께 돌아갑니다
15
+ ## 네 가지가 함께 돌아갑니다
16
16
 
17
17
  | | 하는 일 | 효과 |
18
18
  |---|---|---|
19
19
  | 🔀 **라우팅** | 반복되는 쉬운 작업을 더 싼 모델에 위임 | 절감액을 원장에 실측 기록 |
20
+ | 📄 **문서 변환** | pptx·xlsx·pdf·docx·fig 를 읽기 전에 Markdown 으로 변환 | 발표자료 한 건에 **51만 토큰** 절약 ([아래](#-doc2md-문서를-읽기-전에-markdown-으로-바꿉니다)) |
20
21
  | 🅷 **Harness** | 증거 없는 완료 보고·검증 생략 차단 (5원칙) | **비용 −18.6%** ([실측](#실제-효과-도입-전후-리포트)) |
21
22
  | ⚙️ **Ratchet** | 한 번 겪은 에러를 룰로 고정 | 같은 실수 재발 차단 |
22
23
 
23
- 설치 한 번이면 셋 다 적용됩니다. 실측 −18.6%는 Harness와 ratchet의 몫이고, 라우팅 절감액은 그 위에 얹힙니다.
24
+ 설치 한 번이면 넷 다 적용됩니다. 실측 −18.6%는 Harness와 ratchet의 몫이고, 라우팅과 문서 변환 절감액은 그 위에 얹힙니다.
25
+
26
+ 두 절감액은 성격이 달라서 한 숫자로 합치지 않습니다. 라우팅은 "같은 일을 더 싼 모델이 했다"이고, 문서 변환은 "읽을 수 없던 파일을 읽었고 그 과정에서 원본을 통째로 밀어 넣지 않았다"입니다. statusline 은 둘을 각각의 줄로 보여 주고, 금액이 큰 쪽을 위에 놓습니다.
24
27
 
25
28
  ## 🔀 절감액은 추정이 아니라 원장 기록입니다
26
29
 
@@ -62,7 +65,6 @@ $ claude-token-saver route-scan savings # 모든 금액을 룰 단위까지
62
65
  | 🚨 **한도 초과 예방** | 5시간·7일 rate-limit 윈도가 90%에 닿으면 경고하고, `handoff`로 작업을 백업합니다 |
63
66
  | 🧠 **캐시 낭비 감지** | 히트율·TTL·1M 컨텍스트를 감지해 토큰 급증 원인을 코드로 진단합니다 |
64
67
  | 🇰🇷 **한국어 문체 교정** | 한국어 환경이면 자동으로 켜집니다 ([아래](#-한국어-문체-지침)) |
65
- | 📄 **문서 자동 변환** | pptx·xlsx·pdf·docx 를 읽기 전에 Markdown 으로 바꿔 읽지 못하는 바이트가 컨텍스트에 올라가지 않게 합니다 ([아래](#-doc2md-첨부-문서를-읽기-전에-markdown-으로-바꿉니다)) |
66
68
 
67
69
  ## 라우터가 아닙니다: 60초 설명
68
70
 
@@ -368,9 +370,11 @@ npm의 `postinstall`이나 CI처럼 사람이 붙어 있지 않은 설치에서
368
370
  > 지침 원문은 [fluent-korean](https://github.com/snflkd/fluent-korean)에서 가져왔습니다. Copyright (c) 2026 snflkd, MIT License.
369
371
  > 원문은 수정하지 않았고 output style 프런트매터만 제거했습니다. 라이선스 전문은 패키지의 `presets/korean-style/LICENSE-fluent-korean`에 함께 배포합니다.
370
372
 
371
- ## 📄 doc2md: 첨부 문서를 읽기 전에 Markdown 으로 바꿉니다
373
+ ## 📄 doc2md: 문서를 읽기 전에 Markdown 으로 바꿉니다
374
+
375
+ 기획서와 보고서는 대부분 pptx·xlsx·pdf·docx·fig 로 옵니다. 이 형식들을 그대로 다루면 두 가지 중 하나가 일어납니다. Claude Code 가 이진 파일이라며 거부해서 아무것도 못 읽거나, 압축을 풀어 본문 XML 을 읽느라 토큰을 태우거나. 30MB 짜리 발표자료 하나가 XML 로는 **54만 토큰**이고, 200k 컨텍스트에는 들어가지도 않습니다.
372
376
 
373
- pptx·xlsx·pdf·docx 를 그대로 `Read` 하면 모델이 읽지 못하는 바이트가 컨텍스트에 그대로 올라갑니다. 이 기능은 그 `Read` 를 가로채 파일을 한 번 변환해 두고, 원본 대신 변환본을 읽게 합니다.
377
+ doc2md 는 그 파일을 한 번 변환해 두고 원본 대신 변환본을 읽게 합니다. 같은 발표자료가 22,610 토큰이 됩니다.
374
378
 
375
379
  **이 기능은 옵트인입니다.** 설치만으로는 켜지지 않고, 아래 두 명령을 모두 실행해야 동작합니다. 훅만 등록하고 변환기가 없으면 아무 일도 일어나지 않습니다.
376
380
 
@@ -385,7 +389,7 @@ pptx·xlsx·pdf·docx 를 그대로 `Read` 하면 모델이 읽지 못하는 바
385
389
  두 번째 줄의 제약은 실측으로 확인한 것입니다. `.pdf` 를 Read 하면 훅이 실행되고, 같은 세션에서 `.pptx` 를 Read 하면 훅 로그에 아무 기록도 남지 않습니다.
386
390
 
387
391
  ```bash
388
- claude-token-saver doc2md install-converter # 전용 venv에 markitdown 설치
392
+ claude-token-saver doc2md install-converter # markitdown·편집 라이브러리·.fig 파서 설치
389
393
  claude-token-saver doc2md on # Read 훅 등록
390
394
  claude-token-saver doc2md # 변환기·훅 등록 상태 확인
391
395
  claude-token-saver doc2md 보고서.pptx # 직접 변환해 결과 확인
@@ -403,6 +407,50 @@ claude-token-saver doc2md 보고서.pptx # 직접 변환해 결과 확
403
407
  - **압축 폭탄은 막습니다.** pptx·xlsx·docx 는 zip 컨테이너입니다. 선언된 크기를 먼저 걸러 내고, 선언은 조작될 수 있으므로 실제 해제 바이트도 상한과 대조합니다.
404
408
  - **엑셀은 행 수로 자릅니다.** 변환 시간은 파일 크기가 아니라 행 수를 따릅니다(실측: PDF 6.3MB 0.9초, 엑셀 5.8MB 47.75초). 5만 행을 넘으면 앞부분만 변환하고, **잘랐다는 사실과 전체 행 수를 안내에 함께 적습니다.**
405
409
 
410
+ ### 변환이 얼마를 아끼는지
411
+
412
+ 변환본은 첫머리에 출처 주석을 답니다. 어떤 원본을 언제 변환했고 몇 토큰인지가 파일을 여는 순간 보입니다. 절감액은 스테이터스라인의 절감 줄 끝에 `📄 Doc2md saved` 로 붙습니다.
413
+
414
+ 절감액의 기준은 변환기가 없을 때 실제로 하게 되는 일이고, 그 일이 형식마다 다릅니다. 두 경우 모두 2026-09-06 에 실측했습니다.
415
+
416
+ **PDF 는 첨부와 비교합니다.** `claude --print --input-format stream-json` 으로 같은 한 줄 프롬프트를 첨부 있이·없이 보내고 입력 토큰을 비교했습니다. 대조군은 42,204 토큰이었고 두 번 반복해 값이 같았습니다.
417
+
418
+ | 첨부 파일 | 분량 | 첨부가 더 든 토큰 | 페이지당 |
419
+ |---|---|---|---|
420
+ | 이력서 PDF | 7페이지 | +20,537 | 2,934 |
421
+ | 이력서 PDF | 5페이지 | +12,709 | 2,542 |
422
+
423
+ PDF 는 첨부하면 모델이 내용을 그대로 읽습니다. 대신 페이지마다 2,500~2,900 토큰이 붙어서, 변환본(5,531 토큰)의 서너 배가 듭니다. 계수는 두 실측치보다 낮은 페이지당 2,500 을 씁니다. 넉넉히 잡아 부풀리는 것보다 낮게 잡아 밑도는 편이 낫습니다.
424
+
425
+ **pptx·xlsx·docx 는 압축을 푸는 경우와 비교합니다.** 이 형식들은 애초에 첨부로 모델에 닿지 않습니다. 같은 방식으로 docx 를 보냈더니 78 토큰만 늘었고 모델은 파일이 없다고 답했으며, `Read` 도 이진 파일이라며 거부합니다. 그래서 변환기가 없을 때 실제로 하게 되는 일은 압축을 풀고 본문 XML 을 읽는 것입니다. 태그와 스타일 속성이 글자 수의 대부분을 차지하는 그 XML 말입니다.
426
+
427
+ | 원본 | 본문 XML | 변환본 | 차이 |
428
+ |---|---|---|---|
429
+ | 발표자료 pptx (31.8MB) | 약 540,429 토큰 | 약 22,610 토큰 | 23.8배 |
430
+ | 이력서 docx (189KB) | 약 79,621 토큰 | 약 1,684 토큰 | 47.3배 |
431
+
432
+ 30MB 짜리 발표자료 하나가 XML 로는 54만 토큰입니다. 200k 컨텍스트에는 들어가지도 않습니다. 이 기준은 형식별 계수가 아니라 파일마다 실제 XML 크기를 재서 씁니다.
433
+
434
+ `.xls` 는 zip 컨테이너가 아니라 재어 볼 마크업이 없으므로 절감을 0 으로 둡니다.
435
+
436
+ 클라이언트 동작이 바뀌면 `scripts/doc2md-baseline.mjs` 로 첨부 쪽을 다시 재고, `src/doc2md-ledger.cjs` 의 `ATTACHMENT_BASELINE` 표에 값만 갈아 끼우면 됩니다.
437
+
438
+ ### 피그마 `.fig` 도 변환합니다
439
+
440
+ 기획서가 PPT 에서 피그마로 옮겨 가는 추세를 따라, `.fig` 파일도 같은 훅이 잡습니다. `.fig` 는 zip 컨테이너지만 안에 든 `canvas.fig` 가 피그마의 비공개 바이너리(kiwi 포맷)라 markitdown 이 열지 못하므로, 이 형식만 Node 파서([openfig-core](https://github.com/OpenFig-org/openfig-core), MIT)로 변환합니다. `doc2md install-converter` 가 markitdown 과 함께 도구 상태 디렉터리에 설치하며, 패키지 자체는 여전히 무의존성입니다.
441
+
442
+ 변환 결과는 페이지·프레임 계층을 헤딩으로, 텍스트 노드를 본문으로 정리한 아웃라인입니다. 도형·벡터 같은 시각 요소는 나열하지 않고 개수만 남깁니다. 기획서에서 내용은 글이고, `Rectangle 173` 이 이백 줄 나오면 글이 묻히기 때문입니다. 텍스트가 하나도 없는 파일(순수 그래픽)은 빈 문서로 꾸미지 않고 변환 불가로 알립니다.
443
+
444
+ 실제 파일로 검증했습니다: 피그마 커뮤니티의 Bootstrap UI kit(8.1MB, 노드 4,155개, 텍스트 1,312개)와 Tailwind kit(52MB)이 각각 0.2초 안에 71.9KB·44KB 아웃라인으로 변환됐고, 한국어 텍스트 왕복도 무손실이었습니다. `.fig` 는 두 세대가 있습니다. 요즘 익스포트는 zip 컨테이너, 옛 익스포트는 fig-kiwi 바이너리 원형인데 둘 다 처리합니다. 절감액은 청구하지 않습니다. `.fig` 는 압축을 풀어도 또 바이너리라 비교할 대안 자체가 없고, 변환이 유일한 읽기 경로입니다.
445
+
446
+ ### 문서를 수정해야 할 때: 복사본 + 스크립트
447
+
448
+ 변환은 단방향이라 변환본 .md 를 고쳐도 원본에는 반영되지 않습니다. 훅이 변환 캐시와 원본 이진 파일에 대한 Edit/Write 를 거부하면서 올바른 경로를 안내합니다. 원본을 복사하고, 복사본을 스크립트로 수정하고, 수정본을 doc2md 로 재변환해 검증하는 순서입니다.
449
+
450
+ `install-converter` 가 편집 라이브러리(python-pptx·python-docx·openpyxl)를 변환기 venv 에 함께 설치하므로, "23번 슬라이드 차트를 꺾은선으로 바꿔줘" 같은 구조 편집도 에이전트가 그 자리에서 스크립트로 처리할 수 있습니다. `.fig` 는 openfig-core 가 인코더까지 제공해 텍스트 수정 후 재인코드가 됩니다.
451
+
452
+ 네 형식 모두 실제로 몇 바퀴 돌려 검증했습니다(2026-09-06): docx 텍스트 치환 10건과 3회 연속 재저장, pptx 막대→꺾은선 차트 교체와 데이터 행 추가, xlsx 값 정정·행 추가, fig 텍스트 수정·재인코드·재파싱. 전 케이스에서 원본은 바이트 그대로였고, 수정본 재변환에 변경 내용이 반영됐습니다. 한 가지 주의: pptx 에서 차트 도형을 제거하면 옛 차트 XML 파트가 고아로 남습니다. PowerPoint 는 무시하지만, 깔끔히 하려면 파트와 rels 도 지우십시오. 차트·이미지 같은 시각 요소는 변환본에 잡히지 않으므로, 시각 편집의 최종 확인은 해당 프로그램에서 해야 합니다.
453
+
406
454
  `claude-token-saver doc2md --clean` 으로 변환 캐시를 비우고, `doc2md off` 로 훅을 제거합니다. 훅 해제는 자기 항목만 골라 지우므로 `PreToolUse` 에 등록해 둔 다른 훅은 그대로 남습니다.
407
455
 
408
456
  ## 🌐 Bedrock·Vertex 경유 환경
@@ -489,7 +537,7 @@ npm uninstall -g claude-cache-monitor && npm i -g claude-token-saver
489
537
  - **모르는 서브커맨드를 `--hook` 으로 부르면 아무것도 출력하지 않습니다.** 3.25.0 전역 설치본이 3.26.0 이 쓴 `settings.json` 을 만나면 `doc2md` 를 인식하지 못하고 기본 리포트로 흘러가, `Read` 할 때마다 통계 표 전문을 훅 스트림에 밀어 넣었습니다.
490
538
 
491
539
  ### v3.26.0 (2026-09-04)
492
- - **첨부 문서를 읽기 전에 Markdown 으로 변환합니다.** pptx·xlsx·pdf·docx 를 그대로 `Read` 하면 모델이 읽지 못하는 바이트가 컨텍스트에 올라갑니다. `doc2md on` 으로 `Read` 훅을 등록하면 파일을 한 번 변환해 캐시에 두고 변환본을 읽게 합니다. 변환기가 없으면 안내를 한 번만 하고 원본 `Read` 를 통과시키며, 압축 폭탄은 막고, 5만 행이 넘는 엑셀은 앞부분만 변환한 뒤 잘랐다는 사실을 함께 알립니다. 자세한 내용은 [doc2md](#-doc2md-첨부-문서를-읽기-전에-markdown-으로-바꿉니다) 절을 참고하십시오.
540
+ - **문서를 읽기 전에 Markdown 으로 변환합니다.** pptx·xlsx·pdf·docx·fig 를 그대로 `Read` 하면 모델이 읽지 못하는 바이트가 컨텍스트에 올라갑니다. `doc2md on` 으로 `Read` 훅을 등록하면 파일을 한 번 변환해 캐시에 두고 변환본을 읽게 합니다. 변환기가 없으면 안내를 한 번만 하고 원본 `Read` 를 통과시키며, 압축 폭탄은 막고, 5만 행이 넘는 엑셀은 앞부분만 변환한 뒤 잘랐다는 사실을 함께 알립니다. 자세한 내용은 [doc2md](#-doc2md-문서를-읽기-전에-markdown-으로-바꿉니다) 절을 참고하십시오.
493
541
  - **Bedrock·Vertex 경유 환경의 TTL 표시를 바로잡았습니다.** 게이트웨이는 버킷별 분해 값을 내려보내지 않는데, 판정 불가일 때 1시간을 기본값으로 잡고 있었습니다. 5분 버킷만 제공하는 환경에서 남은 시간이 최대 12배로 부풀어 보였습니다. 이제 모델 ID로 게이트웨이를 감지해 5분을 기본값으로 쓰고, 라벨을 `5m?` 로 적어 추정임을 밝힙니다. `mode ttl=5m` 으로 직접 지정할 수도 있습니다.
494
542
  - **위임 집계가 조용히 버려지지 않습니다.** 모델 ID를 해석하지 못해 제외된 위임이 있으면 statusline 에 `🔀 N unresolved` 로 알립니다. 이전에는 "위임한 적 없음"과 화면상 구별되지 않아, 집계가 통째로 사라져도 알 방법이 없었습니다. `foundation-model` ARN 으로 지정된 환경변수도 이제 해석합니다.
495
543
  - **한국어 지침의 적용 범위 충돌을 해소했습니다.** 주입문은 코드 주석을 검사 대상에 넣는데 벤더링한 원문은 두 번에 걸쳐 제외한다고 적고 있어서, 모델이 어느 쪽을 따를지 판단할 근거가 없었습니다. 원문은 그대로 두고 어느 쪽이 우선인지 명시하는 한 줄을 추가했습니다. 출처 표기에 들어 있던 엠대시도 지침 스스로 금지하는 표기였으므로 콜론으로 바꿨습니다.
package/bin/cli.js CHANGED
@@ -534,6 +534,16 @@ async function main() {
534
534
  } catch (e) {
535
535
  debug('savings-ledger:totals', e);
536
536
  }
537
+ // Document conversions, same shape as the delegation totals: a lifetime sum
538
+ // plus a document count. A lookup of a small JSON file, never a scan.
539
+ let doc2mdTotals = null;
540
+ try {
541
+ const { doc2mdSavedTotals } = await import('../src/doc2md-ledger.cjs');
542
+ const { userDataDir } = await import('../src/paths.js');
543
+ doc2mdTotals = doc2mdSavedTotals(userDataDir());
544
+ } catch (e) {
545
+ debug('doc2md-ledger:totals', e);
546
+ }
537
547
  // Delegated runs route-scan had to throw away because their model id could
538
548
  // not be priced. Also a lookup of the cached scan, never a scan. Without it
539
549
  // the statusline shows the same blank for "no delegation happened" and for
@@ -574,6 +584,7 @@ async function main() {
574
584
  model,
575
585
  delegationSaved,
576
586
  delegationTotals,
587
+ doc2mdTotals,
577
588
  unresolvedRuns,
578
589
  ttlBucket,
579
590
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "3.27.0",
3
+ "version": "3.28.0",
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": {
@@ -5,7 +5,8 @@ Invoked as a child process by src/doc2md.cjs. Everything it needs to say
5
5
  travels in the JSON on stdout, so the Node side never has to interpret a
6
6
  traceback:
7
7
 
8
- {"ok": true, "markdown": "...", "note": null, "truncated": false, "rows": 0}
8
+ {"ok": true, "markdown": "...", "note": null, "truncated": false,
9
+ "rows": 0, "pages": 0, "markup_bytes": 0}
9
10
  {"ok": false, "reason": "no-text", "detail": "..."}
10
11
 
11
12
  Exit status is 0 whenever the JSON was written, including for a refusal. A
@@ -79,6 +80,53 @@ def check_zip(path):
79
80
  return None
80
81
 
81
82
 
83
+ # Which entries inside a zip container hold the document's own text. The rest
84
+ # of the archive is media, themes and relationship tables — bytes a reader
85
+ # would never wade through even without a converter.
86
+ BODY_XML = {
87
+ ".pptx": ("ppt/slides/", "ppt/notesSlides/"),
88
+ ".docx": ("word/document.xml", "word/footnotes.xml", "word/endnotes.xml"),
89
+ ".xlsx": ("xl/worksheets/", "xl/sharedStrings.xml"),
90
+ }
91
+
92
+
93
+ def markup_bytes(path, ext):
94
+ """Uncompressed size of the body markup inside a zip document, or 0.
95
+
96
+ This prices the alternative to converting. A model cannot read the binary,
97
+ so the fallback a reader actually reaches for is unzipping the container
98
+ and wading through its XML — where tags and style attributes outweigh the
99
+ text several times over.
100
+ """
101
+ prefixes = BODY_XML.get(ext)
102
+ if not prefixes:
103
+ return 0
104
+ try:
105
+ import zipfile
106
+ with zipfile.ZipFile(path) as z:
107
+ return sum(i.file_size for i in z.infolist()
108
+ if i.filename.endswith(".xml")
109
+ and any(i.filename.startswith(p) for p in prefixes))
110
+ except Exception:
111
+ return 0
112
+
113
+
114
+ def pdf_pages(path):
115
+ """Page count of a PDF, or 0 when it cannot be counted.
116
+
117
+ The count is what prices the alternative to converting: attaching a PDF
118
+ to a message bills every page as an image, while the conversion bills
119
+ only the extracted text. pdfminer ships with markitdown's pdf extra, so
120
+ this costs no extra dependency.
121
+ """
122
+ try:
123
+ from pdfminer.pdfpage import PDFPage
124
+ with open(path, "rb") as fh:
125
+ return sum(1 for _ in PDFPage.get_pages(fh))
126
+ except Exception:
127
+ return 0
128
+
129
+
82
130
  def sheet_rows(path):
83
131
  """Total rows across every sheet, or None when openpyxl cannot say."""
84
132
  try:
@@ -149,6 +197,8 @@ def main():
149
197
  note = None
150
198
  truncated = False
151
199
  rows = 0
200
+ pages = pdf_pages(path) if ext == ".pdf" else 0
201
+ markup = markup_bytes(path, ext)
152
202
 
153
203
  if ext in (".xlsx", ".xls"):
154
204
  counted = sheet_rows(path)
@@ -161,7 +211,8 @@ def main():
161
211
  note = ("전체 %d행 가운데 앞 %d행만 변환했습니다. "
162
212
  "전수 분석이 필요하면 원본을 직접 다루십시오." % (counted, ROW_CAP))
163
213
  json.dump({"ok": True, "markdown": text, "note": note,
164
- "truncated": True, "rows": counted}, sys.stdout)
214
+ "truncated": True, "rows": counted, "pages": 0,
215
+ "markup_bytes": markup}, sys.stdout)
165
216
  return
166
217
 
167
218
  try:
@@ -181,7 +232,8 @@ def main():
181
232
  fail("no-text", "converter returned nothing")
182
233
 
183
234
  json.dump({"ok": True, "markdown": text, "note": note,
184
- "truncated": truncated, "rows": rows}, sys.stdout)
235
+ "truncated": truncated, "rows": rows, "pages": pages,
236
+ "markup_bytes": markup}, sys.stdout)
185
237
 
186
238
 
187
239
  if __name__ == "__main__":
@@ -48,7 +48,7 @@ export async function run({ args, hasFlag }) {
48
48
  if (!payload) return;
49
49
  let out = null;
50
50
  try {
51
- out = doc2md.formatHookOutput(doc2md.decideForRead(payload));
51
+ out = doc2md.formatHookOutput(doc2md.decideForRead(payload) || doc2md.decideForWrite(payload));
52
52
  } catch {
53
53
  // A converter that throws must not take the Read down with it. Printing
54
54
  // nothing leaves Claude Code to run the tool call exactly as before.
@@ -62,10 +62,21 @@ export async function run({ args, hasFlag }) {
62
62
  const res = doc2md.installConverter({ onProgress: (m) => console.log(` ${m}`) });
63
63
  if (res.ok) {
64
64
  console.log(`✓ converter ready: ${res.python}`);
65
- return;
65
+ } else {
66
+ console.error(`✗ ${res.reason}: ${res.detail}`);
67
+ process.exitCode = 1;
68
+ }
69
+ // The .fig parser is a separate, Node-side install. A markitdown failure
70
+ // above must not block it — the two formats fail independently.
71
+ const fig2md = require('../fig2md.cjs');
72
+ const { userDataDir } = await import('../paths.js');
73
+ const figRes = fig2md.installFigParser(userDataDir(), { onProgress: (m) => console.log(` ${m}`) });
74
+ if (figRes.ok) {
75
+ console.log('✓ .fig parser ready (openfig-core)');
76
+ } else {
77
+ console.error(`✗ .fig parser: ${figRes.reason}: ${figRes.detail || ''}`);
78
+ process.exitCode = 1;
66
79
  }
67
- console.error(`✗ ${res.reason}: ${res.detail}`);
68
- process.exitCode = 1;
69
80
  return;
70
81
  }
71
82
 
@@ -0,0 +1,208 @@
1
+ /**
2
+ * doc2md-ledger — one event per document conversion, with what it saved.
3
+ *
4
+ * Separate from delegation-ledger.json on purpose. Routing savings and
5
+ * conversion savings answer different questions ("work ran on a cheaper
6
+ * model" vs "a document was read as text instead of as an attachment"), and
7
+ * a statusline that folds them into one figure cannot tell the reader which
8
+ * habit earned the money.
9
+ *
10
+ * File: <userDataDir>/doc2md-ledger.json
11
+ * { "version": 1,
12
+ * "events": { "<source path>": { "ts", "usd", "ext", "tokens", "baseline" } } }
13
+ *
14
+ * Keyed by the source path so re-converting the same document after an edit
15
+ * updates its event instead of counting the file twice.
16
+ *
17
+ * # What "saved" means here, and what it deliberately does not
18
+ *
19
+ * The counterfactual is what the reader would have done without a converter,
20
+ * and it differs by format. Both were measured on 2026-09-06.
21
+ *
22
+ * PDF: attaching the file. The same one-line prompt was sent through
23
+ * `claude --print --input-format stream-json` with and without the file as a
24
+ * document block. The control turn cost 42,204 tokens, twice, to the token.
25
+ *
26
+ * kohjuho_resume_kr.pdf 7 pages +20,537 tokens 2,934 per page
27
+ * xeoyoung_resume.pdf 5 pages +12,709 tokens 2,542 per page
28
+ *
29
+ * A PDF is read whole: the model answered from its contents. Converting one
30
+ * to text is worth three to four times its own size.
31
+ *
32
+ * pptx/xlsx/docx: unpacking the container. These never reach the model as
33
+ * attachments at all — the same probe on a docx added 78 tokens and the model
34
+ * replied that it had no file, and Read refuses the format outright. What a
35
+ * reader does instead is unzip the archive and wade through its XML, where
36
+ * tags and style attributes outweigh the text many times over:
37
+ *
38
+ * aws-summit-seoul.pptx 2.1MB of slide XML ~540,429 tokens 23.8× the conversion
39
+ * 우리은행이력서.docx 312KB of body XML ~78,113 tokens ~46× the conversion
40
+ *
41
+ * So the baseline for these formats is the body markup the converter read,
42
+ * measured per file rather than assumed from a ratio. It is a real number for
43
+ * a real fallback — this very session unzipped a pptx to verify a conversion
44
+ * before this ledger existed.
45
+ *
46
+ * Erring low is deliberate throughout. A savings figure that flatters the
47
+ * tool is worth less than one the user can trust.
48
+ *
49
+ * `scripts/doc2md-baseline.mjs` re-measures the attachment side if the
50
+ * client's handling changes.
51
+ */
52
+
53
+ const fs = require('node:fs');
54
+ const path = require('node:path');
55
+
56
+ const WEEK_MS = 7 * 24 * 3600 * 1000;
57
+ const MONTH_MS = 30 * 24 * 3600 * 1000;
58
+
59
+ const LEDGER_VERSION = 1;
60
+
61
+ /**
62
+ * Tokens an attached PDF costs per page, from the two measurements in the
63
+ * header: 2,934 and 2,542 per page. 2,500 sits below both, so the saving is
64
+ * understated for a dense document rather than overstated for a sparse one.
65
+ */
66
+ const PDF_TOKENS_PER_PAGE = 2500;
67
+
68
+ /**
69
+ * How each format's alternative is priced. `perPage` values an attached
70
+ * page-image document; `markup` values the body XML a reader would have had
71
+ * to wade through instead.
72
+ *
73
+ * `.xls` is the pre-2007 binary format, which is not a zip container and so
74
+ * has no markup to measure. It falls back to parity, recording no saving.
75
+ */
76
+ const ATTACHMENT_BASELINE = {
77
+ '.pdf': { perPage: PDF_TOKENS_PER_PAGE },
78
+ '.pptx': { markup: true },
79
+ '.docx': { markup: true },
80
+ '.xlsx': { markup: true },
81
+ '.xls': { ratio: 1 },
82
+ // A .fig unzips to another binary (Figma's kiwi format), so unlike the
83
+ // office containers there is no readable markup to price the fallback
84
+ // against. Converting is the only way to read it at all; no money claimed.
85
+ '.fig': { ratio: 1 },
86
+ };
87
+
88
+ /**
89
+ * Input price per token used to value the difference, in USD. Sonnet's input
90
+ * rate, chosen as the mid tier: crediting the conversion at Opus rates would
91
+ * quietly triple every figure for anyone who never runs Opus.
92
+ */
93
+ const INPUT_USD_PER_TOKEN = 3 / 1_000_000;
94
+
95
+ /** Rough token count for text. Four bytes per token, the usual approximation. */
96
+ function estimateTokens(text) {
97
+ return Math.ceil(Buffer.byteLength(String(text || ''), 'utf8') / 4);
98
+ }
99
+
100
+ /**
101
+ * What the conversion saved, in USD, and the two token figures behind it.
102
+ * `meta` is the conversion metadata: `pages` for PDFs, plus the markdown that
103
+ * was written.
104
+ */
105
+ function estimateSaving({ ext, pages = 0, markupBytes = 0, markdown = '' }) {
106
+ const tokens = estimateTokens(markdown);
107
+ const rule = ATTACHMENT_BASELINE[String(ext).toLowerCase()] || { ratio: 1 };
108
+ let baseline;
109
+ if (rule.perPage && pages > 0) {
110
+ baseline = pages * rule.perPage;
111
+ } else if (rule.markup && markupBytes > 0) {
112
+ baseline = Math.ceil(markupBytes / 4);
113
+ } else {
114
+ baseline = Math.round(tokens * (rule.ratio || 1));
115
+ }
116
+ // Never below what the conversion actually produced. A dense PDF can cost
117
+ // more as text than its page count suggests, and a baseline under the real
118
+ // figure would show as a zero saving while understating the document.
119
+ baseline = Math.max(baseline, tokens);
120
+ const usd = Math.max(0, baseline - tokens) * INPUT_USD_PER_TOKEN;
121
+ return { tokens, baseline, usd: Math.round(usd * 10000) / 10000 };
122
+ }
123
+
124
+ function ledgerPath(userDataDir) {
125
+ return path.join(userDataDir, 'doc2md-ledger.json');
126
+ }
127
+
128
+ function loadLedger(userDataDir) {
129
+ try {
130
+ const data = JSON.parse(fs.readFileSync(ledgerPath(userDataDir), 'utf8'));
131
+ if (!data || typeof data.events !== 'object' || data.events === null) {
132
+ return { version: LEDGER_VERSION, events: {} };
133
+ }
134
+ if (data.version !== LEDGER_VERSION) return { version: LEDGER_VERSION, events: {} };
135
+ return data;
136
+ } catch {
137
+ return { version: LEDGER_VERSION, events: {} };
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Record one conversion. Never throws: an unwritable ledger costs a
143
+ * statusline figure, which is not worth failing a conversion over.
144
+ */
145
+ function recordConversion(userDataDir, event) {
146
+ if (!event || !event.key) return;
147
+ const data = loadLedger(userDataDir);
148
+ data.version = LEDGER_VERSION;
149
+ data.events[event.key] = {
150
+ ts: Number.isFinite(event.ts) ? event.ts : Date.now(),
151
+ usd: Math.max(0, Math.round((Number(event.usd) || 0) * 10000) / 10000),
152
+ ext: event.ext || '',
153
+ tokens: Number(event.tokens) || 0,
154
+ baseline: Number(event.baseline) || 0,
155
+ };
156
+ try {
157
+ fs.mkdirSync(userDataDir, { recursive: true });
158
+ fs.writeFileSync(ledgerPath(userDataDir), JSON.stringify(data) + '\n', { mode: 0o600 });
159
+ } catch {
160
+ /* best effort, like every other state file here */
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Rolling totals plus `docs` (documents converted, lifetime) and `byExt` —
166
+ * the lifetime rollup per format, priciest first, then most-converted. Never
167
+ * throws; an unreadable ledger yields zeros, and the statusline hides the
168
+ * chip on a zero.
169
+ */
170
+ function doc2mdSavedTotals(userDataDir, now = Date.now()) {
171
+ const empty = () => ({ week: 0, month: 0, total: 0, docs: 0, tokens: 0, byExt: [] });
172
+ const totals = empty();
173
+ const byExt = new Map();
174
+ try {
175
+ for (const e of Object.values(loadLedger(userDataDir).events)) {
176
+ const usd = Number(e.usd) || 0;
177
+ totals.total += usd;
178
+ totals.docs += 1;
179
+ totals.tokens += Number(e.tokens) || 0;
180
+ if (Number.isFinite(e.ts)) {
181
+ if (now - e.ts <= WEEK_MS) totals.week += usd;
182
+ if (now - e.ts <= MONTH_MS) totals.month += usd;
183
+ }
184
+ const key = String(e.ext || '?').replace(/^\./, '') || '?';
185
+ const row = byExt.get(key) || { ext: key, docs: 0, usd: 0 };
186
+ row.docs += 1;
187
+ row.usd += usd;
188
+ byExt.set(key, row);
189
+ }
190
+ } catch {
191
+ return empty();
192
+ }
193
+ totals.byExt = [...byExt.values()].sort((a, b) => b.usd - a.usd || b.docs - a.docs);
194
+ return totals;
195
+ }
196
+
197
+ module.exports = {
198
+ LEDGER_VERSION,
199
+ PDF_TOKENS_PER_PAGE,
200
+ ATTACHMENT_BASELINE,
201
+ INPUT_USD_PER_TOKEN,
202
+ estimateTokens,
203
+ estimateSaving,
204
+ ledgerPath,
205
+ loadLedger,
206
+ recordConversion,
207
+ doc2mdSavedTotals,
208
+ };