@agentguard-run/burn 0.1.0 → 0.1.1
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.
|
@@ -27,6 +27,7 @@ const claude_transcript_1 = require("../history/claude-transcript");
|
|
|
27
27
|
const reservations_1 = require("../state/reservations");
|
|
28
28
|
const session_1 = require("../state/session");
|
|
29
29
|
const defaults_1 = require("../defaults");
|
|
30
|
+
const render_1 = require("../replay/render");
|
|
30
31
|
const SPAWN_TOOLS = new Set(['Agent', 'Task']);
|
|
31
32
|
function sessionFile(home, sessionId) {
|
|
32
33
|
return (0, node_path_1.join)(home, 'sessions', `${sessionId.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`);
|
|
@@ -145,10 +146,10 @@ function handlePreToolUse(input, home, now = Date.now()) {
|
|
|
145
146
|
}
|
|
146
147
|
return { continue: true, suppressOutput: true };
|
|
147
148
|
}
|
|
148
|
-
function buildDenyReason(report,
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
return
|
|
149
|
+
function buildDenyReason(report, _reservation) {
|
|
150
|
+
// Claude Code shows this reason to the user. A box reads as an alarm; a
|
|
151
|
+
// sentence reads as a log line. Colour is off: the host decides rendering.
|
|
152
|
+
return (0, render_1.renderStop)(report, { colour: false });
|
|
152
153
|
}
|
|
153
154
|
function deny(reason) {
|
|
154
155
|
return {
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Terminal rendering
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Terminal rendering. This output is the distribution artifact, so it is
|
|
3
|
+
* designed to be screenshotted: one giant number, a curve you can see the
|
|
4
|
+
* runaway in, one line worth quoting. Pure ASCII/ANSI, degrades cleanly.
|
|
5
5
|
*/
|
|
6
|
+
import type { BurnReport } from '../types';
|
|
6
7
|
import type { ReplaySummary, SessionReplay } from './simulate';
|
|
8
|
+
export declare function sparkline(curve: number[], stopAt: number | null, on: boolean): string;
|
|
9
|
+
export declare function comparison(tokens: number): string;
|
|
7
10
|
export declare function renderReplay(summary: ReplaySummary, opts?: {
|
|
8
11
|
colour?: boolean;
|
|
9
12
|
top?: number;
|
|
10
13
|
}): string;
|
|
14
|
+
export declare function renderStop(report: BurnReport, opts?: {
|
|
15
|
+
colour?: boolean;
|
|
16
|
+
}): string;
|
|
11
17
|
export declare function renderSessionRow(s: SessionReplay): string;
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* Terminal rendering
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* Terminal rendering. This output is the distribution artifact, so it is
|
|
4
|
+
* designed to be screenshotted: one giant number, a curve you can see the
|
|
5
|
+
* runaway in, one line worth quoting. Pure ASCII/ANSI, degrades cleanly.
|
|
6
6
|
*/
|
|
7
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.sparkline = sparkline;
|
|
9
|
+
exports.comparison = comparison;
|
|
8
10
|
exports.renderReplay = renderReplay;
|
|
11
|
+
exports.renderStop = renderStop;
|
|
9
12
|
exports.renderSessionRow = renderSessionRow;
|
|
10
13
|
const evaluate_1 = require("../detectors/evaluate");
|
|
11
14
|
const C = {
|
|
@@ -16,74 +19,201 @@ const C = {
|
|
|
16
19
|
yellow: '\x1b[33m',
|
|
17
20
|
green: '\x1b[32m',
|
|
18
21
|
cyan: '\x1b[36m',
|
|
22
|
+
magenta: '\x1b[35m',
|
|
23
|
+
bgRed: '\x1b[41m',
|
|
24
|
+
white: '\x1b[97m',
|
|
19
25
|
};
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
const W = 72;
|
|
27
|
+
function paint(on, code, text) {
|
|
28
|
+
return on ? `${code}${text}${C.reset}` : text;
|
|
22
29
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
30
|
+
function strip(text) {
|
|
31
|
+
return text.replace(/\x1b\[[0-9;]*m/g, '');
|
|
32
|
+
}
|
|
33
|
+
function row(text = '') {
|
|
34
|
+
const pad = Math.max(0, W - 2 - strip(text).length);
|
|
27
35
|
return `│ ${text}${' '.repeat(pad)} │`;
|
|
28
36
|
}
|
|
29
37
|
const TOP = `╭${'─'.repeat(W)}╮`;
|
|
30
38
|
const MID = `├${'─'.repeat(W)}┤`;
|
|
31
39
|
const BOT = `╰${'─'.repeat(W)}╯`;
|
|
32
|
-
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Block digits. Five rows, three columns each, for 0-9 . % B K M and space.
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
const GLYPHS = {
|
|
44
|
+
'0': ['█▀▀█', '█ █', '█ █', '█ █', '▀▀▀▀'],
|
|
45
|
+
'1': [' ▀█', ' █', ' █', ' █', ' ▀'],
|
|
46
|
+
'2': ['▀▀▀█', ' █', '█▀▀▀', '█ ', '▀▀▀▀'],
|
|
47
|
+
'3': ['▀▀▀█', ' █', ' ▀▀█', ' █', '▀▀▀▀'],
|
|
48
|
+
'4': ['█ █', '█ █', '▀▀▀█', ' █', ' ▀'],
|
|
49
|
+
'5': ['█▀▀▀', '█ ', '▀▀▀█', ' █', '▀▀▀▀'],
|
|
50
|
+
'6': ['█▀▀▀', '█ ', '█▀▀█', '█ █', '▀▀▀▀'],
|
|
51
|
+
'7': ['▀▀▀█', ' █', ' █', ' █', ' ▀'],
|
|
52
|
+
'8': ['█▀▀█', '█ █', '█▀▀█', '█ █', '▀▀▀▀'],
|
|
53
|
+
'9': ['█▀▀█', '█ █', '▀▀▀█', ' █', '▀▀▀▀'],
|
|
54
|
+
'.': [' ', ' ', ' ', ' ', ' ▀ '],
|
|
55
|
+
'%': ['▀▀ █', ' █ ', ' █ ', ' █ ', '█ ▀▀'],
|
|
56
|
+
B: ['█▀▀▄', '█ █', '█▀▀▄', '█ █', '▀▀▀ '],
|
|
57
|
+
M: ['█▄ ▄█', '█ ▀ █', '█ █', '█ █', '▀ ▀'],
|
|
58
|
+
K: ['█ █', '█ █ ', '██ ', '█ █ ', '▀ ▀'],
|
|
59
|
+
' ': [' ', ' ', ' ', ' ', ' '],
|
|
60
|
+
};
|
|
61
|
+
function blockDigits(text) {
|
|
62
|
+
const rows = ['', '', '', '', ''];
|
|
63
|
+
for (const ch of text) {
|
|
64
|
+
const g = GLYPHS[ch] ?? GLYPHS[' '];
|
|
65
|
+
for (let r = 0; r < 5; r++)
|
|
66
|
+
rows[r] += g[r] + ' ';
|
|
67
|
+
}
|
|
68
|
+
return rows.map((r) => r.trimEnd());
|
|
69
|
+
}
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Sparkline with the STOP point marked.
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
const BARS = '▁▂▃▄▅▆▇█';
|
|
74
|
+
function sparkline(curve, stopAt, on) {
|
|
75
|
+
if (curve.length === 0)
|
|
76
|
+
return '';
|
|
77
|
+
let out = '';
|
|
78
|
+
for (let i = 0; i < curve.length; i++) {
|
|
79
|
+
const level = Math.min(BARS.length - 1, Math.max(0, Math.round(curve[i] * (BARS.length - 1))));
|
|
80
|
+
const bar = BARS[level];
|
|
81
|
+
if (stopAt !== null && i >= stopAt) {
|
|
82
|
+
// Everything after the stop is the tail enforcement would have cut.
|
|
83
|
+
out += paint(on, C.red, i === stopAt ? '┃' : bar);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
out += paint(on, C.green, bar);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// The quotable comparison. Tokens to something a human can picture.
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
const COMPARISONS = [
|
|
95
|
+
{ tokens: 7_000_000_000, label: 'all of English Wikipedia' },
|
|
96
|
+
{ tokens: 1_200_000_000, label: 'the entire Harry Potter series, 1,000 times' },
|
|
97
|
+
{ tokens: 200_000_000, label: 'the complete works of Shakespeare, 200 times' },
|
|
98
|
+
{ tokens: 40_000_000, label: 'the King James Bible, 50 times' },
|
|
99
|
+
{ tokens: 130_000, label: 'Slaughterhouse-Five' },
|
|
100
|
+
];
|
|
101
|
+
function comparison(tokens) {
|
|
102
|
+
for (const c of COMPARISONS) {
|
|
103
|
+
const times = tokens / c.tokens;
|
|
104
|
+
if (times >= 1) {
|
|
105
|
+
const n = times >= 10 ? Math.round(times) : Math.round(times * 10) / 10;
|
|
106
|
+
return `≈ ${c.label}${n > 1 ? `, ${n}×` : ''}`;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return '';
|
|
110
|
+
}
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Replay
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
33
114
|
function scenarioUsd(tokens, cacheShare) {
|
|
34
115
|
const cached = tokens * cacheShare;
|
|
35
116
|
return (cached * 0.3 + (tokens - cached) * 5) / 1e6;
|
|
36
117
|
}
|
|
37
118
|
function renderReplay(summary, opts = {}) {
|
|
38
119
|
const on = opts.colour ?? Boolean(process.stdout.isTTY);
|
|
39
|
-
const top = opts.top ??
|
|
120
|
+
const top = opts.top ?? 6;
|
|
40
121
|
const out = [];
|
|
41
|
-
const cacheShare = summary.
|
|
42
|
-
? summary.sessions.reduce((s, r) => s + r.cacheReadRatio * r.totalTokens, 0) /
|
|
122
|
+
const cacheShare = summary.totalTokens
|
|
123
|
+
? summary.sessions.reduce((s, r) => s + r.cacheReadRatio * r.totalTokens, 0) / summary.totalTokens
|
|
43
124
|
: 0;
|
|
125
|
+
const share = Math.round(summary.catchableShare * 100);
|
|
44
126
|
out.push(TOP);
|
|
45
|
-
out.push(
|
|
46
|
-
out.push(line(colour(on, C.dim, 'Your agent history, before enforcement was installed')));
|
|
127
|
+
out.push(row(paint(on, C.bold + C.magenta, 'AGENTGUARD') + ' ' + paint(on, C.dim, 'replay · your agent history, before enforcement')));
|
|
47
128
|
out.push(MID);
|
|
48
|
-
|
|
49
|
-
out.push(
|
|
129
|
+
// Hero: the one number. Rendered huge.
|
|
130
|
+
out.push(row(''));
|
|
131
|
+
for (const line of blockDigits(`${share}%`))
|
|
132
|
+
out.push(row(' ' + paint(on, C.bold + C.green, line)));
|
|
133
|
+
out.push(row(''));
|
|
134
|
+
out.push(row(' ' + paint(on, C.bold, 'of everything you burned came after a point it would have blocked')));
|
|
135
|
+
out.push(row(''));
|
|
136
|
+
// Three supporting numbers, card-style.
|
|
137
|
+
const cards = [
|
|
138
|
+
{ n: (0, evaluate_1.fmt)(summary.totalTokens), l: 'observed', c: C.white },
|
|
139
|
+
{ n: (0, evaluate_1.fmt)(summary.catchableTail), l: 'after STOP', c: C.red },
|
|
140
|
+
{ n: `$${Math.round(scenarioUsd(summary.catchableTail, cacheShare)).toLocaleString()}`, l: 'API-list, scenario', c: C.yellow },
|
|
141
|
+
];
|
|
142
|
+
const COL = 20;
|
|
143
|
+
out.push(row(' ' + cards.map((k) => paint(on, C.bold + k.c, k.n.padEnd(COL))).join('')));
|
|
144
|
+
out.push(row(' ' + cards.map((k) => paint(on, C.dim, k.l.padEnd(COL))).join('')));
|
|
145
|
+
const comp = comparison(summary.catchableTail);
|
|
146
|
+
if (comp)
|
|
147
|
+
out.push(row(' ' + paint(on, C.dim, comp)));
|
|
50
148
|
out.push(MID);
|
|
51
|
-
out.push(
|
|
52
|
-
|
|
53
|
-
const share = Math.round(summary.catchableShare * 100);
|
|
54
|
-
out.push(line(`${colour(on, C.bold, (0, evaluate_1.fmt)(summary.catchableTail))} tokens observed after the first STOP boundary`));
|
|
55
|
-
out.push(line(`${share}% of everything you burned ≈ $${Math.round(scenarioUsd(summary.catchableTail, cacheShare)).toLocaleString()} at API list, as a scenario`));
|
|
56
|
-
out.push(line(''));
|
|
57
|
-
out.push(line(colour(on, C.dim, 'Upper bound. It assumes you would not have overridden or restarted.')));
|
|
58
|
-
out.push(MID);
|
|
59
|
-
out.push(line(`${colour(on, C.red, `${summary.stops} STOP`)} ${colour(on, C.yellow, `${summary.warns} WARN`)} ${colour(on, C.green, `${summary.clean} clean`)}`));
|
|
149
|
+
out.push(row(`${paint(on, C.bold + C.red, `${summary.stops} STOP`)} ${paint(on, C.bold + C.yellow, `${summary.warns} WARN`)} ${paint(on, C.bold + C.green, `${summary.clean} clean`)}` +
|
|
150
|
+
paint(on, C.dim, ` across ${summary.sessions.length} sessions, ${summary.totalSpawns} spawns`)));
|
|
60
151
|
out.push(MID);
|
|
61
152
|
for (const s of summary.sessions.slice(0, top)) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (s.firstWarn)
|
|
71
|
-
out.push(line(` ${colour(on, C.yellow, 'WARN')} (${s.firstWarn.detector}) at ${(0, evaluate_1.fmt)(s.firstWarn.tokensAt)} · no stop`));
|
|
72
|
-
else
|
|
73
|
-
out.push(line(` ${colour(on, C.green, 'clean')}`));
|
|
74
|
-
}
|
|
75
|
-
if (s.catchableTail > 0) {
|
|
76
|
-
out.push(line(` observed tail after stop: ${colour(on, C.bold, (0, evaluate_1.fmt)(s.catchableTail))}`));
|
|
77
|
-
}
|
|
153
|
+
const tag = s.fanoutStop ? paint(on, C.red, 'FAN-OUT STOP') : s.sustainedStop ? paint(on, C.red, 'SUSTAINED STOP') : s.firstWarn ? paint(on, C.yellow, 'WARN') : paint(on, C.green, 'clean');
|
|
154
|
+
out.push(row(`${sparkline(s.curve, s.stopAtIndex, on)} ${tag}`));
|
|
155
|
+
const detail = s.fanoutStop
|
|
156
|
+
? `before spawn ${s.fanoutStop.atSpawn}, tail ${(0, evaluate_1.fmt)(s.catchableTail)}`
|
|
157
|
+
: s.sustainedStop
|
|
158
|
+
? `near ${(0, evaluate_1.fmt)(s.sustainedStop.tokensAtStop)}, tail ${(0, evaluate_1.fmt)(s.catchableTail)}`
|
|
159
|
+
: `${(0, evaluate_1.fmt)(s.totalTokens)} · ${s.spawns} spawns`;
|
|
160
|
+
out.push(row(paint(on, C.dim, `${s.sessionId.slice(0, 8)} ${(0, evaluate_1.fmt)(s.totalTokens).padStart(6)} · ${String(s.spawns).padStart(3)} spawns ${detail}`)));
|
|
78
161
|
}
|
|
79
162
|
if (summary.sessions.length > top) {
|
|
80
|
-
out.push(
|
|
163
|
+
out.push(row(paint(on, C.dim, `… ${summary.sessions.length - top} more, all clean`)));
|
|
81
164
|
}
|
|
82
165
|
out.push(MID);
|
|
83
|
-
out.push(
|
|
166
|
+
out.push(row(paint(on, C.dim, 'Upper bound; assumes no override or restart. Nothing left this machine.')));
|
|
167
|
+
out.push(row(paint(on, C.cyan, 'next agentguard-burn init') + paint(on, C.dim, ' shadow mode; blocks nothing until you say so')));
|
|
84
168
|
out.push(BOT);
|
|
85
169
|
return out.join('\n');
|
|
86
170
|
}
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// The mid-session STOP box. What Claude shows the user when a spawn is denied.
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
function renderStop(report, opts = {}) {
|
|
175
|
+
const on = opts.colour ?? false;
|
|
176
|
+
const lead = report.findings.find((f) => f.verdict === 'STOP')?.summary ?? 'Fan-out ceiling reached.';
|
|
177
|
+
const w = 64;
|
|
178
|
+
const bar = '━'.repeat(w);
|
|
179
|
+
const lines = [];
|
|
180
|
+
lines.push(paint(on, C.red, `┏${bar}┓`));
|
|
181
|
+
lines.push(paint(on, C.red, '┃ ') + paint(on, C.bold + C.bgRed + C.white, ' AGENTGUARD STOP ') + paint(on, C.bold, ' agent spawn blocked') + paint(on, C.red, ' '.repeat(w - 40) + '┃'));
|
|
182
|
+
lines.push(paint(on, C.red, `┣${bar}┫`));
|
|
183
|
+
lines.push(paint(on, C.red, '┃ ') + lead.padEnd(w - 1).slice(0, w - 1) + paint(on, C.red, '┃'));
|
|
184
|
+
lines.push(paint(on, C.red, '┃ ') + paint(on, C.dim, `${(0, evaluate_1.fmt)(report.totals.tokens)} tokens · ${report.totals.spawns} spawns · depth ${report.totals.maxDepth}`.padEnd(w - 1)) + paint(on, C.red, '┃'));
|
|
185
|
+
lines.push(paint(on, C.red, `┣${bar}┫`));
|
|
186
|
+
lines.push(paint(on, C.red, '┃ ') + paint(on, C.bold, 'DO NOW'.padEnd(w - 1)) + paint(on, C.red, '┃'));
|
|
187
|
+
report.prescriptions.slice(0, 3).forEach((p, i) => {
|
|
188
|
+
const wrapped = wrap(`${i + 1}. ${p}`, w - 2, ' ');
|
|
189
|
+
for (const line of wrapped) {
|
|
190
|
+
lines.push(paint(on, C.red, '┃ ') + line.padEnd(w - 1) + paint(on, C.red, '┃'));
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
lines.push(paint(on, C.red, `┣${bar}┫`));
|
|
194
|
+
lines.push(paint(on, C.red, '┃ ') + paint(on, C.dim, 'override once: agentguard-burn resume --once --reason "..."'.padEnd(w - 1)) + paint(on, C.red, '┃'));
|
|
195
|
+
lines.push(paint(on, C.red, `┗${bar}┛`));
|
|
196
|
+
return lines.join('\n');
|
|
197
|
+
}
|
|
198
|
+
/** Word-aware wrap. Continuation lines are indented. */
|
|
199
|
+
function wrap(text, width, indent) {
|
|
200
|
+
const words = text.split(' ');
|
|
201
|
+
const lines = [];
|
|
202
|
+
let current = '';
|
|
203
|
+
for (const word of words) {
|
|
204
|
+
const prefix = lines.length ? indent : '';
|
|
205
|
+
if ((current + ' ' + word).trim().length + prefix.length > width && current) {
|
|
206
|
+
lines.push((lines.length ? indent : '') + current);
|
|
207
|
+
current = word;
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
current = current ? `${current} ${word}` : word;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (current)
|
|
214
|
+
lines.push((lines.length ? indent : '') + current);
|
|
215
|
+
return lines;
|
|
216
|
+
}
|
|
87
217
|
function renderSessionRow(s) {
|
|
88
218
|
return `${s.sessionId.slice(0, 8)} ${(0, evaluate_1.fmt)(s.totalTokens).padStart(8)} ${String(s.spawns).padStart(4)} spawns ${s.finalVerdict}`;
|
|
89
219
|
}
|
|
@@ -37,6 +37,10 @@ export interface SessionReplay {
|
|
|
37
37
|
tokensAt: number;
|
|
38
38
|
spawnsAt: number;
|
|
39
39
|
} | null;
|
|
40
|
+
/** Cumulative tokens sampled over active time, for a sparkline. 0..1 normalised. */
|
|
41
|
+
curve: number[];
|
|
42
|
+
/** Index into curve where the earliest STOP fired, or null. */
|
|
43
|
+
stopAtIndex: number | null;
|
|
40
44
|
}
|
|
41
45
|
export declare function discoverTranscripts(root?: string): string[];
|
|
42
46
|
export declare function replaySession(path: string, thresholds: Thresholds): SessionReplay | null;
|
|
@@ -60,6 +60,9 @@ function replayEvents(sessionId, path, events, thresholds) {
|
|
|
60
60
|
let sustainedStop = null;
|
|
61
61
|
let firstWarn = null;
|
|
62
62
|
let tokensAtEarliestStop = null;
|
|
63
|
+
// Cumulative tokens after every event, for the sparkline.
|
|
64
|
+
const timeline = [];
|
|
65
|
+
let stopEventIndex = null;
|
|
63
66
|
for (const event of events) {
|
|
64
67
|
// A hook boundary exists only where a spawn was attempted. Evaluate the
|
|
65
68
|
// *proposal* before applying the event, then apply it.
|
|
@@ -80,6 +83,7 @@ function replayEvents(sessionId, path, events, thresholds) {
|
|
|
80
83
|
}
|
|
81
84
|
if ((fanoutStop || sustainedStop) && tokensAtEarliestStop === null) {
|
|
82
85
|
tokensAtEarliestStop = state.totalTokens;
|
|
86
|
+
stopEventIndex = timeline.length;
|
|
83
87
|
}
|
|
84
88
|
}
|
|
85
89
|
// The sustained plane also has a boundary at every tool call, not just
|
|
@@ -92,12 +96,16 @@ function replayEvents(sessionId, path, events, thresholds) {
|
|
|
92
96
|
const hit = report.findings.find((f) => f.verdict === 'STOP' && (f.detector === 'sustained_burn' || f.detector === 'burn_debt'));
|
|
93
97
|
if (hit) {
|
|
94
98
|
sustainedStop = { atSpawn: state.spawnCount, tokensAtStop: state.totalTokens };
|
|
95
|
-
if (tokensAtEarliestStop === null)
|
|
99
|
+
if (tokensAtEarliestStop === null) {
|
|
96
100
|
tokensAtEarliestStop = state.totalTokens;
|
|
101
|
+
stopEventIndex = timeline.length;
|
|
102
|
+
}
|
|
97
103
|
}
|
|
98
104
|
}
|
|
99
105
|
(0, session_1.applyEvent)(state, event);
|
|
106
|
+
timeline.push(state.totalTokens);
|
|
100
107
|
}
|
|
108
|
+
const { curve, stopAtIndex } = downsample(timeline, stopEventIndex, 32);
|
|
101
109
|
const final = (0, evaluate_1.evaluate)(state, thresholds, null);
|
|
102
110
|
return {
|
|
103
111
|
sessionId,
|
|
@@ -112,8 +120,23 @@ function replayEvents(sessionId, path, events, thresholds) {
|
|
|
112
120
|
sustainedStop,
|
|
113
121
|
catchableTail: tokensAtEarliestStop === null ? 0 : Math.max(0, state.totalTokens - tokensAtEarliestStop),
|
|
114
122
|
firstWarn,
|
|
123
|
+
curve,
|
|
124
|
+
stopAtIndex,
|
|
115
125
|
};
|
|
116
126
|
}
|
|
127
|
+
/** Compress a cumulative timeline into N normalised buckets, carrying the stop index across. */
|
|
128
|
+
function downsample(timeline, stopIndex, buckets) {
|
|
129
|
+
if (timeline.length === 0)
|
|
130
|
+
return { curve: [], stopAtIndex: null };
|
|
131
|
+
const max = timeline[timeline.length - 1] || 1;
|
|
132
|
+
const curve = [];
|
|
133
|
+
for (let b = 0; b < buckets; b++) {
|
|
134
|
+
const i = Math.min(timeline.length - 1, Math.floor(((b + 1) / buckets) * timeline.length) - 1);
|
|
135
|
+
curve.push(Math.max(0, timeline[Math.max(0, i)]) / max);
|
|
136
|
+
}
|
|
137
|
+
const stopAtIndex = stopIndex === null ? null : Math.min(buckets - 1, Math.floor((stopIndex / timeline.length) * buckets));
|
|
138
|
+
return { curve, stopAtIndex };
|
|
139
|
+
}
|
|
117
140
|
function replayAll(paths, thresholds, minTokens = 0) {
|
|
118
141
|
const sessions = [];
|
|
119
142
|
for (const path of paths) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentguard-run/burn",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Local runaway-agent circuit breaker for AI coding agents. Detects fan-out storms and sustained token burn, blocks the next spawn, and proves what happened. Nothing leaves the machine.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -39,4 +39,4 @@
|
|
|
39
39
|
"runaway",
|
|
40
40
|
"fan-out"
|
|
41
41
|
]
|
|
42
|
-
}
|
|
42
|
+
}
|