@agentguard-run/burn 0.2.3 → 0.2.6

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +30 -2
  2. package/README.md +105 -20
  3. package/dist/src/adapters/codex.js +11 -1
  4. package/dist/src/adapters/cursor.js +2 -2
  5. package/dist/src/calibrate.js +2 -3
  6. package/dist/src/cli.js +53 -15
  7. package/dist/src/conformance.d.ts +5 -2
  8. package/dist/src/conformance.js +30 -17
  9. package/dist/src/defaults.d.ts +7 -4
  10. package/dist/src/defaults.js +9 -6
  11. package/dist/src/detectors/evaluate.d.ts +4 -5
  12. package/dist/src/detectors/evaluate.js +13 -11
  13. package/dist/src/eligibility.d.ts +17 -0
  14. package/dist/src/eligibility.js +29 -0
  15. package/dist/src/gateway.d.ts +2 -0
  16. package/dist/src/gateway.js +4 -8
  17. package/dist/src/history/claude-transcript.d.ts +20 -2
  18. package/dist/src/history/claude-transcript.js +56 -15
  19. package/dist/src/hook/pre-tool-use.d.ts +13 -9
  20. package/dist/src/hook/pre-tool-use.js +63 -31
  21. package/dist/src/insights/attribution.d.ts +4 -0
  22. package/dist/src/insights/attribution.js +151 -0
  23. package/dist/src/insights/blocks.d.ts +61 -0
  24. package/dist/src/insights/blocks.js +243 -0
  25. package/dist/src/insights/live.d.ts +53 -0
  26. package/dist/src/insights/live.js +211 -0
  27. package/dist/src/insights/pace.d.ts +34 -0
  28. package/dist/src/insights/pace.js +54 -0
  29. package/dist/src/insights/pricing.d.ts +48 -0
  30. package/dist/src/insights/pricing.js +139 -0
  31. package/dist/src/insights/render.d.ts +8 -0
  32. package/dist/src/insights/render.js +126 -0
  33. package/dist/src/insights/sessions.d.ts +12 -0
  34. package/dist/src/insights/sessions.js +51 -0
  35. package/dist/src/insights/transcript.d.ts +14 -0
  36. package/dist/src/insights/transcript.js +505 -0
  37. package/dist/src/insights/types.d.ts +164 -0
  38. package/dist/src/insights/types.js +4 -0
  39. package/dist/src/install.js +14 -5
  40. package/dist/src/policy.d.ts +4 -0
  41. package/dist/src/policy.js +57 -0
  42. package/dist/src/replay/render.js +4 -2
  43. package/dist/src/replay/simulate.d.ts +5 -0
  44. package/dist/src/replay/simulate.js +23 -8
  45. package/dist/src/state/reservations.d.ts +7 -5
  46. package/dist/src/state/reservations.js +60 -45
  47. package/dist/src/state/spawn-window.d.ts +10 -0
  48. package/dist/src/state/spawn-window.js +25 -0
  49. package/dist/src/types.d.ts +8 -1
  50. package/docs/USAGE_AND_PRICING.md +132 -0
  51. package/fixtures/usage-dedup-session/subagents/agent-synthetic-first.jsonl +5 -0
  52. package/fixtures/usage-dedup-session/subagents/agent-synthetic-second.jsonl +4 -0
  53. package/fixtures/usage-dedup-session.jsonl +4 -0
  54. package/package.json +4 -3
