claude-token-saver 3.11.2 → 3.12.1

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,14 @@ Also update `statusLine.command` in `~/.claude/settings.json` to `claude-token-s
301
301
 
302
302
  ## Release notes
303
303
 
304
+ ### v3.12.1 (2026-08-22)
305
+ - **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
+
307
+ ### v3.12.0 (2026-08-22)
308
+ - **Routing saved now leads the statusline with weekly / monthly / lifetime totals** — `🔀 Routing saved weekly $1.3 · monthly $2.0 · total $9.8`. 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.
309
+ - **`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.
310
+ - **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.
311
+
304
312
  ### v3.11.0 (2026-08-21)
305
313
  - **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
314
  - **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,14 @@ npm uninstall -g claude-cache-monitor && npm i -g claude-token-saver
257
257
 
258
258
  ## 릴리스 노트
259
259
 
260
+ ### v3.12.1 (2026-08-22)
261
+ - **라우팅 절감 헤드라인의 가독성 정리** — 금액을 절감 녹색으로 칠하고(그 줄에서 유일하게 명확한 호재입니다), `wk`·`mo`·`all` 축약을 `weekly`·`monthly`·`total`로 풀었으며, 금액이 앞서던 순서를 뒤집어 기간이 먼저 오게 했습니다. 금액 셋이 연달아 나오면 뒤따르는 기간 표시를 찾기 전까지 한 덩어리로 읽혔습니다.
262
+
263
+ ### v3.12.0 (2026-08-22)
264
+ - **Routing saved가 주간·월간·누적으로 statusline 1줄째에 올라옵니다** — `🔀 Routing saved weekly $1.3 · monthly $2.0 · total $9.8`. 위임 절감 이벤트를 서브에이전트 run 단위로 원장(`delegation-ledger.json`)에 기록하고(트랜스크립트 경로 키라 재스캔이 겹쳐도 중복 집계 없음), 7일·30일·전체 합산을 표시합니다. 나머지 칩은 2줄째로 내려갑니다. 원장이 비어 있으면 기존 1줄 그대로이고, 일부 환경(macOS 구버전 Claude Code)에서 첫 줄만 보이면 `--single-line`으로 기존 레이아웃을 유지할 수 있습니다. 집계는 원장 도입 시점부터 시작합니다(소급 없음).
265
+ - **`harness check`가 컨텍스트 무게도 알려줍니다** — CLAUDE.md의 요청당 대략 토큰(~4k 가이드라인 초과 시 경고)과 `.claudeignore` 유무를 표시합니다. 자문 정보일 뿐 🅷 N/5 점수에는 반영하지 않습니다.
266
+ - **모델명이 내장된 ARN은 학습 없이 즉시 해석** — `foundation-model/anthropic.claude-…`와 시스템 교차 리전 프로파일(`inference-profile/us.anthropic.claude-…`)은 자원 ID가 곧 모델명인데도 `unknown`으로 떨어져, 학습 표본이 쌓이기 전의 게이트웨이 환경에서 위임·비용 집계가 통째로 빠졌습니다. 이제 즉시 기존 티어 분류로 넘깁니다. 불투명한 application-profile ID는 종전대로 오버라이드/학습 경로입니다.
267
+
260
268
  ### v3.11.0 (2026-08-21)
261
269
  - **statusline에 위임 절감 칩 추가** — `🔀 Routing saved $3.2`. 모델 위임으로 아낀 누적 비용이며, 모델 이름 바로 뒤에 놓여 줄 앞쪽에서 읽힙니다. 기존 `💰 Cache saved`(프롬프트 캐시 절감)와는 다른 수치입니다. 값이 0이거나 데이터가 없으면 칩을 아예 그리지 않아 직접 API 사용자에게는 아무것도 늘지 않습니다. statusline은 `model-rules.json`을 읽기만 하며 스캔을 돌리지 않습니다(5초마다 호출되는 자리입니다). 세그먼트 이름은 `delegated`입니다.
262
270
  - **역할 학습의 오분류 수정** — 서브에이전트 레코드가 부모 트랜스크립트에 `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.1",
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,30 @@ 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 weekly $1.2 · monthly $3.4 · total $9.8"
281
+ // text: "Routing saved weekly $1.2 · monthly $3.4 · total $9.8"
282
+ const totals = data.delegationTotals;
283
+ let totalsLine = null;
284
+ if (!singleLine && totals && Number(totals.total) > 0) {
285
+ // Money is green throughout — it is saved cost, the one number on the
286
+ // line that is unambiguously good news. The period markers stay gray so
287
+ // the eye lands on the amounts, not on "wk / mo / all".
288
+ // Period first, amount second — three bare amounts in a row read as one
289
+ // number until the eye finds the trailing marker, so the label leads and
290
+ // the green amount answers it.
291
+ const part = (usd, label) =>
292
+ `${c(GRAY)}${label}${c(RESET)} ${c(GREEN)}${formatMoney(Number(usd) || 0)}${c(RESET)}`;
293
+ const head = isIcon ? '🔀 Routing saved' : 'Routing saved';
294
+ totalsLine =
295
+ `${c(GREEN)}${c(BOLD)}${head}${c(RESET)} ` +
296
+ `${part(totals.week, 'weekly')} ${c(GRAY)}·${c(RESET)} ` +
297
+ `${part(totals.month, 'monthly')} ${c(GRAY)}·${c(RESET)} ` +
298
+ `${part(totals.total, 'total')}`;
299
+ }
300
+
276
301
  // Period label honors hour-precision configs (`mode 6h` → "6h", `mode 1d` → "1d").
277
302
  // Fall back to legacy `${days}d` when callers haven't supplied a label.
278
303
  const periodLabel = options.windowLabel || `${options.days}d`;
@@ -493,7 +518,9 @@ export function formatReport(data, { color = true, verbose = false, timer = true
493
518
  // Delegation savings ride up front, next to the model that would otherwise
494
519
  // have done the work. "Cache saved" stays at the tail: it is a lifetime brag
495
520
  // stat, while this one is the point of the tool.
496
- if (delegateSeg && want('delegated')) segs.push(delegateSeg);
521
+ // When the totals headline owns line 1, the inline session chip would
522
+ // repeat the same story on line 2 — drop it there.
523
+ if (delegateSeg && want('delegated') && !totalsLine) segs.push(delegateSeg);
497
524
  if (want('hit')) segs.push(hitSeg);
498
525
  if (want('ttl')) segs.push(ttlSeg);
499
526
  for (const { key, seg } of usageSegs) {
@@ -509,5 +536,10 @@ export function formatReport(data, { color = true, verbose = false, timer = true
509
536
  // (longer) statusline render don't bleed into ours. \x1b[K is the standard
510
537
  // "erase from cursor to EOL" CSI. Only emitted when color (i.e. ANSI) is
511
538
  // allowed — --no-color/NO_COLOR consumers expect escape-free output.
512
- return segs.join(' · ') + (color ? '\x1b[K' : '');
539
+ const eol = color ? '\x1b[K' : '';
540
+ const rest = segs.join(' · ') + eol;
541
+ if (totalsLine && want('delegated')) {
542
+ return totalsLine + eol + '\n' + rest;
543
+ }
544
+ return rest;
513
545
  }
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
+ }