claude-token-saver 3.27.1 → 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 +51 -9
- package/README.md +57 -9
- 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 +199 -5
- 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,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
|
|
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--
|
|
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
|
-
|
|
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,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 #
|
|
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
|
-
-
|
|
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.
|
|
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,
|
|
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
|
+
};
|
package/src/doc2md.cjs
CHANGED
|
@@ -27,13 +27,16 @@ const os = require('node:os');
|
|
|
27
27
|
const path = require('node:path');
|
|
28
28
|
const crypto = require('node:crypto');
|
|
29
29
|
const { spawnSync } = require('node:child_process');
|
|
30
|
+
const ledger = require('./doc2md-ledger.cjs');
|
|
30
31
|
|
|
31
32
|
// Formats where the original is of no use to the model. Images are absent
|
|
32
33
|
// deliberately: markitdown returns nothing for them, and OCR misread resource
|
|
33
34
|
// names in testing (`c5.xlarge` as `c.xlarge`), which is worse than no text at
|
|
34
35
|
// all in a document where those names are the content. The model reads images
|
|
35
36
|
// natively anyway.
|
|
36
|
-
|
|
37
|
+
// `.fig` converts through openfig-core in Node rather than markitdown; see
|
|
38
|
+
// fig2md.cjs for why it cannot go through the Python path.
|
|
39
|
+
const TARGET_EXTENSIONS = ['.pptx', '.xlsx', '.xls', '.pdf', '.docx', '.fig'];
|
|
37
40
|
|
|
38
41
|
// Big enough for real decks and reports, small enough that a hostile file
|
|
39
42
|
// cannot make the converter the expensive part of the turn.
|
|
@@ -107,7 +110,13 @@ function isSensitivePath(filePath) {
|
|
|
107
110
|
function cachePathFor(filePath) {
|
|
108
111
|
const abs = path.resolve(filePath);
|
|
109
112
|
const hash = crypto.createHash('sha256').update(abs).digest('hex').slice(0, 12);
|
|
110
|
-
|
|
113
|
+
// Keep letters and digits of any script, so a Korean or Japanese file name
|
|
114
|
+
// stays readable in the cache directory instead of collapsing into a row of
|
|
115
|
+
// underscores. Only characters that are awkward in a path are replaced.
|
|
116
|
+
const base = path.basename(abs)
|
|
117
|
+
.replace(/[^\p{L}\p{N}._-]/gu, '_')
|
|
118
|
+
.replace(/_{2,}/g, '_')
|
|
119
|
+
.slice(0, 80);
|
|
111
120
|
return path.join(cacheDir(), `${base}.${hash}.md`);
|
|
112
121
|
}
|
|
113
122
|
|
|
@@ -129,16 +138,81 @@ function readCache(filePath) {
|
|
|
129
138
|
}
|
|
130
139
|
}
|
|
131
140
|
|
|
141
|
+
/** `31854740` → `30.4MB`, for the banner and the hook's one-line summary. */
|
|
142
|
+
function humanBytes(n) {
|
|
143
|
+
const bytes = Number(n) || 0;
|
|
144
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
145
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
146
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The header stamped onto every conversion.
|
|
151
|
+
*
|
|
152
|
+
* Without it a conversion is an anonymous .md in a directory nobody opened on
|
|
153
|
+
* purpose, and the reader has no way to tell that this tool produced it, from
|
|
154
|
+
* what, or when. Four lines of provenance answer all of that at the top of the
|
|
155
|
+
* file the model is about to read, and cost about 60 tokens.
|
|
156
|
+
*/
|
|
157
|
+
function banner(sourcePath, saving) {
|
|
158
|
+
const src = fs.statSync(sourcePath);
|
|
159
|
+
const lines = [
|
|
160
|
+
'<!--',
|
|
161
|
+
'claude-token-saver doc2md 가 변환한 파일입니다. 직접 편집하지 마십시오.',
|
|
162
|
+
`원본: ${path.resolve(sourcePath)} (${humanBytes(src.size)})`,
|
|
163
|
+
`변환: ${new Date().toISOString()} · Markdown ${humanBytes(Buffer.byteLength(saving.markdown, 'utf8'))} · 약 ${saving.tokens.toLocaleString('en-US')} 토큰`,
|
|
164
|
+
];
|
|
165
|
+
if (saving.usd > 0) {
|
|
166
|
+
const savedTokens = Math.max(0, saving.baseline - saving.tokens);
|
|
167
|
+
// The alternative differs by format, so the sentence names it: a PDF
|
|
168
|
+
// would have been attached, a zip document would have been unpacked.
|
|
169
|
+
const alternative = path.extname(sourcePath).toLowerCase() === '.pdf'
|
|
170
|
+
? '원본을 첨부하면'
|
|
171
|
+
: '압축을 풀어 본문 XML을 읽으면';
|
|
172
|
+
lines.push(
|
|
173
|
+
`${alternative} 약 ${saving.baseline.toLocaleString('en-US')} 토큰이 드는데, `
|
|
174
|
+
+ `변환본은 ${saving.tokens.toLocaleString('en-US')} 토큰입니다. `
|
|
175
|
+
+ `약 ${savedTokens.toLocaleString('en-US')} 토큰, $${saving.usd.toFixed(2)} 을 아꼈습니다(추정).`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
lines.push('-->', '');
|
|
179
|
+
return lines.join('\n');
|
|
180
|
+
}
|
|
181
|
+
|
|
132
182
|
function writeCache(filePath, markdown, extra) {
|
|
133
183
|
ensureCacheDir();
|
|
134
184
|
const cacheFile = cachePathFor(filePath);
|
|
135
185
|
const src = fs.statSync(filePath);
|
|
136
|
-
|
|
186
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
187
|
+
const saving = Object.assign(
|
|
188
|
+
ledger.estimateSaving({
|
|
189
|
+
ext,
|
|
190
|
+
pages: (extra && extra.pages) || 0,
|
|
191
|
+
markupBytes: (extra && extra.markupBytes) || 0,
|
|
192
|
+
markdown,
|
|
193
|
+
}),
|
|
194
|
+
{ markdown },
|
|
195
|
+
);
|
|
196
|
+
fs.writeFileSync(cacheFile, banner(filePath, saving) + markdown, { encoding: 'utf8', mode: 0o600 });
|
|
197
|
+
ledger.recordConversion(userDataDir(), {
|
|
198
|
+
key: path.resolve(filePath),
|
|
199
|
+
ts: Date.now(),
|
|
200
|
+
usd: saving.usd,
|
|
201
|
+
ext,
|
|
202
|
+
tokens: saving.tokens,
|
|
203
|
+
baseline: saving.baseline,
|
|
204
|
+
});
|
|
137
205
|
const meta = Object.assign({
|
|
138
206
|
source: path.resolve(filePath),
|
|
139
207
|
size: src.size,
|
|
140
208
|
mtimeMs: src.mtimeMs,
|
|
141
209
|
convertedAt: new Date().toISOString(),
|
|
210
|
+
// Carried in the metadata so the hook can report what the conversion cost
|
|
211
|
+
// and saved without re-reading and re-measuring the markdown.
|
|
212
|
+
markdownBytes: Buffer.byteLength(markdown, 'utf8'),
|
|
213
|
+
tokens: saving.tokens,
|
|
214
|
+
baselineTokens: saving.baseline,
|
|
215
|
+
savedUsd: saving.usd,
|
|
142
216
|
}, extra || {});
|
|
143
217
|
fs.writeFileSync(metaPathFor(cacheFile), JSON.stringify(meta, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
144
218
|
return { cacheFile, meta };
|
|
@@ -193,6 +267,12 @@ function managedPython() {
|
|
|
193
267
|
|
|
194
268
|
const MARKITDOWN_SPEC = 'markitdown[pptx,pdf,xlsx,docx]';
|
|
195
269
|
|
|
270
|
+
// Editing libraries, installed alongside the converter. Reading is doc2md's
|
|
271
|
+
// own job; editing is the agent's, done per-request with a short script
|
|
272
|
+
// against a COPY of the document. These are the libraries those scripts need,
|
|
273
|
+
// pre-installed so "swap the chart on slide 23" does not stall on pip.
|
|
274
|
+
const EDIT_LIBS = ['python-pptx', 'python-docx', 'openpyxl'];
|
|
275
|
+
|
|
196
276
|
/**
|
|
197
277
|
* Build the managed venv and install markitdown into it.
|
|
198
278
|
*
|
|
@@ -217,8 +297,8 @@ function installConverter({ onProgress = () => {} } = {}) {
|
|
|
217
297
|
}
|
|
218
298
|
}
|
|
219
299
|
|
|
220
|
-
onProgress(`installing ${MARKITDOWN_SPEC}`);
|
|
221
|
-
const install = spawnSync(target, ['-m', 'pip', 'install', '--quiet', MARKITDOWN_SPEC], {
|
|
300
|
+
onProgress(`installing ${MARKITDOWN_SPEC} + ${EDIT_LIBS.join(', ')}`);
|
|
301
|
+
const install = spawnSync(target, ['-m', 'pip', 'install', '--quiet', MARKITDOWN_SPEC, ...EDIT_LIBS], {
|
|
222
302
|
encoding: 'utf8',
|
|
223
303
|
timeout: 900_000,
|
|
224
304
|
});
|
|
@@ -246,6 +326,30 @@ function installConverter({ onProgress = () => {} } = {}) {
|
|
|
246
326
|
* Every failure is a reason to leave the original Read alone, never to break
|
|
247
327
|
* it. That is the whole contract with the hook.
|
|
248
328
|
*/
|
|
329
|
+
/**
|
|
330
|
+
* Bridge from this synchronous pipeline to the async fig converter: run it in
|
|
331
|
+
* a child process and read one JSON object from stdout, exactly the contract
|
|
332
|
+
* the Python converter already speaks. Costs a process spawn, buys the same
|
|
333
|
+
* timeout and isolation the other formats get.
|
|
334
|
+
*/
|
|
335
|
+
function spawnFigConvert(fig2md, filePath) {
|
|
336
|
+
const runner = path.join(__dirname, 'fig2md-runner.cjs');
|
|
337
|
+
const run = spawnSync(process.execPath, [runner, filePath, userDataDir()], {
|
|
338
|
+
encoding: 'utf8',
|
|
339
|
+
timeout: CONVERT_TIMEOUT_MS,
|
|
340
|
+
maxBuffer: MAX_MARKDOWN_BYTES * 4,
|
|
341
|
+
});
|
|
342
|
+
if (run.error && run.error.code === 'ETIMEDOUT') return { ok: false, reason: 'timeout' };
|
|
343
|
+
if (run.status !== 0) {
|
|
344
|
+
return { ok: false, reason: 'convert-failed', detail: (run.stderr || '').slice(0, 300) };
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
return JSON.parse(run.stdout);
|
|
348
|
+
} catch {
|
|
349
|
+
return { ok: false, reason: 'convert-failed', detail: 'fig converter produced no JSON' };
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
249
353
|
function convert(filePath, { converter = CONVERTER, python: pythonOverride = null } = {}) {
|
|
250
354
|
if (!isTargetPath(filePath)) return { ok: false, reason: 'not-target' };
|
|
251
355
|
if (isSensitivePath(filePath)) return { ok: false, reason: 'sensitive' };
|
|
@@ -263,6 +367,17 @@ function convert(filePath, { converter = CONVERTER, python: pythonOverride = nul
|
|
|
263
367
|
const cached = readCache(filePath);
|
|
264
368
|
if (cached) return { ok: true, cached: true, cacheFile: cached.cacheFile, meta: cached.meta };
|
|
265
369
|
|
|
370
|
+
// Figma files take the Node converter; everything else goes to markitdown.
|
|
371
|
+
if (path.extname(filePath).toLowerCase() === '.fig') {
|
|
372
|
+
const fig2md = require('./fig2md.cjs');
|
|
373
|
+
const result = spawnFigConvert(fig2md, filePath);
|
|
374
|
+
if (!result.ok) return result;
|
|
375
|
+
const written = writeCache(filePath, result.markdown, {
|
|
376
|
+
note: result.note, truncated: false, rows: 0, pages: 0, markupBytes: 0,
|
|
377
|
+
});
|
|
378
|
+
return { ok: true, cached: false, cacheFile: written.cacheFile, meta: written.meta };
|
|
379
|
+
}
|
|
380
|
+
|
|
266
381
|
// The override exists so tests can drive a stub converter with any Python at
|
|
267
382
|
// all: the normal search insists the interpreter can import markitdown,
|
|
268
383
|
// which would make the whole path untestable without the real package.
|
|
@@ -299,6 +414,8 @@ function convert(filePath, { converter = CONVERTER, python: pythonOverride = nul
|
|
|
299
414
|
note: payload.note || null,
|
|
300
415
|
truncated: !!payload.truncated || clipped,
|
|
301
416
|
rows: payload.rows || 0,
|
|
417
|
+
pages: payload.pages || 0,
|
|
418
|
+
markupBytes: payload.markup_bytes || 0,
|
|
302
419
|
clipped,
|
|
303
420
|
});
|
|
304
421
|
return { ok: true, cached: false, cacheFile: written.cacheFile, meta: written.meta };
|
|
@@ -449,6 +566,24 @@ function documentPathsIn(text) {
|
|
|
449
566
|
// conversion. The rest are named in the note so nothing disappears quietly.
|
|
450
567
|
const MAX_PROMPT_CONVERSIONS = 3;
|
|
451
568
|
|
|
569
|
+
/**
|
|
570
|
+
* The one line that makes a conversion visible: what came in, what went out,
|
|
571
|
+
* and what not attaching the original was worth.
|
|
572
|
+
*
|
|
573
|
+
* The sizes matter more than they look. A 30MB deck that becomes 90KB of
|
|
574
|
+
* Markdown is the whole argument for this feature, and without the figures
|
|
575
|
+
* the model has nothing concrete to tell the user it happened.
|
|
576
|
+
*/
|
|
577
|
+
function conversionSummary(sourcePath, meta = {}) {
|
|
578
|
+
const parts = [];
|
|
579
|
+
try {
|
|
580
|
+
parts.push(`${humanBytes(fs.statSync(sourcePath).size)} → ${humanBytes(meta.markdownBytes || 0)}`);
|
|
581
|
+
} catch { /* the size is a nicety, not the point */ }
|
|
582
|
+
if (meta.tokens) parts.push(`약 ${Number(meta.tokens).toLocaleString('en-US')} 토큰`);
|
|
583
|
+
if (meta.savedUsd > 0) parts.push(`첨부 대비 약 $${Number(meta.savedUsd).toFixed(2)} 절감(추정)`);
|
|
584
|
+
return parts.join(', ');
|
|
585
|
+
}
|
|
586
|
+
|
|
452
587
|
/**
|
|
453
588
|
* Context to inject for a UserPromptSubmit payload, or null.
|
|
454
589
|
*
|
|
@@ -471,6 +606,7 @@ function contextForPrompt(payload, opts = {}) {
|
|
|
471
606
|
const result = convert(p, opts);
|
|
472
607
|
if (result.ok) {
|
|
473
608
|
lines.push(` ${name} → ${result.cacheFile}`);
|
|
609
|
+
lines.push(` ${conversionSummary(p, result.meta)}`);
|
|
474
610
|
if (result.meta && result.meta.note) lines.push(` ${result.meta.note}`);
|
|
475
611
|
if (result.meta && result.meta.clipped) {
|
|
476
612
|
lines.push(' 변환 결과가 너무 커서 뒷부분을 잘랐습니다. 전체가 필요하면 원본을 직접 다루십시오.');
|
|
@@ -479,6 +615,10 @@ function contextForPrompt(payload, opts = {}) {
|
|
|
479
615
|
lines.push(lang === 'ko'
|
|
480
616
|
? ` ${name}: 변환기가 없어 변환하지 못했습니다. 설치: ${INSTALL_HINT}`
|
|
481
617
|
: ` ${name}: no converter installed. Install it with: ${INSTALL_HINT}`);
|
|
618
|
+
} else if (result.reason === 'no-figparser') {
|
|
619
|
+
lines.push(lang === 'ko'
|
|
620
|
+
? ` ${name}: .fig 파서가 없어 변환하지 못했습니다. 설치: claude-token-saver doc2md install-converter`
|
|
621
|
+
: ` ${name}: no .fig parser installed. Install it with: claude-token-saver doc2md install-converter`);
|
|
482
622
|
} else if (result.reason === 'sensitive') {
|
|
483
623
|
lines.push(lang === 'ko'
|
|
484
624
|
? ` ${name}: 파일명이 민감 문서 패턴에 걸려 변환하지 않았습니다.`
|
|
@@ -500,12 +640,14 @@ function contextForPrompt(payload, opts = {}) {
|
|
|
500
640
|
'[doc2md] 이 프롬프트에 문서 경로가 있어 Markdown 으로 변환해 두었습니다.',
|
|
501
641
|
...lines,
|
|
502
642
|
'원본 대신 위 변환본을 Read 하십시오. pptx·xlsx·docx 는 Read 도구가 이진 파일이라며 거부하므로 원본을 직접 열 수 없습니다.',
|
|
643
|
+
'변환이 일어났다는 사실은 사용자에게도 보여야 하므로, 답변 첫 줄에 변환한 문서마다 다음 형식으로 한 줄씩 출력하십시오: `📄 [claude-token-saver] doc2md: <파일명> (<원본 크기> → <Markdown 크기>, 약 <토큰> 토큰)`. 절감액이 함께 안내됐다면 그 금액도 같은 줄 끝에 덧붙이십시오.',
|
|
503
644
|
'경로가 주어지지 않은 문서를 열어야 할 때는 `claude-token-saver doc2md <경로>` 를 실행해 변환본 경로를 얻으십시오.',
|
|
504
645
|
].join('\n')
|
|
505
646
|
: [
|
|
506
647
|
'[doc2md] This prompt names document paths, which have been converted to Markdown:',
|
|
507
648
|
...lines,
|
|
508
649
|
'Read the conversions above rather than the originals: Read refuses pptx/xlsx/docx as binary files, so the originals cannot be opened directly.',
|
|
650
|
+
'The user cannot see that any of this happened, so open your reply with one line per converted document: `📄 [claude-token-saver] doc2md: <name> (<source size> → <markdown size>, ~<tokens> tokens)`, adding the saving above if one was reported.',
|
|
509
651
|
'For a document whose path was not given here, run `claude-token-saver doc2md <path>` to get its conversion.',
|
|
510
652
|
].join('\n');
|
|
511
653
|
}
|
|
@@ -523,15 +665,66 @@ function sessionNote(lang = 'en') {
|
|
|
523
665
|
'[doc2md] 문서(pptx·xlsx·pdf·docx)를 다룰 때의 규칙입니다.',
|
|
524
666
|
' 문서를 열어야 하는데 Read 가 이진 파일이라며 거부하면, `claude-token-saver doc2md <경로>` 를 실행하고 그 결과로 나온 .md 를 Read 하십시오.',
|
|
525
667
|
' 사용자가 문서를 메시지에 직접 첨부했다면, 첨부는 내용 전체가 컨텍스트에 실려 토큰을 크게 씁니다. 다음부터는 첨부 대신 파일 경로를 적어 달라고 안내하십시오. 경로로 주면 변환본만 읽습니다.',
|
|
668
|
+
' 변환은 단방향입니다. 변환본 .md 를 고쳐도 원본에는 반영되지 않습니다. 문서 수정 요청을 받으면: ① 원본을 복사하고 ② 복사본을 스크립트로 수정하십시오. pptx·docx·xlsx 는 도구 venv 파이썬에 python-pptx·python-docx·openpyxl 이 준비되어 있고(`claude-token-saver doc2md` 상태 출력에 경로가 나옵니다), .fig 는 openfig-core 로 편집·재인코드합니다. ③ 수정한 복사본을 doc2md 로 재변환해 변경이 들어갔는지 검증하십시오. 차트·이미지 같은 시각 요소는 변환본에 안 잡히므로 텍스트 검증만으로 완료를 단정하지 마십시오.',
|
|
526
669
|
].join('\n');
|
|
527
670
|
}
|
|
528
671
|
return [
|
|
529
672
|
'[doc2md] Handling documents (pptx/xlsx/pdf/docx):',
|
|
530
673
|
' If Read refuses a document as a binary file, run `claude-token-saver doc2md <path>` and Read the .md it prints.',
|
|
531
674
|
' If the user attached a document to their message, its full contents were billed into the context. Tell them that naming the file path instead is far cheaper, since only the converted Markdown gets read.',
|
|
675
|
+
' Conversions are one-way: editing the cached .md changes nothing in the source. When asked to modify a document: ① copy the original, ② edit the COPY with a script — the managed venv python has python-pptx/python-docx/openpyxl, and .fig edits go through openfig-core — then ③ re-convert the copy with doc2md to verify the change landed. Charts and images do not appear in conversions, so text verification alone does not prove visual edits.',
|
|
532
676
|
].join('\n');
|
|
533
677
|
}
|
|
534
678
|
|
|
679
|
+
/**
|
|
680
|
+
* Guard for Edit/Write. Two writes are refused, for different reasons.
|
|
681
|
+
*
|
|
682
|
+
* A write to a conversion in the cache: the conversion is one-way, so an edit
|
|
683
|
+
* there changes nothing the user cares about — and worse, the cache still
|
|
684
|
+
* matches the source's mtime, so the corrupted copy would be served on every
|
|
685
|
+
* later read while looking exactly like the document. The deny explains where
|
|
686
|
+
* the work should go instead.
|
|
687
|
+
*
|
|
688
|
+
* A write to the original document: Edit and Write emit text, and a pptx or
|
|
689
|
+
* .fig overwritten with text is destroyed, not edited. Nothing this tool
|
|
690
|
+
* ships can write those formats back.
|
|
691
|
+
*/
|
|
692
|
+
function decideForWrite(context) {
|
|
693
|
+
if (!context || (context.tool_name !== 'Edit' && context.tool_name !== 'Write')) return null;
|
|
694
|
+
const toolInput = context.tool_input;
|
|
695
|
+
const filePath = toolInput && typeof toolInput.file_path === 'string' ? toolInput.file_path : '';
|
|
696
|
+
if (!filePath) return null;
|
|
697
|
+
const abs = path.resolve(filePath);
|
|
698
|
+
|
|
699
|
+
if (abs.startsWith(cacheDir() + path.sep)) {
|
|
700
|
+
let source = null;
|
|
701
|
+
try {
|
|
702
|
+
source = JSON.parse(fs.readFileSync(metaPathFor(abs), 'utf8')).source;
|
|
703
|
+
} catch { /* the deny stands on its own */ }
|
|
704
|
+
return {
|
|
705
|
+
deny: true,
|
|
706
|
+
reason: '[doc2md] 이 파일은 변환 캐시입니다. 여기를 고쳐도 원본 문서에는 아무것도 반영되지 않고, '
|
|
707
|
+
+ '캐시만 오염된 채 다음 읽기부터 계속 서빙됩니다.\n'
|
|
708
|
+
+ (source ? ` 원본: ${source}\n` : '')
|
|
709
|
+
+ ' 문서 수정이 목적이라면: 원본을 복사한 뒤(cp) 복사본을 스크립트로 수정하십시오. '
|
|
710
|
+
+ `pptx·docx·xlsx 는 ${managedPython()} 에 python-pptx·python-docx·openpyxl 이 설치되어 있고, `
|
|
711
|
+
+ '.fig 는 doc2md-fig 의 openfig-core 로 편집·재인코드할 수 있습니다. '
|
|
712
|
+
+ '수정 후 복사본을 `claude-token-saver doc2md <복사본>` 으로 재변환해 의도한 변경이 들어갔는지 확인하십시오. 원본은 절대 직접 수정하지 마십시오.',
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (isTargetPath(abs)) {
|
|
717
|
+
return {
|
|
718
|
+
deny: true,
|
|
719
|
+
reason: `[doc2md] ${path.basename(abs)} 는 이진 문서입니다. Edit/Write 는 텍스트를 쓰므로 이 파일을 파괴합니다. `
|
|
720
|
+
+ '수정하려면 원본을 복사한 뒤(cp) 복사본을 스크립트로 고치십시오. '
|
|
721
|
+
+ `pptx·docx·xlsx 는 ${managedPython()} 의 python-pptx·python-docx·openpyxl, .fig 는 openfig-core 를 쓰고, `
|
|
722
|
+
+ '수정 후 `claude-token-saver doc2md <복사본>` 재변환으로 결과를 검증하십시오.',
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
|
|
535
728
|
/** The decision rendered as the JSON Claude Code expects on stdout. */
|
|
536
729
|
function formatHookOutput(decision) {
|
|
537
730
|
if (!decision) return null;
|
|
@@ -565,5 +758,6 @@ module.exports = {
|
|
|
565
758
|
writeCache,
|
|
566
759
|
convert,
|
|
567
760
|
decideForRead,
|
|
761
|
+
decideForWrite,
|
|
568
762
|
formatHookOutput,
|
|
569
763
|
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Child-process shim between the synchronous doc2md pipeline and the async
|
|
4
|
+
* fig converter. Prints the conversion result as one JSON object on stdout,
|
|
5
|
+
* exactly like the Python converter does, so the caller treats both the same.
|
|
6
|
+
*
|
|
7
|
+
* Usage: node fig2md-runner.cjs <file.fig> <userDataDir>
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const { convertFig } = require('./fig2md.cjs');
|
|
13
|
+
|
|
14
|
+
const [file, userDataDir] = process.argv.slice(2);
|
|
15
|
+
convertFig(file, userDataDir)
|
|
16
|
+
.then((result) => { process.stdout.write(JSON.stringify(result)); })
|
|
17
|
+
.catch((e) => {
|
|
18
|
+
process.stdout.write(JSON.stringify({
|
|
19
|
+
ok: false, reason: 'convert-failed', detail: String(e && e.message || e).slice(0, 300),
|
|
20
|
+
}));
|
|
21
|
+
});
|
package/src/fig2md.cjs
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fig2md — Markdown rendering of a Figma `.fig` export.
|
|
3
|
+
*
|
|
4
|
+
* Planning documents increasingly live in Figma rather than PowerPoint, and a
|
|
5
|
+
* `.fig` handed to the model is even more opaque than a pptx: the container
|
|
6
|
+
* is a zip, but the payload inside (`canvas.fig`) is Figma's binary kiwi
|
|
7
|
+
* format, so there is no XML to fall back on. Without a converter the file is
|
|
8
|
+
* simply unreadable.
|
|
9
|
+
*
|
|
10
|
+
* markitdown does not speak this format, so the conversion runs in Node with
|
|
11
|
+
* [openfig-core] (MIT, three small pure-JS dependencies). The parser is not
|
|
12
|
+
* bundled: this package deliberately ships with zero dependencies, so
|
|
13
|
+
* openfig-core is installed on demand into the tool's own state directory —
|
|
14
|
+
* the same arrangement as the markitdown venv, for the same reason.
|
|
15
|
+
*
|
|
16
|
+
* Verified 2026-09-06 against real files: a community Bootstrap UI kit
|
|
17
|
+
* (8.1MB, 4,155 nodes, 1,312 of them text) and a 52MB Tailwind kit, each
|
|
18
|
+
* converting in under a second, plus a round-trip fixture whose Korean text
|
|
19
|
+
* nodes came back byte-identical. Both .fig vintages parse — the current
|
|
20
|
+
* zip container and the older bare fig-kiwi stream.
|
|
21
|
+
*
|
|
22
|
+
* [openfig-core]: https://github.com/OpenFig-org/openfig-core
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
const { spawnSync } = require('node:child_process');
|
|
30
|
+
|
|
31
|
+
const FIG_PARSER_SPEC = 'openfig-core@^0.4.1';
|
|
32
|
+
|
|
33
|
+
/** Where the on-demand parser install lives, under the tool's state dir. */
|
|
34
|
+
function managedFigDir(userDataDir) {
|
|
35
|
+
return path.join(userDataDir, 'doc2md-fig');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function loadParser(userDataDir) {
|
|
39
|
+
try {
|
|
40
|
+
// eslint-disable-next-line import/no-dynamic-require
|
|
41
|
+
return require(path.join(managedFigDir(userDataDir), 'node_modules', 'openfig-core', 'dist', 'index.cjs'));
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Install openfig-core into the managed directory. Mirrors the markitdown
|
|
49
|
+
* venv install: network once, probe as the acceptance test.
|
|
50
|
+
*/
|
|
51
|
+
function installFigParser(userDataDir, { onProgress = () => {} } = {}) {
|
|
52
|
+
const dir = managedFigDir(userDataDir);
|
|
53
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
54
|
+
const pkgJson = path.join(dir, 'package.json');
|
|
55
|
+
if (!fs.existsSync(pkgJson)) {
|
|
56
|
+
fs.writeFileSync(pkgJson, JSON.stringify({ name: 'doc2md-fig', private: true }) + '\n');
|
|
57
|
+
}
|
|
58
|
+
onProgress(`installing ${FIG_PARSER_SPEC}`);
|
|
59
|
+
const r = spawnSync('npm', ['install', '--no-audit', '--no-fund', '--silent', FIG_PARSER_SPEC], {
|
|
60
|
+
cwd: dir,
|
|
61
|
+
encoding: 'utf8',
|
|
62
|
+
timeout: 300_000,
|
|
63
|
+
});
|
|
64
|
+
if (r.status !== 0) {
|
|
65
|
+
return { ok: false, reason: 'npm-failed', detail: (r.stderr || '').slice(0, 400) };
|
|
66
|
+
}
|
|
67
|
+
if (!loadParser(userDataDir)) {
|
|
68
|
+
return { ok: false, reason: 'import-failed', detail: 'installed, but openfig-core does not load' };
|
|
69
|
+
}
|
|
70
|
+
return { ok: true, dir };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Node name plus type, indented by depth: the skeleton lines of the outline.
|
|
75
|
+
*/
|
|
76
|
+
function heading(node, depth) {
|
|
77
|
+
const name = String(node.name || '').trim() || '(이름 없음)';
|
|
78
|
+
return `${'#'.repeat(Math.min(depth + 2, 6))} ${name}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Walk the parsed document and render an outline.
|
|
83
|
+
*
|
|
84
|
+
* The traversal follows `childrenMap` (guid → ordered children), which is how
|
|
85
|
+
* the parser exposes hierarchy. Containers become headings, TEXT nodes become
|
|
86
|
+
* body lines, and everything visual (vectors, rectangles, images) is counted
|
|
87
|
+
* rather than listed: in a planning document the words are the content, and
|
|
88
|
+
* two hundred `Rectangle 173` lines would drown them.
|
|
89
|
+
*/
|
|
90
|
+
function renderMarkdown(doc, sourceName) {
|
|
91
|
+
const guidKey = (g) => `${g.sessionID}:${g.localID}`;
|
|
92
|
+
const lines = [];
|
|
93
|
+
const skipped = Object.create(null);
|
|
94
|
+
let textNodes = 0;
|
|
95
|
+
|
|
96
|
+
const CONTAINERS = new Set(['DOCUMENT', 'CANVAS', 'FRAME', 'GROUP', 'SECTION', 'COMPONENT', 'COMPONENT_SET', 'INSTANCE', 'SLIDE', 'SYMBOL']);
|
|
97
|
+
|
|
98
|
+
function walk(node, depth) {
|
|
99
|
+
if (!node || node.phase === 'REMOVED' || node.visible === false) return;
|
|
100
|
+
const type = node.type || '?';
|
|
101
|
+
if (type === 'TEXT') {
|
|
102
|
+
textNodes += 1;
|
|
103
|
+
const text = String(node.textData?.characters || '').trim();
|
|
104
|
+
const name = String(node.name || '').trim();
|
|
105
|
+
// The layer name usually repeats the text's first line; only show it
|
|
106
|
+
// when it says something the text does not.
|
|
107
|
+
if (name && text && !text.startsWith(name) && !name.startsWith(text.slice(0, 20))) {
|
|
108
|
+
lines.push(`- **${name}**: ${text.replace(/\n/g, ' / ')}`);
|
|
109
|
+
} else if (text) {
|
|
110
|
+
lines.push(`- ${text.replace(/\n/g, ' / ')}`);
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (CONTAINERS.has(type)) {
|
|
115
|
+
if (type !== 'DOCUMENT') {
|
|
116
|
+
lines.push('', heading(node, depth), '');
|
|
117
|
+
}
|
|
118
|
+
const children = doc.childrenMap?.get?.(guidKey(node.guid))
|
|
119
|
+
|| doc.childrenMap?.[guidKey(node.guid)]
|
|
120
|
+
|| [];
|
|
121
|
+
for (const child of children) walk(child, depth + (type === 'DOCUMENT' ? 0 : 1));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
skipped[type] = (skipped[type] || 0) + 1;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const root = (doc.nodes || []).find((n) => n.type === 'DOCUMENT');
|
|
128
|
+
if (root) walk(root, 0);
|
|
129
|
+
|
|
130
|
+
const title = doc.meta?.file_name || sourceName;
|
|
131
|
+
const head = [`# ${title}`];
|
|
132
|
+
const skippedText = Object.entries(skipped)
|
|
133
|
+
.sort((a, b) => b[1] - a[1])
|
|
134
|
+
.map(([t, n]) => `${t} ${n}개`)
|
|
135
|
+
.join(', ');
|
|
136
|
+
if (skippedText) {
|
|
137
|
+
head.push('', `(텍스트 외 시각 요소는 생략했습니다: ${skippedText}. 시각 확인이 필요하면 Figma에서 원본을 여십시오.)`);
|
|
138
|
+
}
|
|
139
|
+
return { markdown: [...head, ...lines, ''].join('\n'), textNodes };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Convert one `.fig` file. Same result contract as the Python converter:
|
|
144
|
+
* `{ ok, markdown, note, truncated, rows, pages, markup_bytes }` or
|
|
145
|
+
* `{ ok: false, reason, detail }`.
|
|
146
|
+
*
|
|
147
|
+
* `markup_bytes` is 0 on purpose. The office formats price their alternative
|
|
148
|
+
* as "unzip and wade through the XML", but a .fig unzips to another binary —
|
|
149
|
+
* there is no readable fallback, so there is no honest baseline to claim and
|
|
150
|
+
* these conversions count as documents handled rather than money saved.
|
|
151
|
+
*/
|
|
152
|
+
async function convertFig(filePath, userDataDir) {
|
|
153
|
+
const parser = loadParser(userDataDir);
|
|
154
|
+
if (!parser) return { ok: false, reason: 'no-figparser' };
|
|
155
|
+
let doc;
|
|
156
|
+
try {
|
|
157
|
+
const buf = fs.readFileSync(filePath);
|
|
158
|
+
// Two vintages of the same extension: current exports are a zip wrapping
|
|
159
|
+
// canvas.fig, older ones are the bare fig-kiwi stream (magic "fig-kiwi").
|
|
160
|
+
// Verified on a real 8.4MB community kit that only the binary path reads.
|
|
161
|
+
doc = buf[0] === 0x50 && buf[1] === 0x4b
|
|
162
|
+
? await parser.parseFig(buf)
|
|
163
|
+
: await parser.parseFigBinary(buf);
|
|
164
|
+
} catch (e) {
|
|
165
|
+
return { ok: false, reason: 'convert-failed', detail: String(e.message || e).slice(0, 300) };
|
|
166
|
+
}
|
|
167
|
+
const { markdown, textNodes } = renderMarkdown(doc, path.basename(filePath, '.fig'));
|
|
168
|
+
if (!textNodes) {
|
|
169
|
+
// A design file with no words converts to an empty outline, which would
|
|
170
|
+
// read as "the document says nothing" — a worse claim than "unreadable".
|
|
171
|
+
return { ok: false, reason: 'no-text', detail: 'the file has no text nodes' };
|
|
172
|
+
}
|
|
173
|
+
return { ok: true, markdown, note: null, truncated: false, rows: 0, pages: 0, markup_bytes: 0 };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = {
|
|
177
|
+
FIG_PARSER_SPEC,
|
|
178
|
+
managedFigDir,
|
|
179
|
+
installFigParser,
|
|
180
|
+
convertFig,
|
|
181
|
+
renderMarkdown,
|
|
182
|
+
};
|
|
@@ -261,7 +261,7 @@ export function formatNoSession({ caps = null, model = null, windowLabel = '', v
|
|
|
261
261
|
* @param {boolean} [opts.verbose=false] - longer layout with labels
|
|
262
262
|
* @param {boolean} [opts.timer=true] - show TTL countdown segment
|
|
263
263
|
* @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses 🧠 ⏳ 💰 instead of word labels.
|
|
264
|
-
* @param {string[]|null} [opts.segments] - whitelist of segments to render. Names: cap-warn, spike, version, harness, korean, model, hit, ttl, saved, delegated, ctx, period, plus per-window keys (`five_hour`, `seven_day`, …). `5h`/`7d` are kept as aliases for back-compat. Null/undefined = all.
|
|
264
|
+
* @param {string[]|null} [opts.segments] - whitelist of segments to render. Names: cap-warn, spike, version, harness, korean, model, hit, ttl, saved, delegated, doc2md, ctx, period, plus per-window keys (`five_hour`, `seven_day`, …). `5h`/`7d` are kept as aliases for back-compat. Null/undefined = all.
|
|
265
265
|
* @param {boolean} [opts.singleLine=false] - force the legacy one-line layout. By default, when the delegation ledger has lifetime savings, the routing totals lead on their own first line and everything else moves to line 2 (Claude Code renders multi-line statuslines; `--single-line` is the escape hatch for terminals that only show the first line).
|
|
266
266
|
*/
|
|
267
267
|
export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null, singleLine = false } = {}) {
|
|
@@ -350,6 +350,32 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
350
350
|
? `${c(YELLOW)}🔀 ${unresolvedRuns} unresolved${c(RESET)}`
|
|
351
351
|
: null);
|
|
352
352
|
|
|
353
|
+
// Document conversions — the same kind of number as "Routing saved", earned
|
|
354
|
+
// a different way: a document read as Markdown instead of attached whole.
|
|
355
|
+
// Kept as its own chip rather than folded into the routing total, because a
|
|
356
|
+
// single figure could not tell the reader which habit earned it.
|
|
357
|
+
//
|
|
358
|
+
// Conversions that saved nothing measurable still show as a count. For
|
|
359
|
+
// pptx/xlsx/docx the honest saving is zero — the client extracts much the
|
|
360
|
+
// same text — and a chip that disappeared on those would read as "doc2md
|
|
361
|
+
// did nothing" on the very formats it is the only way to open.
|
|
362
|
+
// icon: "📄 $0.42" verbose: "📄 Doc2md saved $0.42 · 12 docs"
|
|
363
|
+
// text: "Doc2md saved $0.42"
|
|
364
|
+
const doc2md = data.doc2mdTotals;
|
|
365
|
+
const doc2mdUsd = Number(doc2md && doc2md.total) || 0;
|
|
366
|
+
const doc2mdDocs = Number(doc2md && doc2md.docs) || 0;
|
|
367
|
+
const doc2mdLabel = isIcon
|
|
368
|
+
? (verbose ? '📄 Doc2md saved' : '📄')
|
|
369
|
+
: 'Doc2md saved';
|
|
370
|
+
let doc2mdSeg = null;
|
|
371
|
+
if (doc2mdUsd > 0) {
|
|
372
|
+
doc2mdSeg = `${c(GREEN)}${doc2mdLabel}${c(RESET)} ${formatMoney(doc2mdUsd)}`
|
|
373
|
+
+ (verbose ? ` ${c(GRAY)}· ${doc2mdDocs} docs${c(RESET)}` : '');
|
|
374
|
+
} else if (doc2mdDocs > 0) {
|
|
375
|
+
const label = isIcon ? '📄' : 'Doc2md';
|
|
376
|
+
doc2mdSeg = `${c(GRAY)}${label} ${doc2mdDocs} docs${c(RESET)}`;
|
|
377
|
+
}
|
|
378
|
+
|
|
353
379
|
// Routing-savings headline line (multi-line layout). The lifetime sum from
|
|
354
380
|
// the delegation ledger — the number the whole tool exists to grow, so it
|
|
355
381
|
// gets line 1 to itself while the diagnostics move to line 2.
|
|
@@ -388,6 +414,24 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
388
414
|
(pairText ? ` ${c(GRAY)}|${c(RESET)} ${pairText}` : '');
|
|
389
415
|
}
|
|
390
416
|
|
|
417
|
+
// Doc2md's own headline line, same anatomy as the routing one: a green
|
|
418
|
+
// lifetime total, then a gray per-format breakdown. Only built when there
|
|
419
|
+
// is money to report — a bare document count stays an inline chip, since a
|
|
420
|
+
// whole line for "3 docs" would be all frame and no figure.
|
|
421
|
+
// icon: "📄 Doc2md saved $1.8 | pptx 1× $1.55 · pdf 2× $0.27"
|
|
422
|
+
let doc2mdLine = null;
|
|
423
|
+
if (!singleLine && doc2mdUsd > 0) {
|
|
424
|
+
const head = isIcon ? '📄 Doc2md saved' : 'Doc2md saved';
|
|
425
|
+
const byExt = Array.isArray(doc2md.byExt) ? doc2md.byExt : [];
|
|
426
|
+
const extText = byExt
|
|
427
|
+
.map((r) => `${c(GRAY)}${r.ext} ${r.docs}× ${formatMoney(r.usd)}${c(RESET)}`)
|
|
428
|
+
.join(` ${c(GRAY)}·${c(RESET)} `);
|
|
429
|
+
doc2mdLine =
|
|
430
|
+
`${c(GREEN)}${c(BOLD)}${head}${c(RESET)} ` +
|
|
431
|
+
`${c(GREEN)}${formatMoney(doc2mdUsd)}${c(RESET)}` +
|
|
432
|
+
(extText ? ` ${c(GRAY)}|${c(RESET)} ${extText}` : '');
|
|
433
|
+
}
|
|
434
|
+
|
|
391
435
|
// Period label honors hour-precision configs (`mode 6h` → "6h", `mode 1d` → "1d").
|
|
392
436
|
// Fall back to legacy `${days}d` when callers haven't supplied a label.
|
|
393
437
|
const periodLabel = options.windowLabel || `${options.days}d`;
|
|
@@ -628,6 +672,8 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
628
672
|
// When the totals headline owns line 1, the inline session chip would
|
|
629
673
|
// repeat the same story on line 2 — drop it there.
|
|
630
674
|
if (delegateSeg && want('delegated') && !totalsLine) segs.push(delegateSeg);
|
|
675
|
+
// Only when it did not already earn a headline line above.
|
|
676
|
+
if (doc2mdSeg && want('doc2md') && !doc2mdLine) segs.push(doc2mdSeg);
|
|
631
677
|
if (want('hit')) segs.push(hitSeg);
|
|
632
678
|
if (want('ttl')) segs.push(ttlSeg);
|
|
633
679
|
for (const { key, seg } of usageSegs) {
|
|
@@ -646,8 +692,17 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
646
692
|
// allowed — --no-color/NO_COLOR consumers expect escape-free output.
|
|
647
693
|
const eol = color ? '\x1b[K' : '';
|
|
648
694
|
const rest = segs.join(' · ') + eol;
|
|
695
|
+
// Each savings source that earned real money gets a headline line, ordered
|
|
696
|
+
// biggest saver first — the top line is the one the eye lands on, so it
|
|
697
|
+
// goes to whichever habit is actually paying for the tool. The diagnostics
|
|
698
|
+
// line always closes.
|
|
699
|
+
const headlines = [];
|
|
649
700
|
if (totalsLine && want('delegated')) {
|
|
650
|
-
|
|
701
|
+
headlines.push({ usd: Number(totals && totals.total) || 0, line: totalsLine });
|
|
702
|
+
}
|
|
703
|
+
if (doc2mdLine && want('doc2md')) {
|
|
704
|
+
headlines.push({ usd: doc2mdUsd, line: doc2mdLine });
|
|
651
705
|
}
|
|
652
|
-
|
|
706
|
+
headlines.sort((a, b) => b.usd - a.usd);
|
|
707
|
+
return headlines.map((h) => h.line + eol + '\n').join('') + rest;
|
|
653
708
|
}
|
package/src/installer.js
CHANGED
|
@@ -394,6 +394,27 @@ export function installDoc2mdHook() {
|
|
|
394
394
|
settings.hooks.PreToolUse = list;
|
|
395
395
|
}
|
|
396
396
|
|
|
397
|
+
// The write guard rides the same command on its own matcher. A conversion
|
|
398
|
+
// is one-way: an Edit to the cached .md changes nothing the user cares
|
|
399
|
+
// about, and a Write to the original clobbers a binary with text. Both are
|
|
400
|
+
// denied with an explanation of where the work should go instead.
|
|
401
|
+
let addedWriteGuard = false;
|
|
402
|
+
{
|
|
403
|
+
const list2 = Array.isArray(settings.hooks.PreToolUse) ? settings.hooks.PreToolUse : [];
|
|
404
|
+
const hasWriteGuard = list2.some((m) =>
|
|
405
|
+
m?.matcher === 'Edit|Write'
|
|
406
|
+
&& Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && /doc2md --hook(?!-)/.test(h.command)),
|
|
407
|
+
);
|
|
408
|
+
if (!hasWriteGuard) {
|
|
409
|
+
list2.push({
|
|
410
|
+
matcher: 'Edit|Write',
|
|
411
|
+
hooks: [{ type: 'command', command: DOC2MD_HOOK_COMMAND }],
|
|
412
|
+
});
|
|
413
|
+
settings.hooks.PreToolUse = list2;
|
|
414
|
+
addedWriteGuard = true;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
397
418
|
// The Read hook alone covers only PDFs. Claude Code refuses pptx/xlsx/docx
|
|
398
419
|
// as binary before any PreToolUse hook runs, so for exactly the formats this
|
|
399
420
|
// feature exists for, the tool call is dead before doc2md is consulted.
|
|
@@ -412,7 +433,7 @@ export function installDoc2mdHook() {
|
|
|
412
433
|
}
|
|
413
434
|
}
|
|
414
435
|
|
|
415
|
-
if (!hasReadHook || addedPrompt) {
|
|
436
|
+
if (!hasReadHook || addedPrompt || addedWriteGuard) {
|
|
416
437
|
writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
417
438
|
return { path: file, action: hasReadHook ? 'updated' : 'created' };
|
|
418
439
|
}
|