@dzhechkov/harness-core 0.3.99 → 0.3.100

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/src/usage.ts CHANGED
@@ -1,34 +1,29 @@
1
1
  /**
2
2
  * `dz usage` data source — a READONLY, never-throw, best-effort estimate of Claude SESSION
3
- * (active 5h-block) and WEEKLY (rolling 7d) token usage, aggregated from the local Claude Code
4
- * transcript files under `~/.claude/projects/<munged>/<session>.jsonl`.
3
+ * and WEEKLY token usage, aggregated from the local Claude Code transcript files under
4
+ * `~/.claude/projects/<munged>/<session>.jsonl`.
5
5
  *
6
- * ## Honest-uncertainty contract (LOAD-BEARING — NFR-4)
6
+ * ## Honest-uncertainty contract (LOAD-BEARING)
7
7
  *
8
- * These percentages are **ESTIMATES** derived by aggregating local transcript token counts against
9
- * a USER-CONFIGURED limit. There is **no official usage API** being consulted this is a fuel
10
- * gauge built from the flow meter, not a tank sensor. Therefore:
8
+ * These percentages are ESTIMATES derived by aggregating local transcript token counts against
9
+ * USER-CONFIGURED limits. There is no official Anthropic usage API being consulted; claude.ai is
10
+ * authoritative. Therefore:
11
11
  *
12
- * - Every payload carries `estimated: true` so a consumer can NEVER mistake it for ground truth.
13
- * - When a limit is **unconfigured** (`.dz/config.json` has no `memory.usage.sessionTokenLimit` /
14
- * `weeklyTokenLimit`), the corresponding `pct` is `null` **unknown, never 0, never a guess**.
15
- * - **Calibration protocol (observed-exhaustion):** when a real limit-hit occurs at an estimated
16
- * `X%`, scale the configured limit by `X/100` so the next estimate lines up with reality. The
17
- * 4 token fields are summed with equal weight; the relative weighting (cache-read tokens
18
- * dominate real transcripts) is absorbed into the calibrated limit value.
12
+ * - Every JSON payload emitted by the CLI carries `estimated: true`.
13
+ * - When a limit is unconfigured, the corresponding `pct` is `null` — unknown, never 0.
14
+ * - Weekly usage counts from a fixed configured reset anchor such as `Wed 08:59`, not from a
15
+ * rolling seven-day window.
16
+ * - Session usage counts from the active fixed-length transcript block, not from a rolling
17
+ * last-N-hours window.
19
18
  *
20
- * ## Statusline discipline (NFR-1)
19
+ * ## Statusline discipline
21
20
  *
22
21
  * Modeled on {@link ./statusline.ts} and {@link ./vector-tier.ts}'s `readVectorEngineMode`:
23
- * - **never-throw**any error (missing `~/.claude/projects`, corrupt jsonl line, missing/corrupt
24
- * config, garbled usage object) collapses to a value with `null` pcts, never an exception.
25
- * - **readonly** — zero writes/appends/mkdir/unlink anywhere (the optional aggregate cache in
26
- * arch §2.2(6) is DEFERRED v1 ships with zero writes so READONLY is trivially true).
27
- * - **<100ms steady-state** via an `mtime` prefilter: a file whose `mtime` is older than the weekly
28
- * window cannot contribute and is skipped WITHOUT opening it; only files touched inside the
29
- * session window are line-parsed for the active block.
30
- * - **injectable clock** — `computeUsage(root, now?)` takes an optional `now` (ms epoch) so all
31
- * window math is deterministic under test.
22
+ * - never-throw — missing/corrupt config, transcript directories, and jsonl lines collapse to
23
+ * best-effort zero/null values, never exceptions;
24
+ * - readonly — `computeUsage` performs zero writes;
25
+ * - bounded scanning via an mtime prefilter;
26
+ * - injectable clock `computeUsage(root, now?)` takes an optional epoch-ms clock.
32
27
  *
33
28
  * @packageDocumentation
34
29
  */
@@ -38,40 +33,195 @@ import { homedir } from 'node:os';
38
33
  import { join } from 'node:path';
39
34
 
40
35
  const HOUR_MS = 60 * 60 * 1000;
41
- const SESSION_BLOCK_MS = 5 * HOUR_MS; // ccusage 5h-block
42
- const WEEK_MS = 7 * 24 * HOUR_MS; // rolling 7-day weekly window
43
- // mtime prefilter slack (+1h) — guards against clock skew between the writer and this reader
44
- // (a file with a slightly-stale mtime that is actually in-window must not be dropped).
36
+ const DEFAULT_SESSION_BLOCK_HOURS = 5;
37
+ const DEFAULT_WEEKLY_RESET_ANCHOR = 'Wed 08:59';
38
+ // mtime prefilter slack (+1h) — guards against clock skew between the writer and this reader.
45
39
  const MTIME_SLACK_MS = HOUR_MS;
