claude-token-saver 3.9.2 โ†’ 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -77,6 +77,7 @@ The statusline appears at the bottom of Claude Code right away. If auto-registra
77
77
  | `โœฆ current` / `๐Ÿ“… weekly` | 5-hour / 7-day rate-limit window usage + reset time |
78
78
  | `๐Ÿ“ฆ` | Context usage (e.g. `Ctx 68% of 1M`) โ€” colored by fill. Current models default to 1M with no premium, but token volume itself drives per-turn cost and 5H/7D burn |
79
79
  | `๐Ÿ’ฐ` | Cumulative savings from prompt caching |
80
+ | `๐Ÿ”€` | Cumulative savings from delegation โ€” what running work on a cheaper tier saved, a different number from `๐Ÿ’ฐ`. Hidden until a delegation has actually been measured |
80
81
 
81
82
  When something is wrong, a **warning chip leads the line**:
82
83
 
@@ -202,6 +203,24 @@ claude-token-saver route-scan rules # list model-fitting rules (rm
202
203
 
203
204
  Dig deeper: **tier criteria & research evidence** โ†’ [docs/TIER_CRITERIA.md](./docs/TIER_CRITERIA.md) (Korean) ยท **rule-file mechanics, scan triggers, subagent setup** โ†’ [docs/ROUTE_SCAN.md](./docs/ROUTE_SCAN.md) (Korean + English)
204
205
 
206
+ ### Behind a gateway (Bedrock / LiteLLM)
207
+
208
+ Through a corporate gateway the transcript records an inference-profile ARN where the model id belongs. That string says nothing about `opus` or `haiku`, so older versions read every session as Sonnet โ€” which made **T1 (โ†’sonnet) rules unreachable and zeroed the savings figures**.
209
+
210
+ Since v3.10.0 the profile id is mapped back to a role (main, opus, sonnet, haiku) and then to the alias your `ANTHROPIC_DEFAULT_*_MODEL` variables declare. The mapping is learned by joining each parent `Task` call to the subagent run it spawned via `toolUseId`. Below three observations, or when the role votes agree less than 80% of the time, the id stays `unknown` and drops out of the delegation aggregate rather than being guessed at.
211
+
212
+ For environments the learner cannot reach, write the mapping yourself in `<userDataDir>/profile-map.json`. Account id and region may be wildcarded:
213
+
214
+ ```jsonc
215
+ {
216
+ "modelAliases": {
217
+ "arn:aws:bedrock:*:*:application-inference-profile/<PROFILE_ID>": "claude-opus-5"
218
+ }
219
+ }
220
+ ```
221
+
222
+ That file holds internal identifiers in plain text โ€” do not commit it. On a direct-API machine it is never created and behaviour is unchanged.
223
+
205
224
  ## Spike issue codes
206
225
 
207
226
  | Code | Meaning |
