@dzhechkov/harness-core 0.3.99 → 0.3.101
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/LICENSE +21 -0
- package/dist/claim-check.d.ts +21 -0
- package/dist/claim-check.d.ts.map +1 -0
- package/dist/claim-check.js +184 -0
- package/dist/claim-check.js.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/publish.d.ts +17 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +38 -3
- package/dist/publish.js.map +1 -1
- package/dist/usage.d.ts +88 -35
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +322 -86
- package/dist/usage.js.map +1 -1
- package/package.json +18 -18
- package/src/claim-check.ts +211 -0
- package/src/index.ts +24 -2
- package/src/publish.ts +53 -3
- package/src/usage.ts +421 -93
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
|
-
*
|
|
4
|
-
*
|
|
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
|
|
6
|
+
* ## Honest-uncertainty contract (LOAD-BEARING)
|
|
7
7
|
*
|
|
8
|
-
* These percentages are
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
13
|
-
* - When a limit is
|
|
14
|
-
*
|
|
15
|
-
* -
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
19
|
+
* ## Statusline discipline
|
|
21
20
|
*
|
|
22
21
|
* Modeled on {@link ./statusline.ts} and {@link ./vector-tier.ts}'s `readVectorEngineMode`:
|
|
23
|
-
* -
|
|
24
|
-
*
|
|
25
|
-
* -
|
|
26
|
-
*
|
|
27
|
-
* -
|
|
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
|
|
42
|
-
const
|
|
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
|
|
73
|
+
/** Active fixed-length session block token total (all projects). */
|
|
56
74
|
readonly sessionTokens: number;
|
|
57
|
-
/**
|
|
75
|
+
/** Fixed weekly-reset-window token total (all projects). */
|
|
58
76
|
readonly weeklyTokens: number;
|
|
59
|
-
/** `null` ⇔ `sessionTokenLimit` unconfigured (unknown — never
|
|
77
|
+
/** `null` ⇔ `sessionTokenLimit` unconfigured (unknown — never a guess). */
|
|
60
78
|
readonly sessionPct: number | null;
|
|
61
|
-
/**
|
|
79
|
+
/** Binding weekly pct: per-model max when configured, otherwise all-model aggregate. */
|
|
62
80
|
readonly weeklyPct: number | null;
|
|
63
|
-
/** ISO — active block start +
|
|
81
|
+
/** ISO — active block start + configured session hours; `null` when there is no active block. */
|
|
64
82
|
readonly sessionResetsAt: string | null;
|
|
65
|
-
/** ISO —
|
|
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
|
|
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
|
|
84
|
-
*
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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
|
-
*
|
|
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,
|
|
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 <
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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 =
|
|
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
|
-
|
|
224
|
-
|
|
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 (
|
|
405
|
+
if (nowMs >= blockStart && nowMs < blockEnd) {
|
|
230
406
|
activeStart = blockStart;
|
|
407
|
+
activeEnd = blockEnd;
|
|
231
408
|
activeTokens = tokens;
|
|
232
409
|
}
|
|
233
410
|
}
|
|
234
|
-
|
|
235
|
-
return { tokens: activeTokens,
|
|
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;
|
|
241
|
-
* (
|
|
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
|
|
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
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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 =
|
|
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
|
|
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
|
|
285
|
-
sessionResetsAt: block.
|
|
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
|
+
}
|