pi-mega-compact 0.20.85 → 0.20.86

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,388 +1,15 @@
1
1
  /**
2
- * compact.ts — full compaction pipeline (Trident) + pi no-op prediction.
3
- *
4
- * `runCompact` runs the full Trident pipeline (fast-gate aside) and persists a
5
- * checkpoint. `piCompactWouldNoop` predicts whether pi's `ctx.compact()` would
6
- * throw a no-op error. Both mutate the shared MegaRuntime (token accounting,
7
- * ticker, status, events) and are driven by the event + command handlers in
8
- * mega-events.ts / mega-commands.ts.
9
- */
10
-
11
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
- import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
13
- import type { AgentMessage } from "@earendil-works/pi-agent-core";
14
- import { compactSession } from "../../src/engine.js";
15
- import type { EngineMessage } from "../../src/types.js";
16
- import { normalizeSessionId } from "../../src/store.js";
17
- import { repoKey } from "../../src/store/repoKey.js";
18
- import { estimateBlockTokens } from "../../src/tokens.js";
19
- import { touchSession, logDaily, incCompactCount, incCacheHitTokens } from "../../src/store/sqlite.js";
20
- import { consolidateMemories } from "../../src/memory.js";
21
- import {
22
- type MegaRuntime,
23
- C,
24
- MARKER_TYPE,
25
- } from "../mega-runtime.js";
26
- import { resolveRepoRoot, preserveRecentForPressure, type MegaConfig } from "../mega-config.js";
27
- import { runRaptor } from "../../src/dedup/raptor/index.js";
28
- import { isRaptorTreeFresh } from "../../src/dedup/raptor/buildHistory.js";
29
- import { loadDedupConfig } from "../../src/config/dedup.js";
30
- import { upsertEmbedding as indexUpsertEmbedding } from "../../src/store/vectorIndex.js";
31
- import { runMemoryReview } from "./memory-review.js";
32
- import { vectorList } from "../../src/vectorStore.js";
33
-
34
- export type RunCompactResult =
35
- | { skipped: true }
36
- | { skipped: false; result: ReturnType<typeof compactSession>; keepFrom: number; saved: number };
37
-
38
- /** Run the full compaction pipeline and persist a checkpoint. Returns the result. */
39
- export function runCompact(
40
- pi: ExtensionAPI,
41
- runtime: MegaRuntime,
42
- config: MegaConfig,
43
- ctx: ExtensionContext,
44
- messages: AgentMessage[],
45
- opts: { keepFrom?: number; summary?: string; compressionPressure?: number } = {},
46
- ): RunCompactResult {
47
- runtime.bindRepo(ctx.cwd);
48
- const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
49
- runtime.resetRuntime(sid);
50
- runtime.rt.sessionId = sid;
51
-
52
- const view = runtime.engineView(messages);
53
- // keepFrom deepens with context pressure (Fix E): under high pressure we
54
- // compact more of the session, down to the preserveRecentMin floor.
55
- const preserve = preserveRecentForPressure(
56
- opts.compressionPressure ?? 0,
57
- config.preserveRecent,
58
- config.preserveRecentMin,
59
- );
60
- const keepFrom = opts.keepFrom ?? Math.max(0, view.length - preserve);
61
- // For very small sessions (fewer messages than preserveRecent), allow
62
- // compacting everything except the last message — the user explicitly
63
- // requested compaction, so don't refuse it just because the session is short.
64
- if (keepFrom <= 0) {
65
- if (view.length <= 1) return { skipped: true };
66
- // Use the fallback: compact everything except the last message
67
- const fallbackKeepFrom = view.length - 1;
68
- return doCompact(view, fallbackKeepFrom, opts, sid, config, pi, ctx, runtime);
69
- }
70
-
71
- return doCompact(view, keepFrom, opts, sid, config, pi, ctx, runtime);
72
- }
73
-
74
- function doCompact(
75
- view: EngineMessage[],
76
- keepFrom: number,
77
- opts: { keepFrom?: number; summary?: string; compressionPressure?: number },
78
- sid: string,
79
- config: MegaConfig,
80
- pi: ExtensionAPI,
81
- ctx: ExtensionContext,
82
- runtime: MegaRuntime,
83
- ): RunCompactResult {
84
- runtime.pulsing = true; // animate the status line while the (sync) pipeline runs
85
- runtime.setEffect?.("pulse", "accent", 1500); // v0.8.3: ambient border pulse during compaction
86
- // S21.2: reset the per-compaction memory-op counter so the post-compact
87
- // consolidate pass only fires when memory rows actually changed during the
88
- // compaction window (turn_end → auto-review may have written some).
89
- runtime.memoriesTouchedThisCompaction = 0;
90
- const result = compactSession(
91
- {
92
- sessionId: sid,
93
- messages: view,
94
- keepFrom,
95
- summary: opts.summary,
96
- timestamp: Date.now(),
97
- onTier: runtime.makeTierCallback(ctx),
98
- compressionPressure: opts.compressionPressure,
99
- },
100
- runtime.store,
101
- );
102
- runtime.pulsing = false;
103
-
104
- if (result.skipped) return { skipped: true };
105
- if (!result.deduped) {
106
- runtime.rt.persistedThisSession = true;
107
- runtime.rt.lastCheckpointId = result.checkpointId;
108
- }
109
- runtime.rt.lastCompactedFrom = result.compactedFrom;
110
- runtime.rt.lastCompactedTokens = result.tokenEstimate;
111
- runtime.rt.dedupAttempts++;
112
- // Honest "tokens saved" for this session-instance only:
113
- // new checkpoint → original − stored
114
- // deduped onto existing → whole original region (nothing new stored)
115
- // Resets to 0 on session_start (rt is rebuilt) — so a fresh session shows 0
116
- // while the repo's cumulative saved (SQLite meta) keeps the running total.
117
- const saved = result.deduped
118
- ? result.originalTokenEstimate
119
- : Math.max(0, result.originalTokenEstimate - result.tokenEstimate);
120
- runtime.rt.tokensSaved += saved;
121
- runtime.rt.compactCount += 1;
122
- incCompactCount(runtime.currentStateDir);
123
- if (result.deduped) { runtime.rt.cacheHitTokens += saved; incCacheHitTokens(saved, runtime.currentStateDir); }
124
- runtime.rt.lastCompactAt = Date.now();
125
- if (result.deduped) runtime.rt.dedupSkips++;
126
- // Grow the rolling "saved" goal so the progress bar always has a fresh
127
- // denominator (we don't want it pinned at 100% once we pass an old target).
128
- if (runtime.rt.tokensSaved > runtime.savedGoal) runtime.savedGoal = Math.ceil((runtime.rt.tokensSaved * 1.25) / 10_000) * 10_000;
129
-
130
- // Live toolbar activity: what file/region just got compacted or deduped.
131
- // Rendered via the rotating ticker line (see snapshot); the ring buffer is
132
- // cycled one-per-repaint so the single line scrolls through recent files.
133
- const files = result.filesModified ?? [];
134
- const fileLabel = files.length
135
- ? files.map((f) => f.split("/").pop() ?? f).slice(0, 2).join(", ")
136
- : result.regionHash.slice(0, 8);
137
- runtime.lastActivityAt = Date.now();
138
- // Explain-why line: surfaced while fresh. Pulls the dedup reason (which for
139
- // L2 includes the cosine sim) so the user sees WHY a region was kept/dropped.
140
- runtime.lastWhy = result.deduped
141
- ? `why: deduped@${result.dedupReason ?? "tier"}`
142
- : `why: compacted → ${result.checkpointId}`;
143
- // Recall/activity ticker: record this event in the ring buffer.
144
- const savedK = (saved / 1000).toFixed(1);
145
- runtime.pushTicker(
146
- result.deduped
147
- ? `${C.green}♻${C.reset} deduped ${fileLabel} · ${savedK}k saved`
148
- : `${C.cyan}🗜${C.reset} ${result.checkpointId} · +${savedK}k · ${fileLabel}`,
149
- );
150
- // The per-tier trace has settled into the final outcome — fold it back into
151
- // the activity line and stop showing the live trace.
152
- runtime.tierTrace = undefined;
153
-
154
- // Record session activity + a daily-log entry in the per-repo SQLite store
155
- // (foundation for resume-sessions / daily-log features). Best-effort — never
156
- // block a compaction on bookkeeping.
157
- try {
158
- const root = resolveRepoRoot(ctx.cwd);
159
- touchSession(sid, root, runtime.currentStateDir);
160
- logDaily(sid, "compact", result.checkpointId, saved, runtime.currentStateDir);
161
- } catch {
162
- /* non-fatal: stats bookkeeping only */
163
- }
164
-
165
- // S21.2: best-effort consolidation of near-duplicate memories for this repo.
166
- // Runs after the per-repo stats touch so `consolidateMemories` can use the
167
- // same stateDir. Non-fatal — a failed consolidate never blocks a compaction.
168
- // Only runs when new memory ops landed in this pass (otherwise the prior
169
- // compaction's consolidate already had its shot — re-running would just
170
- // touch every row again with no merges).
171
- if (!result.deduped && runtime.memoriesTouchedThisCompaction > 0) {
172
- try {
173
- const root = resolveRepoRoot(ctx.cwd);
174
- void consolidateMemories(runtime.currentStateDir, root).then(
175
- (n) => {
176
- if (n > 0) runtime.pushTicker(`${C.green}∫${C.reset} consolidated ${n} memory dup${n === 1 ? "" : "s"}`);
177
- },
178
- () => {
179
- /* swallow: consolidate failures must never surface to the user */
180
- },
181
- );
182
- } catch {
183
- /* non-fatal */
184
- }
185
- }
186
-
187
- // S24 review-on-compact: when pressure is high, the just-compacted region is
188
- // exactly the context worth remembering, so review it immediately rather than
189
- // waiting for the next turn-cadence tick. Uses the shared runMemoryReview
190
- // helper (fire-and-forget; doCompact is sync). Best-effort + non-fatal. Only
191
- // fires above the `high` band so low-pressure compactions don't pay the cost.
192
- if (!result.deduped && config.memoryAutoReview && runtime.pressureBand !== "low" && runtime.pressureBand !== "medium") {
193
- void runMemoryReview(runtime, view, "pressure");
194
- }
195
-
196
- // Sentinel marker: a non-LLM bookkeeping entry so subsequent triggers can
197
- // skip re-vectorizing an already-compacted region (zero token cost).
198
- // v0.8.6: gate on !result.deduped so the marker ONLY lands when a genuinely
199
- // new checkpoint was created. Without this, every dedup re-fire appended a
200
- // fresh sentinel to the real transcript, bloating it and perturbing the
201
- // provider KV-cache prefix (the alternating cache-miss regression). Matches
202
- // the RAPTOR + vector-index blocks above, which are already !deduped-gated.
203
- if (!result.deduped) {
204
- pi.appendEntry(MARKER_TYPE, {
205
- checkpointId: result.checkpointId,
206
- regionHash: result.regionHash,
207
- tokenEstimate: result.tokenEstimate,
208
- deduped: result.deduped,
209
- });
210
- }
211
-
212
- // Fix D: refresh the RAPTOR tree for this session so live recall (search) can
213
- // serve high-level summaries. Best-effort + non-fatal: never block compaction.
214
- // Budget-guarded (RAPTOR_BUDGET_MS) so it can't hang a large session.
215
- if (config.raptorEnabled && !result.deduped) {
216
- try {
217
- const dd = loadDedupConfig();
218
- const all = vectorList(runtime.store, sid);
219
- const leaves = all.map((cp) => ({
220
- id: cp.checkpointId,
221
- messages: [],
222
- sourceText: cp.normalizedText ?? cp.summary ?? cp.regionHash,
223
- embedding: cp.embedding,
224
- }));
225
- if (leaves.length >= 2) {
226
- // S42D: skip the rebuild when the last build is fresh (within
227
- // RAPTOR_FRESHNESS_HOURS) and the checkpoint count hasn't drifted by
228
- // more than 20%. avoids re-clustering on every compaction when the
229
- // tree is still representative. 0 disables (always rebuild).
230
- if (
231
- dd.RAPTOR_FRESHNESS_HOURS > 0 &&
232
- isRaptorTreeFresh(sid, runtime.currentStateDir, dd.RAPTOR_FRESHNESS_HOURS, all.length)
233
- ) {
234
- runtime.logger?.info("raptor_skip_fresh", { sessionId: sid });
235
- } else {
236
- // S25: stamp the tree with the newest checkpoint epoch so the
237
- // freshness guard in raptorSearchHits can reject stale trees after a
238
- // later compaction adds newer checkpoints.
239
- const builtAt = all.length > 0 ? Math.max(...all.map((c) => c.timestamp)) : Date.now();
240
- runRaptor(
241
- leaves,
242
- {
243
- stateDir: runtime.currentStateDir,
244
- sessionId: sid,
245
- budgetMs: dd.RAPTOR_BUDGET_MS,
246
- clustersPerLevel: dd.RAPTOR_CLUSTERS_PER_LEVEL,
247
- consistencyThreshold: dd.RAPTOR_CONSISTENCY,
248
- logger: runtime.logger,
249
- builtAt: Number.isFinite(builtAt) ? builtAt : Date.now(),
250
- },
251
- );
252
- }
253
- }
254
- } catch {
255
- /* non-fatal: tree refresh never blocks a compaction */
256
- }
257
- }
258
-
259
- // Slice 2: best-effort mirror of the new checkpoint into the async global
260
- // PGlite/HNSW vector index. Fires once per compaction (not per-add), so the
261
- // shared global dir is never hammered by concurrent test workers.
262
- // Non-fatal: a WASM init failure degrades to the sync scan silently.
263
- if (!result.deduped) {
264
- try {
265
- const all = vectorList(runtime.store, sid);
266
- const latest = all.find((cp) => cp.checkpointId === result.checkpointId);
267
- if (latest?.embedding) {
268
- void indexUpsertEmbedding(
269
- repoKey(runtime.currentStateDir),
270
- sid,
271
- latest.checkpointId,
272
- latest.embedding,
273
- ).catch(() => {
274
- /* non-fatal: index refresh never blocks a compaction */
275
- });
276
- }
277
- } catch {
278
- /* non-fatal: index refresh never blocks a compaction */
279
- }
280
- }
281
-
282
- runtime.setStatus(
283
- ctx,
284
- runtime.rt.persistedThisSession
285
- ? `mega-compact: ${result.checkpointId} · ${saved} tok saved`
286
- : `mega-compact: ready`,
287
- );
288
- runtime.logger.info("compact", {
289
- sessionId: sid,
290
- checkpointId: result.checkpointId ?? "(deduped)",
291
- deduped: result.deduped,
292
- tokenEstimate: saved,
293
- compactedFrom: result.compactedFrom,
294
- });
295
- runtime.dashboard.event("compact", {
296
- sessionId: sid,
297
- checkpointId: result.checkpointId ?? "(deduped)",
298
- deduped: result.deduped,
299
- tokenEstimate: saved,
300
- compactedFrom: result.compactedFrom,
301
- });
302
- runtime.snapshot(ctx);
303
- return { skipped: false, result, keepFrom, saved };
304
- }
305
-
306
- /**
307
- * Predict whether pi's `ctx.compact()` would throw a no-op error — "Already
308
- * compacted" or "Nothing to compact (session too small)" — so the auto-trigger
309
- * can SKIP the call instead of surfacing a hard, user-facing error.
310
- *
311
- * Why we can't intercept or suppress it: pi's public `compact()` computes
312
- * `prepareCompaction()` and throws *before* it emits `session_before_compact`,
313
- * so our handler there never runs on the no-op path. And `ctx.compact()`'s
314
- * `onError` callback fires only AFTER pi has already emitted a `compaction_end`
315
- * event carrying the error message (which the interactive UI renders) — so
316
- * `onError` cannot mute it either. The only robust fix is to not call
317
- * `ctx.compact()` when pi would no-op. (pi's own `_runAutoCompaction` path is
318
- * silent on this same condition; the public path we're forced through is the
319
- * one that throws.)
320
- *
321
- * Skipping is correct, not a compromise: by the time this runs, `runCompact()`
322
- * has already persisted the recall checkpoint (Path A). The durable on-disk
323
- * trim is only useful when pi can actually summarize a region; a transcript
324
- * under pi's `keepRecentTokens` budget is small enough that reloading it on
325
- * resume isn't a token-growth problem, so the durable trim is unnecessary
326
- * there anyway.
327
- *
328
- * Mirrors pi's `prepareCompaction()` return-undefined conditions (compaction.js):
329
- * (1) last entry is a compaction → "Already compacted"
330
- * (2) <2 cut-point messages since the last compaction → nothing to summarize
331
- * (a cut point = any non-toolResult message — user/assistant/bash/custom/
332
- * branchSummary/compactionSummary — matching pi's isCutPointMessage)
333
- * (3) transcript tokens since the last compaction < keepRecentTokens → pi
334
- * keeps everything → nothing to summarize
335
- * `keepRecentTokens` isn't readable from the extension API, so (3) uses the pi
336
- * default (20000) as a conservative floor; raise it via
337
- * `MEGACOMPACT_DURABLE_TRIM_FLOOR` if you raise pi's `compact.keepRecentTokens`.
338
- *
339
- * Best-effort: on any read error returns true (skip) — skipping a durable trim
340
- * is always safe; calling `ctx.compact()` on a no-op throws to the user.
2
+ * compact.ts — delegate-shell for the compaction pipeline (3WF-2 split).
3
+ *
4
+ * This file is now a thin barrel: the real bodies live in `./compact/` so the
5
+ * module stays well under the extensions/ 400-line soft cap. The public API is
6
+ * UNCHANGED every existing importer (via `../mega-pipeline.js`, which does
7
+ * `export * from "./mega-pipeline/compact.js"`) keeps working with ZERO changes
8
+ * at their call sites. Exports preserved exactly:
9
+ * - `RunCompactResult` (type)
10
+ * - `runCompact`
11
+ * - `piCompactWouldNoop`
341
12
  */
