@genesislcap/ai-assistant 15.19.6 → 15.20.0-alpha.3

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 (52) hide show
  1. package/dist/ai-assistant.api.json +707 -69
  2. package/dist/ai-assistant.d.ts +443 -25
  3. package/dist/chat-driver.cjs +440 -34
  4. package/dist/chat-driver.cjs.map +4 -4
  5. package/dist/chat-driver.mjs +440 -34
  6. package/dist/chat-driver.mjs.map +4 -4
  7. package/dist/custom-elements.json +709 -47
  8. package/dist/dts/components/ai-driver/ai-driver.d.ts +40 -7
  9. package/dist/dts/components/ai-driver/ai-driver.d.ts.map +1 -1
  10. package/dist/dts/components/chat-driver/chat-driver.d.ts +81 -2
  11. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  12. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +23 -3
  13. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  14. package/dist/dts/config/config.d.ts +44 -0
  15. package/dist/dts/config/config.d.ts.map +1 -1
  16. package/dist/dts/main/main.d.ts +187 -5
  17. package/dist/dts/main/main.d.ts.map +1 -1
  18. package/dist/dts/main/main.styles.d.ts.map +1 -1
  19. package/dist/dts/main/main.template.d.ts.map +1 -1
  20. package/dist/dts/state/persistence/diagnostics-cursors.d.ts +7 -2
  21. package/dist/dts/state/persistence/diagnostics-cursors.d.ts.map +1 -1
  22. package/dist/dts/state/persistence/diagnostics.d.ts +30 -0
  23. package/dist/dts/state/persistence/diagnostics.d.ts.map +1 -1
  24. package/dist/dts/state/persistence/session-persister.d.ts +18 -0
  25. package/dist/dts/state/persistence/session-persister.d.ts.map +1 -1
  26. package/dist/dts/utils/condense-history.d.ts +35 -0
  27. package/dist/dts/utils/condense-history.d.ts.map +1 -1
  28. package/dist/dts/utils/context-tokens.d.ts +156 -0
  29. package/dist/dts/utils/context-tokens.d.ts.map +1 -0
  30. package/dist/dts/utils/history-transform.d.ts +76 -14
  31. package/dist/dts/utils/history-transform.d.ts.map +1 -1
  32. package/dist/dts/utils/resolve-context-budget.d.ts +98 -0
  33. package/dist/dts/utils/resolve-context-budget.d.ts.map +1 -0
  34. package/dist/esm/components/chat-driver/chat-driver.js +200 -35
  35. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +28 -4
  36. package/dist/esm/main/main.js +400 -24
  37. package/dist/esm/main/main.styles.js +128 -0
  38. package/dist/esm/main/main.template.js +64 -29
  39. package/dist/esm/state/debug-event-log.js +1 -1
  40. package/dist/esm/state/persistence/diagnostics-cursors.js +1 -1
  41. package/dist/esm/state/persistence/diagnostics.js +49 -0
  42. package/dist/esm/state/persistence/session-persister.js +38 -18
  43. package/dist/esm/utils/condense-history.js +125 -26
  44. package/dist/esm/utils/context-tokens.js +339 -0
  45. package/dist/esm/utils/history-transform.js +101 -19
  46. package/dist/esm/utils/resolve-context-budget.js +84 -0
  47. package/package.json +16 -16
  48. package/sandbox/README.md +93 -4
  49. package/sandbox/controls.ts +77 -10
  50. package/sandbox/fixtures.ts +163 -6
  51. package/sandbox/sandbox.css +54 -1
  52. package/sandbox/sandbox.ts +384 -7
@@ -1,4 +1,5 @@
1
1
  import { __rest } from "tslib";
2
+ import { APPROX_CHARS_PER_TOKEN } from './context-tokens';
2
3
  /**
3
4
  * Tool-context condensation: collapse stale tool payloads out of the history
4
5
  * sent to the model, while leaving stored history untouched.
@@ -21,11 +22,6 @@ import { __rest } from "tslib";
21
22
  export const CONDENSE_MIN_CHARS = 1000;
22
23
  /** Key the collapsed args are stored under — a tool-call's args must stay a Record. */
23
24
  export const CONDENSED_ARGS_KEY = 'condensed';
24
- /**
25
- * Rough chars-per-token divisor for the `tokensSaved` estimate. No tokenizer is
26
- * available in this stack; ~4 chars/token is the usual English approximation.
27
- */
28
- const APPROX_CHARS_PER_TOKEN = 4;
29
25
  /** Short human-readable reason for the stub text, per fired trigger. */
