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 +29 -0
- package/README.md +29 -0
- package/bin/cli.js +12 -0
- package/package.json +1 -1
- package/src/cost.js +15 -0
- package/src/formatters/statusline.js +18 -1
- package/src/model-alias.js +437 -0
- package/src/model-rules.js +21 -0
- package/src/parser.js +2 -1
- package/src/route-scan.js +11 -0
- package/src/session-records.js +8 -2
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.
|
|
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
|
+
}
|
package/src/model-rules.js
CHANGED
|
@@ -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
|
|
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 }
|
package/src/session-records.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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. */
|