342
- export function piCompactWouldNoop(ctx: ExtensionContext): boolean {
343
- try {
344
- const branch = ctx.sessionManager.getBranch();
345
- if (branch.length === 0) return true;
346
- // (1) already compacted — pi throws "Already compacted"
347
- if (branch[branch.length - 1].type === "compaction") return true;
348
- // boundaryStart = index just after the most recent compaction entry (or 0)
349
- let boundaryStart = 0;
350
- for (let i = branch.length - 1; i >= 0; i--) {
351
- if (branch[i].type === "compaction") { boundaryStart = i + 1; break; }
352
- }
353
- let cutPoints = 0;
354
- let tokens = 0;
355
- for (let i = boundaryStart; i < branch.length; i++) {
356
- const e = branch[i];
357
- if (e.type === "compaction") continue;
358
- let isCut = false;
359
- for (const m of sessionEntryToContextMessages(e)) {
360
- // pi's isCutPointMessage: every role except toolResult
361
- if ((m as { role?: string }).role !== "toolResult") isCut = true;
362
- const c = (m as { content?: unknown }).content;
363
- const text =
364
- typeof c === "string" ? c
365
- : Array.isArray(c)
366
- ? (c as { text?: string }[]).map((b) => b?.text ?? "").join(" ")
367
- : "";
368
- if (text) tokens += estimateBlockTokens(text);
369
- }
370
- if (isCut) cutPoints++;
371
- }
372
- // (2) need >=2 cut points so the kept cut isn't the first message
373
- if (cutPoints < 2) return true;
374
- // (3) transcript under pi's keepRecentTokens budget → pi keeps everything
375
- if (tokens < durableTrimFloorTokens()) return true;
376
- return false;
377
- } catch {
378
- return true; // safe: skip the durable trim rather than risk a user-facing throw
379
- }
380
- }
381
13
 
