claude-token-saver 3.25.0 → 3.26.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.md +48 -0
- package/bin/cli.js +30 -0
- package/package.json +8 -3
- 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.md
CHANGED
|
@@ -162,6 +162,9 @@ Claude Code 안에서 `/claude-token-saver` Skill을 실행하거나, 칩에 적
|
|
|
162
162
|
| `claude-token-saver korean on\|off\|status` | 한국어 문체 지침을 세션 시작 시 주입하고, 쓰기 시점 검사를 함께 설치합니다 (아래 참고) |
|
|
163
163
|
| `claude-token-saver korean lint block\|warn\|off` | 쓰기 시점 검사가 위반을 어떻게 처리할지 정합니다 |
|
|
164
164
|
| `claude-token-saver korean lint scope all\|prose` | 검사 범위를 모든 텍스트 파일과 문서 전용 사이에서 고릅니다 |
|
|
165
|
+
| `claude-token-saver doc2md on\|off` | 첨부 문서를 모델이 읽기 전에 Markdown 으로 변환합니다 (아래 참고) |
|
|
166
|
+
| `claude-token-saver doc2md <파일>` | 파일 하나를 직접 변환합니다. 진단 용도이며 실패 이유를 그대로 출력합니다 |
|
|
167
|
+
| `claude-token-saver mode ttl=5m\|1h\|auto` | 캐시 TTL 버킷을 직접 지정합니다. 기본값 `auto`는 실측값을 먼저 보고, 실측값이 없으면 게이트웨이 여부로 판정합니다 |
|
|
165
168
|
| `claude-token-saver --version` | 설치된 버전을 출력합니다 |
|
|
166
169
|
| `claude-token-saver update-check` | 새 버전이 있는지 확인합니다 (`--refresh`로 즉시 조회, `--dismiss`로 그 버전 안내 끄기) |
|
|
167
170
|
| `claude-token-saver upgrade` | 설치 경로에 맞는 명령으로 최신 버전을 설치합니다 (`--print`로 실행 없이 명령만 확인) |
|
|
@@ -364,6 +367,45 @@ npm의 `postinstall`이나 CI처럼 사람이 붙어 있지 않은 설치에서
|
|
|
364
367
|
> 지침 원문은 [fluent-korean](https://github.com/snflkd/fluent-korean)에서 가져왔습니다. Copyright (c) 2026 snflkd, MIT License.
|
|
365
368
|
> 원문은 수정하지 않았고 output style 프런트매터만 제거했습니다. 라이선스 전문은 패키지의 `presets/korean-style/LICENSE-fluent-korean`에 함께 배포합니다.
|
|
366
369
|
|
|
370
|
+
## 📄 doc2md: 첨부 문서를 읽기 전에 Markdown 으로 바꿉니다
|
|
371
|
+
|
|
372
|
+
pptx·xlsx·pdf·docx 를 그대로 `Read` 하면 모델이 읽지 못하는 바이트가 컨텍스트에 그대로 올라갑니다. 이 기능은 그 `Read` 를 가로채 파일을 한 번 변환해 두고, 원본 대신 변환본을 읽게 합니다.
|
|
373
|
+
|
|
374
|
+
```bash
|
|
375
|
+
pip install "markitdown[pptx,pdf,xlsx,docx]" # 변환기는 파이썬 패키지입니다
|
|
376
|
+
claude-token-saver doc2md on # Read 훅 등록
|
|
377
|
+
claude-token-saver doc2md 보고서.pptx # 직접 변환해 결과 확인
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
변환은 [markitdown](https://github.com/microsoft/markitdown)이 담당하며, 슬라이드 번호와 제목 계층, 표, 발표자 노트, 시트 구분이 모두 남습니다. 한글도 깨지지 않습니다.
|
|
381
|
+
|
|
382
|
+
몇 가지는 의도적으로 하지 않습니다.
|
|
383
|
+
|
|
384
|
+
- **이미지는 변환하지 않습니다.** markitdown 이 빈 결과를 돌려주고, OCR 은 실측에서 리소스 이름을 틀리게 읽었습니다(`c5.xlarge` 를 `c.xlarge` 로). 이름 자체가 내용인 문서에서는 텍스트가 없느니만 못합니다. 모델이 이미지는 직접 읽습니다.
|
|
385
|
+
- **markitdown 이 없으면 조용히 실패하지 않습니다.** 설치 명령을 한 번 안내한 뒤 원본 `Read` 를 그대로 통과시킵니다. 매번 알리면 그것대로 방해가 되고, 아무 말도 하지 않으면 고장을 숨기게 됩니다.
|
|
386
|
+
- **변환본은 프로젝트 안에 남기지 않습니다.** 도구의 상태 디렉터리 아래 권한 `0700` 으로 저장하므로 `.gitignore` 에 무엇을 추가할 필요가 없습니다. 파일명이 급여·계약·개인정보 같은 패턴에 걸리면 아예 변환하지 않습니다.
|
|
387
|
+
- **압축 폭탄은 막습니다.** pptx·xlsx·docx 는 zip 컨테이너입니다. 선언된 크기를 먼저 걸러 내고, 선언은 조작될 수 있으므로 실제 해제 바이트도 상한과 대조합니다.
|
|
388
|
+
- **엑셀은 행 수로 자릅니다.** 변환 시간은 파일 크기가 아니라 행 수를 따릅니다(실측: PDF 6.3MB 0.9초, 엑셀 5.8MB 47.75초). 5만 행을 넘으면 앞부분만 변환하고, **잘랐다는 사실과 전체 행 수를 안내에 함께 적습니다.**
|
|
389
|
+
|
|
390
|
+
`claude-token-saver doc2md --clean` 으로 변환 캐시를 비우고, `doc2md off` 로 훅을 제거합니다. 훅 해제는 자기 항목만 골라 지우므로 `PreToolUse` 에 등록해 둔 다른 훅은 그대로 남습니다.
|
|
391
|
+
|
|
392
|
+
## 🌐 Bedrock·Vertex 경유 환경
|
|
393
|
+
|
|
394
|
+
게이트웨이를 거치면 응답이 캐시 쓰기 합계만 내려보내고 5분·1시간 버킷별 분해 값은 채우지 않습니다. 그래서 이 도구가 "캐시 쓰기가 아직 없다"와 "이 제공자는 알려 주지 않는다"를 구별하지 못했고, 판정 불가일 때 1시간을 기본값으로 잡았습니다. Bedrock 은 5분 버킷만 제공하므로 남은 시간이 최대 12배로 부풀어 보였습니다.
|
|
395
|
+
|
|
396
|
+
v3.26.0부터 트랜스크립트의 모델 ID로 게이트웨이를 감지해 다음을 바로잡습니다.
|
|
397
|
+
|
|
398
|
+
- 판정 불가일 때의 카운트다운 기본값이 5분이 되고, 버킷 라벨이 `5m?` 로 표시됩니다. 실측값은 `5m`, 추정은 `5m?`, 근거 없음은 `?` 로 세 단계를 구분합니다.
|
|
399
|
+
- 5분 버킷에서는 카운트다운 색이 비율이 아니라 절대 시간을 따릅니다. 5분의 30%는 90초여서, 초록이 주는 여유가 실제와 어긋났습니다.
|
|
400
|
+
- `⚠ 5m TTL` 경고가 이 환경에도 도달합니다. 다만 조언 문구는 다릅니다. 구독 플랜을 바꿔도 해소되지 않는 환경이므로 플랜 전환을 권하지 않습니다.
|
|
401
|
+
- `Extra cost if 5m-only` 는 1시간 쓰기가 있는 경우에만 묻습니다. 이미 5분 전용인 환경에서는 질문 자체가 성립하지 않아 `+$0` 이 잘못 읽혔습니다.
|
|
402
|
+
- 위임 건이 모델 ID 해석 실패로 버려졌으면 statusline 에 `🔀 N unresolved` 로 알립니다. 이전에는 "위임한 적 없음"과 화면상 구별되지 않았습니다.
|
|
403
|
+
- 환경변수를 `foundation-model` ARN 으로 지정한 경우에도 모델을 해석합니다. 이름을 담고 있지 않은 `application-inference-profile` ID 는 그대로 거부합니다. 값을 추측해 넣으면 원장에 틀린 금액이 들어가기 때문입니다.
|
|
404
|
+
|
|
405
|
+
감지가 틀리면 `claude-token-saver mode ttl=5m`(또는 `ttl=1h`)로 직접 지정할 수 있습니다. 지정값이 실측값보다 우선합니다.
|
|
406
|
+
|
|
407
|
+
세션 기본 모델이 sonnet 이면 sonnet 위임 규칙(T1)은 구조적으로 절감이 0입니다. 같은 급으로 내려보내 봐야 차액이 없기 때문이며 이는 정상 동작입니다. 다만 `route-scan rules` 가 이 경우를 "아직 위임 없음"과 같은 문구로 표시해 고장처럼 보였으므로, 이제 현재 기본 모델 기준으로 적용되지 않는다는 사실을 따로 적습니다.
|
|
408
|
+
|
|
367
409
|
## 토큰 급증 원인 코드
|
|
368
410
|
|
|
369
411
|
| 코드 | 의미 |
|
|
@@ -420,6 +462,12 @@ npm uninstall -g claude-cache-monitor && npm i -g claude-token-saver
|
|
|
420
462
|
|
|
421
463
|
## 릴리스 노트
|
|
422
464
|
|
|
465
|
+
### v3.26.0 (2026-09-04)
|
|
466
|
+
- **첨부 문서를 읽기 전에 Markdown 으로 변환합니다.** pptx·xlsx·pdf·docx 를 그대로 `Read` 하면 모델이 읽지 못하는 바이트가 컨텍스트에 올라갑니다. `doc2md on` 으로 `Read` 훅을 등록하면 파일을 한 번 변환해 캐시에 두고 변환본을 읽게 합니다. 변환기가 없으면 안내를 한 번만 하고 원본 `Read` 를 통과시키며, 압축 폭탄은 막고, 5만 행이 넘는 엑셀은 앞부분만 변환한 뒤 잘랐다는 사실을 함께 알립니다. 자세한 내용은 [doc2md](#-doc2md-첨부-문서를-읽기-전에-markdown-으로-바꿉니다) 절을 참고하십시오.
|
|
467
|
+
- **Bedrock·Vertex 경유 환경의 TTL 표시를 바로잡았습니다.** 게이트웨이는 버킷별 분해 값을 내려보내지 않는데, 판정 불가일 때 1시간을 기본값으로 잡고 있었습니다. 5분 버킷만 제공하는 환경에서 남은 시간이 최대 12배로 부풀어 보였습니다. 이제 모델 ID로 게이트웨이를 감지해 5분을 기본값으로 쓰고, 라벨을 `5m?` 로 적어 추정임을 밝힙니다. `mode ttl=5m` 으로 직접 지정할 수도 있습니다.
|
|
468
|
+
- **위임 집계가 조용히 버려지지 않습니다.** 모델 ID를 해석하지 못해 제외된 위임이 있으면 statusline 에 `🔀 N unresolved` 로 알립니다. 이전에는 "위임한 적 없음"과 화면상 구별되지 않아, 집계가 통째로 사라져도 알 방법이 없었습니다. `foundation-model` ARN 으로 지정된 환경변수도 이제 해석합니다.
|
|
469
|
+
- **한국어 지침의 적용 범위 충돌을 해소했습니다.** 주입문은 코드 주석을 검사 대상에 넣는데 벤더링한 원문은 두 번에 걸쳐 제외한다고 적고 있어서, 모델이 어느 쪽을 따를지 판단할 근거가 없었습니다. 원문은 그대로 두고 어느 쪽이 우선인지 명시하는 한 줄을 추가했습니다. 출처 표기에 들어 있던 엠대시도 지침 스스로 금지하는 표기였으므로 콜론으로 바꿨습니다.
|
|
470
|
+
|
|
423
471
|
### v3.25.0 (2026-09-04)
|
|
424
472
|
- **statusline에 실행 중인 버전을 표시합니다.** 지금까지는 표 형식 리포트의 각주에만 버전이 있었기 때문에, 어떤 버전이 도는지 확인하려면 전체 리포트를 실행해야 했습니다. `--version` 플래그도 함께 추가했습니다.
|
|
425
473
|
- **새 버전이 나오면 세션 시작에 물어봅니다.** 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.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": {
|
|
@@ -38,14 +38,19 @@
|
|
|
38
38
|
"cache",
|
|
39
39
|
"statusline",
|
|
40
40
|
"cli",
|
|
41
|
-
"1m-context"
|
|
41
|
+
"1m-context",
|
|
42
|
+
"claude-code-statusline",
|
|
43
|
+
"claude-code-cost",
|
|
44
|
+
"model-fitting",
|
|
45
|
+
"ratchet",
|
|
46
|
+
"agent-harness"
|
|
42
47
|
],
|
|
43
48
|
"license": "MIT",
|
|
44
49
|
"repository": {
|
|
45
50
|
"type": "git",
|
|
46
51
|
"url": "git+https://github.com/rootstudioyaml/claude-token-saver.git"
|
|
47
52
|
},
|
|
48
|
-
"homepage": "https://
|
|
53
|
+
"homepage": "https://github.com/rootstudioyaml/claude-token-saver#readme",
|
|
49
54
|
"bugs": {
|
|
50
55
|
"url": "https://github.com/rootstudioyaml/claude-token-saver/issues"
|
|
51
56
|
},
|
|
@@ -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
|
|
package/src/cost.js
CHANGED
|
@@ -234,6 +234,12 @@ export function estimateCost(totals, model) {
|
|
|
234
234
|
savingsRate: noCacheCost > 0 ? (noCacheCost - actual) / noCacheCost : 0,
|
|
235
235
|
scenario5mCost: round(scenario5mCost),
|
|
236
236
|
extraCostIf5m: round(scenario5mCost - actual),
|
|
237
|
+
// The row asks a counterfactual: what would dropping to 5m-only cost you?
|
|
238
|
+
// With no 1h writes there is nothing to lose, and the arithmetically
|
|
239
|
+
// honest `+$0` it printed read as "5m-only is free" — the opposite of the
|
|
240
|
+
// truth for a gateway user already confined to 5m. The display layer has
|
|
241
|
+
// to change the question rather than the number.
|
|
242
|
+
extraCostIf5mApplicable: write1h > 0,
|
|
237
243
|
};
|
|
238
244
|
}
|
|
239
245
|
|