@agentguard-run/burn 0.2.2 → 0.2.5

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 (43) hide show
  1. package/CHANGELOG.md +39 -2
  2. package/LICENSE +9 -10
  3. package/README.md +96 -16
  4. package/dist/src/adapters/codex.js +11 -1
  5. package/dist/src/adapters/cursor.js +2 -2
  6. package/dist/src/cli.js +40 -13
  7. package/dist/src/conformance.js +3 -1
  8. package/dist/src/defaults.d.ts +9 -8
  9. package/dist/src/defaults.js +9 -8
  10. package/dist/src/detectors/evaluate.d.ts +5 -2
  11. package/dist/src/detectors/evaluate.js +26 -2
  12. package/dist/src/eligibility.d.ts +17 -0
  13. package/dist/src/eligibility.js +29 -0
  14. package/dist/src/gateway.d.ts +2 -0
  15. package/dist/src/gateway.js +6 -3
  16. package/dist/src/hook/pre-tool-use.d.ts +2 -0
  17. package/dist/src/hook/pre-tool-use.js +93 -63
  18. package/dist/src/insights/attribution.d.ts +4 -0
  19. package/dist/src/insights/attribution.js +151 -0
  20. package/dist/src/insights/live.d.ts +53 -0
  21. package/dist/src/insights/live.js +211 -0
  22. package/dist/src/insights/pace.d.ts +34 -0
  23. package/dist/src/insights/pace.js +54 -0
  24. package/dist/src/insights/pricing.d.ts +48 -0
  25. package/dist/src/insights/pricing.js +139 -0
  26. package/dist/src/insights/render.d.ts +8 -0
  27. package/dist/src/insights/render.js +126 -0
  28. package/dist/src/insights/sessions.d.ts +12 -0
  29. package/dist/src/insights/sessions.js +51 -0
  30. package/dist/src/insights/transcript.d.ts +12 -0
  31. package/dist/src/insights/transcript.js +492 -0
  32. package/dist/src/insights/types.d.ts +157 -0
  33. package/dist/src/insights/types.js +4 -0
  34. package/dist/src/install.js +14 -5
  35. package/dist/src/replay/render.js +1 -1
  36. package/dist/src/state/account.d.ts +3 -0
  37. package/dist/src/state/account.js +39 -0
  38. package/dist/src/state/reservations.d.ts +5 -5
  39. package/dist/src/state/reservations.js +60 -45
  40. package/dist/src/types.d.ts +6 -0
  41. package/docs/USAGE_AND_PRICING.md +132 -0
  42. package/fixtures/codex-0.151.0-pretooluse.json +11 -11
  43. package/package.json +4 -3
@@ -41,6 +41,7 @@ const pre_tool_use_1 = require("./hook/pre-tool-use");
41
41
  const override_1 = require("./override");
42
42
  const receipt_1 = require("./receipt");
43
43
  const reservations_1 = require("./state/reservations");
44
+ const account_1 = require("./state/account");
44
45
  const session_1 = require("./state/session");
45
46
  const MAX_SEEN_EVENTS = 4000;
46
47
  const FILE_PREFIX = 'gw-';
@@ -49,6 +50,8 @@ function safeName(sessionId) {
49
50
  }
