@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.
- package/CHANGELOG.md +39 -2
- package/LICENSE +9 -10
- package/README.md +96 -16
- package/dist/src/adapters/codex.js +11 -1
- package/dist/src/adapters/cursor.js +2 -2
- package/dist/src/cli.js +40 -13
- package/dist/src/conformance.js +3 -1
- package/dist/src/defaults.d.ts +9 -8
- package/dist/src/defaults.js +9 -8
- package/dist/src/detectors/evaluate.d.ts +5 -2
- package/dist/src/detectors/evaluate.js +26 -2
- package/dist/src/eligibility.d.ts +17 -0
- package/dist/src/eligibility.js +29 -0
- package/dist/src/gateway.d.ts +2 -0
- package/dist/src/gateway.js +6 -3
- package/dist/src/hook/pre-tool-use.d.ts +2 -0
- package/dist/src/hook/pre-tool-use.js +93 -63
- package/dist/src/insights/attribution.d.ts +4 -0
- package/dist/src/insights/attribution.js +151 -0
- package/dist/src/insights/live.d.ts +53 -0
- package/dist/src/insights/live.js +211 -0
- package/dist/src/insights/pace.d.ts +34 -0
- package/dist/src/insights/pace.js +54 -0
- package/dist/src/insights/pricing.d.ts +48 -0
- package/dist/src/insights/pricing.js +139 -0
- package/dist/src/insights/render.d.ts +8 -0
- package/dist/src/insights/render.js +126 -0
- package/dist/src/insights/sessions.d.ts +12 -0
- package/dist/src/insights/sessions.js +51 -0
- package/dist/src/insights/transcript.d.ts +12 -0
- package/dist/src/insights/transcript.js +492 -0
- package/dist/src/insights/types.d.ts +157 -0
- package/dist/src/insights/types.js +4 -0
- package/dist/src/install.js +14 -5
- package/dist/src/replay/render.js +1 -1
- package/dist/src/state/account.d.ts +3 -0
- package/dist/src/state/account.js +39 -0
- package/dist/src/state/reservations.d.ts +5 -5
- package/dist/src/state/reservations.js +60 -45
- package/dist/src/types.d.ts +6 -0
- package/docs/USAGE_AND_PRICING.md +132 -0
- package/fixtures/codex-0.151.0-pretooluse.json +11 -11
- package/package.json +4 -3
package/dist/src/gateway.js
CHANGED
|
@@ -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
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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: '
|
|
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;
|