claude-token-saver 3.12.1 → 3.13.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 +12 -1
- package/README.md +12 -1
- package/package.json +1 -1
- package/src/cost.js +23 -0
- package/src/model-alias.js +12 -3
- package/src/model-rules.js +8 -0
- package/src/route-scan.js +60 -4
- package/src/savings-ledger.js +33 -9
package/README.en.md
CHANGED
|
@@ -214,11 +214,15 @@ For environments the learner cannot reach, write the mapping yourself in `<userD
|
|
|
214
214
|
```jsonc
|
|
215
215
|
{
|
|
216
216
|
"modelAliases": {
|
|
217
|
-
"arn:aws:bedrock:*:*:application-inference-profile/<PROFILE_ID>": "claude-opus-5"
|
|
217
|
+
"arn:aws:bedrock:*:*:application-inference-profile/<PROFILE_ID>": "claude-opus-5",
|
|
218
|
+
"prod-large": "claude-opus-5", // house aliases map the same way
|
|
219
|
+
"team-*": "claude-haiku-4-5"
|
|
218
220
|
}
|
|
219
221
|
}
|
|
220
222
|
```
|
|
221
223
|
|
|
224
|
+
**Map house aliases that carry no family name** (`prod-large`, `team-fast`) here too. Shapes that keep the family name are recognized as-is — Bedrock (`anthropic.claude-opus-4-5-v1:0`), Vertex (`claude-opus-4-5@20251101`), and the 1M suffix (`claude-sonnet-4-5[1m]`) — but an alias without one cannot be priced. Rather than report a wrong figure, routing-savings **drops those runs from the aggregate** (both sides of the comparison must be recognizable); one line in the table above brings them back.
|
|
225
|
+
|
|
222
226
|
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
227
|
|
|
224
228
|
## Spike issue codes
|
|
@@ -301,6 +305,13 @@ Also update `statusLine.command` in `~/.claude/settings.json` to `claude-token-s
|
|
|
301
305
|
|
|
302
306
|
## Release notes
|
|
303
307
|
|
|
308
|
+
### v3.13.0 (2026-08-22)
|
|
309
|
+
- **Routing savings are now priced "model before the rule → model that ran it"** — previously the counterfactual was the session's priciest model, which booked a saving even when that model had never handled work of this shape. Each rule now remembers the **model that handled its category before promotion** (its baseline) and prices only against that. The baseline is sticky once set: as a rule takes effect fewer episodes stay on the expensive model, so a recomputed baseline would drift down and shrink the very saving the rule produces.
|
|
310
|
+
- **Delegations no rule covers are excluded** — `Explore`, hand-written agents, and plugin subagents were all being counted, crediting this tool for routing it never did. Ledger events now carry `rule` / `from` / `to`, so every dollar is traceable to the rule and model change behind it.
|
|
311
|
+
- **Ledger schema version 2** — v1 events used the old counterfactual and are discarded rather than migrated; the next `route-scan` refills whatever is genuinely attributable.
|
|
312
|
+
- **House alias model ids no longer price as Sonnet by accident** — when a gateway reports an alias carrying no family name (`prod-large`), the pricing table's default made it Sonnet, fabricating or erasing savings. Both sides of a comparison must now be recognizable ids. Bedrock (`anthropic.claude-opus-4-5-v1:0`), Vertex (`claude-opus-4-5@20251101`), and the `[1m]` suffix are recognized as-is; house aliases come back into the aggregate with one line in `modelAliases` (wildcards supported).
|
|
313
|
+
- `harness check` also reports CLAUDE.md size and whether a `.claudeignore` exists (advisory; the 🅷 score is unaffected).
|
|
314
|
+
|
|
304
315
|
### v3.12.1 (2026-08-22)
|
|
305
316
|
- **Routing-savings headline reads more clearly** — the amounts are now in the savings green (the one unambiguously good number on the line), `wk`/`mo`/`all` are spelled out as `weekly`/`monthly`/`total`, and the period now leads the amount. Three bare amounts in a row read as a single number until the eye found the trailing marker.
|
|
306
317
|
|
package/README.md
CHANGED
|
@@ -194,11 +194,15 @@ v3.10.0부터는 프로파일 ID를 역할(main·opus·sonnet·haiku)로 되돌
|
|
|
194
194
|
```jsonc
|
|
195
195
|
{
|
|
196
196
|
"modelAliases": {
|
|
197
|
-
"arn:aws:bedrock:*:*:application-inference-profile/<PROFILE_ID>": "claude-opus-5"
|
|
197
|
+
"arn:aws:bedrock:*:*:application-inference-profile/<PROFILE_ID>": "claude-opus-5",
|
|
198
|
+
"prod-large": "claude-opus-5", // 사내 별칭도 같은 방식으로 매핑됩니다
|
|
199
|
+
"team-*": "claude-haiku-4-5"
|
|
198
200
|
}
|
|
199
201
|
}
|
|
200
202
|
```
|
|
201
203
|
|
|
204
|
+
**모델명에 `opus`·`sonnet`·`haiku`·`fable` 이 들어 있지 않은 사내 별칭**(`prod-large`, `team-fast` 등)도 이 표로 매핑하십시오. Bedrock(`anthropic.claude-opus-4-5-v1:0`)·Vertex(`claude-opus-4-5@20251101`)·1M 접미사(`claude-sonnet-4-5[1m]`) 같이 계열명이 남아 있는 형태는 그대로 인식되지만, 계열명이 사라진 별칭은 가격표가 알아볼 수 없습니다. 이 경우 라우팅 절감 계산은 **틀린 금액을 내놓는 대신 그 실행을 집계에서 제외**하며(비교 양쪽 모두 인식 가능한 이름이어야 합니다), 위 표에 한 줄 추가하면 다시 집계에 들어옵니다.
|
|
205
|
+
|
|
202
206
|
이 파일에는 사내 식별자가 평문으로 남으므로 저장소에 커밋하지 마십시오. 게이트웨이를 쓰지 않는 환경에서는 파일이 아예 만들어지지 않고 기존 동작이 그대로 유지됩니다.
|
|
203
207
|
|
|
204
208
|
## 토큰 급증 원인 코드
|
|
@@ -257,6 +261,13 @@ npm uninstall -g claude-cache-monitor && npm i -g claude-token-saver
|
|
|
257
261
|
|
|
258
262
|
## 릴리스 노트
|
|
259
263
|
|
|
264
|
+
### v3.13.0 (2026-08-22)
|
|
265
|
+
- **라우팅 절감액의 기준을 '승격 전 모델 → 위임 모델' 차액으로 바꿨습니다** — 이전에는 세션의 최상위 모델을 반사실로 잡아, 그 모델이 해당 유형을 실제로 처리한 적이 없어도 차액을 절감으로 기록했습니다. 이제 각 룰이 **승격 전 그 유형을 직접 처리하던 모델**(baseline)을 기억하고, 그 기준 대비로만 계산합니다. baseline은 한 번 정해지면 고정됩니다 — 룰이 효력을 낼수록 직접 처리 사례가 줄어 기준이 흘러내리고, 그러면 룰이 만든 절감이 스스로 작아지기 때문입니다.
|
|
266
|
+
- **룰이 커버하지 않는 위임은 집계에서 뺐습니다** — `Explore`, 직접 만든 에이전트, 플러그인 에이전트처럼 이 도구와 무관하게 돌던 서브에이전트 실행까지 절감으로 잡히고 있었습니다. 도구가 라우팅하지 않은 작업의 절감을 도구 성과로 표시하면 안 됩니다. 원장 이벤트에 `rule`/`from`/`to`를 남겨 어느 룰이 어떤 모델 차이를 만들었는지 추적할 수 있습니다.
|
|
267
|
+
- **원장 스키마 version 2** — 반사실 기준이 달라진 만큼 v1 항목은 마이그레이션 없이 폐기합니다(두 의미를 한 합계에 섞을 수 없습니다). 다음 `route-scan`이 귀속 가능한 절감만 다시 채웁니다.
|
|
268
|
+
- **사내 별칭 모델명이 조용히 Sonnet으로 계산되지 않습니다** — 게이트웨이가 계열명 없는 별칭(`prod-large` 등)을 모델명으로 기록하면 가격표 기본값이 걸려 Sonnet으로 계산됐고, 그 결과 없는 절감이 생기거나 있는 절감이 지워졌습니다. 이제 비교 양쪽 모두 계열명이 남아 있는 id일 때만 집계합니다. Bedrock(`anthropic.claude-opus-4-5-v1:0`)·Vertex(`claude-opus-4-5@20251101`)·1M 접미사(`claude-sonnet-4-5[1m]`)는 그대로 인식되고, 사내 별칭은 `profile-map.json`의 `modelAliases`에 한 줄 추가하면 집계에 복귀합니다(와일드카드 가능).
|
|
269
|
+
- `harness check`가 CLAUDE.md 크기와 `.claudeignore` 유무를 함께 보고합니다(자문 정보, 🅷 점수에는 미반영).
|
|
270
|
+
|
|
260
271
|
### v3.12.1 (2026-08-22)
|
|
261
272
|
- **라우팅 절감 헤드라인의 가독성 정리** — 금액을 절감 녹색으로 칠하고(그 줄에서 유일하게 명확한 호재입니다), `wk`·`mo`·`all` 축약을 `weekly`·`monthly`·`total`로 풀었으며, 금액이 앞서던 순서를 뒤집어 기간이 먼저 오게 했습니다. 금액 셋이 연달아 나오면 뒤따르는 기간 표시를 찾기 전까지 한 덩어리로 읽혔습니다.
|
|
262
273
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-token-saver",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.13.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
|
@@ -128,6 +128,29 @@ export function isUnknownModel(model) {
|
|
|
128
128
|
return !model || String(model).toLowerCase() === 'unknown';
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* True when the id actually names a Claude family this table can price, as
|
|
133
|
+
* opposed to falling through to the Sonnet default.
|
|
134
|
+
*
|
|
135
|
+
* `detectPricingTier` must keep defaulting — a plain cost estimate is better
|
|
136
|
+
* off guessing Sonnet than refusing to answer. But anything that compares two
|
|
137
|
+
* models must not: behind a company gateway an id can be a house alias
|
|
138
|
+
* (`prod-large`, `team-fast`) carrying no family name, and pricing that as
|
|
139
|
+
* Sonnet silently invents or erases a delegation saving. Callers that need a
|
|
140
|
+
* real comparison gate on this and skip when it is false.
|
|
141
|
+
*
|
|
142
|
+
* Covers the shapes gateways actually emit — Bedrock
|
|
143
|
+
* (`anthropic.claude-opus-4-5-v1:0`, `us.anthropic.…`), Vertex
|
|
144
|
+
* (`claude-opus-4-5@20251101`), and the `[1m]` context suffix — because all of
|
|
145
|
+
* them keep the family name in the string. House aliases that do not are
|
|
146
|
+
* exactly what this returns false for; map those in profile-map.json's
|
|
147
|
+
* `modelAliases`.
|
|
148
|
+
*/
|
|
149
|
+
export function isRecognizedModelId(model) {
|
|
150
|
+
if (isUnknownModel(model)) return false;
|
|
151
|
+
return /fable|mythos|opus|sonnet|haiku/i.test(String(model));
|
|
152
|
+
}
|
|
153
|
+
|
|
131
154
|
export function modelRank(model) {
|
|
132
155
|
// -1 sits below every real tier, so worthDelegating() rejects it and
|
|
133
156
|
// tierForRank() attributes no saving to it: the run leaves the aggregate
|
package/src/model-alias.js
CHANGED
|
@@ -134,8 +134,10 @@ function overrideAlias(model, map) {
|
|
|
134
134
|
for (const [pattern, alias] of Object.entries(map.modelAliases || {})) {
|
|
135
135
|
if (globMatch(pattern, model)) return alias;
|
|
136
136
|
// Overrides are usually written without the LiteLLM `converse/` prefix.
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
// Only meaningful for ARNs — on a plain id indexOf returns -1 and the
|
|
138
|
+
// slice would hand the matcher a single trailing character.
|
|
139
|
+
const at = model.indexOf('arn:aws:bedrock:');
|
|
140
|
+
if (at > 0 && globMatch(pattern, model.slice(at))) return alias;
|
|
139
141
|
}
|
|
140
142
|
return null;
|
|
141
143
|
}
|
|
@@ -150,13 +152,20 @@ function overrideAlias(model, map) {
|
|
|
150
152
|
export function resolveModelAlias(rawModel, { env = process.env } = {}) {
|
|
151
153
|
if (!rawModel) return UNKNOWN_MODEL;
|
|
152
154
|
const model = String(rawModel);
|
|
153
|
-
if (!isGatewayModelId(model)) return model;
|
|
154
155
|
|
|
155
156
|
const map = loadProfileMap();
|
|
156
157
|
|
|
158
|
+
// Overrides are consulted for EVERY id, not just ARNs. A gateway can be
|
|
159
|
+
// configured to report a house alias (`prod-large`, `team-fast`) that names
|
|
160
|
+
// no Claude family at all; those never reach the ARN branch below, and left
|
|
161
|
+
// alone they price as Sonnet by default. One `modelAliases` entry maps them
|
|
162
|
+
// back. Ids that already name a family are left untouched — an override
|
|
163
|
+
// pattern has to match before anything changes.
|
|
157
164
|
const override = overrideAlias(model, map);
|
|
158
165
|
if (override) return override;
|
|
159
166
|
|
|
167
|
+
if (!isGatewayModelId(model)) return model;
|
|
168
|
+
|
|
160
169
|
const pid = profileIdFrom(model);
|
|
161
170
|
if (!pid) return UNKNOWN_MODEL;
|
|
162
171
|
|
package/src/model-rules.js
CHANGED
|
@@ -340,6 +340,14 @@ export function refreshModelRules(episodeStats, delegatedStats = new Map(), { no
|
|
|
340
340
|
if (s) {
|
|
341
341
|
r.count = s.count;
|
|
342
342
|
r.errRate = s.epCount > 0 ? s.errCount / s.epCount : 0;
|
|
343
|
+
// Baseline = the model that handled this category before the rule moved
|
|
344
|
+
// it. Sticky once set: as a rule takes effect, fewer episodes stay on the
|
|
345
|
+
// expensive model, so a recomputed baseline would drift downward and
|
|
346
|
+
// shrink the very savings the rule is producing.
|
|
347
|
+
if (!r.baselineModel && s.baselineModel) {
|
|
348
|
+
r.baselineModel = s.baselineModel;
|
|
349
|
+
changed = true;
|
|
350
|
+
}
|
|
343
351
|
}
|
|
344
352
|
// Window snapshot, not a running total: these describe the current scan
|
|
345
353
|
// window so a rule that stopped firing decays to zero instead of coasting
|
package/src/route-scan.js
CHANGED
|
@@ -23,7 +23,7 @@ import { userDataDir } from './paths.js';
|
|
|
23
23
|
import { discoverSessionFiles } from './parser.js';
|
|
24
24
|
import { collectSessionRecords } from './session-records.js';
|
|
25
25
|
import { collectSubagentRuns, indexRuns, runsForEpisode } from './subagent-records.js';
|
|
26
|
-
import { estimateCost, modelRank, TIER_TARGET_RANK, tierForRank } from './cost.js';
|
|
26
|
+
import { estimateCost, modelRank, isRecognizedModelId, TIER_TARGET_RANK, tierForRank } from './cost.js';
|
|
27
27
|
import { learnProfileMapping, resetModelAliasCache } from './model-alias.js';
|
|
28
28
|
import { agentPhrase, agentPhraseEn } from './agents.js';
|
|
29
29
|
|
|
@@ -379,10 +379,24 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
379
379
|
const episodeStats = new Map(); // "category|project" (+ "category|*") → outcome stats
|
|
380
380
|
let tieredEpisodes = 0;
|
|
381
381
|
const bumpStats = (key, ep) => {
|
|
382
|
-
const s = episodeStats.get(key) || { count: 0, errCount: 0, epCount: 0 };
|
|
382
|
+
const s = episodeStats.get(key) || { count: 0, errCount: 0, epCount: 0, baselineModel: null, baselineRank: -1 };
|
|
383
383
|
s.count += 1;
|
|
384
384
|
s.epCount += 1;
|
|
385
385
|
if (ep.errors > 0) s.errCount += 1;
|
|
386
|
+
// Baseline model: what actually handled this category BEFORE any rule sent
|
|
387
|
+
// it elsewhere. This is the only honest counterfactual for "routing saved
|
|
388
|
+
// money" — the session's priciest model is not, since it may never have
|
|
389
|
+
// touched work of this shape. Episodes counted here are by definition ones
|
|
390
|
+
// an expensive model handled directly, so the priciest model seen on them
|
|
391
|
+
// is the before-picture the rule replaced.
|
|
392
|
+
for (const m of ep.models) {
|
|
393
|
+
// Only ids the pricing table really recognizes may become a baseline —
|
|
394
|
+
// an unresolved gateway id or a house alias would be priced as Sonnet
|
|
395
|
+
// and quietly rewrite every saving computed against it.
|
|
396
|
+
if (!isRecognizedModelId(m)) continue;
|
|
397
|
+
const r = modelRank(m);
|
|
398
|
+
if (r > s.baselineRank) { s.baselineRank = r; s.baselineModel = m; }
|
|
399
|
+
}
|
|
386
400
|
episodeStats.set(key, s);
|
|
387
401
|
};
|
|
388
402
|
|
|
@@ -454,7 +468,33 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
454
468
|
// Ledger events feed the statusline's weekly/monthly "Routing saved"
|
|
455
469
|
// totals. Keyed by run transcript path so overlapping re-scans upsert the
|
|
456
470
|
// same event instead of double-counting it.
|
|
471
|
+
//
|
|
472
|
+
// What counts as a routing saving is narrower than what counts for
|
|
473
|
+
// rule-health above. A saving is the price difference a REGISTERED RULE
|
|
474
|
+
// caused: the model that used to handle this category (the rule's baseline,
|
|
475
|
+
// learned from episodes an expensive model handled directly) versus the
|
|
476
|
+
// model the run actually used. Subagent runs that no rule covers —
|
|
477
|
+
// Explore, a hand-written agent, a plugin's own subagent — would have gone
|
|
478
|
+
// to the same cheap model with or without this tool, so attributing their
|
|
479
|
+
// savings here would credit the tool for work it did not route.
|
|
457
480
|
const ledgerEvents = [];
|
|
481
|
+
let ledgerRules = [];
|
|
482
|
+
try {
|
|
483
|
+
const { loadModelRules } = await import('./model-rules.js');
|
|
484
|
+
ledgerRules = loadModelRules().rules.filter((r) => r.status !== 'off');
|
|
485
|
+
} catch { /* no registry → no attributable savings, which is the honest zero */ }
|
|
486
|
+
// A rule's baseline: what it stored at promotion, else what this scan still
|
|
487
|
+
// observes handling the category directly (a rule promoted before baselines
|
|
488
|
+
// existed backfills on the next refresh).
|
|
489
|
+
const baselineFor = (rule, tier, catId, projectDir) => {
|
|
490
|
+
if (rule.baselineModel) return rule.baselineModel;
|
|
491
|
+
const s = episodeStats.get(`${tier}|${catId}|${projectDir}`)
|
|
492
|
+
|| (rule.scope === 'global' ? episodeStats.get(`${tier}|${catId}|*`) : null);
|
|
493
|
+
return s?.baselineModel || null;
|
|
494
|
+
};
|
|
495
|
+
const ruleForRun = (tier, catId, projectDir) => ledgerRules.find((r) =>
|
|
496
|
+
r.tier === tier && r.category === catId &&
|
|
497
|
+
(r.scope === 'global' || r.project === projectDir));
|
|
458
498
|
for (const [sessionPath, index] of runIndexBySession) {
|
|
459
499
|
const used = new Set();
|
|
460
500
|
for (const { ep, projectDir, sessionPath: epSession } of all) {
|
|
@@ -478,11 +518,27 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
478
518
|
const saved = runSaving(run, mainModel);
|
|
479
519
|
bumpDelegated(`${runTier}|${cat.id}|${projectDir}`, run, saved);
|
|
480
520
|
bumpDelegated(`${runTier}|${cat.id}|*`, run, saved);
|
|
481
|
-
|
|
521
|
+
// Routing saving: priced against the rule's baseline (before → after),
|
|
522
|
+
// not against whatever the session's priciest model happened to be.
|
|
523
|
+
const rule = ruleForRun(runTier, cat.id, projectDir);
|
|
524
|
+
if (!rule) continue; // no rule routed this run — not our saving to claim
|
|
525
|
+
const baseline = baselineFor(rule, runTier, cat.id, projectDir);
|
|
526
|
+
if (!baseline) continue; // baseline unknown → no honest counterfactual
|
|
527
|
+
// Both sides of the comparison must be ids the pricing table really
|
|
528
|
+
// recognizes. A house alias from a company gateway prices as Sonnet by
|
|
529
|
+
// default, which would fabricate a saving against a cheap baseline or
|
|
530
|
+
// erase a real one — worse than showing nothing. Map such ids in
|
|
531
|
+
// profile-map.json's `modelAliases` to bring these runs back in.
|
|
532
|
+
if (!isRecognizedModelId(baseline) || !isRecognizedModelId(run.model)) continue;
|
|
533
|
+
const routed = runSaving(run, baseline);
|
|
534
|
+
if (routed > 0) {
|
|
482
535
|
ledgerEvents.push({
|
|
483
536
|
key: run.path,
|
|
484
537
|
ts: run.endedAt ?? run.startedAt ?? Date.now(),
|
|
485
|
-
usd:
|
|
538
|
+
usd: routed,
|
|
539
|
+
rule: rule.signature,
|
|
540
|
+
from: baseline,
|
|
541
|
+
to: run.model,
|
|
486
542
|
});
|
|
487
543
|
}
|
|
488
544
|
}
|
package/src/savings-ledger.js
CHANGED
|
@@ -8,7 +8,15 @@
|
|
|
8
8
|
* upsert instead of double-counting.
|
|
9
9
|
*
|
|
10
10
|
* File: <userDataDir>/delegation-ledger.json
|
|
11
|
-
* { "
|
|
11
|
+
* { "version": 2,
|
|
12
|
+
* "events": { "<run path>": { "ts", "usd", "rule", "from", "to" } } }
|
|
13
|
+
*
|
|
14
|
+
* `from`/`to` are the models the routing decision moved between, and `usd` is
|
|
15
|
+
* the price difference between them for this run's tokens. Version 1 priced
|
|
16
|
+
* every downgraded subagent run against the session's priciest model, which
|
|
17
|
+
* credited the tool for runs no rule of its own had routed; those events are
|
|
18
|
+
* discarded rather than migrated, since the number they carry cannot be
|
|
19
|
+
* recomputed without a rescan (which route-scan does anyway).
|
|
12
20
|
*
|
|
13
21
|
* Best-effort like every other state file here: an unreadable ledger reads as
|
|
14
22
|
* empty, and the statusline renders totals of 0 as "no chip".
|
|
@@ -25,32 +33,48 @@ export function ledgerPath() {
|
|
|
25
33
|
return join(userDataDir(), 'delegation-ledger.json');
|
|
26
34
|
}
|
|
27
35
|
|
|
36
|
+
export const LEDGER_VERSION = 2;
|
|
37
|
+
|
|
28
38
|
export function loadLedger() {
|
|
29
39
|
try {
|
|
30
40
|
const data = JSON.parse(readFileSync(ledgerPath(), 'utf8'));
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
41
|
+
if (!data || typeof data.events !== 'object' || data.events === null) {
|
|
42
|
+
return { version: LEDGER_VERSION, events: {} };
|
|
43
|
+
}
|
|
44
|
+
// Pre-v2 events were priced against a different counterfactual — drop them
|
|
45
|
+
// instead of mixing two meanings into one total. The next scan rebuilds
|
|
46
|
+
// whatever is still attributable.
|
|
47
|
+
if (data.version !== LEDGER_VERSION) return { version: LEDGER_VERSION, events: {} };
|
|
48
|
+
return data;
|
|
34
49
|
} catch {
|
|
35
|
-
return { events: {} };
|
|
50
|
+
return { version: LEDGER_VERSION, events: {} };
|
|
36
51
|
}
|
|
37
52
|
}
|
|
38
53
|
|
|
39
54
|
/**
|
|
40
|
-
* Upsert saving events. `events` is an array of
|
|
41
|
-
* is the run's transcript path
|
|
42
|
-
*
|
|
55
|
+
* Upsert saving events. `events` is an array of
|
|
56
|
+
* { key, ts, usd, rule, from, to } where `key` is the run's transcript path
|
|
57
|
+
* (unique per subagent run) and `from`/`to` name the models the routing
|
|
58
|
+
* decision moved between. Zero-saving runs are skipped — they carry no
|
|
59
|
+
* information the totals care about.
|
|
43
60
|
*/
|
|
44
61
|
export function recordDelegationEvents(events) {
|
|
45
62
|
if (!Array.isArray(events) || events.length === 0) return;
|
|
46
63
|
const data = loadLedger();
|
|
64
|
+
data.version = LEDGER_VERSION;
|
|
47
65
|
let changed = false;
|
|
48
66
|
for (const e of events) {
|
|
49
67
|
if (!e || !e.key || !(Number(e.usd) > 0) || !Number.isFinite(e.ts)) continue;
|
|
50
68
|
const prev = data.events[e.key];
|
|
51
69
|
const usd = Math.round(Number(e.usd) * 10000) / 10000;
|
|
52
70
|
if (prev && prev.ts === e.ts && prev.usd === usd) continue;
|
|
53
|
-
data.events[e.key] = {
|
|
71
|
+
data.events[e.key] = {
|
|
72
|
+
ts: e.ts,
|
|
73
|
+
usd,
|
|
74
|
+
...(e.rule ? { rule: e.rule } : {}),
|
|
75
|
+
...(e.from ? { from: e.from } : {}),
|
|
76
|
+
...(e.to ? { to: e.to } : {}),
|
|
77
|
+
};
|
|
54
78
|
changed = true;
|
|
55
79
|
}
|
|
56
80
|
if (!changed) return;
|