@@ -0,0 +1,211 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.pacePath = pacePath;
4
+ exports.readPricing = readPricing;
5
+ exports.dollars = dollars;
6
+ exports.hostWindows = hostWindows;
7
+ exports.heavyTurnMessage = heavyTurnMessage;
8
+ exports.observeTool = observeTool;
9
+ exports.renderStatusLine = renderStatusLine;
10
+ const node_fs_1 = require("node:fs");
11
+ const node_crypto_1 = require("node:crypto");
12
+ const node_path_1 = require("node:path");
13
+ const transcript_1 = require("./transcript");
14
+ const attribution_1 = require("./attribution");
15
+ const pricing_1 = require("./pricing");
16
+ const pace_1 = require("./pace");
17
+ const digest = (text) => (0, node_crypto_1.createHash)('sha256').update(text).digest('hex');
18
+ const wait = new Int32Array(new SharedArrayBuffer(4));
19
+ function pacePath(home, sessionId) { return (0, node_path_1.join)(home, 'pace', `${digest(sessionId)}.json`); }
20
+ function readPricing(home, policy) {
21
+ const file = process.env.AGENTGUARD_BURN_PRICING_FILE || policy?.insights?.pricingFile || (0, node_path_1.join)(home, 'burn-pricing.json');
22
+ return (0, node_fs_1.existsSync)(file) ? (0, pricing_1.loadPricingOverrides)(file) : {};
23
+ }
24
+ function dollars(value) {
25
+ return value === null ? 'unpriced' : Math.abs(value.max - value.min) < 0.000001 ? `$${value.min.toFixed(2)}` : `$${value.min.toFixed(2)} to $${value.max.toFixed(2)}`;
26
+ }
27
+ function hostWindows(value, source, at) {
28
+ if (!value || typeof value !== 'object')
29
+ return [];
30
+ const result = [];
31
+ const record = value;
32
+ const names = source === 'claude-statusline' ? ['five_hour', 'seven_day'] : ['primary', 'secondary'];
33
+ for (const name of names) {
34
+ const raw = record[name];
35
+ if (!raw || typeof raw !== 'object')
36
+ continue;
37
+ const v = raw;
38
+ const percent = v.used_percentage ?? v.used_percent;
39
+ if (typeof percent !== 'number' || percent < 0 || percent > 100 || !Number.isFinite(percent) || typeof v.resets_at !== 'number' || !Number.isFinite(v.resets_at))
40
+ continue;
41
+ result.push({ name, usedPercent: percent, resetsAt: v.resets_at * 1000, observedAt: at, source,
42
+ pool: typeof record.limit_id === 'string' ? digest(record.limit_id) : 'default',
43
+ windowMinutes: typeof v.window_minutes === 'number' && Number.isFinite(v.window_minutes) ? v.window_minutes : undefined });
44
+ }
45
+ return result;
46
+ }
47
+ function readAppended(path, old) {
48
+ const info = (0, node_fs_1.statSync)(path), pathDigest = digest(path);
49
+ const reset = !old || old.cursor.inode !== info.ino || old.cursor.pathDigest !== pathDigest || info.size < old.cursor.offset;
50
+ const offset = reset ? 0 : old.cursor.offset;
51
+ const fd = (0, node_fs_1.openSync)(path, 'r');
52
+ let bytes;
53
+ try {
54
+ bytes = Buffer.alloc(info.size - offset);
55
+ let read = 0;
56
+ while (read < bytes.length) {
57
+ const count = (0, node_fs_1.readSync)(fd, bytes, read, bytes.length - read, offset + read);
58
+ if (!count)
59
+ break;
60
+ read += count;
61
+ }
62
+ bytes = bytes.subarray(0, read);
63
+ }
64
+ finally {
65
+ (0, node_fs_1.closeSync)(fd);
66
+ }
67
+ const newline = bytes.lastIndexOf(10);
68
+ const committed = newline < 0 ? 0 : newline + 1;
69
+ return { text: bytes.subarray(0, committed).toString('utf8'), cursor: { offset: offset + committed, inode: info.ino, pathDigest }, reset };
70
+ }
71
+ function heavyTurnMessage(turn, rates) {
72
+ const estimated = (0, pricing_1.priceTurn)({ ...turn, inputTokens: 0, cacheReadTokens: 0, outputTokens: 0, cacheWriteTokens: turn.contextTokens,
73
+ cacheWrite5mTokens: undefined, cacheWrite1hTokens: undefined }, rates);
74
+ return `Heavy turn: ${(0, pace_1.formatCount)(turn.contextTokens)} context tokens. Compaction may pause this session and rewrite its prefix (${dollars(estimated.usd)} at the list cache-write rate). Start a fresh session with a handoff note. Context weight is not your account usage limit.`;
75
+ }
76
+ /** Local advisory observer. No tool input or output content enters this API. */
77
+ function observeTool(home, input, host, policy, now = Date.now()) {
78
+ if (!input.session_id || !input.transcript_path)
79
+ return { messages: [] };
80
+ const file = pacePath(home, input.session_id), lock = `${file}.lock`;
81
+ let fd;
82
+ try {
83
+ (0, node_fs_1.mkdirSync)((0, node_path_1.join)(home, 'pace'), { recursive: true, mode: 0o700 });
84
+ const started = Date.now();
85
+ while (fd === undefined) {
86
+ try {
87
+ fd = (0, node_fs_1.openSync)(lock, 'wx', 0o600);
88
+ (0, node_fs_1.writeFileSync)(fd, String(process.pid));
89
+ }
90
+ catch (error) {
91
+ if (error.code !== 'EEXIST')
92
+ throw error;
93
+ try {
94
+ if (Date.now() - (0, node_fs_1.statSync)(lock).mtimeMs > 60_000) {
95
+ (0, node_fs_1.unlinkSync)(lock);
96
+ continue;
97
+ }
98
+ }
99
+ catch {
100
+ continue;
101
+ }
102
+ if (Date.now() - started > 1500)
103
+ return { messages: [] };
104
+ Atomics.wait(wait, 0, 0, 5);
105
+ }
106
+ }
107
+ let previous;
108
+ try {
109
+ previous = JSON.parse((0, node_fs_1.readFileSync)(file, 'utf8'));
110
+ if (previous.schema !== 1)
111
+ previous = undefined;
112
+ }
113
+ catch { /* first observation */ }
114
+ // A 0.2.4 cache has no interval byte metadata. Re-read the transcript once
115
+ // rather than treating later intervals as if their earlier reads were new.
116
+ const appended = readAppended(input.transcript_path, previous?.parser?.intervalStateVersion === 1 ? previous : undefined);
117
+ const parsed = (0, transcript_1.parseInsightTranscript)(appended.text, { host, sessionId: input.session_id, state: appended.reset ? undefined : previous?.parser });
118
+ const turns = parsed.turns, rates = readPricing(home, policy);
119
+ const windows = (previous?.windows ?? []).filter(s => s.observedAt >= now - 600_000 && s.resetsAt > now);
120
+ windows.push(...hostWindows(input.rate_limits, 'claude-statusline', now));
121
+ const acceptedQuotaLines = new Set(parsed.acceptedQuotaLineNumbers);
122
+ if (host === 'codex')
123
+ for (const [lineNumber, line] of appended.text.split('\n').entries()) {
124
+ if (!acceptedQuotaLines.has(lineNumber))
125
+ continue;
126
+ try {
127
+ const raw = JSON.parse(line);
128
+ const at = Date.parse(raw.timestamp ?? '');
129
+ if (Number.isFinite(at) && raw.payload?.type === 'token_count')
130
+ windows.push(...hostWindows(raw.payload.rate_limits, 'codex-transcript', at));
131
+ }
132
+ catch { /* malformed or partial records are not quota evidence */ }
133
+ }
134
+ const uniqueWindows = [...new Map(windows.filter(w => w.observedAt >= now - 600_000 && w.resetsAt > now)
135
+ .map(w => [`${w.name}:${w.source}:${w.pool}:${w.windowMinutes}:${w.observedAt}:${w.resetsAt}`, w])).values()];
136
+ const messages = [];
137
+ let heavyAbove = appended.reset ? false : previous?.heavyAbove ?? false;
138
+ const configuredHeavy = policy.insights?.heavyTurnTokens;
139
+ const heavy = typeof configuredHeavy === 'number' && Number.isFinite(configuredHeavy) && configuredHeavy >= 0 ? configuredHeavy : 500_000;
140
+ const updates = appended.reset ? parsed.updatedTurns.slice(-1) : parsed.updatedTurns;
141
+ for (const turn of updates) {
142
+ if (turn === parsed.updatedTurns.at(-1) && turn.contextTokens > heavy && !heavyAbove)
143
+ messages.push(heavyTurnMessage(turn, rates));
144
+ heavyAbove = turn.contextTokens > heavy;
145
+ }
146
+ const today = new Date(now);
147
+ today.setHours(0, 0, 0, 0);
148
+ let todayMin = 0, todayMax = 0, hourMin = 0, hourMax = 0, unknown = 0, hourUnknown = 0;
149
+ const causes = {};
150
+ for (const turn of turns) {
151
+ const rewrite = (0, attribution_1.classifyRewrite)(turn);
152
+ if (!rewrite || turn.at === undefined || turn.at > now || turn.at < today.getTime() && turn.at <= now - 3_600_000)
153
+ continue;
154
+ const price = (0, pricing_1.priceTurn)({ ...turn, inputTokens: 0, cacheReadTokens: 0, outputTokens: 0 }, rates).usd;
155
+ if (turn.at >= today.getTime()) {
156
+ if (price) {
157
+ todayMin += price.min;
158
+ todayMax += price.max;
159
+ }
160
+ else
161
+ unknown++;
162
+ }
163
+ if (turn.at > now - 3_600_000) {
164
+ causes[rewrite.explanation] = (causes[rewrite.explanation] ?? 0) + 1;
165
+ if (price) {
166
+ hourMin += price.min;
167
+ hourMax += price.max;
168
+ }
169
+ else
170
+ hourUnknown++;
171
+ }
172
+ }
173
+ const configuredRewrite = policy.insights?.rewriteWarnDollarsPerHour;
174
+ const rewriteThreshold = typeof configuredRewrite === 'number' && Number.isFinite(configuredRewrite) && configuredRewrite >= 0 ? configuredRewrite : 5;
175
+ const rewriteAbove = hourMin > rewriteThreshold;
176
+ if (rewriteAbove && (appended.reset || !previous?.rewriteAbove))
177
+ messages.push(`Cache rewrites: ${hourUnknown ? 'at least ' : ''}${dollars({ min: hourMin, max: hourMax })} API list equivalent in the past hour${hourUnknown ? `, plus ${hourUnknown} unpriced rewrites` : ''}: ${Object.entries(causes).map(([cause, count]) => `${count} ${cause}`).join('; ')}.`);
178
+ const timed = turns.filter((t) => typeof t.at === 'number');
179
+ const snapshot = { schema: 1, host, sessionId: input.session_id, updatedAt: now, toolEvents: (previous?.toolEvents ?? 0) + (input.tool_name ? 1 : 0),
180
+ cursor: appended.cursor, parser: parsed.state, pace: (0, pace_1.calculatePace)(timed, now), windows: uniqueWindows,
181
+ forecast: parsed.state.forkHistoryBoundaryUnknown ? { minutes: null, reason: 'inherited host quota history has no verifiable boundary' } : (0, pace_1.forecastLimit)(uniqueWindows, now), lastTurnTokens: turns.at(-1)?.contextTokens ?? 0,
182
+ rewriteDollarsToday: unknown ? null : { min: todayMin, max: todayMax }, unpricedRewritesToday: unknown, heavyAbove, rewriteAbove };
183
+ const temporary = `${file}.${(0, node_crypto_1.randomUUID)()}.tmp`;
184
+ (0, node_fs_1.writeFileSync)(temporary, JSON.stringify(snapshot), { mode: 0o600 });
185
+ (0, node_fs_1.renameSync)(temporary, file);
186
+ return { snapshot, messages };
187
+ }
188
+ catch (error) {
189
+ // Hosts can create their transcript after the first tool event. Keep the
190
+ // existing admission response until usage exists, instead of warning per call.
191
+ if (error.code === 'ENOENT')
192
+ return { messages: [] };
193
+ return { messages: ['AgentGuard pace unavailable. Local usage metadata could not be updated.'] };
194
+ }
195
+ finally {
196
+ if (fd !== undefined) {
197
+ (0, node_fs_1.closeSync)(fd);
198
+ try {
199
+ (0, node_fs_1.unlinkSync)(lock);
200
+ }
201
+ catch { /* already removed */ }
202
+ }
203
+ }
204
+ }
205
+ function renderStatusLine(snapshot) {
206
+ const pace = snapshot.pace;
207
+ const limit = snapshot.forecast.minutes === null ? `limit unknown (${snapshot.forecast.reason})` : `at this pace you reach your limit in ${Math.ceil(snapshot.forecast.minutes)} minutes`;
208
+ if (snapshot.parser?.forkHistoryBoundaryUnknown)
209
+ return `AgentGuard pace unknown | last turn unknown | rewrites today unknown | inherited Codex history has no verifiable boundary | ${limit}`;
210
+ return `AgentGuard ${(0, pace_1.formatCount)(pace.cachedPerMinute)} cached + ${(0, pace_1.formatCount)(pace.uncachedPerMinute)} uncached tokens/min | last ${(0, pace_1.formatCount)(snapshot.lastTurnTokens)} | rewrites today ${dollars(snapshot.rewriteDollarsToday)} | next hour ${(0, pace_1.formatCount)(pace.projectedNextHour)} tokens | ${limit}`;
211
+ }
@@ -0,0 +1,34 @@
1
+ export interface PaceUsage {
2
+ id: string;
3
+ at: number;
4
+ cacheReadTokens: number;
5
+ inputTokens: number;
6
+ cacheWriteTokens: number;
7
+ outputTokens: number;
8
+ }
9
+ export interface Pace {
10
+ cachedPerMinute: number;
11
+ uncachedPerMinute: number;
12
+ totalPerMinute: number;
13
+ projectedNextHour: number;
14
+ intervalMinutes: number;
15
+ }
16
+ export interface UsageWindow {
17
+ name: string;
18
+ usedPercent: number;
19
+ resetsAt: number;
20
+ observedAt: number;
21
+ source: 'claude-statusline' | 'codex-transcript';
22
+ pool?: string;
23
+ windowMinutes?: number;
24
+ }
25
+ export interface LimitForecast {
26
+ minutes: number | null;
27
+ reason: string;
28
+ source?: string;
29
+ }
30
+ /** Wall-clock ten-minute pace. Cached means cache reads; writes count as uncached. */
31
+ export declare function calculatePace(turns: PaceUsage[], now?: number): Pace;
32
+ /** Forecast only observed host percentage consumption, never a token-to-quota conversion. */
33
+ export declare function forecastLimit(samples: UsageWindow[], now?: number): LimitForecast;
34
+ export declare function formatCount(n: number): string;
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.calculatePace = calculatePace;
4
+ exports.forecastLimit = forecastLimit;
5
+ exports.formatCount = formatCount;
6
+ /** Wall-clock ten-minute pace. Cached means cache reads; writes count as uncached. */
7
+ function calculatePace(turns, now = Date.now()) {
8
+ const latest = new Map();
9
+ for (const turn of turns)
10
+ latest.set(turn.id, turn);
11
+ let cached = 0, uncached = 0;
12
+ for (const turn of latest.values()) {
13
+ if (turn.at > now || turn.at <= now - 600_000)
14
+ continue;
15
+ cached += turn.cacheReadTokens;
16
+ uncached += turn.inputTokens + turn.cacheWriteTokens + turn.outputTokens;
17
+ }
18
+ return { cachedPerMinute: cached / 10, uncachedPerMinute: uncached / 10, totalPerMinute: (cached + uncached) / 10,
19
+ projectedNextHour: (cached + uncached) * 6, intervalMinutes: 10 };
20
+ }
21
+ /** Forecast only observed host percentage consumption, never a token-to-quota conversion. */
22
+ function forecastLimit(samples, now = Date.now()) {
23
+ const valid = samples.filter(s => Number.isFinite(s.usedPercent) && s.usedPercent >= 0 && s.usedPercent <= 100 &&
24
+ Number.isFinite(s.resetsAt) && s.resetsAt > now && s.observedAt <= now && s.observedAt >= now - 600_000);
25
+ if (!valid.length)
26
+ return { minutes: null, reason: 'host does not expose a usage window here' };
27
+ const identity = (s) => `${s.source}:${s.pool ?? 'default'}:${s.name}:${s.windowMinutes ?? ''}:${s.resetsAt}`;
28
+ const latest = new Map();
29
+ for (const sample of valid)
30
+ if (!latest.has(identity(sample)) || latest.get(identity(sample)).observedAt < sample.observedAt)
31
+ latest.set(identity(sample), sample);
32
+ let earliest;
33
+ for (const current of latest.values()) {
34
+ if (current.observedAt < now - 60_000)
35
+ continue;
36
+ const previous = valid.filter(s => identity(s) === identity(current) && s.observedAt < current.observedAt)
37
+ .sort((a, b) => a.observedAt - b.observedAt)[0];
38
+ if (current.usedPercent >= 100)
39
+ return { minutes: 0, reason: 'host reports its usage window exhausted', source: current.source };
40
+ if (!previous || previous.usedPercent >= current.usedPercent)
41
+ continue;
42
+ const rate = (current.usedPercent - previous.usedPercent) / ((current.observedAt - previous.observedAt) / 60_000);
43
+ const minutes = (100 - current.usedPercent) / rate;
44
+ if (minutes > (current.resetsAt - now) / 60_000)
45
+ continue;
46
+ if (!earliest || minutes < earliest.minutes)
47
+ earliest = { minutes, source: current.source };
48
+ }
49
+ return earliest ? { ...earliest, reason: 'based on observed host usage percentage, not token weighting' }
50
+ : { minutes: null, reason: 'host window present; insufficient rising samples before reset' };
51
+ }
52
+ function formatCount(n) {
53
+ return n >= 1e9 ? `${(n / 1e9).toFixed(2)}B` : n >= 1e6 ? `${(n / 1e6).toFixed(2)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : `${Math.round(n)}`;
54
+ }
@@ -0,0 +1,48 @@
1
+ import type { InsightTurn } from './types';
2
+ /** USD per million tokens. Null means that this rate has not been verified. */
3
+ export interface ModelPricing {
4
+ input: number | null;
5
+ cacheRead: number | null;
6
+ cacheWrite5m: number | null;
7
+ cacheWrite1h: number | null;
8
+ output: number | null;
9
+ sourceUrl: string;
10
+ verifiedAt: string;
11
+ /** Some providers publish one cache-write rate, independent of TTL. */
12
+ cacheWriteTtl?: 'not_tiered';
13
+ longContext?: {
14
+ aboveInputTokens: number;
15
+ inputMultiplier: number;
16
+ outputMultiplier: number;
17
+ };
18
+ }
19
+ export type PricingTable = Readonly<Record<string, ModelPricing>>;
20
+ export interface DollarRange {
21
+ min: number;
22
+ max: number;
23
+ }
24
+ export interface TurnPrice {
25
+ model: string | null;
26
+ sourceUrl: string | null;
27
+ verifiedAt: string | null;
28
+ basis: 'estimated_api_list_price';
29
+ usd: DollarRange | null;
30
+ categories: {
31
+ input: DollarRange | null;
32
+ cacheRead: DollarRange | null;
33
+ cacheWrite: DollarRange | null;
34
+ output: DollarRange | null;
35
+ };
36
+ unknownCacheWriteTokens: number;
37
+ reason?: string;
38
+ }
39
+ /** Exact IDs only. Never price an unknown model by a similar family name. */
40
+ export declare const MODEL_PRICING: PricingTable;
41
+ /** Local JSON only. Errors are generic so neither file contents nor paths leak. */
42
+ export declare function loadPricingOverrides(file: string): PricingTable;
43
+ type PriceableTurn = Omit<InsightTurn, 'contextTokens'> & {
44
+ contextTokens?: number;
45
+ };
46
+ /** API list-price equivalent, never a subscription charge or quota conversion. */
47
+ export declare function priceTurn(turn: PriceableTurn, overrides?: PricingTable): TurnPrice;
48
+ export {};
@@ -0,0 +1,139 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MODEL_PRICING = void 0;
4
+ exports.loadPricingOverrides = loadPricingOverrides;
5
+ exports.priceTurn = priceTurn;
6
+ const node_fs_1 = require("node:fs");
7
+ const SOURCE = 'https://platform.claude.com/docs/en/about-claude/pricing';
8
+ const DATE = '2026-09-17';
9
+ const rates = (input, cacheRead, cacheWrite5m, cacheWrite1h, output) => Object.freeze({ input, cacheRead, cacheWrite5m, cacheWrite1h, output, sourceUrl: SOURCE, verifiedAt: DATE });
10
+ const openaiRates = (input, cacheRead, cacheWrite, output, sourceUrl, longContext = true) => Object.freeze({ input, cacheRead, cacheWrite5m: cacheWrite, cacheWrite1h: cacheWrite, output, sourceUrl, verifiedAt: DATE,
11
+ cacheWriteTtl: 'not_tiered', ...(longContext ? { longContext: Object.freeze({ aboveInputTokens: 272_000, inputMultiplier: 2, outputMultiplier: 1.5 }) } : {}) });
12
+ /** Exact IDs only. Never price an unknown model by a similar family name. */
13
+ exports.MODEL_PRICING = Object.freeze({
14
+ 'claude-fable-5-1': rates(10, 0.25, 12.5, 20, 50),
15
+ 'claude-fable-5': rates(10, 1, 12.5, 20, 50),
16
+ 'claude-opus-5': rates(5, 0.5, 6.25, 10, 25),
17
+ 'claude-opus-4-8': rates(5, 0.5, 6.25, 10, 25),
18
+ 'claude-sonnet-5': rates(2, 0.2, 2.5, 4, 10),
19
+ 'claude-haiku-4-5-20251001': rates(1, 0.1, 1.25, 2, 5),
20
+ 'gpt-6-astra': openaiRates(10, 1, 12.5, 50, 'https://developers.openai.com/api/docs/pricing'),
21
+ 'gpt-5.6-sol': openaiRates(4, 0.4, 5, 20, 'https://developers.openai.com/api/docs/pricing'),
22
+ 'gpt-5.6-luna': openaiRates(0.2, 0.02, 0.25, 1.2, 'https://developers.openai.com/api/docs/pricing'),
23
+ 'gpt-5.4-mini': openaiRates(0.75, 0.075, null, 4.5, 'https://developers.openai.com/api/docs/models/gpt-5.4-mini', false),
24
+ });
25
+ const RATE_KEYS = ['input', 'cacheRead', 'cacheWrite5m', 'cacheWrite1h', 'output'];
26
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
27
+ /** Local JSON only. Errors are generic so neither file contents nor paths leak. */
28
+ function loadPricingOverrides(file) {
29
+ let value;
30
+ try {
31
+ value = JSON.parse((0, node_fs_1.readFileSync)(file, 'utf8'));
32
+ }
33
+ catch {
34
+ throw new Error('Cannot read pricing override JSON');
35
+ }
36
+ if (!isRecord(value) || !isRecord(value.models))
37
+ throw new Error('Pricing overrides require a models object');
38
+ const table = Object.create(null);
39
+ for (const [model, row] of Object.entries(value.models)) {
40
+ if (!model || !isRecord(row) || !RATE_KEYS.every(key => row[key] === null || (typeof row[key] === 'number' && Number.isFinite(row[key]) && row[key] >= 0))) {
41
+ throw new Error('Pricing overrides require nonnegative rates or null for every token category');
42
+ }
43
+ if (typeof row.sourceUrl !== 'string' || !/^https?:\/\//.test(row.sourceUrl)
44
+ || typeof row.verifiedAt !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(row.verifiedAt)
45
+ || !Number.isFinite(Date.parse(row.verifiedAt)) || new Date(row.verifiedAt).toISOString().slice(0, 10) !== row.verifiedAt) {
46
+ throw new Error('Pricing overrides require a sourceUrl and verifiedAt date');
47
+ }
48
+ if (row.cacheWriteTtl !== undefined && row.cacheWriteTtl !== 'not_tiered')
49
+ throw new Error('Invalid cache-write pricing mode');
50
+ if (row.cacheWriteTtl === 'not_tiered' && row.cacheWrite5m !== row.cacheWrite1h)
51
+ throw new Error('A single cache-write rate requires matching rates');
52
+ const extra = row.cacheWriteTtl === 'not_tiered' ? { cacheWriteTtl: 'not_tiered' } : {};
53
+ if (row.longContext !== undefined) {
54
+ const tier = row.longContext;
55
+ if (!isRecord(tier) || !Number.isSafeInteger(tier.aboveInputTokens) || Number(tier.aboveInputTokens) < 0
56
+ || !['inputMultiplier', 'outputMultiplier'].every(key => typeof tier[key] === 'number' && Number.isFinite(tier[key]) && tier[key] >= 0)) {
57
+ throw new Error('Invalid long-context pricing tier');
58
+ }
59
+ extra.longContext = Object.freeze({ aboveInputTokens: tier.aboveInputTokens, inputMultiplier: tier.inputMultiplier, outputMultiplier: tier.outputMultiplier });
60
+ }
61
+ table[model] = Object.freeze({ ...Object.fromEntries([...RATE_KEYS, 'sourceUrl', 'verifiedAt'].map(key => [key, row[key]])), ...extra });
62
+ }
63
+ return Object.freeze(table);
64
+ }
65
+ function charge(tokens, rate) {
66
+ if (tokens === 0)
67
+ return { min: 0, max: 0 };
68
+ if (rate === null)
69
+ return null;
70
+ const dollars = tokens * rate / 1_000_000;
71
+ return { min: dollars, max: dollars };
72
+ }
73
+ function sum(values) {
74
+ if (values.some(value => value === null))
75
+ return null;
76
+ return values.reduce((total, value) => ({ min: total.min + value.min, max: total.max + value.max }), { min: 0, max: 0 });
77
+ }
78
+ /** API list-price equivalent, never a subscription charge or quota conversion. */
79
+ function priceTurn(turn, overrides = {}) {
80
+ const model = turn.model ?? null;
81
+ const baseRate = model === null ? undefined : Object.hasOwn(overrides, model) ? overrides[model] : Object.hasOwn(exports.MODEL_PRICING, model) ? exports.MODEL_PRICING[model] : undefined;
82
+ let rate = baseRate;
83
+ const result = {
84
+ model, sourceUrl: rate?.sourceUrl ?? null, verifiedAt: rate?.verifiedAt ?? null, basis: 'estimated_api_list_price',
85
+ usd: null, categories: { input: null, cacheRead: null, cacheWrite: null, output: null }, unknownCacheWriteTokens: 0,
86
+ };
87
+ const counts = [turn.inputTokens, turn.cacheReadTokens, turn.cacheWriteTokens, turn.outputTokens, turn.cacheWrite5mTokens ?? 0, turn.cacheWrite1hTokens ?? 0];
88
+ if (counts.some(n => !Number.isSafeInteger(n) || n < 0))
89
+ return { ...result, reason: 'invalid_token_count' };
90
+ if (turn.contextTokens !== undefined && (!Number.isSafeInteger(turn.contextTokens) || turn.contextTokens < 0))
91
+ return { ...result, reason: 'invalid_context_count' };
92
+ if (!rate)
93
+ return { ...result, reason: 'unknown_model_price' };
94
+ // Bucket and rewrite prices retain the complete request context. A subset
95
+ // must not fall back to the short-context tier after other categories vanish.
96
+ const actualInput = turn.inputTokens + turn.cacheReadTokens + turn.cacheWriteTokens;
97
+ const pricingContext = Math.max(turn.contextTokens ?? actualInput, actualInput);
98
+ if (rate.longContext && pricingContext > rate.longContext.aboveInputTokens) {
99
+ const tier = rate.longContext;
100
+ const scale = (value, factor) => value === null ? null : value * factor;
101
+ rate = { ...rate, input: scale(rate.input, tier.inputMultiplier), cacheRead: scale(rate.cacheRead, tier.inputMultiplier),
102
+ cacheWrite5m: scale(rate.cacheWrite5m, tier.inputMultiplier), cacheWrite1h: scale(rate.cacheWrite1h, tier.inputMultiplier), output: scale(rate.output, tier.outputMultiplier) };
103
+ }
104
+ let five = turn.cacheWrite5mTokens ?? 0;
105
+ let hour = turn.cacheWrite1hTokens ?? 0;
106
+ let unknown = turn.cacheWriteTokens - five - hour;
107
+ if (unknown < 0)
108
+ return { ...result, reason: 'cache_write_breakdown_exceeds_total' };
109
+ if (turn.cacheTtlSeconds === 300) {
110
+ five += unknown;
111
+ unknown = 0;
112
+ }
113
+ if (turn.cacheTtlSeconds === 3600) {
114
+ hour += unknown;
115
+ unknown = 0;
116
+ }
117
+ if (rate.cacheWriteTtl === 'not_tiered') {
118
+ five += unknown;
119
+ unknown = 0;
120
+ }
121
+ const lowRate = rate.cacheWrite5m === null || rate.cacheWrite1h === null ? null : Math.min(rate.cacheWrite5m, rate.cacheWrite1h);
122
+ const highRate = rate.cacheWrite5m === null || rate.cacheWrite1h === null ? null : Math.max(rate.cacheWrite5m, rate.cacheWrite1h);
123
+ const lower = charge(unknown, lowRate);
124
+ const upper = charge(unknown, highRate);
125
+ const unresolved = lower && upper ? { min: lower.min, max: upper.max } : null;
126
+ result.categories = {
127
+ input: charge(turn.inputTokens, rate.input),
128
+ cacheRead: charge(turn.cacheReadTokens, rate.cacheRead),
129
+ cacheWrite: sum([charge(five, rate.cacheWrite5m), charge(hour, rate.cacheWrite1h), unresolved]),
130
+ output: charge(turn.outputTokens, rate.output),
131
+ };
132
+ result.usd = sum(Object.values(result.categories));
133
+ result.unknownCacheWriteTokens = unknown;
134
+ if (!result.usd)
135
+ result.reason = 'unknown_token_category_price';
136
+ else if (unknown > 0)
137
+ result.reason = 'cache_write_ttl_unknown';
138
+ return result;
139
+ }
@@ -0,0 +1,8 @@
1
+ import { type DollarRange, type PricingTable } from './pricing';
2
+ import { type InsightTranscript, type InsightTurn } from './types';
3
+ export declare function sumPrices(values: Array<DollarRange | null>): DollarRange | null;
4
+ export declare function totalTokens(turn: InsightTurn): number;
5
+ export declare function renderWhy(transcript: InsightTranscript, rates?: PricingTable): string;
6
+ export declare function rewriteReport(transcripts: InsightTranscript[], rates?: PricingTable): Record<string, unknown>;
7
+ export declare function renderRewrites(transcripts: InsightTranscript[], rates?: PricingTable): string;
8
+ export declare function renderPricing(overrides?: PricingTable): string;
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sumPrices = sumPrices;
4
+ exports.totalTokens = totalTokens;
5
+ exports.renderWhy = renderWhy;
6
+ exports.rewriteReport = rewriteReport;
7
+ exports.renderRewrites = renderRewrites;
8
+ exports.renderPricing = renderPricing;
9
+ const attribution_1 = require("./attribution");
10
+ const pricing_1 = require("./pricing");
11
+ const live_1 = require("./live");
12
+ const transcript_1 = require("./transcript");
13
+ const types_1 = require("./types");
14
+ const labels = {
15
+ instruction_stack: 'Instruction stack + system', conversation: 'Conversation', history_resent: 'History re-sent', reread_files: 'Re-read files',
16
+ subagent_fanout: 'Subagent fan-out', tool_output: 'Tool output', full_prefix_rewrites: 'Full-prefix rewrites', output: 'Output', unattributed: 'Unattributed',
17
+ };
18
+ function sumPrices(values) {
19
+ return values.some(value => value === null) ? null : values.reduce((sum, value) => ({ min: sum.min + value.min, max: sum.max + value.max }), { min: 0, max: 0 });
20
+ }
21
+ function totalTokens(turn) { return turn.inputTokens + turn.cacheWriteTokens + turn.cacheReadTokens + turn.outputTokens; }
22
+ function bucketPrice(turn, allocation, rates) {
23
+ const quantities = allocation.categories;
24
+ if (Object.values(quantities).every(value => value === 0))
25
+ return { min: 0, max: 0 };
26
+ const completeWrites = quantities.cacheCreation === turn.cacheWriteTokens;
27
+ const mixedWrites = (turn.cacheWrite5mTokens ?? 0) > 0 && (turn.cacheWrite1hTokens ?? 0) > 0;
28
+ return (0, pricing_1.priceTurn)({ ...turn, inputTokens: quantities.input, cacheReadTokens: quantities.cacheRead,
29
+ cacheWriteTokens: quantities.cacheCreation, outputTokens: quantities.output,
30
+ cacheTtlSeconds: !completeWrites && mixedWrites ? undefined : turn.cacheTtlSeconds,
31
+ cacheWrite5mTokens: completeWrites ? turn.cacheWrite5mTokens : undefined,
32
+ cacheWrite1hTokens: completeWrites ? turn.cacheWrite1hTokens : undefined }, rates).usd;
33
+ }
34
+ function table(headers, rows) {
35
+ const widths = headers.map((header, i) => Math.max(header.length, ...rows.map(row => row[i]?.length ?? 0)));
36
+ return [headers, ...rows].map(row => row.map((cell, i) => cell.padEnd(widths[i])).join(' ').trimEnd()).join('\n');
37
+ }
38
+ const count = (n) => n.toLocaleString('en-US');
39
+ function renderWhy(transcript, rates = {}) {
40
+ if (transcript.state.forkHistoryBoundaryUnknown)
41
+ return [
42
+ `AgentGuard why: ${transcript.sessionId}`,
43
+ 'Usage unavailable: inherited Codex history has no verifiable boundary. Copied usage is excluded, not counted as zero.',
44
+ table(['Bucket', 'Tokens', 'List USD', 'Session tokens', 'Last turn tokens'], types_1.INSIGHT_BUCKETS.map(bucket => [labels[bucket], 'unattributed', 'unattributed', 'unattributed', 'unattributed'])),
45
+ ].join('\n');
46
+ const turns = (0, transcript_1.deduplicateTurns)(transcript.turns).sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
47
+ const attribution = (0, attribution_1.attributeTurns)(turns), lastAttribution = attribution.turnAttributions.at(-1);
48
+ const byTurn = new Map(attribution.turnAttributions.map(turn => [turn.turnId, turn]));
49
+ const prices = turns.map(turn => (0, pricing_1.priceTurn)(turn, rates));
50
+ const mixedWriteAllocation = turns.some(turn => (turn.cacheWrite5mTokens ?? 0) > 0 && (turn.cacheWrite1hTokens ?? 0) > 0
51
+ && byTurn.get(turn.id).buckets.some(bucket => bucket.categories.cacheCreation > 0 && bucket.categories.cacheCreation < turn.cacheWriteTokens));
52
+ const totals = { input: 0, write: 0, read: 0, output: 0 };
53
+ turns.forEach(turn => { totals.input += turn.inputTokens; totals.write += turn.cacheWriteTokens; totals.read += turn.cacheReadTokens; totals.output += turn.outputTokens; });
54
+ const rows = types_1.INSIGHT_BUCKETS.map(bucket => {
55
+ const row = attribution.buckets.find(item => item.bucket === bucket);
56
+ const lastTokens = lastAttribution?.buckets.find(item => item.bucket === bucket).tokens ?? 0;
57
+ return [labels[bucket], count(row.tokens), (0, live_1.dollars)(sumPrices(turns.map(turn => bucketPrice(turn, byTurn.get(turn.id).buckets.find(item => item.bucket === bucket), rates)))),
58
+ `${(attribution.totalTokens ? row.tokens / attribution.totalTokens * 100 : 0).toFixed(1)}%`,
59
+ `${(lastAttribution?.totalTokens ? lastTokens / lastAttribution.totalTokens * 100 : 0).toFixed(1)}%`];
60
+ });
61
+ const unknownModels = [...new Set(prices.filter(price => price.usd === null).map(price => price.model ?? 'missing model id'))];
62
+ return [
63
+ `AgentGuard why: ${transcript.sessionId}`,
64
+ `${turns.length} assistant responses | ${count(attribution.totalTokens)} tokens | ${(0, live_1.dollars)(sumPrices(prices.map(price => price.usd)))} API list equivalent`,
65
+ `Usage: input ${count(totals.input)}, cache creation ${count(totals.write)}, cache read ${count(totals.read)}, output ${count(totals.output)}.`,
66
+ '', table(['Bucket', 'Tokens', 'List USD', 'Session tokens', 'Last turn tokens'], rows), '',
67
+ 'Token shares partition recorded usage; byte sizes only split measured token increments and are never converted into tokens.',
68
+ `Shared event increments: ${attribution.sharedTurns}. Prefix rebaselines: ${attribution.prefixRebaselines}. Prior output retained in unattributed input: ${count(attribution.previousOutputTokensReserved)} tokens.`,
69
+ `Observed calls: ${transcript.observations.readCalls} reads of ${transcript.observations.uniqueReadFiles} paths, ${transcript.observations.repeatedReadCalls} repeated reads, ${transcript.observations.spawnCalls} spawns, ${transcript.observations.toolResultRecords} tool results.`,
70
+ '', 'Method',
71
+ 'Instruction stack + system: the first response context sets a fixed-prefix baseline including initial user content, later cached prefix is counted up to it, and prefix-change rewrites reset it with any history then present.',
72
+ 'History re-sent: each response contributes cache-read tokens above its session fixed-prefix baseline, floored at zero.',
73
+ 'Re-read files: the measured input and cache-creation increment after prior output subtraction is split by result bytes, with repeated Read paths assigned here.',
74
+ 'Subagent fan-out: child transcripts contribute their measured input categories after rewrite allocation.',
75
+ 'Tool output: the same measured increment is split by result bytes among other tool results.',
76
+ 'Conversation: user-message bytes receive their share of that measured increment, with mixed user and tool intervals marked shared.',
77
+ 'Full-prefix rewrites: qualifying turns contribute their measured cache-creation tokens and are excluded from event-increment allocation.',
78
+ 'Output: every response contributes its recorded generated output tokens once.',
79
+ 'Unattributed: residual measured usage includes prior output deducted from arriving-content increments and intervals without usable event evidence.',
80
+ ...(turns.some(turn => turn.explicitAttribution.length > 0) ? ['Explicit host token attribution takes precedence over the delta method for responses that provide it.'] : []),
81
+ ...(unknownModels.length ? [`Tokens only for unpriced models: ${unknownModels.join(', ')}. No verified exact model rate is available for at least one token category.`] : []),
82
+ ...(prices.some(price => price.unknownCacheWriteTokens > 0) ? ['Cache TTL is missing for some writes; their dollars span the verified five-minute and one-hour rates.'] : []),
83
+ ...(mixedWriteAllocation ? ['Some bucket cache-write costs are ranges because mixed cache lifetimes are not attributed to individual buckets. Range endpoints are not additive across buckets.'] : []),
84
+ `Usage records de-duplicated: ${transcript.diagnostics.duplicateUsageRecords}. Malformed lines skipped: ${transcript.diagnostics.malformedLines}.`,
85
+ ].join('\n');
86
+ }
87
+ function rewriteReport(transcripts, rates = {}) {
88
+ const grouped = new Map();
89
+ for (const turn of (0, transcript_1.deduplicateTurns)(transcripts.flatMap(transcript => transcript.turns)))
90
+ grouped.set(turn.sessionId, [...grouped.get(turn.sessionId) ?? [], turn]);
91
+ const sessions = [...grouped].map(([sessionId, turns]) => {
92
+ const records = turns.flatMap(turn => {
93
+ const rewrite = (0, attribution_1.classifyRewrite)(turn);
94
+ return rewrite ? [{ ...rewrite, sessionId, at: turn.at, model: turn.model,
95
+ usd: (0, pricing_1.priceTurn)({ ...turn, inputTokens: 0, cacheReadTokens: 0, outputTokens: 0 }, rates).usd }] : [];
96
+ });
97
+ return { sessionId, records };
98
+ });
99
+ return { sessions, total: sessions.reduce((sum, session) => sum + session.records.length, 0),
100
+ excludedUnknownHistories: transcripts.filter(transcript => transcript.state.forkHistoryBoundaryUnknown).length,
101
+ idleOver60Minutes: sessions.reduce((sum, session) => sum + session.records.filter(record => record.idleOver60Minutes).length, 0) };
102
+ }
103
+ function renderRewrites(transcripts, rates = {}) {
104
+ const report = rewriteReport(transcripts, rates);
105
+ const rows = [];
106
+ const totals = new Map();
107
+ for (const session of report.sessions) {
108
+ const groups = new Map();
109
+ session.records.forEach(record => {
110
+ groups.set(record.cause, [...groups.get(record.cause) ?? [], record]);
111
+ totals.set(record.cause, [...totals.get(record.cause) ?? [], record]);
112
+ });
113
+ for (const [cause, records] of groups)
114
+ rows.push([session.sessionId, cause, String(records.length), count(records.reduce((sum, record) => sum + record.cacheWriteTokens, 0)), (0, live_1.dollars)(sumPrices(records.map(record => record.usd)))]);
115
+ }
116
+ for (const [cause, records] of totals)
117
+ rows.push(['ALL', cause, String(records.length), count(records.reduce((sum, record) => sum + record.cacheWriteTokens, 0)), (0, live_1.dollars)(sumPrices(records.map(record => record.usd)))]);
118
+ return [`AgentGuard rewrites: ${report.total}; ${report.idleOver60Minutes} followed an idle gap over 60 minutes.`,
119
+ table(['Session', 'Cause', 'Count', 'Write tokens', 'List USD'], rows),
120
+ ...(transcripts.some(transcript => transcript.state.forkHistoryBoundaryUnknown) ? [`Excluded ${transcripts.filter(transcript => transcript.state.forkHistoryBoundaryUnknown).length} Codex histories whose inherited usage boundary is unknown. Counts are incomplete.`] : []),
121
+ 'Expiration is inferred from observed idle time and known TTL. Prefix change is the residual explanation; absent TTL remains unknown.'].join('\n');
122
+ }
123
+ function renderPricing(overrides = {}) {
124
+ const rows = Object.entries({ ...pricing_1.MODEL_PRICING, ...overrides }).map(([model, rate]) => [model, String(rate.input), String(rate.cacheRead), String(rate.cacheWrite5m), String(rate.cacheWrite1h), String(rate.output), rate.verifiedAt, rate.sourceUrl]);
125
+ return 'USD per million tokens. API list equivalents, not subscription charges.\n' + table(['Model', 'Input', 'Read', 'Write 5m', 'Write 1h', 'Output', 'Verified', 'Source'], rows);
126
+ }
@@ -0,0 +1,12 @@
1
+ export interface TranscriptLocation {
2
+ path: string;
3
+ host: 'claude' | 'codex';
4
+ sessionId: string;
5
+ modifiedAt: number;
6
+ }
7
+ /** Local locators only. Never follows directory symlinks or reads message content. */
8
+ export declare function discoverInsightTranscripts(roots?: {
9
+ claude: string;
10
+ codex: string;
11
+ }): TranscriptLocation[];
12
+ export declare function selectInsightTranscript(session?: string, locations?: TranscriptLocation[]): TranscriptLocation;