@genesislcap/ai-assistant 15.23.0 → 15.23.1-FUI-0.4

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 (33) hide show
  1. package/dist/ai-assistant.api.json +269 -5
  2. package/dist/ai-assistant.d.ts +256 -31
  3. package/dist/chat-driver.cjs +125 -58
  4. package/dist/chat-driver.cjs.map +2 -2
  5. package/dist/chat-driver.mjs +125 -58
  6. package/dist/chat-driver.mjs.map +2 -2
  7. package/dist/custom-elements.json +125 -6
  8. package/dist/dts/components/ai-driver/ai-driver.d.ts +63 -0
  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 +7 -0
  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 -1
  13. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  14. package/dist/dts/config/config.d.ts +53 -17
  15. package/dist/dts/config/config.d.ts.map +1 -1
  16. package/dist/dts/config/define-stateful-agent.d.ts +16 -1
  17. package/dist/dts/config/define-stateful-agent.d.ts.map +1 -1
  18. package/dist/dts/main/main.d.ts +102 -14
  19. package/dist/dts/main/main.d.ts.map +1 -1
  20. package/dist/dts/utils/context-tokens.d.ts +100 -43
  21. package/dist/dts/utils/context-tokens.d.ts.map +1 -1
  22. package/dist/dts/utils/history-transform.d.ts +7 -1
  23. package/dist/dts/utils/history-transform.d.ts.map +1 -1
  24. package/dist/dts/utils/resolve-context-budget.d.ts +8 -5
  25. package/dist/dts/utils/resolve-context-budget.d.ts.map +1 -1
  26. package/dist/esm/components/chat-driver/chat-driver.js +22 -2
  27. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +92 -0
  28. package/dist/esm/config/define-stateful-agent.js +1 -0
  29. package/dist/esm/main/main.js +170 -27
  30. package/dist/esm/utils/context-tokens.js +331 -106
  31. package/dist/esm/utils/history-transform.js +5 -5
  32. package/dist/esm/utils/resolve-context-budget.js +37 -7
  33. package/package.json +16 -16
@@ -26,6 +26,90 @@ export const APPROX_CHARS_PER_TOKEN = 4;
26
26
  const IMAGE_TOKEN_ESTIMATE = 1600;
27
27
  /** Per-message role/framing overhead every transport adds around the content. */
28
28
  const PER_MESSAGE_FRAMING_TOKENS = 4;
