claude-token-saver 3.27.1 → 3.28.1
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 +58 -10
- package/README.md +64 -10
- package/bin/cli.js +11 -0
- package/package.json +1 -1
- package/presets/doc2md/convert.py +55 -3
- package/src/commands/doc2md.js +15 -4
- package/src/doc2md-ledger.cjs +208 -0
- package/src/doc2md.cjs +361 -23
- package/src/fig2md-runner.cjs +21 -0
- package/src/fig2md.cjs +182 -0
- package/src/formatters/statusline.js +58 -3
- package/src/installer.js +22 -1
package/README.en.md
CHANGED
|
@@ -4,23 +4,26 @@
|
|
|
4
4
|
|
|
5
5
|
# claude-token-saver
|
|
6
6
|
|
|
7
|
-
**
|
|
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
|
-

|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
npm i -g claude-token-saver
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
##
|
|
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
|
|
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 —
|
|
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,18 +391,24 @@ 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
|
|
399
|
-
claude-token-saver doc2md on # register the Read hook
|
|
400
|
+
claude-token-saver doc2md on # register the hooks (the converter installs itself)
|
|
400
401
|
claude-token-saver doc2md # check converter + hook registration
|
|
401
402
|
claude-token-saver doc2md report.pptx # convert by hand and see the result
|
|
403
|
+
claude-token-saver doc2md install-converter # only to get the install out of the way early
|
|
402
404
|
```
|
|
403
405
|
|
|
406
|
+
**The converter installs itself.** Any rollout step a person has to be told about is a step some of them skip, so the converter installs in the background the moment a document first shows up, and converts as soon as it is ready. Measured: about 30s for the first document (15s install plus markitdown's first import), then 3.7s for a new document and 0.1s on a cache hit. The `.fig` parser installs in half a second on the first Figma file.
|
|
407
|
+
|
|
408
|
+
It installs on first use rather than at `install` time: the venv is 47MB, and someone who never opens a document should not pay for it. Set `CTS_DOC2MD_NO_AUTOINSTALL=1` to turn the automatic install off.
|
|
409
|
+
|
|
410
|
+
**Python 3.10+ is required** — markitdown's own floor, and macOS still ships 3.9 as `/usr/bin/python3`. The venv is built on an interpreter chosen by version rather than by PATH order. Built on 3.9, pip resolves markitdown to a 2019 placeholder release (0.0.1a1): the install looks like it worked and every conversion then dies at import. This was found by walking into it. When nothing on the machine is new enough, the message points at `brew install python` instead of at an install command that cannot succeed.
|
|
411
|
+
|
|
404
412
|
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.
|
|
405
413
|
|
|
406
414
|
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.
|
|
@@ -413,6 +421,46 @@ Several things it deliberately does not do:
|
|
|
413
421
|
- **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
422
|
- **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
423
|
|
|
424
|
+
### What a conversion saves
|
|
425
|
+
|
|
426
|
+
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.
|
|
427
|
+
|
|
428
|
+
The baseline is what you would have done without a converter, and that differs by format. Both were measured on 2026-09-06.
|
|
429
|
+
|
|
430
|
+
**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.
|
|
431
|
+
|
|
432
|
+
| Attached file | Size | Extra tokens | Per page |
|
|
433
|
+
|---|---|---|---|
|
|
434
|
+
| Résumé PDF | 7 pages | +20,537 | 2,934 |
|
|
435
|
+
| Résumé PDF | 5 pages | +12,709 | 2,542 |
|
|
436
|
+
|
|
437
|
+
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.
|
|
438
|
+
|
|
439
|
+
**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.
|
|
440
|
+
|
|
441
|
+
| Original | Body XML | Conversion | Ratio |
|
|
442
|
+
|---|---|---|---|
|
|
443
|
+
| Deck, pptx (31.8MB) | ~540,429 tokens | ~22,610 tokens | 23.8× |
|
|
444
|
+
| Résumé, docx (189KB) | ~79,621 tokens | ~1,684 tokens | 47.3× |
|
|
445
|
+
|
|
446
|
+
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.
|
|
447
|
+
|
|
448
|
+
### Figma `.fig` converts too
|
|
449
|
+
|
|
450
|
+
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.
|
|
451
|
+
|
|
452
|
+
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.
|
|
453
|
+
|
|
454
|
+
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.
|
|
455
|
+
|
|
456
|
+
### Editing a document: copy, then script
|
|
457
|
+
|
|
458
|
+
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.
|
|
459
|
+
|
|
460
|
+
`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.
|
|
461
|
+
|
|
462
|
+
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.
|
|
463
|
+
|
|
416
464
|
`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
465
|
|
|
418
466
|
## 🌐 Behind a gateway (Bedrock / Vertex)
|
|
@@ -523,7 +571,7 @@ Also update `statusLine.command` in `~/.claude/settings.json` to `claude-token-s
|
|
|
523
571
|
- **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
572
|
|
|
525
573
|
### 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--
|
|
574
|
+
- **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
575
|
- **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
576
|
- **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
577
|
- **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
|
-
|
|
7
|
+
**아낀 돈을 두 줄로 보여 줍니다.** 비싼 모델이 반복하던 쉬운 작업을 싼 모델로 내려보내고, 모델이 읽지 못하는 문서를 Markdown 으로 바꿉니다. 두 절감액 모두 추정이 아니라 원장 기록이고, 더 많이 아낀 쪽이 첫 줄을 차지합니다. 의존성 0, 설치 한 줄.
|
|
8
8
|
|
|
9
|
-

