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
@@ -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";