claude-token-saver 3.11.2 โ†’ 3.12.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
@@ -301,6 +301,11 @@ Also update `statusLine.command` in `~/.claude/settings.json` to `claude-token-s
301
301
 
302
302
  ## Release notes
303
303
 
304
+ ### v3.12.0 (2026-08-22)
305
+ - **Routing saved now leads the statusline with weekly / monthly / lifetime totals** โ€” `๐Ÿ”€ Routing saved $1.3 wk ยท $2.0 mo ยท $9.8 all`. Each delegated subagent run's saving is recorded in a ledger (`delegation-ledger.json`) keyed by its transcript path, so overlapping re-scans upsert instead of double-counting; the statusline sums 7-day, 30-day, and lifetime windows on line 1 while the other chips move to line 2. An empty ledger keeps the legacy one-line layout, and `--single-line` forces it (some macOS Claude Code builds render only the first line). Totals start accruing from the release โ€” no back-fill.
306
+ - **`harness check` reports context weight** โ€” the approximate per-request token cost of CLAUDE.md (warns over the ~4k guideline) and whether a `.claudeignore` exists. Advisory only; the ๐Ÿ…ท N/5 score is unaffected.
307
+ - **Self-describing ARNs resolve without learning** โ€” `foundation-model/anthropic.claude-โ€ฆ` and system cross-region profiles (`inference-profile/us.anthropic.claude-โ€ฆ`) embed the model id yet resolved to `unknown`, dropping delegation and cost aggregation entirely on gateway machines that hadn't accumulated learning samples. They now pass straight to the existing tier classifier; opaque application-profile ids keep the override/learning path.
308
+
304
309
  ### v3.11.0 (2026-08-21)
305
310
  - **Routing savings chip in the statusline** โ€” `๐Ÿ”€ Routing saved $3.2`. Cumulative savings from model routing, placed right after the model name so it reads early in the line. The existing `๐Ÿ’ฐ Cache saved` covers the prompt cache and is a different number. 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
311
  - **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.
package/README.md CHANGED
@@ -257,6 +257,11 @@ npm uninstall -g claude-cache-monitor && npm i -g claude-token-saver
257
257
 
258
258
  ## ๋ฆด๋ฆฌ์Šค ๋…ธํŠธ
259
259
 
260
+ ### v3.12.0 (2026-08-22)
261
+ - **Routing saved๊ฐ€ ์ฃผ๊ฐ„ยท์›”๊ฐ„ยท๋ˆ„์ ์œผ๋กœ statusline 1์ค„์งธ์— ์˜ฌ๋ผ์˜ต๋‹ˆ๋‹ค** โ€” `๐Ÿ”€ Routing saved $1.3 wk ยท $2.0 mo ยท $9.8 all`. ์œ„์ž„ ์ ˆ๊ฐ ์ด๋ฒคํŠธ๋ฅผ ์„œ๋ธŒ์—์ด์ „ํŠธ run ๋‹จ์œ„๋กœ ์›์žฅ(`delegation-ledger.json`)์— ๊ธฐ๋กํ•˜๊ณ (ํŠธ๋žœ์Šคํฌ๋ฆฝํŠธ ๊ฒฝ๋กœ ํ‚ค๋ผ ์žฌ์Šค์บ”์ด ๊ฒน์ณ๋„ ์ค‘๋ณต ์ง‘๊ณ„ ์—†์Œ), 7์ผยท30์ผยท์ „์ฒด ํ•ฉ์‚ฐ์„ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค. ๋‚˜๋จธ์ง€ ์นฉ์€ 2์ค„์งธ๋กœ ๋‚ด๋ ค๊ฐ‘๋‹ˆ๋‹ค. ์›์žฅ์ด ๋น„์–ด ์žˆ์œผ๋ฉด ๊ธฐ์กด 1์ค„ ๊ทธ๋Œ€๋กœ์ด๊ณ , ์ผ๋ถ€ ํ™˜๊ฒฝ(macOS ๊ตฌ๋ฒ„์ „ Claude Code)์—์„œ ์ฒซ ์ค„๋งŒ ๋ณด์ด๋ฉด `--single-line`์œผ๋กœ ๊ธฐ์กด ๋ ˆ์ด์•„์›ƒ์„ ์œ ์ง€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ง‘๊ณ„๋Š” ์›์žฅ ๋„์ž… ์‹œ์ ๋ถ€ํ„ฐ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค(์†Œ๊ธ‰ ์—†์Œ).
262
+ - **`harness check`๊ฐ€ ์ปจํ…์ŠคํŠธ ๋ฌด๊ฒŒ๋„ ์•Œ๋ ค์ค๋‹ˆ๋‹ค** โ€” CLAUDE.md์˜ ์š”์ฒญ๋‹น ๋Œ€๋žต ํ† ํฐ(~4k ๊ฐ€์ด๋“œ๋ผ์ธ ์ดˆ๊ณผ ์‹œ ๊ฒฝ๊ณ )๊ณผ `.claudeignore` ์œ ๋ฌด๋ฅผ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค. ์ž๋ฌธ ์ •๋ณด์ผ ๋ฟ ๐Ÿ…ท N/5 ์ ์ˆ˜์—๋Š” ๋ฐ˜์˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
263
+ - **๋ชจ๋ธ๋ช…์ด ๋‚ด์žฅ๋œ ARN์€ ํ•™์Šต ์—†์ด ์ฆ‰์‹œ ํ•ด์„** โ€” `foundation-model/anthropic.claude-โ€ฆ`์™€ ์‹œ์Šคํ…œ ๊ต์ฐจ ๋ฆฌ์ „ ํ”„๋กœํŒŒ์ผ(`inference-profile/us.anthropic.claude-โ€ฆ`)์€ ์ž์› ID๊ฐ€ ๊ณง ๋ชจ๋ธ๋ช…์ธ๋ฐ๋„ `unknown`์œผ๋กœ ๋–จ์–ด์ ธ, ํ•™์Šต ํ‘œ๋ณธ์ด ์Œ“์ด๊ธฐ ์ „์˜ ๊ฒŒ์ดํŠธ์›จ์ด ํ™˜๊ฒฝ์—์„œ ์œ„์ž„ยท๋น„์šฉ ์ง‘๊ณ„๊ฐ€ ํ†ต์งธ๋กœ ๋น ์กŒ์Šต๋‹ˆ๋‹ค. ์ด์ œ ์ฆ‰์‹œ ๊ธฐ์กด ํ‹ฐ์–ด ๋ถ„๋ฅ˜๋กœ ๋„˜๊น๋‹ˆ๋‹ค. ๋ถˆํˆฌ๋ช…ํ•œ application-profile ID๋Š” ์ข…์ „๋Œ€๋กœ ์˜ค๋ฒ„๋ผ์ด๋“œ/ํ•™์Šต ๊ฒฝ๋กœ์ž…๋‹ˆ๋‹ค.
264
+
260
265
  ### v3.11.0 (2026-08-21)
261
266
  - **statusline์— ์œ„์ž„ ์ ˆ๊ฐ ์นฉ ์ถ”๊ฐ€** โ€” `๐Ÿ”€ Routing saved $3.2`. ๋ชจ๋ธ ์œ„์ž„์œผ๋กœ ์•„๋‚€ ๋ˆ„์  ๋น„์šฉ์ด๋ฉฐ, ๋ชจ๋ธ ์ด๋ฆ„ ๋ฐ”๋กœ ๋’ค์— ๋†“์—ฌ ์ค„ ์•ž์ชฝ์—์„œ ์ฝํž™๋‹ˆ๋‹ค. ๊ธฐ์กด `๐Ÿ’ฐ Cache saved`(ํ”„๋กฌํ”„ํŠธ ์บ์‹œ ์ ˆ๊ฐ)์™€๋Š” ๋‹ค๋ฅธ ์ˆ˜์น˜์ž…๋‹ˆ๋‹ค. ๊ฐ’์ด 0์ด๊ฑฐ๋‚˜ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์œผ๋ฉด ์นฉ์„ ์•„์˜ˆ ๊ทธ๋ฆฌ์ง€ ์•Š์•„ ์ง์ ‘ API ์‚ฌ์šฉ์ž์—๊ฒŒ๋Š” ์•„๋ฌด๊ฒƒ๋„ ๋Š˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. statusline์€ `model-rules.json`์„ ์ฝ๊ธฐ๋งŒ ํ•˜๋ฉฐ ์Šค์บ”์„ ๋Œ๋ฆฌ์ง€ ์•Š์Šต๋‹ˆ๋‹ค(5์ดˆ๋งˆ๋‹ค ํ˜ธ์ถœ๋˜๋Š” ์ž๋ฆฌ์ž…๋‹ˆ๋‹ค). ์„ธ๊ทธ๋จผํŠธ ์ด๋ฆ„์€ `delegated`์ž…๋‹ˆ๋‹ค.
262
267
  - **์—ญํ•  ํ•™์Šต์˜ ์˜ค๋ถ„๋ฅ˜ ์ˆ˜์ •** โ€” ์„œ๋ธŒ์—์ด์ „ํŠธ ๋ ˆ์ฝ”๋“œ๊ฐ€ ๋ถ€๋ชจ ํŠธ๋žœ์Šคํฌ๋ฆฝํŠธ์— `isSidechain` ์—†์ด ์„ž์—ฌ ๋“ค์–ด์˜ค๋Š” ๊ฒฝ์šฐ๊ฐ€ ์žˆ์–ด, ๊ทธ ์ •ํ™ฉ ์ฆ๊ฑฐ๊ฐ€ ๋ช…์‹œ ์ฆ๊ฑฐ๋ฅผ ์ด๊ฒจ haiku ํ”„๋กœํŒŒ์ผ์ด ์„ธ์…˜ ๋ชจ๋ธ๋กœ ํ™•์ •๋˜๊ณค ํ–ˆ์Šต๋‹ˆ๋‹ค(์‹ค์ธก 16๊ฑด ๋Œ€ 1๊ฑด, ํ•ฉ์˜์œจ ์ •ํ™•ํžˆ 80%). ์ด์ œ **๋ช…์‹œ ์ฆ๊ฑฐ(`Task(model:)` ํŒŒ๋ผ๋ฏธํ„ฐยท์—์ด์ „ํŠธ ์ •์˜ frontmatter)์™€ ์ •ํ™ฉ ์ฆ๊ฑฐ(sidechain ํ”Œ๋ž˜๊ทธ ๋ถ€์žฌ)๋ฅผ ๋ถ„๋ฆฌํ•ด ์ง‘๊ณ„**ํ•˜๊ณ , ๋ช…์‹œ ์ฆ๊ฑฐ๊ฐ€ ์ด๋ฅผ ๋ฐ˜๋ฐ•ํ•˜๋ฉด ์ •ํ™ฉ ์ถ”๋ก ์„ ์ฑ„ํƒํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํ™•์ •์ด ์•ˆ ๋˜๋ฉด `unknown`์œผ๋กœ ๋‚จ์•„ ์ง‘๊ณ„์—์„œ ๋น ์ง‘๋‹ˆ๋‹ค.
package/bin/cli.js CHANGED
@@ -18,6 +18,7 @@
18
18
  * npx claude-token-saver --statusline --no-color # strip ANSI colors
19
19
  * npx claude-token-saver --statusline --icon # use ๐Ÿง  โณ ๐Ÿ’ฐ icons
20
20
  * npx claude-token-saver --statusline --no-timer # hide the TTL countdown
21
+ * npx claude-token-saver --statusline --single-line # legacy 1-line layout (no routing-totals headline)
21
22
  * npx claude-token-saver --statusline --exclude-session <path>
22
23
  * # exclude a JSONL path from lastActivity
23
24
  * # (or set CACHE_MONITOR_EXCLUDE_SESSION env var)
@@ -231,6 +232,7 @@ async function main() {
231
232
  timer: showTimer,
232
233
  mode: isIcon ? 'icon' : 'text',
233
234
  segments,
235
+ singleLine: hasFlag('--single-line'),
234
236
  });
235
237
  // For `cycle` mode, prefix with the scenario label so the screen recorder
236
238
  // shows what the viewer is looking at (only when explicitly requested).
@@ -443,6 +445,15 @@ async function main() {
443
445
  } catch (e) {
444
446
  debug('model-rules:saved', e); // an unreadable registry just hides the chip
445
447
  }
448
+ // Rolling week/month/lifetime totals from the delegation ledger โ€” the
449
+ // statusline's headline line. Falls back to null (chip hidden) on any error.
450
+ let delegationTotals = null;
451
+ try {
452
+ const { delegationSavedTotals } = await import('../src/savings-ledger.js');
453
+ delegationTotals = delegationSavedTotals();
454
+ } catch (e) {
455
+ debug('savings-ledger:totals', e);
456
+ }
446
457
 
447
458
  const data = {
448
459
  summary: sum,
@@ -459,6 +470,7 @@ async function main() {
459
470
  caps,
460
471
  model,
461
472
  delegationSaved,
473
+ delegationTotals,
462
474
  };
463
475
 
464
476
  let output;
@@ -505,6 +517,10 @@ async function main() {
505
517
  timer: showTimer,
506
518
  mode: isIcon ? 'icon' : 'text',
507
519
  segments,
520
+ // macOS builds of Claude Code have rendered only the first line of a
521
+ // multi-line statusline in some versions (anthropics/claude-code#35176)
522
+ // โ€” --single-line restores the legacy one-line layout in that case.
523
+ singleLine: hasFlag('--single-line'),
508
524
  });
509
525
  } else {
510
526
  const { formatReport } = await import('../src/formatters/table.js');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "3.11.2",
3
+ "version": "3.12.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": {
@@ -27,7 +27,7 @@ export async function run({ args, hasFlag }) {
27
27
  }
28
28
  return dflt;
29
29
  };
30
- const { harnessInit, harnessUninit, harnessStatus, harnessPromote, harnessPull, harnessListRules, harnessRmRule, harnessPrune, ratchetSizeStatus, RATCHET_TOKEN_BUDGET, findProjectRoot } =
30
+ const { harnessInit, harnessUninit, harnessStatus, harnessPromote, harnessPull, harnessListRules, harnessRmRule, harnessPrune, ratchetSizeStatus, RATCHET_TOKEN_BUDGET, contextWeightStatus, CLAUDE_MD_TOKEN_BUDGET, findProjectRoot } =
31
31
  await import('../harness.js');
32
32
  const { HARNESS_SECTIONS } = await import('../harness-templates.js');
33
33
  const { loadConfig, saveConfig, userLanguage } = await import('../config.js');
@@ -77,6 +77,24 @@ export async function run({ args, hasFlag }) {
77
77
  console.log(`${line}`);
78
78
  }
79
79
  }
80
+ // Advisory context-weight facts (never counted in ๐Ÿ…ท N/5): CLAUDE.md is
81
+ // charged on every request, and without a .claudeignore Claude Code can
82
+ // pull build artifacts and vendored code into context during searches.
83
+ try {
84
+ const w = contextWeightStatus({ root });
85
+ if (w.claudeMd) {
86
+ const line = `CLAUDE.md size: ~${w.claudeMd.tokens} tok/request (${w.claudeMd.path})`;
87
+ if (w.claudeMd.overBudget) {
88
+ console.log(`\nโš  ${line} โ€” over the ~${CLAUDE_MD_TOKEN_BUDGET} token guideline.`);
89
+ console.log(' Keep rules and file pointers here; move documentation into files it points to.');
90
+ } else {
91
+ console.log(line);
92
+ }
93
+ }
94
+ console.log(`.claudeignore: ${w.hasClaudeIgnore ? 'present' : 'absent โ€” consider adding one so searches skip build output, vendored code, and large data files'}`);
95
+ } catch (e) {
96
+ debug('harness:context-weight', e);
97
+ }
80
98
  return;
81
99
  }
82
100
 
@@ -212,8 +212,9 @@ export function formatNoSession({ caps = null, model = null, windowLabel = '' }
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
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
+ * @param {boolean} [opts.singleLine=false] - force the legacy one-line layout. By default, when the delegation ledger has lifetime savings, the routing totals lead on their own first line and everything else moves to line 2 (Claude Code renders multi-line statuslines; `--single-line` is the escape hatch for terminals that only show the first line).
215
216
  */
216
- export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null } = {}) {
217
+ export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null, singleLine = false } = {}) {
217
218
  const { summary, ttl, cost, options, lastActivity, contextWindow, ctxLive, spikeChip, caps, model } = data;
218
219
  const { hitRate } = summary;
219
220
 
@@ -273,6 +274,24 @@ export function formatReport(data, { color = true, verbose = false, timer = true
273
274
  ? `${c(GREEN)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}`
274
275
  : null;
275
276
 
277
+ // Routing-totals headline line (multi-line layout). Week/month/lifetime
278
+ // sums from the delegation ledger โ€” the number the whole tool exists to
279
+ // grow, so it gets line 1 to itself while the diagnostics move to line 2.
280
+ // icon: "๐Ÿ”€ Routing saved $1.2 wk ยท $3.4 mo ยท $9.8 all"
281
+ // text: "Routing saved $1.2 wk ยท $3.4 mo ยท $9.8 all"
282
+ const totals = data.delegationTotals;
283
+ let totalsLine = null;
284
+ if (!singleLine && totals && Number(totals.total) > 0) {
285
+ const part = (usd, label) =>
286
+ `${formatMoney(Number(usd) || 0)} ${c(GRAY)}${label}${c(RESET)}`;
287
+ const head = isIcon ? '๐Ÿ”€ Routing saved' : 'Routing saved';
288
+ totalsLine =
289
+ `${c(GREEN)}${c(BOLD)}${head}${c(RESET)} ` +
290
+ `${part(totals.week, 'wk')} ${c(GRAY)}ยท${c(RESET)} ` +
291
+ `${part(totals.month, 'mo')} ${c(GRAY)}ยท${c(RESET)} ` +
292
+ `${part(totals.total, 'all')}`;
293
+ }
294
+
276
295
  // Period label honors hour-precision configs (`mode 6h` โ†’ "6h", `mode 1d` โ†’ "1d").
277
296
  // Fall back to legacy `${days}d` when callers haven't supplied a label.
278
297
  const periodLabel = options.windowLabel || `${options.days}d`;
@@ -493,7 +512,9 @@ export function formatReport(data, { color = true, verbose = false, timer = true
493
512
  // Delegation savings ride up front, next to the model that would otherwise
494
513
  // have done the work. "Cache saved" stays at the tail: it is a lifetime brag
495
514
  // stat, while this one is the point of the tool.
496
- if (delegateSeg && want('delegated')) segs.push(delegateSeg);
515
+ // When the totals headline owns line 1, the inline session chip would
516
+ // repeat the same story on line 2 โ€” drop it there.
517
+ if (delegateSeg && want('delegated') && !totalsLine) segs.push(delegateSeg);
497
518
  if (want('hit')) segs.push(hitSeg);
498
519
  if (want('ttl')) segs.push(ttlSeg);
499
520
  for (const { key, seg } of usageSegs) {
@@ -509,5 +530,10 @@ export function formatReport(data, { color = true, verbose = false, timer = true
509
530
  // (longer) statusline render don't bleed into ours. \x1b[K is the standard
510
531
  // "erase from cursor to EOL" CSI. Only emitted when color (i.e. ANSI) is
511
532
  // allowed โ€” --no-color/NO_COLOR consumers expect escape-free output.
512
- return segs.join(' ยท ') + (color ? '\x1b[K' : '');
533
+ const eol = color ? '\x1b[K' : '';
534
+ const rest = segs.join(' ยท ') + eol;
535
+ if (totalsLine && want('delegated')) {
536
+ return totalsLine + eol + '\n' + rest;
537
+ }
538
+ return rest;
513
539
  }
package/src/harness.js CHANGED
@@ -431,6 +431,35 @@ export function ratchetSizeStatus({ root = findProjectRoot(), scope = 'project'
431
431
  return { path, count: rules.length, bytes, tokens, overBudget: tokens > RATCHET_TOKEN_BUDGET };
432
432
  }
433
433
 
434
+ // CLAUDE.md itself is also charged on every request. The community guideline
435
+ // is "rules and file pointers, not documentation" โ€” the harness block plus a
436
+ // modest project section fits well under this; docs pasted wholesale do not.
437
+ export const CLAUDE_MD_TOKEN_BUDGET = 4000;
438
+
439
+ /**
440
+ * Advisory size/ignore facts for `harness check`:
441
+ * - approx token weight of the project (or global) CLAUDE.md
442
+ * - whether the project has a .claudeignore (limits what Claude Code will
443
+ * read into context during searches)
444
+ * Purely informational โ€” never affects the ๐Ÿ…ท N/5 score.
445
+ */
446
+ export function contextWeightStatus({ root = findProjectRoot() } = {}) {
447
+ const out = { claudeMd: null, hasClaudeIgnore: false };
448
+ try {
449
+ out.hasClaudeIgnore = existsSync(join(root, '.claudeignore'));
450
+ } catch { /* fs error โ†’ treat as absent */ }
451
+ for (const file of [join(root, 'CLAUDE.md'), join(homedir(), '.claude', 'CLAUDE.md')]) {
452
+ try {
453
+ if (!existsSync(file)) continue;
454
+ const bytes = statSync(file).size;
455
+ const tokens = Math.round(bytes / 4);
456
+ out.claudeMd = { path: file, bytes, tokens, overBudget: tokens > CLAUDE_MD_TOKEN_BUDGET };
457
+ break; // project file wins; global only as fallback
458
+ } catch { /* unreadable โ†’ skip */ }
459
+ }
460
+ return out;
461
+ }
462
+
434
463
  /**
435
464
  * harness prune โ€” move rules out of ratchet.md into ratchet-archive.md next to
436
465
  * it. Selection is by tag and/or age; nothing is deleted, so a pruned rule can
@@ -44,8 +44,10 @@ export function profileMapPath() {
44
44
  }
45
45
 
46
46
  // `converse/` (LiteLLM) or a bare ARN, foundation- or application-scoped.
47
+ // `foundation-model` ARNs carry the model id directly in the resource part
48
+ // (`foundation-model/anthropic.claude-haiku-โ€ฆ`), so they are captured too.
47
49
  const ARN_RE =
48
- /arn:aws:bedrock:[^:]*:[^:]*:(?:application-)?inference-profile\/([A-Za-z0-9._:-]+)/;
50
+ /arn:aws:bedrock:[^:]*:[^:]*:(?:foundation-model|(?:application-)?inference-profile)\/([A-Za-z0-9._:-]+)/;
49
51
 
50
52
  /** True when the id came from a Bedrock gateway rather than the Claude API. */
51
53
  export function isGatewayModelId(model) {
@@ -158,6 +160,16 @@ export function resolveModelAlias(rawModel, { env = process.env } = {}) {
158
160
  const pid = profileIdFrom(model);
159
161
  if (!pid) return UNKNOWN_MODEL;
160
162
 
163
+ // Self-describing resource ids need no learning: foundation-model ARNs and
164
+ // system cross-region inference profiles both embed the model id
165
+ // (`anthropic.claude-haiku-4-5-โ€ฆ` / `us.anthropic.claude-haiku-4-5-โ€ฆ`).
166
+ // detectPricingTier() already classifies those strings, region prefix
167
+ // included โ€” only opaque application-profile ids (random hex) fall through
168
+ // to the override / learned paths. Without this, a fresh gateway machine
169
+ // reported every run as 'unknown' until enough sessions accumulated to
170
+ // learn a mapping that the string had spelled out all along.
171
+ if (/claude/i.test(pid)) return pid;
172
+
161
173
  const entry = map.learned?.[pid];
162
174
  if (entry?.role) {
163
175
  const alias = aliasForRole(entry.role, env);
package/src/route-scan.js CHANGED
@@ -451,6 +451,10 @@ export async function runRouteScan({ days = 14 } = {}) {
451
451
  d.savedUsd += saved;
452
452
  delegatedStats.set(key, d);
453
453
  };
454
+ // Ledger events feed the statusline's weekly/monthly "Routing saved"
455
+ // totals. Keyed by run transcript path so overlapping re-scans upsert the
456
+ // same event instead of double-counting it.
457
+ const ledgerEvents = [];
454
458
  for (const [sessionPath, index] of runIndexBySession) {
455
459
  const used = new Set();
456
460
  for (const { ep, projectDir, sessionPath: epSession } of all) {
@@ -474,9 +478,22 @@ export async function runRouteScan({ days = 14 } = {}) {
474
478
  const saved = runSaving(run, mainModel);
475
479
  bumpDelegated(`${runTier}|${cat.id}|${projectDir}`, run, saved);
476
480
  bumpDelegated(`${runTier}|${cat.id}|*`, run, saved);
481
+ if (saved > 0) {
482
+ ledgerEvents.push({
483
+ key: run.path,
484
+ ts: run.endedAt ?? run.startedAt ?? Date.now(),
485
+ usd: saved,
486
+ });
487
+ }
477
488
  }
478
489
  }
479
490
  }
491
+ try {
492
+ const { recordDelegationEvents } = await import('./savings-ledger.js');
493
+ recordDelegationEvents(ledgerEvents);
494
+ } catch {
495
+ // ledger write failure only delays the statusline totals, never the scan
496
+ }
480
497
 
481
498
  // Keep prior dismissed/promoted signatures across rescans.
482
499
  const prev = readRouteScan();
@@ -0,0 +1,86 @@
1
+ /**
2
+ * savings-ledger โ€” per-run delegation saving events with timestamps.
3
+ *
4
+ * model-rules.json stores `savedUsd` as a scan-window snapshot per rule, which
5
+ * is right for rule-health but cannot answer "how much did routing save this
6
+ * week / this month / ever". This ledger keeps one event per subagent run,
7
+ * keyed by the run's transcript path so re-scans over overlapping windows
8
+ * upsert instead of double-counting.
9
+ *
10
+ * File: <userDataDir>/delegation-ledger.json
11
+ * { "events": { "<run path>": { "ts": <ms epoch>, "usd": <number> } } }
12
+ *
13
+ * Best-effort like every other state file here: an unreadable ledger reads as
14
+ * empty, and the statusline renders totals of 0 as "no chip".
15
+ */
16
+
17
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { userDataDir } from './paths.js';
20
+
21
+ const WEEK_MS = 7 * 24 * 3600 * 1000;
22
+ const MONTH_MS = 30 * 24 * 3600 * 1000;
23
+
24
+ export function ledgerPath() {
25
+ return join(userDataDir(), 'delegation-ledger.json');
26
+ }
27
+
28
+ export function loadLedger() {
29
+ try {
30
+ const data = JSON.parse(readFileSync(ledgerPath(), 'utf8'));
31
+ return data && typeof data.events === 'object' && data.events !== null
32
+ ? data
33
+ : { events: {} };
34
+ } catch {
35
+ return { events: {} };
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Upsert saving events. `events` is an array of { key, ts, usd } where `key`
41
+ * is the run's transcript path (unique per subagent run). Zero-saving runs
42
+ * are skipped โ€” they carry no information the totals care about.
43
+ */
44
+ export function recordDelegationEvents(events) {
45
+ if (!Array.isArray(events) || events.length === 0) return;
46
+ const data = loadLedger();
47
+ let changed = false;
48
+ for (const e of events) {
49
+ if (!e || !e.key || !(Number(e.usd) > 0) || !Number.isFinite(e.ts)) continue;
50
+ const prev = data.events[e.key];
51
+ const usd = Math.round(Number(e.usd) * 10000) / 10000;
52
+ if (prev && prev.ts === e.ts && prev.usd === usd) continue;
53
+ data.events[e.key] = { ts: e.ts, usd };
54
+ changed = true;
55
+ }
56
+ if (!changed) return;
57
+ try {
58
+ const dir = userDataDir();
59
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
60
+ writeFileSync(ledgerPath(), JSON.stringify(data) + '\n');
61
+ } catch {
62
+ // statusline totals just stay stale until the next successful scan
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Rolling totals: last 7 days, last 30 days, and lifetime. `now` is injectable
68
+ * for tests. Never throws โ€” an unreadable ledger yields all-zero totals.
69
+ */
70
+ export function delegationSavedTotals(now = Date.now()) {
71
+ const totals = { week: 0, month: 0, total: 0 };
72
+ try {
73
+ for (const e of Object.values(loadLedger().events)) {
74
+ const usd = Number(e.usd) || 0;
75
+ if (usd <= 0) continue;
76
+ totals.total += usd;
77
+ if (Number.isFinite(e.ts)) {
78
+ if (now - e.ts <= WEEK_MS) totals.week += usd;
79
+ if (now - e.ts <= MONTH_MS) totals.month += usd;
80
+ }
81
+ }
82
+ } catch {
83
+ return { week: 0, month: 0, total: 0 };
84
+ }
85
+ return totals;
86
+ }