382
- /** pi's default keepRecentTokens (compaction settings). Override with
383
- * MEGACOMPACT_DURABLE_TRIM_FLOOR if you raise pi's compact.keepRecentTokens. */
384
- function durableTrimFloorTokens(): number {
385
- const raw = process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
386
- if (raw !== undefined && Number.isFinite(Number(raw))) return Number(raw);
387
- return 20_000;
388
- }
14
+ export { runCompact, type RunCompactResult } from "./compact/run.js";
15
+ export { piCompactWouldNoop } from "./compact/noop.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.20.85",
3
+ "version": "0.20.86",
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",
@@ -0,0 +1,122 @@
1
+ /**
2
+ * src/failback/compact.ts — 3WF-2 candidate-veto + vote module (pure, advisory).
3
+ *
4
+ * The production bug this fixes: compaction "succeeded" (a checkpoint was
5
+ * persisted, `saved` grew) while the LIVE WINDOW (`currentTokens`) never
6
+ * shrank — because `saved` is a cumulative SQLite total, not the working-set
7
+ * delta. This module builds competing summary candidates (extractive vs
8
+ * cluster/raptor) and VOTES which one, if any, is worth replacing the
9
+ * supersede-only result. It is purely advisory/observational: it never mutates
10
+ * a checkpoint, never overwrites `result.summary`, and returning `null` means
11
+ * "keep the supersede-only result" — the caller must NOT substitute a summary.
12
+ *
13
+ * Pure: no store mutation, no I/O, no network, no console.*. pi-agnostic
14
+ * (imports only from src/). Designed for the 3WF umbrella flag gate at the
15
+ * extension layer (see extensions/mega-pipeline/compact/vote.ts).
16
+ */
17
+
18
+ import type { EngineMessage } from "../types.js";
19
+ import { collectRecentUserRequests, summarizeMessages } from "../compact.js";
20
+ import { summarizeCluster } from "../dedup/raptor/summarizer.js";
21
+ import { estimateBlockTokens } from "../tokens.js";
22
+ import type { CompactCandidate } from "./types.js";
23
+
24
+ /**
25
+ * Default floor (tokens of net reduction) below which a candidate vote is
26
+ * REJECTED, returning `null` (keep the supersede-only result).
27
+ *
28
+ * Rationale: a candidate that reduces the region by fewer than 1 token is not
29
+ * meaningfully smaller than the compacted region it would replace — swapping
30
+ * the supersede-only result for it buys nothing and only adds a (possibly
31
+ * less faithful) summary. The floor therefore requires the voted summary to be
32
+ * STRICTLY smaller than the compacted region. Set to 1 (minimally defensible:
33
+ * the summary must actually be smaller). Overridable via `opts.floor`.
34
+ */
35
+ export const DEFAULT_VOTE_FLOOR_TOKENS = 1;
36
+
37
+ /** Strip a trailing ellipsis/truncation marker from a needle before containment. */
38
+ function stripEllipsis(s: string): string {
39
+ // collectRecentUserRequests truncates to 160 chars via compact.ts's truncate,
40
+ // which appends the U+2026 ellipsis when it cuts. Drop it for a fair test.
41
+ return s.replace(/…\s*$/u, "").trim();
42
+ }
43
+
44
+ /** Normalize for containment: collapse whitespace, lowercase. */
45
+ function normalize(s: string): string {
46
+ return s.replace(/\s+/g, " ").trim().toLowerCase();
47
+ }
48
+
49
+ /** True when `summary` contains the content of EVERY recent user request. */
50
+ export function signalPreserved(summary: string, messages: EngineMessage[]): boolean {
51
+ const requests = collectRecentUserRequests(messages, 3);
52
+ if (requests.length === 0) return true; // nothing to preserve
53
+ const haystack = normalize(summary);
54
+ return requests.every((r) => haystack.includes(normalize(stripEllipsis(r))));
55
+ }
56
+
57
+ /**
58
+ * Build the two competing candidates (extractive + cluster/raptor) for a
59
+ * compacted message region. Degenerate (empty/whitespace-only) summaries are
60
+ * VETOED — never returned. Both candidates use estimateBlockTokens(summary) for
61
+ * a single consistent token basis so the vote compares like with like (the
62
+ * cluster path's own tokenEstimate is intentionally ignored for fairness).
63
+ */
64
+ export function buildCandidates(messages: EngineMessage[]): CompactCandidate[] {
65
+ const out: CompactCandidate[] = [];
66
+
67
+ const extractive = summarizeMessages(messages);
68
+ if (extractive.trim().length > 0) {
69
+ out.push({
70
+ source: "extractive",
71
+ summary: extractive,
72
+ tokenEstimate: estimateBlockTokens(extractive),
73
+ signalPreserved: signalPreserved(extractive, messages),
74
+ });
75
+ }
76
+
77
+ // summarizeCluster returns deterministic extractive when MEGACOMPACT_RAPTOR_MODEL
78
+ // is unset and the local-only Ollama variant when set — so using it makes the
79
+ // Ollama path an insertion that adds NO new LLM call site for the on-by-default
80
+ // extraction. No behavior change for the default config.
81
+ const cluster = summarizeCluster(messages).summary;
82
+ if (cluster.trim().length > 0) {
83
+ out.push({
84
+ source: "cluster",
85
+ summary: cluster,
86
+ tokenEstimate: estimateBlockTokens(cluster),
87
+ signalPreserved: signalPreserved(cluster, messages),
88
+ });
89
+ }
90
+
91
+ return out;
92
+ }
93
+
94
+ /**
95
+ * Vote the best candidate. `score = reduction * (signalPreserved ? 1 : 0.5)`
96
+ * where `reduction = tokensBefore - candidate.tokenEstimate`. Ties resolve to
97
+ * the EARLIER (extractive) candidate for determinism. Returns `null` when the
98
+ * winner's score is below `opts.floor` (default DEFAULT_VOTE_FLOOR_TOKENS) —
99
+ * caller MUST keep the supersede-only result and must NOT substitute a summary.
100
+ */
101
+ export function voteCandidate(
102
+ messages: EngineMessage[],
103
+ tokensBefore: number,
104
+ opts: { floor?: number } = {},
105
+ ): CompactCandidate | null {
106
+ const floor = opts.floor ?? DEFAULT_VOTE_FLOOR_TOKENS;
107
+ const candidates = buildCandidates(messages);
108
+ let best: CompactCandidate | null = null;
109
+ let bestScore = -Infinity;
110
+ for (const c of candidates) {
111
+ const reduction = tokensBefore - c.tokenEstimate;
112
+ const score = reduction * (c.signalPreserved ? 1 : 0.5);
113
+ // Earlier candidate wins ties (strict > keeps insertion order = extractive first).
114
+ if (score > bestScore) {
115
+ bestScore = score;
116
+ best = c;
117
+ }
118
+ }
119
+ if (best === null) return null;
120
+ if (bestScore < floor) return null;
121
+ return best;
122
+ }
@@ -44,3 +44,47 @@ export interface GuardOpts {
44
44
  /** Max hits to recall (mirrors autoInlineK). */
45
45
  limit: number;
46
46
  }
