pi-mega-compact 0.8.24 → 0.8.26

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 (111) hide show
  1. package/README.md +26 -0
  2. package/dist/extensions/mega-compact-s38.test.js +263 -14
  3. package/dist/extensions/mega-compact.js +15 -0
  4. package/dist/extensions/mega-config.js +3 -0
  5. package/dist/extensions/mega-events/agent-handlers.js +211 -26
  6. package/dist/extensions/mega-events/context-handler.js +45 -7
  7. package/dist/extensions/mega-events/error-classifier.js +125 -18
  8. package/dist/extensions/mega-pipeline/compact.js +24 -13
  9. package/dist/extensions/mega-pipeline/recall.js +31 -2
  10. package/dist/extensions/mega-runtime/dashboard-snapshot.js +4 -0
  11. package/dist/extensions/mega-runtime/runtime-snapshot.js +4 -0
  12. package/dist/extensions/mega-runtime/runtime.js +58 -5
  13. package/dist/src/boundary.js +79 -43
  14. package/dist/src/boundary.test.js +119 -2
  15. package/dist/src/canary.js +10 -0
  16. package/dist/src/config/dedup.js +14 -0
  17. package/dist/src/config.js +3 -1
  18. package/dist/src/dedup/raptor/buildHistory.js +164 -0
  19. package/dist/src/dedup/raptor/buildHistory.test.js +292 -0
  20. package/dist/src/dedup/raptor/index.js +38 -0
  21. package/dist/src/dedup/raptor/multilevel-serve.test.js +229 -0
  22. package/dist/src/dedup/raptor/multilevel.js +17 -5
  23. package/dist/src/dedup/raptor/multilevel.test.js +36 -1
  24. package/dist/src/dedup/raptor/raptor.test.js +43 -0
  25. package/dist/src/dedup/raptor/retrieval.js +14 -2
  26. package/dist/src/dedup/raptor/retrieval.test.js +95 -0
  27. package/dist/src/dedup/raptor/serve-gate.test.js +298 -0
  28. package/dist/src/dedup/raptor/summarizer.js +1 -0
  29. package/dist/src/dedup/raptor/tree.js +16 -2
  30. package/dist/src/engine.js +18 -2
  31. package/dist/src/httpEmbedder.js +96 -6
  32. package/dist/src/httpEmbedder.test.js +277 -0
  33. package/dist/src/mechanical-fix.test.js +65 -0
  34. package/dist/src/raptor-inject-summaries.test.js +162 -0
  35. package/dist/src/recall.js +153 -24
  36. package/dist/src/recall.test.js +179 -4
  37. package/dist/src/store/sqlite/dedup-mirror.js +32 -15
  38. package/dist/src/store/sqlite/maintenance.js +2 -2
  39. package/dist/src/store/sqlite/mechanical-fix.test.js +146 -0
  40. package/dist/src/store/sqlite/memories.js +5 -5
  41. package/dist/src/store/sqlite/meta.js +1 -1
  42. package/dist/src/store/sqlite/raptor.js +56 -17
  43. package/dist/src/store/sqlite/raptor.test.js +106 -0
  44. package/dist/src/store/sqlite/schema.js +90 -1
  45. package/dist/src/store/sqlite/session-state.js +9 -3
  46. package/dist/src/store/sqlite/stats.js +9 -5
  47. package/dist/src/store/sqlite/turns.js +179 -0
  48. package/dist/src/store/sqlite/turns.test.js +183 -0
  49. package/dist/src/store/sqlite/utils.js +15 -4
  50. package/dist/src/store/sqlite.js +1 -0
  51. package/dist/src/store.js +2 -2
  52. package/dist/src/vector-search-cache.test.js +157 -0
  53. package/dist/src/vector-search.js +107 -15
  54. package/dist/src/vectorStore.js +36 -8
  55. package/extensions/mega-compact-s38.test.ts +259 -14
  56. package/extensions/mega-compact.ts +15 -0
  57. package/extensions/mega-config.ts +18 -0
  58. package/extensions/mega-dashboard.ts +10 -1
  59. package/extensions/mega-events/agent-handlers.ts +211 -26
  60. package/extensions/mega-events/context-handler.ts +43 -7
  61. package/extensions/mega-events/error-classifier.ts +125 -17
  62. package/extensions/mega-pipeline/compact.ts +28 -16
  63. package/extensions/mega-pipeline/recall.ts +34 -2
  64. package/extensions/mega-runtime/dashboard-snapshot.ts +8 -0
  65. package/extensions/mega-runtime/helpers.ts +25 -1
  66. package/extensions/mega-runtime/runtime-snapshot.ts +4 -0
  67. package/extensions/mega-runtime/runtime.ts +69 -23
  68. package/package.json +1 -1
  69. package/src/boundary.test.ts +128 -2
  70. package/src/boundary.ts +75 -39
  71. package/src/canary.ts +10 -0
  72. package/src/config/dedup.ts +25 -0
  73. package/src/config.ts +3 -1
  74. package/src/dedup/raptor/buildHistory.test.ts +353 -0
  75. package/src/dedup/raptor/buildHistory.ts +259 -0
  76. package/src/dedup/raptor/index.ts +38 -0
  77. package/src/dedup/raptor/multilevel-serve.test.ts +273 -0
  78. package/src/dedup/raptor/multilevel.test.ts +47 -0
  79. package/src/dedup/raptor/multilevel.ts +18 -8
  80. package/src/dedup/raptor/raptor.test.ts +59 -0
  81. package/src/dedup/raptor/retrieval.test.ts +118 -0
  82. package/src/dedup/raptor/retrieval.ts +14 -2
  83. package/src/dedup/raptor/serve-gate.test.ts +348 -0
  84. package/src/dedup/raptor/summarizer.ts +1 -0
  85. package/src/dedup/raptor/tree.ts +17 -2
  86. package/src/engine.ts +32 -3
  87. package/src/httpEmbedder.test.ts +286 -0
  88. package/src/httpEmbedder.ts +98 -8
  89. package/src/mechanical-fix.test.ts +70 -0
  90. package/src/raptor-inject-summaries.test.ts +228 -0
  91. package/src/recall.test.ts +220 -4
  92. package/src/recall.ts +462 -265
  93. package/src/store/sqlite/dedup-mirror.ts +35 -18
  94. package/src/store/sqlite/maintenance.ts +2 -2
  95. package/src/store/sqlite/mechanical-fix.test.ts +162 -0
  96. package/src/store/sqlite/memories.ts +5 -5
  97. package/src/store/sqlite/meta.ts +1 -1
  98. package/src/store/sqlite/raptor.test.ts +139 -0
  99. package/src/store/sqlite/raptor.ts +135 -81
  100. package/src/store/sqlite/schema.ts +90 -1
  101. package/src/store/sqlite/session-state.ts +9 -3
  102. package/src/store/sqlite/stats.ts +10 -8
  103. package/src/store/sqlite/turns.test.ts +218 -0
  104. package/src/store/sqlite/turns.ts +302 -0
  105. package/src/store/sqlite/utils.ts +14 -4
  106. package/src/store/sqlite.ts +1 -0
  107. package/src/store.ts +9 -2
  108. package/src/vector-search-cache.test.ts +190 -0
  109. package/src/vector-search.ts +273 -156
  110. package/src/vectorStore.ts +443 -382
  111. package/extensions/mega-runtime/reset-runtime.ts +0 -80