30
26
  function triggerReason(trigger) {
31
27
  switch (trigger.kind) {
@@ -65,6 +61,38 @@ imageCount = 0) {
65
61
  const images = imageCount > 0 ? ` + ${imageCount} image${imageCount === 1 ? '' : 's'}` : '';
66
62
  return `[${tool}${key} — ${what} elided, ~${origLen} chars${images} (${triggerReason(trigger)})${restore}]`;
67
63
  }
64
+ /**
65
+ * The replacement for a call's ARGS, in the shape args must keep. Shared by the request-time
66
+ * transform and {@link condenseForPersistence} so both render byte-identical stubs.
67
+ *
68
+ * Args must stay a Record, so every mode stashes its stub under a single key.
69
+ *
70
+ * GENC-1494: `drop` used to collapse args to a bare `{}`. That is indistinguishable from a call
71
+ * the model made with NO arguments, and the model reads its own history as fact: a ui-builder run
72
+ * saw its elided `vfs_write` calls as `vfs_write {}` beside a live
73
+ * `{"status":"buffered","path":"…"}` result, concluded it was calling the tool without content,
74
+ * and burned two hours re-writing files that were already correct. A bare marker is not enough
75
+ * either — it still fails to say WHICH file the call wrote, since `path` went with the args. So
76
+ * `drop` renders the same identifying stub as `pointer`, minus only the restore promise it cannot
77
+ * honour.
78
+ */
79
+ function renderCondensedArgs(policy, tool, fired, origLen) {
80
+ return {
81
+ [CONDENSED_ARGS_KEY]: policy === 'drop' || policy === 'pointer'
82
+ ? condenseStub('args', tool, fired, origLen, policy === 'pointer')
83
+ : policy.replaceWith,
84
+ };
85
+ }
86
+ /**
87
+ * The replacement for a call's RESULT. Kept non-empty — Anthropic rejects empty `tool_result`
88
+ * content. Shared with {@link condenseForPersistence} for the same reason as
89
+ * {@link renderCondensedArgs}.
90
+ */
91
+ function renderCondensedResult(policy, tool, fired, textLen, imageCount) {
92
+ return policy === 'drop' || policy === 'pointer'
93
+ ? condenseStub('response', tool, fired, textLen, policy === 'pointer', imageCount)
94
+ : policy.replaceWith;
95
+ }
68
96
  /** Stable label for the `context.condensed` meta-event's `trigger` field. */
69
97
  function triggerLabel(trigger) {
70
98
  switch (trigger.kind) {
@@ -224,24 +252,9 @@ export function applyCondensation(history, policies, ctx, onCondensed) {
224
252
  if (origLen < CONDENSE_MIN_CHARS)
225
253
  return tc;
226
254
  changed = true;
227
- const result = entry.policy.args;
228
- // Args must stay a Record, so every mode stashes its stub under a single key.
229
255
  // Spread `tc` so providerMetadata (e.g. the Gemini reasoning signature that must
230
256
  // round-trip) and UI fields survive.
231
- //
232
- // GENC-1494: `drop` used to collapse args to a bare `{}`. That is indistinguishable
233
- // from a call the model made with NO arguments, and the model reads its own history
234
- // as fact: a ui-builder run saw its elided `vfs_write` calls as `vfs_write {}` beside
235
- // a live `{"status":"buffered","path":"…"}` result, concluded it was calling the tool
236
- // without content, and burned two hours re-writing files that were already correct.
237
- // A bare marker is not enough either — it still fails to say WHICH file the call
238
- // wrote, since `path` went with the args. So `drop` renders the same identifying stub
239
- // as `pointer`, minus only the restore promise it cannot honour.
240
- const args = {
241
- [CONDENSED_ARGS_KEY]: result === 'drop' || result === 'pointer'
242
- ? condenseStub('args', tc.name, fired, origLen, result === 'pointer')
243
- : result.replaceWith,
244
- };
257
+ const args = renderCondensedArgs(entry.policy.args, tc.name, fired, origLen);
245
258
  if (!entry.reportedArgs) {
246
259
  entry.reportedArgs = true;
247
260
  const stubLen = JSON.stringify(args).length;
@@ -281,11 +294,8 @@ export function applyCondensation(history, policies, ctx, onCondensed) {
281
294
  : undefined;
282
295
  if (entry && fired && origLen >= CONDENSE_MIN_CHARS) {
283
296
  const tool = (_e = nameById.get(msg.toolResult.toolCallId)) !== null && _e !== void 0 ? _e : msg.toolResult.toolCallId;
284
- const result = entry.policy.response; // `fired` is only set when response is declared
285
- // Kept non-empty — Anthropic rejects empty tool_result content.
286
- const content = result === 'drop' || result === 'pointer'
287
- ? condenseStub('response', tool, fired, textLen, result === 'pointer', images.length)
288
- : result.replaceWith;
297
+ // `fired` is only set when a response policy is declared, so the `!` holds.
298
+ const content = renderCondensedResult(entry.policy.response, tool, fired, textLen, images.length);
289
299
  if (!entry.reportedResponse) {
290
300
  entry.reportedResponse = true;
291
301
  onCondensed({
@@ -308,3 +318,92 @@ export function applyCondensation(history, policies, ctx, onCondensed) {
308
318
  return msg;
309
319
  });
310
320
  }
321
+ /**
322
+ * Project history for PERSISTENCE: collapse the payloads the model has **already** stopped
323
+ * seeing, using the same stubs it was sent (GENC-1566).
324
+ *
325
+ * `applyCondensation` leaves stored history whole on purpose, and that is right for the live
326
+ * transcript — but a host that WRITES the transcript to durable storage is then writing payloads
327
+ * the driver has already decided are spent, on every autosave, for the life of the project.
328
+ * Measured on one small Create session: 58,187 chars, 7.4% of a 790 KB session object, across
329
+ * five payloads the driver had condensed out of context to save 14,434 tokens. That session
330
+ * autosaved 86 times, so the bytes were written 86 times.
331
+ *
332
+ * **Deliberately dumber than {@link applyCondensation}, and that is the safety argument.** No
333
+ * clocks, no trigger evaluation, no events, no writes to the registry's latches. It reads only
334
+ * `reportedArgs` / `reportedResponse` — set when a payload was actually replaced by a stub — and
335
+ * `firedTrigger`, which is latched, so the stub it renders is byte-identical to the one the model
336
+ * received. It therefore cannot condense something the model can still see, and calling it is
337
+ * free of side effects: safe on a save path, and safe to call twice.
338
+ *
339
+ * What survives: `diag/`-style diagnostic streams keep the full payloads (they are a separate,
340
+ * chunked keyspace), and stored history in memory is untouched — this returns a copy. So the
341
+ * transcript a host RENDERS and the transcript a host STORES can differ, which is the point.
342
+ *
343
+ * **That divergence lasts only until the next reload, and the difference is user-visible** (PR
344
+ * #2495 review). Restore feeds the STORED transcript back into both the driver and the UI, so a
345
+ * resumed session renders the stubs: someone reopening a project sees
346
+ * `[read_context — result elided, ~26907 chars (agent finished)]` where the payload used to be,
347
+ * and the only route back to it is the debug-log download. That is the trade this function makes —
348
+ * 7.4% of a measured session object against a spent payload no longer being visible in a resumed
349
+ * transcript — and it is deliberate, but it is not invisible. Anyone handling a "where did my tool
350
+ * output go?" report should find the answer here.
351
+ *
352
+ * Returns `history` itself when there is nothing to collapse, so a caller that saves on every
353
+ * keystroke pays one walk and no allocation.
354
+ */
355
+ export function condenseForPersistence(history, policies) {
356
+ var _a;
357
+ if (policies.size === 0)
358
+ return history;
359
+ // A tool message carries only an id, so the tool NAME for its stub comes from the call.
360
+ const nameById = new Map();
361
+ for (const msg of history) {
362
+ for (const tc of (_a = msg.toolCalls) !== null && _a !== void 0 ? _a : [])
363
+ nameById.set(tc.id, tc.name);
364
+ }
365
+ let changed = false;
366
+ const projected = history.map((msg) => {
367
+ var _a, _b, _c, _d, _e;
368
+ if ((_a = msg.toolCalls) === null || _a === void 0 ? void 0 : _a.length) {
369
+ let callsChanged = false;
370
+ const toolCalls = msg.toolCalls.map((tc) => {
371
+ var _a;
372
+ const entry = policies.get(tc.id);
373
+ if (!(entry === null || entry === void 0 ? void 0 : entry.reportedArgs) || !entry.firedTrigger || !entry.policy.args)
374
+ return tc;
375
+ callsChanged = true;
376
+ const origLen = JSON.stringify((_a = tc.args) !== null && _a !== void 0 ? _a : {}).length;
377
+ return Object.assign(Object.assign({}, tc), { args: renderCondensedArgs(entry.policy.args, tc.name, entry.firedTrigger, origLen) });
378
+ });
379
+ // Returns unconditionally, matching `applyCondensation`'s equivalent branch
380
+ // (`return changed ? { ...msg, toolCalls } : msg`). A message can carry BOTH `toolCalls` and
381
+ // a `toolResult`, so falling through here would walk a result the request-time transform
382
+ // never reaches — safe today only because `reportedResponse` can be set by nothing except
383
+ // `applyCondensation`, which cannot reach it either. The whole safety argument for this
384
+ // function is that it renders the stub the model was already sent, so the control flow is
385
+ // held identical rather than resting on that coincidence (PR #2495 review).
386
+ if (callsChanged) {
387
+ changed = true;
388
+ return Object.assign(Object.assign({}, msg), { toolCalls });
389
+ }
390
+ return msg;
391
+ }
392
+ if (msg.toolResult) {
393
+ const entry = policies.get(msg.toolResult.toolCallId);
394
+ if ((entry === null || entry === void 0 ? void 0 : entry.reportedResponse) && entry.firedTrigger && entry.policy.response) {
395
+ changed = true;
396
+ const tool = (_b = nameById.get(msg.toolResult.toolCallId)) !== null && _b !== void 0 ? _b : msg.toolResult.toolCallId;
397
+ const images = (_c = msg.toolResult.attachments) !== null && _c !== void 0 ? _c : [];
398
+ const content = renderCondensedResult(entry.policy.response, tool, entry.firedTrigger, (_e = (_d = msg.toolResult.content) === null || _d === void 0 ? void 0 : _d.length) !== null && _e !== void 0 ? _e : 0, images.length);
399
+ // `attachments` is dropped rather than spread through, exactly as the request-time
400
+ // transform drops it: the stub says the payload was elided, and keeping the base64
401
+ // would both make that a lie and shed none of the bytes this exists to shed.
402
+ const _f = msg.toolResult, { attachments: _elided } = _f, restOfResult = __rest(_f, ["attachments"]);
403
+ return Object.assign(Object.assign({}, msg), { toolResult: Object.assign(Object.assign({}, restOfResult), { content }) });
404
+ }
405
+ }
406
+ return msg;
407
+ });
408
+ return changed ? projected : history;
409
+ }
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Context-window accounting (GENC-1567) — estimating how much of the model's
3
+ * context each message occupies, so compaction can be sized in the unit that
4
+ * actually matters and a gate can promise a reclaim before spending a
5
+ * summarizer call.
6
+ *
7
+ * The estimates here are deliberately **conservative** (biased to over-count).
8
+ * They feed a gate that blocks sending: over-counting costs a little unused
9
+ * headroom, under-counting lets a turn overflow, which is the failure this
10
+ * whole feature exists to prevent.
11
+ */
12
+ /**
13
+ * Fallback ratio for messages with no measured anchor nearby. Shared with
14
+ * `condense-history.ts`, which uses it for the same purpose (reporting bytes
15
+ * shed as tokens) — one definition so the two never drift.
16
+ */
17
+ export const APPROX_CHARS_PER_TOKEN = 4;
18
+ /**
19
+ * Flat per-image allowance. Both vendors price an image by its pixel area
20
+ * (Anthropic ≈ `w × h / 750`), and a `ChatImageAttachment` carries only base64
21
+ * bytes — no dimensions — so there is nothing to compute from. This stands in
22
+ * for a mid-size screenshot and is intentionally on the high side: an image is
23
+ * the one payload compaction cannot shrink (it is dropped, never summarized),
24
+ * so under-counting images is exactly how a projected reclaim comes up short.
25
+ */
26
+ const IMAGE_TOKEN_ESTIMATE = 1600;
27
+ /** Per-message role/framing overhead every transport adds around the content. */
28
+ const PER_MESSAGE_FRAMING_TOKENS = 4;
29
+ /**
30
+ * Allowance for the summary a compaction will produce, used when projecting the
31
+ * post-compaction size before the summarizer has run. The system prompt asks for
32
+ * concise prose, but the length is the model's choice, so this is a ceiling
33
+ * rather than an average — a projection that under-estimates the summary is a
34
+ * gate that promises a reclaim it cannot deliver.
35
+ */
36
+ export const COMPACTION_SUMMARY_TOKEN_ESTIMATE = 1200;
37
+ /**
38
+ * Whether a message contributes to the provider request at all.
39
+ *
40
+ * Exported because the cut logic needs the same answer: a boundary is judged by
41
+ * what the provider will actually see, so a display-only row sitting between an
42
+ * `assistant(toolCalls)` and its results must not be mistaken for a safe split.
43
+ *
44
+ * Mirrors what the request-build chokepoint and the transports actually send:
45
+ * `system-event` / `synthetic-user` are display-only rows, and the
46
+ * reasoning/narration splits (GENC-1410, plus the legacy `thinking` flag they
47
+ * superseded) are skipped when building the request. Counting any of them would
48
+ * inflate every estimate against a prompt that never carries them.
49
+ */
50
+ export function reachesProvider(m) {
51
+ if (m.role === 'system-event' || m.role === 'synthetic-user')
52
+ return false;
53
+ if (m.category === 'reasoning' || m.category === 'narration')
54
+ return false;
55
+ return !m.thinking;
56
+ }
57
+ /** Images on a message — both the user-attached kind and the kind a tool handed back. */
58
+ function imageCount(m) {
59
+ var _a, _b, _c, _d, _e;
60
+ const onMessage = (_b = (_a = m.attachments) === null || _a === void 0 ? void 0 : _a.filter((a) => a.kind === 'image').length) !== null && _b !== void 0 ? _b : 0;
61
+ const onResult = (_e = (_d = (_c = m.toolResult) === null || _c === void 0 ? void 0 : _c.attachments) === null || _d === void 0 ? void 0 : _d.filter((a) => a.kind === 'image').length) !== null && _e !== void 0 ? _e : 0;
62
+ return onMessage + onResult;
63
+ }
64
+ /** Framing a text attachment adds on the wire: `[File: ` + `]\n`. */
65
+ const TEXT_ATTACHMENT_FRAMING_CHARS = 9;
66
+ /**
67
+ * Characters this message puts on the wire: prose, text-attachment contents,
68
+ * tool-call names and serialized arguments, and tool-result content.
69
+ *
70
+ * **Text attachments are counted here, not by `imageCount`.** A `kind: 'text'`
71
+ * attachment is sent verbatim as `[File: <name>]\n<content>` — it is ordinary
72
+ * prompt text, and a picked file can be megabytes of it. Charging only for
73
+ * images made the single largest sanctioned input invisible to every estimate,
74
+ * so both the composer gate and the mid-loop guard waved it straight through:
75
+ * the exact overrun they exist to catch. Note `kind` is OPTIONAL on the text arm
76
+ * (attachments authored before images existed carry none), so anything that is
77
+ * not explicitly an image is text.
78
+ *
79
+ * `toolResult.attachments` is deliberately not walked for text: that field is
80
+ * typed image-only, because a text attachment there would be indistinguishable
81
+ * from `toolResult.content` and the transports drop it rather than double-send.
82
+ *
83
+ * `toolCall.subAgentTrace` is deliberately not counted either — it hangs off the
84
+ * tool call for the UI's benefit and is never sent, so counting it would
85
+ * attribute a whole sub-agent transcript to the parent's context.
86
+ */
87
+ function wireChars(m) {
88
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
89
+ let chars = (_b = (_a = m.content) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0;
90
+ for (const attachment of (_c = m.attachments) !== null && _c !== void 0 ? _c : []) {
91
+ if (attachment.kind === 'image')
92
+ continue;
93
+ chars +=
94
+ TEXT_ATTACHMENT_FRAMING_CHARS + attachment.name.length + ((_e = (_d = attachment.content) === null || _d === void 0 ? void 0 : _d.length) !== null && _e !== void 0 ? _e : 0);
95
+ }
96
+ for (const tc of (_f = m.toolCalls) !== null && _f !== void 0 ? _f : []) {
97
+ chars += tc.name.length;
98
+ try {
99
+ chars += JSON.stringify((_g = tc.args) !== null && _g !== void 0 ? _g : {}).length;
100
+ }
101
+ catch (_l) {
102
+ // Circular or otherwise unserializable args: skip rather than throw. The
103
+ // framing allowance below still counts the call itself.
104
+ }
105
+ }
106
+ chars += (_k = (_j = (_h = m.toolResult) === null || _h === void 0 ? void 0 : _h.content) === null || _j === void 0 ? void 0 : _j.length) !== null && _k !== void 0 ? _k : 0;
107
+ return chars;
108
+ }
109
+ /** Char-derived token estimate for one message, before any measured calibration. */
110
+ function baselineTokens(m) {
111
+ if (!reachesProvider(m))
112
+ return 0;
113
+ return (Math.ceil(wireChars(m) / APPROX_CHARS_PER_TOKEN) +
114
+ imageCount(m) * IMAGE_TOKEN_ESTIMATE +
115
+ PER_MESSAGE_FRAMING_TOKENS);
116
+ }
117
+ /**
118
+ * True creation time of the compaction that produced this history, if any.
119
+ *
120
+ * Read from `compaction.createdAt` rather than the summary's `timestamp`, which
121
+ * is deliberately backdated to the covered region's start so the summary sorts to
122
+ * the head of chronological views.
123
+ */
124
+ function compactedAtOf(history) {
125
+ var _a, _b;
126
+ return (_b = (_a = history.find((m) => m.role === 'compacted-summary')) === null || _a === void 0 ? void 0 : _a.compaction) === null || _b === void 0 ? void 0 : _b.createdAt;
127
+ }
128
+ /**
129
+ * Whether a message's `inputTokens` still describes the history being sent.
130
+ *
131
+ * A measurement is only meaningful against the history that produced it, and
132
+ * compaction rewrites that history underneath the tail it keeps. Every retained
133
+ * message was appended before the compaction ran, so an older measurement is
134
+ * describing a transcript that no longer exists — and it is describing a much
135
+ * BIGGER one, which is what made trusting it a dead end rather than a rounding
136
+ * error (GENC-1567).
137
+ *
138
+ * A measurement with no timestamp is rejected once a compaction is present: its
139
+ * age is unknowable, and falling back to the character estimate is the safer of
140
+ * the two wrong answers.
141
+ */
142
+ function usableAnchor(m, compactedAt) {
143
+ if (m.inputTokens == null)
144
+ return false;
145
+ if (!compactedAt)
146
+ return true;
147
+ return !!m.timestamp && m.timestamp >= compactedAt;
148
+ }
149
+ /** Index of the first message carrying a usable measurement, or `-1`. */
150
+ function firstAnchor(history, compactedAt) {
151
+ return history.findIndex((m) => usableAnchor(m, compactedAt));
152
+ }
153
+ /**
154
+ * Whether anything about this transcript's size is knowable.
155
+ *
156
+ * The gate's "should I have an opinion at all?" question. Deliberately broader
157
+ * than "is there a USABLE anchor": a compaction invalidates every retained
158
+ * measurement, which is the normal state immediately after one runs, and gating
159
+ * the recompute on a usable anchor there meant no assignment at all — leaving a
160
+ * blocked figure latched on a conversation that had just shrunk. That is the very
161
+ * dead end this machinery exists to prevent, so the question has to be whether the
162
+ * transcript was EVER measured, not whether the measurement is still good.
163
+ *
164
+ * When it is true, {@link estimateContextTokens} is the figure to use — it drops
165
+ * the invalidated anchors itself and falls back to characters, which is still the
166
+ * truth about the current history and, crucially, is not the stale number.
167
+ *
168
+ * A `compacted-summary` counts on its own: a conversation only reaches one by
169
+ * being large, and a compaction can summarise away every measured turn.
170
+ *
171
+ * When it is false nothing has ever reported usage, and the honest answer is to
172
+ * leave the gate inert rather than let a character estimate invent a percentage.
173
+ */
174
+ export function isContextMeasurable(history) {
175
+ return history.some((m) => m.inputTokens != null || m.role === 'compacted-summary');
176
+ }
177
+ /**
178
+ * Per-message token estimates, calibrated against measured prompt sizes wherever
179
+ * the transcript provides them.
180
+ *
181
+ * **How the calibration works.** `inputTokens` on the message produced by a call
182
+ * is the size of that call's WHOLE prompt (the cache buckets break it down
183
+ * rather than add to it), so it covers `history[0 … i-1]`. Two consecutive
184
+ * anchors therefore bracket a segment whose exact measured cost is their
185
+ * difference: `inputTokens[b] − inputTokens[a]` is what `history[a … b-1]` cost.
186
+ * Within a segment the measured total is redistributed across its messages in
187
+ * proportion to their baseline estimate — segment granularity is all that is
188
+ * needed, since compaction only ever cuts on boundaries.
189
+ *
190
+ * A non-positive delta means history SHRANK between the two calls (a compaction
191
+ * or a `condenseWhen` collapse landed in between); there is nothing to
192
+ * distribute, so those messages keep their baseline.
193
+ *
194
+ * The region before the first anchor keeps its baseline too: that anchor's
195
+ * measurement also contains the system prompt and tool definitions, which are
196
+ * not attributable to any message — see {@link estimateSystemOverhead}.
197
+ */
198
+ export function estimateMessageTokens(history) {
199
+ const estimates = history.map(baselineTokens);
200
+ const compactedAt = compactedAtOf(history);
201
+ const anchors = [];
202
+ history.forEach((m, i) => {
203
+ if (usableAnchor(m, compactedAt))
204
+ anchors.push(i);
205
+ });
206
+ for (let a = 0; a < anchors.length - 1; a += 1) {
207
+ const from = anchors[a];
208
+ const to = anchors[a + 1];
209
+ const measured = history[to].inputTokens - history[from].inputTokens;
210
+ if (measured <= 0)
211
+ continue;
212
+ let baseline = 0;
213
+ for (let i = from; i < to; i += 1)
214
+ baseline += estimates[i];
215
+ if (baseline <= 0) {
216
+ // Nothing to scale against (an all-display-only segment): put the measured
217
+ // cost on the first message rather than losing it.
218
+ estimates[from] = measured;
219
+ continue;
220
+ }
221
+ // Rescale, then put the rounding residual on the last message of the segment
222
+ // so the segment sums to EXACTLY what was measured. Without it the per-message
223
+ // rounding drifts, and every projection inherits the drift of every segment
224
+ // before it.
225
+ const scale = measured / baseline;
226
+ let assigned = 0;
227
+ for (let i = from; i < to - 1; i += 1) {
228
+ estimates[i] = Math.round(estimates[i] * scale);
229
+ assigned += estimates[i];
230
+ }
231
+ estimates[to - 1] = measured - assigned;
232
+ }
233
+ return estimates;
234
+ }
235
+ /**
236
+ * Context consumed by everything that is NOT a message — the system prompt and
237
+ * the tool definitions.
238
+ *
239
+ * Derived rather than guessed: the first measured prompt covers the messages
240
+ * before it plus that fixed preamble, so subtracting our estimate of those
241
+ * messages leaves the preamble. Floored at zero, since an over-estimate of the
242
+ * early messages would otherwise produce a negative overhead.
243
+ *
244
+ * It matters for the gate because it is real context the user never sees in the
245
+ * transcript, it does not shrink when history does, and on an agent with a large
246
+ * tool surface it is far from negligible.
247
+ */
248
+ export function estimateSystemOverhead(history) {
249
+ const first = firstAnchor(history, compactedAtOf(history));
250
+ if (first < 0)
251
+ return 0;
252
+ const estimates = estimateMessageTokens(history);
253
+ let messages = 0;
254
+ for (let i = 0; i < first; i += 1)
255
+ messages += estimates[i];
256
+ return Math.max(0, history[first].inputTokens - messages);
257
+ }
258
+ /**
259
+ * Total estimated context for a transcript — the preamble plus every message.
260
+ *
261
+ * On a transcript with measured anchors this lands close to the last observed
262
+ * `inputTokens` by construction; the value of computing it rather than reading
263
+ * that figure is that it stays defined for a hypothetical history (the tail a
264
+ * compaction would leave behind), which is what {@link projectCompaction} needs.
265
+ */
266
+ export function estimateContextTokens(history) {
267
+ const estimates = estimateMessageTokens(history);
268
+ let total = estimateSystemOverhead(history);
269
+ for (const t of estimates)
270
+ total += t;
271
+ return total;
272
+ }
273
+ /**
274
+ * Estimated size of the request that will actually be SENT, given the stored
275
+ * history it was derived from.
276
+ *
277
+ * The two differ by design and can differ by a lot. `condenseWhen` replaces spent
278
+ * tool payloads with short stubs, and multi-agent masking blanks another agent's
279
+ * payloads — both on a copy, leaving stored history full-fat for the UI and the
280
+ * log. A guard that measured stored history would therefore refuse turns whose
281
+ * real request is a fraction of the size.
282
+ *
283
+ * Naively estimating the transformed copy does NOT work either, and the reason is
284
+ * subtle: `inputTokens` anchors live on the stored messages and measured the
285
+ * untransformed prompt, so calibrating against them re-inflates the estimate back
286
+ * to roughly what it was before the transform — exactly the figure we were trying
287
+ * to avoid.
288
+ *
289
+ * So the calibrated total is scaled by how much smaller the request is in
290
+ * characters. That keeps the accuracy the measurements buy while still reflecting
291
+ * what the transform removed. The preamble is carried across unscaled: the system
292
+ * prompt and tool definitions are not what condensation touches.
293
+ */
294
+ export function estimateRequestTokens(storedHistory, requestHistory) {
295
+ const overhead = estimateSystemOverhead(storedHistory);
296
+ const calibratedMessages = Math.max(0, estimateContextTokens(storedHistory) - overhead);
297
+ let storedBaseline = 0;
298
+ for (const m of storedHistory)
299
+ storedBaseline += baselineTokens(m);
300
+ let requestBaseline = 0;
301
+ for (const m of requestHistory)
302
+ requestBaseline += baselineTokens(m);
303
+ // No stored baseline to scale against (an all-display-only history): the
304
+ // request's own baseline is the best available answer.
305
+ if (storedBaseline <= 0)
306
+ return overhead + requestBaseline;
307
+ return Math.round(overhead + calibratedMessages * (requestBaseline / storedBaseline));
308
+ }
309
+ /**
310
+ * Project the effect of compacting at `cut`, WITHOUT running the summarizer.
311
+ *
312
+ * This is what lets a gate tell the difference between "compaction will unblock
313
+ * you" and "compaction will spend a call and leave you exactly where you are" —
314
+ * a distinction the old boolean `canCompact()` could not express, because it
315
+ * answered only whether a legal cut existed. A legal cut that covers four short
316
+ * messages while a large tool loop sits in the tail is legal and useless.
317
+ *
318
+ * The system preamble is carried across unchanged: compaction removes messages,
319
+ * never the system prompt or the tool definitions.
320
+ */
321
+ export function projectCompaction(history, cut, summaryTokens = COMPACTION_SUMMARY_TOKEN_ESTIMATE) {
322
+ const estimates = estimateMessageTokens(history);
323
+ const overhead = estimateSystemOverhead(history);
324
+ let tail = 0;
325
+ for (let i = cut; i < estimates.length; i += 1)
326
+ tail += estimates[i];
327
+ let all = 0;
328
+ for (const t of estimates)
329
+ all += t;
330
+ const tokensBefore = overhead + all;
331
+ const tokensAfter = overhead + summaryTokens + tail;
332
+ return {
333
+ cut,
334
+ compactedCount: cut,
335
+ tokensBefore,
336
+ tokensAfter,
337
+ reclaimed: Math.max(0, tokensBefore - tokensAfter),
338
+ };
339
+ }
@@ -1,4 +1,5 @@
1
1
  import { __rest } from "tslib";
2
+ import { estimateMessageTokens, projectCompaction, reachesProvider, } from './context-tokens';
2
3
  /**
3
4
  * Masks the tool-specific payload of a single message — clears tool call args
4
5
  * and replaces tool result content with a placeholder. Used when passing history
@@ -40,32 +41,113 @@ export function applyHistoryCap(history, cap) {
40
41
  const cutoff = history.length - cap;
41
42
  return history.map((msg, i) => (i < cutoff ? maskToolPayload(msg) : msg));
42
43
  }
43
- // ───────────────────────────── Compaction (GENC-1351 §5.7) ─────────────────────────────
44
- /** Recent messages kept verbatim when compacting; everything earlier is summarized. */
45
- export const COMPACT_KEEP_RECENT_MESSAGES = 4;
46
- /** Don't bother compacting unless at least this many messages would be summarized. */
47
- export const COMPACT_MIN_MESSAGES_TO_COMPACT = 4;
44
+ // ───────────────────────────── Compaction (GENC-1351 §5.7, GENC-1567) ─────────────────────────────
48
45
  /**
49
- * Index at which the verbatim tail begins — everything before it gets summarized.
50
- * The tail starts on a clean turn boundary (a `user` message): a user message
51
- * never appears between a `tool_use` and its `tool_result`, so cutting there
52
- * guarantees the summarized region is tool-pair-balanced (compaction only runs
53
- * when idle, so history is turn-complete). Returns `null` when there is nothing
54
- * worth compacting (too short, or no earlier user boundary) — e.g. a freshly
55
- * restored session whose only user turn is the current one.
46
+ * Default size of the verbatim tail a compaction leaves behind, in tokens —
47
+ * MESSAGES only, since the system preamble is not something compaction can
48
+ * remove. A caller with a resolved context limit should pass a budget derived
49
+ * from it rather than relying on this.
56
50
  */
57
- export function findCompactionCut(history) {
58
- if (history.length < COMPACT_KEEP_RECENT_MESSAGES + COMPACT_MIN_MESSAGES_TO_COMPACT) {
51
+ export const DEFAULT_TAIL_TOKEN_BUDGET = 30000;
52
+ /**
53
+ * Default floor on what a compaction must reclaim to be worth running. Below
54
+ * this the summarizer call costs more than the space it buys — and, crucially,
55
+ * a gate that offered compaction as its escape would be promising a reclaim
56
+ * that does not clear the gate.
57
+ */
58
+ export const DEFAULT_MIN_RECLAIM_TOKENS = 5000;
59
+ /**
60
+ * Whether cutting at `i` — summarizing `[0, i)` and keeping `[i, end)` verbatim
61
+ * — leaves a request the provider will accept.
62
+ *
63
+ * The one hard constraint is tool pairing: a `tool_result` whose `tool_use` was
64
+ * summarized away is rejected outright by every provider. So the first message
65
+ * the tail actually SENDS must not be a tool result.
66
+ *
67
+ * This replaces the old "cut only on a `user` message" rule (GENC-1351), which
68
+ * was a proxy for the same invariant — a user message never appears between a
69
+ * tool call and its results, so it was guaranteed safe without any pairing
70
+ * check. The proxy was sound but far too narrow: an agentic tool loop contains
71
+ * no user messages at all, so the whole loop was one indivisible block and a
72
+ * runaway turn could not be compacted at any point. Checking the invariant
73
+ * directly opens up every gap between tool groups (GENC-1567).
74
+ *
75
+ * Display-only rows are skipped when looking for that first sent message: a
76
+ * `system-event` (e.g. "Stopped.") sitting between an `assistant(toolCalls)` and
77
+ * its results reaches no transport, so cutting in front of it would still orphan
78
+ * the results behind it.
79
+ */
80
+ export function isSafeCompactionCut(history, i) {
81
+ if (i <= 0 || i >= history.length)
82
+ return false;
83
+ for (let j = i; j < history.length; j += 1) {
84
+ if (!reachesProvider(history[j]))
85
+ continue;
86
+ return history[j].role !== 'tool';
87
+ }
88
+ // A tail of nothing but display-only rows sends no tool result, so nothing can
89
+ // be orphaned.
90
+ return true;
91
+ }
92
+ /**
93
+ * Index at which the verbatim tail begins — everything before it gets summarized
94
+ * — chosen so the tail fits `tailTokenBudget` (GENC-1567).
95
+ *
96
+ * Walks back from the end accumulating estimated tokens until the next message
97
+ * would overrun the budget, then snaps FORWARD to the nearest safe boundary.
98
+ * Forward is the correct direction: it can only shrink the tail, so the budget
99
+ * still holds after the snap, whereas snapping backwards could blow it.
100
+ *
101
+ * Returns `null` when the whole transcript already fits (nothing worth doing) or
102
+ * when no safe boundary exists ahead of the walk — e.g. a tail that is one
103
+ * enormous, indivisible tool group, which no cut can shrink because a cut cannot
104
+ * split a message.
105
+ */
106
+ export function findCompactionCut(history, tailTokenBudget = DEFAULT_TAIL_TOKEN_BUDGET) {
107
+ const estimates = estimateMessageTokens(history);
108
+ let messagesTotal = 0;
109
+ for (const t of estimates)
110
+ messagesTotal += t;
111
+ if (messagesTotal <= tailTokenBudget)
59
112
  return null;
113
+ let tail = 0;
114
+ let raw = history.length;
115
+ for (let i = history.length - 1; i > 0; i -= 1) {
116
+ if (tail + estimates[i] > tailTokenBudget)
117
+ break;
118
+ tail += estimates[i];
119
+ raw = i;
60
120
  }
61
- const target = history.length - COMPACT_KEEP_RECENT_MESSAGES;
62
- for (let i = target; i > 0; i -= 1) {
63
- if (history[i].role === 'user') {
64
- return i >= COMPACT_MIN_MESSAGES_TO_COMPACT ? i : null;
65
- }
121
+ for (let i = raw; i < history.length; i += 1) {
122
+ if (isSafeCompactionCut(history, i))
123
+ return i;
66
124
  }
67
125
  return null;
68
126
  }
127
+ /**
128
+ * The single answer to "what would compacting do right now, and is it worth it?"
129
+ *
130
+ * Every consumer reads this one function — the menu's enabled state, its
131
+ * tooltip, the gate's banner copy, and the action itself — so the affordance can
132
+ * never promise something the action does not deliver. That is the same
133
+ * one-source-of-truth argument that produced `canCompact()` (the GENC-1351
134
+ * follow-up which stopped the gate reading a different history from the action);
135
+ * this extends it from *legality* to *sufficiency*, which is what a blocking
136
+ * gate needs. A legal cut that covers four short messages while a large tool
137
+ * loop sits in the tail is legal and useless, and the old boolean could not say
138
+ * so.
139
+ *
140
+ * Returns `null` when there is no safe cut, or when the projected reclaim does
141
+ * not clear `minReclaimTokens`.
142
+ */
143
+ export function planCompaction(history, options = {}) {
144
+ const { tailTokenBudget = DEFAULT_TAIL_TOKEN_BUDGET, minReclaimTokens = DEFAULT_MIN_RECLAIM_TOKENS, } = options;
145
+ const cut = findCompactionCut(history, tailTokenBudget);
146
+ if (cut == null)
147
+ return null;
148
+ const projection = projectCompaction(history, cut);
149
+ return projection.reclaimed >= minReclaimTokens ? projection : null;
150
+ }
69
151
  /** One-line rendering of a message for the summarizer transcript. */
70
152
  function renderMessageForSummary(m) {
71
153
  var _a;