50
51
  class Gateway {
51
52
  home;
53
+ /** Shared local storage for optional content-free usage observations. */
54
+ get dataDirectory() { return this.home; }
52
55
  store;
53
56
  signer;
54
57
  now;
@@ -214,7 +217,7 @@ class Gateway {
214
217
  }
215
218
  const live = new Map(meta.liveSpawns);
216
219
  const proposedDepth = event.proposedDepth ?? (event.issuerId !== undefined && live.has(event.issuerId) ? live.get(event.issuerId) + 1 : 1);
217
- const report = (0, evaluate_1.evaluate)(state, policy.thresholds, proposedDepth);
220
+ const report = (0, evaluate_1.evaluate)(state, policy.thresholds, proposedDepth, { sessions: (0, account_1.readAccountSessions)(this.home, state, event.at), now: event.at });
218
221
  const reservation = tx.reserve({
219
222
  sessionId: event.sessionId,
220
223
  toolUseId: event.spawnId,
@@ -264,13 +267,13 @@ class Gateway {
264
267
  this.applyUsage(event.callId, event.estimatedTokens, 0, event.at, state, calls);
265
268
  }
266
269
  meta.calls = [...calls];
267
- const session = (0, evaluate_1.evaluate)(state, policy.thresholds, null);
270
+ const session = (0, evaluate_1.evaluate)(state, policy.thresholds, null, { sessions: (0, account_1.readAccountSessions)(this.home, state, event.at), now: event.at });
268
271
  const local = (0, local_compute_1.evaluateLocalCompute)(compute, policy.thresholds);
269
272
  const report = merge(session, local.findings);
270
273
  const wouldBlock = report.verdict === 'STOP';
271
274
  // A low-confidence session (proxy without a session header) can be
272
275
  // stopped only on machine-scope grounds. Its session count is a guess.
273
- const machineStop = local.verdict === 'STOP';
276
+ const machineStop = local.verdict === 'STOP' || session.findings.some((f) => f.detector === 'account' && f.verdict === 'STOP');
274
277
  const due = wouldBlock && policy.mode === 'enforce' && (event.attribution === 'high' || machineStop);
275
278
  const override = due ? (0, override_1.consumeOverride)(this.home, event.at) : null;
276
279
  const blocked = due && !override;
@@ -49,6 +49,8 @@ export interface PersistedSession {
49
49
  };
50
50
  /** Signature of the last finding set the user was told about. */
51
51
  notified?: string;
52
+ /** Additive chain head; old session files begin at genesis. */
53
+ lastReceipt?: string | null;
52
54
  }
53
55
  /** Inflate a persisted Claude Code session. Shared with status. */
54
56
  export declare function inflateHookSession(raw: PersistedSession): SessionState;
@@ -22,6 +22,10 @@ exports.loadPolicy = loadPolicy;
22
22
  exports.refreshSession = refreshSession;
23
23
  exports.handlePreToolUse = handlePreToolUse;
24
24
  exports.settingsSnippet = settingsSnippet;
25
+ const node_crypto_1 = require("node:crypto");
26
+ const events_1 = require("../events");
27
+ const receipt_1 = require("../receipt");
28
+ const account_1 = require("../state/account");
25
29
  const node_fs_1 = require("node:fs");
26
30
  const node_path_1 = require("node:path");
27
31
  const evaluate_1 = require("../detectors/evaluate");
@@ -31,6 +35,7 @@ const session_1 = require("../state/session");
31
35
  const defaults_1 = require("../defaults");
32
36
  const override_1 = require("../override");
33
37
  const render_1 = require("../replay/render");
38
+ const live_1 = require("../insights/live");
34
39
  const SPAWN_TOOLS = new Set(['Agent', 'Task']);
35
40
  function sessionFile(home, sessionId) {
36
41
  return (0, node_path_1.join)(home, 'sessions', `${sessionId.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`);
@@ -48,13 +53,13 @@ function loadSession(home, sessionId, firstEventAt) {
48
53
  try {
49
54
  const raw = JSON.parse((0, node_fs_1.readFileSync)(sessionFile(home, sessionId), 'utf8'));
50
55
  const cursor = { ...raw.cursor, depthByUuid: new Map(raw.cursor.depthByUuid) };
51
- return { cursor, state: inflateHookSession(raw), notified: raw.notified ?? '' };
56
+ return { cursor, state: inflateHookSession(raw), notified: raw.notified ?? '', lastReceipt: raw.lastReceipt ?? null };
52
57
  }
53
58
  catch {
54
- return { cursor: (0, claude_transcript_1.newCursor)(), state: (0, session_1.newSessionState)(sessionId, firstEventAt), notified: '' };
59
+ return { cursor: (0, claude_transcript_1.newCursor)(), state: (0, session_1.newSessionState)(sessionId, firstEventAt), notified: '', lastReceipt: null };
55
60
  }
56
61
  }
57
- function saveSession(home, cursor, state, notified) {
62
+ function saveSession(home, cursor, state, notified, lastReceipt) {
58
63
  (0, node_fs_1.mkdirSync)((0, node_path_1.join)(home, 'sessions'), { recursive: true, mode: 0o700 });
59
64
  const persisted = {
60
65
  cursor: { offset: cursor.offset, size: cursor.size, malformedLines: cursor.malformedLines, depthByUuid: [...cursor.depthByUuid] },
@@ -65,6 +70,7 @@ function saveSession(home, cursor, state, notified) {
65
70
  surfaceReaders: [...state.surfaceReaders].map(([k, v]) => [k, [...v]]),
66
71
  },
67
72
  notified,
73
+ lastReceipt,
68
74
  };
69
75
  const file = sessionFile(home, state.sessionId);
70
76
  (0, node_fs_1.writeFileSync)(`${file}.tmp`, JSON.stringify(persisted), { mode: 0o600 });
@@ -99,14 +105,14 @@ function recordDecision(home, entry) {
99
105
  }
100
106
  /** Refresh session state from the transcript. Cheap: only new bytes are read. */
101
107
  function refreshSession(home, sessionId, transcriptPath) {
102
- const { cursor, state, notified } = loadSession(home, sessionId, Date.now());
108
+ const { cursor, state, notified, lastReceipt } = loadSession(home, sessionId, Date.now());
103
109
  const before = state.spawnCount;
104
110
  const events = (0, claude_transcript_1.readIncremental)(transcriptPath, cursor);
105
111
  for (const event of events)
106
112
  (0, session_1.applyEvent)(state, event);
107
113
  if (events.length > 0 && state.startedAt > events[0].at)
108
114
  state.startedAt = events[0].at;
109
- saveSession(home, cursor, state, notified);
115
+ saveSession(home, cursor, state, notified, lastReceipt);
110
116
  return { cursor, state, newSpawns: state.spawnCount - before, notified };
111
117
  }
112
118
  function rememberNotified(home, sessionId, signature) {
@@ -122,77 +128,101 @@ function rememberNotified(home, sessionId, signature) {
122
128
  }
123
129
  }
124
130
  function handlePreToolUse(input, home, now = Date.now()) {
131
+ const observation = (0, live_1.observeTool)(home, input, 'claude', loadPolicy(home), now);
132
+ const output = handleSpawnPreToolUse(input, home, now);
133
+ if (!observation.messages.length)
134
+ return output;
135
+ return { ...output, suppressOutput: false, systemMessage: [output.systemMessage, ...observation.messages].filter(Boolean).join('\n') };
136
+ }
137
+ function handleSpawnPreToolUse(input, home, now) {
125
138
  const toolName = input.tool_name ?? '';
126
139
  if (!SPAWN_TOOLS.has(toolName) || !input.session_id || !input.transcript_path) {
127
140
  return { continue: true, suppressOutput: true };
128
141
  }
129
142
  const policy = loadPolicy(home);
130
143
  const store = new reservations_1.ReservationStore(home);
131
- let report;
132
- let reservation;
133
- let notified = '';
134
144
  try {
135
- const refreshed = refreshSession(home, input.session_id, input.transcript_path);
136
- const { state, newSpawns } = refreshed;
137
- notified = refreshed.notified;
138
- if (newSpawns > 0)
139
- store.reconcile(input.session_id, state.spawnCount, state.spawnCount - newSpawns);
140
- // Depth of the proposed child: the issuing agent's depth plus one. A hook
141
- // fired inside a subagent carries agent_id; treat that as depth 1 issuer.
142
- const proposedDepth = input.agent_id ? 2 : 1;
143
- report = (0, evaluate_1.evaluate)(state, policy.thresholds, proposedDepth);
144
- reservation = store.reserve({
145
- sessionId: input.session_id,
146
- toolUseId: input.tool_use_id ?? `${input.session_id}:${now}`,
147
- observedSpawns: state.spawnCount,
148
- ceiling: policy.thresholds.fanout.stop,
149
- now,
145
+ const signer = receipt_1.ReceiptSigner.loadOrCreate(home);
146
+ return store.withLock((tx) => {
147
+ const refreshed = refreshSession(home, input.session_id, input.transcript_path);
148
+ const { state, newSpawns } = refreshed;
149
+ const notified = refreshed.notified;
150
+ if (newSpawns > 0)
151
+ tx.reconcile(input.session_id, state.spawnCount, state.spawnCount - newSpawns);
152
+ // Depth of the proposed child: the issuing agent's depth plus one. A hook
153
+ // fired inside a subagent carries agent_id; treat that as depth 1 issuer.
154
+ const proposedDepth = input.agent_id ? 2 : 1;
155
+ const report = (0, evaluate_1.evaluate)(state, policy.thresholds, proposedDepth, { sessions: (0, account_1.readAccountSessions)(home, state, now), now });
156
+ const reservation = tx.reserve({
157
+ sessionId: input.session_id,
158
+ toolUseId: input.tool_use_id ?? `${input.session_id}:${now}`,
159
+ observedSpawns: state.spawnCount,
160
+ ceiling: policy.thresholds.fanout.stop,
161
+ now,
162
+ });
163
+ const shouldDeny = report.verdict === 'STOP' || !reservation.allowed;
164
+ const reason = shouldDeny ? buildDenyReason(report, reservation) : '';
165
+ // The audited override: only consulted when a block is about to happen.
166
+ const override = shouldDeny && policy.mode === 'enforce' ? (0, override_1.consumeOverride)(home, now) : null;
167
+ const blocked = policy.mode === 'enforce' && shouldDeny && !override;
168
+ const verdict = shouldDeny ? 'STOP' : report.verdict;
169
+ const raw = JSON.parse((0, node_fs_1.readFileSync)(sessionFile(home, input.session_id), 'utf8'));
170
+ const receipt = signer.sign({
171
+ schema: 'agentguard.burn.decision.v1', decisionId: (0, node_crypto_1.randomUUID)(), at: now,
172
+ host: 'claude-code', action: 'spawn', sessionDigest: (0, receipt_1.sha256)(input.session_id),
173
+ policy: { mode: policy.mode, digest: (0, receipt_1.sha256)((0, receipt_1.canonical)(policy)) },
174
+ measured: { sessionTokens: state.totalTokens, sessionSpawns: reservation.effectiveSpawns,
175
+ proposedDepth, inFlight: 0, occupiedMs: 0 },
176
+ coverage: events_1.CAPABILITIES['claude-code'], verdict, blocked,
177
+ reasons: [...report.findings.map((f) => `${f.detector}:${f.verdict}`), ...(!reservation.allowed ? ['fanout:STOP'] : [])],
178
+ previous: raw.lastReceipt ?? null,
179
+ });
180
+ store.assertHeld();
181
+ (0, node_fs_1.appendFileSync)((0, node_path_1.join)(home, 'receipts.ndjson'), `${JSON.stringify(receipt)}\n`, { mode: 0o600 });
182
+ saveSession(home, refreshed.cursor, state, notified, (0, receipt_1.receiptDigest)(receipt));
183
+ recordDecision(home, {
184
+ host: 'claude-code', action: 'spawn',
185
+ at: now,
186
+ sessionId: input.session_id,
187
+ toolUseId: input.tool_use_id ?? null,
188
+ verdict,
189
+ wouldDeny: shouldDeny,
190
+ enforced: policy.mode === 'enforce' && shouldDeny && !override,
191
+ overridden: override ? { once: override.once, reason: override.reason } : undefined,
192
+ mode: policy.mode,
193
+ findings: report.findings.map((f) => ({ detector: f.detector, verdict: f.verdict, observed: f.observed, threshold: f.threshold })),
194
+ effectiveSpawns: reservation.effectiveSpawns,
195
+ totals: report.totals,
196
+ });
197
+ if (shouldDeny && policy.mode === 'enforce') {
198
+ if (!override)
199
+ return deny(reason);
200
+ return {
201
+ continue: true,
202
+ systemMessage: `AgentGuard STOP overridden${override.once ? ' once' : ''} ("${override.reason}"): ${report.findings[0]?.summary ?? ''}`,
203
+ };
204
+ }
205
+ if (report.verdict !== 'OK') {
206
+ const signature = findingSignature(report);
207
+ if (signature === notified)
208
+ return { continue: true, suppressOutput: true };
209
+ rememberNotified(home, input.session_id, signature);
210
+ return {
211
+ continue: true,
212
+ systemMessage: `AgentGuard ${report.verdict}${policy.mode === 'shadow' && shouldDeny ? ' (shadow: would have blocked)' : ''}: ${report.findings[0]?.summary ?? ''}`,
213
+ };
214
+ }
215
+ if (notified)
216
+ rememberNotified(home, input.session_id, '');
217
+ return { continue: true, suppressOutput: true };
150
218
  });
151
219
  }
152
220
  catch (error) {
153
221
  // Could not coordinate. Deny the spawn; never let a burst through blind.
154
222
  const reason = `AgentGuard failed closed: ${error instanceof Error ? error.message : 'unknown error'}`;
155
- recordDecision(home, { at: now, sessionId: input.session_id, verdict: 'STOP', enforced: policy.mode === 'enforce', reason, failClosed: true });
223
+ recordDecision(home, { host: 'claude-code', action: 'spawn', at: now, sessionId: input.session_id, verdict: 'STOP', enforced: policy.mode === 'enforce', reason, failClosed: true });
156
224
  return policy.mode === 'enforce' ? deny(reason) : { continue: true, suppressOutput: true };
157
225
  }
158
- const shouldDeny = report.verdict === 'STOP' || !reservation.allowed;
159
- const reason = shouldDeny ? buildDenyReason(report, reservation) : '';
160
- // The audited override: only consulted when a block is about to happen.
161
- const override = shouldDeny && policy.mode === 'enforce' ? (0, override_1.consumeOverride)(home, now) : null;
162
- recordDecision(home, {
163
- at: now,
164
- sessionId: input.session_id,
165
- toolUseId: input.tool_use_id ?? null,
166
- verdict: report.verdict,
167
- wouldDeny: shouldDeny,
168
- enforced: policy.mode === 'enforce' && shouldDeny && !override,
169
- overridden: override ? { once: override.once, reason: override.reason } : undefined,
170
- mode: policy.mode,
171
- findings: report.findings.map((f) => ({ detector: f.detector, verdict: f.verdict, observed: f.observed, threshold: f.threshold })),
172
- effectiveSpawns: reservation.effectiveSpawns,
173
- totals: report.totals,
174
- });
175
- if (shouldDeny && policy.mode === 'enforce') {
176
- if (!override)
177
- return deny(reason);
178
- return {
179
- continue: true,
180
- systemMessage: `AgentGuard STOP overridden${override.once ? ' once' : ''} ("${override.reason}"): ${report.findings[0]?.summary ?? ''}`,
181
- };
182
- }
183
- if (report.verdict !== 'OK') {
184
- const signature = findingSignature(report);
185
- if (signature === notified)
186
- return { continue: true, suppressOutput: true };
187
- rememberNotified(home, input.session_id, signature);
188
- return {
189
- continue: true,
190
- systemMessage: `AgentGuard ${report.verdict}${policy.mode === 'shadow' && shouldDeny ? ' (shadow: would have blocked)' : ''}: ${report.findings[0]?.summary ?? ''}`,
191
- };
192
- }
193
- if (notified)
194
- rememberNotified(home, input.session_id, '');
195
- return { continue: true, suppressOutput: true };
196
226
  }
197
227
  function buildDenyReason(report, _reservation) {
198
228
  // Claude Code shows this reason to the user. A box reads as an alarm; a
@@ -209,7 +239,7 @@ function deny(reason) {
209
239
  function settingsSnippet(command) {
210
240
  return {
211
241
  hooks: {
212
- PreToolUse: [{ matcher: '^(Agent|Task)$', hooks: [{ type: 'command', command, timeout: 5 }] }],
242
+ PreToolUse: [{ matcher: '.*', hooks: [{ type: 'command', command, timeout: 15 }] }],
213
243
  },
214
244
  };
215
245
  }
@@ -0,0 +1,4 @@
1
+ import { type AttributionSummary, type InsightTurn, type RewriteEvidence } from './types';
2
+ export declare function classifyRewrite(turn: InsightTurn): RewriteEvidence | null;
3
+ /** Partition measured usage; byte lengths only divide a measured increment among its recorded events. */
4
+ export declare function attributeTurns(turns: InsightTurn[]): AttributionSummary;
@@ -0,0 +1,151 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.classifyRewrite = classifyRewrite;
4
+ exports.attributeTurns = attributeTurns;
5
+ const types_1 = require("./types");
6
+ const transcript_1 = require("./transcript");
7
+ function classifyRewrite(turn) {
8
+ if (!(turn.cacheWriteTokens > 150_000 && turn.cacheWriteTokens > turn.contextTokens / 2))
9
+ return null;
10
+ const idleMs = turn.signals.idleMs;
11
+ const ttl = turn.cacheTtlSeconds;
12
+ const evidence = { turnId: turn.id, cacheWriteTokens: turn.cacheWriteTokens, contextTokens: turn.contextTokens,
13
+ ...(idleMs !== undefined ? { idleMs } : {}), idleOver60Minutes: idleMs !== undefined && idleMs > 3_600_000,
14
+ ...(ttl ? { ttlSeconds: ttl } : {}), ttlUnknown: !ttl };
15
+ if (ttl && idleMs !== undefined && idleMs > ttl * 1000)
16
+ return { ...evidence, cause: 'idle_ttl_expired', explanation: 'Idle gap exceeds the TTL reported for this cache-write class; timing is consistent with expiration.' };
17
+ if (turn.signals.afterCompaction)
18
+ return { ...evidence, cause: 'compaction', explanation: 'A compaction boundary immediately precedes this usage turn.' };
19
+ if (turn.signals.firstSpawnedTurn)
20
+ return { ...evidence, cause: 'first_spawned_turn', explanation: 'This is the first recorded usage turn in an explicitly identified subagent session.' };
21
+ if (!ttl)
22
+ return { ...evidence, cause: 'unknown_ttl', explanation: 'Cache TTL is absent or mixed; expiration cannot be distinguished from a prefix change.' };
23
+ return { ...evidence, cause: 'prefix_change', explanation: turn.signals.prefixChanged
24
+ ? 'The host reports a changed prefix identifier without an expired-TTL or compaction signal.'
25
+ : 'No expiration, compaction, or first-subagent-turn signal explains the rewrite; prefix change is a residual hypothesis, not an observed cause.' };
26
+ }
27
+ const tokenCategories = ['input', 'cacheCreation', 'cacheRead', 'output'];
28
+ const emptyBuckets = () => types_1.INSIGHT_BUCKETS.map(bucket => ({ bucket, tokens: 0,
29
+ categories: { input: 0, cacheCreation: 0, cacheRead: 0, output: 0 } }));
30
+ /** Largest remainders split an already measured token total without inventing tokens from bytes. */
31
+ function integerShares(total, weights) {
32
+ const weight = weights.reduce((sum, value) => sum + value, 0);
33
+ if (!weight || !total)
34
+ return weights.map(() => 0);
35
+ const exact = weights.map(value => total * (value / weight));
36
+ const shares = exact.map(Math.floor);
37
+ const order = exact.map((value, index) => ({ index, remainder: value - shares[index] }))
38
+ .sort((a, b) => b.remainder - a.remainder || a.index - b.index);
39
+ for (let left = total - shares.reduce((sum, value) => sum + value, 0), i = 0; left > 0; left--, i++)
40
+ shares[order[i % order.length].index]++;
41
+ return shares;
42
+ }
43
+ /** Partition measured usage; byte lengths only divide a measured increment among its recorded events. */
44
+ function attributeTurns(turns) {
45
+ const buckets = emptyBuckets();
46
+ const byBucket = new Map(buckets.map(bucket => [bucket.bucket, bucket]));
47
+ const result = { buckets, totalTokens: 0, rewrites: [],
48
+ rewriteCounts: { idle_ttl_expired: 0, compaction: 0, first_spawned_turn: 0, prefix_change: 0, unknown_ttl: 0 },
49
+ idleOver60MinuteRewrites: 0, uncertainties: [], turnAttributions: [], sharedTurns: 0,
50
+ prefixRebaselines: 0, previousOutputTokensReserved: 0 };
51
+ const uncertainties = new Set();
52
+ const sessions = new Map();
53
+ const ordered = (0, transcript_1.deduplicateTurns)(turns).sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
54
+ for (const turn of ordered) {
55
+ const remaining = { input: turn.inputTokens, cacheCreation: turn.cacheWriteTokens, cacheRead: turn.cacheReadTokens, output: turn.outputTokens };
56
+ const turnBuckets = emptyBuckets();
57
+ const turnByBucket = new Map(turnBuckets.map(bucket => [bucket.bucket, bucket]));
58
+ const totalTokens = Object.values(remaining).reduce((sum, value) => sum + value, 0);
59
+ result.totalTokens += totalTokens;
60
+ const allocate = (bucket, category, tokens) => {
61
+ const amount = Math.min(remaining[category], Math.max(0, tokens));
62
+ if (amount < tokens)
63
+ uncertainties.add('attribution_evidence_exceeds_category_total');
64
+ for (const target of [byBucket.get(bucket), turnByBucket.get(bucket)]) {
65
+ target.tokens += amount;
66
+ target.categories[category] += amount;
67
+ }
68
+ remaining[category] -= amount;
69
+ };
70
+ const previous = sessions.get(turn.sessionId);
71
+ let prefix = previous?.prefix ?? turn.contextTokens;
72
+ allocate('output', 'output', remaining.output);
73
+ const rewrite = classifyRewrite(turn);
74
+ if (rewrite) {
75
+ result.rewrites.push(rewrite);
76
+ result.rewriteCounts[rewrite.cause]++;
77
+ if (rewrite.idleOver60Minutes)
78
+ result.idleOver60MinuteRewrites++;
79
+ allocate('full_prefix_rewrites', 'cacheCreation', remaining.cacheCreation);
80
+ if (rewrite.ttlUnknown)
81
+ uncertainties.add('rewrite_ttl_unknown');
82
+ if (rewrite.cause === 'prefix_change') {
83
+ prefix = turn.contextTokens;
84
+ if (previous)
85
+ result.prefixRebaselines++;
86
+ if (!turn.signals.prefixChanged)
87
+ uncertainties.add('prefix_change_is_residual_hypothesis');
88
+ }
89
+ }
90
+ for (const item of turn.explicitAttribution) {
91
+ if (item.bucket !== 'output' && item.bucket !== 'full_prefix_rewrites' && item.bucket !== 'unattributed' && Number.isSafeInteger(item.tokens) && item.tokens > 0)
92
+ allocate(item.bucket, item.category, item.tokens);
93
+ }
94
+ if (turn.subagent) {
95
+ for (const category of ['input', 'cacheCreation', 'cacheRead'])
96
+ allocate('subagent_fanout', category, remaining[category]);
97
+ }
98
+ else if (!turn.explicitAttribution.length) {
99
+ if (!previous || rewrite?.cause === 'prefix_change') {
100
+ // Rewrites retain their measured write tokens; the rest establishes the new fixed prefix.
101
+ for (const category of ['input', 'cacheCreation', 'cacheRead'])
102
+ allocate('instruction_stack', category, remaining[category]);
103
+ }
104
+ else {
105
+ const history = Math.max(0, turn.cacheReadTokens - prefix);
106
+ allocate('instruction_stack', 'cacheRead', Math.min(remaining.cacheRead, prefix));
107
+ allocate('history_resent', 'cacheRead', Math.min(remaining.cacheRead, history));
108
+ if (!rewrite) {
109
+ // Prior assistant output reappears in input usage but is not newly arrived content.
110
+ // Keep that measured input in the residual rather than count it as generated output twice.
111
+ let reserved = Math.min(previous.previousOutput, remaining.input + remaining.cacheCreation);
112
+ result.previousOutputTokensReserved += reserved;
113
+ for (const category of ['input', 'cacheCreation']) {
114
+ const amount = Math.min(reserved, remaining[category]);
115
+ allocate('unattributed', category, amount);
116
+ reserved -= amount;
117
+ }
118
+ const interval = turn.interval;
119
+ const eventBuckets = ['tool_output', 'reread_files', 'conversation'];
120
+ let weights = interval ? [interval.toolOutputBytes, interval.reReadBytes, interval.conversationBytes] : [0, 0, 0];
121
+ if (interval && !weights.some(value => value > 0)) {
122
+ // A sole event class owns the whole measured increment even for an
123
+ // empty result; mixed empty payloads have no byte-based split.
124
+ const present = [interval.toolResults > (interval.reReadResults ?? 0), (interval.reReadResults ?? 0) > 0, interval.userMessages > 0];
125
+ if (present.filter(Boolean).length === 1)
126
+ weights = present.map(value => value ? 1 : 0);
127
+ }
128
+ if (weights.some(value => value > 0)) {
129
+ const shares = integerShares(remaining.input + remaining.cacheCreation, weights);
130
+ const inputs = integerShares(remaining.input, shares);
131
+ if (interval.shared && shares.some(value => value > 0))
132
+ result.sharedTurns++;
133
+ eventBuckets.forEach((bucket, i) => {
134
+ allocate(bucket, 'input', inputs[i]);
135
+ allocate(bucket, 'cacheCreation', shares[i] - inputs[i]);
136
+ });
137
+ }
138
+ }
139
+ }
140
+ }
141
+ for (const category of tokenCategories)
142
+ allocate('unattributed', category, remaining[category]);
143
+ turn.uncertainties.forEach(value => uncertainties.add(value));
144
+ sessions.set(turn.sessionId, { prefix, previousOutput: turn.outputTokens });
145
+ result.turnAttributions.push({ turnId: turn.id, sessionId: turn.sessionId, buckets: turnBuckets, totalTokens });
146
+ }
147
+ if (byBucket.get('unattributed').tokens)
148
+ uncertainties.add('unattributed_usage_includes_prior_output_and_missing_event_evidence');
149
+ result.uncertainties = [...uncertainties].sort();
150
+ return result;
151
+ }
@@ -0,0 +1,53 @@
1
+ import type { Policy } from '../types';
2
+ import type { InsightHost, InsightParserState, InsightTurn } from './types';
3
+ import { type PricingTable } from './pricing';
4
+ import { type Pace, type UsageWindow, type LimitForecast } from './pace';
5
+ export interface InsightSettings {
6
+ rewriteWarnDollarsPerHour?: number;
7
+ heavyTurnTokens?: number;
8
+ pricingFile?: string;
9
+ }
10
+ export interface LiveSnapshot {
11
+ schema: 1;
12
+ host: InsightHost;
13
+ sessionId: string;
14
+ updatedAt: number;
15
+ toolEvents: number;
16
+ cursor: {
17
+ offset: number;
18
+ inode: number;
19
+ pathDigest: string;
20
+ };
21
+ parser?: InsightParserState;
22
+ pace: Pace;
23
+ windows: UsageWindow[];
24
+ forecast: LimitForecast;
25
+ lastTurnTokens: number;
26
+ rewriteDollarsToday: {
27
+ min: number;
28
+ max: number;
29
+ } | null;
30
+ unpricedRewritesToday: number;
31
+ heavyAbove: boolean;
32
+ rewriteAbove: boolean;
33
+ }
34
+ export interface LiveInput {
35
+ session_id?: string;
36
+ transcript_path?: string;
37
+ tool_name?: string;
38
+ rate_limits?: unknown;
39
+ }
40
+ export declare function pacePath(home: string, sessionId: string): string;
41
+ export declare function readPricing(home: string, policy?: Policy): PricingTable;
42
+ export declare function dollars(value: {
43
+ min: number;
44
+ max: number;
45
+ } | null): string;
46
+ export declare function hostWindows(value: unknown, source: UsageWindow['source'], at: number): UsageWindow[];
47
+ export declare function heavyTurnMessage(turn: InsightTurn, rates: PricingTable): string;
48
+ /** Local advisory observer. No tool input or output content enters this API. */
49
+ export declare function observeTool(home: string, input: LiveInput, host: InsightHost, policy: Policy, now?: number): {
50
+ snapshot?: LiveSnapshot;
51
+ messages: string[];
52
+ };
53
+ export declare function renderStatusLine(snapshot: LiveSnapshot): string;