@@ -24,6 +24,7 @@ import {
24
24
  } from "../mega-runtime.js";
25
25
  import { resolveRepoRoot, preserveRecentForPressure, type MegaConfig } from "../mega-config.js";
26
26
  import { runRaptor } from "../../src/dedup/raptor/index.js";
27
+ import { isRaptorTreeFresh } from "../../src/dedup/raptor/buildHistory.js";
27
28
  import { loadDedupConfig } from "../../src/config/dedup.js";
28
29
  import { upsertEmbedding as indexUpsertEmbedding } from "../../src/store/vectorIndex.js";
29
30
  import { runMemoryReview } from "./memory-review.js";
@@ -221,22 +222,33 @@ function doCompact(
221
222
  embedding: cp.embedding,
222
223
  }));
223
224
  if (leaves.length >= 2) {
224
- // S25: stamp the tree with the newest checkpoint epoch so the
225
- // freshness guard in raptorSearchHits can reject stale trees after a
226
- // later compaction adds newer checkpoints.
227
- const builtAt = all.length > 0 ? Math.max(...all.map((c) => c.timestamp)) : Date.now();
228
- runRaptor(
229
- leaves,
230
- {
231
- stateDir: runtime.currentStateDir,
232
- sessionId: sid,
233
- budgetMs: dd.RAPTOR_BUDGET_MS,
234
- clustersPerLevel: dd.RAPTOR_CLUSTERS_PER_LEVEL,
235
- consistencyThreshold: dd.RAPTOR_CONSISTENCY,
236
- logger: runtime.logger,
237
- builtAt: Number.isFinite(builtAt) ? builtAt : Date.now(),
238
- },
239
- );
225
+ // S42D: skip the rebuild when the last build is fresh (within
226
+ // RAPTOR_FRESHNESS_HOURS) and the checkpoint count hasn't drifted by
227
+ // more than 20%. avoids re-clustering on every compaction when the
228
+ // tree is still representative. 0 disables (always rebuild).
229
+ if (
230
+ dd.RAPTOR_FRESHNESS_HOURS > 0 &&
231
+ isRaptorTreeFresh(sid, runtime.currentStateDir, dd.RAPTOR_FRESHNESS_HOURS, all.length)
232
+ ) {
233
+ runtime.logger?.info("raptor_skip_fresh", { sessionId: sid });
234
+ } else {
235
+ // S25: stamp the tree with the newest checkpoint epoch so the
236
+ // freshness guard in raptorSearchHits can reject stale trees after a
237
+ // later compaction adds newer checkpoints.
238
+ const builtAt = all.length > 0 ? Math.max(...all.map((c) => c.timestamp)) : Date.now();
239
+ runRaptor(
240
+ leaves,
241
+ {
242
+ stateDir: runtime.currentStateDir,
243
+ sessionId: sid,
244
+ budgetMs: dd.RAPTOR_BUDGET_MS,
245
+ clustersPerLevel: dd.RAPTOR_CLUSTERS_PER_LEVEL,
246
+ consistencyThreshold: dd.RAPTOR_CONSISTENCY,
247
+ logger: runtime.logger,
248
+ builtAt: Number.isFinite(builtAt) ? builtAt : Date.now(),
249
+ },
250
+ );
251
+ }
240
252
  }
