pi-mega-compact 0.20.85 → 0.20.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/config.js +9 -0
  2. package/dist/extensions/dashboard-server/routes-rag-settings-helpers.js +2 -0
  3. package/dist/extensions/mega-config.js +12 -0
  4. package/dist/extensions/mega-events/context-handler/gateCheck.js +27 -0
  5. package/dist/extensions/mega-events/context-handler/thrashGuard.js +186 -0
  6. package/dist/extensions/mega-events/context-handler.js +33 -1
  7. package/dist/extensions/mega-pipeline/compact/noop.js +104 -0
  8. package/dist/extensions/mega-pipeline/compact/run.js +268 -0
  9. package/dist/extensions/mega-pipeline/compact/vote.js +72 -0
  10. package/dist/extensions/mega-pipeline/compact.js +12 -343
  11. package/dist/extensions/mega-pipeline/recall/impl.js +258 -0
  12. package/dist/extensions/mega-pipeline/recall.js +6 -253
  13. package/dist/src/config.js +9 -0
  14. package/dist/src/failback/compact.js +109 -0
  15. package/dist/src/recall/readonly.js +39 -0
  16. package/dist/src/recall/recall3wf.fixture.js +67 -0
  17. package/dist/src/recall/validator.js +113 -0
  18. package/dist/src/recall/vote.js +217 -0
  19. package/dist/src/store/sqlite/fts5-search.js +26 -0
  20. package/dist/src/store/sqlite/meta.js +32 -0
  21. package/extensions/dashboard-server/routes-rag-settings-helpers.ts +9 -0
  22. package/extensions/mega-config-types.ts +13 -0
  23. package/extensions/mega-config.ts +12 -0
  24. package/extensions/mega-events/context-handler/gateCheck.ts +30 -0
  25. package/extensions/mega-events/context-handler/thrashGuard.ts +228 -0
  26. package/extensions/mega-events/context-handler.ts +36 -1
  27. package/extensions/mega-pipeline/compact/noop.ts +96 -0
  28. package/extensions/mega-pipeline/compact/run.ts +322 -0
  29. package/extensions/mega-pipeline/compact/vote.ts +85 -0
  30. package/extensions/mega-pipeline/compact.ts +12 -385
  31. package/extensions/mega-pipeline/recall/impl.ts +312 -0
  32. package/extensions/mega-pipeline/recall.ts +10 -306
  33. package/package.json +1 -1
  34. package/src/config.ts +12 -0
  35. package/src/failback/compact.ts +122 -0
  36. package/src/failback/types.ts +72 -0
  37. package/src/recall/readonly.ts +57 -0
  38. package/src/recall/recall3wf.fixture.ts +87 -0
  39. package/src/recall/validator.ts +150 -0
  40. package/src/recall/vote.ts +240 -0
  41. package/src/store/sqlite/fts5-search.ts +40 -0
  42. package/src/store/sqlite/meta.ts +36 -0
@@ -26,7 +26,11 @@ import { buildTailResult } from "./context-handler/tailResult.js";
26
26
  import { runTriggerGuard } from "./context-handler/triggerGuard.js";
27
27
  import { persistEpochAndMaintain } from "./context-handler/afterCompact.js";
28
28
  import { appendMirrorAndLedger } from "./context-handler/dbMirrorAppend.js";
29
- import { evaluateGate } from "./context-handler/gateCheck.js";
29
+ import { evaluateGate, thrashGuardBlocks } from "./context-handler/gateCheck.js";
30
+ import {
31
+ markCompactionFired,
32
+ evaluatePendingReduction,
33
+ } from "./context-handler/thrashGuard.js";
30
34
  import { invokePipeline } from "./context-handler/pipelineRun.js";
31
35
  import { buildLiveTrimView } from "./context-handler/liveTrim.js";
32
36
 
@@ -88,6 +92,14 @@ export function registerContextHandler(
88
92
  : null) ??
89
93
  Math.round(((pct ?? 0) / 100) * (usage?.contextWindow ?? 0));
90
94
  runtime.lastCtxTokens = currentTokens ?? null;