46
40
 
41
+ export const CLAUDE_USAGE_MODELS = ['fable', 'opus', 'sonnet', 'haiku'] as const;
42
+ export type ClaudeUsageModel = (typeof CLAUDE_USAGE_MODELS)[number];
43
+
44
+ export interface UsageWindow {
45
+ readonly startedAtMs: number;
46
+ readonly resetsAtMs: number;
47
+ }
48
+
49
+ export interface WeeklyResetAnchor {
50
+ readonly weekday: number; // local Date#getDay(): Sun=0, Mon=1, ...
51
+ readonly hour: number;
52
+ readonly minute: number;
53
+ }
54
+
47
55
  /** Optional, plan-dependent calibration limits from `.dz/config.json`. Absent ⇒ pct is `null`. */
48
56
  export interface UsageLimits {
49
57
  readonly sessionTokenLimit?: number;
50
58
  readonly weeklyTokenLimit?: number;
59
+ readonly weeklyTokenLimitByModel?: Partial<Record<ClaudeUsageModel, number>>;
60
+ readonly weeklyResetAnchor?: string;
61
+ readonly sessionBlockHours?: number;
62
+ readonly calibratedAt?: string;
63
+ readonly source?: string;
64
+ }
65
+
66
+ export interface UsageModelEstimate {
67
+ readonly tokens: number;
68
+ readonly pct: number | null;
51
69
  }
52
70
 
53
71
  /** A never-throw usage estimate. `estimated` is ALWAYS `true` (honest-uncertainty marker). */
