atom-agent 0.3.0 → 1.1.0
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 +82 -0
- package/README.md +83 -32
- package/dist/App.js +2178 -318
- package/dist/adapters.js +146 -15
- package/dist/agent/gates.js +153 -0
- package/dist/agent/loop-guard.js +184 -0
- package/dist/agent/loop.js +908 -0
- package/dist/agent/normalize.js +144 -0
- package/dist/agent/types.js +1 -0
- package/dist/auth.js +2 -1
- package/dist/cli.js +68 -6
- package/dist/compact.js +6 -48
- package/dist/config.js +171 -0
- package/dist/context-manager.js +564 -0
- package/dist/kilo.js +343 -0
- package/dist/local-discovery.js +308 -0
- package/dist/policy.js +286 -0
- package/dist/prompt-cache.js +99 -0
- package/dist/providers.js +183 -2
- package/dist/rollback.js +21 -0
- package/dist/scheduler.js +247 -0
- package/dist/session.js +35 -3
- package/dist/skills.js +214 -43
- package/dist/snapshots.js +57 -2
- package/dist/system.js +8 -1
- package/dist/telemetry-dashboard.js +589 -0
- package/dist/telemetry-server.js +301 -0
- package/dist/telemetry.js +1056 -0
- package/dist/tools/dir-cache.js +207 -0
- package/dist/tools/filesystem.js +149 -0
- package/dist/tools/fingerprints.js +33 -0
- package/dist/tools/overflow.js +76 -0
- package/dist/tools/read-cache.js +160 -0
- package/dist/tools/registry.js +802 -0
- package/dist/tools/search.js +242 -0
- package/dist/tools/shared.js +31 -0
- package/dist/tools/shell.js +273 -0
- package/dist/tools/todo.js +191 -0
- package/dist/tools/web.js +454 -0
- package/dist/tools.js +17 -1863
- package/dist/ui/activity.js +51 -0
- package/dist/ui/diff-panel.js +55 -0
- package/dist/ui/diff-view.js +112 -0
- package/dist/ui/diff.js +422 -0
- package/dist/ui/errors.js +129 -0
- package/dist/ui/highlight.js +120 -0
- package/dist/ui/input-model.js +115 -0
- package/dist/ui/input.js +40 -0
- package/dist/ui/live-tail.js +15 -0
- package/dist/ui/markdown.js +525 -0
- package/dist/ui/modals.js +47 -0
- package/dist/ui/palette.js +70 -0
- package/dist/ui/pickers.js +32 -0
- package/dist/ui/side-by-side.js +144 -0
- package/dist/ui/status-bar.js +75 -0
- package/dist/ui/theme.js +128 -0
- package/dist/ui/todo-panel.js +30 -0
- package/dist/ui/tool-inspector.js +59 -0
- package/dist/ui/transcript.js +128 -0
- package/dist/zen.js +145 -666
- package/package.json +1 -1
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
// ContextManager — the single place that answers, for one model:
|
|
2
|
+
// - How much context is available? (budget())
|
|
3
|
+
// - How much is currently used? (usage())
|
|
4
|
+
// - Should we compact? (needsCompaction())
|
|
5
|
+
// - What messages should be sent? (trimForSend())
|
|
6
|
+
//
|
|
7
|
+
// Budget derivation (no fixed-200K assumption): the history allowance comes
|
|
8
|
+
// from the model's ACTUAL verified context window:
|
|
9
|
+
//
|
|
10
|
+
// available history = window − system prompt − tool definitions
|
|
11
|
+
// − expected output reserve − safety margin
|
|
12
|
+
//
|
|
13
|
+
// measured in tokens via the shared 4ch/token estimator. A model with a 1M
|
|
14
|
+
// window therefore gets ~1M of usable history instead of ~50K tokens.
|
|
15
|
+
//
|
|
16
|
+
// Hard safety ceiling (configurable, never primary): env ATOM_MAX_HISTORY_*
|
|
17
|
+
// and atom.json maxHistory* still resolve through historyCharSource /
|
|
18
|
+
// historyMessageSource. The ceiling only ever CAPS the derived budget — with
|
|
19
|
+
// nothing configured it never binds for known-window models. Models with NO
|
|
20
|
+
// verified window keep the legacy 200K-char / 100-message behavior exactly
|
|
21
|
+
// (a window is never invented; auto-compact stays off for them).
|
|
22
|
+
//
|
|
23
|
+
// Layering: this module owns measurement + budget math. It imports
|
|
24
|
+
// context-windows (metadata) and config (file fallback) at runtime, and
|
|
25
|
+
// zen.js types ONLY (no runtime cycle — zen.ts imports this module for its
|
|
26
|
+
// loop trim). The agent loop, compaction mechanics, and providers are
|
|
27
|
+
// untouched: truncateHistory/shouldAutoCompact/compactPct keep working via
|
|
28
|
+
// re-exports from their original modules.
|
|
29
|
+
//
|
|
30
|
+
// Prompt-caching foundation (NOT implemented): all inputs here are explicit
|
|
31
|
+
// values (system/tools/history split, measured sizes, stable options), so a
|
|
32
|
+
// future cache layer can key stable prefixes (system + tools) without
|
|
33
|
+
// re-architecting call sites. No cache state lives here yet by design.
|
|
34
|
+
import { contextWindowFor } from "./context-windows.js";
|
|
35
|
+
import { loadAtomConfig } from "./config.js";
|
|
36
|
+
// ---- Units ----
|
|
37
|
+
// Shared chars-per-token estimator (opencode's 4ch/token preflight
|
|
38
|
+
// heuristic). Floors to whole tokens; never used for billed spend, only for
|
|
39
|
+
// sizing decisions and display.
|
|
40
|
+
export const CHARS_PER_TOKEN = 4;
|
|
41
|
+
export function estimateTokensForChars(chars) {
|
|
42
|
+
const c = Number.isFinite(chars) && chars > 0 ? Math.floor(chars) : 0;
|
|
43
|
+
return Math.floor(c / CHARS_PER_TOKEN);
|
|
44
|
+
}
|
|
45
|
+
// Deterministic size of one message: string content counts as-is, anything
|
|
46
|
+
// else counts stringified; assistant tool_calls and tool ids count too (they
|
|
47
|
+
// ride on every POST). History chars = the sum over all messages.
|
|
48
|
+
export function messageChars(m) {
|
|
49
|
+
let n = 0;
|
|
50
|
+
const content = m.content;
|
|
51
|
+
if (typeof content === "string") {
|
|
52
|
+
n += content.length;
|
|
53
|
+
}
|
|
54
|
+
else if (content !== null && content !== undefined) {
|
|
55
|
+
n += JSON.stringify(content).length;
|
|
56
|
+
}
|
|
57
|
+
if (m.role === "assistant") {
|
|
58
|
+
if (m.tool_calls !== undefined)
|
|
59
|
+
n += JSON.stringify(m.tool_calls).length;
|
|
60
|
+
}
|
|
61
|
+
else if (m.role === "tool") {
|
|
62
|
+
n += m.tool_call_id.length;
|
|
63
|
+
}
|
|
64
|
+
return n;
|
|
65
|
+
}
|
|
66
|
+
export function historyChars(history) {
|
|
67
|
+
const state = ledgerFor(history);
|
|
68
|
+
if (state)
|
|
69
|
+
return state.chars;
|
|
70
|
+
let total = 0;
|
|
71
|
+
for (const m of history)
|
|
72
|
+
total += messageChars(m);
|
|
73
|
+
return total;
|
|
74
|
+
}
|
|
75
|
+
// Load = last POST's reported prompt_tokens when available, else the
|
|
76
|
+
// 4ch/token estimate of the sent history chars.
|
|
77
|
+
export function computeContextLoad(lastPromptTokens, sentHistoryChars) {
|
|
78
|
+
if (typeof lastPromptTokens === "number" &&
|
|
79
|
+
Number.isFinite(lastPromptTokens) &&
|
|
80
|
+
lastPromptTokens >= 0) {
|
|
81
|
+
return Math.floor(lastPromptTokens);
|
|
82
|
+
}
|
|
83
|
+
return estimateTokensForChars(sentHistoryChars);
|
|
84
|
+
}
|
|
85
|
+
// ---- Safety-ceiling sources (env > atom.json > compiled default) ----
|
|
86
|
+
export const MAX_HISTORY_MESSAGES = 100;
|
|
87
|
+
export const MAX_HISTORY_CHARS = 200_000;
|
|
88
|
+
function clampInt(n, min, max) {
|
|
89
|
+
return Math.min(Math.max(Math.floor(n), min), max);
|
|
90
|
+
}
|
|
91
|
+
function envInt(raw) {
|
|
92
|
+
if (raw === undefined)
|
|
93
|
+
return undefined;
|
|
94
|
+
const text = raw.trim();
|
|
95
|
+
if (!/^\d+$/.test(text))
|
|
96
|
+
return undefined;
|
|
97
|
+
const n = Number(text);
|
|
98
|
+
return Number.isFinite(n) ? Math.floor(n) : undefined;
|
|
99
|
+
}
|
|
100
|
+
// Message-count ceiling source. `explicit` tells whether a human configured
|
|
101
|
+
// it (env or file) as opposed to the compiled default.
|
|
102
|
+
export function historyMessageSource() {
|
|
103
|
+
const env = envInt(process.env.ATOM_MAX_HISTORY_MESSAGES);
|
|
104
|
+
if (env !== undefined)
|
|
105
|
+
return { value: clampInt(env, 10, 1000), explicit: true };
|
|
106
|
+
const file = loadAtomConfig().config.maxHistoryMessages;
|
|
107
|
+
if (file !== undefined)
|
|
108
|
+
return { value: file, explicit: true };
|
|
109
|
+
return { value: MAX_HISTORY_MESSAGES, explicit: false };
|
|
110
|
+
}
|
|
111
|
+
// Char-count safety ceiling source. Same explicit contract.
|
|
112
|
+
export function historyCharSource() {
|
|
113
|
+
const env = envInt(process.env.ATOM_MAX_HISTORY_CHARS);
|
|
114
|
+
if (env !== undefined)
|
|
115
|
+
return { value: clampInt(env, 10_000, 2_000_000), explicit: true };
|
|
116
|
+
const file = loadAtomConfig().config.maxHistoryChars;
|
|
117
|
+
if (file !== undefined)
|
|
118
|
+
return { value: file, explicit: true };
|
|
119
|
+
return { value: MAX_HISTORY_CHARS, explicit: false };
|
|
120
|
+
}
|
|
121
|
+
// Legacy accessors (env → file → default). Kept for the loop's legacy path
|
|
122
|
+
// and existing callers; the manager uses the sources above so it can tell
|
|
123
|
+
// configured ceilings apart from defaults.
|
|
124
|
+
export function historyMessageBudget() {
|
|
125
|
+
return historyMessageSource().value;
|
|
126
|
+
}
|
|
127
|
+
export function historyCharBudget() {
|
|
128
|
+
return historyCharSource().value;
|
|
129
|
+
}
|
|
130
|
+
// ---- Compaction threshold (moved here: the manager owns "should compact") ----
|
|
131
|
+
export const COMPACT_PCT_DEFAULT = 0.83;
|
|
132
|
+
function clampPctPercent(n) {
|
|
133
|
+
return Math.min(Math.max(n, 50), 95) / 100;
|
|
134
|
+
}
|
|
135
|
+
// Auto-compact threshold as a fraction (default 0.83). Precedence: env
|
|
136
|
+
// ATOM_COMPACT_PCT percent (e.g. "83", clamped 50–95) → atom.json compactPct
|
|
137
|
+
// → default; invalid/unset falls through.
|
|
138
|
+
export function compactPct() {
|
|
139
|
+
const raw = process.env.ATOM_COMPACT_PCT;
|
|
140
|
+
if (raw !== undefined) {
|
|
141
|
+
const text = raw.trim();
|
|
142
|
+
if (/^\d+(\.\d+)?$/.test(text)) {
|
|
143
|
+
const n = Number(text);
|
|
144
|
+
if (Number.isFinite(n))
|
|
145
|
+
return clampPctPercent(n);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const file = loadAtomConfig().config.compactPct;
|
|
149
|
+
if (file !== undefined)
|
|
150
|
+
return file / 100;
|
|
151
|
+
return COMPACT_PCT_DEFAULT;
|
|
152
|
+
}
|
|
153
|
+
export function shouldAutoCompact(load, model, pctOverride) {
|
|
154
|
+
const window = contextWindowFor(model);
|
|
155
|
+
if (window === undefined)
|
|
156
|
+
return false; // never invent a window
|
|
157
|
+
const pct = typeof pctOverride === "number" && Number.isFinite(pctOverride)
|
|
158
|
+
? pctOverride
|
|
159
|
+
: compactPct();
|
|
160
|
+
return load / window >= pct;
|
|
161
|
+
}
|
|
162
|
+
// Searchable text for todo matching: message content plus the assistant's
|
|
163
|
+
// tool_calls payload (todowrite CALLS carry the list, tool RESULTS echo it).
|
|
164
|
+
// Tool call ids are NOT searched — they are pairing keys, not goal text, so
|
|
165
|
+
// a todo that reads like an id can never false-pin a turn.
|
|
166
|
+
function todoHaystack(m) {
|
|
167
|
+
let hay = "";
|
|
168
|
+
const content = m.content;
|
|
169
|
+
if (typeof content === "string")
|
|
170
|
+
hay += content;
|
|
171
|
+
if (m.role === "assistant" && m.tool_calls !== undefined) {
|
|
172
|
+
try {
|
|
173
|
+
hay += JSON.stringify(m.tool_calls);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// unstringifiable payload pins nothing
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return hay;
|
|
180
|
+
}
|
|
181
|
+
function turnMentionsTodo(history, start, end, needles) {
|
|
182
|
+
for (let i = start; i < end; i++) {
|
|
183
|
+
const hay = todoHaystack(history[i]);
|
|
184
|
+
if (hay.length === 0)
|
|
185
|
+
continue;
|
|
186
|
+
for (const n of needles) {
|
|
187
|
+
if (n.length > 0 && hay.includes(n))
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
// Drop oldest user-turns until history fits BOTH caps (message count AND
|
|
194
|
+
// total chars, each plus the caller's `reserve` headroom for a message it is
|
|
195
|
+
// about to push). A user turn = the `user` message plus all following
|
|
196
|
+
// messages up to (excluding) the next `user` message, so assistant
|
|
197
|
+
// tool_calls always stay paired with their tool results across all three
|
|
198
|
+
// wire formats. NEVER drops history[0] (system prompt), the first user turn
|
|
199
|
+
// (the task prompt — the goal a long run must never forget), any turn that
|
|
200
|
+
// still quotes a CURRENT open todo, or the latest turn (the one being
|
|
201
|
+
// sent/built). Budget-aware edge: when the pinned content alone (first turn
|
|
202
|
+
// + todo turns + latest) already exceeds a cap, there is nothing left to
|
|
203
|
+
// drop — stop and still send (same never-drop-the-live-turn principle).
|
|
204
|
+
// Mutates `history` in place via splice (so caller indices captured after
|
|
205
|
+
// this call stay valid) and, when at least one turn dropped, fires ONE
|
|
206
|
+
// `notify` (the caller surfaces it dim in the TUI); silence otherwise.
|
|
207
|
+
// Returns what was dropped.
|
|
208
|
+
export function truncateHistoryWithCaps(history, caps, opts) {
|
|
209
|
+
const result = { droppedTurns: 0, droppedMessages: 0 };
|
|
210
|
+
if (history.length <= 1)
|
|
211
|
+
return result;
|
|
212
|
+
const maxMessages = caps.maxMessages;
|
|
213
|
+
const maxChars = caps.maxChars;
|
|
214
|
+
const reserve = opts?.reserve;
|
|
215
|
+
const needles = opts?.todoNeedles ?? [];
|
|
216
|
+
const roomMessages = reserve?.messages !== undefined && Number.isFinite(reserve.messages)
|
|
217
|
+
? Math.max(0, Math.floor(reserve.messages))
|
|
218
|
+
: 0;
|
|
219
|
+
const roomChars = reserve?.chars !== undefined && Number.isFinite(reserve.chars)
|
|
220
|
+
? Math.max(0, reserve.chars)
|
|
221
|
+
: 0;
|
|
222
|
+
for (;;) {
|
|
223
|
+
const over = history.length + roomMessages > maxMessages ||
|
|
224
|
+
historyChars(history) + roomChars > maxChars;
|
|
225
|
+
if (!over)
|
|
226
|
+
break;
|
|
227
|
+
// Turn boundaries over history[1..]: each turn starts at a `user`
|
|
228
|
+
// message (the oldest slice starts at 1 even when it isn't one, matching
|
|
229
|
+
// the pre-pin drop unit). Whole-turn drops keep assistant/tool pairing.
|
|
230
|
+
const starts = [1];
|
|
231
|
+
for (let i = 2; i < history.length; i++) {
|
|
232
|
+
if (history[i]?.role === "user")
|
|
233
|
+
starts.push(i);
|
|
234
|
+
}
|
|
235
|
+
// Oldest NON-pinned, non-latest turn goes first: the first turn (task
|
|
236
|
+
// prompt) and any turn still quoting a current open todo stay, and the
|
|
237
|
+
// latest turn is never dropped. No candidate means pinned content alone
|
|
238
|
+
// is over budget — stop and send it as-is (see edge above).
|
|
239
|
+
let drop = -1;
|
|
240
|
+
for (let t = 0; t < starts.length; t++) {
|
|
241
|
+
if (t === starts.length - 1)
|
|
242
|
+
continue; // latest turn
|
|
243
|
+
if (t === 0)
|
|
244
|
+
continue; // task prompt
|
|
245
|
+
const end = t + 1 < starts.length ? starts[t + 1] : history.length;
|
|
246
|
+
if (needles.length > 0 && turnMentionsTodo(history, starts[t], end, needles))
|
|
247
|
+
continue;
|
|
248
|
+
drop = t;
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
if (drop === -1)
|
|
252
|
+
break;
|
|
253
|
+
const end = drop + 1 < starts.length ? starts[drop + 1] : history.length;
|
|
254
|
+
const removed = history.splice(starts[drop], end - starts[drop]);
|
|
255
|
+
result.droppedTurns += 1;
|
|
256
|
+
result.droppedMessages += removed.length;
|
|
257
|
+
}
|
|
258
|
+
if (result.droppedTurns > 0) {
|
|
259
|
+
try {
|
|
260
|
+
opts?.notify?.(`(history truncated: dropped ${result.droppedTurns} oldest turn(s))`);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
// observer errors never break the loop
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
// ---- Budget derivation ----
|
|
269
|
+
// Expected completion/output reserve: one full summary-sized generation must
|
|
270
|
+
// always fit alongside history (mirrors the compaction output cap).
|
|
271
|
+
export const OUTPUT_RESERVE_TOKENS = 4096;
|
|
272
|
+
// Safety headroom below the raw window: the trim cap never plans to use the
|
|
273
|
+
// last 5% (auto-compact at ~83% fires long before this matters — the margin
|
|
274
|
+
// is the last defense, not the trigger).
|
|
275
|
+
export const SAFETY_MARGIN_PCT = 0.05;
|
|
276
|
+
// Default safety ceiling when nothing is configured AND no window is known
|
|
277
|
+
// (the legacy 200K-char budget, preserved byte-for-byte as fallback).
|
|
278
|
+
export const HARD_CEILING_FLOOR_CHARS = 200_000;
|
|
279
|
+
export function createContextManager(opts) {
|
|
280
|
+
const model = opts.model;
|
|
281
|
+
const toolsChars = opts.toolsChars ?? 0;
|
|
282
|
+
const reserveTokens = opts.outputReserveTokens ?? OUTPUT_RESERVE_TOKENS;
|
|
283
|
+
const marginPct = opts.safetyMarginPct ?? SAFETY_MARGIN_PCT;
|
|
284
|
+
const pct = opts.compactPct ?? compactPct();
|
|
285
|
+
function ceilingChars() {
|
|
286
|
+
if (opts.hardCeilingChars !== undefined) {
|
|
287
|
+
return { value: opts.hardCeilingChars, explicit: opts.hardCeilingExplicit ?? true };
|
|
288
|
+
}
|
|
289
|
+
return historyCharSource();
|
|
290
|
+
}
|
|
291
|
+
function budget(history) {
|
|
292
|
+
const window = contextWindowFor(model);
|
|
293
|
+
const stats = ledgerStats(history);
|
|
294
|
+
const systemTokens = estimateTokensForChars(stats.systemChars);
|
|
295
|
+
const toolsTokens = estimateTokensForChars(Math.max(0, Math.floor(toolsChars)));
|
|
296
|
+
const margin = window !== undefined ? Math.floor(window * marginPct) : 0;
|
|
297
|
+
const historyTokens = window !== undefined
|
|
298
|
+
? Math.max(0, window - systemTokens - toolsTokens - reserveTokens - margin)
|
|
299
|
+
: undefined;
|
|
300
|
+
const historyCharsCap = historyTokens !== undefined ? historyTokens * CHARS_PER_TOKEN : undefined;
|
|
301
|
+
const ceil = ceilingChars();
|
|
302
|
+
const ceilMsgs = opts.hardCeilingMessages ?? historyMessageSource().value;
|
|
303
|
+
// The ceiling only ever CAPS: with nothing configured the derived budget
|
|
304
|
+
// rules (large windows stay usable); an explicit ceiling still binds as
|
|
305
|
+
// the safety net it is. Unknown windows fall back to the legacy floor.
|
|
306
|
+
const effectiveMaxChars = historyCharsCap !== undefined
|
|
307
|
+
? ceil.explicit
|
|
308
|
+
? Math.min(historyCharsCap, ceil.value)
|
|
309
|
+
: historyCharsCap
|
|
310
|
+
: ceil.explicit
|
|
311
|
+
? ceil.value
|
|
312
|
+
: HARD_CEILING_FLOOR_CHARS;
|
|
313
|
+
return {
|
|
314
|
+
windowTokens: window,
|
|
315
|
+
systemTokens,
|
|
316
|
+
toolsTokens,
|
|
317
|
+
outputReserveTokens: reserveTokens,
|
|
318
|
+
safetyMarginTokens: margin,
|
|
319
|
+
historyTokens,
|
|
320
|
+
historyChars: historyCharsCap,
|
|
321
|
+
hardCeilingChars: ceil.value,
|
|
322
|
+
hardCeilingExplicit: ceil.explicit,
|
|
323
|
+
hardCeilingMessages: ceilMsgs,
|
|
324
|
+
effectiveMaxChars,
|
|
325
|
+
effectiveMaxMessages: ceilMsgs,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
function usage(history, lastPromptTokens) {
|
|
329
|
+
const s = ledgerStats(history);
|
|
330
|
+
const loadTokens = computeContextLoad(lastPromptTokens, s.chars);
|
|
331
|
+
const window = contextWindowFor(model);
|
|
332
|
+
return {
|
|
333
|
+
historyChars: s.chars,
|
|
334
|
+
historyMessages: s.messages,
|
|
335
|
+
userTurns: s.userMessages,
|
|
336
|
+
loadTokens,
|
|
337
|
+
loadPct: window !== undefined ? Math.round((100 * loadTokens) / window) : undefined,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
function needsCompaction(loadTokens) {
|
|
341
|
+
return shouldAutoCompact(loadTokens, model, pct);
|
|
342
|
+
}
|
|
343
|
+
function trimForSend(history, notify, reserve, todoNeedles = []) {
|
|
344
|
+
const b = budget(history);
|
|
345
|
+
return truncateHistoryWithCaps(history, { maxMessages: b.effectiveMaxMessages, maxChars: b.effectiveMaxChars }, { notify, reserve, todoNeedles });
|
|
346
|
+
}
|
|
347
|
+
return { model, budget, usage, needsCompaction, trimForSend };
|
|
348
|
+
}
|
|
349
|
+
const ledgerByRaw = new WeakMap();
|
|
350
|
+
const ledgerByProxy = new WeakMap();
|
|
351
|
+
const footprints = new WeakMap();
|
|
352
|
+
function bucketOf(role) {
|
|
353
|
+
return role === "system" || role === "user" || role === "assistant" || role === "tool"
|
|
354
|
+
? role
|
|
355
|
+
: "other";
|
|
356
|
+
}
|
|
357
|
+
function footprintOf(m) {
|
|
358
|
+
if (typeof m !== "object" || m === null)
|
|
359
|
+
return { chars: 0, role: "other" };
|
|
360
|
+
const cached = footprints.get(m);
|
|
361
|
+
if (cached)
|
|
362
|
+
return cached;
|
|
363
|
+
const fp = {
|
|
364
|
+
chars: messageChars(m),
|
|
365
|
+
role: bucketOf(m.role),
|
|
366
|
+
};
|
|
367
|
+
footprints.set(m, fp);
|
|
368
|
+
return fp;
|
|
369
|
+
}
|
|
370
|
+
function addFootprint(state, m) {
|
|
371
|
+
if (typeof m !== "object" || m === null)
|
|
372
|
+
return;
|
|
373
|
+
const fp = footprintOf(m);
|
|
374
|
+
state.chars += fp.chars;
|
|
375
|
+
if (fp.role === "system")
|
|
376
|
+
state.systemChars += fp.chars;
|
|
377
|
+
else if (fp.role === "user")
|
|
378
|
+
state.userMessages += 1;
|
|
379
|
+
else if (fp.role === "assistant")
|
|
380
|
+
state.assistantMessages += 1;
|
|
381
|
+
else if (fp.role === "tool") {
|
|
382
|
+
state.toolMessages += 1;
|
|
383
|
+
state.toolChars += fp.chars;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
function removeFootprint(state, m) {
|
|
387
|
+
if (typeof m !== "object" || m === null)
|
|
388
|
+
return;
|
|
389
|
+
const fp = footprintOf(m);
|
|
390
|
+
state.chars -= fp.chars;
|
|
391
|
+
if (fp.role === "system")
|
|
392
|
+
state.systemChars -= fp.chars;
|
|
393
|
+
else if (fp.role === "user")
|
|
394
|
+
state.userMessages -= 1;
|
|
395
|
+
else if (fp.role === "assistant")
|
|
396
|
+
state.assistantMessages -= 1;
|
|
397
|
+
else if (fp.role === "tool") {
|
|
398
|
+
state.toolMessages -= 1;
|
|
399
|
+
state.toolChars -= fp.chars;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
function isArrayIndex(prop) {
|
|
403
|
+
if (typeof prop !== "string")
|
|
404
|
+
return false;
|
|
405
|
+
if (!/^(0|[1-9]\d*)$/.test(prop))
|
|
406
|
+
return false;
|
|
407
|
+
return Number(prop) < 4294967295;
|
|
408
|
+
}
|
|
409
|
+
function toArrayLength(value) {
|
|
410
|
+
const n = Number(value);
|
|
411
|
+
if (!Number.isFinite(n) || n < 0)
|
|
412
|
+
return 0;
|
|
413
|
+
return Math.min(Math.floor(n), 4294967295);
|
|
414
|
+
}
|
|
415
|
+
function snapshotOf(state) {
|
|
416
|
+
return {
|
|
417
|
+
messages: state.messages,
|
|
418
|
+
chars: state.chars,
|
|
419
|
+
tokens: estimateTokensForChars(state.chars),
|
|
420
|
+
systemChars: state.systemChars,
|
|
421
|
+
toolChars: state.toolChars,
|
|
422
|
+
userMessages: state.userMessages,
|
|
423
|
+
assistantMessages: state.assistantMessages,
|
|
424
|
+
toolMessages: state.toolMessages,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
// Wrap a history array for incremental accounting (idempotent — wrapping an
|
|
428
|
+
// already-tracked array returns it as-is). ALWAYS use the return value: the
|
|
429
|
+
// caller must drop its raw reference so every later mutation flows through
|
|
430
|
+
// the proxy traps.
|
|
431
|
+
export function trackHistory(history) {
|
|
432
|
+
if (ledgerByProxy.has(history))
|
|
433
|
+
return history;
|
|
434
|
+
const rebound = ledgerByRaw.get(history);
|
|
435
|
+
if (rebound)
|
|
436
|
+
return rebound.proxy;
|
|
437
|
+
const state = {
|
|
438
|
+
proxy: [],
|
|
439
|
+
messages: 0,
|
|
440
|
+
chars: 0,
|
|
441
|
+
systemChars: 0,
|
|
442
|
+
toolChars: 0,
|
|
443
|
+
userMessages: 0,
|
|
444
|
+
assistantMessages: 0,
|
|
445
|
+
toolMessages: 0,
|
|
446
|
+
};
|
|
447
|
+
for (const m of history)
|
|
448
|
+
addFootprint(state, m);
|
|
449
|
+
state.messages = history.length;
|
|
450
|
+
const proxy = new Proxy(history, {
|
|
451
|
+
set(target, prop, value) {
|
|
452
|
+
if (prop === "length") {
|
|
453
|
+
const newLen = toArrayLength(value);
|
|
454
|
+
const oldLen = target.length;
|
|
455
|
+
if (newLen < oldLen) {
|
|
456
|
+
for (let i = newLen; i < oldLen; i++)
|
|
457
|
+
removeFootprint(state, target[i]);
|
|
458
|
+
}
|
|
459
|
+
const ok = Reflect.set(target, prop, value);
|
|
460
|
+
state.messages = target.length;
|
|
461
|
+
return ok;
|
|
462
|
+
}
|
|
463
|
+
if (isArrayIndex(prop)) {
|
|
464
|
+
const i = Number(prop);
|
|
465
|
+
if (i < target.length)
|
|
466
|
+
removeFootprint(state, target[i]);
|
|
467
|
+
addFootprint(state, value);
|
|
468
|
+
}
|
|
469
|
+
const ok = Reflect.set(target, prop, value);
|
|
470
|
+
state.messages = target.length;
|
|
471
|
+
return ok;
|
|
472
|
+
},
|
|
473
|
+
deleteProperty(target, prop) {
|
|
474
|
+
if (isArrayIndex(prop)) {
|
|
475
|
+
const i = Number(prop);
|
|
476
|
+
if (i < target.length)
|
|
477
|
+
removeFootprint(state, target[i]);
|
|
478
|
+
}
|
|
479
|
+
return Reflect.deleteProperty(target, prop);
|
|
480
|
+
},
|
|
481
|
+
defineProperty(target, prop, descriptor) {
|
|
482
|
+
if (prop === "length" || isArrayIndex(prop)) {
|
|
483
|
+
const had = typeof prop === "string" && isArrayIndex(prop) && Number(prop) < target.length
|
|
484
|
+
? target[Number(prop)]
|
|
485
|
+
: undefined;
|
|
486
|
+
const ok = Reflect.defineProperty(target, prop, descriptor);
|
|
487
|
+
if (had !== undefined)
|
|
488
|
+
removeFootprint(state, had);
|
|
489
|
+
if ("value" in descriptor)
|
|
490
|
+
addFootprint(state, descriptor.value);
|
|
491
|
+
state.messages = target.length;
|
|
492
|
+
return ok;
|
|
493
|
+
}
|
|
494
|
+
return Reflect.defineProperty(target, prop, descriptor);
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
state.proxy = proxy;
|
|
498
|
+
ledgerByRaw.set(history, state);
|
|
499
|
+
ledgerByProxy.set(proxy, state);
|
|
500
|
+
return proxy;
|
|
501
|
+
}
|
|
502
|
+
function ledgerFor(history) {
|
|
503
|
+
return ledgerByProxy.get(history) ?? ledgerByRaw.get(history);
|
|
504
|
+
}
|
|
505
|
+
// O(1) snapshot of a TRACKED array. Untracked arrays (tests, transient
|
|
506
|
+
// copies) fall back to an exact full scan — always correct, just O(n).
|
|
507
|
+
export function ledgerStats(history) {
|
|
508
|
+
const state = ledgerFor(history);
|
|
509
|
+
if (state)
|
|
510
|
+
return snapshotOf(state);
|
|
511
|
+
return scanHistory(history);
|
|
512
|
+
}
|
|
513
|
+
// Independent O(n) reference implementation (never touches the footprint
|
|
514
|
+
// cache, so cache bugs cannot hide from it). Holes (deleted indices) count
|
|
515
|
+
// as empty: the pre-ledger messageChars crashes on them, but tracked arrays
|
|
516
|
+
// can hold holes after `delete`, so the reference must stay total. Tests
|
|
517
|
+
// diff stats() against this after every op sequence.
|
|
518
|
+
export function scanHistory(history) {
|
|
519
|
+
let chars = 0;
|
|
520
|
+
let systemChars = 0;
|
|
521
|
+
let toolChars = 0;
|
|
522
|
+
let user = 0;
|
|
523
|
+
let assistant = 0;
|
|
524
|
+
let tool = 0;
|
|
525
|
+
for (const m of history) {
|
|
526
|
+
if (m === undefined)
|
|
527
|
+
continue;
|
|
528
|
+
const c = messageChars(m);
|
|
529
|
+
chars += c;
|
|
530
|
+
const role = m?.role;
|
|
531
|
+
if (role === "user")
|
|
532
|
+
user += 1;
|
|
533
|
+
else if (role === "assistant")
|
|
534
|
+
assistant += 1;
|
|
535
|
+
else if (role === "tool") {
|
|
536
|
+
tool += 1;
|
|
537
|
+
toolChars += c;
|
|
538
|
+
}
|
|
539
|
+
else if (role === "system")
|
|
540
|
+
systemChars += c;
|
|
541
|
+
}
|
|
542
|
+
return {
|
|
543
|
+
messages: history.length,
|
|
544
|
+
chars,
|
|
545
|
+
tokens: estimateTokensForChars(chars),
|
|
546
|
+
systemChars,
|
|
547
|
+
toolChars,
|
|
548
|
+
userMessages: user,
|
|
549
|
+
assistantMessages: assistant,
|
|
550
|
+
toolMessages: tool,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
// Full independent verification: true when the incremental counters exactly
|
|
554
|
+
// match a fresh scan. Tests assert this; prod hot paths never pay for it.
|
|
555
|
+
export function verifyLedger(history) {
|
|
556
|
+
const live = ledgerStats(history);
|
|
557
|
+
const ref = scanHistory(history);
|
|
558
|
+
const mismatches = [];
|
|
559
|
+
Object.keys(ref).forEach((k) => {
|
|
560
|
+
if (live[k] !== ref[k])
|
|
561
|
+
mismatches.push(`${k}: ledger=${live[k]} scan=${ref[k]}`);
|
|
562
|
+
});
|
|
563
|
+
return { ok: mismatches.length === 0, mismatches };
|
|
564
|
+
}
|