95
+ // 3WF-2: consume a pending live-window delta from a prior compaction. If a
96
+ // compaction fired on the previous context event and the live window did
97
+ // not shrink, this arms the ThrashGuard (meta). No-op when none pending.
98
+ try {
99
+ evaluatePendingReduction(runtime, currentTokens ?? 0, config);
100
+ } catch {
101
+ /* non-fatal */
102
+ }
91
103
  runtime.lastCtxPercent = pct ?? null;
92
104
  runtime.lastCtxWindow = usage?.contextWindow ?? 0;
93
105
  runtime.snapshot(ctx);
@@ -147,6 +159,18 @@ export function registerContextHandler(
147
159
  // else: context grew enough → fall through to re-compact (cache is stale)
148
160
  }
149
161
 
162
+ // 3WF-2 ThrashGuard: refuse a NEW compaction while armed (an ineffective
163
+ // prior compaction left the live window unshrunk). Sits AFTER the replay
164
+ // block — replay is free and must stay exempt — and BEFORE debounce +
165
+ // invokePipeline (the real fire point), so it covers the percent + token
166
+ // gate paths alike. Umbrella OFF ⇒ never blocks (byte-identical). Returns
167
+ // the tailed view so a staged recall block still rides along.
168
+ if (thrashGuardBlocks(runtime, config, currentTokens)) {
169
+ runtime.diagCtxFastGate++;
170
+ runtime.snapshot(ctx);
171
+ return tailResult() ?? undefined;
172
+ }
173
+
150
174
  // Debounce so we don't fire on every context event past threshold.
151
175
  // (Replay already returned above — only fresh compacts reach this point.)
152
176
  const now = Date.now();