54
72
  export interface UsageEstimate {
55
- /** Active 5h-block token total (all projects). `0` when there is no active block. */
73
+ /** Active fixed-length session block token total (all projects). */
56
74
  readonly sessionTokens: number;
57
- /** Rolling 7d token total (all projects). */
75
+ /** Fixed weekly-reset-window token total (all projects). */
58
76
  readonly weeklyTokens: number;
59
- /** `null` ⇔ `sessionTokenLimit` unconfigured (unknown — never 0). */
77
+ /** `null` ⇔ `sessionTokenLimit` unconfigured (unknown — never a guess). */
60
78
  readonly sessionPct: number | null;
61
- /** `null` `weeklyTokenLimit` unconfigured (unknown never 0). */
79
+ /** Binding weekly pct: per-model max when configured, otherwise all-model aggregate. */
62
80
  readonly weeklyPct: number | null;
63
- /** ISO — active block start + 5h; `null` when there is no active block. */
81
+ /** ISO — active block start + configured session hours; `null` when there is no active block. */
64
82
  readonly sessionResetsAt: string | null;
65
- /** ISO — oldest in-window sample ts + 7d (estimate); `null` when the weekly window is empty. */
83
+ /** ISO — next configured fixed weekly reset. */
66
84
  readonly weeklyResetsAt: string | null;
67
85
  /** ALWAYS `true` — these are estimates from local aggregation, not an official API. */
68
86
  readonly estimated: true;
87
+ /** Present only when per-model weekly limits are configured. */
88
+ readonly weeklyByModel?: Partial<Record<ClaudeUsageModel, UsageModelEstimate>>;
89
+ /** Raw weekly model totals for calibration; CLI omits this from the compatibility JSON. */
90
+ readonly weeklyTokensByModel: Partial<Record<ClaudeUsageModel, number>>;
91
+ /** Model family that supplied the binding top-level weeklyPct, if any. */
92
+ readonly weeklyBindingModel?: ClaudeUsageModel;
93
+ /** Traceability for tests and calibration diagnostics. */
94
+ readonly sessionStartedAt: string | null;
95
+ readonly weeklyStartedAt: string | null;
96
+ }
97
+
98
+ export interface UsageCalibrationInput {
99
+ readonly sessionPct?: unknown;
100
+ readonly weeklyPct?: unknown;
101
+ readonly modelPct?: Readonly<Record<string, unknown>>;
102
+ readonly calibratedAt: string;
103
+ readonly source: 'claude.ai/settings/usage';
104
+ }
105
+
106
+ export interface UsageCalibrationChange {
107
+ readonly key: string;
108
+ readonly before: number | null;
109
+ readonly after: number;
110
+ readonly tokens: number;
111
+ readonly pct: number;
112
+ }
113
+
114
+ export interface UsageCalibrationPlan {
115
+ readonly before: UsageLimits;
116
+ readonly after: UsageLimits;
117
+ readonly changes: readonly UsageCalibrationChange[];
118
+ readonly skipped: readonly string[];
119
+ }
120
+
121
+ interface MutableUsageLimits {
122
+ sessionTokenLimit?: number;
123
+ weeklyTokenLimit?: number;
124
+ weeklyTokenLimitByModel?: Partial<Record<ClaudeUsageModel, number>>;
125
+ weeklyResetAnchor?: string;
126
+ sessionBlockHours?: number;
127
+ calibratedAt?: string;
128
+ source?: string;
129
+ }
130
+
131
+ const WEEKDAY_TO_DAY: Record<string, number> = {
132
+ sun: 0,
133
+ mon: 1,
134
+ tue: 2,
135
+ wed: 3,
136
+ thu: 4,
137
+ fri: 5,
138
+ sat: 6,
139
+ };
140
+
141
+ const DAY_TO_WEEKDAY = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const;
142
+
143
+ function isRecord(value: unknown): value is Record<string, unknown> {
144
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
145
+ }
146
+
147
+ function positiveFiniteNumber(value: unknown): number | undefined {
148
+ return typeof value === 'number' && isFinite(value) && value > 0 ? value : undefined;
149
+ }
150
+
151
+ function hasOwn(obj: object, key: string): boolean {
152
+ return Object.prototype.hasOwnProperty.call(obj, key);
153
+ }
154
+
155
+ function canonicalWeeklyResetAnchor(anchor: WeeklyResetAnchor): string {
156
+ return `${DAY_TO_WEEKDAY[anchor.weekday] ?? 'Wed'} ${String(anchor.hour).padStart(2, '0')}:${String(anchor.minute).padStart(2, '0')}`;
157
+ }
158
+
159
+ export function parseWeeklyResetAnchor(anchor: string): WeeklyResetAnchor | null {
160
+ const m = /^\s*(sun|mon|tue|wed|thu|fri|sat)\s+(\d{1,2}):(\d{2})\s*$/i.exec(anchor);
161
+ if (!m) return null;
162
+ const weekday = WEEKDAY_TO_DAY[m[1]!.toLowerCase()];
163
+ const hour = Number(m[2]);
164
+ const minute = Number(m[3]);
165
+ if (weekday === undefined || !Number.isInteger(hour) || !Number.isInteger(minute)) return null;
166
+ if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
167
+ return { weekday, hour, minute };
168
+ }
169
+
170
+ export function weeklyWindowFor(nowMs: number, anchor: string): UsageWindow | null {
171
+ if (!isFinite(nowMs)) return null;
172
+ const parsed = parseWeeklyResetAnchor(anchor);
173
+ if (!parsed) return null;
174
+
175
+ const now = new Date(nowMs);
176
+ const candidate = new Date(
177
+ now.getFullYear(),
178
+ now.getMonth(),
179
+ now.getDate(),
180
+ parsed.hour,
181
+ parsed.minute,
182
+ 0,
183
+ 0,
184
+ );
185
+ candidate.setDate(candidate.getDate() + (parsed.weekday - now.getDay()));
186
+ if (candidate.getTime() > nowMs) candidate.setDate(candidate.getDate() - 7);
187
+ const next = new Date(candidate.getTime());
188
+ next.setDate(next.getDate() + 7);
189
+ return { startedAtMs: candidate.getTime(), resetsAtMs: next.getTime() };
190
+ }
191
+
192
+ /**
193
+ * A simple fixed-duration grid helper kept exported for pure date-math tests. `computeUsage` uses
194
+ * transcript-established session blocks per the feature requirements because no account-specific
195
+ * session anchor is stored today.
196
+ */
197
+ export function fixedBlockWindowFor(nowMs: number, blockHours: number): UsageWindow | null {
198
+ if (!isFinite(nowMs)) return null;
199
+ const hours = positiveFiniteNumber(blockHours);
200
+ if (hours === undefined) return null;
201
+ const blockMs = hours * HOUR_MS;
202
+ const startedAtMs = Math.floor(nowMs / blockMs) * blockMs;
203
+ return { startedAtMs, resetsAtMs: startedAtMs + blockMs };
204
+ }
205
+
206
+ export function normalizeClaudeUsageModel(raw: unknown): ClaudeUsageModel | null {
207
+ if (typeof raw !== 'string') return null;
208
+ const s = raw.toLowerCase();
209
+ if (s.includes('fable')) return 'fable';
210
+ if (s.includes('opus')) return 'opus';
211
+ if (s.includes('sonnet')) return 'sonnet';
212
+ if (s.includes('haiku')) return 'haiku';
213
+ return null;
214
+ }
215
+
216
+ export function normalizeClaudeUsageModelKey(raw: unknown): ClaudeUsageModel | null {
217
+ if (typeof raw !== 'string') return null;
218
+ const key = raw.trim().toLowerCase();
219
+ return (CLAUDE_USAGE_MODELS as readonly string[]).includes(key) ? (key as ClaudeUsageModel) : null;
69
220
  }
