claude-token-saver 3.25.1 → 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 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.25.1",
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": {
@@ -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
+ }
@@ -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
- : (lang === 'ko' ? '실제 위임 기록 — (아직 없음)' : 'measured delegations — (none yet)');
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
 
package/src/doc2md.cjs ADDED
@@ -0,0 +1,378 @@
1
+ /**
2
+ * doc2md — hand the model a Markdown rendering of an attached document
3
+ * instead of the binary.
4
+ *
5
+ * A pptx or xlsx read straight into the context window is close to the worst
6
+ * thing a token-saving tool can allow: the bytes are unreadable to the model,
7
+ * so it either gets nothing useful or spends a fortune finding that out. This
8
+ * intercepts the Read, converts the file once, caches the result, and points
9
+ * the model at the .md.
10
+ *
11
+ * CommonJS on purpose. It runs from ~/.claude/ through the copied hook script,
12
+ * where there is no package.json to declare `"type": "module"`, which is the
13
+ * same reason korean-lint.cjs is written this way.
14
+ *
15
+ * Conversion is markitdown, a Python package. It cannot be an npm dependency,
16
+ * so a missing install is an ordinary state rather than an error: say so once,
17
+ * then get out of the way and let the Read proceed untouched. Failing loudly
18
+ * on every Read would be worse than the problem being solved, and failing
19
+ * silently is how graphify's `except ImportError: return ""` hid a broken
20
+ * converter for months.
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ const fs = require('node:fs');
26
+ const os = require('node:os');
27
+ const path = require('node:path');
28
+ const crypto = require('node:crypto');
29
+ const { spawnSync } = require('node:child_process');
30
+
31
+ // Formats where the original is of no use to the model. Images are absent
32
+ // deliberately: markitdown returns nothing for them, and OCR misread resource
33
+ // names in testing (`c5.xlarge` as `c.xlarge`), which is worse than no text at
34
+ // all in a document where those names are the content. The model reads images
35
+ // natively anyway.
36
+ const TARGET_EXTENSIONS = ['.pptx', '.xlsx', '.xls', '.pdf', '.docx'];
37
+
38
+ // Big enough for real decks and reports, small enough that a hostile file
39
+ // cannot make the converter the expensive part of the turn.
40
+ const MAX_SOURCE_BYTES = 50 * 1024 * 1024;
41
+ // A conversion larger than this costs more to read than it saves.
42
+ const MAX_MARKDOWN_BYTES = 2 * 1024 * 1024;
43
+ // Cold `import markitdown` measured at ~12s; conversions after that are under
44
+ // two seconds except for very large workbooks, which the row cap handles.
45
+ const CONVERT_TIMEOUT_MS = 120_000;
46
+
47
+ // Names that suggest the file should not be left lying around as plain text.
48
+ // Deliberately blunt: the cost of skipping a payroll deck is one extra manual
49
+ // step, and the cost of caching it is not recoverable.
50
+ const SENSITIVE_PATTERNS = [
51
+ /secret/i, /password/i, /credential/i, /salary/i, /payroll/i, /confidential/i,
52
+ /개인정보/, /급여/, /계약/, /대외비/,
53
+ ];
54
+
55
+ // Mirrors src/paths.js userDataDir(). Duplicated rather than imported because
56
+ // this file is CommonJS and paths.js is ESM; the precedence order has to match
57
+ // it exactly, or conversions would land somewhere the rest of the tool does
58
+ // not look.
59
+ function userDataDir() {
60
+ if (process.env.XDG_CONFIG_HOME) {
61
+ return path.join(process.env.XDG_CONFIG_HOME, 'claude-token-saver');
62
+ }
63
+ if (process.platform === 'win32' && process.env.APPDATA) {
64
+ return path.join(process.env.APPDATA, 'claude-token-saver');
65
+ }
66
+ if (process.platform === 'darwin') {
67
+ return path.join(os.homedir(), 'Library', 'Application Support', 'claude-token-saver');
68
+ }
69
+ return path.join(os.homedir(), '.config', 'claude-token-saver');
70
+ }
71
+
72
+ /**
73
+ * Where conversions live.
74
+ *
75
+ * Not next to the original, and not in the project's own `.claude/`: either
76
+ * one drops a plain-text copy of a possibly confidential attachment into a
77
+ * directory people commit. Keeping it in the tool's own state directory means
78
+ * there is nothing for the user to remember to gitignore.
79
+ */
80
+ function cacheDir() {
81
+ return path.join(userDataDir(), 'doc2md-cache');
82
+ }
83
+
84
+ function ensureCacheDir() {
85
+ const dir = cacheDir();
86
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
87
+ // mkdir honours the mode only on creation, so an older directory made with
88
+ // the default mask is tightened here.
89
+ try { fs.chmodSync(dir, 0o700); } catch { /* best effort */ }
90
+ return dir;
91
+ }
92
+
93
+ function isTargetPath(filePath) {
94
+ if (typeof filePath !== 'string' || !filePath) return false;
95
+ return TARGET_EXTENSIONS.includes(path.extname(filePath).toLowerCase());
96
+ }
97
+
98
+ function isSensitivePath(filePath) {
99
+ const base = path.basename(filePath || '');
100
+ return SENSITIVE_PATTERNS.some((re) => re.test(base));
101
+ }
102
+
103
+ /**
104
+ * Cache path for a source file. The hash covers the absolute path, so two
105
+ * `report.pptx` files in different projects do not overwrite each other.
106
+ */
107
+ function cachePathFor(filePath) {
108
+ const abs = path.resolve(filePath);
109
+ const hash = crypto.createHash('sha256').update(abs).digest('hex').slice(0, 12);
110
+ const base = path.basename(abs).replace(/[^\w.\-]/g, '_');
111
+ return path.join(cacheDir(), `${base}.${hash}.md`);
112
+ }
113
+
114
+ function metaPathFor(cacheFile) {
115
+ return cacheFile.replace(/\.md$/, '.meta.json');
116
+ }
117
+
118
+ /** A cached conversion still matching the source's mtime and size, or null. */
119
+ function readCache(filePath) {
120
+ const cacheFile = cachePathFor(filePath);
121
+ try {
122
+ const src = fs.statSync(filePath);
123
+ const meta = JSON.parse(fs.readFileSync(metaPathFor(cacheFile), 'utf8'));
124
+ if (meta.size !== src.size || meta.mtimeMs !== src.mtimeMs) return null;
125
+ fs.statSync(cacheFile);
126
+ return { cacheFile, meta };
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ function writeCache(filePath, markdown, extra) {
133
+ ensureCacheDir();
134
+ const cacheFile = cachePathFor(filePath);
135
+ const src = fs.statSync(filePath);
136
+ fs.writeFileSync(cacheFile, markdown, { encoding: 'utf8', mode: 0o600 });
137
+ const meta = Object.assign({
138
+ source: path.resolve(filePath),
139
+ size: src.size,
140
+ mtimeMs: src.mtimeMs,
141
+ convertedAt: new Date().toISOString(),
142
+ }, extra || {});
143
+ fs.writeFileSync(metaPathFor(cacheFile), JSON.stringify(meta, null, 2), { encoding: 'utf8', mode: 0o600 });
144
+ return { cacheFile, meta };
145
+ }
146
+
147
+ /**
148
+ * A Python that can import markitdown, or null.
149
+ *
150
+ * Order matters: an explicit override first, then a `uv tool` install, then
151
+ * whatever is on PATH. Probing costs a process spawn each, so the answer is
152
+ * memoized for the life of this process, and the caller memoizes across
153
+ * processes through the notice file.
154
+ */
155
+ let interpreterCache;
156
+ function findInterpreter() {
157
+ if (interpreterCache !== undefined) return interpreterCache;
158
+ const candidates = [];
159
+ if (process.env.CTS_DOC2MD_PYTHON) candidates.push(process.env.CTS_DOC2MD_PYTHON);
160
+ candidates.push(
161
+ path.join(os.homedir(), '.local', 'share', 'uv', 'tools', 'markitdown', 'bin', 'python'),
162
+ path.join(os.homedir(), '.local', 'bin', 'markitdown-python'),
163
+ 'python3',
164
+ 'python',
165
+ );
166
+ for (const bin of candidates) {
167
+ try {
168
+ const probe = spawnSync(bin, ['-c', 'import markitdown'], { timeout: 20_000, stdio: 'ignore' });
169
+ if (probe.status === 0) {
170
+ interpreterCache = bin;
171
+ return bin;
172
+ }
173
+ } catch { /* candidate unusable, try the next */ }
174
+ }
175
+ interpreterCache = null;
176
+ return null;
177
+ }
178
+
179
+ const CONVERTER = path.join(__dirname, '..', 'presets', 'doc2md', 'convert.py');
180
+
181
+ /**
182
+ * Convert one file. Returns `{ ok: true, cacheFile, meta }`, or
183
+ * `{ ok: false, reason, detail }` where reason is one of:
184
+ * no-markitdown | too-large | sensitive | unsafe-archive | no-text |
185
+ * convert-failed | timeout
186
+ *
187
+ * Every failure is a reason to leave the original Read alone, never to break
188
+ * it. That is the whole contract with the hook.
189
+ */
190
+ function convert(filePath, { converter = CONVERTER, python: pythonOverride = null } = {}) {
191
+ if (!isTargetPath(filePath)) return { ok: false, reason: 'not-target' };
192
+ if (isSensitivePath(filePath)) return { ok: false, reason: 'sensitive' };
193
+
194
+ let stat;
195
+ try {
196
+ stat = fs.statSync(filePath);
197
+ } catch (e) {
198
+ return { ok: false, reason: 'missing', detail: String(e.message || e) };
199
+ }
200
+ if (stat.size > MAX_SOURCE_BYTES) {
201
+ return { ok: false, reason: 'too-large', detail: `${stat.size} bytes` };
202
+ }
203
+
204
+ const cached = readCache(filePath);
205
+ if (cached) return { ok: true, cached: true, cacheFile: cached.cacheFile, meta: cached.meta };
206
+
207
+ // The override exists so tests can drive a stub converter with any Python at
208
+ // all: the normal search insists the interpreter can import markitdown,
209
+ // which would make the whole path untestable without the real package.
210
+ const python = pythonOverride || findInterpreter();
211
+ if (!python) return { ok: false, reason: 'no-markitdown' };
212
+
213
+ const run = spawnSync(python, [converter, filePath], {
214
+ encoding: 'utf8',
215
+ timeout: CONVERT_TIMEOUT_MS,
216
+ maxBuffer: MAX_MARKDOWN_BYTES * 4,
217
+ });
218
+ if (run.error && run.error.code === 'ETIMEDOUT') return { ok: false, reason: 'timeout' };
219
+ if (run.status !== 0) {
220
+ return { ok: false, reason: 'convert-failed', detail: (run.stderr || '').slice(0, 300) };
221
+ }
222
+
223
+ let payload;
224
+ try {
225
+ payload = JSON.parse(run.stdout);
226
+ } catch {
227
+ return { ok: false, reason: 'convert-failed', detail: 'converter produced no JSON' };
228
+ }
229
+ if (!payload.ok) return { ok: false, reason: payload.reason, detail: payload.detail };
230
+
231
+ let markdown = payload.markdown || '';
232
+ let clipped = false;
233
+ if (Buffer.byteLength(markdown, 'utf8') > MAX_MARKDOWN_BYTES) {
234
+ // Reading a 20MB markdown file is the same waste in a different format.
235
+ markdown = markdown.slice(0, MAX_MARKDOWN_BYTES);
236
+ clipped = true;
237
+ }
238
+
239
+ const written = writeCache(filePath, markdown, {
240
+ note: payload.note || null,
241
+ truncated: !!payload.truncated || clipped,
242
+ rows: payload.rows || 0,
243
+ clipped,
244
+ });
245
+ return { ok: true, cached: false, cacheFile: written.cacheFile, meta: written.meta };
246
+ }
247
+
248
+ /** Where the "markitdown is not installed" notice records that it was shown. */
249
+ function noticePath() {
250
+ return path.join(userDataDir(), 'doc2md-notice.json');
251
+ }
252
+
253
+ function noticeAlreadyShown() {
254
+ try {
255
+ return JSON.parse(fs.readFileSync(noticePath(), 'utf8')).shown === true;
256
+ } catch {
257
+ return false;
258
+ }
259
+ }
260
+
261
+ function markNoticeShown() {
262
+ try {
263
+ fs.mkdirSync(userDataDir(), { recursive: true });
264
+ fs.writeFileSync(noticePath(), JSON.stringify({ shown: true, at: new Date().toISOString() }));
265
+ } catch { /* an unwritable state dir just means the notice repeats */ }
266
+ }
267
+
268
+ const INSTALL_HINT = 'pip install "markitdown[pptx,pdf,xlsx,docx]"';
269
+
270
+ /**
271
+ * Decide what to tell Claude Code about one PreToolUse(Read) payload.
272
+ *
273
+ * Returns null when the hook should stay out of the way, or a PreToolUse hook
274
+ * output object. Blocking is the right call for a successful conversion:
275
+ * allowing the Read and merely mentioning the .md would put the binary in the
276
+ * context window anyway, which is the cost this exists to avoid.
277
+ */
278
+ function decideForRead(context, opts = {}) {
279
+ if (!context || context.tool_name !== 'Read') return null;
280
+ const toolInput = context.tool_input;
281
+ const filePath = toolInput && typeof toolInput.file_path === 'string' ? toolInput.file_path : '';
282
+ if (!isTargetPath(filePath)) return null;
283
+
284
+ const result = convert(filePath, opts);
285
+ const name = path.basename(filePath);
286
+
287
+ if (result.ok) {
288
+ const bits = [`[doc2md] ${name} 는 Markdown 으로 변환했습니다.`];
289
+ bits.push(` 변환본: ${result.cacheFile}`);
290
+ if (result.meta && result.meta.note) bits.push(` ${result.meta.note}`);
291
+ if (result.meta && result.meta.clipped) {
292
+ bits.push(' 변환 결과가 너무 커서 뒷부분을 잘랐습니다. 전체가 필요하면 원본을 직접 다루십시오.');
293
+ }
294
+ bits.push(' 원본 대신 이 파일을 Read 하십시오. 원본을 직접 확인해야 한다면 그 이유를 밝히십시오.');
295
+ return { deny: true, reason: bits.join('\n') };
296
+ }
297
+
298
+ // From here down the Read is allowed through untouched. The only question is
299
+ // whether the model is told why nothing was converted.
300
+ if (result.reason === 'no-markitdown') {
301
+ if (noticeAlreadyShown()) return null;
302
+ markNoticeShown();
303
+ return {
304
+ deny: false,
305
+ reason: `[doc2md] ${name} 를 변환하려 했으나 markitdown 이 설치되어 있지 않습니다.\n`
306
+ + ` 설치: ${INSTALL_HINT}\n`
307
+ + ' 설치 전까지는 원본을 그대로 읽습니다. 이 안내는 한 번만 표시됩니다.',
308
+ };
309
+ }
310
+ if (result.reason === 'no-text') {
311
+ return {
312
+ deny: false,
313
+ reason: `[doc2md] ${name} 에서 본문 텍스트를 추출하지 못했습니다(스캔 PDF 로 보입니다). 원본을 직접 확인하십시오.`,
314
+ };
315
+ }
316
+ // The one case that blocks without converting. A file that expands to fill
317
+ // the disk is not something to hand on to the next reader with a shrug.
318
+ if (result.reason === 'unsafe-archive') {
319
+ return {
320
+ deny: true,
321
+ reason: `[doc2md] ${name} 는 압축 폭탄으로 보여 변환하지 않았습니다 (${result.detail}). 신뢰할 수 있는 파일인지 먼저 확인하십시오.`,
322
+ };
323
+ }
324
+ if (result.reason === 'bad-archive') {
325
+ return {
326
+ deny: false,
327
+ reason: `[doc2md] ${name} 는 압축 파일로 열리지 않습니다 (${result.detail}). 내려받다 끊겼을 수 있습니다.`,
328
+ };
329
+ }
330
+ if (result.reason === 'too-large') {
331
+ return {
332
+ deny: false,
333
+ reason: `[doc2md] ${name} 는 크기 상한(50MB)을 넘어 변환하지 않았습니다 (${result.detail}).`,
334
+ };
335
+ }
336
+ if (result.reason === 'sensitive') {
337
+ return {
338
+ deny: false,
339
+ reason: `[doc2md] ${name} 는 파일명이 민감 문서 패턴에 걸려 변환하지 않았습니다. 평문 사본을 남기지 않기 위한 조치입니다.`,
340
+ };
341
+ }
342
+ if (result.reason === 'timeout' || result.reason === 'convert-failed') {
343
+ return {
344
+ deny: false,
345
+ reason: `[doc2md] ${name} 변환에 실패했습니다(${result.reason}). 원본을 그대로 읽습니다.`,
346
+ };
347
+ }
348
+ return null;
349
+ }
350
+
351
+ /** The decision rendered as the JSON Claude Code expects on stdout. */
352
+ function formatHookOutput(decision) {
353
+ if (!decision) return null;
354
+ return JSON.stringify({
355
+ hookSpecificOutput: {
356
+ hookEventName: 'PreToolUse',
357
+ permissionDecision: decision.deny ? 'deny' : 'allow',
358
+ permissionDecisionReason: decision.reason,
359
+ },
360
+ });
361
+ }
362
+
363
+ module.exports = {
364
+ TARGET_EXTENSIONS,
365
+ MAX_SOURCE_BYTES,
366
+ INSTALL_HINT,
367
+ cacheDir,
368
+ cachePathFor,
369
+ metaPathFor,
370
+ isTargetPath,
371
+ isSensitivePath,
372
+ findInterpreter,
373
+ readCache,
374
+ writeCache,
375
+ convert,
376
+ decideForRead,
377
+ formatHookOutput,
378
+ };
@@ -280,10 +280,27 @@ export function formatReport(data, { color = true, verbose = false, timer = true
280
280
  // infer the bucket. Default to 1h-sized countdown rather than 5m so Max
281
281
  // users on idle don't see a misleading "Cache expires 5:00". The bucket
282
282
  // label is shown as "?" so the uncertainty is visible.
283
+ //
284
+ // That default is exactly backwards behind a gateway. Bedrock and Vertex
285
+ // never report the per-bucket split, so ttl.total stays 0 there forever, and
286
+ // they offer only the 5m bucket: the countdown opened at 59:59 for a window
287
+ // that was really 5:00, overstating it twelvefold. So the fallback now
288
+ // follows the evidence — gateway seen, assume 5m; otherwise keep 1h. An
289
+ // explicit `ttlBucket` setting outranks both, so a gateway that starts
290
+ // reporting the split correctly does not need a release to be believed.
283
291
  const hasTtlData = ttl.total > 0;
284
- const is1h = hasTtlData ? ttl.pct1h >= 0.5 : true;
285
- const bucketLabel = hasTtlData ? (is1h ? '1h' : '5m') : '?';
286
- const bucketColor = hasTtlData ? (is1h ? GREEN : YELLOW) : GRAY;
292
+ const override = data.ttlBucket === '5m' || data.ttlBucket === '1h' ? data.ttlBucket : null;
293
+ const is1h = override ? override === '1h' : (hasTtlData ? ttl.pct1h >= 0.5 : !ttl.gatewayObserved);
294
+ // Three grades of certainty, three labels: measured (`1h`/`5m`), inferred
295
+ // from a gateway model id (`5m?`), and unknown (`?`). Folding the middle
296
+ // case into `?` would hide a judgement the user could otherwise check.
297
+ const bucketKnown = hasTtlData || !!override;
298
+ const bucketLabel = bucketKnown
299
+ ? (is1h ? '1h' : '5m')
300
+ : (ttl.gatewayObserved ? '5m?' : '?');
301
+ const bucketColor = bucketKnown
302
+ ? (is1h ? GREEN : YELLOW)
303
+ : (ttl.gatewayObserved ? YELLOW : GRAY);
287
304
  const ttlSeconds = is1h ? 3600 : 300;
288
305
 
289
306
  const savings = cost?.savings ?? 0;
@@ -320,9 +337,18 @@ export function formatReport(data, { color = true, verbose = false, timer = true
320
337
  const delegateLabel = isIcon
321
338
  ? (verbose ? '🔀 Routing saved' : '🔀')
322
339
  : 'Routing saved';
340
+ // Zero savings has two very different causes and, until now, one appearance:
341
+ // nothing at all. "Never delegated" and "delegated plenty, but every run was
342
+ // dropped because the gateway model id could not be resolved" looked
343
+ // identical, so users in the second case had no reason to suspect anything
344
+ // was wrong. The count gets a chip; the explanation stays in `route-scan
345
+ // rules`, where there is room for it.
346
+ const unresolvedRuns = Number(data.unresolvedRuns) || 0;
323
347
  const delegateSeg = delegationSaved > 0
324
348
  ? `${c(GREEN)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}`
325
- : null;
349
+ : (unresolvedRuns > 0
350
+ ? `${c(YELLOW)}🔀 ${unresolvedRuns} unresolved${c(RESET)}`
351
+ : null);
326
352
 
327
353
  // Routing-savings headline line (multi-line layout). The lifetime sum from
328
354
  // the delegation ledger — the number the whole tool exists to grow, so it
@@ -397,11 +423,15 @@ export function formatReport(data, { color = true, verbose = false, timer = true
397
423
  const remaining = Math.min(ttlSeconds, ttlSeconds - elapsed);
398
424
  const text = formatTimer(remaining);
399
425
  const pct = remaining / ttlSeconds;
426
+ // Percentages are the wrong unit in a 5-minute bucket: 30% of it is 90
427
+ // seconds, and green there reads as comfort the user does not have. Below
428
+ // an hour the thresholds are absolute, so the color tracks whether there
429
+ // is time to finish a thought rather than a share of a short window.
400
430
  const timerColor =
401
431
  remaining <= 0 ? RED :
402
- pct > 0.30 ? GREEN :
403
- pct > 0.10 ? YELLOW :
404
- RED;
432
+ is1h
433
+ ? (pct > 0.30 ? GREEN : pct > 0.10 ? YELLOW : RED)
434
+ : (remaining > 60 ? GREEN : remaining > 30 ? YELLOW : RED);
405
435
 
406
436
  if (isIcon && verbose) {
407
437
  // Drop bucket here too — `⏳ Expires 1h 57:20` reads as "1h 57m 20s left"
@@ -241,7 +241,14 @@ export function formatReport({ summary: sum, trend, ttl, anomalies, cost, option
241
241
  lines.push(' ' + tableRow(['Without cache', `$${cost.noCacheCost}`], costW, costA));
242
242
  lines.push(' ' + tableSep(costW));
243
243
  lines.push(' ' + tableRow(['Savings', `$${cost.savings} (${pct(cost.savingsRate)})`], costW, costA));
244
- lines.push(' ' + tableRow(['Extra cost if 5m-only', `+$${cost.extraCostIf5m}`], costW, costA));
244
+ // Only worth asking of someone who has 1h writes to lose. Everyone else got
245
+ // a `+$0` that read as an endorsement of the 5m bucket they were already
246
+ // stuck in.
247
+ if (cost.extraCostIf5mApplicable === false) {
248
+ lines.push(' ' + tableRow(['Already 5m-only', ttl.gatewayObserved ? 'gateway' : 'yes'], costW, costA));
249
+ } else {
250
+ lines.push(' ' + tableRow(['Extra cost if 5m-only', `+$${cost.extraCostIf5m}`], costW, costA));
251
+ }
245
252
  lines.push(' ' + tableBot(costW));
246
253
  lines.push('');
247
254
 
package/src/installer.js CHANGED
@@ -346,6 +346,75 @@ export function removeKoreanLintHook() {
346
346
  return { path: file, action: 'removed' };
347
347
  }
348
348
 
349
+ // Registers the PreToolUse hook that converts attached documents to Markdown
350
+ // before the model reads them. PreToolUse rather than PostToolUse because the
351
+ // point is to intervene before a pptx lands in the context window; afterwards
352
+ // the tokens are already spent.
353
+ //
354
+ // No `timeout` is set, on purpose. Claude Code defaults command hooks to ten
355
+ // minutes, so naming a number here could only lower that ceiling, and a cold
356
+ // `import markitdown` measured at twelve seconds by itself, with a very large
357
+ // workbook adding a minute on top. The row cap inside the converter is what
358
+ // actually bounds the work; the timeout is only a backstop.
359
+ // Installed by `doc2md on`, removed by `doc2md off`. Idempotent.
360
+ const DOC2MD_HOOK_COMMAND = 'claude-token-saver doc2md --hook';
361
+
362
+ export function installDoc2mdHook() {
363
+ const dir = claudeUserDir();
364
+ const file = join(dir, 'settings.json');
365
+ mkdirSync(dir, { recursive: true });
366
+
367
+ let settings = {};
368
+ if (existsSync(file)) {
369
+ try {
370
+ settings = JSON.parse(readFileSync(file, 'utf8'));
371
+ } catch (e) {
372
+ return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
373
+ }
374
+ }
375
+
376
+ settings.hooks = settings.hooks || {};
377
+ if (settings.hooks.PreToolUse !== undefined && !Array.isArray(settings.hooks.PreToolUse)) {
378
+ return { path: file, action: 'skipped', reason: 'hooks.PreToolUse is not an array — fix settings.json manually' };
379
+ }
380
+ const list = Array.isArray(settings.hooks.PreToolUse) ? settings.hooks.PreToolUse : [];
381
+ const already = list.some((m) =>
382
+ Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('doc2md --hook')),
383
+ );
384
+ if (already) return { path: file, action: 'exists' };
385
+
386
+ list.push({
387
+ matcher: 'Read',
388
+ hooks: [{ type: 'command', command: DOC2MD_HOOK_COMMAND }],
389
+ });
390
+ settings.hooks.PreToolUse = list;
391
+ writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
392
+ return { path: file, action: 'created' };
393
+ }
394
+
395
+ export function removeDoc2mdHook() {
396
+ const file = join(claudeUserDir(), 'settings.json');
397
+ if (!existsSync(file)) return { path: file, action: 'absent' };
398
+ let settings;
399
+ try {
400
+ settings = JSON.parse(readFileSync(file, 'utf8'));
401
+ } catch (e) {
402
+ return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
403
+ }
404
+ const list = settings?.hooks?.PreToolUse;
405
+ if (!Array.isArray(list)) return { path: file, action: 'absent' };
406
+ // Only this tool's own entry goes; anything else registered under
407
+ // PreToolUse stays exactly where the user put it.
408
+ const kept = list.filter((m) =>
409
+ !(Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('doc2md --hook'))),
410
+ );
411
+ if (kept.length === list.length) return { path: file, action: 'absent' };
412
+ if (kept.length === 0) delete settings.hooks.PreToolUse;
413
+ else settings.hooks.PreToolUse = kept;
414
+ writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
415
+ return { path: file, action: 'removed' };
416
+ }
417
+
349
418
  export function installAll({ force = false } = {}) {
350
419
  return {
351
420
  skill: installSkill({ force }),
@@ -35,7 +35,10 @@ const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
35
35
 
36
36
  export const KOREAN_STYLE_PATH = join(packageRoot, 'presets', 'korean-style', 'fluent-korean.md');
37
37
  export const KOREAN_STYLE_LICENSE_PATH = join(packageRoot, 'presets', 'korean-style', 'LICENSE-fluent-korean');
38
- export const KOREAN_STYLE_SOURCE = 'fluent-korean by snflkd (MIT) — https://github.com/snflkd/fluent-korean';
38
+ // The separator here is a colon, not an em dash. The guidance this line cites
39
+ // bans em dashes in Korean prose, and shipping one inside its own attribution
40
+ // is the kind of contradiction that teaches the model the rule is negotiable.
41
+ export const KOREAN_STYLE_SOURCE = 'fluent-korean by snflkd (MIT): https://github.com/snflkd/fluent-korean';
39
42
 
40
43
  /** Whether session-start injection is enabled. Off unless the user asked. */
41
44
  export function koreanStyleEnabled(cfg = loadConfig()) {
@@ -134,6 +137,13 @@ export function koreanScopeLines(cfg = loadConfig()) {
134
137
  return [
135
138
  '적용 대상: 대화 답변, 그리고 세션이 쓰거나 고치는 모든 파일의 한국어를 포함합니다. 문서와 보고서는 물론이고 코드 주석, 화면에 나가는 문자열, 자막과 템플릿, 스크립트가 읽어 산출물을 만드는 데이터 파일까지 모두 해당합니다.',
136
139
  '적용 예외: 원문을 그대로 옮기는 인용과, 프로젝트의 기존 표기 관례를 따라야 하는 커밋 메시지와 로그 문자열입니다.',
140
+ // The vendored text says the opposite of the line above, and it says so
141
+ // twice. Left unaddressed the model has no way to decide which sentence
142
+ // governs a code comment, and in September 2026 that ambiguity shipped em
143
+ // dashes into comments and log strings while the guidance was active. The
144
+ // vendored wording stays untouched, so the conflict is resolved by naming
145
+ // which side wins rather than by editing the source.
146
+ '아래에 이어지는 fluent-korean 본문은 "인용, 코드, 코드 주석에는 이 지침들을 적용하지 않습니다"라고 적고 있으며 동작 범위 2번 조항에서 한 번 더 강조합니다. 그러나 이 세션에서는 바로 위의 적용 대상과 적용 예외가 그 문장보다 우선합니다. 즉 코드 주석에도 지침을 적용하십시오.',
137
147
  '쓰기 시점에 기계 검사가 함께 돌아갑니다. 위반을 알리면 그 파일을 고친 뒤에 다음 작업으로 넘어가십시오.',
138
148
  ];
139
149
  }
@@ -69,8 +69,14 @@ const ROLES = ['main', 'opus', 'sonnet', 'haiku', 'fable'];
69
69
 
70
70
  /**
71
71
  * Alias for a role, taken from the environment Claude Code itself uses to
72
- * pick subagent models. Returns null when the variable is absent or is itself
73
- * an ARN (resolving an ARN to another ARN would loop).
72
+ * pick subagent models. Returns null when the variable is absent, or when it
73
+ * is an opaque ARN that names no model (resolving one of those to another ARN
74
+ * would loop).
75
+ *
76
+ * A `foundation-model` ARN is not opaque: it spells the model out in its
77
+ * resource part, so it is unwrapped rather than rejected. Users who point
78
+ * these variables straight at an ARN — a normal way to configure a private
79
+ * gateway — used to get no delegation stats at all, and no hint as to why.
74
80
  */
75
81
  export function aliasForRole(role, env = process.env) {
76
82
  const candidates = {
@@ -81,7 +87,13 @@ export function aliasForRole(role, env = process.env) {
81
87
  fable: [env.ANTHROPIC_DEFAULT_FABLE_MODEL],
82
88
  }[role] || [];
83
89
  for (const v of candidates) {
84
- if (typeof v === 'string' && v && !isGatewayModelId(v)) return v;
90
+ if (typeof v !== 'string' || !v) continue;
91
+ if (!isGatewayModelId(v)) return v;
92
+ // `arn:…:foundation-model/anthropic.claude-haiku-4-5-…` → the model id.
93
+ // An `application-inference-profile` id is a random string and stays
94
+ // rejected: guessing at it is how wrong prices get into the ledger.
95
+ const inner = profileIdFrom(v);
96
+ if (inner && /claude/i.test(inner)) return inner;
85
97
  }
86
98
  return null;
87
99
  }
package/src/parser.js CHANGED
@@ -4,10 +4,23 @@ import { createInterface } from 'node:readline';
4
4
  import { join, isAbsolute } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import { loadCache, getCached, putCached, saveCache } from './session-cache.js';
7
- import { resolveModelAlias } from './model-alias.js';
7
+ import { resolveModelAlias, isGatewayModelId } from './model-alias.js';
8
8
 
9
9
  const CLAUDE_DIR = join(homedir(), '.claude', 'projects');
10
10
 
11
+ /**
12
+ * Keys LiteLLM adds when it rewrites a Bedrock response into Anthropic shape.
13
+ * A stock Anthropic `usage` object carries none of them, so their presence is
14
+ * evidence of a gateway even when the model id looks ordinary. It is weak
15
+ * evidence — another gateway may not add them — so it is only consulted after
16
+ * the model id has already failed to answer the question.
17
+ */
18
+ const GATEWAY_USAGE_KEYS = ['inference_geo', 'iterations', 'speed'];
19
+
20
+ function usageLooksGatewayShaped(usage) {
21
+ return GATEWAY_USAGE_KEYS.some((k) => Object.prototype.hasOwnProperty.call(usage, k));
22
+ }
23
+
11
24
  /**
12
25
  * Parse a single session JSONL file.
13
26
  * Deduplicates by requestId (last-write-wins for streaming chunks).
@@ -17,6 +30,7 @@ export async function parseSessionFile(filePath) {
17
30
  let sessionId = null;
18
31
  let firstTimestamp = null;
19
32
  let lastTimestamp = null;
33
+ let gatewayObserved = false;
20
34
 
21
35
  const rl = createInterface({
22
36
  input: createReadStream(filePath, { encoding: 'utf8' }),
@@ -48,6 +62,14 @@ export async function parseSessionFile(filePath) {
48
62
  const cc = usage.cache_creation || {};
49
63
  const reqId = entry.requestId || msg.id;
50
64
 
65
+ // Recorded from the RAW id, before resolveModelAlias() turns the ARN into
66
+ // a plain model name. Downstream this is the only thing that distinguishes
67
+ // "no cache writes yet" from "a gateway that never reports the TTL split",
68
+ // and those two states want opposite countdown defaults.
69
+ if (!gatewayObserved && (isGatewayModelId(msg.model) || usageLooksGatewayShaped(usage))) {
70
+ gatewayObserved = true;
71
+ }
72
+
51
73
  requests.set(reqId, {
52
74
  requestId: reqId,
53
75
  model: resolveModelAlias(msg.model),
@@ -87,6 +109,7 @@ export async function parseSessionFile(filePath) {
87
109
  totals,
88
110
  maxContextPerRequest,
89
111
  model: reqs[0]?.model || 'unknown',
112
+ gatewayObserved,
90
113
  };
91
114
  }
92
115
 
@@ -26,7 +26,10 @@ import { debug } from './debug.js';
26
26
  const CACHE_PATH = join(userDataDir(), 'session-cache.json');
27
27
  // Bump when the cached summary's shape changes — old entries are dropped
28
28
  // wholesale rather than migrated.
29
- const CACHE_VERSION = 1;
29
+ // 2: sessions carry `gatewayObserved`. Entries written by version 1 lack it,
30
+ // and a missing flag reads as "not a gateway" — the wrong default for exactly
31
+ // the users the flag exists for.
32
+ const CACHE_VERSION = 2;
30
33
  // Entries for transcripts this old are pruned on write. Keeps the file
31
34
  // bounded without an existence check per entry (which would cost the syscalls
32
35
  // the cache exists to avoid).
package/src/stats.js CHANGED
@@ -59,10 +59,12 @@ export function dailyTrend(sessions) {
59
59
  export function ttlBreakdown(sessions) {
60
60
  let total5m = 0;
61
61
  let total1h = 0;
62
+ let gatewayObserved = false;
62
63
 
63
64
  for (const s of sessions) {
64
65
  total5m += s.totals.ephemeral5m;
65
66
  total1h += s.totals.ephemeral1h;
67
+ if (s.gatewayObserved) gatewayObserved = true;
66
68
  }
67
69
 
68
70
  const total = total5m + total1h;
@@ -72,6 +74,12 @@ export function ttlBreakdown(sessions) {
72
74
  total,
73
75
  pct5m: total > 0 ? total5m / total : 0,
74
76
  pct1h: total > 0 ? total1h / total : 0,
77
+ // Bedrock and Vertex fill only the `cache_creation_input_tokens` sum and
78
+ // leave the per-bucket split at zero, so `total === 0` there means "cannot
79
+ // tell" rather than "no cache writes happened". Carrying the observation
80
+ // alongside the numbers lets the display layer tell those two apart
81
+ // without going back to the environment.
82
+ gatewayObserved,
75
83
  };
76
84
  }
77
85
 
@@ -135,6 +143,7 @@ export function sessionMetrics(session) {
135
143
  outputRatio,
136
144
  writeToReadRatio,
137
145
  maxContextPerRequest: session.maxContextPerRequest || 0,
146
+ gatewayObserved: !!session.gatewayObserved,
138
147
  totals: t,
139
148
  };
140
149
  }
@@ -217,11 +226,23 @@ export function diagnoseSession(metrics, baseline) {
217
226
  });
218
227
  }
219
228
 
220
- if (metrics.pct5m > 0.7 && (metrics.totals.ephemeral5m + metrics.totals.ephemeral1h) > 0) {
229
+ // The split is only knowable when the provider reports it. Requiring that
230
+ // sum outright meant gateway users — who are on a 5m-only backend and so
231
+ // need this warning most — never saw it at all.
232
+ const ttlSplitKnown = (metrics.totals.ephemeral5m + metrics.totals.ephemeral1h) > 0;
233
+ if (ttlSplitKnown && metrics.pct5m > 0.7) {
221
234
  issues.push({
222
235
  code: 'BUCKET_5M_DOMINANT',
223
236
  pct5m: metrics.pct5m,
224
237
  });
238
+ } else if (!ttlSplitKnown && metrics.gatewayObserved && metrics.totals.cacheCreation > 0) {
239
+ // Bedrock and Vertex offer no 1h bucket, so every write is a 5m write.
240
+ // The advice differs from the subscription case: no plan change fixes it.
241
+ issues.push({
242
+ code: 'BUCKET_5M_DOMINANT_GATEWAY',
243
+ pct5m: 1.0,
244
+ inferred: true,
245
+ });
225
246
  }
226
247
 
227
248
  if (metrics.outputRatio > 0.15) {