@@ -171,6 +195,17 @@ export function registerContextHandler(
171
195
  // topic seed + fire-and-forget dedup. Best-effort + non-fatal.
172
196
  await persistEpochAndMaintain(runtime, config, pipeline.ran);
173
197
 
198
+ // 3WF-2: record the live-window baseline at the moment a compaction actually
199
+ // fired, so the NEXT context event can judge whether the window shrank. We
200
+ // use the LIVE currentTokens here (not ran.saved — that is the false
201
+ // stored-checkpoint metric the thrash bug used), matching the spec's
202
+ // "value observed just BEFORE that compaction fired" seam.
203
+ try {
204
+ markCompactionFired(runtime, currentTokens ?? 0);
205
+ } catch {
206
+ /* non-fatal */
207
+ }
208
+
174
209
  // LEGACY path (rollback): v0.4.28 ctx.compact() + the no-op gate. The
175
210
  // manual compact path aborts the in-flight turn — only used behind the flag.
176
211
  // Read live from env (in addition to the load-time config) so the flag can be
@@ -0,0 +1,96 @@
1
+ /**
2
+ * noop.ts — pi durable-compact no-op prediction (moved from compact.ts as part
3
+ * of the delegate-shell split; the shell re-exports the public API unchanged).
4
+ *
5
+ * See piCompactWouldNoop's JSDoc for the full rationale (predicting pi's
6
+ * `ctx.compact()` no-op throw before it surfaces a user-facing error).
7
+ */
8
+
9
+ import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
10
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
11
+ import { estimateBlockTokens } from "../../../src/tokens.js";
12
+
13
+ /**
14
+ * Predict whether pi's `ctx.compact()` would throw a no-op error — "Already
15
+ * compacted" or "Nothing to compact (session too small)" — so the auto-trigger
16
+ * can SKIP the call instead of surfacing a hard, user-facing error.
17
+ *
18
+ * Why we can't intercept or suppress it: pi's public `compact()` computes
19
+ * `prepareCompaction()` and throws *before* it emits `session_before_compact`,
20
+ * so our handler there never runs on the no-op path. And `ctx.compact()`'s
21
+ * `onError` callback fires only AFTER pi has already emitted a `compaction_end`
22
+ * event carrying the error message (which the interactive UI renders) — so
23
+ * `onError` cannot mute it either. The only robust fix is to not call
24
+ * `ctx.compact()` when pi would no-op. (pi's own `_runAutoCompaction` path is
25
+ * silent on this same condition; the public path we're forced through is the
26
+ * one that throws.)
27
+ *
28
+ * Skipping is correct, not a compromise: by the time this runs, `runCompact()`
29
+ * has already persisted the recall checkpoint (Path A). The durable on-disk
30
+ * trim is only useful when pi can actually summarize a region; a transcript
31
+ * under pi's `keepRecentTokens` budget is small enough that reloading it on
32
+ * resume isn't a token-growth problem, so the durable trim is unnecessary
33
+ * there anyway.
34
+ *
35
+ * Mirrors pi's `prepareCompaction()` return-undefined conditions (compaction.js):
36
+ * (1) last entry is a compaction → "Already compacted"
37
+ * (2) <2 cut-point messages since the last compaction → nothing to summarize
38
+ * (a cut point = any non-toolResult message — user/assistant/bash/custom/
39
+ * branchSummary/compactionSummary — matching pi's isCutPointMessage)
40
+ * (3) transcript tokens since the last compaction < keepRecentTokens → pi
41
+ * keeps everything → nothing to summarize
42
+ * `keepRecentTokens` isn't readable from the extension API, so (3) uses the pi
43
+ * default (20000) as a conservative floor; raise it via
44
+ * `MEGACOMPACT_DURABLE_TRIM_FLOOR` if you raise pi's `compact.keepRecentTokens`.
45
+ *
46
+ * Best-effort: on any read error returns true (skip) — skipping a durable trim
47
+ * is always safe; calling `ctx.compact()` on a no-op throws to the user.
48
+ */
49
+ export function piCompactWouldNoop(ctx: ExtensionContext): boolean {
50
+ try {
51
+ const branch = ctx.sessionManager.getBranch();
52
+ if (branch.length === 0) return true;
53
+ // (1) already compacted — pi throws "Already compacted"
54
+ if (branch[branch.length - 1].type === "compaction") return true;
55
+ // boundaryStart = index just after the most recent compaction entry (or 0)
56
+ let boundaryStart = 0;
57
+ for (let i = branch.length - 1; i >= 0; i--) {
58
+ if (branch[i].type === "compaction") { boundaryStart = i + 1; break; }
59
+ }
60
+ void boundaryStart;
61
+ let cutPoints = 0;
62
+ let tokens = 0;
63
+ for (let i = boundaryStart; i < branch.length; i++) {
64
+ const e = branch[i];
65
+ if (e.type === "compaction") continue;
66
+ let isCut = false;
67
+ for (const m of sessionEntryToContextMessages(e)) {
68
+ // pi's isCutPointMessage: every role except toolResult
69
+ if ((m as { role?: string }).role !== "toolResult") isCut = true;
70
+ const c = (m as { content?: unknown }).content;
71
+ const text =
72
+ typeof c === "string" ? c
73
+ : Array.isArray(c)
74
+ ? (c as { text?: string }[]).map((b) => b?.text ?? "").join(" ")
75
+ : "";
76
+ if (text) tokens += estimateBlockTokens(text);
77
+ }
78
+ if (isCut) cutPoints++;
79
+ }
80
+ // (2) need >=2 cut points so the kept cut isn't the first message
81
+ if (cutPoints < 2) return true;
82
+ // (3) transcript under pi's keepRecentTokens budget → pi keeps everything
83
+ if (tokens < durableTrimFloorTokens()) return true;
84
+ return false;
85
+ } catch {
86
+ return true; // safe: skip the durable trim rather than risk a user-facing throw
87
+ }
88
+ }
89
+
90
+ /** pi's default keepRecentTokens (compaction settings). Override with
91
+ * MEGACOMPACT_DURABLE_TRIM_FLOOR if you raise pi's compact.keepRecentTokens. */
92
+ function durableTrimFloorTokens(): number {
93
+ const raw = process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
94
+ if (raw !== undefined && Number.isFinite(Number(raw))) return Number(raw);
95
+ return 20_000;
96
+ }
@@ -0,0 +1,322 @@
1
+ /**
2
+ * run.ts — full compaction pipeline (Trident) + the 3WF-2 advisory vote wiring.
3
+ *
4
+ * `runCompact` runs the full Trident pipeline (fast-gate aside) and persists a
5
+ * checkpoint. Moved here from extensions/mega-pipeline/compact.ts as part of
6
+ * the delegate-shell split (the shell re-exports the public API unchanged).
7
+ *
8
+ * Behavior change in this file vs v0.20.83: AFTER compactSession returns a
9
+ * non-skipped result AND the 3WF umbrella flag (config.threeWayFailback) is ON,
10
+ * the 3-source vote (voteCandidate) runs OBSERVATIONALLY — it logs the outcome
11
+ * and never mutates the result. supersede stays exactly as src/engine.ts:143
12
+ * (the unchanged precondition): we do NOT change compactSession, do NOT
13
+ * overwrite result.summary, and do NOT re-persist a checkpoint. A rejected
14
+ * vote (returned null) keeps the supersede-only result — which is what happens
15
+ * when the vote does not mutate anything. Flag OFF ⇒ the vote code does not run
16
+ * at all (byte-identical to v0.20.83).
17
+ */
18
+
19
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
20
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
21
+ import { compactSession } from "../../../src/engine.js";
22
+ import type { EngineMessage } from "../../../src/types.js";
23
+ import { normalizeSessionId } from "../../../src/store.js";
24
+ import { repoKey } from "../../../src/store/repoKey.js";
25
+ import { touchSession, logDaily, incCompactCount, incCacheHitTokens } from "../../../src/store/sqlite.js";
26
+ import { consolidateMemories } from "../../../src/memory.js";
27
+ import {
28
+ type MegaRuntime,
29
+ C,
30
+ MARKER_TYPE,
31
+ } from "../../mega-runtime.js";
32
+ import { resolveRepoRoot, preserveRecentForPressure, type MegaConfig } from "../../mega-config.js";
33
+ import { runRaptor } from "../../../src/dedup/raptor/index.js";
34
+ import { isRaptorTreeFresh } from "../../../src/dedup/raptor/buildHistory.js";
35
+ import { loadDedupConfig } from "../../../src/config/dedup.js";
36
+ import { upsertEmbedding as indexUpsertEmbedding } from "../../../src/store/vectorIndex.js";
37
+ import { runMemoryReview } from "../memory-review.js";
38
+ import { vectorList } from "../../../src/vectorStore.js";
39
+ import { wireCompactVote } from "./vote.js";
40
+
41
+ export type RunCompactResult =
42
+ | { skipped: true }
43
+ | { skipped: false; result: ReturnType<typeof compactSession>; keepFrom: number; saved: number };
44
+
45
+ /** Run the full compaction pipeline and persist a checkpoint. Returns the result. */
46
+ export function runCompact(
47
+ pi: ExtensionAPI,
48
+ runtime: MegaRuntime,
49
+ config: MegaConfig,
50
+ ctx: ExtensionContext,
51
+ messages: AgentMessage[],
52
+ opts: { keepFrom?: number; summary?: string; compressionPressure?: number } = {},
53
+ ): RunCompactResult {
54
+ runtime.bindRepo(ctx.cwd);
55
+ const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
56
+ runtime.resetRuntime(sid);
57
+ runtime.rt.sessionId = sid;
58
+
59
+ const view = runtime.engineView(messages);
60
+ // keepFrom deepens with context pressure (Fix E): under high pressure we
61
+ // compact more of the session, down to the preserveRecentMin floor.
62
+ const preserve = preserveRecentForPressure(
63
+ opts.compressionPressure ?? 0,
64
+ config.preserveRecent,
65
+ config.preserveRecentMin,
66
+ );
67
+ const keepFrom = opts.keepFrom ?? Math.max(0, view.length - preserve);
68
+ // For very small sessions (fewer messages than preserveRecent), allow
69
+ // compacting everything except the last message — the user explicitly
70
+ // requested compaction, so don't refuse it just because the session is short.
71
+ if (keepFrom <= 0) {
72
+ if (view.length <= 1) return { skipped: true };
73
+ // Use the fallback: compact everything except the last message
74
+ const fallbackKeepFrom = view.length - 1;
75
+ return doCompact(view, fallbackKeepFrom, opts, sid, config, pi, ctx, runtime);
76
+ }
77
+
78
+ return doCompact(view, keepFrom, opts, sid, config, pi, ctx, runtime);
79
+ }
80
+
81
+ function doCompact(
82
+ view: EngineMessage[],
83
+ keepFrom: number,
84
+ opts: { keepFrom?: number; summary?: string; compressionPressure?: number },
85
+ sid: string,
86
+ config: MegaConfig,
87
+ pi: ExtensionAPI,
88
+ ctx: ExtensionContext,
89
+ runtime: MegaRuntime,
90
+ ): RunCompactResult {
91
+ runtime.pulsing = true; // animate the status line while the (sync) pipeline runs
92
+ runtime.setEffect?.("pulse", "accent", 1500); // v0.8.3: ambient border pulse during compaction
93
+ // S21.2: reset the per-compaction memory-op counter so the post-compact
94
+ // consolidate pass only fires when memory rows actually changed during the
95
+ // compaction window (turn_end → auto-review may have written some).
96
+ runtime.memoriesTouchedThisCompaction = 0;
97
+ const result = compactSession(
98
+ {
99
+ sessionId: sid,
100
+ messages: view,
101
+ keepFrom,
102
+ summary: opts.summary,
103
+ timestamp: Date.now(),
104
+ onTier: runtime.makeTierCallback(ctx),
105
+ compressionPressure: opts.compressionPressure,
106
+ },
107
+ runtime.store,
108
+ );
109
+ runtime.pulsing = false;
110
+
111
+ if (result.skipped) return { skipped: true };
112
+ if (!result.deduped) {
113
+ runtime.rt.persistedThisSession = true;
114
+ runtime.rt.lastCheckpointId = result.checkpointId;
115
+ }
116
+ runtime.rt.lastCompactedFrom = result.compactedFrom;
117
+ runtime.rt.lastCompactedTokens = result.tokenEstimate;
118
+ runtime.rt.dedupAttempts++;
119
+ // Honest "tokens saved" for this session-instance only:
120
+ // new checkpoint → original − stored
121
+ // deduped onto existing → whole original region (nothing new stored)
122
+ // Resets to 0 on session_start (rt is rebuilt) — so a fresh session shows 0
123
+ // while the repo's cumulative saved (SQLite meta) keeps the running total.
124
+ const saved = result.deduped
125
+ ? result.originalTokenEstimate
126
+ : Math.max(0, result.originalTokenEstimate - result.tokenEstimate);
127
+ runtime.rt.tokensSaved += saved;
128
+ runtime.rt.compactCount += 1;
129
+ incCompactCount(runtime.currentStateDir);
130
+ if (result.deduped) { runtime.rt.cacheHitTokens += saved; incCacheHitTokens(saved, runtime.currentStateDir); }
131
+ runtime.rt.lastCompactAt = Date.now();
132
+ if (result.deduped) runtime.rt.dedupSkips++;
133
+ // Grow the rolling "saved" goal so the progress bar always has a fresh
134
+ // denominator (we don't want it pinned at 100% once we pass an old target).
135
+ if (runtime.rt.tokensSaved > runtime.savedGoal) runtime.savedGoal = Math.ceil((runtime.rt.tokensSaved * 1.25) / 10_000) * 10_000;
136
+
137
+ // Live toolbar activity: what file/region just got compacted or deduped.
138
+ // Rendered via the rotating ticker line (see snapshot); the ring buffer is
139
+ // cycled one-per-repaint so the single line scrolls through recent files.
140
+ const files = result.filesModified ?? [];
141
+ const fileLabel = files.length
142
+ ? files.map((f) => f.split("/").pop() ?? f).slice(0, 2).join(", ")
143
+ : result.regionHash.slice(0, 8);
144
+ runtime.lastActivityAt = Date.now();
145
+ // Explain-why line: surfaced while fresh. Pulls the dedup reason (which for
146
+ // L2 includes the cosine sim) so the user sees WHY a region was kept/dropped.
147
+ runtime.lastWhy = result.deduped
148
+ ? `why: deduped@${result.dedupReason ?? "tier"}`
149
+ : `why: compacted → ${result.checkpointId}`;
150
+ // Recall/activity ticker: record this event in the ring buffer.
151
+ const savedK = (saved / 1000).toFixed(1);
152
+ runtime.pushTicker(
153
+ result.deduped
154
+ ? `${C.green}♻${C.reset} deduped ${fileLabel} · ${savedK}k saved`
155
+ : `${C.cyan}🗜${C.reset} ${result.checkpointId} · +${savedK}k · ${fileLabel}`,
156
+ );
157
+ // The per-tier trace has settled into the final outcome — fold it back into
158
+ // the activity line and stop showing the live trace.
159
+ runtime.tierTrace = undefined;
160
+
161
+ // Record session activity + a daily-log entry in the per-repo SQLite store
162
+ // (foundation for resume-sessions / daily-log features). Best-effort — never
163
+ // block a compaction on bookkeeping.
164
+ try {
165
+ const root = resolveRepoRoot(ctx.cwd);
166
+ touchSession(sid, root, runtime.currentStateDir);
167
+ logDaily(sid, "compact", result.checkpointId, saved, runtime.currentStateDir);
168
+ } catch {
169
+ /* non-fatal: stats bookkeeping only */
170
+ }
171
+
172
+ // S21.2: best-effort consolidation of near-duplicate memories for this repo.
173
+ // Runs after the per-repo stats touch so `consolidateMemories` can use the
174
+ // same stateDir. Non-fatal — a failed consolidate never blocks a compaction.
175
+ // Only runs when new memory ops landed in this pass (otherwise the prior
176
+ // compaction's consolidate already had its shot — re-running would just
177
+ // touch every row again with no merges).
178
+ if (!result.deduped && runtime.memoriesTouchedThisCompaction > 0) {
179
+ try {
180
+ const root = resolveRepoRoot(ctx.cwd);
181
+ void consolidateMemories(runtime.currentStateDir, root).then(
182
+ (n) => {
183
+ if (n > 0) runtime.pushTicker(`${C.green}∫${C.reset} consolidated ${n} memory dup${n === 1 ? "" : "s"}`);
184
+ },
185
+ () => {
186
+ /* swallow: consolidate failures must never surface to the user */
187
+ },
188
+ );
189
+ } catch {
190
+ /* non-fatal */
191
+ }
192
+ }
193
+
194
+ // S24 review-on-compact: when pressure is high, the just-compacted region is
195
+ // exactly the context worth remembering, so review it immediately rather than
196
+ // waiting for the next turn-cadence tick. Uses the shared runMemoryReview
197
+ // helper (fire-and-forget; doCompact is sync). Best-effort + non-fatal. Only
198
+ // fires above the `high` band so low-pressure compactions don't pay the cost.
199
+ if (!result.deduped && config.memoryAutoReview && runtime.pressureBand !== "low" && runtime.pressureBand !== "medium") {
200
+ void runMemoryReview(runtime, view, "pressure");
201
+ }
202
+
203
+ // Sentinel marker: a non-LLM bookkeeping entry so subsequent triggers can
204
+ // skip re-vectorizing an already-compacted region (zero token cost).
205
+ // v0.8.6: gate on !result.deduped so the marker ONLY lands when a genuinely
206
+ // new checkpoint was created. Without this, every dedup re-fire appended a
207
+ // fresh sentinel to the real transcript, bloating it and perturbing the
208
+ // provider KV-cache prefix (the alternating cache-miss regression). Matches
209
+ // the RAPTOR + vector-index blocks above, which are already !deduped-gated.
210
+ if (!result.deduped) {
211
+ pi.appendEntry(MARKER_TYPE, {
212
+ checkpointId: result.checkpointId,
213
+ regionHash: result.regionHash,
214
+ tokenEstimate: result.tokenEstimate,
215
+ deduped: result.deduped,
216
+ });
217
+ }
218
+
219
+ // Fix D: refresh the RAPTOR tree for this session so live recall (search) can
220
+ // serve high-level summaries. Best-effort + non-fatal: never block compaction.
221
+ // Budget-guarded (RAPTOR_BUDGET_MS) so it can't hang a large session.
222
+ if (config.raptorEnabled && !result.deduped) {
223
+ try {
224
+ const dd = loadDedupConfig();
225
+ const all = vectorList(runtime.store, sid);
226
+ const leaves = all.map((cp) => ({
227
+ id: cp.checkpointId,
228
+ messages: [],
229
+ sourceText: cp.normalizedText ?? cp.summary ?? cp.regionHash,
230
+ embedding: cp.embedding,
231
+ }));
232
+ if (leaves.length >= 2) {
233
+ // S42D: skip the rebuild when the last build is fresh (within
234
+ // RAPTOR_FRESHNESS_HOURS) and the checkpoint count hasn't drifted by
235
+ // more than 20%. avoids re-clustering on every compaction when the
236
+ // tree is still representative. 0 disables (always rebuild).
237
+ if (
238
+ dd.RAPTOR_FRESHNESS_HOURS > 0 &&
239
+ isRaptorTreeFresh(sid, runtime.currentStateDir, dd.RAPTOR_FRESHNESS_HOURS, all.length)
240
+ ) {
241
+ runtime.logger?.info("raptor_skip_fresh", { sessionId: sid });
242
+ } else {
243
+ // S25: stamp the tree with the newest checkpoint epoch so the
244
+ // freshness guard in raptorSearchHits can reject stale trees after a
245
+ // later compaction adds newer checkpoints.
246
+ const builtAt = all.length > 0 ? Math.max(...all.map((c) => c.timestamp)) : Date.now();
247
+ runRaptor(
248
+ leaves,
249
+ {
250
+ stateDir: runtime.currentStateDir,
251
+ sessionId: sid,
252
+ budgetMs: dd.RAPTOR_BUDGET_MS,
253
+ clustersPerLevel: dd.RAPTOR_CLUSTERS_PER_LEVEL,
254
+ consistencyThreshold: dd.RAPTOR_CONSISTENCY,
255
+ logger: runtime.logger,
256
+ builtAt: Number.isFinite(builtAt) ? builtAt : Date.now(),
257
+ },
258
+ );
259
+ }
260
+ }
261
+ } catch {
262
+ /* non-fatal: tree refresh never blocks a compaction */
263
+ }
264
+ }
265
+
266
+ // Slice 2: best-effort mirror of the new checkpoint into the async global
267
+ // PGlite/HNSW vector index. Fires once per compaction (not per-add), so the
268
+ // shared global dir is never hammered by concurrent test workers.
269
+ // Non-fatal: a WASM init failure degrades to the sync scan silently.
270
+ if (!result.deduped) {
271
+ try {
272
+ const all = vectorList(runtime.store, sid);
273
+ const latest = all.find((cp) => cp.checkpointId === result.checkpointId);
274
+ if (latest?.embedding) {
275
+ void indexUpsertEmbedding(
276
+ repoKey(runtime.currentStateDir),
277
+ sid,
278
+ latest.checkpointId,
279
+ latest.embedding,
280
+ ).catch(() => {
281
+ /* non-fatal: index refresh never blocks a compaction */
282
+ });
283
+ }
284
+ } catch {
285
+ /* non-fatal: index refresh never blocks a compaction */
286
+ }
287
+ }
288
+
289
+ runtime.setStatus(
290
+ ctx,
291
+ runtime.rt.persistedThisSession
292
+ ? `mega-compact: ${result.checkpointId} · ${saved} tok saved`
293
+ : `mega-compact: ready`,
294
+ );
295
+ runtime.logger.info("compact", {
296
+ sessionId: sid,
297
+ checkpointId: result.checkpointId ?? "(deduped)",
298
+ deduped: result.deduped,
299
+ tokenEstimate: saved,
300
+ compactedFrom: result.compactedFrom,
301
+ });
302
+ runtime.dashboard.event("compact", {
303
+ sessionId: sid,
304
+ checkpointId: result.checkpointId ?? "(deduped)",
305
+ deduped: result.deduped,
306
+ tokenEstimate: saved,
307
+ compactedFrom: result.compactedFrom,
308
+ });
309
+ runtime.snapshot(ctx);
310
+
311
+ // 3WF-2: OBSERVATIONAL vote only. supersede (src/engine.ts:143) is the
312
+ // unchanged precondition; this never mutates result or re-persists anything.
313
+ // The winner label + reduction are logged for telemetry. Non-fatal: any
314
+ // failure here must never break the compaction above.
315
+ try {
316
+ wireCompactVote(runtime, config, sid, result, view, keepFrom);
317
+ } catch {
318
+ /* non-fatal: telemetry-only vote must never break a compaction */
319
+ }
320
+
321
+ return { skipped: false, result, keepFrom, saved };
322
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * vote.ts — 3WF-2 observational vote wiring (thin adapter over
3
+ * src/failback/compact.ts). The ONLY behavior addition in Track A.
4
+ *
5
+ * Runs ONLY when the 3WF umbrella flag (config.threeWayFailback) is ON. After
6
+ * compactSession returns a non-skipped result, it votes the two competing
7
+ * summary candidates for the compacted region (`view.slice(0, keepFrom)` — the
8
+ * same slice compactSession compacts). The outcome is LOGGED (structured
9
+ * `compact_vote` event) and is purely observational: supersede (src/engine.ts:143)
10
+ * stays the unchanged precondition, result.summary is NOT overwritten, and no
11
+ * checkpoint is re-persisted. A null vote (rejected by the floor) means "keep
12
+ * the supersede-only result", which is exactly what happens when we don't touch
13
+ * the result. Flag OFF ⇒ this function is never called (byte-identical to
14
+ * v0.20.83). Non-fatal: the caller wraps it in try/catch and swallows.
15
+ */
16
+
17
+ import type { EngineMessage } from "../../../src/types.js";
18
+ import { voteCandidate } from "../../../src/failback/compact.js";
19
+ import type { MegaRuntime } from "../../mega-runtime.js";
20
+ import type { MegaConfig } from "../../mega-config.js";
21
+ import type { RunCompactResult } from "./run.js";
22
+
23
+ /**
24
+ * Wire the observational 3-source vote after a successful compaction.
25
+ * @param runtime the shared mega runtime (logger + dashboard).
26
+ * @param config mega config (flag gate).
27
+ * @param sid normalized session id.
28
+ * @param result the compactSession result (unmodified by this call).
29
+ * @param view the full engine view (region = view.slice(0, keepFrom)).
30
+ * @param keepFrom index where the verbatim tail starts.
31
+ */
32
+ export function wireCompactVote(
33
+ runtime: MegaRuntime,
34
+ config: MegaConfig,
35
+ sid: string,
36
+ result: Extract<RunCompactResult, { skipped: false }>["result"],
37
+ view: EngineMessage[],
38
+ keepFrom: number,
39
+ ): void {
40
+ // Flag OFF ⇒ do nothing (byte-identical to v0.20.83 behavior).
41
+ if (!config.threeWayFailback) return;
42
+
43
+ const tokensBefore = result.originalTokenEstimate;
44
+ const region = view.slice(0, keepFrom);
45
+ const winner = voteCandidate(region, tokensBefore);
46
+
47
+ // Observational only: pick a stable label for telemetry.
48
+ let label = "none";
49
+ let reduction = 0;
50
+ let signalPreserved = false;
51
+ let rejectedByFloor = false;
52
+ if (winner) {
53
+ // Structural label from the candidate itself — never sniff the summary text.
54
+ label = winner.source;
55
+ reduction = tokensBefore - winner.tokenEstimate;
56
+ signalPreserved = winner.signalPreserved;
57
+ } else {
58
+ // Distinguish "no candidate" from "candidate rejected by the floor".
59
+ // A null return from voteCandidate means the winner scored below the floor
60
+ // (or there were no candidates) — i.e. keep the supersede-only result.
61
+ rejectedByFloor = true;
62
+ }
63
+
64
+ runtime.logger?.info("compact_vote", {
65
+ sessionId: sid,
66
+ checkpointId: result.checkpointId ?? "(deduped)",
67
+ winner: label,
68
+ reduction,
69
+ signalPreserved,
70
+ rejectedByFloor,
71
+ tokensBefore,
72
+ });
73
+ try {
74
+ runtime.dashboard?.event("compact_vote", {
75
+ sessionId: sid,
76
+ checkpointId: result.checkpointId ?? "(deduped)",
77
+ winner: label,
78
+ reduction,
79
+ signalPreserved,
80
+ rejectedByFloor,
81
+ });
82
+ } catch {
83
+ /* non-fatal: dashboard probe must never break a compaction */
84
+ }
85
+ }