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.
Files changed (61) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +83 -32
  3. package/dist/App.js +2178 -318
  4. package/dist/adapters.js +146 -15
  5. package/dist/agent/gates.js +153 -0
  6. package/dist/agent/loop-guard.js +184 -0
  7. package/dist/agent/loop.js +908 -0
  8. package/dist/agent/normalize.js +144 -0
  9. package/dist/agent/types.js +1 -0
  10. package/dist/auth.js +2 -1
  11. package/dist/cli.js +68 -6
  12. package/dist/compact.js +6 -48
  13. package/dist/config.js +171 -0
  14. package/dist/context-manager.js +564 -0
  15. package/dist/kilo.js +343 -0
  16. package/dist/local-discovery.js +308 -0
  17. package/dist/policy.js +286 -0
  18. package/dist/prompt-cache.js +99 -0
  19. package/dist/providers.js +183 -2
  20. package/dist/rollback.js +21 -0
  21. package/dist/scheduler.js +247 -0
  22. package/dist/session.js +35 -3
  23. package/dist/skills.js +214 -43
  24. package/dist/snapshots.js +57 -2
  25. package/dist/system.js +8 -1
  26. package/dist/telemetry-dashboard.js +589 -0
  27. package/dist/telemetry-server.js +301 -0
  28. package/dist/telemetry.js +1056 -0
  29. package/dist/tools/dir-cache.js +207 -0
  30. package/dist/tools/filesystem.js +149 -0
  31. package/dist/tools/fingerprints.js +33 -0
  32. package/dist/tools/overflow.js +76 -0
  33. package/dist/tools/read-cache.js +160 -0
  34. package/dist/tools/registry.js +802 -0
  35. package/dist/tools/search.js +242 -0
  36. package/dist/tools/shared.js +31 -0
  37. package/dist/tools/shell.js +273 -0
  38. package/dist/tools/todo.js +191 -0
  39. package/dist/tools/web.js +454 -0
  40. package/dist/tools.js +17 -1863
  41. package/dist/ui/activity.js +51 -0
  42. package/dist/ui/diff-panel.js +55 -0
  43. package/dist/ui/diff-view.js +112 -0
  44. package/dist/ui/diff.js +422 -0
  45. package/dist/ui/errors.js +129 -0
  46. package/dist/ui/highlight.js +120 -0
  47. package/dist/ui/input-model.js +115 -0
  48. package/dist/ui/input.js +40 -0
  49. package/dist/ui/live-tail.js +15 -0
  50. package/dist/ui/markdown.js +525 -0
  51. package/dist/ui/modals.js +47 -0
  52. package/dist/ui/palette.js +70 -0
  53. package/dist/ui/pickers.js +32 -0
  54. package/dist/ui/side-by-side.js +144 -0
  55. package/dist/ui/status-bar.js +75 -0
  56. package/dist/ui/theme.js +128 -0
  57. package/dist/ui/todo-panel.js +30 -0
  58. package/dist/ui/tool-inspector.js +59 -0
  59. package/dist/ui/transcript.js +128 -0
  60. package/dist/zen.js +145 -666
  61. 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
+ }