29
+ /**
30
+ * Chars-per-token for the system prompt — prose, so the ordinary ratio applies.
31
+ */
32
+ const PREAMBLE_PROSE_CHARS_PER_TOKEN = 4;
33
+ /**
34
+ * Chars-per-token for the tool definitions — JSON, which tokenizes WORSE than
35
+ * prose. Punctuation-dense, short quoted keys, deep nesting: `{`, `"`, `:`, `[`
36
+ * mostly cost a token each where four characters of prose share one.
37
+ *
38
+ * **This ratio is REASONED, not measured** — see {@link estimatePreambleTokens}
39
+ * for exactly what was measured and what was not. It is deliberately the
40
+ * pessimistic side of the plausible 3–3.5 range: this figure feeds the guard that
41
+ * refuses an oversized request, and over-stating the preamble costs a little
42
+ * unused headroom where under-stating lets a turn overflow.
43
+ */
44
+ const PREAMBLE_JSON_CHARS_PER_TOKEN = 3;
45
+ /**
46
+ * Per-tool framing allowance, the tool-surface equivalent of
47
+ * {@link PER_MESSAGE_FRAMING_TOKENS}.
48
+ *
49
+ * What reaches the wire is not the JSON we serialize: every transport reshapes a
50
+ * `ChatToolDefinition` into its own vendor form (`input_schema` for Anthropic,
51
+ * `functionDeclarations` for Gemini) and wraps each one. Counting only the
52
+ * serialized bytes therefore misses a fixed cost per tool, which on an agent
53
+ * advertising a large surface is not negligible.
54
+ */
55
+ const PER_TOOL_FRAMING_TOKENS = 8;
56
+ /**
57
+ * Token cost of everything a request carries that is NOT a message — the system
58
+ * prompt, the tool definitions, and the framed tail (GENC-1582).
59
+ *
60
+ * **Why this is measured per call rather than derived.** The preamble used to be
61
+ * inferred as a residual: the first measured prompt minus the char estimate of the
62
+ * messages it covered. That derivation is accurate at the time, but it is taken
63
+ * ONCE and never updated, while the preamble changes every time the agent changes
64
+ * — each agent brings its own system prompt and its own tool surface. Every
65
+ * attempt to patch the staleness downstream made it worse; the cap that used to
66
+ * sit in `calibrate` compared stored CHARACTERS against a wire-measured TOKEN
67
+ * total and collapsed the preamble to zero on any condensed session, which is the
68
+ * same category error this module exists to fix. Measuring what is actually being
69
+ * sent removes the whole class of problem.
70
+ *
71
+ * **What is measured and what is not.** From the isa-platform session of
72
+ * 2026-09-03 (project 067f0fca, `claude-sonnet-5`), the first anchor measured
73
+ * 5,249 tokens against a single 290-character user message, so:
74
+ *
75
+ * | quantity | value | how |
76
+ * |---|---|---|
77
+ * | whole preamble, Bootstrap's first call | **5,173 tok** | MEASURED (5,249 − ~76 for the message) |
78
+ * | system prompt | **8,387 chars** | MEASURED (diag `turn` records it verbatim) |
79
+ * | system prompt in tokens | 2,097 | prose ratio applied to the measured chars |
80
+ * | 3 tool definitions | 3,076 tok (~1,025/tool) | **INFERRED as the residual** |
81
+ *
82
+ * The tools figure is a residual in TOKENS, not a ratio: diagnostics record
83
+ * `toolNames` only, never the schemas, so their character count is unavailable and
84
+ * `PREAMBLE_JSON_CHARS_PER_TOKEN` could not be calibrated from it. It is consistent
85
+ * with 3 — ~1,025 tokens per tool implies ~3,075 characters of schema per tool,
86
+ * which is the right order for a Create tool carrying descriptions and enum
87
+ * options — but consistent is not measured, and the next person should not read it
88
+ * as such. To calibrate it properly, log serialized schema characters alongside the
89
+ * first anchor for one real session and solve for the ratio.
90
+ *
91
+ * `tailContext` counts. It is regenerated per turn rather than accumulated, so
92
+ * compaction cannot remove it any more than it can remove the system prompt, which
93
+ * is the property that matters to every consumer of this figure.
94
+ */
95
+ export function estimatePreambleTokens(input) {
96
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
97
+ const prose = ((_b = (_a = input.systemPrompt) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0) + ((_d = (_c = input.tailContext) === null || _c === void 0 ? void 0 : _c.length) !== null && _d !== void 0 ? _d : 0);
98
+ let json = 0;
99
+ for (const def of (_e = input.toolDefinitions) !== null && _e !== void 0 ? _e : []) {
100
+ try {
101
+ json += JSON.stringify(def).length;
102
+ }
103
+ catch (_k) {
104
+ // Circular or otherwise unserializable: the framing allowance below still
105
+ // charges for the tool itself rather than dropping it silently.
106
+ json += (_g = (_f = def.name) === null || _f === void 0 ? void 0 : _f.length) !== null && _g !== void 0 ? _g : 0;
107
+ }
108
+ }
109
+ return (Math.ceil(prose / PREAMBLE_PROSE_CHARS_PER_TOKEN) +
110
+ Math.ceil(json / PREAMBLE_JSON_CHARS_PER_TOKEN) +
111
+ ((_j = (_h = input.toolDefinitions) === null || _h === void 0 ? void 0 : _h.length) !== null && _j !== void 0 ? _j : 0) * PER_TOOL_FRAMING_TOKENS);
112
+ }
29
113
  /**
30
114
  * Allowance for the summary a compaction will produce, used when projecting the
31
115
  * post-compaction size before the summarizer has run. The system prompt asks for
@@ -174,6 +258,190 @@ function usableAnchor(m, compactedAt) {
174
258
  function firstAnchor(history, compactedAt) {
175
259
  return history.findIndex((m) => usableAnchor(m, compactedAt));
176
260
  }
261
+ /** Index of the LAST message carrying a usable measurement, or `-1`. */
262
+ function lastAnchor(history, compactedAt) {
263
+ for (let i = history.length - 1; i >= 0; i -= 1) {
264
+ if (usableAnchor(history[i], compactedAt))
265
+ return i;
266
+ }
267
+ return -1;
268
+ }
269
+ /**
270
+ * The one calibration every estimate in this module is derived from (GENC-1582).
271
+ *
272
+ * **What `inputTokens` actually measures, and what it does not.** It is the size
273
+ * of the prompt a call really SENT — that is, the prompt after `condenseWhen` has
274
+ * stubbed spent tool payloads and per-agent masking has blanked another agent's.
275
+ * It is therefore an authoritative measurement of the wire, and no measurement of
276
+ * stored history exists at all. The first implementation of this module had that
277
+ * backwards (see the note left on {@link estimateRequestTokens}) and treated the
278
+ * anchors as describing untransformed history, which is where every figure here
279
+ * went wrong.
280
+ *
281
+ * **Why the old segment chain could not be repaired in place.** It charged each
282
+ * pair of consecutive anchors the DIFFERENCE between their measurements and kept
283
+ * the result, skipping the pair when the difference was negative. Growth was
284
+ * banked permanently; shrinkage was discarded. The total was consequently an
285
+ * integral of everything the conversation had ever grown by and rose
286
+ * monotonically, so no condensation, masking or compaction could ever bring it
287
+ * down. On a measured Create session it read 868,761 tokens — 87% of a 1M window
288
+ * — against a real last prompt of 107,928, having accumulated 857,712 of growth
289
+ * while throwing away 755,033 of shrinkage. Since the same figure gates the
290
+ * composer and, through `estimateRequestTokens`, the running-turn guard, it was
291
+ * on course to refuse work on a conversation using 11% of its window.
292
+ *
293
+ * **The model that replaced it.** Three facts, in the order they can be known:
294
+ *
295
+ * 1. The FIRST measurement is the only place the preamble is separable: it covers
296
+ * the preamble plus a handful of messages, so subtracting the char estimate of
297
+ * those messages leaves the preamble (see {@link estimateSystemOverhead}).
298
+ * 2. The LAST measurement is authoritative for everything before it — it is the
299
+ * most recent true statement about the size of this conversation — so the total
300
+ * is that figure plus the char estimate of whatever has been appended since.
301
+ * Nothing is chained, so nothing accumulates, and a condensation shows up as a
302
+ * smaller total on the very next call.
303
+ * 3. Per-message estimates are then shares of that total, in proportion to each
304
+ * message's CURRENT size. Proportional-to-baseline is what makes a stub cost
305
+ * what a stub costs: the old scheme pinned a segment to its historical measured
306
+ * cost, so shrinking a message inside it merely moved tokens to its neighbours.
307
+ *
308
+ * The accuracy this buys is worth stating, because the old code's docblocks argued
309
+ * the opposite trade-off. Replayed call-by-call over the session above, the total's
310
+ * median absolute error against the prompt actually sent next falls from 76% to
311
+ * 0.3%. It is least accurate in two situations, both bounded by one call's change
312
+ * rather than accumulating: it over-states while a condensation is pending (the
313
+ * last measurement still describes the fat prompt), and it under-states a call
314
+ * that loads a large block of context in one step (nothing in stored history
315
+ * predicts it). The second is what a headroom reserve is for, not an estimator.
316
+ *
317
+ * `scale` is deliberately not used to compute the total — only to distribute it.
318
+ * It is noisy exactly where stored history is a poor proxy for the wire, and
319
+ * keeping the total anchored on a measurement rather than on `scale * baseline`
320
+ * is what stops that noise reaching the gate.
321
+ */
322
+ function calibrate(history, preambleTokens) {
323
+ const baselines = history.map(baselineTokens);
324
+ let baselineTotal = 0;
325
+ for (const b of baselines)
326
+ baselineTotal += b;
327
+ const compactedAt = compactedAtOf(history);
328
+ const first = firstAnchor(history, compactedAt);
329
+ const last = lastAnchor(history, compactedAt);
330
+ // Nothing has ever reported usage, or a compaction invalidated every
331
+ // measurement. The char estimate is all there is for the MESSAGES — but a
332
+ // measured preamble is still authoritative, because it describes what was just
333
+ // built rather than being derived from an anchor. Only the derivation needs an
334
+ // anchor; a handed-in measurement does not, and discarding it here reported a
335
+ // preamble of zero on the two paths that reach this branch: a conversation
336
+ // before its first response, and the moment after a compaction (every retained
337
+ // message predates `compactedAt`, so no anchor survives). The second is the one
338
+ // that bit — `estimateRequestTokens` is the mid-turn overflow guard, and on a
339
+ // large tool surface it saw ~15 tokens instead of ~30,000.
340
+ //
341
+ // The preamble is added to `total` as well as reported as `overhead`, and both
342
+ // halves matter. In the anchored path below, `overhead` is carved OUT of a
343
+ // wire-measured total that already contains it; here there is no such total, so
344
+ // it has to be added or `messages` absorbs the difference. Reporting it in only
345
+ // one of the two places is worse than not reporting it at all: `projectCompaction`
346
+ // prices the preamble into `tokensAfter` and reads `total` for `tokensBefore`, so
347
+ // an overhead missing from the total makes every projection look like a net LOSS,
348
+ // `reclaimed` floors at zero, and `planCompaction` rejects the plan
349
+ // (`reclaimed >= minReclaimTokens`) — telling the user compaction cannot help on
350
+ // a transcript where it would reclaim plenty. Measured on the fixture in
351
+ // `context-tokens.test.ts`: reclaimed 8,814 → 0 with the total left alone.
352
+ //
353
+ // Nothing is invented: with no measurement passed this is exactly the old
354
+ // zero-overhead result, and `reclaimed` is unchanged either way, since the
355
+ // preamble then appears on both sides of `tokensBefore - tokensAfter`.
356
+ if (first < 0 || last < 0) {
357
+ const measured = Math.max(0, preambleTokens !== null && preambleTokens !== void 0 ? preambleTokens : 0);
358
+ return {
359
+ overhead: measured,
360
+ total: baselineTotal + measured,
361
+ estimates: baselines,
362
+ scale: 1,
363
+ };
364
+ }
365
+ // The last measurement covers `history[0 … last-1]`; the messages from `last`
366
+ // onwards rode no measured prompt yet, so they are added at their char estimate.
367
+ let sinceLast = 0;
368
+ for (let i = last; i < baselines.length; i += 1)
369
+ sinceLast += baselines[i];
370
+ const total = history[last].inputTokens + sinceLast;
371
+ let beforeFirst = 0;
372
+ for (let i = 0; i < first; i += 1)
373
+ beforeFirst += baselines[i];
374
+ // A MEASURED preamble wins outright (GENC-1582). Only the total bounds it: the
375
+ // preamble cannot exceed a prompt that contains it.
376
+ //
377
+ // It is deliberately NOT capped against `baselineTotal`, which is what the
378
+ // previous cap did and why it had to go. `baselineTotal` counts stored
379
+ // CHARACTERS — full-fat, every tool payload intact, because `condenseWhen` works
380
+ // on a copy — while `total` is anchored on a wire-measured TOKEN count of an
381
+ // already-condensed prompt. Mid-run the former dwarfs the latter, so
382
+ // `total - baselineTotal` went negative and clamped the preamble to zero on
383
+ // exactly the condensed sessions this module exists to serve. Measured: 0 against
384
+ // full-fat stored history, 5,000 against the same session's post-condensation
385
+ // export — which is why the export-based check missed it. Comparing stored
386
+ // characters with wire tokens is the category error the whole file is about.
387
+ //
388
+ // Without a measurement we still fall back to the residual derivation, which is
389
+ // accurate when taken and goes stale on handover — see
390
+ // {@link estimatePreambleTokens} for why the fix was to stop deriving it.
391
+ const derived = Math.max(0, history[first].inputTokens - beforeFirst);
392
+ // A MEASURED preamble is trusted (GENC-1582) — but only up to the point where it
393
+ // starts contradicting a measurement, which is where an earlier version of this
394
+ // got it wrong.
395
+ //
396
+ // `total` is anchored on a WIRE measurement, and that measurement contains the
397
+ // preamble AND the messages that rode with it. So a preamble at or above `total`,
398
+ // on a history that HAS messages, is arithmetically impossible: it is proof the
399
+ // ESTIMATE is too high, not a fact about the conversation. `estimatePreambleTokens`
400
+ // converts characters to tokens with reasoned divisors rather than a tokenizer, and
401
+ // measured live on a large tool surface it reads about 12% high — 34,325 against a
402
+ // whole prompt the provider measured at 30,608.
403
+ //
404
+ // Letting it win there zeroed `messages`, which zeroes every share, which nulls the
405
+ // compaction plan — so the gate tells the user compaction cannot help on a transcript
406
+ // where it would in fact reclaim plenty. That is reachable on ANY window: it needs
407
+ // only a condensation to shrink the prompt below the preamble, which is routine after
408
+ // a long tool loop.
409
+ //
410
+ // The comment this replaces claimed the zero-shares state "is precisely the `'model'`
411
+ // block cause". It is not, and the two are not even related: `contextBlockCause` keys
412
+ // off `contextBudget.blockAt === 0`, i.e. the reserve against the WINDOW, and never
413
+ // consults this figure. Conflating "preamble exceeds the window" (rare, and genuinely
414
+ // means the model cannot host the agent) with "preamble exceeds the current transcript"
415
+ // (routine) is what put a dead end on the common path.
416
+ //
417
+ // So when the estimate is contradicted, fall back to the residual derivation, which is
418
+ // itself computed from a measured prompt. Zero shares remain possible only when there
419
+ // is genuinely nothing to price.
420
+ const claimed = preambleTokens !== null && preambleTokens !== void 0 ? preambleTokens : derived;
421
+ const contradicted = baselineTotal > 0 && claimed >= total;
422
+ const overhead = Math.min(contradicted ? derived : claimed, total);
423
+ const messages = Math.max(0, total - overhead);
424
+ if (baselineTotal <= 0) {
425
+ return { overhead, total, estimates: baselines, scale: 1 };
426
+ }
427
+ const scale = messages / baselineTotal;
428
+ const estimates = baselines.map((b) => Math.round(b * scale));
429
+ // Land the rounding residual on the heaviest message so the shares sum to
430
+ // EXACTLY `messages`. Heaviest rather than last: its share is at least the mean,
431
+ // which is comfortably more than the residual can ever be (half a token per
432
+ // message), so the correction cannot drive an estimate negative. Without this
433
+ // the per-message rounding drifts and every projection inherits the drift.
434
+ let assigned = 0;
435
+ let heaviest = -1;
436
+ for (let i = 0; i < estimates.length; i += 1) {
437
+ assigned += estimates[i];
438
+ if (baselines[i] > 0 && (heaviest < 0 || baselines[i] > baselines[heaviest]))
439
+ heaviest = i;
440
+ }
441
+ if (heaviest >= 0)
442
+ estimates[heaviest] += messages - assigned;
443
+ return { overhead, total, estimates, scale };
444
+ }
177
445
  /**
178
446
  * Whether anything about this transcript's size is knowable.
179
447
  *
@@ -199,100 +467,55 @@ export function isContextMeasurable(history) {
199
467
  return history.some((m) => m.inputTokens != null || m.role === 'compacted-summary');
200
468
  }
201
469
  /**
202
- * Per-message token estimates, calibrated against measured prompt sizes wherever
203
- * the transcript provides them.
204
- *
205
- * **How the calibration works.** `inputTokens` on the message produced by a call
206
- * is the size of that call's WHOLE prompt (the cache buckets break it down
207
- * rather than add to it), so it covers `history[0 … i-1]`. Two consecutive
208
- * anchors therefore bracket a segment whose exact measured cost is their
209
- * difference: `inputTokens[b] − inputTokens[a]` is what `history[a … b-1]` cost.
210
- * Within a segment the measured total is redistributed across its messages in
211
- * proportion to their baseline estimate — segment granularity is all that is
212
- * needed, since compaction only ever cuts on boundaries.
213
- *
214
- * A non-positive delta means history SHRANK between the two calls (a compaction
215
- * or a `condenseWhen` collapse landed in between); there is nothing to
216
- * distribute, so those messages keep their baseline.
217
- *
218
- * The region before the first anchor keeps its baseline too: that anchor's
219
- * measurement also contains the system prompt and tool definitions, which are
220
- * not attributable to any message — see {@link estimateSystemOverhead}.
470
+ * Per-message token estimates — each message's share of {@link calibrate}'s
471
+ * total, in proportion to how big that message is NOW.
472
+ *
473
+ * Used to choose and evaluate a compaction cut, so what matters is that the
474
+ * shares are in the same unit as the total and that they track the current size
475
+ * of each message. A payload `condenseWhen` has stubbed costs what the stub
476
+ * costs; under the segment-chained scheme this replaced, it kept the cost it had
477
+ * when it was full and the difference was merely redistributed to its neighbours.
478
+ *
479
+ * Sums to exactly `estimateContextTokens(history) - estimateSystemOverhead(history)`.
221
480
  */
222
- export function estimateMessageTokens(history) {
223
- const estimates = history.map(baselineTokens);
224
- const compactedAt = compactedAtOf(history);
225
- const anchors = [];
226
- history.forEach((m, i) => {
227
- if (usableAnchor(m, compactedAt))
228
- anchors.push(i);
229
- });
230
- for (let a = 0; a < anchors.length - 1; a += 1) {
231
- const from = anchors[a];
232
- const to = anchors[a + 1];
233
- const measured = history[to].inputTokens - history[from].inputTokens;
234
- if (measured <= 0)
235
- continue;
236
- let baseline = 0;
237
- for (let i = from; i < to; i += 1)
238
- baseline += estimates[i];
239
- if (baseline <= 0) {
240
- // Nothing to scale against (an all-display-only segment): put the measured
241
- // cost on the first message rather than losing it.
242
- estimates[from] = measured;
243
- continue;
244
- }
245
- // Rescale, then put the rounding residual on the last message of the segment
246
- // so the segment sums to EXACTLY what was measured. Without it the per-message
247
- // rounding drifts, and every projection inherits the drift of every segment
248
- // before it.
249
- const scale = measured / baseline;
250
- let assigned = 0;
251
- for (let i = from; i < to - 1; i += 1) {
252
- estimates[i] = Math.round(estimates[i] * scale);
253
- assigned += estimates[i];
254
- }
255
- estimates[to - 1] = measured - assigned;
256
- }
257
- return estimates;
481
+ export function estimateMessageTokens(history, preambleTokens) {
482
+ return calibrate(history, preambleTokens).estimates;
258
483
  }
259
484
  /**
260
485
  * Context consumed by everything that is NOT a message — the system prompt and
261
486
  * the tool definitions.
262
487
  *
263
488
  * Derived rather than guessed: the first measured prompt covers the messages
264
- * before it plus that fixed preamble, so subtracting our estimate of those
265
- * messages leaves the preamble. Floored at zero, since an over-estimate of the
266
- * early messages would otherwise produce a negative overhead.
489
+ * before it plus the preamble, so subtracting our estimate of those messages
490
+ * leaves the preamble. Floored at zero, since an over-estimate of the early
491
+ * messages would otherwise produce a negative overhead, and capped at what the
492
+ * current total has left after paying for each message's characters — see
493
+ * {@link calibrate} for why that cap is load-bearing.
494
+ *
495
+ * It is NOT a constant, which the first cut of this assumed. Each agent brings its
496
+ * own system prompt and its own tool surface, so it steps up and down as agents
497
+ * hand over; what is true is that it does not shrink when the MESSAGES do.
267
498
  *
268
499
  * It matters for the gate because it is real context the user never sees in the
269
- * transcript, it does not shrink when history does, and on an agent with a large
270
- * tool surface it is far from negligible.
500
+ * transcript, and on an agent with a large tool surface it is far from negligible.
271
501
  */
272
- export function estimateSystemOverhead(history) {
273
- const first = firstAnchor(history, compactedAtOf(history));
274
- if (first < 0)
275
- return 0;
276
- const estimates = estimateMessageTokens(history);
277
- let messages = 0;
278
- for (let i = 0; i < first; i += 1)
279
- messages += estimates[i];
280
- return Math.max(0, history[first].inputTokens - messages);
502
+ export function estimateSystemOverhead(history, preambleTokens) {
503
+ return calibrate(history, preambleTokens).overhead;
281
504
  }
282
505
  /**
283
- * Total estimated context for a transcript — the preamble plus every message.
506
+ * Total estimated context for a transcript — the figure the gauge shows and the
507
+ * composer gate is judged against.
508
+ *
509
+ * The most recent usable measurement plus the char estimate of everything
510
+ * appended since it, which is as close as anything gets to "how big is the prompt
511
+ * we are about to send". See {@link calibrate} for why it is anchored on that one
512
+ * measurement rather than accumulated across all of them.
284
513
  *
285
- * On a transcript with measured anchors this lands close to the last observed
286
- * `inputTokens` by construction; the value of computing it rather than reading
287
- * that figure is that it stays defined for a hypothetical history (the tail a
288
- * compaction would leave behind), which is what {@link projectCompaction} needs.
514
+ * Stays defined for a hypothetical history (the tail a compaction would leave
515
+ * behind), which is what {@link projectCompaction} needs.
289
516
  */
290
517
  export function estimateContextTokens(history) {
291
- const estimates = estimateMessageTokens(history);
292
- let total = estimateSystemOverhead(history);
293
- for (const t of estimates)
294
- total += t;
295
- return total;
518
+ return calibrate(history).total;
296
519
  }
297
520
  /**
298
521
  * Estimated size of the request that will actually be SENT, given the stored
@@ -304,31 +527,34 @@ export function estimateContextTokens(history) {
304
527
  * log. A guard that measured stored history would therefore refuse turns whose
305
528
  * real request is a fraction of the size.
306
529
  *
307
- * Naively estimating the transformed copy does NOT work either, and the reason is
308
- * subtle: `inputTokens` anchors live on the stored messages and measured the
309
- * untransformed prompt, so calibrating against them re-inflates the estimate back
310
- * to roughly what it was before the transform — exactly the figure we were trying
311
- * to avoid.
530
+ * So the request is estimated from its OWN characters, plus the preamble. Nothing
531
+ * is scaled: `requestHistory` IS the payload, so `baselineTokens` is already
532
+ * measuring the right bytes, and there is no transform left to correct for.
533
+ *
534
+ * **This previously applied a ratio, and a docblock here previously claimed it did
535
+ * not.** Both were wrong, and the second is the part worth recording. The original
536
+ * expression scaled the stored-history total by `requestChars / storedChars`,
537
+ * reasoning that `inputTokens` "measured the untransformed prompt". That premise is
538
+ * false — `inputTokens` measures the prompt that was SENT, transform already
539
+ * applied — and it is what made every figure in this module too large. The GENC-1582
540
+ * rewrite claimed to drop the ratio but only re-factored it: `scale` is
541
+ * `(total - overhead) / storedBaseline`, so `overhead + requestBaseline * scale` is
542
+ * the identical expression rearranged. With the total now anchored on a measurement
543
+ * of an already-condensed prompt, that discounted the same shrinkage twice —
544
+ * measured at **5,034 against a last measured prompt of 120,000**, i.e. a guard that
545
+ * believed it had 995k spare. Under-estimating is the one direction this figure must
546
+ * not err in, since its only job is refusing an overflow.
312
547
  *
313
- * So the calibrated total is scaled by how much smaller the request is in
314
- * characters. That keeps the accuracy the measurements buy while still reflecting
315
- * what the transform removed. The preamble is carried across unscaled: the system
316
- * prompt and tool definitions are not what condensation touches.
548
+ * The preamble must be passed in for the same reason: `requestBaseline` counts
549
+ * message characters, and the system prompt and tool definitions are not messages,
550
+ * so without it the guard silently ignores the entire tool surface.
317
551
  */
318
- export function estimateRequestTokens(storedHistory, requestHistory) {
319
- const overhead = estimateSystemOverhead(storedHistory);
320
- const calibratedMessages = Math.max(0, estimateContextTokens(storedHistory) - overhead);
321
- let storedBaseline = 0;
322
- for (const m of storedHistory)
323
- storedBaseline += baselineTokens(m);
552
+ export function estimateRequestTokens(storedHistory, requestHistory, preambleTokens) {
553
+ const { overhead } = calibrate(storedHistory, preambleTokens);
324
554
  let requestBaseline = 0;
325
555
  for (const m of requestHistory)
326
556
  requestBaseline += baselineTokens(m);
327
- // No stored baseline to scale against (an all-display-only history): the
328
- // request's own baseline is the best available answer.
329
- if (storedBaseline <= 0)
330
- return overhead + requestBaseline;
331
- return Math.round(overhead + calibratedMessages * (requestBaseline / storedBaseline));
557
+ return Math.round(overhead + requestBaseline);
332
558
  }
333
559
  /**
334
560
  * Project the effect of compacting at `cut`, WITHOUT running the summarizer.
@@ -342,16 +568,15 @@ export function estimateRequestTokens(storedHistory, requestHistory) {
342
568
  * The system preamble is carried across unchanged: compaction removes messages,
343
569
  * never the system prompt or the tool definitions.
344
570
  */
345
- export function projectCompaction(history, cut, summaryTokens = COMPACTION_SUMMARY_TOKEN_ESTIMATE) {
346
- const estimates = estimateMessageTokens(history);
347
- const overhead = estimateSystemOverhead(history);
571
+ export function projectCompaction(history, cut, summaryTokens = COMPACTION_SUMMARY_TOKEN_ESTIMATE, preambleTokens) {
572
+ // One calibration for both figures, so `tokensBefore` is the same number the
573
+ // gauge and the gate read. Deriving it independently is how an affordance ends
574
+ // up promising a reclaim measured against a total nothing else agrees with.
575
+ const { estimates, overhead, total } = calibrate(history, preambleTokens);
348
576
  let tail = 0;
349
577
  for (let i = cut; i < estimates.length; i += 1)
350
578
  tail += estimates[i];
351
- let all = 0;
352
- for (const t of estimates)
353
- all += t;
354
- const tokensBefore = overhead + all;
579
+ const tokensBefore = total;
355
580
  const tokensAfter = overhead + summaryTokens + tail;
356
581
  return {
357
582
  cut,
@@ -1,5 +1,5 @@
1
1
  import { __rest } from "tslib";
2
- import { estimateMessageTokens, projectCompaction, reachesProvider, } from './context-tokens';
2
+ import { COMPACTION_SUMMARY_TOKEN_ESTIMATE, estimateMessageTokens, projectCompaction, reachesProvider, } from './context-tokens';
3
3
  /**
4
4
  * Masks the tool-specific payload of a single message — clears tool call args
5
5
  * and replaces tool result content with a placeholder. Used when passing history
@@ -152,8 +152,8 @@ export function isSafeCompactionCut(history, i) {
152
152
  * enormous, indivisible tool group, which no cut can shrink because a cut cannot
153
153
  * split a message.
154
154
  */
155
- export function findCompactionCut(history, tailTokenBudget = DEFAULT_TAIL_TOKEN_BUDGET) {
156
- const estimates = estimateMessageTokens(history);
155
+ export function findCompactionCut(history, tailTokenBudget = DEFAULT_TAIL_TOKEN_BUDGET, preambleTokens) {
156
+ const estimates = estimateMessageTokens(history, preambleTokens);
157
157
  let messagesTotal = 0;
158
158
  for (const t of estimates)
159
159
  messagesTotal += t;
@@ -191,10 +191,10 @@ export function findCompactionCut(history, tailTokenBudget = DEFAULT_TAIL_TOKEN_
191
191
  */
192
192
  export function planCompaction(history, options = {}) {
193
193
  const { tailTokenBudget = DEFAULT_TAIL_TOKEN_BUDGET, minReclaimTokens = DEFAULT_MIN_RECLAIM_TOKENS, } = options;
194
- const cut = findCompactionCut(history, tailTokenBudget);
194
+ const cut = findCompactionCut(history, tailTokenBudget, options.preambleTokens);
195
195
  if (cut == null)
196
196
  return null;
197
- const projection = projectCompaction(history, cut);
197
+ const projection = projectCompaction(history, cut, COMPACTION_SUMMARY_TOKEN_ESTIMATE, options.preambleTokens);
198
198
  return projection.reclaimed >= minReclaimTokens ? projection : null;
199
199
  }
200
200
  /** One-line rendering of a message for the summarizer transcript. */
@@ -1,4 +1,3 @@
1
- import { COMPACTION_SUMMARY_TOKEN_ESTIMATE } from './context-tokens';
2
1
  // Re-exported, not redeclared: this module and `planCompaction` must apply the
3
2
  // SAME worth-it floor, or a host passing explicit options and a caller relying
4
3
  // on the module default would silently disagree about whether a compaction is
@@ -41,7 +40,7 @@ export const CONTEXT_ABORT_MARGIN_TOKENS = 4096;
41
40
  */
42
41
  export function resolveContextBudget(input) {
43
42
  var _a, _b, _c;
44
- const { chatConfig, contextLimit, agentReserveTokens, systemOverhead = 0 } = input;
43
+ const { chatConfig, contextLimit, agentReserveTokens, agentWarnAheadTokens } = input;
45
44
  const cfg = chatConfig.context;
46
45
  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
46
  const warnMultiplier = Math.max(1, (_b = cfg === null || cfg === void 0 ? void 0 : cfg.warnMultiplier) !== null && _b !== void 0 ? _b : DEFAULT_WARN_MULTIPLIER);
@@ -60,11 +59,42 @@ export function resolveContextBudget(input) {
60
59
  }
61
60
  const enabled = (cfg === null || cfg === void 0 ? void 0 : cfg.enabled) !== false;
62
61
  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);
62
+ // An agent-declared warn-ahead is an ABSOLUTE distance above the block, so the
63
+ // warning sits a fixed, meaningful step ahead of it — one more of that agent's
64
+ // turns — instead of a multiple of a reserve it has no relation to. Only
65
+ // honoured alongside that agent's own reserve; on its own it would be measuring
66
+ // a step ahead of somebody else's line.
67
+ const warnAt = agentReserveTokens != null && agentWarnAheadTokens != null
68
+ ? Math.max(0, limit - (reserveTokens + agentWarnAheadTokens))
69
+ : Math.max(0, limit - warnMultiplier * reserveTokens);
70
+ // Compaction's own target, deliberately NOT derived from `warnAt` (GENC-1582).
71
+ //
72
+ // `limit - 2 * reserve` is "leave room for about two of this agent's turns" of
73
+ // FREE headroom, so the retained tail shrinks as an agent's appetite grows: a
74
+ // 400k-reserve agent on a 1M window keeps 200,000 and compacts hard, while an
75
+ // agent on the ambient 25k keeps 950,000 and `findCompactionCut` correctly
76
+ // declines to do anything. That is the right way round — the expensive agent is
77
+ // the one that cannot afford history sitting alongside its turns.
78
+ //
79
+ // **This was coupled to `warnAt`, and the coupling was a real behaviour bug.**
80
+ // GENC-1567 (08f6504c44) introduced both this budget and the token-based cut, and
81
+ // aimed the budget at the warning line — coherent while `warnAt` meant "a whole
82
+ // reserve of room left". The GENC-1582 warn-ahead redefined `warnAt` as "one
83
+ // turn's residue left", which is what the warning wants and the opposite of what
84
+ // compaction wants, and this line inherited the new meaning silently: `warnAt`
85
+ // 200,000 -> 560,000 took the budget 400,000 -> 553,800 and cut the reclaim from
86
+ // ~195,000 to ~40,000. Since the budget drives `findCompactionCut`, which drives
87
+ // `compact()`, that is how much less gets summarized — not a display figure.
88
+ // Two unrelated questions ("when do we tell the user?" and "how much do we throw
89
+ // away?") were sharing one number; they no longer are.
90
+ //
91
+ // The `reserveTokens` floor went with it. It is what made the budget 400,000 in
92
+ // the first place, and on a tight window it produced a tail wider than the window
93
+ // had room for — a compaction that did nothing. A window that cannot host the
94
+ // agent is now reported as the `'model'` block cause instead of being papered
95
+ // over here. `systemOverhead` is gone for the same reason it stopped being
96
+ // derivable: see `estimatePreambleTokens`.
97
+ const tailTokenBudget = Math.max(0, limit - 2 * reserveTokens);
68
98
  return {
69
99
  enabled,
70
100
  limit,
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.23.0",
4
+ "version": "15.23.1-FUI-0.4",
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.23.0",
78
- "@genesislcap/genx": "15.23.0",
79
- "@genesislcap/rollup-builder": "15.23.0",
80
- "@genesislcap/ts-builder": "15.23.0",
81
- "@genesislcap/uvu-playwright-builder": "15.23.0",
82
- "@genesislcap/vite-builder": "15.23.0",
83
- "@genesislcap/webpack-builder": "15.23.0",
77
+ "@genesislcap/foundation-testing": "15.23.1-FUI-0.4",
78
+ "@genesislcap/genx": "15.23.1-FUI-0.4",
79
+ "@genesislcap/rollup-builder": "15.23.1-FUI-0.4",
80
+ "@genesislcap/ts-builder": "15.23.1-FUI-0.4",
81
+ "@genesislcap/uvu-playwright-builder": "15.23.1-FUI-0.4",
82
+ "@genesislcap/vite-builder": "15.23.1-FUI-0.4",
83
+ "@genesislcap/webpack-builder": "15.23.1-FUI-0.4",
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.23.0",
90
- "@genesislcap/foundation-logger": "15.23.0",
91
- "@genesislcap/foundation-notifications": "15.23.0",
92
- "@genesislcap/foundation-redux": "15.23.0",
93
- "@genesislcap/foundation-ui": "15.23.0",
94
- "@genesislcap/foundation-utils": "15.23.0",
95
- "@genesislcap/rapid-design-system": "15.23.0",
96
- "@genesislcap/web-core": "15.23.0",
89
+ "@genesislcap/foundation-ai": "15.23.1-FUI-0.4",
90
+ "@genesislcap/foundation-logger": "15.23.1-FUI-0.4",
91
+ "@genesislcap/foundation-notifications": "15.23.1-FUI-0.4",
92
+ "@genesislcap/foundation-redux": "15.23.1-FUI-0.4",
93
+ "@genesislcap/foundation-ui": "15.23.1-FUI-0.4",
94
+ "@genesislcap/foundation-utils": "15.23.1-FUI-0.4",
95
+ "@genesislcap/rapid-design-system": "15.23.1-FUI-0.4",
96
+ "@genesislcap/web-core": "15.23.1-FUI-0.4",
97
97
  "dompurify": "^3.3.1",
98
98
  "marked": "^17.0.3"
99
99
  },