47
+
48
+ /** A competing compaction summary candidate produced by the 3-source vote. */
49
+ export interface CompactCandidate {
50
+ /** Which generator produced this candidate (structural telemetry label —
51
+ * never infer the source by sniffing the summary text). */
52
+ source: "extractive" | "cluster";
53
+ /** The candidate summary text (extractive or cluster/raptor variant). */
54
+ summary: string;
55
+ /** Estimated token cost of the candidate summary (estimateBlockTokens basis). */
56
+ tokenEstimate: number;
57
+ /** True when the summary preserves every recent user request signal. */
58
+ signalPreserved: boolean;
59
+ }
60
+
61
+ /**
62
+ * The measured reduction verdict across consecutive `context` events in the
63
+ * LIVE WINDOW (the model's current working tokens), NOT the stored-checkpoint
64
+ * `saved` metric. The live-window `currentTokens` delta is the real signal of
65
+ * whether compaction actually freed working context; the stored `saved` field
66
+ * is a cumulative SQLite total that can look healthy while the live window is
67
+ * unchanged — the false metric behind the production thrash bug this sprint
68
+ * fixes. `liveBefore`/`liveAfter` are the live-window token counts bracketing
69
+ * the compaction.
70
+ */
71
+ export interface ReductionVerdict {
72
+ /** True when the live window measurably shrank after compaction. */
73
+ effective: boolean;
74
+ /** Live-window token count before the compaction event. */
75
+ liveBefore: number;
76
+ /** Live-window token count after the compaction event. */
77
+ liveAfter: number;
78
+ }
79
+
80
+ /**
81
+ * State of the compaction thrash guard. Arms after a compaction that produced
82
+ * no live-window reduction, so we do not re-fire into a window that cannot
83
+ * shrink. Re-arms or clears as the live window grows again.
84
+ */
85
+ export interface ThrashGuardState {
86
+ /** Live-window token count below which re-firing is refused (guard active). */
87
+ blockedUntilTokens: number;
88
+ /** ms epoch at which the guard was armed. */
89
+ armedAt: number;
90
+ }
@@ -55,6 +55,42 @@ export function getMetaNumber(key: string, stateDir: string = getStateDir()): nu
55
55
  return Number.isFinite(n) ? n : 0;