|
|
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
|
-
설치 한 번이면
|
|
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:
|
|
373
|
+
## 📄 doc2md: 문서를 읽기 전에 Markdown 으로 바꿉니다
|
|
374
|
+
|
|
375
|
+
기획서와 보고서는 대부분 pptx·xlsx·pdf·docx·fig 로 옵니다. 이 형식들을 그대로 다루면 두 가지 중 하나가 일어납니다. Claude Code 가 이진 파일이라며 거부해서 아무것도 못 읽거나, 압축을 풀어 본문 XML 을 읽느라 토큰을 태우거나. 30MB 짜리 발표자료 하나가 XML 로는 **54만 토큰**이고, 200k 컨텍스트에는 들어가지도 않습니다.
|
|
372
376
|
|
|
373
|
-
|
|
377
|
+
doc2md 는 그 파일을 한 번 변환해 두고 원본 대신 변환본을 읽게 합니다. 같은 발표자료가 22,610 토큰이 됩니다.
|
|
374
378
|
|
|
375
379
|
**이 기능은 옵트인입니다.** 설치만으로는 켜지지 않고, 아래 두 명령을 모두 실행해야 동작합니다. 훅만 등록하고 변환기가 없으면 아무 일도 일어나지 않습니다.
|
|
376
380
|
|
|
@@ -385,12 +389,18 @@ pptx·xlsx·pdf·docx 를 그대로 `Read` 하면 모델이 읽지 못하는 바
|
|
|
385
389
|
두 번째 줄의 제약은 실측으로 확인한 것입니다. `.pdf` 를 Read 하면 훅이 실행되고, 같은 세션에서 `.pptx` 를 Read 하면 훅 로그에 아무 기록도 남지 않습니다.
|
|
386
390
|
|
|
387
391
|
```bash
|
|
388
|
-
claude-token-saver doc2md
|
|
389
|
-
claude-token-saver doc2md on # Read 훅 등록
|
|
392
|
+
claude-token-saver doc2md on # 훅 등록 (변환기는 첫 문서에서 자동 설치)
|
|
390
393
|
claude-token-saver doc2md # 변환기·훅 등록 상태 확인
|
|
391
394
|
claude-token-saver doc2md 보고서.pptx # 직접 변환해 결과 확인
|
|
395
|
+
claude-token-saver doc2md install-converter # 설치를 미리 끝내 두고 싶을 때만
|
|
392
396
|
```
|
|
393
397
|
|
|
398
|
+
**변환기는 알아서 깔립니다.** 팀에 배포할 때 각자 설치 명령을 실행하게 만들면 그 단계에서 빠지는 사람이 생깁니다. 그래서 문서가 처음 등장하는 시점에 변환기가 백그라운드로 설치되고, 설치가 끝나는 대로 곧바로 변환합니다. 실측으로 첫 문서는 약 30초(설치 15초 + markitdown 최초 임포트), 이후로는 새 문서 3.7초, 캐시 적중 0.1초입니다. `.fig` 파서는 첫 Figma 파일에서 0.5초 만에 깔립니다.
|
|
399
|
+
|
|
400
|
+
설치는 `install` 단계가 아니라 첫 사용 시점에 합니다. venv 가 47MB 라서, 문서를 다루지 않는 사람은 낼 이유가 없는 비용입니다. 자동 설치를 끄려면 `CTS_DOC2MD_NO_AUTOINSTALL=1` 을 설정하십시오.
|
|
401
|
+
|
|
402
|
+
**파이썬 3.10 이상이 필요합니다.** markitdown 의 요구 사항이고, macOS 기본 `/usr/bin/python3` 는 3.9 입니다. 이 도구는 PATH 순서를 따르지 않고 3.10 이상인 인터프리터를 골라 venv 를 만듭니다. 3.9 로 만들면 pip 가 markitdown 을 2019 년 자리표시자 릴리스(0.0.1a1)로 해석해서, 설치는 성공한 것처럼 보이지만 모든 변환이 임포트 단계에서 죽습니다. 실제로 이 함정을 밟고 잡았습니다. 3.10 이상이 아예 없으면 설치 명령을 안내하는 대신 `brew install python` 을 안내합니다.
|
|
403
|
+
|
|
394
404
|
변환기는 도구 전용 venv(`<상태 디렉터리>/doc2md-venv`)에 설치합니다. 시스템 파이썬을 건드리지 않고, CLI를 지우면 함께 사라집니다. 이미 `uv tool` 이나 다른 경로에 markitdown 이 있으면 그쪽을 먼저 씁니다.
|
|
395
405
|
|
|
396
406
|
변환은 [markitdown](https://github.com/microsoft/markitdown)이 담당하며, 슬라이드 번호와 제목 계층, 표, 발표자 노트, 시트 구분이 모두 남습니다. 한글도 깨지지 않습니다.
|
|
@@ -403,6 +413,50 @@ claude-token-saver doc2md 보고서.pptx # 직접 변환해 결과 확
|
|
|
403
413
|
- **압축 폭탄은 막습니다.** pptx·xlsx·docx 는 zip 컨테이너입니다. 선언된 크기를 먼저 걸러 내고, 선언은 조작될 수 있으므로 실제 해제 바이트도 상한과 대조합니다.
|
|
404
414
|
- **엑셀은 행 수로 자릅니다.** 변환 시간은 파일 크기가 아니라 행 수를 따릅니다(실측: PDF 6.3MB 0.9초, 엑셀 5.8MB 47.75초). 5만 행을 넘으면 앞부분만 변환하고, **잘랐다는 사실과 전체 행 수를 안내에 함께 적습니다.**
|
|
405
415
|
|
|
416
|
+
### 변환이 얼마를 아끼는지
|
|
417
|
+
|
|
418
|
+
변환본은 첫머리에 출처 주석을 답니다. 어떤 원본을 언제 변환했고 몇 토큰인지가 파일을 여는 순간 보입니다. 절감액은 스테이터스라인의 절감 줄 끝에 `📄 Doc2md saved` 로 붙습니다.
|
|
419
|
+
|
|
420
|
+
절감액의 기준은 변환기가 없을 때 실제로 하게 되는 일이고, 그 일이 형식마다 다릅니다. 두 경우 모두 2026-09-06 에 실측했습니다.
|
|
421
|
+
|
|
422
|
+
**PDF 는 첨부와 비교합니다.** `claude --print --input-format stream-json` 으로 같은 한 줄 프롬프트를 첨부 있이·없이 보내고 입력 토큰을 비교했습니다. 대조군은 42,204 토큰이었고 두 번 반복해 값이 같았습니다.
|
|
423
|
+
|
|
424
|
+
| 첨부 파일 | 분량 | 첨부가 더 든 토큰 | 페이지당 |
|
|
425
|
+
|---|---|---|---|
|
|
426
|
+
| 이력서 PDF | 7페이지 | +20,537 | 2,934 |
|
|
427
|
+
| 이력서 PDF | 5페이지 | +12,709 | 2,542 |
|
|
428
|
+
|
|
429
|
+
PDF 는 첨부하면 모델이 내용을 그대로 읽습니다. 대신 페이지마다 2,500~2,900 토큰이 붙어서, 변환본(5,531 토큰)의 서너 배가 듭니다. 계수는 두 실측치보다 낮은 페이지당 2,500 을 씁니다. 넉넉히 잡아 부풀리는 것보다 낮게 잡아 밑도는 편이 낫습니다.
|
|
430
|
+
|
|
431
|
+
**pptx·xlsx·docx 는 압축을 푸는 경우와 비교합니다.** 이 형식들은 애초에 첨부로 모델에 닿지 않습니다. 같은 방식으로 docx 를 보냈더니 78 토큰만 늘었고 모델은 파일이 없다고 답했으며, `Read` 도 이진 파일이라며 거부합니다. 그래서 변환기가 없을 때 실제로 하게 되는 일은 압축을 풀고 본문 XML 을 읽는 것입니다. 태그와 스타일 속성이 글자 수의 대부분을 차지하는 그 XML 말입니다.
|
|
432
|
+
|
|
433
|
+
| 원본 | 본문 XML | 변환본 | 차이 |
|
|
434
|
+
|---|---|---|---|
|
|
435
|
+
| 발표자료 pptx (31.8MB) | 약 540,429 토큰 | 약 22,610 토큰 | 23.8배 |
|
|
436
|
+
| 이력서 docx (189KB) | 약 79,621 토큰 | 약 1,684 토큰 | 47.3배 |
|
|
437
|
+
|
|
438
|
+
30MB 짜리 발표자료 하나가 XML 로는 54만 토큰입니다. 200k 컨텍스트에는 들어가지도 않습니다. 이 기준은 형식별 계수가 아니라 파일마다 실제 XML 크기를 재서 씁니다.
|
|
439
|
+
|
|
440
|
+
`.xls` 는 zip 컨테이너가 아니라 재어 볼 마크업이 없으므로 절감을 0 으로 둡니다.
|
|
441
|
+
|
|
442
|
+
클라이언트 동작이 바뀌면 `scripts/doc2md-baseline.mjs` 로 첨부 쪽을 다시 재고, `src/doc2md-ledger.cjs` 의 `ATTACHMENT_BASELINE` 표에 값만 갈아 끼우면 됩니다.
|
|
443
|
+
|
|
444
|
+
### 피그마 `.fig` 도 변환합니다
|
|
445
|
+
|
|
446
|
+
기획서가 PPT 에서 피그마로 옮겨 가는 추세를 따라, `.fig` 파일도 같은 훅이 잡습니다. `.fig` 는 zip 컨테이너지만 안에 든 `canvas.fig` 가 피그마의 비공개 바이너리(kiwi 포맷)라 markitdown 이 열지 못하므로, 이 형식만 Node 파서([openfig-core](https://github.com/OpenFig-org/openfig-core), MIT)로 변환합니다. `doc2md install-converter` 가 markitdown 과 함께 도구 상태 디렉터리에 설치하며, 패키지 자체는 여전히 무의존성입니다.
|
|
447
|
+
|
|
448
|
+
변환 결과는 페이지·프레임 계층을 헤딩으로, 텍스트 노드를 본문으로 정리한 아웃라인입니다. 도형·벡터 같은 시각 요소는 나열하지 않고 개수만 남깁니다. 기획서에서 내용은 글이고, `Rectangle 173` 이 이백 줄 나오면 글이 묻히기 때문입니다. 텍스트가 하나도 없는 파일(순수 그래픽)은 빈 문서로 꾸미지 않고 변환 불가로 알립니다.
|
|
449
|
+
|
|
450
|
+
실제 파일로 검증했습니다: 피그마 커뮤니티의 Bootstrap UI kit(8.1MB, 노드 4,155개, 텍스트 1,312개)와 Tailwind kit(52MB)이 각각 0.2초 안에 71.9KB·44KB 아웃라인으로 변환됐고, 한국어 텍스트 왕복도 무손실이었습니다. `.fig` 는 두 세대가 있습니다. 요즘 익스포트는 zip 컨테이너, 옛 익스포트는 fig-kiwi 바이너리 원형인데 둘 다 처리합니다. 절감액은 청구하지 않습니다. `.fig` 는 압축을 풀어도 또 바이너리라 비교할 대안 자체가 없고, 변환이 유일한 읽기 경로입니다.
|
|
451
|
+
|
|
452
|
+
### 문서를 수정해야 할 때: 복사본 + 스크립트
|
|
453
|
+
|
|
454
|
+
변환은 단방향이라 변환본 .md 를 고쳐도 원본에는 반영되지 않습니다. 훅이 변환 캐시와 원본 이진 파일에 대한 Edit/Write 를 거부하면서 올바른 경로를 안내합니다. 원본을 복사하고, 복사본을 스크립트로 수정하고, 수정본을 doc2md 로 재변환해 검증하는 순서입니다.
|
|
455
|
+
|
|
456
|
+
`install-converter` 가 편집 라이브러리(python-pptx·python-docx·openpyxl)를 변환기 venv 에 함께 설치하므로, "23번 슬라이드 차트를 꺾은선으로 바꿔줘" 같은 구조 편집도 에이전트가 그 자리에서 스크립트로 처리할 수 있습니다. `.fig` 는 openfig-core 가 인코더까지 제공해 텍스트 수정 후 재인코드가 됩니다.
|
|
457
|
+
|
|
458
|
+
네 형식 모두 실제로 몇 바퀴 돌려 검증했습니다(2026-09-06): docx 텍스트 치환 10건과 3회 연속 재저장, pptx 막대→꺾은선 차트 교체와 데이터 행 추가, xlsx 값 정정·행 추가, fig 텍스트 수정·재인코드·재파싱. 전 케이스에서 원본은 바이트 그대로였고, 수정본 재변환에 변경 내용이 반영됐습니다. 한 가지 주의: pptx 에서 차트 도형을 제거하면 옛 차트 XML 파트가 고아로 남습니다. PowerPoint 는 무시하지만, 깔끔히 하려면 파트와 rels 도 지우십시오. 차트·이미지 같은 시각 요소는 변환본에 잡히지 않으므로, 시각 편집의 최종 확인은 해당 프로그램에서 해야 합니다.
|
|
459
|
+
|
|
406
460
|
`claude-token-saver doc2md --clean` 으로 변환 캐시를 비우고, `doc2md off` 로 훅을 제거합니다. 훅 해제는 자기 항목만 골라 지우므로 `PreToolUse` 에 등록해 둔 다른 훅은 그대로 남습니다.
|
|
407
461
|
|
|
408
462
|
## 🌐 Bedrock·Vertex 경유 환경
|
|
@@ -489,7 +543,7 @@ npm uninstall -g claude-cache-monitor && npm i -g claude-token-saver
|
|
|
489
543
|
- **모르는 서브커맨드를 `--hook` 으로 부르면 아무것도 출력하지 않습니다.** 3.25.0 전역 설치본이 3.26.0 이 쓴 `settings.json` 을 만나면 `doc2md` 를 인식하지 못하고 기본 리포트로 흘러가, `Read` 할 때마다 통계 표 전문을 훅 스트림에 밀어 넣었습니다.
|
|
490
544
|
|
|
491
545
|
### v3.26.0 (2026-09-04)
|
|
492
|
-
-
|
|
546
|
+
- **문서를 읽기 전에 Markdown 으로 변환합니다.** pptx·xlsx·pdf·docx·fig 를 그대로 `Read` 하면 모델이 읽지 못하는 바이트가 컨텍스트에 올라갑니다. `doc2md on` 으로 `Read` 훅을 등록하면 파일을 한 번 변환해 캐시에 두고 변환본을 읽게 합니다. 변환기가 없으면 안내를 한 번만 하고 원본 `Read` 를 통과시키며, 압축 폭탄은 막고, 5만 행이 넘는 엑셀은 앞부분만 변환한 뒤 잘랐다는 사실을 함께 알립니다. 자세한 내용은 [doc2md](#-doc2md-문서를-읽기-전에-markdown-으로-바꿉니다) 절을 참고하십시오.
|
|
493
547
|
- **Bedrock·Vertex 경유 환경의 TTL 표시를 바로잡았습니다.** 게이트웨이는 버킷별 분해 값을 내려보내지 않는데, 판정 불가일 때 1시간을 기본값으로 잡고 있었습니다. 5분 버킷만 제공하는 환경에서 남은 시간이 최대 12배로 부풀어 보였습니다. 이제 모델 ID로 게이트웨이를 감지해 5분을 기본값으로 쓰고, 라벨을 `5m?` 로 적어 추정임을 밝힙니다. `mode ttl=5m` 으로 직접 지정할 수도 있습니다.
|
|
494
548
|
- **위임 집계가 조용히 버려지지 않습니다.** 모델 ID를 해석하지 못해 제외된 위임이 있으면 statusline 에 `🔀 N unresolved` 로 알립니다. 이전에는 "위임한 적 없음"과 화면상 구별되지 않아, 집계가 통째로 사라져도 알 방법이 없었습니다. `foundation-model` ARN 으로 지정된 환경변수도 이제 해석합니다.
|
|
495
549
|
- **한국어 지침의 적용 범위 충돌을 해소했습니다.** 주입문은 코드 주석을 검사 대상에 넣는데 벤더링한 원문은 두 번에 걸쳐 제외한다고 적고 있어서, 모델이 어느 쪽을 따를지 판단할 근거가 없었습니다. 원문은 그대로 두고 어느 쪽이 우선인지 명시하는 한 줄을 추가했습니다. 출처 표기에 들어 있던 엠대시도 지침 스스로 금지하는 표기였으므로 콜론으로 바꿨습니다.
|
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.
|
|
3
|
+
"version": "3.28.1",
|
|
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,
|
|
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
|
|
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
|
|
235
|
+
"truncated": truncated, "rows": rows, "pages": pages,
|
|
236
|
+
"markup_bytes": markup}, sys.stdout)
|
|
185
237
|
|
|
186
238
|
|
|
187
239
|
if __name__ == "__main__":
|
package/src/commands/doc2md.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|