claude-token-saver 3.25.1 โ 3.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +49 -0
- package/README.md +49 -0
- package/bin/cli.js +30 -0
- package/package.json +1 -1
- package/presets/doc2md/convert.py +188 -0
- package/src/advice.js +34 -2
- package/src/commands/doc2md.js +100 -0
- package/src/commands/mode.js +1 -0
- package/src/commands/route-scan.js +15 -1
- package/src/config.js +15 -0
- package/src/cost.js +6 -0
- package/src/doc2md.cjs +378 -0
- package/src/formatters/statusline.js +37 -7
- package/src/formatters/table.js +8 -1
- package/src/installer.js +69 -0
- package/src/korean-style.js +11 -1
- package/src/model-alias.js +15 -3
- package/src/parser.js +24 -1
- package/src/session-cache.js +4 -1
- package/src/stats.js +22 -1
package/README.en.md
CHANGED
|
@@ -62,6 +62,7 @@ By run (newest first):
|
|
|
62
62
|
| ๐จ **No surprise rate limits** | Warns when the 5H/7D window hits 90%; `handoff` backs up your work |
|
|
63
63
|
| ๐ง **Cache waste detection** | Hit rate, TTL, 1M-context detection โ spikes diagnosed with issue codes |
|
|
64
64
|
| ๐ฐ๐ท **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)) |
|
|
65
66
|
|
|
66
67
|
## Not a router โ 60 seconds
|
|
67
68
|
|
|
@@ -163,6 +164,9 @@ Run these in your shell (inside Claude Code, the `/claude-token-saver` Skill is
|
|
|
163
164
|
| `claude-token-saver korean on\|off\|status` | Inject Korean writing guidance at session start and install the write-time check (below) |
|
|
164
165
|
| `claude-token-saver korean lint block\|warn\|off` | How the write-time check handles findings |
|
|
165
166
|
| `claude-token-saver korean lint scope all\|prose` | Check every text file, or documents only |
|
|
167
|
+
| `claude-token-saver doc2md on\|off` | Convert attached documents to Markdown before the model reads them (below) |
|
|
168
|
+
| `claude-token-saver doc2md <file>` | Convert one file by hand. Diagnostic: it prints the refusal reason instead of swallowing it |
|
|
169
|
+
| `claude-token-saver mode ttl=5m\|1h\|auto` | Pin the cache TTL bucket. The default `auto` trusts the measured split, then falls back to gateway detection |
|
|
166
170
|
| `claude-token-saver --version` | Print the installed version |
|
|
167
171
|
| `claude-token-saver update-check` | Is a newer version out? (`--refresh` to ask now, `--dismiss` to mute this version's offer) |
|
|
168
172
|
| `claude-token-saver upgrade` | Install the latest release with the package manager that installed this copy (`--print` shows the command only) |
|
|
@@ -374,6 +378,45 @@ Installs with nobody attached โ npm `postinstall`, CI, piped stdin โ skip th
|
|
|
374
378
|
> The guidance text comes from [fluent-korean](https://github.com/snflkd/fluent-korean). Copyright (c) 2026 snflkd, MIT License.
|
|
375
379
|
> 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`.
|
|
376
380
|
|
|
381
|
+
## ๐ doc2md โ attached documents become Markdown before the model reads them
|
|
382
|
+
|
|
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
|
+
|
|
385
|
+
```bash
|
|
386
|
+
pip install "markitdown[pptx,pdf,xlsx,docx]" # the converter is a Python package
|
|
387
|
+
claude-token-saver doc2md on # register the Read hook
|
|
388
|
+
claude-token-saver doc2md report.pptx # convert by hand and see the result
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
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
|
+
|
|
393
|
+
Several things it deliberately does not do:
|
|
394
|
+
|
|
395
|
+
- **Images are not converted.** markitdown returns nothing for them, and OCR misread resource names in testing (`c5.xlarge` as `c.xlarge`). In a document where those names *are* the content, wrong text is worse than none. The model reads images natively anyway.
|
|
396
|
+
- **A missing markitdown never fails silently.** The install command is shown once, then the original `Read` proceeds untouched. Repeating the notice on every read would be its own nuisance; saying nothing is how a broken converter hides.
|
|
397
|
+
- **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
|
+
- **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
|
+
- **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.
|
|
400
|
+
|
|
401
|
+
`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.
|
|
402
|
+
|
|
403
|
+
## ๐ Behind a gateway (Bedrock / Vertex)
|
|
404
|
+
|
|
405
|
+
A gateway reports the cache-creation total but never the 5m/1h split. That left the tool unable to tell "nothing cached yet" from "this provider does not say", and the fallback assumed an hour โ for a window that is really five minutes on Bedrock, overstating it twelvefold.
|
|
406
|
+
|
|
407
|
+
Since v3.26.0 the gateway is detected from the model ids in the transcript, which fixes:
|
|
408
|
+
|
|
409
|
+
- The countdown falls back to 5 minutes, labelled `5m?`. Three grades of certainty get three labels: measured (`5m`), inferred (`5m?`), unknown (`?`).
|
|
410
|
+
- In a 5-minute bucket the countdown colour follows absolute time rather than a percentage. 30% of five minutes is 90 seconds, and green there promised comfort that was not there.
|
|
411
|
+
- The `โ 5m TTL` warning finally reaches these users โ with different advice, since no subscription plan changes a gateway's TTL.
|
|
412
|
+
- `Extra cost if 5m-only` is only asked of sessions that have 1h writes to lose. Elsewhere the arithmetically honest `+$0` read as an endorsement of the bucket you are already stuck in.
|
|
413
|
+
- Delegated runs dropped for an unpriceable model id show as `๐ N unresolved` instead of nothing, which used to be indistinguishable from never having delegated.
|
|
414
|
+
- Environment variables set to a `foundation-model` ARN now resolve. An opaque `application-inference-profile` id still does not: guessing at it is how wrong prices enter the ledger.
|
|
415
|
+
|
|
416
|
+
If the detection is wrong, pin it with `claude-token-saver mode ttl=5m` (or `ttl=1h`). An explicit value outranks the measurement.
|
|
417
|
+
|
|
418
|
+
One related non-bug: if your session model is already sonnet, a sonnet-delegation (T1) rule can never save anything, because there is no price gap to capture. That is correct, but `route-scan rules` displayed it identically to "no delegations yet", so it now says outright that the rule does not apply at the current default model.
|
|
419
|
+
|
|
377
420
|
## Spike issue codes
|
|
378
421
|
|
|
379
422
|
| Code | Meaning |
|
|
@@ -454,6 +497,12 @@ Also update `statusLine.command` in `~/.claude/settings.json` to `claude-token-s
|
|
|
454
497
|
|
|
455
498
|
## Release notes
|
|
456
499
|
|
|
500
|
+
### v3.26.0 (2026-09-04)
|
|
501
|
+
- **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
|
+
- **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.
|
|
503
|
+
- **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.
|
|
504
|
+
- **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.
|
|
505
|
+
|
|
457
506
|
### v3.25.0 (2026-09-04)
|
|
458
507
|
- **The statusline now shows which version is running** โ until now the version lived only in the table report's footer, so "which version am I on" meant running a full report. A `--version` flag was added alongside it.
|
|
459
508
|
- **A new release asks at session start** โ a statusline cannot open a dialog, so telling and asking are split. The statusline only reports (`โฌ v3.24.0 โ 3.25.0`); the SessionStart hook injects a line telling the model to ask the user whether to upgrade. On yes it runs `claude-token-saver upgrade`, which uses the package manager that installed this copy; on no, `update-check --dismiss` mutes that version until a newer one ships.
|
package/README.md
CHANGED
|
@@ -62,6 +62,7 @@ $ claude-token-saver route-scan savings # ๋ชจ๋ ๊ธ์ก์ ๋ฃฐ ๋จ์๊น์ง
|
|
|
62
62
|
| ๐จ **ํ๋ ์ด๊ณผ ์๋ฐฉ** | 5์๊ฐยท7์ผ rate-limit ์๋๊ฐ 90%์ ๋ฟ์ผ๋ฉด ๊ฒฝ๊ณ ํ๊ณ , `handoff`๋ก ์์
์ ๋ฐฑ์
ํฉ๋๋ค |
|
|
63
63
|
| ๐ง **์บ์ ๋ญ๋น ๊ฐ์ง** | ํํธ์จยทTTLยท1M ์ปจํ
์คํธ๋ฅผ ๊ฐ์งํด ํ ํฐ ๊ธ์ฆ ์์ธ์ ์ฝ๋๋ก ์ง๋จํฉ๋๋ค |
|
|
64
64
|
| ๐ฐ๐ท **ํ๊ตญ์ด ๋ฌธ์ฒด ๊ต์ ** | ํ๊ตญ์ด ํ๊ฒฝ์ด๋ฉด ์๋์ผ๋ก ์ผ์ง๋๋ค ([์๋](#-ํ๊ตญ์ด-๋ฌธ์ฒด-์ง์นจ)) |
|
|
65
|
+
| ๐ **๋ฌธ์ ์๋ ๋ณํ** | pptxยทxlsxยทpdfยทdocx ๋ฅผ ์ฝ๊ธฐ ์ ์ Markdown ์ผ๋ก ๋ฐ๊ฟ ์ฝ์ง ๋ชปํ๋ ๋ฐ์ดํธ๊ฐ ์ปจํ
์คํธ์ ์ฌ๋ผ๊ฐ์ง ์๊ฒ ํฉ๋๋ค ([์๋](#-doc2md-์ฒจ๋ถ-๋ฌธ์๋ฅผ-์ฝ๊ธฐ-์ ์-markdown-์ผ๋ก-๋ฐ๊ฟ๋๋ค)) |
|
|
65
66
|
|
|
66
67
|
## ๋ผ์ฐํฐ๊ฐ ์๋๋๋ค: 60์ด ์ค๋ช
|
|
67
68
|
|
|
@@ -162,6 +163,9 @@ Claude Code ์์์ `/claude-token-saver` Skill์ ์คํํ๊ฑฐ๋, ์นฉ์ ์
|
|
|
162
163
|
| `claude-token-saver korean on\|off\|status` | ํ๊ตญ์ด ๋ฌธ์ฒด ์ง์นจ์ ์ธ์
์์ ์ ์ฃผ์
ํ๊ณ , ์ฐ๊ธฐ ์์ ๊ฒ์ฌ๋ฅผ ํจ๊ป ์ค์นํฉ๋๋ค (์๋ ์ฐธ๊ณ ) |
|
|
163
164
|
| `claude-token-saver korean lint block\|warn\|off` | ์ฐ๊ธฐ ์์ ๊ฒ์ฌ๊ฐ ์๋ฐ์ ์ด๋ป๊ฒ ์ฒ๋ฆฌํ ์ง ์ ํฉ๋๋ค |
|
|
164
165
|
| `claude-token-saver korean lint scope all\|prose` | ๊ฒ์ฌ ๋ฒ์๋ฅผ ๋ชจ๋ ํ
์คํธ ํ์ผ๊ณผ ๋ฌธ์ ์ ์ฉ ์ฌ์ด์์ ๊ณ ๋ฆ
๋๋ค |
|
|
166
|
+
| `claude-token-saver doc2md on\|off` | ์ฒจ๋ถ ๋ฌธ์๋ฅผ ๋ชจ๋ธ์ด ์ฝ๊ธฐ ์ ์ Markdown ์ผ๋ก ๋ณํํฉ๋๋ค (์๋ ์ฐธ๊ณ ) |
|
|
167
|
+
| `claude-token-saver doc2md <ํ์ผ>` | ํ์ผ ํ๋๋ฅผ ์ง์ ๋ณํํฉ๋๋ค. ์ง๋จ ์ฉ๋์ด๋ฉฐ ์คํจ ์ด์ ๋ฅผ ๊ทธ๋๋ก ์ถ๋ ฅํฉ๋๋ค |
|
|
168
|
+
| `claude-token-saver mode ttl=5m\|1h\|auto` | ์บ์ TTL ๋ฒํท์ ์ง์ ์ง์ ํฉ๋๋ค. ๊ธฐ๋ณธ๊ฐ `auto`๋ ์ค์ธก๊ฐ์ ๋จผ์ ๋ณด๊ณ , ์ค์ธก๊ฐ์ด ์์ผ๋ฉด ๊ฒ์ดํธ์จ์ด ์ฌ๋ถ๋ก ํ์ ํฉ๋๋ค |
|
|
165
169
|
| `claude-token-saver --version` | ์ค์น๋ ๋ฒ์ ์ ์ถ๋ ฅํฉ๋๋ค |
|
|
166
170
|
| `claude-token-saver update-check` | ์ ๋ฒ์ ์ด ์๋์ง ํ์ธํฉ๋๋ค (`--refresh`๋ก ์ฆ์ ์กฐํ, `--dismiss`๋ก ๊ทธ ๋ฒ์ ์๋ด ๋๊ธฐ) |
|
|
167
171
|
| `claude-token-saver upgrade` | ์ค์น ๊ฒฝ๋ก์ ๋ง๋ ๋ช
๋ น์ผ๋ก ์ต์ ๋ฒ์ ์ ์ค์นํฉ๋๋ค (`--print`๋ก ์คํ ์์ด ๋ช
๋ น๋ง ํ์ธ) |
|
|
@@ -364,6 +368,45 @@ npm์ `postinstall`์ด๋ CI์ฒ๋ผ ์ฌ๋์ด ๋ถ์ด ์์ง ์์ ์ค์น์์
|
|
|
364
368
|
> ์ง์นจ ์๋ฌธ์ [fluent-korean](https://github.com/snflkd/fluent-korean)์์ ๊ฐ์ ธ์์ต๋๋ค. Copyright (c) 2026 snflkd, MIT License.
|
|
365
369
|
> ์๋ฌธ์ ์์ ํ์ง ์์๊ณ output style ํ๋ฐํธ๋งคํฐ๋ง ์ ๊ฑฐํ์ต๋๋ค. ๋ผ์ด์ ์ค ์ ๋ฌธ์ ํจํค์ง์ `presets/korean-style/LICENSE-fluent-korean`์ ํจ๊ป ๋ฐฐํฌํฉ๋๋ค.
|
|
366
370
|
|
|
371
|
+
## ๐ doc2md: ์ฒจ๋ถ ๋ฌธ์๋ฅผ ์ฝ๊ธฐ ์ ์ Markdown ์ผ๋ก ๋ฐ๊ฟ๋๋ค
|
|
372
|
+
|
|
373
|
+
pptxยทxlsxยทpdfยทdocx ๋ฅผ ๊ทธ๋๋ก `Read` ํ๋ฉด ๋ชจ๋ธ์ด ์ฝ์ง ๋ชปํ๋ ๋ฐ์ดํธ๊ฐ ์ปจํ
์คํธ์ ๊ทธ๋๋ก ์ฌ๋ผ๊ฐ๋๋ค. ์ด ๊ธฐ๋ฅ์ ๊ทธ `Read` ๋ฅผ ๊ฐ๋ก์ฑ ํ์ผ์ ํ ๋ฒ ๋ณํํด ๋๊ณ , ์๋ณธ ๋์ ๋ณํ๋ณธ์ ์ฝ๊ฒ ํฉ๋๋ค.
|
|
374
|
+
|
|
375
|
+
```bash
|
|
376
|
+
pip install "markitdown[pptx,pdf,xlsx,docx]" # ๋ณํ๊ธฐ๋ ํ์ด์ฌ ํจํค์ง์
๋๋ค
|
|
377
|
+
claude-token-saver doc2md on # Read ํ
๋ฑ๋ก
|
|
378
|
+
claude-token-saver doc2md ๋ณด๊ณ ์.pptx # ์ง์ ๋ณํํด ๊ฒฐ๊ณผ ํ์ธ
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
๋ณํ์ [markitdown](https://github.com/microsoft/markitdown)์ด ๋ด๋นํ๋ฉฐ, ์ฌ๋ผ์ด๋ ๋ฒํธ์ ์ ๋ชฉ ๊ณ์ธต, ํ, ๋ฐํ์ ๋
ธํธ, ์ํธ ๊ตฌ๋ถ์ด ๋ชจ๋ ๋จ์ต๋๋ค. ํ๊ธ๋ ๊นจ์ง์ง ์์ต๋๋ค.
|
|
382
|
+
|
|
383
|
+
๋ช ๊ฐ์ง๋ ์๋์ ์ผ๋ก ํ์ง ์์ต๋๋ค.
|
|
384
|
+
|
|
385
|
+
- **์ด๋ฏธ์ง๋ ๋ณํํ์ง ์์ต๋๋ค.** markitdown ์ด ๋น ๊ฒฐ๊ณผ๋ฅผ ๋๋ ค์ฃผ๊ณ , OCR ์ ์ค์ธก์์ ๋ฆฌ์์ค ์ด๋ฆ์ ํ๋ฆฌ๊ฒ ์ฝ์์ต๋๋ค(`c5.xlarge` ๋ฅผ `c.xlarge` ๋ก). ์ด๋ฆ ์์ฒด๊ฐ ๋ด์ฉ์ธ ๋ฌธ์์์๋ ํ
์คํธ๊ฐ ์๋๋๋ง ๋ชปํฉ๋๋ค. ๋ชจ๋ธ์ด ์ด๋ฏธ์ง๋ ์ง์ ์ฝ์ต๋๋ค.
|
|
386
|
+
- **markitdown ์ด ์์ผ๋ฉด ์กฐ์ฉํ ์คํจํ์ง ์์ต๋๋ค.** ์ค์น ๋ช
๋ น์ ํ ๋ฒ ์๋ดํ ๋ค ์๋ณธ `Read` ๋ฅผ ๊ทธ๋๋ก ํต๊ณผ์ํต๋๋ค. ๋งค๋ฒ ์๋ฆฌ๋ฉด ๊ทธ๊ฒ๋๋ก ๋ฐฉํด๊ฐ ๋๊ณ , ์๋ฌด ๋ง๋ ํ์ง ์์ผ๋ฉด ๊ณ ์ฅ์ ์จ๊ธฐ๊ฒ ๋ฉ๋๋ค.
|
|
387
|
+
- **๋ณํ๋ณธ์ ํ๋ก์ ํธ ์์ ๋จ๊ธฐ์ง ์์ต๋๋ค.** ๋๊ตฌ์ ์ํ ๋๋ ํฐ๋ฆฌ ์๋ ๊ถํ `0700` ์ผ๋ก ์ ์ฅํ๋ฏ๋ก `.gitignore` ์ ๋ฌด์์ ์ถ๊ฐํ ํ์๊ฐ ์์ต๋๋ค. ํ์ผ๋ช
์ด ๊ธ์ฌยท๊ณ์ฝยท๊ฐ์ธ์ ๋ณด ๊ฐ์ ํจํด์ ๊ฑธ๋ฆฌ๋ฉด ์์ ๋ณํํ์ง ์์ต๋๋ค.
|
|
388
|
+
- **์์ถ ํญํ์ ๋ง์ต๋๋ค.** pptxยทxlsxยทdocx ๋ zip ์ปจํ
์ด๋์
๋๋ค. ์ ์ธ๋ ํฌ๊ธฐ๋ฅผ ๋จผ์ ๊ฑธ๋ฌ ๋ด๊ณ , ์ ์ธ์ ์กฐ์๋ ์ ์์ผ๋ฏ๋ก ์ค์ ํด์ ๋ฐ์ดํธ๋ ์ํ๊ณผ ๋์กฐํฉ๋๋ค.
|
|
389
|
+
- **์์
์ ํ ์๋ก ์๋ฆ
๋๋ค.** ๋ณํ ์๊ฐ์ ํ์ผ ํฌ๊ธฐ๊ฐ ์๋๋ผ ํ ์๋ฅผ ๋ฐ๋ฆ
๋๋ค(์ค์ธก: PDF 6.3MB 0.9์ด, ์์
5.8MB 47.75์ด). 5๋ง ํ์ ๋์ผ๋ฉด ์๋ถ๋ถ๋ง ๋ณํํ๊ณ , **์๋๋ค๋ ์ฌ์ค๊ณผ ์ ์ฒด ํ ์๋ฅผ ์๋ด์ ํจ๊ป ์ ์ต๋๋ค.**
|
|
390
|
+
|
|
391
|
+
`claude-token-saver doc2md --clean` ์ผ๋ก ๋ณํ ์บ์๋ฅผ ๋น์ฐ๊ณ , `doc2md off` ๋ก ํ
์ ์ ๊ฑฐํฉ๋๋ค. ํ
ํด์ ๋ ์๊ธฐ ํญ๋ชฉ๋ง ๊ณจ๋ผ ์ง์ฐ๋ฏ๋ก `PreToolUse` ์ ๋ฑ๋กํด ๋ ๋ค๋ฅธ ํ
์ ๊ทธ๋๋ก ๋จ์ต๋๋ค.
|
|
392
|
+
|
|
393
|
+
## ๐ BedrockยทVertex ๊ฒฝ์ ํ๊ฒฝ
|
|
394
|
+
|
|
395
|
+
๊ฒ์ดํธ์จ์ด๋ฅผ ๊ฑฐ์น๋ฉด ์๋ต์ด ์บ์ ์ฐ๊ธฐ ํฉ๊ณ๋ง ๋ด๋ ค๋ณด๋ด๊ณ 5๋ถยท1์๊ฐ ๋ฒํท๋ณ ๋ถํด ๊ฐ์ ์ฑ์ฐ์ง ์์ต๋๋ค. ๊ทธ๋์ ์ด ๋๊ตฌ๊ฐ "์บ์ ์ฐ๊ธฐ๊ฐ ์์ง ์๋ค"์ "์ด ์ ๊ณต์๋ ์๋ ค ์ฃผ์ง ์๋๋ค"๋ฅผ ๊ตฌ๋ณํ์ง ๋ชปํ๊ณ , ํ์ ๋ถ๊ฐ์ผ ๋ 1์๊ฐ์ ๊ธฐ๋ณธ๊ฐ์ผ๋ก ์ก์์ต๋๋ค. Bedrock ์ 5๋ถ ๋ฒํท๋ง ์ ๊ณตํ๋ฏ๋ก ๋จ์ ์๊ฐ์ด ์ต๋ 12๋ฐฐ๋ก ๋ถํ์ด ๋ณด์์ต๋๋ค.
|
|
396
|
+
|
|
397
|
+
v3.26.0๋ถํฐ ํธ๋์คํฌ๋ฆฝํธ์ ๋ชจ๋ธ ID๋ก ๊ฒ์ดํธ์จ์ด๋ฅผ ๊ฐ์งํด ๋ค์์ ๋ฐ๋ก์ก์ต๋๋ค.
|
|
398
|
+
|
|
399
|
+
- ํ์ ๋ถ๊ฐ์ผ ๋์ ์นด์ดํธ๋ค์ด ๊ธฐ๋ณธ๊ฐ์ด 5๋ถ์ด ๋๊ณ , ๋ฒํท ๋ผ๋ฒจ์ด `5m?` ๋ก ํ์๋ฉ๋๋ค. ์ค์ธก๊ฐ์ `5m`, ์ถ์ ์ `5m?`, ๊ทผ๊ฑฐ ์์์ `?` ๋ก ์ธ ๋จ๊ณ๋ฅผ ๊ตฌ๋ถํฉ๋๋ค.
|
|
400
|
+
- 5๋ถ ๋ฒํท์์๋ ์นด์ดํธ๋ค์ด ์์ด ๋น์จ์ด ์๋๋ผ ์ ๋ ์๊ฐ์ ๋ฐ๋ฆ
๋๋ค. 5๋ถ์ 30%๋ 90์ด์ฌ์, ์ด๋ก์ด ์ฃผ๋ ์ฌ์ ๊ฐ ์ค์ ์ ์ด๊ธ๋ฌ์ต๋๋ค.
|
|
401
|
+
- `โ 5m TTL` ๊ฒฝ๊ณ ๊ฐ ์ด ํ๊ฒฝ์๋ ๋๋ฌํฉ๋๋ค. ๋ค๋ง ์กฐ์ธ ๋ฌธ๊ตฌ๋ ๋ค๋ฆ
๋๋ค. ๊ตฌ๋
ํ๋์ ๋ฐ๊ฟ๋ ํด์๋์ง ์๋ ํ๊ฒฝ์ด๋ฏ๋ก ํ๋ ์ ํ์ ๊ถํ์ง ์์ต๋๋ค.
|
|
402
|
+
- `Extra cost if 5m-only` ๋ 1์๊ฐ ์ฐ๊ธฐ๊ฐ ์๋ ๊ฒฝ์ฐ์๋ง ๋ฌป์ต๋๋ค. ์ด๋ฏธ 5๋ถ ์ ์ฉ์ธ ํ๊ฒฝ์์๋ ์ง๋ฌธ ์์ฒด๊ฐ ์ฑ๋ฆฝํ์ง ์์ `+$0` ์ด ์๋ชป ์ฝํ์ต๋๋ค.
|
|
403
|
+
- ์์ ๊ฑด์ด ๋ชจ๋ธ ID ํด์ ์คํจ๋ก ๋ฒ๋ ค์ก์ผ๋ฉด statusline ์ `๐ N unresolved` ๋ก ์๋ฆฝ๋๋ค. ์ด์ ์๋ "์์ํ ์ ์์"๊ณผ ํ๋ฉด์ ๊ตฌ๋ณ๋์ง ์์์ต๋๋ค.
|
|
404
|
+
- ํ๊ฒฝ๋ณ์๋ฅผ `foundation-model` ARN ์ผ๋ก ์ง์ ํ ๊ฒฝ์ฐ์๋ ๋ชจ๋ธ์ ํด์ํฉ๋๋ค. ์ด๋ฆ์ ๋ด๊ณ ์์ง ์์ `application-inference-profile` ID ๋ ๊ทธ๋๋ก ๊ฑฐ๋ถํฉ๋๋ค. ๊ฐ์ ์ถ์ธกํด ๋ฃ์ผ๋ฉด ์์ฅ์ ํ๋ฆฐ ๊ธ์ก์ด ๋ค์ด๊ฐ๊ธฐ ๋๋ฌธ์
๋๋ค.
|
|
405
|
+
|
|
406
|
+
๊ฐ์ง๊ฐ ํ๋ฆฌ๋ฉด `claude-token-saver mode ttl=5m`(๋๋ `ttl=1h`)๋ก ์ง์ ์ง์ ํ ์ ์์ต๋๋ค. ์ง์ ๊ฐ์ด ์ค์ธก๊ฐ๋ณด๋ค ์ฐ์ ํฉ๋๋ค.
|
|
407
|
+
|
|
408
|
+
์ธ์
๊ธฐ๋ณธ ๋ชจ๋ธ์ด sonnet ์ด๋ฉด sonnet ์์ ๊ท์น(T1)์ ๊ตฌ์กฐ์ ์ผ๋ก ์ ๊ฐ์ด 0์
๋๋ค. ๊ฐ์ ๊ธ์ผ๋ก ๋ด๋ ค๋ณด๋ด ๋ด์ผ ์ฐจ์ก์ด ์๊ธฐ ๋๋ฌธ์ด๋ฉฐ ์ด๋ ์ ์ ๋์์
๋๋ค. ๋ค๋ง `route-scan rules` ๊ฐ ์ด ๊ฒฝ์ฐ๋ฅผ "์์ง ์์ ์์"๊ณผ ๊ฐ์ ๋ฌธ๊ตฌ๋ก ํ์ํด ๊ณ ์ฅ์ฒ๋ผ ๋ณด์์ผ๋ฏ๋ก, ์ด์ ํ์ฌ ๊ธฐ๋ณธ ๋ชจ๋ธ ๊ธฐ์ค์ผ๋ก ์ ์ฉ๋์ง ์๋๋ค๋ ์ฌ์ค์ ๋ฐ๋ก ์ ์ต๋๋ค.
|
|
409
|
+
|
|
367
410
|
## ํ ํฐ ๊ธ์ฆ ์์ธ ์ฝ๋
|
|
368
411
|
|
|
369
412
|
| ์ฝ๋ | ์๋ฏธ |
|
|
@@ -420,6 +463,12 @@ npm uninstall -g claude-cache-monitor && npm i -g claude-token-saver
|
|
|
420
463
|
|
|
421
464
|
## ๋ฆด๋ฆฌ์ค ๋
ธํธ
|
|
422
465
|
|
|
466
|
+
### v3.26.0 (2026-09-04)
|
|
467
|
+
- **์ฒจ๋ถ ๋ฌธ์๋ฅผ ์ฝ๊ธฐ ์ ์ Markdown ์ผ๋ก ๋ณํํฉ๋๋ค.** pptxยทxlsxยทpdfยทdocx ๋ฅผ ๊ทธ๋๋ก `Read` ํ๋ฉด ๋ชจ๋ธ์ด ์ฝ์ง ๋ชปํ๋ ๋ฐ์ดํธ๊ฐ ์ปจํ
์คํธ์ ์ฌ๋ผ๊ฐ๋๋ค. `doc2md on` ์ผ๋ก `Read` ํ
์ ๋ฑ๋กํ๋ฉด ํ์ผ์ ํ ๋ฒ ๋ณํํด ์บ์์ ๋๊ณ ๋ณํ๋ณธ์ ์ฝ๊ฒ ํฉ๋๋ค. ๋ณํ๊ธฐ๊ฐ ์์ผ๋ฉด ์๋ด๋ฅผ ํ ๋ฒ๋ง ํ๊ณ ์๋ณธ `Read` ๋ฅผ ํต๊ณผ์ํค๋ฉฐ, ์์ถ ํญํ์ ๋ง๊ณ , 5๋ง ํ์ด ๋๋ ์์
์ ์๋ถ๋ถ๋ง ๋ณํํ ๋ค ์๋๋ค๋ ์ฌ์ค์ ํจ๊ป ์๋ฆฝ๋๋ค. ์์ธํ ๋ด์ฉ์ [doc2md](#-doc2md-์ฒจ๋ถ-๋ฌธ์๋ฅผ-์ฝ๊ธฐ-์ ์-markdown-์ผ๋ก-๋ฐ๊ฟ๋๋ค) ์ ์ ์ฐธ๊ณ ํ์ญ์์ค.
|
|
468
|
+
- **BedrockยทVertex ๊ฒฝ์ ํ๊ฒฝ์ TTL ํ์๋ฅผ ๋ฐ๋ก์ก์์ต๋๋ค.** ๊ฒ์ดํธ์จ์ด๋ ๋ฒํท๋ณ ๋ถํด ๊ฐ์ ๋ด๋ ค๋ณด๋ด์ง ์๋๋ฐ, ํ์ ๋ถ๊ฐ์ผ ๋ 1์๊ฐ์ ๊ธฐ๋ณธ๊ฐ์ผ๋ก ์ก๊ณ ์์์ต๋๋ค. 5๋ถ ๋ฒํท๋ง ์ ๊ณตํ๋ ํ๊ฒฝ์์ ๋จ์ ์๊ฐ์ด ์ต๋ 12๋ฐฐ๋ก ๋ถํ์ด ๋ณด์์ต๋๋ค. ์ด์ ๋ชจ๋ธ ID๋ก ๊ฒ์ดํธ์จ์ด๋ฅผ ๊ฐ์งํด 5๋ถ์ ๊ธฐ๋ณธ๊ฐ์ผ๋ก ์ฐ๊ณ , ๋ผ๋ฒจ์ `5m?` ๋ก ์ ์ด ์ถ์ ์์ ๋ฐํ๋๋ค. `mode ttl=5m` ์ผ๋ก ์ง์ ์ง์ ํ ์๋ ์์ต๋๋ค.
|
|
469
|
+
- **์์ ์ง๊ณ๊ฐ ์กฐ์ฉํ ๋ฒ๋ ค์ง์ง ์์ต๋๋ค.** ๋ชจ๋ธ ID๋ฅผ ํด์ํ์ง ๋ชปํด ์ ์ธ๋ ์์์ด ์์ผ๋ฉด statusline ์ `๐ N unresolved` ๋ก ์๋ฆฝ๋๋ค. ์ด์ ์๋ "์์ํ ์ ์์"๊ณผ ํ๋ฉด์ ๊ตฌ๋ณ๋์ง ์์, ์ง๊ณ๊ฐ ํต์งธ๋ก ์ฌ๋ผ์ ธ๋ ์ ๋ฐฉ๋ฒ์ด ์์์ต๋๋ค. `foundation-model` ARN ์ผ๋ก ์ง์ ๋ ํ๊ฒฝ๋ณ์๋ ์ด์ ํด์ํฉ๋๋ค.
|
|
470
|
+
- **ํ๊ตญ์ด ์ง์นจ์ ์ ์ฉ ๋ฒ์ ์ถฉ๋์ ํด์ํ์ต๋๋ค.** ์ฃผ์
๋ฌธ์ ์ฝ๋ ์ฃผ์์ ๊ฒ์ฌ ๋์์ ๋ฃ๋๋ฐ ๋ฒค๋๋งํ ์๋ฌธ์ ๋ ๋ฒ์ ๊ฑธ์ณ ์ ์ธํ๋ค๊ณ ์ ๊ณ ์์ด์, ๋ชจ๋ธ์ด ์ด๋ ์ชฝ์ ๋ฐ๋ฅผ์ง ํ๋จํ ๊ทผ๊ฑฐ๊ฐ ์์์ต๋๋ค. ์๋ฌธ์ ๊ทธ๋๋ก ๋๊ณ ์ด๋ ์ชฝ์ด ์ฐ์ ์ธ์ง ๋ช
์ํ๋ ํ ์ค์ ์ถ๊ฐํ์ต๋๋ค. ์ถ์ฒ ํ๊ธฐ์ ๋ค์ด ์๋ ์ ๋์๋ ์ง์นจ ์ค์ค๋ก ๊ธ์งํ๋ ํ๊ธฐ์์ผ๋ฏ๋ก ์ฝ๋ก ์ผ๋ก ๋ฐ๊ฟจ์ต๋๋ค.
|
|
471
|
+
|
|
423
472
|
### v3.25.0 (2026-09-04)
|
|
424
473
|
- **statusline์ ์คํ ์ค์ธ ๋ฒ์ ์ ํ์ํฉ๋๋ค.** ์ง๊ธ๊น์ง๋ ํ ํ์ ๋ฆฌํฌํธ์ ๊ฐ์ฃผ์๋ง ๋ฒ์ ์ด ์์๊ธฐ ๋๋ฌธ์, ์ด๋ค ๋ฒ์ ์ด ๋๋์ง ํ์ธํ๋ ค๋ฉด ์ ์ฒด ๋ฆฌํฌํธ๋ฅผ ์คํํด์ผ ํ์ต๋๋ค. `--version` ํ๋๊ทธ๋ ํจ๊ป ์ถ๊ฐํ์ต๋๋ค.
|
|
425
474
|
- **์ ๋ฒ์ ์ด ๋์ค๋ฉด ์ธ์
์์์ ๋ฌผ์ด๋ด
๋๋ค.** statusline์ ๋ํ ์์๋ฅผ ๋์ธ ์ ์์ผ๋ฏ๋ก, ์๋ฆผ๊ณผ ์ง๋ฌธ์ ๋๋์์ต๋๋ค. statusline์ `โฌ v3.24.0 โ 3.25.0`์ผ๋ก ์๋ฆฌ๊ธฐ๋ง ํ๊ณ , ์ค์ ์ง๋ฌธ์ SessionStart ํ
์ด ๋ชจ๋ธ์๊ฒ "์ฌ์ฉ์์๊ฒ ์
๊ทธ๋ ์ด๋ ์ฌ๋ถ๋ฅผ ํ์ธํ๋ผ"๊ณ ์ฃผ์
ํด์ ์ด๋ฃจ์ด์ง๋๋ค. ์น๋ํ๋ฉด `claude-token-saver upgrade`๊ฐ ์ค์น ๊ฒฝ๋ก์ ๋ง๋ ๋ช
๋ น์ ์คํํ๊ณ , ๊ฑฐ์ ํ๋ฉด `update-check --dismiss`๊ฐ ๊ทธ ๋ฒ์ ์ ๋ ๋ฌป์ง ์๋๋ก ๊ธฐ๋กํฉ๋๋ค.
|
package/bin/cli.js
CHANGED
|
@@ -161,6 +161,13 @@ async function main() {
|
|
|
161
161
|
return (await import('../src/commands/korean.js')).run({ args, hasFlag });
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
// Subcommand: doc2md โ convert pptx/xlsx/pdf/docx to Markdown before the
|
|
165
|
+
// model reads them, so an unreadable binary never enters the context window.
|
|
166
|
+
// claude-token-saver doc2md on | off | <file> | --clean
|
|
167
|
+
if (args[0] === 'doc2md') {
|
|
168
|
+
return (await import('../src/commands/doc2md.js')).run({ args, hasFlag });
|
|
169
|
+
}
|
|
170
|
+
|
|
164
171
|
// Subcommand: harness โ manage the project's CLAUDE.md harness rules.
|
|
165
172
|
// claude-token-saver harness init # write CLAUDE.md (5 sections) + ratchet.md
|
|
166
173
|
// claude-token-saver harness uninit # remove harness block from CLAUDE.md (backup kept)
|
|
@@ -511,6 +518,27 @@ async function main() {
|
|
|
511
518
|
} catch (e) {
|
|
512
519
|
debug('savings-ledger:totals', e);
|
|
513
520
|
}
|
|
521
|
+
// Delegated runs route-scan had to throw away because their model id could
|
|
522
|
+
// not be priced. Also a lookup of the cached scan, never a scan. Without it
|
|
523
|
+
// the statusline shows the same blank for "no delegation happened" and for
|
|
524
|
+
// "delegation happened and was silently discarded".
|
|
525
|
+
let unresolvedRuns = 0;
|
|
526
|
+
try {
|
|
527
|
+
const { readRouteScan } = await import('../src/route-scan.js');
|
|
528
|
+
unresolvedRuns = Number(readRouteScan()?.unresolvedRuns) || 0;
|
|
529
|
+
} catch (e) {
|
|
530
|
+
debug('route-scan:unresolved', e);
|
|
531
|
+
}
|
|
532
|
+
// 'auto' unless the user pinned a bucket. Read here rather than in the
|
|
533
|
+
// statusline branch below because the table and JSON formatters want the
|
|
534
|
+
// same answer.
|
|
535
|
+
let ttlBucket = 'auto';
|
|
536
|
+
try {
|
|
537
|
+
const { statuslineDefaults } = await import('../src/config.js');
|
|
538
|
+
ttlBucket = statuslineDefaults().ttlBucket;
|
|
539
|
+
} catch (e) {
|
|
540
|
+
debug('config:ttlBucket', e);
|
|
541
|
+
}
|
|
514
542
|
|
|
515
543
|
const data = {
|
|
516
544
|
summary: sum,
|
|
@@ -530,6 +558,8 @@ async function main() {
|
|
|
530
558
|
model,
|
|
531
559
|
delegationSaved,
|
|
532
560
|
delegationTotals,
|
|
561
|
+
unresolvedRuns,
|
|
562
|
+
ttlBucket,
|
|
533
563
|
};
|
|
534
564
|
|
|
535
565
|
let output;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-token-saver",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.26.1",
|
|
4
4
|
"description": "Route the easy work your expensive Claude model keeps repeating down to haiku/sonnet โ post-hoc session analysis, no realtime router, no extra LLM calls.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Convert one office/PDF document to Markdown and print a JSON result.
|
|
3
|
+
|
|
4
|
+
Invoked as a child process by src/doc2md.cjs. Everything it needs to say
|
|
5
|
+
travels in the JSON on stdout, so the Node side never has to interpret a
|
|
6
|
+
traceback:
|
|
7
|
+
|
|
8
|
+
{"ok": true, "markdown": "...", "note": null, "truncated": false, "rows": 0}
|
|
9
|
+
{"ok": false, "reason": "no-text", "detail": "..."}
|
|
10
|
+
|
|
11
|
+
Exit status is 0 whenever the JSON was written, including for a refusal. A
|
|
12
|
+
non-zero exit means the interpreter itself failed and the caller falls back to
|
|
13
|
+
letting the original file be read as it always was.
|
|
14
|
+
|
|
15
|
+
Two things here are defenses rather than features:
|
|
16
|
+
|
|
17
|
+
* Zip bombs. pptx/xlsx/docx are zip containers, and a hostile attachment can
|
|
18
|
+
declare a small size and expand to fill the disk. The central directory is
|
|
19
|
+
checked first because it is cheap, and then every member is decompressed in
|
|
20
|
+
chunks against a hard ceiling, because the central directory is written by
|
|
21
|
+
whoever built the file and can simply lie.
|
|
22
|
+
* Row count. Conversion time tracks spreadsheet rows, not bytes: a 6MB PDF
|
|
23
|
+
converts in about a second while a 6MB 200,000-row workbook takes about a
|
|
24
|
+
minute. Past the row cap the sheet is converted head-first by hand and the
|
|
25
|
+
truncation is stated in the result, because a silently shortened table is
|
|
26
|
+
worse than no table.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import sys
|
|
32
|
+
import zipfile
|
|
33
|
+
|
|
34
|
+
MAX_UNCOMPRESSED = 500 * 1024 * 1024
|
|
35
|
+
MAX_RATIO = 200
|
|
36
|
+
ROW_CAP = int(os.environ.get("CTS_DOC2MD_ROW_CAP", "50000"))
|
|
37
|
+
ZIP_EXTS = {".pptx", ".xlsx", ".docx"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def fail(reason, detail=""):
|
|
41
|
+
json.dump({"ok": False, "reason": reason, "detail": str(detail)[:500]}, sys.stdout)
|
|
42
|
+
sys.exit(0)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def check_zip(path):
|
|
46
|
+
"""Classify an archive before opening it as a document.
|
|
47
|
+
|
|
48
|
+
Returns None when it is safe to convert, or a (reason, detail) pair. The
|
|
49
|
+
reason distinguishes a hostile file from a merely broken one: those want
|
|
50
|
+
opposite handling, and calling a truncated download a zip bomb would send
|
|
51
|
+
the user hunting for an attacker who is not there.
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
with zipfile.ZipFile(path) as zf:
|
|
55
|
+
infos = zf.infolist()
|
|
56
|
+
declared = sum(i.file_size for i in infos)
|
|
57
|
+
packed = sum(i.compress_size for i in infos) or 1
|
|
58
|
+
if declared > MAX_UNCOMPRESSED or declared / packed > MAX_RATIO:
|
|
59
|
+
return ("unsafe-archive",
|
|
60
|
+
"declared size %d bytes at %.0fx compression" % (declared, declared / packed))
|
|
61
|
+
# The numbers above came from the archive itself, so verify them by
|
|
62
|
+
# actually decompressing, stopping the moment the running total
|
|
63
|
+
# passes the ceiling rather than once the disk is full. Understated
|
|
64
|
+
# sizes are caught here twice over: by this budget, and by the
|
|
65
|
+
# CRC-32 check zipfile performs while streaming, which fails as
|
|
66
|
+
# soon as a member's real contents disagree with its header.
|
|
67
|
+
budget = MAX_UNCOMPRESSED
|
|
68
|
+
for info in infos:
|
|
69
|
+
with zf.open(info) as member:
|
|
70
|
+
while True:
|
|
71
|
+
chunk = member.read(1 << 20)
|
|
72
|
+
if not chunk:
|
|
73
|
+
break
|
|
74
|
+
budget -= len(chunk)
|
|
75
|
+
if budget <= 0:
|
|
76
|
+
return ("unsafe-archive", "expands past %d bytes" % MAX_UNCOMPRESSED)
|
|
77
|
+
except zipfile.BadZipFile as e:
|
|
78
|
+
return ("bad-archive", str(e))
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def sheet_rows(path):
|
|
83
|
+
"""Total rows across every sheet, or None when openpyxl cannot say."""
|
|
84
|
+
try:
|
|
85
|
+
import openpyxl
|
|
86
|
+
except ImportError:
|
|
87
|
+
return None
|
|
88
|
+
try:
|
|
89
|
+
wb = openpyxl.load_workbook(path, read_only=True)
|
|
90
|
+
try:
|
|
91
|
+
return sum(ws.max_row or 0 for ws in wb.worksheets)
|
|
92
|
+
finally:
|
|
93
|
+
wb.close()
|
|
94
|
+
except Exception:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def md_cell(v):
|
|
99
|
+
if v is None:
|
|
100
|
+
return ""
|
|
101
|
+
return str(v).replace("|", "\\|").replace("\n", " ")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def head_of_workbook(path, cap):
|
|
105
|
+
"""Markdown for the first `cap` rows, sheet by sheet.
|
|
106
|
+
|
|
107
|
+
Used only past the row cap. markitdown would produce nicer output, but it
|
|
108
|
+
reads the whole workbook first, which is the cost being avoided.
|
|
109
|
+
"""
|
|
110
|
+
import openpyxl
|
|
111
|
+
|
|
112
|
+
wb = openpyxl.load_workbook(path, read_only=True)
|
|
113
|
+
out = []
|
|
114
|
+
left = cap
|
|
115
|
+
try:
|
|
116
|
+
for ws in wb.worksheets:
|
|
117
|
+
if left <= 0:
|
|
118
|
+
break
|
|
119
|
+
out.append("## %s" % ws.title)
|
|
120
|
+
header_written = False
|
|
121
|
+
for row in ws.iter_rows(values_only=True):
|
|
122
|
+
if left <= 0:
|
|
123
|
+
break
|
|
124
|
+
cells = [md_cell(c) for c in row]
|
|
125
|
+
out.append("| " + " | ".join(cells) + " |")
|
|
126
|
+
if not header_written:
|
|
127
|
+
out.append("| " + " | ".join(["---"] * len(cells)) + " |")
|
|
128
|
+
header_written = True
|
|
129
|
+
left -= 1
|
|
130
|
+
out.append("")
|
|
131
|
+
finally:
|
|
132
|
+
wb.close()
|
|
133
|
+
return "\n".join(out)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def main():
|
|
137
|
+
if len(sys.argv) < 2:
|
|
138
|
+
fail("usage", "convert.py <file>")
|
|
139
|
+
path = sys.argv[1]
|
|
140
|
+
if not os.path.isfile(path):
|
|
141
|
+
fail("missing", path)
|
|
142
|
+
|
|
143
|
+
ext = os.path.splitext(path)[1].lower()
|
|
144
|
+
if ext in ZIP_EXTS:
|
|
145
|
+
problem = check_zip(path)
|
|
146
|
+
if problem:
|
|
147
|
+
fail(problem[0], problem[1])
|
|
148
|
+
|
|
149
|
+
note = None
|
|
150
|
+
truncated = False
|
|
151
|
+
rows = 0
|
|
152
|
+
|
|
153
|
+
if ext in (".xlsx", ".xls"):
|
|
154
|
+
counted = sheet_rows(path)
|
|
155
|
+
rows = counted or 0
|
|
156
|
+
if counted and counted > ROW_CAP:
|
|
157
|
+
try:
|
|
158
|
+
text = head_of_workbook(path, ROW_CAP)
|
|
159
|
+
except Exception as e:
|
|
160
|
+
fail("convert-failed", e)
|
|
161
|
+
note = ("์ ์ฒด %dํ ๊ฐ์ด๋ฐ ์ %dํ๋ง ๋ณํํ์ต๋๋ค. "
|
|
162
|
+
"์ ์ ๋ถ์์ด ํ์ํ๋ฉด ์๋ณธ์ ์ง์ ๋ค๋ฃจ์ญ์์ค." % (counted, ROW_CAP))
|
|
163
|
+
json.dump({"ok": True, "markdown": text, "note": note,
|
|
164
|
+
"truncated": True, "rows": counted}, sys.stdout)
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
from markitdown import MarkItDown
|
|
169
|
+
except ImportError as e:
|
|
170
|
+
fail("no-markitdown", e)
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
result = MarkItDown().convert(path)
|
|
174
|
+
text = (result.text_content or "").strip()
|
|
175
|
+
except Exception as e:
|
|
176
|
+
fail("convert-failed", e)
|
|
177
|
+
|
|
178
|
+
if not text:
|
|
179
|
+
# An empty file would read to the model as a document with nothing in
|
|
180
|
+
# it, which is a different and worse claim than "could not extract".
|
|
181
|
+
fail("no-text", "converter returned nothing")
|
|
182
|
+
|
|
183
|
+
json.dump({"ok": True, "markdown": text, "note": note,
|
|
184
|
+
"truncated": truncated, "rows": rows}, sys.stdout)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
main()
|
package/src/advice.js
CHANGED
|
@@ -180,6 +180,34 @@ export const ISSUE_MESSAGES = {
|
|
|
180
180
|
},
|
|
181
181
|
],
|
|
182
182
|
},
|
|
183
|
+
// Same symptom as BUCKET_5M_DOMINANT, different remedy. On Bedrock/Vertex
|
|
184
|
+
// the 5m bucket is the only bucket, so telling the user to upgrade a
|
|
185
|
+
// subscription plan sends them to buy something that changes nothing.
|
|
186
|
+
BUCKET_5M_DOMINANT_GATEWAY: {
|
|
187
|
+
title: 'This gateway offers only the 5-minute TTL bucket',
|
|
188
|
+
titleKo: '์ด ๊ฒ์ดํธ์จ์ด๋ 5๋ถ TTL ๋ฒํท๋ง ์ ๊ณตํฉ๋๋ค',
|
|
189
|
+
explain:
|
|
190
|
+
'Bedrock/Vertex do not expose the 1h extended bucket, and they do not report the ' +
|
|
191
|
+
'per-bucket split either, so this is inferred from the model ids in the transcript. ' +
|
|
192
|
+
'No subscription plan changes it.',
|
|
193
|
+
explainKo:
|
|
194
|
+
'Bedrock๊ณผ Vertex๋ 1์๊ฐ ํ์ฅ ๋ฒํท์ ์ ๊ณตํ์ง ์์ผ๋ฉฐ, ๋ฒํท๋ณ ๋ถํด ๊ฐ๋ ๋ด๋ ค๋ณด๋ด์ง ์์ต๋๋ค. ' +
|
|
195
|
+
'๊ทธ๋์ ์ด ํ์ ์ ํธ๋์คํฌ๋ฆฝํธ์ ๋จ์ ๋ชจ๋ธ ID๋ก ์ถ์ ํ ๊ฒ์
๋๋ค. ๊ตฌ๋
ํ๋์ ๋ฐ๊พธ์ด๋ ํด์๋์ง ์์ต๋๋ค.',
|
|
196
|
+
actions: () => [
|
|
197
|
+
{
|
|
198
|
+
label: 'What actually helps here',
|
|
199
|
+
labelKo: '์ด ํ๊ฒฝ์์ ์ค์ ๋ก ๋ฃ๋ ๋์',
|
|
200
|
+
commands: [
|
|
201
|
+
'Send the next request within 5 minutes โ that window is all you get',
|
|
202
|
+
'`/compact` before stepping away, so the rebuild after expiry costs less',
|
|
203
|
+
],
|
|
204
|
+
commandsKo: [
|
|
205
|
+
'๋ค์ ์์ฒญ์ 5๋ถ ์์ ๋ณด๋ด์ญ์์ค. ์ด ํ๊ฒฝ์์ ์ฃผ์ด์ง๋ ์ฐฝ์ ๊ทธ๊ฒ์ด ์ ๋ถ์
๋๋ค.',
|
|
206
|
+
'์์
์ ์ค๋จํ๊ธฐ ์ ์ `/compact`๋ฅผ ์คํํด ๋ง๋ฃ ํ์ ์ฌ๋น๋ ๋น์ฉ์ ๋ฎ์ถ์ญ์์ค.',
|
|
207
|
+
],
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
},
|
|
183
211
|
HIGH_OUTPUT_RATIO: {
|
|
184
212
|
title: 'Output share is abnormally high',
|
|
185
213
|
titleKo: '์ถ๋ ฅ ๋น์ค์ด ๋น์ ์์ ์ผ๋ก ๋์',
|
|
@@ -454,6 +482,10 @@ export const ISSUE_TIPS = {
|
|
|
454
482
|
en: 'Send any prompt within 5min to keep cache warm; Max plan unlocks 1h TTL',
|
|
455
483
|
ko: '5๋ถ ์ด๋ด ํ ๋ฒ ๋ ๋ณด๋ด ์บ์ ์ ์ง; Max ํ๋์ 1์๊ฐ TTL ์ ๊ณต',
|
|
456
484
|
},
|
|
485
|
+
BUCKET_5M_DOMINANT_GATEWAY: {
|
|
486
|
+
en: 'Gateway (Bedrock/Vertex) is 5m-only โ no plan changes that; send within 5min or `/compact` before idling',
|
|
487
|
+
ko: '๊ฒ์ดํธ์จ์ด(BedrockยทVertex)๋ 5๋ถ ๊ณ ์ ์ด๋ผ ํ๋์ผ๋ก ํด์๋์ง ์์; 5๋ถ ์์ ๋ณด๋ด๊ฑฐ๋ ์์
์ค๋จ ์ `/compact`',
|
|
488
|
+
},
|
|
457
489
|
HIGH_OUTPUT_RATIO: {
|
|
458
490
|
en: 'Check `/effort` (`xhigh` inflates output); prefer Edit over full rewrites; model matching: Sonnet 80% / Opus 15% / Haiku 5%',
|
|
459
491
|
ko: '`/effort` ํ์ธ (`xhigh`๋ ์ถ๋ ฅ ํญ์ฆ); Edit ๋๊ตฌ ์ฐ์ (์ ์ฒด ์ฌ์์ฑ ํผํ๊ธฐ); ๋ชจ๋ธ ๋งค์นญ: Sonnet 80% / Opus 15% / Haiku 5%',
|
|
@@ -488,7 +520,7 @@ export const CHIP_TO_CODES = {
|
|
|
488
520
|
'โ 1M ON': ['LARGE_INPUT_PER_REQUEST'],
|
|
489
521
|
'โ Cache miss': ['LOW_HIT_RATE'],
|
|
490
522
|
'โ Input spike': ['LARGE_INPUT_PER_REQUEST'],
|
|
491
|
-
'โ 5m TTL': ['BUCKET_5M_DOMINANT'],
|
|
523
|
+
'โ 5m TTL': ['BUCKET_5M_DOMINANT', 'BUCKET_5M_DOMINANT_GATEWAY'],
|
|
492
524
|
'โ Rebuild churn': ['FREQUENT_CACHE_REBUILD'],
|
|
493
525
|
'โ Output heavy': ['HIGH_OUTPUT_RATIO'],
|
|
494
526
|
'โ Call surge': ['HIGH_REQUEST_COUNT'],
|
|
@@ -520,7 +552,7 @@ export function chipForIssues(issues, contextWindow) {
|
|
|
520
552
|
if (contextWindow?.size === '1M') return 'โ Ctx 200k+';
|
|
521
553
|
const codes = issues.map((i) => i.code);
|
|
522
554
|
if (codes.includes('LARGE_INPUT_PER_REQUEST')) return 'โ Input spike';
|
|
523
|
-
if (codes.includes('BUCKET_5M_DOMINANT')) return 'โ 5m TTL';
|
|
555
|
+
if (codes.includes('BUCKET_5M_DOMINANT') || codes.includes('BUCKET_5M_DOMINANT_GATEWAY')) return 'โ 5m TTL';
|
|
524
556
|
if (codes.includes('LOW_HIT_RATE')) return 'โ Cache miss';
|
|
525
557
|
if (codes.includes('FREQUENT_CACHE_REBUILD')) return 'โ Rebuild churn';
|
|
526
558
|
if (codes.includes('HIGH_OUTPUT_RATIO')) return 'โ Output heavy';
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subcommand: doc2md โ convert attached documents to Markdown before the
|
|
3
|
+
* model reads them.
|
|
4
|
+
*
|
|
5
|
+
* claude-token-saver doc2md # status
|
|
6
|
+
* claude-token-saver doc2md on|off # register / remove the Read hook
|
|
7
|
+
* claude-token-saver doc2md <file> # convert one file by hand
|
|
8
|
+
* claude-token-saver doc2md --clean # drop every cached conversion
|
|
9
|
+
* claude-token-saver doc2md --hook # PreToolUse entry point
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import { readdirSync, rmSync, existsSync, statSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
|
|
18
|
+
export async function run({ args, hasFlag }) {
|
|
19
|
+
const doc2md = require('../doc2md.cjs');
|
|
20
|
+
const sub = args[1];
|
|
21
|
+
|
|
22
|
+
// Hook path first and cheap: this runs on every Read of a matching file, so
|
|
23
|
+
// nothing above it may cost a syscall.
|
|
24
|
+
if (hasFlag?.('--hook') || sub === '--hook') {
|
|
25
|
+
const { readStdinJson } = await import('../stdin-payload.js');
|
|
26
|
+
const payload = readStdinJson();
|
|
27
|
+
if (!payload) return;
|
|
28
|
+
let out = null;
|
|
29
|
+
try {
|
|
30
|
+
out = doc2md.formatHookOutput(doc2md.decideForRead(payload));
|
|
31
|
+
} catch {
|
|
32
|
+
// A converter that throws must not take the Read down with it. Printing
|
|
33
|
+
// nothing leaves Claude Code to run the tool call exactly as before.
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (out) console.log(out);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (sub === 'on') {
|
|
41
|
+
const { installDoc2mdHook } = await import('../installer.js');
|
|
42
|
+
const res = installDoc2mdHook();
|
|
43
|
+
console.log(res.action === 'skipped'
|
|
44
|
+
? `โ ${res.reason}`
|
|
45
|
+
: `โ Read hook ${res.action} (${res.path})`);
|
|
46
|
+
const python = doc2md.findInterpreter();
|
|
47
|
+
console.log(python
|
|
48
|
+
? ` converter: markitdown via ${python}`
|
|
49
|
+
: ` converter: not installed yet โ ${doc2md.INSTALL_HINT}`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (sub === 'off') {
|
|
54
|
+
const { removeDoc2mdHook } = await import('../installer.js');
|
|
55
|
+
const res = removeDoc2mdHook();
|
|
56
|
+
console.log(res.action === 'skipped' ? `โ ${res.reason}` : `โ Read hook ${res.action}`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (hasFlag?.('--clean') || sub === '--clean') {
|
|
61
|
+
const dir = doc2md.cacheDir();
|
|
62
|
+
let removed = 0;
|
|
63
|
+
if (existsSync(dir)) {
|
|
64
|
+
for (const name of readdirSync(dir)) {
|
|
65
|
+
rmSync(join(dir, name), { force: true });
|
|
66
|
+
removed += 1;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
console.log(`โ removed ${removed} cached file(s) from ${dir}`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// A path: convert it now and print where the result landed. This is the
|
|
74
|
+
// diagnostic path โ it reports the refusal reason instead of swallowing it,
|
|
75
|
+
// which is how you find out that markitdown is missing rather than guessing.
|
|
76
|
+
if (sub && !sub.startsWith('-')) {
|
|
77
|
+
const result = doc2md.convert(sub);
|
|
78
|
+
if (result.ok) {
|
|
79
|
+
console.log(`โ ${result.cached ? 'cached' : 'converted'}: ${result.cacheFile}`);
|
|
80
|
+
if (result.meta?.note) console.log(` ${result.meta.note}`);
|
|
81
|
+
if (result.meta?.clipped) console.log(' ๊ฒฐ๊ณผ๊ฐ ์ํ์ ๋์ด ๋ท๋ถ๋ถ์ ์๋์ต๋๋ค.');
|
|
82
|
+
console.log(` ${statSync(result.cacheFile).size} bytes`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
console.error(`โ ${result.reason}${result.detail ? `: ${result.detail}` : ''}`);
|
|
86
|
+
if (result.reason === 'no-markitdown') console.error(` ${doc2md.INSTALL_HINT}`);
|
|
87
|
+
process.exitCode = 1;
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const python = doc2md.findInterpreter();
|
|
92
|
+
const dir = doc2md.cacheDir();
|
|
93
|
+
const cached = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.md')).length : 0;
|
|
94
|
+
console.log('doc2md โ attached documents are converted to Markdown before the model reads them.');
|
|
95
|
+
console.log(` formats: ${doc2md.TARGET_EXTENSIONS.join(' ')}`);
|
|
96
|
+
console.log(` converter: ${python ? `markitdown via ${python}` : `not installed โ ${doc2md.INSTALL_HINT}`}`);
|
|
97
|
+
console.log(` cache: ${dir} (${cached} file(s))`);
|
|
98
|
+
console.log('');
|
|
99
|
+
console.log('Enable with: claude-token-saver doc2md on');
|
|
100
|
+
}
|
package/src/commands/mode.js
CHANGED
|
@@ -20,6 +20,7 @@ export async function run({ args }) {
|
|
|
20
20
|
console.log(` timer: ${eff.timer}`);
|
|
21
21
|
console.log(` color: ${eff.color}`);
|
|
22
22
|
console.log(` window: ${eff.windowLabel} (${eff.windowHours}h)`);
|
|
23
|
+
console.log(` ttl: ${eff.ttlBucket}${eff.ttlBucket === 'auto' ? ' (measured split, else gateway detection)' : ' (pinned)'}`);
|
|
23
24
|
console.log('');
|
|
24
25
|
console.log('Output language (advice / history / last):');
|
|
25
26
|
console.log(` language: ${userLanguage()}`);
|
|
@@ -100,6 +100,14 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
100
100
|
return;
|
|
101
101
|
}
|
|
102
102
|
console.log(lang === 'ko' ? '๐ ๋ชจ๋ธ ํผํ
๋ฃฐ (๋ก๊ทธ ๊ธฐ๋ฐ ์๋ ๊ฐฑ์ ):' : '๐ Model-fitting rules (auto-refreshed from logs):');
|
|
103
|
+
// A T1 rule delegates to sonnet, so in a session whose own model is
|
|
104
|
+
// already sonnet it can never save anything. That is correct behaviour,
|
|
105
|
+
// but listed as "measured delegations โ (none yet)" it reads as a
|
|
106
|
+
// promise of savings that will never arrive.
|
|
107
|
+
const { aliasForRole } = await import('../model-alias.js');
|
|
108
|
+
const { modelRank } = await import('../cost.js');
|
|
109
|
+
const sessionModel = aliasForRole('main');
|
|
110
|
+
const sessionRank = sessionModel ? modelRank(sessionModel) : null;
|
|
103
111
|
rules.forEach((r, i) => {
|
|
104
112
|
const health = r.status === 'review'
|
|
105
113
|
? (lang === 'ko' ? ' โ ์๋ฌ์จ ์ด๊ณผ โ ์ฌ๊ฒํ ํ์' : ' โ error rate over threshold โ needs review')
|
|
@@ -115,7 +123,13 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
115
123
|
? (lang === 'ko'
|
|
116
124
|
? `์ค์ ์์ ${r.delegatedRuns}๊ฑด ยท ์๋ฌ์จ ${Math.round((r.delegatedErrRate || 0) * 100)}% ยท ์ ๊ฐ ~$${(r.savedUsd || 0).toFixed(2)}`
|
|
117
125
|
: `measured ร${r.delegatedRuns} ยท err ${Math.round((r.delegatedErrRate || 0) * 100)}% ยท saved ~$${(r.savedUsd || 0).toFixed(2)}`)
|
|
118
|
-
: (
|
|
126
|
+
: (sessionRank !== null && !rs.worthDelegating(r.tier, sessionRank)
|
|
127
|
+
? (lang === 'ko'
|
|
128
|
+
? `์ด ๊ท์น์ ํ์ฌ ๊ธฐ๋ณธ ๋ชจ๋ธ(${sessionModel}) ๊ธฐ์ค์ผ๋ก๋ ์ ์ฉ๋์ง ์์ต๋๋ค. ์ธ์
๋ชจ๋ธ์ด ์ด๋ฏธ ์์ ๋ชฉํ์ ๊ฐ์ ๊ธ์ด์ด์ ์ ๊ฐ์ด ๋ฐ์ํ์ง ์์ต๋๋ค.`
|
|
129
|
+
: `not in effect for the current default model (${sessionModel}) โ the session already runs at the delegation target's tier`)
|
|
130
|
+
: (lang === 'ko'
|
|
131
|
+
? '์ค์ ์์ ๊ธฐ๋ก์ด ์์ง ์์ต๋๋ค.'
|
|
132
|
+
: 'measured delegations โ (none yet)'));
|
|
119
133
|
console.log(` ${measured}`);
|
|
120
134
|
// Same composer the md file uses, so what is listed here is exactly
|
|
121
135
|
// what the model reads.
|
package/src/config.js
CHANGED
|
@@ -82,6 +82,7 @@ export const VALID_KEYWORDS = Object.keys(KEYWORDS)
|
|
|
82
82
|
'<N>h (e.g. 1h, 6h, 24h)',
|
|
83
83
|
'<N>d (e.g. 1d, 7d, 30d)',
|
|
84
84
|
'lang=en | lang=ko',
|
|
85
|
+
'ttl=5m | ttl=1h | ttl=auto',
|
|
85
86
|
'reset',
|
|
86
87
|
'default',
|
|
87
88
|
]);
|
|
@@ -120,6 +121,15 @@ export function applyMode(words) {
|
|
|
120
121
|
applied.push(`lang=${cfg.language}`);
|
|
121
122
|
continue;
|
|
122
123
|
}
|
|
124
|
+
// Checked before parseWindow, which would otherwise read the `5m`/`1h`
|
|
125
|
+
// part as an analysis window.
|
|
126
|
+
const ttlMatch = lower.match(/^ttl=(5m|1h|auto)$/);
|
|
127
|
+
if (ttlMatch) {
|
|
128
|
+
if (ttlMatch[1] === 'auto') delete cfg.statusline.ttlBucket;
|
|
129
|
+
else cfg.statusline.ttlBucket = ttlMatch[1];
|
|
130
|
+
applied.push(`ttl=${ttlMatch[1]}`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
123
133
|
const hours = parseWindow(lower);
|
|
124
134
|
if (hours !== null && hours > 0) {
|
|
125
135
|
cfg.statusline.windowHours = hours;
|
|
@@ -168,6 +178,11 @@ export function statuslineDefaults() {
|
|
|
168
178
|
color: s.color !== false,
|
|
169
179
|
windowHours,
|
|
170
180
|
windowLabel: formatWindow(windowHours),
|
|
181
|
+
// 'auto' | '5m' | '1h'. Auto lets the measured split decide and falls back
|
|
182
|
+
// to gateway detection. An explicit value exists because detection can be
|
|
183
|
+
// wrong in either direction, and a user who can read their own clock
|
|
184
|
+
// should not have to wait for a release to correct it.
|
|
185
|
+
ttlBucket: s.ttlBucket === '5m' || s.ttlBucket === '1h' ? s.ttlBucket : 'auto',
|
|
171
186
|
};
|
|
172
187
|
}
|
|
173
188
|
|