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.
@@ -164,7 +164,12 @@ export function SessionContextGauges({
164
164
  // Also skip rows where percent is 0 but tokens are present — these are
165
165
  // stale heartbeats whose token_samples join returned a 0 percent
166
166
  // (a transient state during session startup). A 0% gauge is noise.
167
- if (s.percent != null && s.percent === 0 && s.tokens != null && s.tokens > 0)
167
+ if (
168
+ s.percent != null &&
169
+ s.percent === 0 &&
170
+ s.tokens != null &&
171
+ s.tokens > 0
172
+ )
168
173
  return false;
169
174
  const repoKey = s.repoRoot ?? `(pid:${s.pid})`;
170
175
  if (seenRepo.has(repoKey)) return false;
@@ -55,7 +55,7 @@ function mostRecentOtherRepoStateDir(launchStateDir: string): string | null {
55
55
  const { DatabaseSync } =
56
56
  require("node:sqlite") as typeof import("node:sqlite");
57
57
  db = new DatabaseSync(indexPath, { readOnly: true });
58
- const rows = db
58
+ const rows = db
59
59
  .prepare(
60
60
  `SELECT state_dir, last_seen FROM repo_registry
61
61
  WHERE state_dir IS NOT NULL AND last_seen IS NOT NULL
@@ -19,7 +19,7 @@ import {
19
19
  openStore,
20
20
  } from "../../src/store/sqlite.js";
21
21
  import { autoCompactCheck } from "../../src/compact.js";
22
- import { estimateSessionTokens } from "../../src/tokens.js";
22
+ import { estimateSessionTokens, estimateBlockTokens, estimateMessageTokens } from "../../src/tokens.js";
23
23
  import type { MegaRuntime } from "../mega-runtime.js";
24
24
  import { runCompact, piCompactWouldNoop } from "../mega-pipeline.js";
25
25
  import {
@@ -324,13 +324,23 @@ export function registerContextHandler(
324
324
  compactedFrom: ran.result.compactedFrom,
325
325
  summary: ran.result.summary,
326
326
  anchorUserMessages,
327
- // CRITICAL-OVER ESCAPE HATCH: when context is at/over ~90% of the
328
- // window, relief takes priority over the anchor floor. Without this,
329
- // computeLiveTrimCut bails to null (can't satisfy the floor) and the
330
- // model is fed a raw overflow that errors every turn — the
331
- // "Already compacted" + overflow death-spiral (2026-08-01 incident).
332
- // A thin anchor is recoverable; an overflowed session is not.
333
- criticalOver: (pct ?? 0) >= 90,
327
+ // CRITICAL-OVER ESCAPE HATCH: when context is at/over ~90% of the
328
+ // window, relief takes priority over the anchor floor. Without this,
329
+ // computeLiveTrimCut bails to null (can't satisfy the floor) and the
330
+ // model is fed a raw overflow that errors every turn — the
331
+ // "Already compacted" + overflow death-spiral (2026-08-01 incident).
332
+ // A thin anchor is recoverable; an overflowed session is not.
333
+ //
334
+ // CRITICAL: pct is null for OpenAI-compatible providers that don't
335
+ // report usage.percent (e.g. neuralwatt). Without the token-pressure
336
+ // fallback the hatch never armed → cut=null → raw overflow → 400
337
+ // "conversation too long even after compaction" (2026-08-03 incident
338
+ // on glm-5.2-short, 200K window). Now also fires on pressure >= 0.9
339
+ // (token-basis) so the hatch arms regardless of whether the provider
340
+ // reports pct.
341
+ criticalOver:
342
+ (pct ?? 0) >= 90 ||
343
+ pressure >= 0.9,
334
344
  });
335
345
  if (cut === null) {
336
346
  runtime.diagCtxCutNull++;
@@ -357,7 +367,69 @@ export function registerContextHandler(
357
367
  // on every replay within the same compaction epoch.
358
368
  timestamp: runtime.rt.lastCompactAt ?? Date.now(),
359
369
  } as unknown as AgentMessage;
360
- 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.
370
+ 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.
371
+
372
+ // FIX 2 (2026-08-03 incident): TOKEN-BUDGET CAP on the live-trim view.
373
+ // Compaction fires at tier% of the window (140K for a 200K window),
374
+ // but a SINGLE turn can inject a huge tool output (file read, bash) that
375
+ // jumps context from 139K → 199K+ before the next gate fires. When that
376
+ // happens [summary + preserved tail] can STILL exceed the model window,
377
+ // and the provider rejects with 400 "conversation too long even after
378
+ // compaction". The anchor floor (PREVENT-PI-001) keeps ≥N user messages
379
+ // but has NO token cap, so a 2-message tail of two 80K bash outputs sails
380
+ // right past the window.
381
+ //
382
+ // Cap: when the model context window is known, reserve room for the
383
+ // summary + the model's max output tokens + a 10% safety margin, then
384
+ // drop oldest preserved messages from the front of `recentRaw` until the
385
+ // tail fits. Never drops below the FINAL message (always keep the latest
386
+ // turn so the agent can respond). This is a last-resort HARD cap — it
387
+ // only fires when the preserved tail alone is oversized, which is rare.
388
+ const ctxWindow = runtime.lastCtxWindow;
389
+ // Reserve room for output tokens. Use the model's reported max output
390
+ // when known; fall back to 10% of the window (scales with any model —
391
+ // 20K for a 200K window, 100K for a 1M window) so we never let the
392
+ // preserved tail eat the model's output budget when maxTokens is unknown.
393
+ const maxOutput =
394
+ runtime.currentModel?.maxTokens && runtime.currentModel.maxTokens > 0
395
+ ? runtime.currentModel.maxTokens
396
+ : Math.ceil(ctxWindow * 0.1);
397
+ let recent = recentRaw;
398
+ if (ctxWindow > 0 && recentRaw.length > 1) {
399
+ const summaryTokens = estimateBlockTokens(summaryMsg.text);
400
+ // Reserve: summary + max output + 10% safety margin.
401
+ const safetyMargin = Math.ceil(ctxWindow * 0.1);
402
+ const budget =
403
+ ctxWindow - maxOutput - safetyMargin - summaryTokens;
404
+ if (budget > 0) {
405
+ // Walk recent from the front, dropping oldest first until the
406
+ // remaining tail fits. Use the AgentMessage→engine-text estimate via
407
+ // messageContentText (already imported) + estimateMessageTokens.
408
+ let tailTokens = 0;
409
+ for (let i = recentRaw.length - 1; i >= 0; i--) {
410
+ const m = recentRaw[i];
411
+ tailTokens += estimateMessageTokens({
412
+ text: messageContentText(m),
413
+ });
414
+ if (tailTokens > budget) {
415
+ // Keep from i+1 onward; but never fewer than the final message.
416
+ const startIdx = Math.min(i + 1, recentRaw.length - 1);
417
+ if (startIdx > 0) {
418
+ recent = recentRaw.slice(startIdx);
419
+ runtime.logger.warn("live-trim-tail-cap", {
420
+ sessionId: runtime.rt.sessionId,
421
+ dropped: startIdx,
422
+ tailTokens,
423
+ budget,
424
+ ctxWindow,
425
+ });
426
+ }
427
+ break;
428
+ }
429
+ }
430
+ }
431
+ }
432
+
361
433
  // v0.8.6: cache the trim view so subsequent gated calls in this epoch
362
434
  // replay it verbatim (stabilizing the KV-cache prefix) instead of
363
435
  // regenerating a fresh summary + sentinel every fire.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.15.4",
3
+ "version": "0.16.0",
4
4
  "description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
5
5
  "type": "module",
6
6
  "license": "BSD-3-Clause",