pi-mega-compact 0.20.84 → 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.
- package/dist/extensions/dashboard-server/routes-rag-settings-helpers.js +7 -0
- package/dist/extensions/mega-config.js +26 -1
- package/dist/extensions/mega-events/context-handler/gateCheck.js +41 -2
- package/dist/extensions/mega-events/context-handler/thrashGuard.js +186 -0
- package/dist/extensions/mega-events/context-handler.js +33 -1
- package/dist/extensions/mega-pipeline/compact/noop.js +104 -0
- package/dist/extensions/mega-pipeline/compact/run.js +268 -0
- package/dist/extensions/mega-pipeline/compact/vote.js +72 -0
- package/dist/extensions/mega-pipeline/compact.js +12 -343
- package/dist/extensions/mega-runtime/pressure-getters.js +12 -0
- package/dist/src/failback/compact.js +109 -0
- package/dist/src/store/sqlite/meta.js +32 -0
- package/extensions/dashboard-server/routes-rag-settings-helpers.ts +21 -0
- package/extensions/mega-config-types.ts +8 -0
- package/extensions/mega-config.ts +26 -1
- package/extensions/mega-events/context-handler/gateCheck.ts +48 -2
- package/extensions/mega-events/context-handler/thrashGuard.ts +228 -0
- package/extensions/mega-events/context-handler.ts +36 -1
- package/extensions/mega-pipeline/compact/noop.ts +96 -0
- package/extensions/mega-pipeline/compact/run.ts +322 -0
- package/extensions/mega-pipeline/compact/vote.ts +85 -0
- package/extensions/mega-pipeline/compact.ts +12 -385
- package/extensions/mega-runtime/pressure-getters.ts +14 -0
- package/package.json +1 -1
- package/src/failback/compact.ts +122 -0
- package/src/failback/types.ts +44 -0
- package/src/store/sqlite/meta.ts +36 -0
|
@@ -1,388 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* compact.ts —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
383
|
-
|
|
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";
|
|
@@ -81,6 +81,20 @@ export function pressureImpl(self: PressureContext): number {
|
|
|
81
81
|
* always below pi's native auto-compaction (~80% of window).
|
|
82
82
|
*/
|
|
83
83
|
export function effectiveThresholdImpl(self: PressureContext): number {
|
|
84
|
+
// 3WF-2 threshold invariant: under the umbrella, a tiered config with an
|
|
85
|
+
// UNKNOWN window (lastCtxWindow <= 0) DEFERS — auto-compaction must never
|
|
86
|
+
// substitute a guessed window. Returning +Infinity keeps every downstream
|
|
87
|
+
// `tokens >= threshold` comparison false (gateCheck token path,
|
|
88
|
+
// agent_end durable trigger, live-trim re-compact), so no compaction fires
|
|
89
|
+
// until the provider reports a real window. custom (tierPct null) and
|
|
90
|
+
// umbrella-OFF fall through to the legacy helper (byte-identical).
|
|
91
|
+
if (
|
|
92
|
+
self.config.threeWayFailback &&
|
|
93
|
+
self.config.tierPct != null &&
|
|
94
|
+
self.lastCtxWindow <= 0
|
|
95
|
+
) {
|
|
96
|
+
return Number.POSITIVE_INFINITY;
|
|
97
|
+
}
|
|
84
98
|
return effectiveThresholdTokens({
|
|
85
99
|
tierPct: self.config.tierPct,
|
|
86
100
|
fallbackThreshold: self.config.thresholdTokens,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-mega-compact",
|
|
3
|
-
"version": "0.20.
|
|
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
|
+
}
|
package/src/failback/types.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/store/sqlite/meta.ts
CHANGED
|
@@ -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;
|