pi-mega-compact 0.15.4 → 0.16.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.
@@ -1,6 +1,6 @@
1
1
  import { openStore, } from "../../src/store/sqlite.js";
2
2
  import { autoCompactCheck } from "../../src/compact.js";
3
- import { estimateSessionTokens } from "../../src/tokens.js";
3
+ import { estimateSessionTokens, estimateBlockTokens, estimateMessageTokens } from "../../src/tokens.js";
4
4
  import { runCompact, piCompactWouldNoop } from "../mega-pipeline.js";
5
5
  import { pressureFromPct, pressureRatio, } from "../mega-config.js";
6
6
  import { appendMirrorMessages } from "./mirror-append.js";
@@ -281,7 +281,16 @@ export function registerContextHandler(pi, runtime, config) {
281
281
  // model is fed a raw overflow that errors every turn — the
282
282
  // "Already compacted" + overflow death-spiral (2026-08-01 incident).
283
283
  // A thin anchor is recoverable; an overflowed session is not.
284
- criticalOver: (pct ?? 0) >= 90,
284
+ //
285
+ // CRITICAL: pct is null for OpenAI-compatible providers that don't
286
+ // report usage.percent (e.g. neuralwatt). Without the token-pressure
287
+ // fallback the hatch never armed → cut=null → raw overflow → 400
288
+ // "conversation too long even after compaction" (2026-08-03 incident
289
+ // on glm-5.2-short, 200K window). Now also fires on pressure >= 0.9
290
+ // (token-basis) so the hatch arms regardless of whether the provider
291
+ // reports pct.
292
+ criticalOver: (pct ?? 0) >= 90 ||
293
+ pressure >= 0.9,
285
294
  });
286
295
  if (cut === null) {
287
296
  runtime.diagCtxCutNull++;
@@ -308,7 +317,65 @@ export function registerContextHandler(pi, runtime, config) {
308
317
  // on every replay within the same compaction epoch.
309
318
  timestamp: runtime.rt.lastCompactAt ?? Date.now(),
310
319
  };
311
- const recent = messages.slice(cut); // guardrails-allow PREVENT-PI-002: `cut` is the pre-sanitized `compactedFrom` produced by src/boundary.ts computeDropRange, so the preserved run begins on a toolPair-safe index.
320
+ const recentRaw = messages.slice(cut); // guardrails-allow PREVENT-PI-002: `cut` is the pre-sanitized `compactedFrom` produced by src/boundary.ts computeDropRange, so the preserved run begins on a toolPair-safe index.
321
+ // FIX 2 (2026-08-03 incident): TOKEN-BUDGET CAP on the live-trim view.
322
+ // Compaction fires at tier% of the window (140K for a 200K window),
323
+ // but a SINGLE turn can inject a huge tool output (file read, bash) that
324
+ // jumps context from 139K → 199K+ before the next gate fires. When that
325
+ // happens [summary + preserved tail] can STILL exceed the model window,
326
+ // and the provider rejects with 400 "conversation too long even after
327
+ // compaction". The anchor floor (PREVENT-PI-001) keeps ≥N user messages
328
+ // but has NO token cap, so a 2-message tail of two 80K bash outputs sails
329
+ // right past the window.
330
+ //
331
+ // Cap: when the model context window is known, reserve room for the
332
+ // summary + the model's max output tokens + a 10% safety margin, then
333
+ // drop oldest preserved messages from the front of `recentRaw` until the
334
+ // tail fits. Never drops below the FINAL message (always keep the latest
335
+ // turn so the agent can respond). This is a last-resort HARD cap — it
336
+ // only fires when the preserved tail alone is oversized, which is rare.
337
+ const ctxWindow = runtime.lastCtxWindow;
338
+ // Reserve room for output tokens. Use the model's reported max output
339
+ // when known; fall back to 10% of the window (scales with any model —
340
+ // 20K for a 200K window, 100K for a 1M window) so we never let the
341
+ // preserved tail eat the model's output budget when maxTokens is unknown.
342
+ const maxOutput = runtime.currentModel?.maxTokens && runtime.currentModel.maxTokens > 0
343
+ ? runtime.currentModel.maxTokens
344
+ : Math.ceil(ctxWindow * 0.1);
345
+ let recent = recentRaw;
346
+ if (ctxWindow > 0 && recentRaw.length > 1) {
347
+ const summaryTokens = estimateBlockTokens(summaryMsg.text);
348
+ // Reserve: summary + max output + 10% safety margin.
349
+ const safetyMargin = Math.ceil(ctxWindow * 0.1);
350
+ const budget = ctxWindow - maxOutput - safetyMargin - summaryTokens;
351
+ if (budget > 0) {
352
+ // Walk recent from the front, dropping oldest first until the
353
+ // remaining tail fits. Use the AgentMessage→engine-text estimate via
354
+ // messageContentText (already imported) + estimateMessageTokens.
355
+ let tailTokens = 0;
356
+ for (let i = recentRaw.length - 1; i >= 0; i--) {
357
+ const m = recentRaw[i];
358
+ tailTokens += estimateMessageTokens({
359
+ text: messageContentText(m),
360
+ });
361
+ if (tailTokens > budget) {
362
+ // Keep from i+1 onward; but never fewer than the final message.
363
+ const startIdx = Math.min(i + 1, recentRaw.length - 1);
364
+ if (startIdx > 0) {
365
+ recent = recentRaw.slice(startIdx);
366
+ runtime.logger.warn("live-trim-tail-cap", {
367
+ sessionId: runtime.rt.sessionId,
368
+ dropped: startIdx,
369
+ tailTokens,
370
+ budget,
371
+ ctxWindow,
372
+ });
373
+ }
374
+ break;
375
+ }
376
+ }
377
+ }
378
+ }
312
379
  // v0.8.6: cache the trim view so subsequent gated calls in this epoch
313
380
  // replay it verbatim (stabilizing the KV-cache prefix) instead of
314
381
  // regenerating a fresh summary + sentinel every fire.