@@ -282,6 +301,16 @@ Also update `statusLine.command` in `~/.claude/settings.json` to `claude-token-s
282
301
 
283
302
  ## Release notes
284
303
 
304
+ ### v3.11.0 (2026-08-21)
305
+ - **Delegation savings chip in the statusline** โ€” `๐Ÿ”€ Delegated $3.2`. The existing `๐Ÿ’ฐ Cache saved` covers the prompt cache; this one is what running work on a cheaper tier saved. Zero or missing data hides the chip entirely, so nothing changes for direct-API users. The statusline only reads `model-rules.json` โ€” it never triggers a scan (it re-renders every few seconds). Segment name: `delegated`.
306
+ - **Role-learning mis-classification fixed** โ€” subagent records sometimes land in the parent transcript without an `isSidechain` flag, and that circumstantial evidence could outvote stated evidence, confirming a haiku profile as the session model (measured: 16 against 1, agreement exactly at the 80% line). Stated evidence (`Task(model:)` parameters, agent-definition frontmatter) and inferred evidence are now tallied separately, and an inference is not adopted when stated evidence contradicts it. Undecided ids stay `unknown` and leave the aggregate.
307
+
308
+ ### v3.10.0 (2026-08-20)
309
+ - **Model tiers are detected again behind a Bedrock / LiteLLM gateway** โ€” when the transcript's model id is an inference-profile ARN there is no `opus` or `haiku` in the string, so it fell back to Sonnet. Since `worthDelegating()` requires `rank > target`, **every T1 rule was rejected**, savings aggregated to zero, and cost was under-counted by roughly 1.67x. The profile id is now learned as a role (parent `Task` call joined to the subagent run by `toolUseId`) and mapped back to the alias your environment declares. The pricing table, the ranks, and the tiering logic are untouched.
310
+ - **No confident mapping means no guess** โ€” under three observations, or below 80% agreement, the id stays `unknown` and leaves the delegation aggregate. Quietly calling it Sonnet was the worse failure.
311
+ - **Manual override** โ€” `modelAliases` in `<userDataDir>/profile-map.json`, wildcards allowed. No profile id or AWS account id is ever hardcoded in this package.
312
+ - Direct-API machines behave **exactly as before** and no new file is written.
313
+
285
314
  ### v3.9.2 (2026-08-01)
286
315
  - **Added a LICENSE file (MIT)** โ€” the field existed in `package.json` but the file did not, which blocked license review for company adoption. It ships in the npm tarball now via `files`.
287
316
  - **Package description and keywords rewritten for what this actually does** โ€” leftover cache-monitoring copy meant it never surfaced for `model-routing` / `delegation` / `subagent`.
package/README.md CHANGED
@@ -76,6 +76,7 @@ npm i -g claude-token-saver
76
76
  | `โœฆ current` / `๐Ÿ“… weekly` | 5์‹œ๊ฐ„ / 7์ผ rate-limit ์œˆ๋„ ์‚ฌ์šฉ๋ฅ  + ๋ฆฌ์…‹ ์‹œ๊ฐ |
77
77
  | `๐Ÿ“ฆ` | ์ปจํ…์ŠคํŠธ ์‚ฌ์šฉ๋ฅ  (์˜ˆ: `Ctx 68% of 1M`) โ€” ์‚ฌ์šฉ๋ฅ  ๊ธฐ์ค€ ๋…น/ํ™ฉ/์ . ํ˜„์žฌ ๋ชจ๋ธ์€ 1M์ด ๊ธฐ๋ณธยทํ”„๋ฆฌ๋ฏธ์—„ ์—†์Œ์ด์ง€๋งŒ, ํ† ํฐ๋Ÿ‰ ์ž์ฒด๊ฐ€ ํ„ด๋‹น ๋น„์šฉ๊ณผ 5H/7D ํ•œ๋„๋ฅผ ํƒœ์›๋‹ˆ๋‹ค |
78
78
  | `๐Ÿ’ฐ` | ์บ์‹œ๊ฐ€ ์ ˆ์•ฝํ•ด์ค€ ๋ˆ„์  ๊ธˆ์•ก |
79
+ | `๐Ÿ”€` | ์œ„์ž„์ด ์ ˆ์•ฝํ•ด์ค€ ๋ˆ„์  ๊ธˆ์•ก โ€” ๋” ์‹ผ ํ‹ฐ์–ด๋กœ ๋‚ด๋ ค์„œ ์•„๋‚€ ๋ชซ์ด๋ฉฐ `๐Ÿ’ฐ`์™€๋Š” ๋‹ค๋ฅธ ์ˆ˜์น˜์ž…๋‹ˆ๋‹ค. ์‹ค์ธก ์œ„์ž„์ด ์—†์œผ๋ฉด ํ‘œ์‹œ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค |
79
80
 
80
81
  ๋ฌธ์ œ๊ฐ€ ๊ฐ์ง€๋˜๋ฉด **๊ฒฝ๊ณ  ์นฉ์ด ๋งจ ์•ž์—** ๋ถ™์Šต๋‹ˆ๋‹ค:
81
82
 
@@ -182,6 +183,24 @@ claude-token-saver route-scan rules # ๋“ฑ๋ก๋œ ๋ชจ๋ธ ํ”ผํŒ… ๋ฃฐ
182
183
 
183
184
  ๋” ์•Œ์•„๋ณด๊ธฐ: **ํ‹ฐ์–ด ๊ธฐ์ค€ยท๋ฆฌ์„œ์น˜ ๊ทผ๊ฑฐ** โ†’ [docs/TIER_CRITERIA.md](./docs/TIER_CRITERIA.md) ยท **๋ฃฐ ํŒŒ์ผ ๊ตฌ์กฐยท์Šค์บ” ํŠธ๋ฆฌ๊ฑฐยท์„œ๋ธŒ์—์ด์ „ํŠธ ์ค€๋น„** โ†’ [docs/ROUTE_SCAN.md](./docs/ROUTE_SCAN.md)
184
185
 
186
+ ### ๊ฒŒ์ดํŠธ์›จ์ด(BedrockยทLiteLLM) ๊ฒฝ์œ  ํ™˜๊ฒฝ
187
+
188
+ ์‚ฌ๋‚ด ๊ฒŒ์ดํŠธ์›จ์ด๋ฅผ ๊ฑฐ์น˜๋ฉด ๋กœ๊ทธ์˜ ๋ชจ๋ธ๋ช… ์ž๋ฆฌ์— ์ถ”๋ก  ํ”„๋กœํŒŒ์ผ ARN์ด ๊ธฐ๋ก๋ฉ๋‹ˆ๋‹ค. ๊ทธ ๋ฌธ์ž์—ด์—๋Š” `opus`ยท`haiku` ๊ฐ™์€ ๋‹จ์„œ๊ฐ€ ์—†์–ด์„œ ์˜ˆ์ „ ๋ฒ„์ „์€ ์ด๊ฒƒ์„ ์ „๋ถ€ Sonnet์œผ๋กœ ์ฝ์—ˆ๊ณ , ๊ทธ ๊ฒฐ๊ณผ **T1(โ†’sonnet) ์œ„์ž„ ๋ฃฐ์ด ํ•˜๋‚˜๋„ ์ œ์•ˆ๋˜์ง€ ์•Š์•˜์œผ๋ฉฐ ์ ˆ๊ฐ ์ง‘๊ณ„๊ฐ€ 0**์ด์—ˆ์Šต๋‹ˆ๋‹ค.
189
+
190
+ v3.10.0๋ถ€ํ„ฐ๋Š” ํ”„๋กœํŒŒ์ผ ID๋ฅผ ์—ญํ• (mainยทopusยทsonnetยทhaiku)๋กœ ๋˜๋Œ๋ฆฐ ๋’ค `ANTHROPIC_DEFAULT_*_MODEL` ํ™˜๊ฒฝ๋ณ€์ˆ˜๊ฐ€ ์„ ์–ธํ•œ ๋ณ„์นญ์œผ๋กœ ์น˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ๋งคํ•‘์€ ๋ถ€๋ชจ ์„ธ์…˜์˜ `Task` ํ˜ธ์ถœ๊ณผ ์„œ๋ธŒ์—์ด์ „ํŠธ ๊ธฐ๋ก์„ `toolUseId`๋กœ ์กฐ์ธํ•ด ์Šค์Šค๋กœ ํ•™์Šตํ•˜๋ฉฐ, ๊ด€์ธก์ด 3๊ฑด ๋ฏธ๋งŒ์ด๊ฑฐ๋‚˜ ์—ญํ•  ํŒ์ •์ด 80% ๋ฏธ๋งŒ์œผ๋กœ ๊ฐˆ๋ฆฌ๋ฉด **์ถ”์ธกํ•˜์ง€ ์•Š๊ณ  `unknown`์œผ๋กœ ๋‘๊ณ  ์œ„์ž„ ์ง‘๊ณ„์—์„œ ์ œ์™ธ**ํ•ฉ๋‹ˆ๋‹ค.
191
+
192
+ ์ž๋™ ํ•™์Šต์ด ๋‹ฟ์ง€ ์•Š๋Š” ํ™˜๊ฒฝ์„ ์œ„ํ•œ ์ˆ˜๋™ ๊ฒฝ๋กœ๋„ ์žˆ์Šต๋‹ˆ๋‹ค. `<userDataDir>/profile-map.json`์— ์•„๋ž˜์ฒ˜๋Ÿผ ์ ์œผ๋ฉด ๋˜๊ณ , ๊ณ„์ • ID์™€ ๋ฆฌ์ „์€ `*`๋กœ ๊ฐ€๋ ค๋„ ๋งค์นญ๋ฉ๋‹ˆ๋‹ค.
193
+
194
+ ```jsonc
195
+ {
196
+ "modelAliases": {
197
+ "arn:aws:bedrock:*:*:application-inference-profile/<PROFILE_ID>": "claude-opus-5"
198
+ }
199
+ }
200
+ ```
201
+
202
+ ์ด ํŒŒ์ผ์—๋Š” ์‚ฌ๋‚ด ์‹๋ณ„์ž๊ฐ€ ํ‰๋ฌธ์œผ๋กœ ๋‚จ์œผ๋ฏ€๋กœ ์ €์žฅ์†Œ์— ์ปค๋ฐ‹ํ•˜์ง€ ๋งˆ์‹ญ์‹œ์˜ค. ๊ฒŒ์ดํŠธ์›จ์ด๋ฅผ ์“ฐ์ง€ ์•Š๋Š” ํ™˜๊ฒฝ์—์„œ๋Š” ํŒŒ์ผ์ด ์•„์˜ˆ ๋งŒ๋“ค์–ด์ง€์ง€ ์•Š๊ณ  ๊ธฐ์กด ๋™์ž‘์ด ๊ทธ๋Œ€๋กœ ์œ ์ง€๋ฉ๋‹ˆ๋‹ค.
203
+
185
204
  ## ํ† ํฐ ๊ธ‰์ฆ ์›์ธ ์ฝ”๋“œ
186
205
 
187
206
  | ์ฝ”๋“œ | ์˜๋ฏธ |
@@ -238,6 +257,16 @@ npm uninstall -g claude-cache-monitor && npm i -g claude-token-saver
238
257
 
239
258
  ## ๋ฆด๋ฆฌ์Šค ๋…ธํŠธ
240
259
 
260
+ ### v3.11.0 (2026-08-21)
261
+ - **statusline์— ์œ„์ž„ ์ ˆ๊ฐ ์นฉ ์ถ”๊ฐ€** โ€” `๐Ÿ”€ Delegated $3.2`. ๊ธฐ์กด `๐Ÿ’ฐ Cache saved`๋Š” ํ”„๋กฌํ”„ํŠธ ์บ์‹œ ์ ˆ๊ฐ์ด๊ณ , ์ƒˆ ์นฉ์€ **๋” ์‹ผ ํ‹ฐ์–ด๋กœ ๋‚ด๋ ค์„œ ์•„๋‚€ ๊ธˆ์•ก**์ž…๋‹ˆ๋‹ค. ๊ฐ’์ด 0์ด๊ฑฐ๋‚˜ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์œผ๋ฉด ์นฉ์„ ์•„์˜ˆ ๊ทธ๋ฆฌ์ง€ ์•Š์•„ ์ง์ ‘ API ์‚ฌ์šฉ์ž์—๊ฒŒ๋Š” ์•„๋ฌด๊ฒƒ๋„ ๋Š˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. statusline์€ `model-rules.json`์„ ์ฝ๊ธฐ๋งŒ ํ•˜๋ฉฐ ์Šค์บ”์„ ๋Œ๋ฆฌ์ง€ ์•Š์Šต๋‹ˆ๋‹ค(5์ดˆ๋งˆ๋‹ค ํ˜ธ์ถœ๋˜๋Š” ์ž๋ฆฌ์ž…๋‹ˆ๋‹ค). ์„ธ๊ทธ๋จผํŠธ ์ด๋ฆ„์€ `delegated`์ž…๋‹ˆ๋‹ค.
262
+ - **์—ญํ•  ํ•™์Šต์˜ ์˜ค๋ถ„๋ฅ˜ ์ˆ˜์ •** โ€” ์„œ๋ธŒ์—์ด์ „ํŠธ ๋ ˆ์ฝ”๋“œ๊ฐ€ ๋ถ€๋ชจ ํŠธ๋žœ์Šคํฌ๋ฆฝํŠธ์— `isSidechain` ์—†์ด ์„ž์—ฌ ๋“ค์–ด์˜ค๋Š” ๊ฒฝ์šฐ๊ฐ€ ์žˆ์–ด, ๊ทธ ์ •ํ™ฉ ์ฆ๊ฑฐ๊ฐ€ ๋ช…์‹œ ์ฆ๊ฑฐ๋ฅผ ์ด๊ฒจ haiku ํ”„๋กœํŒŒ์ผ์ด ์„ธ์…˜ ๋ชจ๋ธ๋กœ ํ™•์ •๋˜๊ณค ํ–ˆ์Šต๋‹ˆ๋‹ค(์‹ค์ธก 16๊ฑด ๋Œ€ 1๊ฑด, ํ•ฉ์˜์œจ ์ •ํ™•ํžˆ 80%). ์ด์ œ **๋ช…์‹œ ์ฆ๊ฑฐ(`Task(model:)` ํŒŒ๋ผ๋ฏธํ„ฐยท์—์ด์ „ํŠธ ์ •์˜ frontmatter)์™€ ์ •ํ™ฉ ์ฆ๊ฑฐ(sidechain ํ”Œ๋ž˜๊ทธ ๋ถ€์žฌ)๋ฅผ ๋ถ„๋ฆฌํ•ด ์ง‘๊ณ„**ํ•˜๊ณ , ๋ช…์‹œ ์ฆ๊ฑฐ๊ฐ€ ์ด๋ฅผ ๋ฐ˜๋ฐ•ํ•˜๋ฉด ์ •ํ™ฉ ์ถ”๋ก ์„ ์ฑ„ํƒํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํ™•์ •์ด ์•ˆ ๋˜๋ฉด `unknown`์œผ๋กœ ๋‚จ์•„ ์ง‘๊ณ„์—์„œ ๋น ์ง‘๋‹ˆ๋‹ค.
263
+
264
+ ### v3.10.0 (2026-08-20)
265
+ - **๊ฒŒ์ดํŠธ์›จ์ด(BedrockยทLiteLLM) ํ™˜๊ฒฝ์—์„œ ๋ชจ๋ธ ํ‹ฐ์–ด๋ฅผ ๋‹ค์‹œ ์ธ์‹ํ•ฉ๋‹ˆ๋‹ค** โ€” ๋กœ๊ทธ์˜ ๋ชจ๋ธ๋ช…์ด ์ถ”๋ก  ํ”„๋กœํŒŒ์ผ ARN์ด๋ฉด `opus`ยท`haiku` ๋‹จ์„œ๊ฐ€ ์—†์–ด Sonnet์œผ๋กœ ํด๋ฐฑํ–ˆ๊ณ , `worthDelegating()`์ด `rank > target`์„ ์š”๊ตฌํ•˜๋ฏ€๋กœ **T1 ์œ„์ž„์ด ์ „๋ถ€ ๊ธฐ๊ฐ**๋์Šต๋‹ˆ๋‹ค. ์ ˆ๊ฐ ์ง‘๊ณ„๋Š” 0, ๋น„์šฉ์€ ์•ฝ 1.67๋ฐฐ ๊ณผ์†Œ ๊ณ„์ƒ์ด์—ˆ์Šต๋‹ˆ๋‹ค. ์ด์ œ ํ”„๋กœํŒŒ์ผ ID๋ฅผ ์—ญํ• ๋กœ ํ•™์Šตํ•ด(๋ถ€๋ชจ `Task` ํ˜ธ์ถœ โ†” ์„œ๋ธŒ์—์ด์ „ํŠธ `toolUseId` ์ •ํ™• ์กฐ์ธ) ํ™˜๊ฒฝ๋ณ€์ˆ˜๊ฐ€ ์„ ์–ธํ•œ ๋ณ„์นญ์œผ๋กœ ๋˜๋Œ๋ฆฝ๋‹ˆ๋‹ค. ๊ฐ€๊ฒฉํ‘œยท๋žญํฌยทํŒ์ • ๋กœ์ง์€ ๊ทธ๋Œ€๋กœ์ž…๋‹ˆ๋‹ค.
266
+ - **ํ™•์‹ ์ด ์—†์œผ๋ฉด ์ˆจ๊ธฐ์ง€ ์•Š๊ณ  ๋“œ๋Ÿฌ๋ƒ…๋‹ˆ๋‹ค** โ€” ๊ด€์ธก 3๊ฑด ๋ฏธ๋งŒ์ด๊ฑฐ๋‚˜ ์—ญํ•  ๋™์˜์œจ 80% ๋ฏธ๋งŒ์ด๋ฉด `unknown`์œผ๋กœ ๋‘๊ณ  ์œ„์ž„ ์ง‘๊ณ„์—์„œ ์ œ์™ธํ•ฉ๋‹ˆ๋‹ค. Sonnet์œผ๋กœ ์กฐ์šฉํžˆ ํ‹€๋ฆฌ๋˜ ๊ธฐ์กด ๋™์ž‘์ด ๋” ๋‚˜๋นด์Šต๋‹ˆ๋‹ค.
267
+ - **์ˆ˜๋™ ์˜ค๋ฒ„๋ผ์ด๋“œ** โ€” `<userDataDir>/profile-map.json`์˜ `modelAliases`์— ์™€์ผ๋“œ์นด๋“œ ํŒจํ„ด์œผ๋กœ ์ง์ ‘ ์ง€์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ”„๋กœํŒŒ์ผ IDยทAWS ๊ณ„์ • ID๋Š” ์†Œ์Šค์— ์ „ํ˜€ ๋„ฃ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
268
+ - ๊ฒŒ์ดํŠธ์›จ์ด๋ฅผ ์“ฐ์ง€ ์•Š๋Š” ํ™˜๊ฒฝ์€ **๋™์ž‘์ด ์™„์ „ํžˆ ๋™์ผ**ํ•ฉ๋‹ˆ๋‹ค(ํŒŒ์ผ๋„ ๋งŒ๋“ค์ง€ ์•Š์Šต๋‹ˆ๋‹ค).
269
+
241
270
  ### v3.9.2 (2026-08-01)
242
271
  - **LICENSE ํŒŒ์ผ ์ถ”๊ฐ€ (MIT)** โ€” `package.json`์—๋งŒ ์žˆ๊ณ  ํŒŒ์ผ์ด ์—†์–ด์„œ, ์‚ฌ๋‚ด ๋„์ž… ๊ฒ€ํ†  ์‹œ ๋ผ์ด์„ ์Šค ํ™•์ธ์ด ๋ง‰ํžˆ๋˜ ๋ฌธ์ œ. npm ํŒจํ‚ค์ง€์—๋„ ํฌํ•จ๋˜๋„๋ก `files`์— ๋„ฃ์—ˆ์Šต๋‹ˆ๋‹ค.
243
272
  - **ํŒจํ‚ค์ง€ ์„ค๋ช…ยทํ‚ค์›Œ๋“œ๋ฅผ ํ˜„์žฌ ๊ธฐ๋Šฅ์— ๋งž๊ฒŒ ๊ต์ฒด** โ€” ์บ์‹œ ๋ชจ๋‹ˆํ„ฐ๋ง ์‹œ์ ˆ ๋ฌธ๊ตฌ๊ฐ€ ๋‚จ์•„ ์žˆ์–ด ๋ชจ๋ธ ์œ„์ž„(`model-routing`ยท`delegation`ยท`subagent`)์œผ๋กœ ๊ฒ€์ƒ‰๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.
package/bin/cli.js CHANGED
@@ -433,6 +433,17 @@ async function main() {
433
433
  }
434
434
  const lastActivity = Math.max(otherLastActivity, currentSessionLastUser);
435
435
 
436
+ // What delegation has measurably saved, read straight from the registry
437
+ // route-scan maintains. A lookup, never a scan: the statusline re-renders
438
+ // every few seconds and a scan parses tens of MB of transcripts.
439
+ let delegationSaved = 0;
440
+ try {
441
+ const { delegationSavedUsd } = await import('../src/model-rules.js');
442
+ delegationSaved = delegationSavedUsd();
443
+ } catch (e) {
444
+ debug('model-rules:saved', e); // an unreadable registry just hides the chip
445
+ }
446
+
436
447
  const data = {
437
448
  summary: sum,
438
449
  trend,
@@ -447,6 +458,7 @@ async function main() {
447
458
  spikeChip,
448
459
  caps,
449
460
  model,
461
+ delegationSaved,
450
462
  };
451
463
 
452
464
  let output;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "3.9.2",
3
+ "version": "3.11.0",
4
4
  "description": "Route the easy work your expensive Claude model keeps repeating down to haiku/sonnet โ€” post-hoc session analysis, no realtime router, no extra LLM calls.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cost.js CHANGED
@@ -117,7 +117,22 @@ const TIER_RANK = {
117
117
  'claude-haiku-3': 0,
118
118
  };
119
119
 
120
+ /**
121
+ * True for the explicit 'unknown' marker โ€” an id that could not be resolved
122
+ * at all (see model-alias.js), as opposed to an id this table simply has no
123
+ * entry for. The two must not share a fate: an unresolved gateway id counted
124
+ * as Sonnet silently corrupts every delegation statistic, so it is dropped
125
+ * from the ranking instead of guessed at.
126
+ */
127
+ export function isUnknownModel(model) {
128
+ return !model || String(model).toLowerCase() === 'unknown';
129
+ }
130
+
120
131
  export function modelRank(model) {
132
+ // -1 sits below every real tier, so worthDelegating() rejects it and
133
+ // tierForRank() attributes no saving to it: the run leaves the aggregate
134
+ // rather than distorting it.
135
+ if (isUnknownModel(model)) return -1;
121
136
  const rank = TIER_RANK[detectPricingTier(model)];
122
137
  // Unknown ids fall through detectPricingTier to the Sonnet tier; ranking
123
138
  // them 1 keeps the conservative reading (cheap enough that a Sonnet-target
@@ -211,7 +211,7 @@ export function formatNoSession({ caps = null, model = null, windowLabel = '' }
211
211
  * @param {boolean} [opts.verbose=false] - longer layout with labels
212
212
  * @param {boolean} [opts.timer=true] - show TTL countdown segment
213
213
  * @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses ๐Ÿง  โณ ๐Ÿ’ฐ instead of word labels.
214
- * @param {string[]|null} [opts.segments] - whitelist of segments to render. Names: cap-warn, spike, harness, model, hit, ttl, saved, ctx, period, plus per-window keys (`five_hour`, `seven_day`, โ€ฆ). `5h`/`7d` are kept as aliases for back-compat. Null/undefined = all.
214
+ * @param {string[]|null} [opts.segments] - whitelist of segments to render. Names: cap-warn, spike, harness, model, hit, ttl, saved, delegated, ctx, period, plus per-window keys (`five_hour`, `seven_day`, โ€ฆ). `5h`/`7d` are kept as aliases for back-compat. Null/undefined = all.
215
215
  */
216
216
  export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null } = {}) {
217
217
  const { summary, ttl, cost, options, lastActivity, contextWindow, ctxLive, spikeChip, caps, model } = data;
@@ -255,6 +255,21 @@ export function formatReport(data, { color = true, verbose = false, timer = true
255
255
  : 'Cache saved';
256
256
  const saveSeg = `${c(CYAN)}${saveLabel}${c(RESET)} ${formatMoney(savings)}`;
257
257
 
258
+ // Delegation savings โ€” a DIFFERENT number from "Cache saved" above, which
259
+ // covers the prompt cache only. This one is what running work on a cheaper
260
+ // tier saved, summed from the rule registry route-scan maintains. Hidden
261
+ // when zero or absent: a permanent "$0" is noise for direct-API users and
262
+ // for anyone who has not delegated yet.
263
+ // text: "Delegated $3.2" | same in verbose
264
+ // icon: "๐Ÿ”€ $3.2" | verbose: "๐Ÿ”€ Delegated $3.2"
265
+ const delegationSaved = Number(data.delegationSaved) || 0;
266
+ const delegateLabel = isIcon
267
+ ? (verbose ? '๐Ÿ”€ Delegated' : '๐Ÿ”€')
268
+ : 'Delegated';
269
+ const delegateSeg = delegationSaved > 0
270
+ ? `${c(GREEN)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}`
271
+ : null;
272
+
258
273
  // Period label honors hour-precision configs (`mode 6h` โ†’ "6h", `mode 1d` โ†’ "1d").
259
274
  // Fall back to legacy `${days}d` when callers haven't supplied a label.
260
275
  const periodLabel = options.windowLabel || `${options.days}d`;
@@ -482,6 +497,8 @@ export function formatReport(data, { color = true, verbose = false, timer = true
482
497
  // it sits near the tail. The period label closes the line as a quiet
483
498
  // timeframe footer.
484
499
  if (want('saved')) segs.push(saveSeg);
500
+ // Both savings numbers sit side by side, then the period label closes the line.
501
+ if (delegateSeg && want('delegated')) segs.push(delegateSeg);
485
502
  if (want('period')) segs.push(periodSeg);
486
503
  // Trailing erase-to-end-of-line so any leftover characters from a previous
487
504
  // (longer) statusline render don't bleed into ours. \x1b[K is the standard
@@ -0,0 +1,437 @@
1
+ /**
2
+ * model-alias โ€” restore a usable model name when the transcript records a
3
+ * gateway identifier instead of a Claude model id.
4
+ *
5
+ * Why this exists: behind a Bedrock / LiteLLM gateway, `message.model` in the
6
+ * transcript is an inference-profile ARN:
7
+ *
8
+ * converse/arn:aws:bedrock:<region>:<account>:application-inference-profile/<id>
9
+ *
10
+ * Nothing in that string says "opus" or "haiku", so `detectPricingTier()`
11
+ * falls through to its Sonnet default. Everything downstream then reads the
12
+ * session as Sonnet: `worthDelegating('T1', 1)` is false, so every T1 rule is
13
+ * rejected, delegation savings aggregate to zero, and cost is under-counted.
14
+ *
15
+ * The fix is a single normalization point rather than a change to the pricing
16
+ * table โ€” plain aliases (`ap-northeast-2.anthropic.claude-opus-5[1m]`) are
17
+ * already classified correctly, region prefix and `[1m]` suffix included. So
18
+ * all that is missing is ARN โ†’ alias.
19
+ *
20
+ * Resolution order, cheapest first:
21
+ * 1. not an ARN โ†’ return the input unchanged (direct-API users
22
+ * must keep their existing behaviour)
23
+ * 2. user override โ†’ `modelAliases` in profile-map.json
24
+ * 3. learned mapping โ†’ profile id โ†’ role, learned from transcripts
25
+ * 4. otherwise โ†’ 'unknown' (never a silent Sonnet guess)
26
+ *
27
+ * A profile id is never hardcoded here. Ids differ per account and change
28
+ * with gateway config, and the ARN embeds a 12-digit AWS account id โ€” this
29
+ * package is published to npm, so neither may live in the source.
30
+ */
31
+
32
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, createReadStream } from 'node:fs';
33
+ import { readFile, readdir } from 'node:fs/promises';
34
+ import { createInterface } from 'node:readline';
35
+ import { join, dirname, basename } from 'node:path';
36
+ import { userDataDir, claudeUserDir } from './paths.js';
37
+
38
+ /** Marker returned when a gateway id could not be resolved to a model. */
39
+ export const UNKNOWN_MODEL = 'unknown';
40
+
41
+ /** File holding user overrides plus the learned profileโ†’role votes. */
42
+ export function profileMapPath() {
43
+ return join(userDataDir(), 'profile-map.json');
44
+ }
45
+
46
+ // `converse/` (LiteLLM) or a bare ARN, foundation- or application-scoped.
47
+ const ARN_RE =
48
+ /arn:aws:bedrock:[^:]*:[^:]*:(?:application-)?inference-profile\/([A-Za-z0-9._:-]+)/;
49
+
50
+ /** True when the id came from a Bedrock gateway rather than the Claude API. */
51
+ export function isGatewayModelId(model) {
52
+ return typeof model === 'string' && model.includes('arn:aws:bedrock:');
53
+ }
54
+
55
+ /** The inference-profile id inside an ARN, or null when there is none. */
56
+ export function profileIdFrom(model) {
57
+ if (typeof model !== 'string') return null;
58
+ const m = ARN_RE.exec(model);
59
+ return m ? m[1] : null;
60
+ }
61
+
62
+ /**
63
+ * Roles a profile id can carry. 'main' is the session's own model, which the
64
+ * env declares separately from the per-tier subagent overrides.
65
+ */
66
+ const ROLES = ['main', 'opus', 'sonnet', 'haiku', 'fable'];
67
+
68
+ /**
69
+ * Alias for a role, taken from the environment Claude Code itself uses to
70
+ * pick subagent models. Returns null when the variable is absent or is itself
71
+ * an ARN (resolving an ARN to another ARN would loop).
72
+ */
73
+ export function aliasForRole(role, env = process.env) {
74
+ const candidates = {
75
+ main: [env.ANTHROPIC_MODEL, env.ANTHROPIC_DEFAULT_MODEL, env.ANTHROPIC_DEFAULT_OPUS_MODEL],
76
+ opus: [env.ANTHROPIC_DEFAULT_OPUS_MODEL, env.ANTHROPIC_MODEL],
77
+ sonnet: [env.ANTHROPIC_DEFAULT_SONNET_MODEL],
78
+ haiku: [env.ANTHROPIC_DEFAULT_HAIKU_MODEL],
79
+ fable: [env.ANTHROPIC_DEFAULT_FABLE_MODEL],
80
+ }[role] || [];
81
+ for (const v of candidates) {
82
+ if (typeof v === 'string' && v && !isGatewayModelId(v)) return v;
83
+ }
84
+ return null;
85
+ }
86
+
87
+ // โ”€โ”€ override / learned map storage โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
88
+
89
+ let cached = null;
90
+
91
+ /** Read profile-map.json (memoized). Missing or corrupt file โ†’ empty map. */
92
+ export function loadProfileMap() {
93
+ if (cached) return cached;
94
+ let data = {};
95
+ try {
96
+ data = JSON.parse(readFileSync(profileMapPath(), 'utf8'));
97
+ } catch { /* absent on first run, and unreadable is not fatal */ }
98
+ cached = {
99
+ version: 1,
100
+ modelAliases: data.modelAliases && typeof data.modelAliases === 'object' ? data.modelAliases : {},
101
+ learned: data.learned && typeof data.learned === 'object' ? data.learned : {},
102
+ learnedAt: data.learnedAt || null,
103
+ scannedSessions: data.scannedSessions || 0,
104
+ };
105
+ return cached;
106
+ }
107
+
108
+ export function saveProfileMap(map) {
109
+ const dir = userDataDir();
110
+ mkdirSync(dir, { recursive: true });
111
+ writeFileSync(profileMapPath(), JSON.stringify(map, null, 2));
112
+ cached = map;
113
+ }
114
+
115
+ /** Drop the memoized map. Tests use this after pointing paths elsewhere. */
116
+ export function resetModelAliasCache() {
117
+ cached = null;
118
+ }
119
+
120
+ /**
121
+ * Glob match for override keys, so a user can write one entry that hides the
122
+ * account id and region: `arn:aws:bedrock:*:*:application-inference-profile/x`.
123
+ */
124
+ function globMatch(pattern, value) {
125
+ const rx = new RegExp(
126
+ '^' + pattern.split('*').map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$',
127
+ );
128
+ return rx.test(value);
129
+ }
130
+
131
+ function overrideAlias(model, map) {
132
+ for (const [pattern, alias] of Object.entries(map.modelAliases || {})) {
133
+ if (globMatch(pattern, model)) return alias;
134
+ // Overrides are usually written without the LiteLLM `converse/` prefix.
135
+ const bare = model.slice(model.indexOf('arn:aws:bedrock:'));
136
+ if (globMatch(pattern, bare)) return alias;
137
+ }
138
+ return null;
139
+ }
140
+
141
+ // โ”€โ”€ resolution โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
142
+
143
+ /**
144
+ * Normalize one transcript model id.
145
+ * Non-gateway ids pass through untouched; gateway ids resolve to an alias, or
146
+ * to 'unknown' when the mapping is not confident yet.
147
+ */
148
+ export function resolveModelAlias(rawModel, { env = process.env } = {}) {
149
+ if (!rawModel) return UNKNOWN_MODEL;
150
+ const model = String(rawModel);
151
+ if (!isGatewayModelId(model)) return model;
152
+
153
+ const map = loadProfileMap();
154
+
155
+ const override = overrideAlias(model, map);
156
+ if (override) return override;
157
+
158
+ const pid = profileIdFrom(model);
159
+ if (!pid) return UNKNOWN_MODEL;
160
+
161
+ const entry = map.learned?.[pid];
162
+ if (entry?.role) {
163
+ const alias = aliasForRole(entry.role, env);
164
+ if (alias) return alias;
165
+ }
166
+ return UNKNOWN_MODEL;
167
+ }
168
+
169
+ // โ”€โ”€ learning โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
170
+
171
+ // A single observation can be wrong: the time-adjacent parent records leak
172
+ // into a naive join, and a mis-set agent definition mislabels one run. Require
173
+ // a few observations that mostly agree before trusting a mapping.
174
+ export const MIN_VOTES = 3;
175
+ export const MIN_AGREEMENT = 0.8;
176
+
177
+ /** Role named directly by a Task call's `model` parameter. */
178
+ function roleFromModelParam(value) {
179
+ if (typeof value !== 'string') return null;
180
+ const v = value.toLowerCase();
181
+ return ROLES.find((r) => r !== 'main' && v.includes(r)) || null;
182
+ }
183
+
184
+ /** Role declared in a subagent definition's frontmatter (`model: haiku`). */
185
+ function roleFromAgentType(agentType, cache) {
186
+ if (!agentType) return null;
187
+ if (cache.has(agentType)) return cache.get(agentType);
188
+ let role = null;
189
+ for (const dir of [join(claudeUserDir(), 'agents'), join(process.cwd(), '.claude', 'agents')]) {
190
+ const file = join(dir, `${agentType}.md`);
191
+ if (!existsSync(file)) continue;
192
+ try {
193
+ const head = readFileSync(file, 'utf8').slice(0, 2000);
194
+ const m = /^model:\s*([A-Za-z0-9._-]+)/m.exec(head);
195
+ if (m) role = roleFromModelParam(m[1]);
196
+ } catch { /* unreadable definition just yields no vote */ }
197
+ if (role) break;
198
+ }
199
+ cache.set(agentType, role);
200
+ return role;
201
+ }
202
+
203
+ /**
204
+ * Record one observation.
205
+ *
206
+ * `kind` matters more than the count. An 'explicit' vote comes from a stated
207
+ * model โ€” a `Task(model: "haiku")` parameter or an agent definition's
208
+ * frontmatter. An 'inferred' vote is circumstantial: the record carried no
209
+ * sidechain flag, so it is *probably* the parent session's own model. Mixing
210
+ * the two by volume let 4 inferred votes outrank 1 explicit one and stamped a
211
+ * haiku profile as 'main' โ€” see tallyVotes.
212
+ */
213
+ function addVote(votes, pid, role, kind) {
214
+ if (!pid || !role) return;
215
+ const v = (votes[pid] ||= { explicit: {}, inferred: {} });
216
+ const bucket = v[kind] || (v[kind] = {});
217
+ bucket[role] = (bucket[role] || 0) + 1;
218
+ }
219
+
220
+ /**
221
+ * Read one main transcript: which profile id the session itself ran on, and
222
+ * which role each Task/Agent tool_use asked for (joined later by tool_use id).
223
+ */
224
+ async function scanMainTranscript(path, votes, requestedByToolUse, agentTypeCache) {
225
+ let sawGateway = false;
226
+ const rl = createInterface({
227
+ input: createReadStream(path, { encoding: 'utf8' }),
228
+ crlfDelay: Infinity,
229
+ });
230
+ try {
231
+ for await (const line of rl) {
232
+ if (!line.includes('arn:aws:bedrock:') && !line.includes('"Task"') && !line.includes('"Agent"')) {
233
+ continue;
234
+ }
235
+ let entry;
236
+ try {
237
+ entry = JSON.parse(line);
238
+ } catch {
239
+ continue;
240
+ }
241
+ const msg = entry.message;
242
+ if (!msg) continue;
243
+
244
+ // The session's own model: the parent side of the transcript.
245
+ if (msg.model && entry.isSidechain !== true) {
246
+ const pid = profileIdFrom(msg.model);
247
+ if (pid) {
248
+ sawGateway = true;
249
+ // Circumstantial: a subagent's records also land in the parent file
250
+ // without the flag often enough to outvote real evidence.
251
+ addVote(votes, pid, 'main', 'inferred');
252
+ }
253
+ }
254
+
255
+ if (!Array.isArray(msg.content)) continue;
256
+ for (const block of msg.content) {
257
+ if (!block || block.type !== 'tool_use') continue;
258
+ if (block.name !== 'Task' && block.name !== 'Agent') continue;
259
+ const input = block.input || {};
260
+ const role = roleFromModelParam(input.model)
261
+ || roleFromAgentType(input.subagent_type, agentTypeCache);
262
+ if (block.id && role) requestedByToolUse.set(block.id, role);
263
+ }
264
+ }
265
+ } finally {
266
+ rl.close();
267
+ }
268
+ return sawGateway;
269
+ }
270
+
271
+ /** First profile id used by a subagent transcript (a run uses exactly one). */
272
+ async function subagentProfileId(path) {
273
+ const rl = createInterface({
274
+ input: createReadStream(path, { encoding: 'utf8' }),
275
+ crlfDelay: Infinity,
276
+ });
277
+ try {
278
+ for await (const line of rl) {
279
+ if (!line.includes('arn:aws:bedrock:')) continue;
280
+ let entry;
281
+ try {
282
+ entry = JSON.parse(line);
283
+ } catch {
284
+ continue;
285
+ }
286
+ const pid = profileIdFrom(entry.message?.model);
287
+ if (pid) return pid;
288
+ }
289
+ } finally {
290
+ rl.close();
291
+ }
292
+ return null;
293
+ }
294
+
295
+ /** Subagent transcripts a session spawned (mirrors subagent-records layout). */
296
+ async function subagentFiles(sessionPath) {
297
+ const dir = join(dirname(sessionPath), basename(sessionPath, '.jsonl'), 'subagents');
298
+ try {
299
+ return (await readdir(dir)).filter((f) => f.endsWith('.jsonl')).map((f) => join(dir, f));
300
+ } catch {
301
+ return [];
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Whether two roles describe the same model. 'main' and 'opus' routinely do:
307
+ * the session model is the opus alias on a default setup, so a Task that asked
308
+ * for opus does not contradict "this is the parent's own profile".
309
+ */
310
+ function rolesAgree(a, b) {
311
+ if (!a || !b) return false;
312
+ if (a === b) return true;
313
+ const pair = new Set([a, b]);
314
+ return pair.has('main') && pair.has('opus');
315
+ }
316
+
317
+ /** Winner of one bucket, with the counts needed to judge confidence. */
318
+ function topRole(tally) {
319
+ let role = null;
320
+ let top = 0;
321
+ let total = 0;
322
+ for (const [r, n] of Object.entries(tally || {})) {
323
+ total += n;
324
+ if (n > top) { top = n; role = r; }
325
+ }
326
+ return { role, top, total };
327
+ }
328
+
329
+ /**
330
+ * Decide a role per profile id.
331
+ *
332
+ * Explicit evidence decides alone whenever there is enough of it; inferred
333
+ * evidence is only consulted when the explicit bucket is too thin. Counting
334
+ * both together is what produced the original mis-classification: a haiku
335
+ * profile appeared 16 times in parent transcripts without a sidechain flag
336
+ * against 1,702 times as a subagent, and those 16 inferred 'main' votes beat
337
+ * the single explicit 'haiku' one at exactly the 80% line.
338
+ *
339
+ * When neither bucket is decisive the id stays unresolved. An 'unknown' that
340
+ * drops out of the aggregate beats a confident wrong answer that silently
341
+ * re-tiers every run on that profile.
342
+ */
343
+ export function tallyVotes(votes, { minVotes = MIN_VOTES, minAgreement = MIN_AGREEMENT } = {}) {
344
+ const learned = {};
345
+ for (const [pid, buckets] of Object.entries(votes)) {
346
+ // Legacy flat shape (`{ haiku: 3 }`) is read as explicit evidence.
347
+ const split = buckets && (buckets.explicit || buckets.inferred)
348
+ ? { explicit: buckets.explicit || {}, inferred: buckets.inferred || {} }
349
+ : { explicit: buckets || {}, inferred: {} };
350
+
351
+ const explicit = topRole(split.explicit);
352
+ const inferred = topRole(split.inferred);
353
+
354
+ let role = null;
355
+ let source = null;
356
+ if (explicit.total >= minVotes && explicit.top / explicit.total >= minAgreement) {
357
+ role = explicit.role;
358
+ source = 'explicit';
359
+ } else if (inferred.total >= minVotes
360
+ && inferred.top / inferred.total >= minAgreement
361
+ && (explicit.total === 0 || rolesAgree(explicit.role, inferred.role))) {
362
+ // Thin explicit evidence still vetoes a contradicting inference: one
363
+ // stated `Task(model: "haiku")` outweighs any number of "no sidechain
364
+ // flag, so probably the session model" observations.
365
+ role = inferred.role;
366
+ source = 'inferred';
367
+ }
368
+
369
+ learned[pid] = {
370
+ role,
371
+ source,
372
+ votes: split,
373
+ total: explicit.total + inferred.total,
374
+ };
375
+ }
376
+ return learned;
377
+ }
378
+
379
+ /**
380
+ * Learn profile id โ†’ role from transcripts and persist the result.
381
+ *
382
+ * The join is exact rather than time-windowed: `.meta.json` carries the
383
+ * `toolUseId` of the Task block that spawned the run, and that block names the
384
+ * model tier. A subagent transcript uses exactly one profile id, so the run's
385
+ * id and the requested role identify each other.
386
+ *
387
+ * @param {object} opts
388
+ * @param {string[]} opts.sessionPaths transcripts to read, newest first
389
+ * @param {number} opts.maxSessions cap on files read (learning is a scan)
390
+ * @returns {Promise<{learned: object, scannedSessions: number, gateway: boolean}>}
391
+ */
392
+ export async function learnProfileMapping({ sessionPaths = [], maxSessions = 40 } = {}) {
393
+ const votes = {};
394
+ const agentTypeCache = new Map();
395
+ let scanned = 0;
396
+ let gateway = false;
397
+
398
+ for (const sessionPath of sessionPaths.slice(0, maxSessions)) {
399
+ const requestedByToolUse = new Map();
400
+ let sawGateway = false;
401
+ try {
402
+ sawGateway = await scanMainTranscript(sessionPath, votes, requestedByToolUse, agentTypeCache);
403
+ } catch {
404
+ continue;
405
+ }
406
+ scanned += 1;
407
+
408
+ for (const jsonl of await subagentFiles(sessionPath)) {
409
+ let meta = null;
410
+ try {
411
+ meta = JSON.parse(await readFile(jsonl.replace(/\.jsonl$/, '.meta.json'), 'utf8'));
412
+ } catch { /* pre-toolUseId runs simply cast no vote */ }
413
+ const role = (meta?.toolUseId && requestedByToolUse.get(meta.toolUseId))
414
+ || roleFromAgentType(meta?.agentType, agentTypeCache);
415
+ if (!role) continue;
416
+ const pid = await subagentProfileId(jsonl);
417
+ if (!pid) continue;
418
+ sawGateway = true;
419
+ // Stated evidence: the Task call named this tier, or the agent
420
+ // definition it used did.
421
+ addVote(votes, pid, role, 'explicit');
422
+ }
423
+ if (sawGateway) gateway = true;
424
+ }
425
+
426
+ const learned = tallyVotes(votes);
427
+ const map = loadProfileMap();
428
+ const next = {
429
+ ...map,
430
+ learned,
431
+ learnedAt: new Date().toISOString(),
432
+ scannedSessions: scanned,
433
+ };
434
+ // Nothing to record on a non-gateway machine โ€” do not create the file there.
435
+ if (gateway || Object.keys(map.learned || {}).length) saveProfileMap(next);
436
+ return { learned, scannedSessions: scanned, gateway };
437
+ }
@@ -100,6 +100,27 @@ export function loadModelRules() {
100
100
  }
101
101
  }
102
102
 
103
+ /**
104
+ * USD that measured delegations have saved, summed across registered rules.
105
+ * Reads the file route-scan already wrote โ€” the statusline refreshes every few
106
+ * seconds and must never start a scan of its own (a scan parses tens of MB of
107
+ * transcripts).
108
+ *
109
+ * Rules with no measured delegation contribute nothing, and an unreadable
110
+ * registry returns 0 rather than throwing: the statusline reads 0 as "draw no
111
+ * chip", which is the right outcome for anyone who never delegates.
112
+ */
113
+ export function delegationSavedUsd() {
114
+ try {
115
+ return loadModelRules().rules.reduce(
116
+ (sum, r) => sum + (r.delegatedRuns ? (Number(r.savedUsd) || 0) : 0),
117
+ 0,
118
+ );
119
+ } catch {
120
+ return 0;
121
+ }
122
+ }
123
+
103
124
  export function saveModelRules(data) {
104
125
  const dir = stateDir();
105
126
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
package/src/parser.js CHANGED
@@ -4,6 +4,7 @@ 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
8
 
8
9
  const CLAUDE_DIR = join(homedir(), '.claude', 'projects');
9
10
 
@@ -49,7 +50,7 @@ export async function parseSessionFile(filePath) {
49
50
 
50
51
  requests.set(reqId, {
51
52
  requestId: reqId,
52
- model: msg.model || 'unknown',
53
+ model: resolveModelAlias(msg.model),
53
54
  inputTokens: usage.input_tokens || 0,
54
55
  cacheCreationTokens: usage.cache_creation_input_tokens || 0,
55
56
  cacheReadTokens: usage.cache_read_input_tokens || 0,
package/src/route-scan.js CHANGED
@@ -24,6 +24,7 @@ import { discoverSessionFiles } from './parser.js';
24
24
  import { collectSessionRecords } from './session-records.js';
25
25
  import { collectSubagentRuns, indexRuns, runsForEpisode } from './subagent-records.js';
26
26
  import { estimateCost, modelRank, TIER_TARGET_RANK, tierForRank } from './cost.js';
27
+ import { learnProfileMapping, resetModelAliasCache } from './model-alias.js';
27
28
  import { agentPhrase, agentPhraseEn } from './agents.js';
28
29
 
29
30
  // โ”€โ”€ Tier bands (docs/TIER_CRITERIA.md ยง3) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@@ -331,6 +332,16 @@ export function tierOf(ep, category, th) {
331
332
  export async function runRouteScan({ days = 14 } = {}) {
332
333
  const files = await discoverSessionFiles({ days });
333
334
 
335
+ // Behind a Bedrock/LiteLLM gateway the transcripts carry an inference-profile
336
+ // ARN where the model id belongs, which reads as Sonnet and rejects every T1
337
+ // rule. Refresh the profileโ†’role mapping before parsing so this scan resolves
338
+ // those ids; on a direct-API machine it finds nothing and writes nothing.
339
+ resetModelAliasCache();
340
+ try {
341
+ await learnProfileMapping({ sessionPaths: files.map((f) => f.path) });
342
+ } catch { /* learning is an optimization โ€” the scan still runs without it */ }
343
+ resetModelAliasCache();
344
+
334
345
  // Pass 1 โ€” collect episodes (needed up front: thresholds are calibrated
335
346
  // from the full window's output distribution before any tiering).
336
347
  const all = []; // { ep, projectDir, sessionPath }
@@ -9,10 +9,16 @@
9
9
 
10
10
  import { createReadStream } from 'node:fs';
11
11
  import { createInterface } from 'node:readline';
12
+ import { resolveModelAlias } from './model-alias.js';
12
13
 
13
- /** Strip context-window suffixes like "[1m]" so model ids compare cleanly. */
14
+ /**
15
+ * Strip context-window suffixes like "[1m]" so model ids compare cleanly, and
16
+ * turn a gateway inference-profile ARN back into a Claude alias. Ids that are
17
+ * already Claude aliases pass through untouched.
18
+ */
14
19
  export function normalizeModelId(model) {
15
- return String(model || 'unknown').replace(/\[[^\]]*\]$/, '');
20
+ const resolved = resolveModelAlias(model || 'unknown');
21
+ return String(resolved).replace(/\[[^\]]*\]$/, '');
16
22
  }
17
23
 
18
24
  /** Extract plain text from a Claude transcript message content field. */