@genesislcap/ai-assistant 15.19.6 → 15.20.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 (42) hide show
  1. package/dist/ai-assistant.api.json +605 -72
  2. package/dist/ai-assistant.d.ts +404 -25
  3. package/dist/chat-driver.cjs +341 -28
  4. package/dist/chat-driver.cjs.map +4 -4
  5. package/dist/chat-driver.mjs +341 -28
  6. package/dist/chat-driver.mjs.map +4 -4
  7. package/dist/custom-elements.json +630 -20
  8. package/dist/dts/components/ai-driver/ai-driver.d.ts +33 -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 +63 -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 +9 -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/utils/condense-history.d.ts.map +1 -1
  21. package/dist/dts/utils/context-tokens.d.ts +156 -0
  22. package/dist/dts/utils/context-tokens.d.ts.map +1 -0
  23. package/dist/dts/utils/history-transform.d.ts +76 -14
  24. package/dist/dts/utils/history-transform.d.ts.map +1 -1
  25. package/dist/dts/utils/resolve-context-budget.d.ts +98 -0
  26. package/dist/dts/utils/resolve-context-budget.d.ts.map +1 -0
  27. package/dist/esm/components/chat-driver/chat-driver.js +179 -34
  28. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +12 -4
  29. package/dist/esm/main/main.js +391 -21
  30. package/dist/esm/main/main.styles.js +128 -0
  31. package/dist/esm/main/main.template.js +64 -29
  32. package/dist/esm/state/debug-event-log.js +1 -1
  33. package/dist/esm/utils/condense-history.js +1 -5
  34. package/dist/esm/utils/context-tokens.js +339 -0
  35. package/dist/esm/utils/history-transform.js +101 -19
  36. package/dist/esm/utils/resolve-context-budget.js +84 -0
  37. package/package.json +16 -16
  38. package/sandbox/README.md +93 -4
  39. package/sandbox/controls.ts +77 -10
  40. package/sandbox/fixtures.ts +163 -6
  41. package/sandbox/sandbox.css +54 -1
  42. package/sandbox/sandbox.ts +384 -7
@@ -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;
@@ -0,0 +1,84 @@
1
+ import { COMPACTION_SUMMARY_TOKEN_ESTIMATE } from './context-tokens';
2
+ // Re-exported, not redeclared: this module and `planCompaction` must apply the
3
+ // SAME worth-it floor, or a host passing explicit options and a caller relying
4
+ // on the module default would silently disagree about whether a compaction is
5
+ // worth running. `history-transform` owns it because that is where the plan is
6
+ // made; this module only threads it through.
7
+ import { DEFAULT_MIN_RECLAIM_TOKENS } from './history-transform';
8
+ export { DEFAULT_MIN_RECLAIM_TOKENS };
9
+ /**
10
+ * Context headroom thresholds (GENC-1567) — resolving `chatConfig.context`,
11
+ * the active model's window, and any per-agent reserve into the four numbers
12
+ * the gate and the compaction planner actually read.
13
+ */
14
+ /**
15
+ * Tokens kept free for a single turn by default.
16
+ *
17
+ * A turn is not atomic in cost: a send allowed just under a threshold can run a
18
+ * long tool loop and add a great deal on its own, so what has to be bounded is
19
+ * the growth of ONE turn — a token count, not a fraction of the window. On a
20
+ * 200k model this leaves ~12% free; on a 1M model the same absolute room, rather
21
+ * than the 200k a percentage rule would strand.
22
+ *
23
+ * Chosen as a starting position, not a measured figure. Per-turn `inputTokens`
24
+ * is recorded for every call, so this should be revisited against real traffic.
25
+ */
26
+ export const DEFAULT_RESERVE_TOKENS = 25000;
27
+ /** Warning appears this many reserves ahead of the block. */
28
+ export const DEFAULT_WARN_MULTIPLIER = 2;
29
+ /**
30
+ * How close to the window a running turn is allowed to get before the driver
31
+ * stops it (GENC-1567). Roughly one more tool result plus a reply — enough that
32
+ * the next request would very likely be refused.
33
+ */
34
+ export const CONTEXT_ABORT_MARGIN_TOKENS = 4096;
35
+ /**
36
+ * Resolve the headroom thresholds for the active model and agent.
37
+ *
38
+ * Everything downstream — the warning, the block, the compaction planner and the
39
+ * banner copy — reads this one result, so the thresholds a user is judged
40
+ * against and the budget compaction aims at can never disagree.
41
+ */
42
+ export function resolveContextBudget(input) {
43
+ var _a, _b, _c;
44
+ const { chatConfig, contextLimit, agentReserveTokens, systemOverhead = 0 } = input;
45
+ const cfg = chatConfig.context;
46
+ const reserveTokens = Math.max(0, (_a = agentReserveTokens !== null && agentReserveTokens !== void 0 ? agentReserveTokens : cfg === null || cfg === void 0 ? void 0 : cfg.reserveTokens) !== null && _a !== void 0 ? _a : DEFAULT_RESERVE_TOKENS);
47
+ const warnMultiplier = Math.max(1, (_b = cfg === null || cfg === void 0 ? void 0 : cfg.warnMultiplier) !== null && _b !== void 0 ? _b : DEFAULT_WARN_MULTIPLIER);
48
+ const minReclaimTokens = Math.max(0, (_c = cfg === null || cfg === void 0 ? void 0 : cfg.minReclaimTokens) !== null && _c !== void 0 ? _c : DEFAULT_MIN_RECLAIM_TOKENS);
49
+ const limit = contextLimit !== null && contextLimit !== void 0 ? contextLimit : cfg === null || cfg === void 0 ? void 0 : cfg.fallbackContextLimit;
50
+ // No limit → no thresholds. The gate stays inert rather than guessing a window
51
+ // and blocking the composer against the guess.
52
+ if (limit == null || limit <= 0) {
53
+ return {
54
+ enabled: false,
55
+ limit: undefined,
56
+ reserveTokens,
57
+ tailTokenBudget: Math.max(0, reserveTokens),
58
+ minReclaimTokens,
59
+ };
60
+ }
61
+ const enabled = (cfg === null || cfg === void 0 ? void 0 : cfg.enabled) !== false;
62
+ const blockAt = Math.max(0, limit - reserveTokens);
63
+ const warnAt = Math.max(0, limit - warnMultiplier * reserveTokens);
64
+ // Aim compaction at the warning line, and leave room for the summary itself.
65
+ // Floored at one reserve so a pathological config (a reserve wider than the
66
+ // window) still asks for a tail rather than an impossible zero.
67
+ const tailTokenBudget = Math.max(reserveTokens, warnAt - systemOverhead - COMPACTION_SUMMARY_TOKEN_ESTIMATE);
68
+ return {
69
+ enabled,
70
+ limit,
71
+ reserveTokens,
72
+ warnAt,
73
+ blockAt,
74
+ tailTokenBudget,
75
+ minReclaimTokens,
76
+ // Withheld when the host opted out. `enabled: false` is documented as "no
77
+ // warning and no block", and a host that would rather take the provider's
78
+ // answer than ever have its composer disabled means the mid-loop guard too —
79
+ // otherwise the visible gate disappears as asked while a long tool loop still
80
+ // dies with `context-exhausted`, which is the same refusal wearing a
81
+ // different hat.
82
+ abortAt: enabled ? Math.max(blockAt, limit - CONTEXT_ABORT_MARGIN_TOKENS) : undefined,
83
+ };
84
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@genesislcap/ai-assistant",
3
3
  "description": "Genesis AI Assistant micro-frontend",
4
- "version": "15.19.6",
4
+ "version": "15.20.0",
5
5
  "license": "SEE LICENSE IN license.txt",
6
6
  "main": "dist/esm/index.js",
7
7
  "types": "dist/ai-assistant.d.ts",
@@ -74,26 +74,26 @@
74
74
  }
