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 +5 -0
- package/README.md +5 -0
- package/bin/cli.js +16 -0
- package/package.json +1 -1
- package/src/commands/harness.js +19 -1
- package/src/formatters/statusline.js +29 -3
- package/src/harness.js +29 -0
- package/src/model-alias.js +13 -1
- package/src/route-scan.js +17 -0
- package/src/savings-ledger.js +86 -0
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.
|
|
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": {
|
package/src/commands/harness.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
package/src/model-alias.js
CHANGED
|
@@ -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
|
+
}
|