claude-token-saver 3.26.1 → 3.27.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 +29 -4
- package/README.md +29 -4
- package/bin/cli.js +16 -0
- package/package.json +1 -1
- package/src/commands/doc2md.js +56 -1
- package/src/commands/route-scan.js +38 -0
- package/src/doc2md.cjs +192 -1
- package/src/installer.js +34 -10
package/README.en.md
CHANGED
|
@@ -382,18 +382,33 @@ Installs with nobody attached — npm `postinstall`, CI, piped stdin — skip th
|
|
|
382
382
|
|
|
383
383
|
`Read` a pptx, xlsx, pdf or docx and the raw bytes go into the context window, where the model cannot read them. This intercepts that `Read`, converts the file once, and hands over the Markdown instead.
|
|
384
384
|
|
|
385
|
+
**This is opt-in.** Installing the CLI does not turn it on: both commands below are required, and a registered hook with no converter behind it does nothing at all.
|
|
386
|
+
|
|
387
|
+
Three situations, three different interception points:
|
|
388
|
+
|
|
389
|
+
| Situation | Where it is caught |
|
|
390
|
+
|---|---|
|
|
391
|
+
| 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 |
|
|
393
|
+
| 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
|
+
|
|
395
|
+
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
|
+
|
|
385
397
|
```bash
|
|
386
|
-
|
|
387
|
-
claude-token-saver doc2md on
|
|
388
|
-
claude-token-saver doc2md
|
|
398
|
+
claude-token-saver doc2md install-converter # markitdown into a dedicated venv
|
|
399
|
+
claude-token-saver doc2md on # register the Read hook
|
|
400
|
+
claude-token-saver doc2md # check converter + hook registration
|
|
401
|
+
claude-token-saver doc2md report.pptx # convert by hand and see the result
|
|
389
402
|
```
|
|
390
403
|
|
|
404
|
+
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
|
+
|
|
391
406
|
Conversion is [markitdown](https://github.com/microsoft/markitdown). Slide numbers, heading levels, tables, speaker notes and per-sheet headings all survive, and non-Latin text comes through intact.
|
|
392
407
|
|
|
393
408
|
Several things it deliberately does not do:
|
|
394
409
|
|
|
395
410
|
- **Images are not converted.** markitdown returns nothing for them, and OCR misread resource names in testing (`c5.xlarge` as `c.xlarge`). In a document where those names *are* the content, wrong text is worse than none. The model reads images natively anyway.
|
|
396
|
-
- **A missing
|
|
411
|
+
- **A missing converter never fails silently.** The install command is shown once, then the original `Read` proceeds untouched. Repeating the notice on every read would be its own nuisance; saying nothing is how a broken converter hides. Run `doc2md` with no arguments to see the converter and hook registration together.
|
|
397
412
|
- **Conversions never land in your project.** They go under the tool's own state directory with mode `0700`, so there is nothing to add to `.gitignore`. Filenames matching payroll/contract/secret patterns are skipped entirely.
|
|
398
413
|
- **Zip bombs are refused.** pptx/xlsx/docx are zip containers: the declared sizes are checked first, and since those are written by whoever built the file, the real decompressed bytes are counted against a ceiling too.
|
|
399
414
|
- **Spreadsheets are capped by rows, not bytes.** Conversion time tracks row count (measured: a 6.3MB PDF in 0.9s, a 5.8MB workbook in 47.75s). Past 50,000 rows only the head is converted, and **the truncation and the true row count are both stated** in what the model is told.
|
|
@@ -497,6 +512,16 @@ Also update `statusLine.command` in `~/.claude/settings.json` to `claude-token-s
|
|
|
497
512
|
|
|
498
513
|
## Release notes
|
|
499
514
|
|
|
515
|
+
### v3.27.0 (2026-09-04)
|
|
516
|
+
- **A document path in the prompt is finally caught.** The 3.26.x `PreToolUse(Read)` hook never reached the formats it was written for: Claude Code refuses pptx/xlsx/docx as binary *before* running any hook. Measured — a `.pdf` Read fires the hook, a `.pptx` Read in the same session leaves no hook log entry. Interception now also happens at `UserPromptSubmit`, which sees the raw prompt: paths there are converted and the conversion is handed back as context. `@path`, quoted and relative forms all count.
|
|
517
|
+
- **A session-start note was added.** It tells the model two things it cannot work out alone: that a binary-file refusal is answered by `doc2md <path>`, and that if the user attached a document, they should be asked for a path next time. An attachment bills its entire contents into the context, and no hook event receives attachment content, so there is nothing the tool itself can do about it.
|
|
518
|
+
- **`doc2md on` registers both hooks**, and `off` removes only its own entries from both.
|
|
519
|
+
|
|
520
|
+
### v3.26.2 (2026-09-04)
|
|
521
|
+
- **The converter installs itself.** The old instruction was `pip install`, which asks the user to modify a system interpreter — and if they skipped it, the hook sat registered and did nothing. `doc2md install-converter` builds a dedicated venv and puts markitdown in it.
|
|
522
|
+
- **`doc2md` status now reports hook registration too.** Showing only the converter made "hook but no converter" and "converter but no hook" look identical, and both look like a broken feature.
|
|
523
|
+
- **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
|
+
|
|
500
525
|
### v3.26.0 (2026-09-04)
|
|
501
526
|
- **Attached documents are converted to Markdown before the model reads them.** Reading a pptx/xlsx/pdf/docx put unreadable bytes into the context window. `doc2md on` registers a `Read` hook that converts the file once, caches it outside your project, and points the model at the Markdown. A missing converter is announced once and then gets out of the way, zip bombs are refused, and workbooks past 50,000 rows are converted head-first with the truncation stated. See [doc2md](#-doc2md--attached-documents-become-markdown-before-the-model-reads-them).
|
|
502
527
|
- **TTL display fixed for Bedrock/Vertex sessions.** Gateways never report the per-bucket split, and the fallback assumed an hour — twelvefold too long for a 5-minute-only backend. The gateway is now detected from the model ids, the fallback follows that evidence, and the label reads `5m?` to mark it as inferred. Pin it manually with `mode ttl=5m` if the detection is wrong.
|
package/README.md
CHANGED
|
@@ -372,18 +372,33 @@ npm의 `postinstall`이나 CI처럼 사람이 붙어 있지 않은 설치에서
|
|
|
372
372
|
|
|
373
373
|
pptx·xlsx·pdf·docx 를 그대로 `Read` 하면 모델이 읽지 못하는 바이트가 컨텍스트에 그대로 올라갑니다. 이 기능은 그 `Read` 를 가로채 파일을 한 번 변환해 두고, 원본 대신 변환본을 읽게 합니다.
|
|
374
374
|
|
|
375
|
+
**이 기능은 옵트인입니다.** 설치만으로는 켜지지 않고, 아래 두 명령을 모두 실행해야 동작합니다. 훅만 등록하고 변환기가 없으면 아무 일도 일어나지 않습니다.
|
|
376
|
+
|
|
377
|
+
세 가지 경로를 덮습니다. 각각 걸리는 지점이 다릅니다.
|
|
378
|
+
|
|
379
|
+
| 상황 | 개입 지점 |
|
|
380
|
+
|---|---|
|
|
381
|
+
| 프롬프트에 문서 경로를 적음 (`@경로`·따옴표·상대 경로 모두) | `UserPromptSubmit`. 변환한 뒤 변환본 경로를 컨텍스트로 넣습니다 |
|
|
382
|
+
| 작업 도중 문서를 `Read` | pdf 는 `PreToolUse(Read)` 가 잡습니다. pptx·xlsx·docx 는 Claude Code 가 이진 파일이라며 훅보다 먼저 거부하므로, 세션 시작 안내문이 모델에게 `doc2md <경로>` 를 실행하도록 지시합니다 |
|
|
383
|
+
| 문서를 메시지에 직접 첨부 | **훅으로 잡을 수 없습니다.** 어떤 훅 이벤트도 첨부 내용을 받지 못합니다. 세션 시작 안내문이 다음부터 경로로 달라고 사용자에게 안내하도록 모델에게 지시합니다 |
|
|
384
|
+
|
|
385
|
+
두 번째 줄의 제약은 실측으로 확인한 것입니다. `.pdf` 를 Read 하면 훅이 실행되고, 같은 세션에서 `.pptx` 를 Read 하면 훅 로그에 아무 기록도 남지 않습니다.
|
|
386
|
+
|
|
375
387
|
```bash
|
|
376
|
-
|
|
377
|
-
claude-token-saver doc2md on
|
|
378
|
-
claude-token-saver doc2md
|
|
388
|
+
claude-token-saver doc2md install-converter # 전용 venv에 markitdown 설치
|
|
389
|
+
claude-token-saver doc2md on # Read 훅 등록
|
|
390
|
+
claude-token-saver doc2md # 변환기·훅 등록 상태 확인
|
|
391
|
+
claude-token-saver doc2md 보고서.pptx # 직접 변환해 결과 확인
|
|
379
392
|
```
|
|
380
393
|
|
|
394
|
+
변환기는 도구 전용 venv(`<상태 디렉터리>/doc2md-venv`)에 설치합니다. 시스템 파이썬을 건드리지 않고, CLI를 지우면 함께 사라집니다. 이미 `uv tool` 이나 다른 경로에 markitdown 이 있으면 그쪽을 먼저 씁니다.
|
|
395
|
+
|
|
381
396
|
변환은 [markitdown](https://github.com/microsoft/markitdown)이 담당하며, 슬라이드 번호와 제목 계층, 표, 발표자 노트, 시트 구분이 모두 남습니다. 한글도 깨지지 않습니다.
|
|
382
397
|
|
|
383
398
|
몇 가지는 의도적으로 하지 않습니다.
|
|
384
399
|
|
|
385
400
|
- **이미지는 변환하지 않습니다.** markitdown 이 빈 결과를 돌려주고, OCR 은 실측에서 리소스 이름을 틀리게 읽었습니다(`c5.xlarge` 를 `c.xlarge` 로). 이름 자체가 내용인 문서에서는 텍스트가 없느니만 못합니다. 모델이 이미지는 직접 읽습니다.
|
|
386
|
-
-
|
|
401
|
+
- **변환기가 없으면 조용히 실패하지 않습니다.** 설치 명령을 한 번 안내한 뒤 원본 `Read` 를 그대로 통과시킵니다. 매번 알리면 그것대로 방해가 되고, 아무 말도 하지 않으면 고장을 숨기게 됩니다. `doc2md` 를 인자 없이 실행하면 변환기와 훅 등록 상태를 한 번에 확인할 수 있습니다.
|
|
387
402
|
- **변환본은 프로젝트 안에 남기지 않습니다.** 도구의 상태 디렉터리 아래 권한 `0700` 으로 저장하므로 `.gitignore` 에 무엇을 추가할 필요가 없습니다. 파일명이 급여·계약·개인정보 같은 패턴에 걸리면 아예 변환하지 않습니다.
|
|
388
403
|
- **압축 폭탄은 막습니다.** pptx·xlsx·docx 는 zip 컨테이너입니다. 선언된 크기를 먼저 걸러 내고, 선언은 조작될 수 있으므로 실제 해제 바이트도 상한과 대조합니다.
|
|
389
404
|
- **엑셀은 행 수로 자릅니다.** 변환 시간은 파일 크기가 아니라 행 수를 따릅니다(실측: PDF 6.3MB 0.9초, 엑셀 5.8MB 47.75초). 5만 행을 넘으면 앞부분만 변환하고, **잘랐다는 사실과 전체 행 수를 안내에 함께 적습니다.**
|
|
@@ -463,6 +478,16 @@ npm uninstall -g claude-cache-monitor && npm i -g claude-token-saver
|
|
|
463
478
|
|
|
464
479
|
## 릴리스 노트
|
|
465
480
|
|
|
481
|
+
### v3.27.0 (2026-09-04)
|
|
482
|
+
- **문서 경로를 프롬프트에 적으면 이제 실제로 걸립니다.** 3.26.x 의 `PreToolUse(Read)` 훅은 정작 목표한 형식에 닿지 못했습니다. Claude Code 가 pptx·xlsx·docx 를 이진 파일이라며 훅보다 먼저 거부하기 때문입니다(실측: `.pdf` Read 는 훅이 실행되고 `.pptx` Read 는 훅 기록이 남지 않습니다). 개입 지점을 `UserPromptSubmit` 으로 넓혀, 프롬프트에 적힌 경로를 변환한 뒤 변환본 경로를 컨텍스트로 넣습니다. `@경로`·따옴표·상대 경로를 모두 인식합니다.
|
|
483
|
+
- **세션 시작 안내문을 추가했습니다.** 두 가지를 모델에게 알립니다. `Read` 가 이진 파일이라며 거부하면 `doc2md <경로>` 를 실행할 것, 그리고 사용자가 문서를 직접 첨부했으면 다음부터 경로로 달라고 안내할 것입니다. 첨부는 내용 전체가 컨텍스트에 실려 토큰을 크게 쓰는데, 어떤 훅 이벤트도 첨부 내용을 받지 못해 도구가 개입할 방법이 없습니다.
|
|
484
|
+
- **`doc2md on` 이 훅 두 개를 함께 등록합니다.** `off` 는 자기 항목만 골라 양쪽에서 지웁니다.
|
|
485
|
+
|
|
486
|
+
### v3.26.2 (2026-09-04)
|
|
487
|
+
- **doc2md 변환기를 도구가 직접 설치합니다.** 지금까지의 안내는 `pip install` 이었는데, 시스템 파이썬을 건드리라는 요구인 데다 실행하지 않으면 훅만 등록된 채 아무 일도 일어나지 않았습니다. `doc2md install-converter` 가 전용 venv 를 만들어 markitdown 을 넣습니다.
|
|
488
|
+
- **`doc2md` 상태 출력에 훅 등록 여부를 함께 적습니다.** 변환기만 알려 주면 "훅은 있는데 변환기가 없다"와 "변환기는 있는데 훅이 없다"가 똑같이 아무 일도 안 하는 상태로 보여서, 어느 쪽이 빠졌는지 알 수 없었습니다.
|
|
489
|
+
- **모르는 서브커맨드를 `--hook` 으로 부르면 아무것도 출력하지 않습니다.** 3.25.0 전역 설치본이 3.26.0 이 쓴 `settings.json` 을 만나면 `doc2md` 를 인식하지 못하고 기본 리포트로 흘러가, `Read` 할 때마다 통계 표 전문을 훅 스트림에 밀어 넣었습니다.
|
|
490
|
+
|
|
466
491
|
### v3.26.0 (2026-09-04)
|
|
467
492
|
- **첨부 문서를 읽기 전에 Markdown 으로 변환합니다.** pptx·xlsx·pdf·docx 를 그대로 `Read` 하면 모델이 읽지 못하는 바이트가 컨텍스트에 올라갑니다. `doc2md on` 으로 `Read` 훅을 등록하면 파일을 한 번 변환해 캐시에 두고 변환본을 읽게 합니다. 변환기가 없으면 안내를 한 번만 하고 원본 `Read` 를 통과시키며, 압축 폭탄은 막고, 5만 행이 넘는 엑셀은 앞부분만 변환한 뒤 잘랐다는 사실을 함께 알립니다. 자세한 내용은 [doc2md](#-doc2md-첨부-문서를-읽기-전에-markdown-으로-바꿉니다) 절을 참고하십시오.
|
|
468
493
|
- **Bedrock·Vertex 경유 환경의 TTL 표시를 바로잡았습니다.** 게이트웨이는 버킷별 분해 값을 내려보내지 않는데, 판정 불가일 때 1시간을 기본값으로 잡고 있었습니다. 5분 버킷만 제공하는 환경에서 남은 시간이 최대 12배로 부풀어 보였습니다. 이제 모델 ID로 게이트웨이를 감지해 5분을 기본값으로 쓰고, 라벨을 `5m?` 로 적어 추정임을 밝힙니다. `mode ttl=5m` 으로 직접 지정할 수도 있습니다.
|
package/bin/cli.js
CHANGED
|
@@ -83,7 +83,23 @@ function readUpdateChip() {
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// Subcommands this build knows how to run. Used only by the guard below.
|
|
87
|
+
const KNOWN_SUBCOMMANDS = new Set([
|
|
88
|
+
'last', 'brief', 'history', 'handoff', 'install', 'uninstall', 'mode', 'korean',
|
|
89
|
+
'doc2md', 'harness', 'route-scan', 'compact-window', 'update-check', 'upgrade',
|
|
90
|
+
]);
|
|
91
|
+
|
|
86
92
|
async function main() {
|
|
93
|
+
// A hook invocation names a subcommand and expects either silence or that
|
|
94
|
+
// subcommand's own protocol on stdout. If this build does not have the
|
|
95
|
+
// subcommand — an older global install against a newer settings.json, which
|
|
96
|
+
// is exactly what a mid-upgrade machine looks like — falling through to the
|
|
97
|
+
// default report would push a full table into the hook stream on every
|
|
98
|
+
// matching tool call. Say nothing instead.
|
|
99
|
+
if (hasFlag('--hook') && args[0] && !KNOWN_SUBCOMMANDS.has(args[0])) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
87
103
|
// Subcommand: last — print the most recent warning + how to handle it.
|
|
88
104
|
// Designed for the auto-trigger skill so the user immediately sees
|
|
89
105
|
// "what just fired and how to fix it" without having to read the whole
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-token-saver",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.27.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": {
|
package/src/commands/doc2md.js
CHANGED
|
@@ -15,6 +15,27 @@ import { join } from 'node:path';
|
|
|
15
15
|
|
|
16
16
|
const require = createRequire(import.meta.url);
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Whether the Read hook is actually in settings.json.
|
|
20
|
+
*
|
|
21
|
+
* Status output that reports only the converter is misleading: a working
|
|
22
|
+
* converter with no hook, and a hook with no converter, both add up to
|
|
23
|
+
* "nothing happens", and the user has no way to tell which half is missing.
|
|
24
|
+
*/
|
|
25
|
+
function hookRegistered() {
|
|
26
|
+
try {
|
|
27
|
+
const { homedir } = require('node:os');
|
|
28
|
+
const settings = JSON.parse(
|
|
29
|
+
require('node:fs').readFileSync(join(homedir(), '.claude', 'settings.json'), 'utf8'),
|
|
30
|
+
);
|
|
31
|
+
return (settings?.hooks?.PreToolUse || []).some((m) =>
|
|
32
|
+
(m.hooks || []).some((h) => typeof h.command === 'string' && h.command.includes('doc2md --hook')),
|
|
33
|
+
);
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
18
39
|
export async function run({ args, hasFlag }) {
|
|
19
40
|
const doc2md = require('../doc2md.cjs');
|
|
20
41
|
const sub = args[1];
|
|
@@ -37,16 +58,48 @@ export async function run({ args, hasFlag }) {
|
|
|
37
58
|
return;
|
|
38
59
|
}
|
|
39
60
|
|
|
61
|
+
if (sub === 'install-converter') {
|
|
62
|
+
const res = doc2md.installConverter({ onProgress: (m) => console.log(` ${m}`) });
|
|
63
|
+
if (res.ok) {
|
|
64
|
+
console.log(`✓ converter ready: ${res.python}`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
console.error(`✗ ${res.reason}: ${res.detail}`);
|
|
68
|
+
process.exitCode = 1;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// UserPromptSubmit entry point. Separate from `--hook` because the two speak
|
|
73
|
+
// different protocols: PreToolUse answers with a permission decision, this
|
|
74
|
+
// one answers with plain text that becomes context the model can act on.
|
|
75
|
+
if (hasFlag?.('--hook-prompt') || sub === '--hook-prompt') {
|
|
76
|
+
const { readStdinJson } = await import('../stdin-payload.js');
|
|
77
|
+
const payload = readStdinJson();
|
|
78
|
+
if (!payload) return;
|
|
79
|
+
const { userLanguage } = await import('../config.js');
|
|
80
|
+
try {
|
|
81
|
+
const context = doc2md.contextForPrompt(payload, { lang: userLanguage() });
|
|
82
|
+
if (context) console.log(context);
|
|
83
|
+
} catch {
|
|
84
|
+
// Never break a prompt over a conversion. Silence leaves the session
|
|
85
|
+
// exactly as it would have been without this feature.
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
40
90
|
if (sub === 'on') {
|
|
41
91
|
const { installDoc2mdHook } = await import('../installer.js');
|
|
42
92
|
const res = installDoc2mdHook();
|
|
43
93
|
console.log(res.action === 'skipped'
|
|
44
94
|
? `✗ ${res.reason}`
|
|
45
95
|
: `✓ Read hook ${res.action} (${res.path})`);
|
|
96
|
+
// A registered hook with no converter behind it does nothing at all, and
|
|
97
|
+
// says nothing about it either, which reads as a broken feature. Offer the
|
|
98
|
+
// one command that closes the gap right where the gap is visible.
|
|
46
99
|
const python = doc2md.findInterpreter();
|
|
47
100
|
console.log(python
|
|
48
101
|
? ` converter: markitdown via ${python}`
|
|
49
|
-
: ` converter:
|
|
102
|
+
: ` converter: missing — run \`${doc2md.INSTALL_HINT}\` or the hook will do nothing`);
|
|
50
103
|
return;
|
|
51
104
|
}
|
|
52
105
|
|
|
@@ -95,6 +148,8 @@ export async function run({ args, hasFlag }) {
|
|
|
95
148
|
console.log(` formats: ${doc2md.TARGET_EXTENSIONS.join(' ')}`);
|
|
96
149
|
console.log(` converter: ${python ? `markitdown via ${python}` : `not installed — ${doc2md.INSTALL_HINT}`}`);
|
|
97
150
|
console.log(` cache: ${dir} (${cached} file(s))`);
|
|
151
|
+
console.log(` hook: ${hookRegistered() ? 'registered on Read' : 'not registered'}`);
|
|
98
152
|
console.log('');
|
|
153
|
+
if (!python) console.log(`Install the converter: ${doc2md.INSTALL_HINT}`);
|
|
99
154
|
console.log('Enable with: claude-token-saver doc2md on');
|
|
100
155
|
}
|
|
@@ -5,9 +5,34 @@
|
|
|
5
5
|
import { readFileSync } from 'node:fs';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { dirname, join } from 'node:path';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
8
11
|
import { readStdinJson } from '../stdin-payload.js';
|
|
9
12
|
import { debug } from '../debug.js';
|
|
10
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Whether the user turned doc2md on.
|
|
16
|
+
*
|
|
17
|
+
* Read from settings.json rather than from a config flag because the hook
|
|
18
|
+
* registration *is* the switch: a note telling the model how to convert
|
|
19
|
+
* documents is noise in a session where nothing will convert them.
|
|
20
|
+
*/
|
|
21
|
+
function doc2mdHookRegistered() {
|
|
22
|
+
try {
|
|
23
|
+
const { homedir } = require('node:os');
|
|
24
|
+
const settings = JSON.parse(readFileSync(join(homedir(), '.claude', 'settings.json'), 'utf8'));
|
|
25
|
+
return Object.values(settings?.hooks || {}).some((matchers) =>
|
|
26
|
+
(matchers || []).some((m) =>
|
|
27
|
+
(m.hooks || []).some((h) => typeof h.command === 'string' && h.command.includes('doc2md --hook')),
|
|
28
|
+
),
|
|
29
|
+
);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
debug('route-scan:doc2md-registered', e);
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
11
36
|
/** This package's own version — the baseline the upgrade offer compares against. */
|
|
12
37
|
function readPackageVersion() {
|
|
13
38
|
try {
|
|
@@ -218,6 +243,17 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
218
243
|
koreanBlock = koreanStyleInjection();
|
|
219
244
|
} catch (e) { debug('route-scan:korean-style', e); /* style is optional */ }
|
|
220
245
|
|
|
246
|
+
// doc2md's standing note, only when the user turned the feature on.
|
|
247
|
+
// Rides the same SessionStart round-trip as the style block for the same
|
|
248
|
+
// reason: one injection, one cached prefix. It carries the two things the
|
|
249
|
+
// model cannot work out for itself — that a binary-file refusal has a
|
|
250
|
+
// one-command answer, and that an attached document costs far more than
|
|
251
|
+
// the path to it.
|
|
252
|
+
let doc2mdBlock = null;
|
|
253
|
+
try {
|
|
254
|
+
if (doc2mdHookRegistered()) doc2mdBlock = require('../doc2md.cjs').sessionNote(lang);
|
|
255
|
+
} catch (e) { debug('route-scan:doc2md-note', e); /* the note is optional */ }
|
|
256
|
+
|
|
221
257
|
// Upgrade offer. A statusline cannot open a dialog, so session start is
|
|
222
258
|
// where the *asking* happens: the model gets one line telling it a newer
|
|
223
259
|
// version exists and to ask before installing anything. Cached read only
|
|
@@ -252,10 +288,12 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
252
288
|
|
|
253
289
|
if (open.length === 0 && reviewRules.length === 0) {
|
|
254
290
|
if (updateBlock) console.log(updateBlock);
|
|
291
|
+
if (doc2mdBlock) console.log(doc2mdBlock);
|
|
255
292
|
if (koreanBlock) console.log(koreanBlock);
|
|
256
293
|
return; // nothing else to inject
|
|
257
294
|
}
|
|
258
295
|
if (updateBlock) console.log(updateBlock);
|
|
296
|
+
if (doc2mdBlock) console.log(doc2mdBlock);
|
|
259
297
|
// This text is injected straight into the model's context, so it must
|
|
260
298
|
// follow the user's configured language — a Korean-only briefing in an
|
|
261
299
|
// English session steers the whole first response into Korean.
|
package/src/doc2md.cjs
CHANGED
|
@@ -158,6 +158,11 @@ function findInterpreter() {
|
|
|
158
158
|
const candidates = [];
|
|
159
159
|
if (process.env.CTS_DOC2MD_PYTHON) candidates.push(process.env.CTS_DOC2MD_PYTHON);
|
|
160
160
|
candidates.push(
|
|
161
|
+
// The tool's own venv, created by `doc2md install-converter`. First
|
|
162
|
+
// because it is the only one this tool controls: telling people to
|
|
163
|
+
// `pip install` into the system interpreter is how a token-saving CLI
|
|
164
|
+
// ends up owning a break in someone else's project.
|
|
165
|
+
managedPython(),
|
|
161
166
|
path.join(os.homedir(), '.local', 'share', 'uv', 'tools', 'markitdown', 'bin', 'python'),
|
|
162
167
|
path.join(os.homedir(), '.local', 'bin', 'markitdown-python'),
|
|
163
168
|
'python3',
|
|
@@ -178,6 +183,60 @@ function findInterpreter() {
|
|
|
178
183
|
|
|
179
184
|
const CONVERTER = path.join(__dirname, '..', 'presets', 'doc2md', 'convert.py');
|
|
180
185
|
|
|
186
|
+
/** Path to the interpreter inside the venv this tool manages. */
|
|
187
|
+
function managedPython() {
|
|
188
|
+
const dir = path.join(userDataDir(), 'doc2md-venv');
|
|
189
|
+
return process.platform === 'win32'
|
|
190
|
+
? path.join(dir, 'Scripts', 'python.exe')
|
|
191
|
+
: path.join(dir, 'bin', 'python');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const MARKITDOWN_SPEC = 'markitdown[pptx,pdf,xlsx,docx]';
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Build the managed venv and install markitdown into it.
|
|
198
|
+
*
|
|
199
|
+
* Kept behind an explicit command: creating a 300MB virtualenv is not
|
|
200
|
+
* something to do because somebody opened a spreadsheet once. But once asked
|
|
201
|
+
* for, it goes somewhere this tool owns, so uninstalling the CLI takes the
|
|
202
|
+
* whole thing with it and no system interpreter is touched.
|
|
203
|
+
*/
|
|
204
|
+
function installConverter({ onProgress = () => {} } = {}) {
|
|
205
|
+
const venv = path.join(userDataDir(), 'doc2md-venv');
|
|
206
|
+
const target = managedPython();
|
|
207
|
+
|
|
208
|
+
if (!fs.existsSync(target)) {
|
|
209
|
+
onProgress(`creating ${venv}`);
|
|
210
|
+
let created = false;
|
|
211
|
+
for (const base of ['python3', 'python']) {
|
|
212
|
+
const r = spawnSync(base, ['-m', 'venv', venv], { encoding: 'utf8', timeout: 180_000 });
|
|
213
|
+
if (r.status === 0) { created = true; break; }
|
|
214
|
+
}
|
|
215
|
+
if (!created) {
|
|
216
|
+
return { ok: false, reason: 'no-python', detail: 'no python3 with the venv module on PATH' };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
onProgress(`installing ${MARKITDOWN_SPEC}`);
|
|
221
|
+
const install = spawnSync(target, ['-m', 'pip', 'install', '--quiet', MARKITDOWN_SPEC], {
|
|
222
|
+
encoding: 'utf8',
|
|
223
|
+
timeout: 900_000,
|
|
224
|
+
});
|
|
225
|
+
if (install.status !== 0) {
|
|
226
|
+
return { ok: false, reason: 'pip-failed', detail: (install.stderr || '').slice(0, 400) };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// The probe is the actual acceptance test: pip can exit 0 and still leave an
|
|
230
|
+
// interpreter that cannot import what was asked for.
|
|
231
|
+
const probe = spawnSync(target, ['-c', 'import markitdown'], { timeout: 60_000, stdio: 'ignore' });
|
|
232
|
+
if (probe.status !== 0) {
|
|
233
|
+
return { ok: false, reason: 'import-failed', detail: 'installed, but markitdown does not import' };
|
|
234
|
+
}
|
|
235
|
+
interpreterCache = target;
|
|
236
|
+
clearNotice();
|
|
237
|
+
return { ok: true, python: target };
|
|
238
|
+
}
|
|
239
|
+
|
|
181
240
|
/**
|
|
182
241
|
* Convert one file. Returns `{ ok: true, cacheFile, meta }`, or
|
|
183
242
|
* `{ ok: false, reason, detail }` where reason is one of:
|
|
@@ -258,6 +317,18 @@ function noticeAlreadyShown() {
|
|
|
258
317
|
}
|
|
259
318
|
}
|
|
260
319
|
|
|
320
|
+
/**
|
|
321
|
+
* Forget that the notice was shown.
|
|
322
|
+
*
|
|
323
|
+
* Called after the converter is installed, so that if it later disappears the
|
|
324
|
+
* user is told once more instead of meeting permanent silence.
|
|
325
|
+
*/
|
|
326
|
+
function clearNotice() {
|
|
327
|
+
try {
|
|
328
|
+
fs.rmSync(noticePath(), { force: true });
|
|
329
|
+
} catch { /* nothing to forget */ }
|
|
330
|
+
}
|
|
331
|
+
|
|
261
332
|
function markNoticeShown() {
|
|
262
333
|
try {
|
|
263
334
|
fs.mkdirSync(userDataDir(), { recursive: true });
|
|
@@ -265,7 +336,7 @@ function markNoticeShown() {
|
|
|
265
336
|
} catch { /* an unwritable state dir just means the notice repeats */ }
|
|
266
337
|
}
|
|
267
338
|
|
|
268
|
-
const INSTALL_HINT = '
|
|
339
|
+
const INSTALL_HINT = 'claude-token-saver doc2md install-converter';
|
|
269
340
|
|
|
270
341
|
/**
|
|
271
342
|
* Decide what to tell Claude Code about one PreToolUse(Read) payload.
|
|
@@ -348,6 +419,119 @@ function decideForRead(context, opts = {}) {
|
|
|
348
419
|
return null;
|
|
349
420
|
}
|
|
350
421
|
|
|
422
|
+
/**
|
|
423
|
+
* Document paths mentioned in a prompt.
|
|
424
|
+
*
|
|
425
|
+
* This exists because the PreToolUse hook cannot reach the formats it was
|
|
426
|
+
* written for. Claude Code rejects pptx/xlsx/docx as binary *before* running
|
|
427
|
+
* the hook — verified: a `.pdf` Read fires the hook, a `.pptx` Read never
|
|
428
|
+
* does — so by the time doc2md could speak, the tool call is already refused.
|
|
429
|
+
* UserPromptSubmit runs earlier than any of that and sees the raw text, so a
|
|
430
|
+
* path the user typed can be converted before the model tries to open it.
|
|
431
|
+
*
|
|
432
|
+
* Quoted, `@`-prefixed and bare paths all count. Windows drive letters are
|
|
433
|
+
* matched too, since the rest of the module is path-agnostic.
|
|
434
|
+
*/
|
|
435
|
+
function documentPathsIn(text) {
|
|
436
|
+
if (typeof text !== 'string' || !text) return [];
|
|
437
|
+
const exts = TARGET_EXTENSIONS.map((e) => e.slice(1)).join('|');
|
|
438
|
+
const re = new RegExp(`[@'"\`]?((?:[A-Za-z]:)?[~./][^\\s'"\`]*\\.(?:${exts}))`, 'gi');
|
|
439
|
+
const found = [];
|
|
440
|
+
for (const m of text.matchAll(re)) {
|
|
441
|
+
const raw = m[1];
|
|
442
|
+
const abs = raw.startsWith('~') ? path.join(os.homedir(), raw.slice(1)) : path.resolve(raw);
|
|
443
|
+
if (!found.includes(abs)) found.push(abs);
|
|
444
|
+
}
|
|
445
|
+
return found;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// One prompt naming a dozen decks should not turn a keystroke into a minute of
|
|
449
|
+
// conversion. The rest are named in the note so nothing disappears quietly.
|
|
450
|
+
const MAX_PROMPT_CONVERSIONS = 3;
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Context to inject for a UserPromptSubmit payload, or null.
|
|
454
|
+
*
|
|
455
|
+
* Returns plain text because that is what this hook can give the model:
|
|
456
|
+
* stdout becomes context it can act on. There is no decision to make here —
|
|
457
|
+
* the prompt is not blocked, it is answered better.
|
|
458
|
+
*/
|
|
459
|
+
function contextForPrompt(payload, opts = {}) {
|
|
460
|
+
const lang = opts.lang === 'ko' ? 'ko' : 'en';
|
|
461
|
+
if (!payload || typeof payload.prompt !== 'string') return null;
|
|
462
|
+
const paths = documentPathsIn(payload.prompt).filter((p) => {
|
|
463
|
+
try { return fs.statSync(p).isFile(); } catch { return false; }
|
|
464
|
+
});
|
|
465
|
+
if (paths.length === 0) return null;
|
|
466
|
+
|
|
467
|
+
const lines = [];
|
|
468
|
+
const targets = paths.slice(0, MAX_PROMPT_CONVERSIONS);
|
|
469
|
+
for (const p of targets) {
|
|
470
|
+
const name = path.basename(p);
|
|
471
|
+
const result = convert(p, opts);
|
|
472
|
+
if (result.ok) {
|
|
473
|
+
lines.push(` ${name} → ${result.cacheFile}`);
|
|
474
|
+
if (result.meta && result.meta.note) lines.push(` ${result.meta.note}`);
|
|
475
|
+
if (result.meta && result.meta.clipped) {
|
|
476
|
+
lines.push(' 변환 결과가 너무 커서 뒷부분을 잘랐습니다. 전체가 필요하면 원본을 직접 다루십시오.');
|
|
477
|
+
}
|
|
478
|
+
} else if (result.reason === 'no-markitdown') {
|
|
479
|
+
lines.push(lang === 'ko'
|
|
480
|
+
? ` ${name}: 변환기가 없어 변환하지 못했습니다. 설치: ${INSTALL_HINT}`
|
|
481
|
+
: ` ${name}: no converter installed. Install it with: ${INSTALL_HINT}`);
|
|
482
|
+
} else if (result.reason === 'sensitive') {
|
|
483
|
+
lines.push(lang === 'ko'
|
|
484
|
+
? ` ${name}: 파일명이 민감 문서 패턴에 걸려 변환하지 않았습니다.`
|
|
485
|
+
: ` ${name}: skipped — the filename matches a sensitive-document pattern.`);
|
|
486
|
+
} else {
|
|
487
|
+
lines.push(lang === 'ko'
|
|
488
|
+
? ` ${name}: 변환하지 못했습니다 (${result.reason}).`
|
|
489
|
+
: ` ${name}: conversion failed (${result.reason}).`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (paths.length > targets.length) {
|
|
493
|
+
lines.push(lang === 'ko'
|
|
494
|
+
? ` (문서 ${paths.length}개 가운데 앞 ${targets.length}개만 변환했습니다. 나머지는 \`claude-token-saver doc2md <경로>\` 로 변환하십시오.)`
|
|
495
|
+
: ` (converted the first ${targets.length} of ${paths.length} documents; run \`claude-token-saver doc2md <path>\` for the rest.)`);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return lang === 'ko'
|
|
499
|
+
? [
|
|
500
|
+
'[doc2md] 이 프롬프트에 문서 경로가 있어 Markdown 으로 변환해 두었습니다.',
|
|
501
|
+
...lines,
|
|
502
|
+
'원본 대신 위 변환본을 Read 하십시오. pptx·xlsx·docx 는 Read 도구가 이진 파일이라며 거부하므로 원본을 직접 열 수 없습니다.',
|
|
503
|
+
'경로가 주어지지 않은 문서를 열어야 할 때는 `claude-token-saver doc2md <경로>` 를 실행해 변환본 경로를 얻으십시오.',
|
|
504
|
+
].join('\n')
|
|
505
|
+
: [
|
|
506
|
+
'[doc2md] This prompt names document paths, which have been converted to Markdown:',
|
|
507
|
+
...lines,
|
|
508
|
+
'Read the conversions above rather than the originals: Read refuses pptx/xlsx/docx as binary files, so the originals cannot be opened directly.',
|
|
509
|
+
'For a document whose path was not given here, run `claude-token-saver doc2md <path>` to get its conversion.',
|
|
510
|
+
].join('\n');
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* The standing note injected at session start.
|
|
515
|
+
*
|
|
516
|
+
* Two things the model cannot work out on its own: that attaching a document
|
|
517
|
+
* costs far more than naming its path, and that the binary-file refusal it
|
|
518
|
+
* will meet on a pptx has a one-command answer.
|
|
519
|
+
*/
|
|
520
|
+
function sessionNote(lang = 'en') {
|
|
521
|
+
if (lang === 'ko') {
|
|
522
|
+
return [
|
|
523
|
+
'[doc2md] 문서(pptx·xlsx·pdf·docx)를 다룰 때의 규칙입니다.',
|
|
524
|
+
' 문서를 열어야 하는데 Read 가 이진 파일이라며 거부하면, `claude-token-saver doc2md <경로>` 를 실행하고 그 결과로 나온 .md 를 Read 하십시오.',
|
|
525
|
+
' 사용자가 문서를 메시지에 직접 첨부했다면, 첨부는 내용 전체가 컨텍스트에 실려 토큰을 크게 씁니다. 다음부터는 첨부 대신 파일 경로를 적어 달라고 안내하십시오. 경로로 주면 변환본만 읽습니다.',
|
|
526
|
+
].join('\n');
|
|
527
|
+
}
|
|
528
|
+
return [
|
|
529
|
+
'[doc2md] Handling documents (pptx/xlsx/pdf/docx):',
|
|
530
|
+
' If Read refuses a document as a binary file, run `claude-token-saver doc2md <path>` and Read the .md it prints.',
|
|
531
|
+
' 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.',
|
|
532
|
+
].join('\n');
|
|
533
|
+
}
|
|
534
|
+
|
|
351
535
|
/** The decision rendered as the JSON Claude Code expects on stdout. */
|
|
352
536
|
function formatHookOutput(decision) {
|
|
353
537
|
if (!decision) return null;
|
|
@@ -364,11 +548,18 @@ module.exports = {
|
|
|
364
548
|
TARGET_EXTENSIONS,
|
|
365
549
|
MAX_SOURCE_BYTES,
|
|
366
550
|
INSTALL_HINT,
|
|
551
|
+
MARKITDOWN_SPEC,
|
|
552
|
+
managedPython,
|
|
553
|
+
installConverter,
|
|
554
|
+
clearNotice,
|
|
367
555
|
cacheDir,
|
|
368
556
|
cachePathFor,
|
|
369
557
|
metaPathFor,
|
|
370
558
|
isTargetPath,
|
|
371
559
|
isSensitivePath,
|
|
560
|
+
documentPathsIn,
|
|
561
|
+
contextForPrompt,
|
|
562
|
+
sessionNote,
|
|
372
563
|
findInterpreter,
|
|
373
564
|
readCache,
|
|
374
565
|
writeCache,
|
package/src/installer.js
CHANGED
|
@@ -358,6 +358,7 @@ export function removeKoreanLintHook() {
|
|
|
358
358
|
// actually bounds the work; the timeout is only a backstop.
|
|
359
359
|
// Installed by `doc2md on`, removed by `doc2md off`. Idempotent.
|
|
360
360
|
const DOC2MD_HOOK_COMMAND = 'claude-token-saver doc2md --hook';
|
|
361
|
+
const DOC2MD_PROMPT_HOOK_COMMAND = 'claude-token-saver doc2md --hook-prompt';
|
|
361
362
|
|
|
362
363
|
export function installDoc2mdHook() {
|
|
363
364
|
const dir = claudeUserDir();
|
|
@@ -388,6 +389,23 @@ export function installDoc2mdHook() {
|
|
|
388
389
|
hooks: [{ type: 'command', command: DOC2MD_HOOK_COMMAND }],
|
|
389
390
|
});
|
|
390
391
|
settings.hooks.PreToolUse = list;
|
|
392
|
+
|
|
393
|
+
// The Read hook alone covers only PDFs. Claude Code refuses pptx/xlsx/docx
|
|
394
|
+
// as binary before any PreToolUse hook runs, so for exactly the formats this
|
|
395
|
+
// feature exists for, the tool call is dead before doc2md is consulted.
|
|
396
|
+
// UserPromptSubmit runs earlier and sees the raw prompt text, which is where
|
|
397
|
+
// a path the user typed can still be turned into Markdown.
|
|
398
|
+
if (settings.hooks.UserPromptSubmit === undefined || Array.isArray(settings.hooks.UserPromptSubmit)) {
|
|
399
|
+
const prompts = Array.isArray(settings.hooks.UserPromptSubmit) ? settings.hooks.UserPromptSubmit : [];
|
|
400
|
+
const hasPromptHook = prompts.some((m) =>
|
|
401
|
+
Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('doc2md --hook-prompt')),
|
|
402
|
+
);
|
|
403
|
+
if (!hasPromptHook) {
|
|
404
|
+
prompts.push({ hooks: [{ type: 'command', command: DOC2MD_PROMPT_HOOK_COMMAND }] });
|
|
405
|
+
settings.hooks.UserPromptSubmit = prompts;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
391
409
|
writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
392
410
|
return { path: file, action: 'created' };
|
|
393
411
|
}
|
|
@@ -401,16 +419,22 @@ export function removeDoc2mdHook() {
|
|
|
401
419
|
} catch (e) {
|
|
402
420
|
return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
|
|
403
421
|
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
//
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
422
|
+
// Both entries go, and only this tool's own: anything else registered under
|
|
423
|
+
// either event stays exactly where the user put it. The substring covers
|
|
424
|
+
// `--hook` and `--hook-prompt` alike.
|
|
425
|
+
let touched = false;
|
|
426
|
+
for (const event of ['PreToolUse', 'UserPromptSubmit']) {
|
|
427
|
+
const list = settings?.hooks?.[event];
|
|
428
|
+
if (!Array.isArray(list)) continue;
|
|
429
|
+
const kept = list.filter((m) =>
|
|
430
|
+
!(Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('doc2md --hook'))),
|
|
431
|
+
);
|
|
432
|
+
if (kept.length === list.length) continue;
|
|
433
|
+
touched = true;
|
|
434
|
+
if (kept.length === 0) delete settings.hooks[event];
|
|
435
|
+
else settings.hooks[event] = kept;
|
|
436
|
+
}
|
|
437
|
+
if (!touched) return { path: file, action: 'absent' };
|
|
414
438
|
writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
415
439
|
return { path: file, action: 'removed' };
|
|
416
440
|
}
|