75
75
  },
76
76
  "devDependencies": {
77
- "@genesislcap/foundation-testing": "15.19.6",
78
- "@genesislcap/genx": "15.19.6",
79
- "@genesislcap/rollup-builder": "15.19.6",
80
- "@genesislcap/ts-builder": "15.19.6",
81
- "@genesislcap/uvu-playwright-builder": "15.19.6",
82
- "@genesislcap/vite-builder": "15.19.6",
83
- "@genesislcap/webpack-builder": "15.19.6",
77
+ "@genesislcap/foundation-testing": "15.20.0",
78
+ "@genesislcap/genx": "15.20.0",
79
+ "@genesislcap/rollup-builder": "15.20.0",
80
+ "@genesislcap/ts-builder": "15.20.0",
81
+ "@genesislcap/uvu-playwright-builder": "15.20.0",
82
+ "@genesislcap/vite-builder": "15.20.0",
83
+ "@genesislcap/webpack-builder": "15.20.0",
84
84
  "@types/dompurify": "^3.0.5",
85
85
  "@types/marked": "^5.0.2",
86
86
  "esbuild": "0.25.12"
87
87
  },
88
88
  "dependencies": {
89
- "@genesislcap/foundation-ai": "15.19.6",
90
- "@genesislcap/foundation-logger": "15.19.6",
91
- "@genesislcap/foundation-notifications": "15.19.6",
92
- "@genesislcap/foundation-redux": "15.19.6",
93
- "@genesislcap/foundation-ui": "15.19.6",
94
- "@genesislcap/foundation-utils": "15.19.6",
95
- "@genesislcap/rapid-design-system": "15.19.6",
96
- "@genesislcap/web-core": "15.19.6",
89
+ "@genesislcap/foundation-ai": "15.20.0",
90
+ "@genesislcap/foundation-logger": "15.20.0",
91
+ "@genesislcap/foundation-notifications": "15.20.0",
92
+ "@genesislcap/foundation-redux": "15.20.0",
93
+ "@genesislcap/foundation-ui": "15.20.0",
94
+ "@genesislcap/foundation-utils": "15.20.0",
95
+ "@genesislcap/rapid-design-system": "15.20.0",
96
+ "@genesislcap/web-core": "15.20.0",
97
97
  "dompurify": "^3.3.1",
98
98
  "marked": "^17.0.3"
99
99
  },