70
221
 
71
222
  /**
72
223
  * The `~/.claude/projects` root (the account-wide transcript store). Overridable via
73
- * `DZ_CLAUDE_PROJECTS_ROOT` — used by tests to point at a temp tree (and by any user who relocates
74
- * the Claude home). Never throws.
224
+ * `DZ_CLAUDE_PROJECTS_ROOT` — used by tests to point at a temp tree. Never throws.
75
225
  */
76
226
  function claudeProjectsRoot(): string {
77
227
  const override = process.env['DZ_CLAUDE_PROJECTS_ROOT'];
@@ -80,23 +230,45 @@ function claudeProjectsRoot(): string {
80
230
  }
81
231
 
82
232
  /**
83
- * Read `memory.usage.{sessionTokenLimit,weeklyTokenLimit}` from `<projectRoot>/.dz/config.json`.
84
- * NEVER throws — absent/corrupt/partial config ⇒ `{}` (⇒ pct `null`). Mirrors the
85
- * `readVectorEngineMode` never-throw shape exactly. `projectRoot` is the ONLY thing that scopes to
86
- * a project; the MEASUREMENT below is account-wide (FR-1.6).
233
+ * Read `memory.usage.*` from `<projectRoot>/.dz/config.json`. NEVER throws —
234
+ * absent/corrupt/partial config ⇒ `{}` or only valid fields.
87
235
  */
88
236
  export function readUsageLimits(projectRoot: string): UsageLimits {
89
237
  try {
90
- const cfg = JSON.parse(readFileSync(join(projectRoot, '.dz', 'config.json'), 'utf-8')) as {
91
- memory?: { usage?: { sessionTokenLimit?: unknown; weeklyTokenLimit?: unknown } };
92
- };
93
- const u = cfg.memory?.usage;
94
- if (!u || typeof u !== 'object') return {};
95
- const out: { sessionTokenLimit?: number; weeklyTokenLimit?: number } = {};
96
- const s = u.sessionTokenLimit;
97
- const w = u.weeklyTokenLimit;
98
- if (typeof s === 'number' && isFinite(s) && s > 0) out.sessionTokenLimit = s;
99
- if (typeof w === 'number' && isFinite(w) && w > 0) out.weeklyTokenLimit = w;
238
+ const cfg = JSON.parse(readFileSync(join(projectRoot, '.dz', 'config.json'), 'utf-8')) as unknown;
239
+ if (!isRecord(cfg)) return {};
240
+ const memory = cfg['memory'];
241
+ if (!isRecord(memory)) return {};
242
+ const u = memory['usage'];
243
+ if (!isRecord(u)) return {};
244
+
245
+ const out: MutableUsageLimits = {};
246
+ const sessionLimit = positiveFiniteNumber(u['sessionTokenLimit']);
247
+ const weeklyLimit = positiveFiniteNumber(u['weeklyTokenLimit']);
248
+ const sessionBlockHours = positiveFiniteNumber(u['sessionBlockHours']);
249
+ if (sessionLimit !== undefined) out.sessionTokenLimit = sessionLimit;
250
+ if (weeklyLimit !== undefined) out.weeklyTokenLimit = weeklyLimit;
251
+ if (sessionBlockHours !== undefined) out.sessionBlockHours = sessionBlockHours;
252
+
253
+ const anchor = u['weeklyResetAnchor'];
254
+ if (typeof anchor === 'string') {
255
+ const parsed = parseWeeklyResetAnchor(anchor);
256
+ if (parsed) out.weeklyResetAnchor = canonicalWeeklyResetAnchor(parsed);
257
+ }
258
+
259
+ const byModel = u['weeklyTokenLimitByModel'];
260
+ if (isRecord(byModel)) {
261
+ const modelLimits: Partial<Record<ClaudeUsageModel, number>> = {};
262
+ for (const [key, value] of Object.entries(byModel)) {
263
+ const model = normalizeClaudeUsageModelKey(key);
264
+ const limit = positiveFiniteNumber(value);
265
+ if (model && limit !== undefined) modelLimits[model] = limit;
266
+ }
267
+ if (Object.keys(modelLimits).length > 0) out.weeklyTokenLimitByModel = modelLimits;
268
+ }
269
+
270
+ if (typeof u['calibratedAt'] === 'string') out.calibratedAt = u['calibratedAt'];
271
+ if (typeof u['source'] === 'string') out.source = u['source'];
100
272
  return out;
101
273
  } catch {
102
274
  return {};
@@ -108,6 +280,7 @@ interface Sample {
108
280
  readonly ts: number; // ms epoch
109
281
  readonly tokens: number;
110
282
  readonly key: string; // dedup key: message.id + ':' + requestId
283
+ readonly model: ClaudeUsageModel | null;
111
284
  }
112
285
 
113
286
  /**
@@ -139,7 +312,7 @@ function listTranscriptFiles(root: string): Array<{ path: string; mtimeMs: numbe
139
312
  try {
140
313
  out.push({ path: p, mtimeMs: statSync(p).mtimeMs });
141
314
  } catch {
142
- // skip a file we can't stat
315
+ // skip a file we cannot stat
143
316
  }
144
317
  }
145
318
  }
@@ -147,11 +320,10 @@ function listTranscriptFiles(root: string): Array<{ path: string; mtimeMs: numbe
147
320
  }
148
321
 
149
322
  /**
150
- * Extract usage samples from one transcript file. Never throws — a corrupt line is skipped
151
- * (statusline discipline). A cheap `"usage"` substring pre-filter avoids `JSON.parse` on lines
152
- * that cannot carry a token count. `weeklyCutoff` drops samples older than the rolling window.
323
+ * Extract usage samples from one transcript file. Never throws — a corrupt line is skipped.
324
+ * `scanCutoff` drops samples older than every window that could affect the estimate.
153
325
  */
154
- function extractSamples(path: string, weeklyCutoff: number, into: Sample[], seen: Set<string>): void {
326
+ function extractSamples(path: string, scanCutoff: number, into: Sample[], seen: Set<string>): void {
155
327
  let raw: string;
156
328
  try {
157
329
  raw = readFileSync(path, 'utf-8');
@@ -165,8 +337,10 @@ function extractSamples(path: string, weeklyCutoff: number, into: Sample[], seen
165
337
  let rec: {
166
338
  timestamp?: unknown;
167
339
  requestId?: unknown;
340
+ model?: unknown;
168
341
  message?: {
169
342
  id?: unknown;
343
+ model?: unknown;
170
344
  usage?: {
171
345
  input_tokens?: unknown;
172
346
  cache_creation_input_tokens?: unknown;
@@ -176,7 +350,7 @@ function extractSamples(path: string, weeklyCutoff: number, into: Sample[], seen
176
350
  };
177
351
  };
178
352
  try {
179
- rec = JSON.parse(line);
353
+ rec = JSON.parse(line) as typeof rec;
180
354
  } catch {
181
355
  continue; // corrupt line — skip, never throw
182
356
  }
@@ -186,7 +360,7 @@ function extractSamples(path: string, weeklyCutoff: number, into: Sample[], seen
186
360
  if (typeof tsRaw !== 'string' && typeof tsRaw !== 'number') continue;
187
361
  const ts = typeof tsRaw === 'number' ? tsRaw : Date.parse(tsRaw);
188
362
  if (!isFinite(ts)) continue;
189
- if (ts < weeklyCutoff) continue; // outside the weekly window — cannot contribute
363
+ if (ts < scanCutoff) continue;
190
364
  const n = (v: unknown): number => (typeof v === 'number' && isFinite(v) && v > 0 ? v : 0);
191
365
  const tokens =
192
366
  n(usage.input_tokens) +
@@ -202,88 +376,242 @@ function extractSamples(path: string, weeklyCutoff: number, into: Sample[], seen
202
376
  if (seen.has(key)) continue;
203
377
  seen.add(key);
204
378
  }
205
- into.push({ ts, tokens, key });
379
+ into.push({ ts, tokens, key, model: normalizeClaudeUsageModel(rec.message?.model ?? rec.model) });
206
380
  }
207
381
  }
208
382
 
209
- /**
210
- * Compute the SESSION active-block total using ccusage 5h-block semantics: walk samples in ts
211
- * order; a block starts at the first sample after the previous block ends, FLOORED to the hour;
212
- * `end = start + 5h`. The ACTIVE block is the one whose `[start, end)` contains `now`. No active
213
- * block ⇒ `{ tokens: 0, resetsAt: null }`.
214
- */
215
- function activeBlock(samplesAsc: Sample[], now: number): { tokens: number; resetsAt: string | null } {
383
+ function activeSessionBlock(
384
+ samplesAsc: Sample[],
385
+ nowMs: number,
386
+ blockHours: number,
387
+ ): { tokens: number; startedAtMs: number | null; resetsAtMs: number | null } {
388
+ const hours = positiveFiniteNumber(blockHours) ?? DEFAULT_SESSION_BLOCK_HOURS;
389
+ const blockMs = hours * HOUR_MS;
216
390
  let blockStart = -1;
217
391
  let blockEnd = -1;
218
392
  let tokens = 0;
219
- let activeStart = -1;
393
+ let activeStart: number | null = null;
394
+ let activeEnd: number | null = null;
220
395
  let activeTokens = 0;
396
+
221
397
  for (const s of samplesAsc) {
398
+ if (s.ts > nowMs) continue;
222
399
  if (blockStart === -1 || s.ts >= blockEnd) {
223
- // close the previous block, open a new one floored to the hour
224
- blockStart = Math.floor(s.ts / HOUR_MS) * HOUR_MS;
225
- blockEnd = blockStart + SESSION_BLOCK_MS;
400
+ blockStart = s.ts;
401
+ blockEnd = blockStart + blockMs;
226
402
  tokens = 0;
227
403
  }
228
404
  tokens += s.tokens;
229
- if (now >= blockStart && now < blockEnd) {
405
+ if (nowMs >= blockStart && nowMs < blockEnd) {
230
406
  activeStart = blockStart;
407
+ activeEnd = blockEnd;
231
408
  activeTokens = tokens;
232
409
  }
233
410
  }
234
- if (activeStart === -1) return { tokens: 0, resetsAt: null };
235
- return { tokens: activeTokens, resetsAt: new Date(activeStart + SESSION_BLOCK_MS).toISOString() };
411
+
412
+ return { tokens: activeTokens, startedAtMs: activeStart, resetsAtMs: activeEnd };
413
+ }
414
+
415
+ function pct(tokens: number, limit?: number): number | null {
416
+ return typeof limit === 'number' && limit > 0 ? Math.round((100 * tokens) / limit) : null;
417
+ }
418
+
419
+ function configuredModelLimits(
420
+ limits?: Partial<Record<ClaudeUsageModel, number>>,
421
+ ): Array<[ClaudeUsageModel, number]> {
422
+ if (!limits) return [];
423
+ const out: Array<[ClaudeUsageModel, number]> = [];
424
+ for (const model of CLAUDE_USAGE_MODELS) {
425
+ const limit = limits[model];
426
+ if (typeof limit === 'number' && isFinite(limit) && limit > 0) out.push([model, limit]);
427
+ }
428
+ return out;
236
429
  }
237
430
 
238
431
  /**
239
432
  * Estimate SESSION + WEEKLY token usage from the local Claude transcript store. NEVER throws;
240
- * READONLY; `<100ms` steady-state via the `mtime` prefilter. `projectRoot` scopes ONLY the config
241
- * (limits) read — measurement is account-wide (all projects). `now` is injectable for tests.
433
+ * READONLY; `projectRoot` scopes ONLY the config (limits) read measurement is account-wide
434
+ * (all projects). `now` is injectable for tests.
242
435
  */
243
436
  export function computeUsage(projectRoot: string, now?: number): UsageEstimate {
244
437
  const nowMs = typeof now === 'number' && isFinite(now) ? now : Date.now();
245
438
  const limits = readUsageLimits(projectRoot);
246
- const weeklyCutoff = nowMs - WEEK_MS - MTIME_SLACK_MS;
439
+ const sessionBlockHours = limits.sessionBlockHours ?? DEFAULT_SESSION_BLOCK_HOURS;
440
+ const weeklyAnchor = limits.weeklyResetAnchor ?? DEFAULT_WEEKLY_RESET_ANCHOR;
441
+ const weeklyWindow = weeklyWindowFor(nowMs, weeklyAnchor);
442
+ // For transcript-established blocks, one previous block may be needed to prove that an event just
443
+ // before the current boundary belongs to the prior block rather than opening the active one.
444
+ const sessionScanCutoff = nowMs - 2 * sessionBlockHours * HOUR_MS;
445
+ const weeklyScanCutoff = weeklyWindow?.startedAtMs ?? nowMs;
446
+ const scanCutoff = Math.min(sessionScanCutoff, weeklyScanCutoff) - MTIME_SLACK_MS;
247
447
 
248
448
  const samples: Sample[] = [];
249
449
  const seen = new Set<string>();
250
450
  try {
251
451
  const files = listTranscriptFiles(claudeProjectsRoot());
252
452
  for (const f of files) {
253
- // mtime prefilter: a file last written before the weekly cutoff cannot hold in-window
254
- // samples — skip it WITHOUT opening it (the <100ms lever).
255
- if (f.mtimeMs < weeklyCutoff) continue;
256
- extractSamples(f.path, weeklyCutoff, samples, seen);
453
+ // mtime prefilter: a file last written before every relevant cutoff cannot contribute.
454
+ if (f.mtimeMs < scanCutoff) continue;
455
+ extractSamples(f.path, scanCutoff, samples, seen);
257
456
  }
258
457
  } catch {
259
458
  // total scan failure ⇒ fall through with empty samples (nulls), never throw
260
459
  }
261
460
 
262
- // Weekly total: every sample inside the exact 7d window (the +slack was only a prefilter guard).
263
- const weeklyHardCutoff = nowMs - WEEK_MS;
264
461
  let weeklyTokens = 0;
265
- let oldestInWindow = -1;
266
- for (const s of samples) {
267
- if (s.ts < weeklyHardCutoff) continue;
268
- weeklyTokens += s.tokens;
269
- if (oldestInWindow === -1 || s.ts < oldestInWindow) oldestInWindow = s.ts;
462
+ const weeklyTokensByModel: Partial<Record<ClaudeUsageModel, number>> = {};
463
+ if (weeklyWindow) {
464
+ for (const s of samples) {
465
+ if (s.ts < weeklyWindow.startedAtMs || s.ts >= weeklyWindow.resetsAtMs || s.ts > nowMs) continue;
466
+ weeklyTokens += s.tokens;
467
+ if (s.model) weeklyTokensByModel[s.model] = (weeklyTokensByModel[s.model] ?? 0) + s.tokens;
468
+ }
270
469
  }
271
470
 
272
471
  const asc = samples.slice().sort((a, b) => a.ts - b.ts);
273
- const block = activeBlock(asc, nowMs);
274
-
275
- const pct = (tokens: number, limit?: number): number | null =>
276
- typeof limit === 'number' && limit > 0 ? Math.round((100 * tokens) / limit) : null;
472
+ const block = activeSessionBlock(asc, nowMs, sessionBlockHours);
277
473
 
278
- const weeklyResetsAt = oldestInWindow === -1 ? null : new Date(oldestInWindow + WEEK_MS).toISOString();
474
+ const modelLimits = configuredModelLimits(limits.weeklyTokenLimitByModel);
475
+ let weeklyByModel: Partial<Record<ClaudeUsageModel, UsageModelEstimate>> | undefined;
476
+ let weeklyPct = pct(weeklyTokens, limits.weeklyTokenLimit);
477
+ let weeklyBindingModel: ClaudeUsageModel | undefined;
478
+ if (modelLimits.length > 0) {
479
+ weeklyByModel = {};
480
+ weeklyPct = null;
481
+ for (const [model, limit] of modelLimits) {
482
+ const tokens = weeklyTokensByModel[model] ?? 0;
483
+ const modelPct = pct(tokens, limit);
484
+ weeklyByModel[model] = { tokens, pct: modelPct };
485
+ if (modelPct !== null && (weeklyPct === null || modelPct > weeklyPct)) {
486
+ weeklyPct = modelPct;
487
+ weeklyBindingModel = model;
488
+ }
489
+ }
490
+ }
279
491
 
280
492
  return {
281
493
  sessionTokens: block.tokens,
282
494
  weeklyTokens,
283
495
  sessionPct: pct(block.tokens, limits.sessionTokenLimit),
284
- weeklyPct: pct(weeklyTokens, limits.weeklyTokenLimit),
285
- sessionResetsAt: block.resetsAt,
286
- weeklyResetsAt,
496
+ weeklyPct,
497
+ sessionResetsAt: block.resetsAtMs === null ? null : new Date(block.resetsAtMs).toISOString(),
498
+ weeklyResetsAt: weeklyWindow === null ? null : new Date(weeklyWindow.resetsAtMs).toISOString(),
287
499
  estimated: true,
500
+ ...(weeklyByModel !== undefined ? { weeklyByModel } : {}),
501
+ weeklyTokensByModel,
502
+ ...(weeklyBindingModel !== undefined ? { weeklyBindingModel } : {}),
503
+ sessionStartedAt: block.startedAtMs === null ? null : new Date(block.startedAtMs).toISOString(),
504
+ weeklyStartedAt: weeklyWindow === null ? null : new Date(weeklyWindow.startedAtMs).toISOString(),
288
505
  };
289
506
  }
507
+
508
+ function validPct(label: string, value: unknown, skipped: string[]): number | null {
509
+ const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN;
510
+ if (!isFinite(n) || n <= 0 || n > 100) {
511
+ skipped.push(`${label}: skipped invalid percentage ${String(value)}`);
512
+ return null;
513
+ }
514
+ return n;
515
+ }
516
+
517
+ function applyCalibrationLimit(opts: {
518
+ label: string;
519
+ tokens: number;
520
+ rawPct: unknown;
521
+ before: number | undefined;
522
+ set: (limit: number) => void;
523
+ skipped: string[];
524
+ changes: UsageCalibrationChange[];
525
+ }): void {
526
+ const suppliedPct = validPct(opts.label, opts.rawPct, opts.skipped);
527
+ if (suppliedPct === null) return;
528
+ if (!isFinite(opts.tokens) || opts.tokens <= 0) {
529
+ opts.skipped.push(`${opts.label}: skipped because current token count is 0`);
530
+ return;
531
+ }
532
+ const limit = Math.round(opts.tokens / (suppliedPct / 100));
533
+ if (!isFinite(limit) || limit <= 0) {
534
+ opts.skipped.push(`${opts.label}: skipped because derived limit is invalid`);
535
+ return;
536
+ }
537
+ opts.set(limit);
538
+ opts.changes.push({
539
+ key: opts.label,
540
+ before: opts.before ?? null,
541
+ after: limit,
542
+ tokens: opts.tokens,
543
+ pct: suppliedPct,
544
+ });
545
+ }
546
+
547
+ export function deriveUsageCalibration(
548
+ current: UsageEstimate,
549
+ before: UsageLimits,
550
+ input: UsageCalibrationInput,
551
+ ): UsageCalibrationPlan {
552
+ const after: MutableUsageLimits = {
553
+ ...before,
554
+ ...(before.weeklyTokenLimitByModel !== undefined
555
+ ? { weeklyTokenLimitByModel: { ...before.weeklyTokenLimitByModel } }
556
+ : {}),
557
+ };
558
+ const skipped: string[] = [];
559
+ const changes: UsageCalibrationChange[] = [];
560
+
561
+ if (hasOwn(input, 'sessionPct')) {
562
+ applyCalibrationLimit({
563
+ label: 'session',
564
+ tokens: current.sessionTokens,
565
+ rawPct: input.sessionPct,
566
+ before: before.sessionTokenLimit,
567
+ set: (limit) => {
568
+ after.sessionTokenLimit = limit;
569
+ },
570
+ skipped,
571
+ changes,
572
+ });
573
+ }
574
+
575
+ if (hasOwn(input, 'weeklyPct')) {
576
+ applyCalibrationLimit({
577
+ label: 'weekly',
578
+ tokens: current.weeklyTokens,
579
+ rawPct: input.weeklyPct,
580
+ before: before.weeklyTokenLimit,
581
+ set: (limit) => {
582
+ after.weeklyTokenLimit = limit;
583
+ },
584
+ skipped,
585
+ changes,
586
+ });
587
+ }
588
+
589
+ if (input.modelPct !== undefined) {
590
+ for (const [key, rawPct] of Object.entries(input.modelPct)) {
591
+ const model = normalizeClaudeUsageModelKey(key);
592
+ if (!model) {
593
+ skipped.push(`model ${key}: skipped unknown model`);
594
+ continue;
595
+ }
596
+ applyCalibrationLimit({
597
+ label: model,
598
+ tokens: current.weeklyTokensByModel[model] ?? 0,
599
+ rawPct,
600
+ before: before.weeklyTokenLimitByModel?.[model],
601
+ set: (limit) => {
602
+ if (after.weeklyTokenLimitByModel === undefined) after.weeklyTokenLimitByModel = {};
603
+ after.weeklyTokenLimitByModel[model] = limit;
604
+ },
605
+ skipped,
606
+ changes,
607
+ });
608
+ }
609
+ }
610
+
611
+ if (changes.length > 0) {
612
+ after.calibratedAt = input.calibratedAt;
613
+ after.source = input.source;
614
+ }
615
+
616
+ return { before, after, changes, skipped };
617
+ }