241
253
  } catch {
242
254
  /* non-fatal: tree refresh never blocks a compaction */
@@ -10,7 +10,8 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
10
  import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
11
11
  import { recallAndInline, recallAndInlineAsync, formatRecallBlock, type RecallInjectResult } from "../../src/recall.js";
12
12
  import { normalizeSessionId } from "../../src/store.js";
13
- import { incRecallInjected, incCacheHitTokens } from "../../src/store/sqlite.js";
13
+ import { incRecallInjected, incCacheHitTokens, getIndexDir } from "../../src/store/sqlite.js";
14
+ import { ensureConversationId, recordTurn, recordTurnRecall, type RecallSource } from "../../src/store/sqlite/turns.js";
14
15
  import {
15
16
  type MegaRuntime,
16
17
  C,
@@ -64,6 +65,30 @@ export function doRecall(
64
65
  runtime.rt.cacheHitTokens += sumTokens;
65
66
  incRecallInjected(result.toInject.length, runtime.currentStateDir);
66
67
  incCacheHitTokens(sumTokens, runtime.currentStateDir);
68
+ // S43: record recall provenance — which checkpoints/summaries served this
69
+ // turn, their score + source path. Linked to the turn row written at
70
+ // turn_end via the conversation+turnIndex. Best-effort + non-fatal.
71
+ try {
72
+ const convId = ensureConversationId(sid, runtime.currentStateDir);
73
+ const turnId = recordTurn({
74
+ conversationId: convId,
75
+ sessionId: sid,
76
+ turnIndex: runtime.currentTurn,
77
+ startedAt: Date.now(),
78
+ }, runtime.currentStateDir);
79
+ recordTurnRecall(
80
+ turnId,
81
+ result.toInject.map((h) => ({
82
+ checkpointId: h.checkpoint.checkpointId,
83
+ score: h.score,
84
+ source: (h.raptorLevel !== undefined ? "raptor" : h.repoId ? "cross-repo" : "flat") as RecallSource,
85
+ raptorLevel: h.raptorLevel,
86
+ })),
87
+ runtime.currentStateDir,
88
+ );
89
+ } catch {
90
+ /* non-fatal: recall provenance never breaks the recall path */
91
+ }
67
92
  }
68
93
  return result;
69
94
  }
@@ -108,7 +133,14 @@ export async function doRecallAsync(
108
133
  sessionId: sid, query, limit: config.autoInlineK, source, skipInjected: true,
109
134
  recallMaxTokens: config.recallMaxTokens, windowDedupe: config.windowDedupe,
110
135
  liveWindow, dedupSim: config.crossRepoCosine, crossRepo: true,
111
- globalIndexDir: process.env.MEGACOMPACT_INDEX_DIR,
136
+ // F2: resolve the machine-wide index dir via the shared resolver so the
137
+ // cross-repo injected-set dedup works even when MEGACOMPACT_INDEX_DIR is
138
+ // unset. The env var still wins when set (getIndexDir checks it first);
139
+ // the default (~/.mega-compact-index) is the same DB mega-commands and the
140
+ // dashboard read, so injection counts stay consistent. Without this, a
141
+ // bare `process.env` read returns undefined → cross-repo hits re-inject in
142
+ // every new session (the global injected-set is never consulted).
143
+ globalIndexDir: getIndexDir(),
112
144
  },
113
145
  runtime.store,
114
146
  );
@@ -50,6 +50,10 @@ export interface SnapshotBuildContext {
50
50
  readonly consecutiveErrors: number;
51
51
  readonly ERROR_RETRY_MAX_CONSECUTIVE: number;
52
52
  readonly errorRetryHardStop: boolean;
53
+ // R7 (retry redesign): session-global cap + poisoned-context counters.
54
+ readonly sessionRetryCount: number;
55
+ readonly sessionRetryMax: number;
56
+ readonly poisonedCount: number;
53
57
  readonly activeAgents: number;
54
58
  readonly currentTurn: number;
55
59
  readonly currentModel: { providerName: string | null; modelId: string; provider: string; inputRate: number; outputRate: number } | null | undefined;
@@ -168,6 +172,10 @@ export function buildDashboardSnapshot(ctx: SnapshotBuildContext): DashboardSnap
168
172
  consecutiveErrors: ctx.consecutiveErrors,
169
173
  maxConsecutiveErrors: ctx.ERROR_RETRY_MAX_CONSECUTIVE,
170
174
  errorRetryHardStop: ctx.errorRetryHardStop,
175
+ // R7 (retry redesign): additive session-cap + poisoned-context counters.
176
+ sessionRetryCount: ctx.sessionRetryCount,
177
+ sessionMax: ctx.sessionRetryMax,
178
+ poisonedCount: ctx.poisonedCount,
171
179
  },
172
180
  };
173
181
  }
@@ -44,10 +44,34 @@ export interface SessionRuntime {
44
44
  cacheHitTokens: number; // tokens saved via cache hits (dedup + recall) this session
45
45
  lengthStopPending: boolean; // S28: set on turn_end when stopReason==='length'
46
46
  errorRetryCount: number; // S38: consecutive error turns, reset on success/turn_start
47
- errorRetryUntil: number; // S38: wall-clock ms debounce for error-retry nudge
47
+ errorRetryUntil: number; // S38: wall-clock ms before which the next nudge is suppressed (R1: now gating)
48
48
  // S38.6: circuit-breaker state — consecutive error turns across the session.
49
49
  // When this exceeds maxConsecutiveErrors, the extension stops retrying.
50
50
  consecutiveErrors: number; // reset to 0 on successful turn_end
51
+ // R1 (retry redesign): in-flight nudge dedup. A nudge queued via
52
+ // deliverAs:'followUp' must not be re-sent until it has been consumed by an
53
+ // actual new agent turn (turn_start) or superseded by a successful turn.
54
+ // Without this, a fast-erroring provider + a per-turn nudge → N nudges queue
55
+ // up and pi dispatches N retry turns, each re-submitting the same failing
56
+ // prompt (the 2026-07-28 incident: ~60-message spam storm).
57
+ lastErrorRetryAt: number; // wall-clock ms of the last fired nudge (diagnostics)
58
+ retryNudgePending: boolean; // true while a queued nudge awaits consumption
59
+ // R2: session-global cap. Total S38 nudges per session across ALL bursts;
60
+ // independent of the per-burst max and the circuit breaker. Hitting it is
61
+ // terminal for the session — the extension stops nudging entirely.
62
+ errorRetrySessionCount: number; // nudges fired this session (across all bursts)
63
+ // R3: poisoned-context detection state. The classifier is stateless; the
64
+ // stateful "repeated identical error text" signal is tracked here and upgrades
65
+ // a 'transient' classification to 'poisoned-context' after the threshold.
66
+ lastErrorText: string | undefined; // normalized error signature from the last error turn
67
+ errorTextRepeatCount: number; // consecutive count of identical error signatures
68
+ // R3b: one-per-session /clear advise message throttle.
69
+ poisonedAdviseSent: boolean; // true after the advise message fires once
70
+ // R3c: one guarded compact per error signature (avoids re-compacting the
71
+ // same poisoned region repeatedly).
72
+ poisonedCompactSignatures: Set<string>; // signatures already attempted a compact for
73
+ // R7: poisoned-context event counter for the dashboard.
74
+ poisonedCount: number;
51
75
  }