56
56
  }
57
57
 
58
+ /**
59
+ * Upsert a single numeric meta key to an absolute value (NOT a cumulative
60
+ * counter). Follows the `addTokensSaved` INSERT-ON-CONFLICT pattern with a
61
+ * fully parameterized query (PREVENT-002: no SQL string concat — `key` and
62
+ * `value` are both bound, never interpolated). The only write is the standard
63
+ * ON CONFLICT upsert of THIS key's own value; no other key is touched, no
64
+ * DELETE is issued.
65
+ *
66
+ * Used by the 3WF-2 ThrashGuard to persist exactly two keys:
67
+ * - `thrasguard.baseline_tokens` — the live-window token count at the moment
68
+ * an ineffective compaction was observed (the baseline the guard re-arms from).
69
+ * - `thrasguard.blocked_until` — the live-window token count below which
70
+ * re-firing is refused (guard active).
71
+ *
72
+ * Non-finite input (NaN / ±Infinity) is rejected: the extension must never
73
+ * persist a non-number into the meta table (getMetaNumber would read it back as
74
+ * 0), so we return early, non-fatal. Best-effort: any store failure is swallowed.
75
+ */
76
+ export function setMetaNumber(
77
+ key: string,
78
+ value: number,
79
+ stateDir: string = getStateDir(),
80
+ ): void {
81
+ if (key.length === 0) return;
82
+ if (!Number.isFinite(value)) return;
83
+ try {
84
+ const db = openStore(stateDir);
85
+ db.prepare(
86
+ `INSERT INTO meta(key, value) VALUES(?, ?)
87
+ ON CONFLICT(key) DO UPDATE SET value = ?`,
88
+ ).run(key, String(value), String(value));
89
+ } catch {
90
+ /* non-fatal: meta writes never break the agent loop */
91
+ }
92
+ }
93
+
58
94
  /** Atomically add `delta` to an integer meta counter. */
59
95
  function incMeta(key: string, delta: number, stateDir: string = getStateDir()): void {
60
96
  if (!(delta > 0)) return;