52
76
 
53
77
  // ── ownVersion ─────────────────────────────────────────────────────────────
@@ -158,6 +158,10 @@ export function snapshotImpl(self: RuntimeSnapshotContext, ctx?: ExtensionContex
158
158
  consecutiveErrors: self.rt.consecutiveErrors,
159
159
  ERROR_RETRY_MAX_CONSECUTIVE: self.config.maxConsecutiveErrors,
160
160
  errorRetryHardStop: self.config.errorRetryHardStop,
161
+ // R7 (retry redesign): session-cap + poisoned-context counters.
162
+ sessionRetryCount: self.rt.errorRetrySessionCount,
163
+ sessionRetryMax: self.config.errorRetrySessionMax,
164
+ poisonedCount: self.rt.poisonedCount,
161
165
  activeAgents: self.activeAgents,
162
166
  currentTurn: self.currentTurn,
163
167
  currentModel: self.currentModel,
@@ -5,7 +5,7 @@
5
5
  * Phase 2d (maximal split): the class body is field declarations, the
6
6
  * constructor, and 1-line delegates only. Every method body lives in its own
7
7
  * module following the context-interface + free-function + thin-delegate
8
- * pattern: pressure-getters.ts / reset-runtime.ts / append-event.ts /
8
+ * pattern: pressure-getters.ts / append-event.ts /
9
9
  * get-state-dir.ts / render-widget.ts / status.ts / engine-view.ts /
10
10
  * runtime-snapshot.ts / runtime-helpers.ts / effects.ts / game-state.ts /
11
11
  * capture-model.ts / bind-repo.ts / perf.ts. state.ts re-exports the class for
@@ -15,27 +15,16 @@
15
15
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
16
16
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
17
17
  import { join } from "node:path";
18
- import { type FSWatcher } from "node:fs";
18
+ import type { FSWatcher } from "node:fs";
19
19
  import { VectorStore } from "../../src/vectorStore.js";
20
- import { toEngineMessages } from "../../src/adapt.js";
20
+ import type { toEngineMessages } from "../../src/adapt.js";
21
21
  import { normalizeSessionId } from "../../src/store.js";
22
22
  import { Logger } from "../../src/log.js";
23
- import {
24
- type ModelSnapshot,
25
- type GameState,
26
- } from "../../src/store/sqlite.js";
27
- import {
28
- type MegaConfig,
29
- type PressureBand,
30
- } from "../mega-config.js";
23
+ import type { ModelSnapshot, GameState } from "../../src/store/sqlite.js";
24
+ import type { MegaConfig, PressureBand } from "../mega-config.js";
31
25
  import { Dashboard } from "../mega-dashboard.js";
32
- import {
33
- type SessionRuntime,
34
- } from "./helpers.js";
35
- import {
36
- type TickerEntry,
37
- type WidgetData,
38
- } from "./widget.js";
26
+ import type { SessionRuntime } from "./helpers.js";
27
+ import type { TickerEntry, WidgetData } from "./widget.js";
39
28
  import {
40
29
  ensureGameStateWatcherImpl,
41
30
  getCachedGameStateImpl,
@@ -59,7 +48,6 @@ import {
59
48
  effectiveThresholdImpl,
60
49
  pressureBandImpl,
61
50
  } from "./pressure-getters.js";
62
- import { resetRuntimeImpl } from "./reset-runtime.js";
63
51
  import { appendEventImpl } from "./append-event.js";
64
52
  import { getStateDirImpl } from "./get-state-dir.js";
65
53
  import { renderWidgetImpl } from "./render-widget.js";
@@ -95,6 +83,15 @@ export class MegaRuntime {
95
83
  errorRetryCount: 0,
96
84
  errorRetryUntil: 0,
97
85
  consecutiveErrors: 0,
86
+ // R1-R3 (retry redesign): in-flight dedup, session cap, poisoned-context state.
87
+ lastErrorRetryAt: 0,
88
+ retryNudgePending: false,
89
+ errorRetrySessionCount: 0,
90
+ lastErrorText: undefined,
91
+ errorTextRepeatCount: 0,
92
+ poisonedAdviseSent: false,
93
+ poisonedCompactSignatures: new Set(),
94
+ poisonedCount: 0,
98
95
  };
99
96
  // v0.8.6 cache-stability: the cached live-trim view for the current
100
97
  // compaction epoch. Set after a fresh runCompact + computeLiveTrimCut, and
@@ -125,7 +122,12 @@ export class MegaRuntime {
125
122
  * compaction start). Threaded into widgetData as `activeEffect`; the widget
126
123
  * computes the per-frame phase from startedAt vs Date.now() (non-expired).
127
124
  * Null when idle/expired. */
128
- activeEffect: { type: "pulse" | "flash"; role: "accent" | "mega" | "red"; startedAt: number; durationMs: number } | null = null;
125
+ activeEffect: {
126
+ type: "pulse" | "flash";
127
+ role: "accent" | "mega" | "red";
128
+ startedAt: number;
129
+ durationMs: number;
130
+ } | null = null;
129
131
  megaCacheFlarePct = 0;
130
132
  levelUpFlare = false;
131
133
  lastLevel = 0;
@@ -316,10 +318,54 @@ export class MegaRuntime {
316
318
  setStatusImpl(this, ctx, text);
317
319
  }
318
320
 
319
- /** Per-session state reset (session_start / session_tree) — thin delegate to
320
- * `resetRuntimeImpl` (reset-runtime.ts). */
321
+ /** Per-session state reset (session_start / session_tree) — inlined by the
322
+ * raptor-promotion merge (R1–R3 retry-redesign fields); reset-runtime.ts was
323
+ * retired by that branch. */
321
324
  resetRuntime(sessionId: string | undefined): void {
322
- resetRuntimeImpl(this, sessionId);
325
+ const sid = normalizeSessionId(sessionId);
326
+ if (this.rt.sessionId === sid && this.rt.persistedThisSession) return; // same session, keep checkpoint memory
327
+ this.rt = {
328
+ sessionId: sid,
329
+ persistedThisSession: false,
330
+ lastCheckpointId: undefined,
331
+ lastCompactedFrom: 0,
332
+ lastCompactedTokens: 0,
333
+ dedupSkips: 0,
334
+ dedupAttempts: 0,
335
+ tokensSaved: 0,
336
+ lastCompactAt: null,
337
+ lastNativeCompactAt: null,
338
+ compactCount: 0,
339
+ recallInjections: 0,
340
+ cacheHitTokens: 0,
341
+ lengthStopPending: false,
342
+ errorRetryCount: 0,
343
+ errorRetryUntil: 0,
344
+ consecutiveErrors: 0,
345
+ // R1-R3 (retry redesign): in-flight dedup, session cap, poisoned-context state.
346
+ lastErrorRetryAt: 0,
347
+ retryNudgePending: false,
348
+ errorRetrySessionCount: 0,
349
+ lastErrorText: undefined,
350
+ errorTextRepeatCount: 0,
351
+ poisonedAdviseSent: false,
352
+ poisonedCompactSignatures: new Set(),
353
+ poisonedCount: 0,
354
+ };
355
+ this.trimCache = null; // v0.8.6: never replay a stale trim into a new session
356
+ this.statusKey = undefined;
357
+ this.activeAgents = 0;
358
+ this.currentTurn = 0;
359
+ this.lastActivityAt = 0;
360
+ this.tierTrace = undefined;
361
+ this.ticker.length = 0;
362
+ this.pulsing = false;
363
+ this.savedGoal = 50_000;
364
+ this.lastWhy = undefined;
365
+ // S31 audit P2: symmetry with bindRepo — a reset can coincide with a context
366
+ // that re-binds the repo, so drop the memo too. Cheap; the next
367
+ // getCachedGameState() re-queries lazily.
368
+ this.cachedGameState = undefined;
323
369
  }
324
370
 
325
371
  captureModel(ctx: ExtensionContext): void {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.8.24",
3
+ "version": "0.8.26",
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",
@@ -7,6 +7,7 @@ function user(t: string): EngineMessage { return { role: "user", text: t }; }
7
7
  function assistant(t: string): EngineMessage { return { role: "assistant", text: t }; }
8
8
  function toolUse(n: string, i = "{}"): EngineMessage { return { role: "assistant", text: "", toolName: n, input: i }; }
9
9
  function toolResult(n: string, o = "ok"): EngineMessage { return { role: "tool", text: "", toolName: n, output: o }; }
10
+ function custom(t: string): EngineMessage { return { role: "custom", text: t }; }
10
11
 
11
12
  test("walks back so first preserved message is not an orphaned tool result", () => {
12
13
  const messages = [
@@ -25,9 +26,14 @@ test("walks back so first preserved message is not an orphaned tool result", ()
25
26
  assert.equal(kept[0].toolName, "search"); // assistant tool-call preserved
26
27
  });
27
28
 
28
- test("isBoundarySafe: tool result at boundary with preceding tool use is safe", () => {
29
+ test("isBoundarySafe: cut that drops a toolCall but keeps its toolResult is unsafe (PREVENT-PI-002)", () => {
29
30
  const messages = [user("a"), toolUse("search"), toolResult("search")];
30
- assert.equal(isBoundarySafe(messages, 2), true);
31
+ // keepFrom=2 drops the toolUse at index 1 but keeps the toolResult at index 2 →
32
+ // the preserved run starts on an orphaned tool result. This is the shape the old
33
+ // check mis-validated (it only compared messages[keepFrom] to messages[keepFrom-1]).
34
+ assert.equal(isBoundarySafe(messages, 2), false);
35
+ // keepFrom=1 keeps the toolCall together with its toolResult → safe.
36
+ assert.equal(isBoundarySafe(messages, 1), true);
31
37
  });
32
38
 
33
39
  test("isBoundarySafe: orphaned tool result without preceding tool use is unsafe", () => {
@@ -59,3 +65,123 @@ test("dropBefore returns original when range is empty", () => {
59
65
  const messages = [user("a"), assistant("b")];
60
66
  assert.equal(dropBefore(messages, 0, 1), messages);
61
67
  });
68
+
69
+ // --- PREVENT-PI-002 regression cases for arbitrary interleavings ---
70
+
71
+ test("interleaved custom message between toolCall and toolResult: walk-back keeps the call", () => {
72
+ // [user, assistant(tc=read), custom/bashExecution, tool(read-result)] with
73
+ // desired keepFrom=3. The old walk-back saw messages[2] was not a toolUse and
74
+ // broke at k=2, dropping the assistant toolCall at index 1 while KEEPING its
75
+ // tool result at index 3 → orphaned tool result → provider 400.
76
+ const messages = [
77
+ user("Search for files"),
78
+ toolUse("read"),
79
+ custom("bash: ls -la"),
80
+ toolResult("read", "file contents"),
81
+ assistant("Done."),
82
+ ];
83
+ const [start, end] = computeDropRange(messages, 3, 0);
84
+ assert.equal(start, 0);
85
+ assert.equal(end, 1); // keep the assistant toolCall at index 1 with its result
86
+ const kept = messages.slice(end);
87
+ assert.equal(kept[0].toolName, "read"); // assistant tool-call preserved
88
+ assert.ok(kept.some((m) => m.role === "tool" && m.toolName === "read"));
89
+ // The toolCall and its toolResult are both in the kept run.
90
+ const callIdx = kept.findIndex((m) => m.role === "assistant" && m.toolName === "read");
91
+ const resultIdx = kept.findIndex((m) => m.role === "tool" && m.toolName === "read");
92
+ assert.ok(callIdx !== -1 && resultIdx !== -1 && callIdx < resultIdx);
93
+ });
94
+
95
+ test("consecutive tool results sharing one call: no-op when the call cannot be kept", () => {
96
+ // [assistant(tc), T1, T2] with keepFrom=1: dropping the call orphans BOTH T1
97
+ // and T2. No pair-safe positive cut exists below keepFrom → no-op compaction
98
+ // (the [start,end) contract preserves a non-zero result only when one exists).
99
+ const messages = [toolUse("multi"), toolResult("multi", "r1"), toolResult("multi", "r2")];
100
+ const [start, end] = computeDropRange(messages, 1, 0);
101
+ assert.equal(start, 0);
102
+ assert.equal(end, 0); // no-op — pair rule outranks dropping
103
+ assert.equal(dropBefore(messages, 1, 0), messages);
104
+ });
105
+
106
+ test("consecutive tool results sharing one call: safe cut keeps the call with both results", () => {
107
+ // [user, assistant(tc), T1, T2, user2] — keepFrom=3 would orphan T2; the guard
108
+ // walks back to keep the call (dropEnd=1, only the first user is dropped).
109
+ const messages = [
110
+ user("u1"),
111
+ toolUse("multi"),
112
+ toolResult("multi", "r1"),
113
+ toolResult("multi", "r2"),
114
+ user("u2"),
115
+ ];
116
+ const [start, end] = computeDropRange(messages, 3, 0);
117
+ assert.equal(start, 0);
118
+ assert.equal(end, 1); // keep [assistant(tc), T1, T2, user2]
119
+ const kept = messages.slice(end);
120
+ const callIdx = kept.findIndex((m) => m.role === "assistant" && m.toolName === "multi");
121
+ const r1Idx = kept.findIndex((m) => m.role === "tool" && m.output === "r1");
122
+ const r2Idx = kept.findIndex((m) => m.role === "tool" && m.output === "r2");
123
+ assert.ok(callIdx < r1Idx && r1Idx < r2Idx);
124
+ });
125
+
126
+ test("consecutive shared-call results: dropping call + all results together is safe", () => {
127
+ // keepFrom=4 preserves only the trailing user — the call and BOTH results are
128
+ // dropped together, so nothing is orphaned.
129
+ const messages = [
130
+ user("u1"),
131
+ toolUse("multi"),
132
+ toolResult("multi", "r1"),
133
+ toolResult("multi", "r2"),
134
+ user("u2"),
135
+ ];
136
+ const [start, end] = computeDropRange(messages, 4, 0);
137
+ assert.equal(start, 0);
138
+ assert.equal(end, 4);
139
+ assert.deepEqual(messages.slice(end), [user("u2")]);
140
+ });
141
+
142
+ test("keepFrom landing on a call whose results follow is safe", () => {
143
+ // [user, assistant(tc1), T1, assistant(tc2), T2, user2] keepFrom=3 → preserved
144
+ // run starts on assistant(tc2) at index 3, whose result T2 follows. Safe.
145
+ const messages = [
146
+ user("u1"),
147
+ toolUse("read"),
148
+ toolResult("read", "r1"),
149
+ toolUse("write"),
150
+ toolResult("write", "r2"),
151
+ user("u2"),
152
+ ];
153
+ const [start, end] = computeDropRange(messages, 3, 0);
154
+ assert.equal(start, 0);
155
+ assert.equal(end, 3);
156
+ const kept = messages.slice(end);
157
+ assert.equal(kept[0].role, "assistant");
158
+ assert.equal(kept[0].toolName, "write");
159
+ assert.ok(kept.some((m) => m.role === "tool" && m.toolName === "write"));
160
+ });
161
+
162
+ test("anchor floor + pair-rule conflict: pair rule wins, drop less", () => {
163
+ // [assistant(tc), user, T] with anchor=1: the anchor floor wants dropEnd<=1
164
+ // (keep the user at index 1), but keeping from index 1 orphans T (its owner at
165
+ // index 0 would be dropped). The pair rule outranks the floor — we drop LESS,
166
+ // keeping everything (no-op) rather than cross a pair.
167
+ const messages = [toolUse("read"), user("keep me"), toolResult("read", "r")];
168
+ const out = dropBefore(messages, 2, 1);
169
+ assert.equal(out, messages, "anchor floor would orphan the tool result → no-op");
170
+ });
171
+
172
+ test("isBoundarySafe: interleaved custom between call and result is detected unsafe", () => {
173
+ const messages = [user("a"), toolUse("read"), custom("bash"), toolResult("read", "r")];
174
+ // keepFrom=3 drops the call at index 1, keeps the result at index 3 → unsafe.
175
+ assert.equal(isBoundarySafe(messages, 3), false);
176
+ // keepFrom=2 ALSO drops the call at index 1 (drop [0,2) = [user, toolUse]) and
177
+ // keeps the result at index 3 → still orphaned → unsafe.
178
+ assert.equal(isBoundarySafe(messages, 2), false);
179
+ // keepFrom=1 keeps the call (index 1) together with its result at index 3 → safe.
180
+ assert.equal(isBoundarySafe(messages, 1), true);
181
+ });
182
+
183
+ test("isBoundarySafe: cut before any tool result is safe", () => {
184
+ const messages = [user("a"), toolUse("read"), toolResult("read", "r"), assistant("done")];
185
+ assert.equal(isBoundarySafe(messages, 0), true); // out of range → safe
186
+ assert.equal(isBoundarySafe(messages, messages.length), true); // out of range → safe
187
+ });
package/src/boundary.ts CHANGED
@@ -5,10 +5,19 @@
5
5
  * 1. ANCHOR FLOOR: never drop the most recent N user messages.
6
6
  * 2. TOOL-PAIR: never split an assistant(toolCall) from its following
7
7
  * tool-result message — an orphaned `tool` role with no preceding
8
- * assistant tool call causes a 400 on the OpenAI-compat path.
8
+ * assistant tool call causes a 400 on the OpenAI-compat path. The pair
9
+ * invariant outranks the anchor floor: on conflict we drop LESS (lower the
10
+ * drop end), never cross a pair.
9
11
  *
10
12
  * The engine reasons over EngineMessage; the pi adapter maps role "tool" +
11
- * toolName to the tool-result shape.
13
+ * toolName to the tool-result shape. EngineMessage carries no tool-call id, so
14
+ * ownership is positional: a tool result's owner is its nearest preceding
15
+ * assistant tool-call (the last assistant message with a `toolName` before it).
16
+ * A preserved tool result is orphaned by a cut when its owner is dropped; the
17
+ * guard rejects any cut that drops an owner while preserving its result, for
18
+ * ARBITRARY interleavings (custom/non-tool messages between call and result,
19
+ * consecutive results sharing one call, a cut landing directly on a call whose
20
+ * results follow).
12
21
  */
13
22
 
14
23
  import type { EngineMessage } from "./types.js";
@@ -23,16 +32,53 @@ function hasToolUse(m: EngineMessage): boolean {
23
32
  return Boolean(m.toolName) && m.role !== "tool";
24
33
  }
25
34
 
35
+ /**
36
+ * Is the drop boundary at `dropEnd` pair-safe? The preserved run is
37
+ * [dropEnd, messages.length). The cut is pair-safe iff NO preserved tool result
38
+ * is orphaned: for every tool result at index >= dropEnd, its nearest preceding
39
+ * assistant tool-call must EXIST and be PRESERVED (index >= dropEnd). A tool
40
+ * result with no preceding assistant tool-call is already orphaned in the
41
+ * input — we treat that as unsafe too, so the guard never endorses shipping an
42
+ * orphaned result to the provider.
43
+ *
44
+ * O(messages.length) single forward pass; early-exits on the first orphan. The
45
+ * owner of each result is the most recent `hasToolUse` message seen so far
46
+ * (tracked across the whole stream, including dropped messages, because a
47
+ * dropped assistant tool-call is exactly the owner we must reject).
48
+ */
49
+ export function isPairSafe(messages: EngineMessage[], dropEnd: number): boolean {
50
+ if (dropEnd <= 0 || dropEnd >= messages.length) return true;
51
+ let lastToolCall = -1;
52
+ for (let i = 0; i < messages.length; i++) {
53
+ if (hasToolUse(messages[i])) lastToolCall = i;
54
+ if (i >= dropEnd && isToolResult(messages[i])) {
55
+ if (lastToolCall === -1) return false; // no preceding call → orphaned
56
+ if (lastToolCall < dropEnd) return false; // owner dropped → orphaned
57
+ }
58
+ }
59
+ return true;
60
+ }
61
+
26
62
  /**
27
63
  * Compute the safe drop range [dropStart, dropEnd) within `messages`.
28
- * `keepFrom` is the caller's desired first-preserved index. We then:
29
- * 1. Walk it back (lower dropEnd = keep more) so the first preserved message
30
- * is never an orphaned tool result (tool-pair invariant).
31
- * 2. Raise it (lower dropEnd) to the anchor floor so the last N user messages
32
- * are never dropped, when enough user messages exist.
33
64
  *
34
- * dropEnd is the first index KEPT. Returns [dropStart, dropEnd]; empty range
35
- * if nothing should be dropped.
65
+ * Contract:
66
+ * - `keepFrom` is the caller's desired first-preserved index (drop [0, keepFrom)).
67
+ * - `dropEnd` is the first index KEPT; we may LOWER it (keep more) to satisfy the
68
+ * guards, never raise it above keepFrom.
69
+ * - The anchor floor (PREVENT-PI-001) caps dropEnd at the index of the
70
+ * Nth-from-last user message so the last N user messages are never dropped.
71
+ * - The tool-pair invariant (PREVENT-PI-002) rejects any dropEnd that orphans a
72
+ * preserved tool result; on conflict with the anchor floor the pair rule wins
73
+ * (we drop less, never cross a pair).
74
+ * - We return the LARGEST pair-safe dropEnd <= min(keepFrom, anchorStart) so the
75
+ * caller drops as much as is safe. When no pair-safe positive cut exists at
76
+ * or below keepFrom, we return [0, 0] (no-op) — the pair rule outranks
77
+ * dropping. dropStart is always 0 today (we drop a prefix); reserved for
78
+ * future two-sided trimming.
79
+ *
80
+ * Returns [0, 0] (empty range, drop nothing) when keepFrom is out of range or no
81
+ * pair-safe positive cut exists.
36
82
  */
37
83
  export function computeDropRange(
38
84
  messages: EngineMessage[],
@@ -44,48 +90,38 @@ export function computeDropRange(
44
90
  const userIndexes: number[] = [];
45
91
  messages.forEach((m, i) => { if (m.role === "user") userIndexes.push(i); });
46
92
  const anchorActive = anchorUserMessages > 0 && userIndexes.length >= anchorUserMessages;
47
- const anchorStart = anchorActive ? userIndexes[userIndexes.length - anchorUserMessages] : 0;
48
- const floor = anchorActive ? anchorStart : 0;
93
+ const anchorStart = anchorActive ? userIndexes[userIndexes.length - anchorUserMessages] : keepFrom;
94
+ // Upper bound on dropEnd: never keep less than the caller asked (dropEnd <= keepFrom)
95
+ // and never drop a must-keep user message (dropEnd <= anchorStart).
96
+ const upperBound = Math.min(keepFrom, anchorActive ? anchorStart : keepFrom);
49
97
 
50
- // Walk back for the tool-pair invariant (keep more when needed).
51
- let k = keepFrom;
52
- while (k > floor) {
53
- const firstPreserved = messages[k];
54
- if (!firstPreserved || !isToolResult(firstPreserved)) break;
55
- const preceding = messages[k - 1];
56
- if (preceding && hasToolUse(preceding)) {
57
- k -= 1; // pair intact across boundary — include the assistant turn
58
- break;
59
- }
60
- k -= 1;
98
+ // Walk down from the upper bound to find the largest pair-safe cut. dropEnd=0
99
+ // (drop nothing) is always pair-safe; the loop finds the largest positive cut,
100
+ // and falls back to [0, 0] when none exists — the pair rule outranks dropping.
101
+ for (let dropEnd = upperBound; dropEnd > 0; dropEnd--) {
102
+ if (isPairSafe(messages, dropEnd)) return [0, dropEnd];
61
103
  }
62
- if (k < floor) k = floor;
63
-
64
- // Anchor floor: never drop a must-keep user message. Raise dropEnd so we keep
65
- // from anchorStart onward when the walk didn't already.
66
- if (anchorActive && k > anchorStart) k = anchorStart;
67
-
68
- if (k <= 0) return [0, 0];
69
- return [0, k];
104
+ return [0, 0];
70
105
  }
71
106
 
72
107
  /**
73
108
  * Validate that the intended split at `keepFrom` (drop [0, keepFrom), keep the
74
- * rest) does not start the preserved run on an orphaned tool result. Checks
75
- * messages[keepFrom] against messages[keepFrom-1] directly independent of the
76
- * walk-back that computeDropRange may apply.
109
+ * rest) does not orphan any preserved tool result. Checks the FULL preserved
110
+ * run, not just the first message, so it holds for arbitrary interleavings
111
+ * (custom messages between call and result, consecutive shared-call results, a
112
+ * cut landing on a call whose results follow). Used on the every-LLM-call
113
+ * live-trim hot path (extensions/mega-trim.ts) and by dropCompactedRange
114
+ * (src/adapt.ts).
77
115
  */
78
116
  export function isBoundarySafe(messages: EngineMessage[], keepFrom: number): boolean {
79
- if (keepFrom <= 0 || keepFrom >= messages.length) return true;
80
- const firstPreserved = messages[keepFrom];
81
- if (!isToolResult(firstPreserved)) return true;
82
- const preceding = messages[keepFrom - 1];
83
- return Boolean(preceding && hasToolUse(preceding));
117
+ return isPairSafe(messages, keepFrom);
84
118
  }
85
119
 
86
120
  /**
87
121
  * Drop everything before the safe keep-index, honoring both guards, returning
88
- * the filtered message list.
122
+ * the filtered message list. Returns the original array reference (unchanged)
123
+ * when the safe range is empty so callers can short-circuit on reference
124
+ * equality.
89
125
  */
90
126
  export function dropBefore(messages: EngineMessage[], keepFrom: number, anchorUserMessages: number): EngineMessage[] {
91
127
  const [dropStart, dropEnd] = computeDropRange(messages, keepFrom, anchorUserMessages);
package/src/canary.ts CHANGED
@@ -8,6 +8,16 @@
8
8
  * The controller owns a MUTABLE working copy of the dedup config; callers read
9
9
  * `controller.config` after each step. Tiers disabled via MARK_ONLY degrade
10
10
  * gracefully rather than fully off.
11
+ *
12
+ * Persistence design: disabled state is IN-MEMORY ONLY by design. The dedup
13
+ * config loads from MEGACOMPACT_* env vars (config/dedup.ts) with no durable
14
+ * save mechanism. `setEnabled` DOES mutate `this.config.L1_ENABLED` etc. so
15
+ * callers reading `controller.config` see the disabled state for the current
16
+ * session. On restart, env defaults re-apply and the canary sequences again
17
+ * from L0 — this is intentional: a tier disabled due to a cold cache or
18
+ * transient load gets a fresh evaluation each run rather than being locked
19
+ * out forever. To permanently disable a tier, set the corresponding
20
+ * MEGACOMPACT_*_ENABLED env var to false.
11
21
  */
12
22
 
13
23
  import type { DedupConfigShape, DedupTier } from "./config/dedup.js";