pi-mega-compact 0.4.21 → 0.4.24
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.js +60 -9
- package/dist/extensions/dashboard-server.test.js +77 -0
- package/dist/extensions/mega-compact-driver.js +79 -0
- package/dist/extensions/mega-compact.test.js +54 -18
- package/dist/extensions/mega-config.js +10 -0
- package/dist/extensions/mega-dashboard-cmds.js +32 -2
- package/dist/extensions/mega-events.js +45 -23
- package/dist/extensions/mega-pipeline.js +77 -3
- package/dist/src/config/dedup.js +4 -1
- package/dist/src/config.js +21 -0
- package/dist/src/dedup/raptor/index.js +28 -6
- package/dist/src/dedup/raptor/promote.test.js +69 -0
- package/dist/src/engine.js +1 -0
- package/dist/src/recall.js +30 -4
- package/dist/src/recall.test.js +28 -0
- package/dist/src/store/backfill.js +5 -6
- package/dist/src/store/compression.js +47 -7
- package/dist/src/store/compression.test.js +48 -0
- package/dist/src/store/sqlite.js +64 -41
- package/dist/src/store.test.js +19 -0
- package/dist/src/vectorStore.js +56 -1
- package/extensions/DASHBOARD.md +3 -3
- package/extensions/dashboard-server.test.ts +77 -0
- package/extensions/dashboard-server.ts +57 -11
- package/extensions/mega-compact-driver.ts +105 -0
- package/extensions/mega-compact.test.ts +65 -18
- package/extensions/mega-config.ts +25 -0
- package/extensions/mega-dashboard-cmds.ts +23 -2
- package/extensions/mega-events.ts +43 -24
- package/extensions/mega-pipeline.ts +83 -4
- package/package.json +6 -7
- package/src/config/dedup.ts +4 -1
- package/src/config.ts +26 -0
- package/src/dedup/raptor/index.ts +42 -7
- package/src/dedup/raptor/promote.test.ts +82 -0
- package/src/engine.ts +5 -0
- package/src/recall.test.ts +44 -0
- package/src/recall.ts +43 -4
- package/src/store/backfill.ts +10 -11
- package/src/store/compression.test.ts +58 -0
- package/src/store/compression.ts +48 -7
- package/src/store/sqlite.ts +72 -49
- package/src/store.test.ts +22 -0
- package/src/vectorStore.ts +63 -1
- package/dist/extensions/openclaw-mega-compact.js +0 -291
- package/dist/src/minilm.js +0 -92
- package/dist/src/wordpiece.js +0 -129
|
@@ -45,6 +45,7 @@ function harness(opts: { keepTier?: boolean; keepThreshold?: boolean } = {}) {
|
|
|
45
45
|
let statusKey: string | undefined;
|
|
46
46
|
let statusText: string | undefined;
|
|
47
47
|
const notifies: string[] = [];
|
|
48
|
+
const compactCalls: any[] = [];
|
|
48
49
|
|
|
49
50
|
// Minimal AgentMessage factory for the session we project into the extension.
|
|
50
51
|
function msg(role: string, text: string, toolName?: string): AgentMessage {
|
|
@@ -107,7 +108,30 @@ function harness(opts: { keepTier?: boolean; keepThreshold?: boolean } = {}) {
|
|
|
107
108
|
hasPendingMessages: () => false,
|
|
108
109
|
shutdown: () => {},
|
|
109
110
|
getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }),
|
|
110
|
-
|
|
111
|
+
// Faithful mock: ctx.compact() starts pi's flow, which fires the
|
|
112
|
+
// session_before_compact handler (where WE supply the durable trim).
|
|
113
|
+
compact: (opts?: any) => {
|
|
114
|
+
compactCalls.push(opts);
|
|
115
|
+
if (handlers["session_before_compact"]) {
|
|
116
|
+
return handlers["session_before_compact"](
|
|
117
|
+
{
|
|
118
|
+
type: "session_before_compact",
|
|
119
|
+
reason: "threshold",
|
|
120
|
+
willRetry: false,
|
|
121
|
+
signal: undefined,
|
|
122
|
+
// pi computed the cut honoring anchor floor + tool-pair (PREVENT-PI-002);
|
|
123
|
+
// our handler reuses it as firstKeptEntryId.
|
|
124
|
+
preparation: {
|
|
125
|
+
firstKeptEntryId: "e2",
|
|
126
|
+
messagesToSummarize: session.slice(0, 2),
|
|
127
|
+
tokensBefore: 500,
|
|
128
|
+
},
|
|
129
|
+
} as any,
|
|
130
|
+
makeCtx(),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return undefined;
|
|
134
|
+
},
|
|
111
135
|
getSystemPrompt: () => "system base",
|
|
112
136
|
...over,
|
|
113
137
|
} as any;
|
|
@@ -143,14 +167,14 @@ function harness(opts: { keepTier?: boolean; keepThreshold?: boolean } = {}) {
|
|
|
143
167
|
mod.default(pi);
|
|
144
168
|
|
|
145
169
|
return {
|
|
146
|
-
stateDir, handlers, commands, appended, get status() { return { statusKey, statusText }; }, notifies,
|
|
170
|
+
stateDir, handlers, commands, appended, get status() { return { statusKey, statusText }; }, notifies, compactCalls,
|
|
147
171
|
fire: (ev: string, event: any, ctx: any) => handlers[ev](event, ctx),
|
|
148
172
|
ctx: makeCtx,
|
|
149
173
|
session,
|
|
150
174
|
};
|
|
151
175
|
}
|
|
152
176
|
|
|
153
|
-
test("auto-trigger: past threshold persists a chkpt and
|
|
177
|
+
test("auto-trigger: past threshold persists a chkpt and starts a durable trim", async () => {
|
|
154
178
|
const h = harness();
|
|
155
179
|
const messages = h.session;
|
|
156
180
|
const ctx = h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
|
|
@@ -159,27 +183,50 @@ test("auto-trigger: past threshold persists a chkpt and drops context", async ()
|
|
|
159
183
|
const { listCheckpoints } = await import("../src/store/sqlite.js");
|
|
160
184
|
assert.ok(listCheckpoints("sess_ext_001", h.stateDir).length > 0, "checkpoint persisted to local vector db");
|
|
161
185
|
assert.equal(h.appended.some((a) => a.t === "mega-compact-marker"), true, "marker sentinel appended");
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
186
|
+
// The context handler no longer drops messages itself (that was ephemeral —
|
|
187
|
+
// the read-path token-growth bug). It triggers pi's compaction flow, which
|
|
188
|
+
// calls our session_before_compact handler to supply the DURABLE trim.
|
|
189
|
+
assert.equal(res, undefined, "context handler returns nothing (no local drop)");
|
|
190
|
+
assert.equal(h.compactCalls.length, 1, "ctx.compact() called to start durable trim");
|
|
191
|
+
// The durable trim was supplied (summary + firstKeptEntryId from pi's prep).
|
|
192
|
+
assert.ok(h.compactCalls[0] !== undefined, "compaction flow executed");
|
|
165
193
|
});
|
|
166
194
|
|
|
167
|
-
test("session_before_compact
|
|
195
|
+
test("session_before_compact supplies our durable trim (not pi's summary)", async () => {
|
|
168
196
|
const h = harness();
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
197
|
+
// pi fires session_before_compact with its own computed preparation.
|
|
198
|
+
const res = await h.fire(
|
|
199
|
+
"session_before_compact",
|
|
200
|
+
{
|
|
201
|
+
type: "session_before_compact",
|
|
202
|
+
reason: "overflow",
|
|
203
|
+
willRetry: true,
|
|
204
|
+
preparation: { firstKeptEntryId: "e2", messagesToSummarize: h.session.slice(0, 2), tokensBefore: 500 },
|
|
205
|
+
signal: undefined,
|
|
206
|
+
} as any,
|
|
207
|
+
h.ctx(),
|
|
208
|
+
);
|
|
209
|
+
assert.ok(res && res.compaction, "returns a compaction result");
|
|
210
|
+
assert.equal(res.compaction.firstKeptEntryId, "e2", "reuses pi's cut boundary (PREVENT-PI-002 safe)");
|
|
211
|
+
assert.ok(typeof res.compaction.summary === "string" && res.compaction.summary.length > 0, "our summary supplied");
|
|
212
|
+
assert.ok(res.compaction.tokensBefore >= 0, "tokensBefore reported");
|
|
175
213
|
});
|
|
176
214
|
|
|
177
|
-
test("session_before_compact
|
|
215
|
+
test("session_before_compact falls back to pi when nothing to summarize", async () => {
|
|
178
216
|
const h = harness();
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
217
|
+
// Empty preparation → no messages to summarize → return {} so pi compacts natively.
|
|
218
|
+
const res = await h.fire(
|
|
219
|
+
"session_before_compact",
|
|
220
|
+
{
|
|
221
|
+
type: "session_before_compact",
|
|
222
|
+
reason: "threshold",
|
|
223
|
+
willRetry: false,
|
|
224
|
+
preparation: { firstKeptEntryId: "e0", messagesToSummarize: [], tokensBefore: 0 },
|
|
225
|
+
signal: undefined,
|
|
226
|
+
} as any,
|
|
227
|
+
h.ctx(),
|
|
228
|
+
);
|
|
229
|
+
assert.deepEqual(res, {}, "no compaction supplied → pi runs its own");
|
|
183
230
|
});
|
|
184
231
|
|
|
185
232
|
test("resume auto-inline stages recall into the system prompt", async () => {
|
|
@@ -34,10 +34,24 @@ export interface MegaConfig {
|
|
|
34
34
|
fastGatePct: number;
|
|
35
35
|
anchorUserMessages: number;
|
|
36
36
|
preserveRecent: number;
|
|
37
|
+
/** High-pressure floor for preserveRecent — when context is near the limit
|
|
38
|
+
* we compact deeper, but never below this (keeps recent turns for coherence). */
|
|
39
|
+
preserveRecentMin: number;
|
|
37
40
|
auto: boolean;
|
|
38
41
|
autoInline: boolean;
|
|
39
42
|
autoInlineK: number;
|
|
40
43
|
dedupSim: number;
|
|
44
|
+
/** RAPTOR hierarchical recall enabled (Fix D). Drives both live recall and
|
|
45
|
+
* the durable-trim summary source (root summary). */
|
|
46
|
+
raptorEnabled: boolean;
|
|
47
|
+
/** Token ceiling for the re-injected recall block (Fix C). Recall stops
|
|
48
|
+
* adding checkpoints once the block would exceed this — bounds read-path
|
|
49
|
+
* token cost so it can never net-inflate the window. */
|
|
50
|
+
recallMaxTokens: number;
|
|
51
|
+
/** Inline-dedupe recalled checkpoints against the live window (Fix C): drop
|
|
52
|
+
* a hit whose summary is ≥ dedupSim similar to a live message — "dedupe on
|
|
53
|
+
* inline/read" so we never re-inject context already resident. */
|
|
54
|
+
windowDedupe: boolean;
|
|
41
55
|
debug: boolean;
|
|
42
56
|
}
|
|
43
57
|
|
|
@@ -65,6 +79,13 @@ function resolveThreshold(): { tier: CompactTier | "custom"; thresholdTokens: nu
|
|
|
65
79
|
return { tier, thresholdTokens: COMPACT_TIERS[tier] };
|
|
66
80
|
}
|
|
67
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Pressure helpers for adaptive compression (Fix E) live in src/config.ts
|
|
84
|
+
* (pi-agnostic) so unit tests can import them without the pi runtime. Re-export
|
|
85
|
+
* here so the extension has one import surface.
|
|
86
|
+
*/
|
|
87
|
+
export { pressureFromPct, preserveRecentForPressure } from "../src/config.js";
|
|
88
|
+
|
|
68
89
|
/** Build the resolved config from env + defaults. */
|
|
69
90
|
export function loadConfig(): MegaConfig {
|
|
70
91
|
const { tier, thresholdTokens } = resolveThreshold();
|
|
@@ -77,10 +98,14 @@ export function loadConfig(): MegaConfig {
|
|
|
77
98
|
thresholdTokens,
|
|
78
99
|
anchorUserMessages: envFlag("MEGACOMPACT_ANCHOR_USER_MESSAGES", 3),
|
|
79
100
|
preserveRecent: envFlag("MEGACOMPACT_PRESERVE_RECENT", 4),
|
|
101
|
+
preserveRecentMin: envFlag("MEGACOMPACT_PRESERVE_RECENT_MIN", 2),
|
|
80
102
|
auto: envBool("MEGACOMPACT_AUTO", true),
|
|
81
103
|
autoInline: envBool("MEGACOMPACT_AUTO_INLINE", true),
|
|
82
104
|
autoInlineK: envFlag("MEGACOMPACT_AUTO_INLINE_K", 3),
|
|
83
105
|
dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),
|
|
106
|
+
raptorEnabled: envBool("MEGACOMPACT_RAPTOR_ENABLED", true),
|
|
107
|
+
recallMaxTokens: envFlag("MEGACOMPACT_RECALL_MAX_TOKENS", 1500),
|
|
108
|
+
windowDedupe: envBool("MEGACOMPACT_WINDOW_DEDUPE", true),
|
|
84
109
|
debug: envBool("MEGACOMPACT_DEBUG", false),
|
|
85
110
|
};
|
|
86
111
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { join, dirname, sep } from "node:path";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
|
-
import { existsSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
|
|
12
|
+
import { existsSync, writeFileSync, readFileSync, unlinkSync, openSync, closeSync } from "node:fs";
|
|
13
13
|
import { spawn } from "node:child_process"; // guardrails-allow PREVENT-PI-004: spawns the optional, user-triggered localhost dashboard server only
|
|
14
14
|
import { MegaRuntime } from "./mega-runtime.js";
|
|
15
15
|
|
|
@@ -209,11 +209,32 @@ export function registerDashboardCommands(pi: ExtensionAPI, runtime: MegaRuntime
|
|
|
209
209
|
return;
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
// Clear any stale marker so a fresh bind never collides with a lingering
|
|
213
|
+
// orphan, and truncate the launch log so the next error report shows only
|
|
214
|
+
// this attempt's output.
|
|
215
|
+
try { unlinkSync(portFile); } catch { /* ignore */ }
|
|
216
|
+
try { writeFileSync(launchLog, ""); } catch { /* ignore */ }
|
|
217
|
+
|
|
212
218
|
const args = dashboardNeedsStrip ? ["--experimental-strip-types", runnerFile] : [runnerFile];
|
|
219
|
+
// Redirect the child's stderr to the launch log so that a CRASH BEFORE the
|
|
220
|
+
// runner's own __fail handler runs (e.g. an ESM module-load / parse error,
|
|
221
|
+
// or a missing entry) is still captured. With the old `stdio: "ignore"`
|
|
222
|
+
// these failures were completely silent and the "check logs" message
|
|
223
|
+
// pointed at an empty file. We open the fd in the parent and pass it to the
|
|
224
|
+
// child; once spawned we close our copy (the child keeps its own dup).
|
|
225
|
+
let stderrFd: number;
|
|
226
|
+
try {
|
|
227
|
+
stderrFd = openSync(launchLog, "a");
|
|
228
|
+
} catch {
|
|
229
|
+
stderrFd = -1; // fall back to ignored stderr
|
|
230
|
+
}
|
|
213
231
|
const child = spawn(process.execPath, args, {
|
|
214
232
|
detached: true,
|
|
215
|
-
stdio: "ignore",
|
|
233
|
+
stdio: ["ignore", "ignore", stderrFd >= 0 ? stderrFd : "ignore"],
|
|
216
234
|
});
|
|
235
|
+
if (stderrFd >= 0) {
|
|
236
|
+
try { closeSync(stderrFd); } catch { /* ignore */ }
|
|
237
|
+
}
|
|
217
238
|
child.unref();
|
|
218
239
|
|
|
219
240
|
// Poll for a live server (port 9320–9329) instead of relying solely on the
|
|
@@ -11,10 +11,10 @@ import type { ExtensionAPI, ExtensionContext, ContextEvent, SessionBeforeCompact
|
|
|
11
11
|
import { normalizeSessionId } from "../src/store.js";
|
|
12
12
|
import { autoCompactCheck } from "../src/compact.js";
|
|
13
13
|
import { estimateSessionTokens } from "../src/tokens.js";
|
|
14
|
-
import { dropCompactedRange } from "../src/adapt.js";
|
|
15
14
|
import { MegaRuntime, recentUserQuery, WIDGET_KEY } from "./mega-runtime.js";
|
|
16
15
|
import { runCompact, doRecall } from "./mega-pipeline.js";
|
|
17
|
-
import
|
|
16
|
+
import { driveNativeCompaction } from "./mega-compact-driver.js";
|
|
17
|
+
import { pressureFromPct, type MegaConfig } from "./mega-config.js";
|
|
18
18
|
|
|
19
19
|
/** Register all pi lifecycle event handlers. */
|
|
20
20
|
export function registerEventHandlers(pi: ExtensionAPI, runtime: MegaRuntime, config: MegaConfig): void {
|
|
@@ -119,7 +119,15 @@ export function registerEventHandlers(pi: ExtensionAPI, runtime: MegaRuntime, co
|
|
|
119
119
|
runtime.snapshot(ctx);
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
-
// ---- Auto-trigger:
|
|
122
|
+
// ---- Auto-trigger: own the decision, pi owns the durable write ----------
|
|
123
|
+
// OUR auto-trigger (over threshold + debounce): persist our Trident checkpoint,
|
|
124
|
+
// then start pi's compaction flow via ctx.compact(). That fires
|
|
125
|
+
// `session_before_compact`, where OUR handler returns our summary +
|
|
126
|
+
// firstKeptEntryId, and pi durably writes the trim to disk (appendCompaction).
|
|
127
|
+
// Result: auto-compact AND a durable trim — resume reloads the trimmed window,
|
|
128
|
+
// no full-reload + additive recall inflation (Fix B kills the token-growth bug).
|
|
129
|
+
// We do NOT drop messages here (that would be ephemeral; the read-only session
|
|
130
|
+
// manager can't trim disk, so the trim has to come through pi).
|
|
123
131
|
pi.on("context", async (event: ContextEvent, ctx: ExtensionContext) => {
|
|
124
132
|
if (!config.auto) return;
|
|
125
133
|
const usage = ctx.getContextUsage();
|
|
@@ -133,15 +141,11 @@ export function registerEventHandlers(pi: ExtensionAPI, runtime: MegaRuntime, co
|
|
|
133
141
|
|
|
134
142
|
const messages = event.messages;
|
|
135
143
|
const view = runtime.engineView(messages);
|
|
136
|
-
// Prefer the runtime's real token estimate; fall back to our heuristic
|
|
137
|
-
// (and to a percent-of-window proxy when tokens is unknown).
|
|
138
144
|
const currentTokens =
|
|
139
145
|
usage?.tokens ?? estimateSessionTokens(view) ??
|
|
140
146
|
Math.round((pct / 100) * (usage?.contextWindow ?? 0));
|
|
141
147
|
|
|
142
148
|
// FAST GATE: token-based (tier threshold), not percentage-based.
|
|
143
|
-
// A 20% gate on a 2M window = 400k, which is way above the 50k low-tier
|
|
144
|
-
// threshold. Gate on the actual token count instead.
|
|
145
149
|
if (currentTokens < config.thresholdTokens) return;
|
|
146
150
|
|
|
147
151
|
const check = autoCompactCheck(currentTokens, config.thresholdTokens); // SERVER-STYLE CONFIRM (local)
|
|
@@ -152,29 +156,44 @@ export function registerEventHandlers(pi: ExtensionAPI, runtime: MegaRuntime, co
|
|
|
152
156
|
if (now < runtime.debounceUntil) return;
|
|
153
157
|
runtime.debounceUntil = now + 2000;
|
|
154
158
|
|
|
155
|
-
|
|
159
|
+
// Adaptive compression (Fix E): scale compression strength + keepFrom depth
|
|
160
|
+
// with how close we are to the model context limit.
|
|
161
|
+
const pressure = pressureFromPct(pct);
|
|
162
|
+
const ran = runCompact(pi, runtime, config, ctx, messages, { compressionPressure: pressure });
|
|
156
163
|
if (ran.skipped) return;
|
|
157
164
|
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
if (kept.length < messages.length) {
|
|
162
|
-
return { messages: kept };
|
|
163
|
-
}
|
|
165
|
+
// Start pi's compaction flow so our session_before_compact handler can
|
|
166
|
+
// supply the durable trim (pi writes it to disk). We never use pi's summary.
|
|
167
|
+
ctx.compact({ customInstructions: undefined });
|
|
164
168
|
});
|
|
165
169
|
|
|
166
|
-
// ----
|
|
167
|
-
|
|
170
|
+
// ---- Supply a DURABLE trim to pi's native compaction (Fix B) ----------
|
|
171
|
+
// We run the Trident pipeline to produce a compressed summary, then return
|
|
172
|
+
// it as a CompactionResult. pi writes the summary into a compactionSummary
|
|
173
|
+
// entry AND truncates the on-disk transcript from firstKeptEntryId. This is
|
|
174
|
+
// the durable fix for "tokens grow on read": the trim survives resume, so
|
|
175
|
+
// there is no full-reload + additive recall inflation.
|
|
176
|
+
pi.on("session_before_compact", async (event: SessionBeforeCompactEvent, ctx: ExtensionContext) => {
|
|
168
177
|
runtime.resetRuntime(ctx.sessionManager.getSessionId());
|
|
169
|
-
if (
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
178
|
+
if (!config.auto) return {}; // let pi run its own native compaction
|
|
179
|
+
try {
|
|
180
|
+
const result = driveNativeCompaction(event, runtime, config);
|
|
181
|
+
if (result) {
|
|
182
|
+
runtime.logger.info("native-compact", {
|
|
183
|
+
sessionId: runtime.rt.sessionId,
|
|
184
|
+
firstKeptEntryId: result.compaction.firstKeptEntryId,
|
|
185
|
+
tokensBefore: result.compaction.tokensBefore,
|
|
186
|
+
summaryTokens: result.compaction.estimatedTokensAfter,
|
|
187
|
+
});
|
|
188
|
+
return { compaction: result.compaction };
|
|
189
|
+
}
|
|
190
|
+
} catch (err) {
|
|
191
|
+
runtime.logger.error("native-compact-failed", {
|
|
192
|
+
sessionId: runtime.rt.sessionId,
|
|
193
|
+
error: String(err instanceof Error ? err.message : err),
|
|
194
|
+
});
|
|
174
195
|
}
|
|
175
|
-
//
|
|
176
|
-
// (Our auto-trigger only fires again past the threshold, and will then
|
|
177
|
-
// capture a checkpoint next time around.)
|
|
196
|
+
// Fall back to pi's own native compaction if we can't supply one.
|
|
178
197
|
return {};
|
|
179
198
|
});
|
|
180
199
|
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
|
|
11
12
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
12
13
|
import { compactSession } from "../src/engine.js";
|
|
13
14
|
import { recallAndInline } from "../src/recall.js";
|
|
@@ -18,7 +19,9 @@ import {
|
|
|
18
19
|
C,
|
|
19
20
|
MARKER_TYPE,
|
|
20
21
|
} from "./mega-runtime.js";
|
|
21
|
-
import { resolveRepoRoot, type MegaConfig } from "./mega-config.js";
|
|
22
|
+
import { resolveRepoRoot, preserveRecentForPressure, type MegaConfig } from "./mega-config.js";
|
|
23
|
+
import { runRaptor } from "../src/dedup/raptor/index.js";
|
|
24
|
+
import { loadDedupConfig } from "../src/config/dedup.js";
|
|
22
25
|
|
|
23
26
|
export type RunCompactResult =
|
|
24
27
|
| { skipped: true }
|
|
@@ -31,7 +34,7 @@ export function runCompact(
|
|
|
31
34
|
config: MegaConfig,
|
|
32
35
|
ctx: ExtensionContext,
|
|
33
36
|
messages: AgentMessage[],
|
|
34
|
-
opts: { keepFrom?: number; summary?: string } = {},
|
|
37
|
+
opts: { keepFrom?: number; summary?: string; compressionPressure?: number } = {},
|
|
35
38
|
): RunCompactResult {
|
|
36
39
|
runtime.bindRepo(ctx.cwd);
|
|
37
40
|
const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
|
|
@@ -39,7 +42,14 @@ export function runCompact(
|
|
|
39
42
|
runtime.rt.sessionId = sid;
|
|
40
43
|
|
|
41
44
|
const view = runtime.engineView(messages);
|
|
42
|
-
|
|
45
|
+
// keepFrom deepens with context pressure (Fix E): under high pressure we
|
|
46
|
+
// compact more of the session, down to the preserveRecentMin floor.
|
|
47
|
+
const preserve = preserveRecentForPressure(
|
|
48
|
+
opts.compressionPressure ?? 0,
|
|
49
|
+
config.preserveRecent,
|
|
50
|
+
config.preserveRecentMin,
|
|
51
|
+
);
|
|
52
|
+
const keepFrom = opts.keepFrom ?? Math.max(0, view.length - preserve);
|
|
43
53
|
if (keepFrom <= 0) return { skipped: true };
|
|
44
54
|
|
|
45
55
|
runtime.pulsing = true; // animate the status line while the (sync) pipeline runs
|
|
@@ -51,6 +61,7 @@ export function runCompact(
|
|
|
51
61
|
summary: opts.summary,
|
|
52
62
|
timestamp: Date.now(),
|
|
53
63
|
onTier: runtime.makeTierCallback(ctx),
|
|
64
|
+
compressionPressure: opts.compressionPressure,
|
|
54
65
|
},
|
|
55
66
|
runtime.store,
|
|
56
67
|
);
|
|
@@ -122,6 +133,37 @@ export function runCompact(
|
|
|
122
133
|
deduped: result.deduped,
|
|
123
134
|
});
|
|
124
135
|
|
|
136
|
+
// Fix D: refresh the RAPTOR tree for this session so live recall (search) can
|
|
137
|
+
// serve high-level summaries. Best-effort + non-fatal: never block compaction.
|
|
138
|
+
// Budget-guarded (RAPTOR_BUDGET_MS) so it can't hang a large session.
|
|
139
|
+
if (config.raptorEnabled && !result.deduped) {
|
|
140
|
+
try {
|
|
141
|
+
const dd = loadDedupConfig();
|
|
142
|
+
const all = runtime.store.list(sid);
|
|
143
|
+
const leaves = all.map((cp) => ({
|
|
144
|
+
id: cp.checkpointId,
|
|
145
|
+
messages: [],
|
|
146
|
+
sourceText: cp.normalizedText ?? cp.summary ?? cp.regionHash,
|
|
147
|
+
embedding: cp.embedding,
|
|
148
|
+
}));
|
|
149
|
+
if (leaves.length >= 2) {
|
|
150
|
+
runRaptor(
|
|
151
|
+
leaves,
|
|
152
|
+
{
|
|
153
|
+
stateDir: runtime.currentStateDir,
|
|
154
|
+
sessionId: sid,
|
|
155
|
+
budgetMs: dd.RAPTOR_BUDGET_MS,
|
|
156
|
+
clustersPerLevel: dd.RAPTOR_CLUSTERS_PER_LEVEL,
|
|
157
|
+
consistencyThreshold: dd.RAPTOR_CONSISTENCY,
|
|
158
|
+
logger: runtime.logger,
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
/* non-fatal: tree refresh never blocks a compaction */
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
125
167
|
runtime.setStatus(
|
|
126
168
|
ctx,
|
|
127
169
|
runtime.rt.persistedThisSession
|
|
@@ -160,8 +202,22 @@ export function doRecall(
|
|
|
160
202
|
) {
|
|
161
203
|
runtime.bindRepo(ctx.cwd);
|
|
162
204
|
const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
|
|
205
|
+
// Live window text for inline dedupe (Fix C): drop recalled checkpoints that
|
|
206
|
+
// are already resident in the session, so recall never re-injects context the
|
|
207
|
+
// model can already see. Best-effort — an empty window just skips dedupe.
|
|
208
|
+
const liveWindow = config.windowDedupe ? extractLiveWindow(ctx) : undefined;
|
|
163
209
|
const result = recallAndInline(
|
|
164
|
-
{
|
|
210
|
+
{
|
|
211
|
+
sessionId: sid,
|
|
212
|
+
query,
|
|
213
|
+
limit: config.autoInlineK,
|
|
214
|
+
source,
|
|
215
|
+
skipInjected: true,
|
|
216
|
+
recallMaxTokens: config.recallMaxTokens,
|
|
217
|
+
windowDedupe: config.windowDedupe,
|
|
218
|
+
liveWindow,
|
|
219
|
+
dedupSim: config.dedupSim,
|
|
220
|
+
},
|
|
165
221
|
runtime.store,
|
|
166
222
|
);
|
|
167
223
|
runtime.dashboard.event("recall", { source, query: query.slice(0, 120), injected: result.toInject.length, empty: result.empty });
|
|
@@ -175,3 +231,26 @@ export function doRecall(
|
|
|
175
231
|
}
|
|
176
232
|
return result;
|
|
177
233
|
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Extract the live-window message texts from the session manager (Fix C),
|
|
237
|
+
* for inline-dedupe of recalled checkpoints. Best-effort: returns [] on any
|
|
238
|
+
* error so recall falls back to unbounded (still correct, just no dedupe).
|
|
239
|
+
* Mirrors recentUserQuery's use of sessionEntryToContextMessages.
|
|
240
|
+
*/
|
|
241
|
+
function extractLiveWindow(ctx: ExtensionContext): string[] {
|
|
242
|
+
try {
|
|
243
|
+
const entries = ctx.sessionManager.getEntries();
|
|
244
|
+
const texts: string[] = [];
|
|
245
|
+
for (const e of entries) {
|
|
246
|
+
for (const m of sessionEntryToContextMessages(e)) {
|
|
247
|
+
const c = (m as { content?: unknown }).content;
|
|
248
|
+
if (typeof c === "string") texts.push(c);
|
|
249
|
+
else if (Array.isArray(c)) texts.push(c.map((b: any) => b.text).join(" "));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return texts;
|
|
253
|
+
} catch {
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-mega-compact",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.24",
|
|
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-2-Clause",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"trident"
|
|
21
21
|
],
|
|
22
22
|
"engines": {
|
|
23
|
-
"node": ">=
|
|
23
|
+
"node": ">=22.13"
|
|
24
24
|
},
|
|
25
25
|
"files": [
|
|
26
26
|
"dist",
|
|
@@ -45,19 +45,18 @@
|
|
|
45
45
|
"test": "npm run build && node --test \"dist/src/**/*.test.js\" \"dist/extensions/**/*.test.js\"",
|
|
46
46
|
"guardrails": "python3 scripts/regression_check.py --all || node scripts/guardrails-scan.mjs",
|
|
47
47
|
"precommit": "bash .claude/hooks/pre-commit.sh",
|
|
48
|
-
"prepublishOnly": "npm run build"
|
|
48
|
+
"prepublishOnly": "npm run build",
|
|
49
|
+
"postinstall": "npm rebuild @mongodb-js/zstd || true"
|
|
49
50
|
},
|
|
50
51
|
"peerDependencies": {
|
|
51
52
|
"@earendil-works/pi-coding-agent": "*",
|
|
52
53
|
"openclaw": ">=0.1.0"
|
|
53
54
|
},
|
|
54
55
|
"devDependencies": {
|
|
55
|
-
"@types/
|
|
56
|
-
"@types/node": "^20.0.0",
|
|
56
|
+
"@types/node": "^22.20.1",
|
|
57
57
|
"typescript": "^5.4.0"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@mongodb-js/zstd": "^7.0.0"
|
|
61
|
-
"better-sqlite3": "^12.11.1"
|
|
60
|
+
"@mongodb-js/zstd": "^7.0.0"
|
|
62
61
|
}
|
|
63
62
|
}
|
package/src/config/dedup.ts
CHANGED
|
@@ -66,7 +66,10 @@ export function loadDedupConfig(): DedupConfigShape {
|
|
|
66
66
|
L0_ENABLED: envBool("MEGACOMPACT_L0_ENABLED", true),
|
|
67
67
|
L1_ENABLED: envBool("MEGACOMPACT_L1_ENABLED", true),
|
|
68
68
|
L2_ENABLED: envBool("MEGACOMPACT_L2_ENABLED", true),
|
|
69
|
-
|
|
69
|
+
// Fix D: RAPTOR promoted to live recall. Default ON; canary.ts sequences it
|
|
70
|
+
// last (L0→L1→L2→RAPTOR) and auto-disables on p95 breach, so promotion is
|
|
71
|
+
// safe. `RAPTOR_SHADOW_MODE=false` still gates serving during transition.
|
|
72
|
+
RAPTOR_ENABLED: envBool("MEGACOMPACT_RAPTOR_ENABLED", true),
|
|
70
73
|
MARK_ONLY_L0: envBool("MEGACOMPACT_MARK_ONLY_L0", false),
|
|
71
74
|
MARK_ONLY_L1: envBool("MEGACOMPACT_MARK_ONLY_L1", false),
|
|
72
75
|
MARK_ONLY_L2: envBool("MEGACOMPACT_MARK_ONLY_L2", false),
|
package/src/config.ts
CHANGED
|
@@ -13,3 +13,29 @@ export const STATE_DIR_DEFAULT = join(homedir(), ".pi", "agent", "extensions", "
|
|
|
13
13
|
|
|
14
14
|
/** Pi custom message / entry type used as the dedup sentinel. */
|
|
15
15
|
export const MARKER_TYPE = "mega-compact-marker";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Derive context-window pressure (0–1) from a usage percentage. Used to scale
|
|
19
|
+
* compression strength + keepFrom depth (Fix E): low pct = room to spare,
|
|
20
|
+
* high pct = near the limit. Deterministic; clamps to [0,1].
|
|
21
|
+
*/
|
|
22
|
+
export function pressureFromPct(pct: number | null | undefined): number {
|
|
23
|
+
if (pct == null || Number.isNaN(pct)) return 0;
|
|
24
|
+
return pct < 0 ? 0 : pct > 100 ? 1 : pct / 100;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Map pressure → how many recent messages to preserve verbatim. Under low
|
|
29
|
+
* pressure we keep `preserveRecent`; under high pressure we compact deeper,
|
|
30
|
+
* down to `preserveRecentMin`. Never splits a tool pair / anchor floor — the
|
|
31
|
+
* boundary guard (computeDropRange) enforces that downstream.
|
|
32
|
+
*/
|
|
33
|
+
export function preserveRecentForPressure(
|
|
34
|
+
pressure: number,
|
|
35
|
+
preserveRecent: number,
|
|
36
|
+
preserveRecentMin: number,
|
|
37
|
+
): number {
|
|
38
|
+
const p = pressure < 0 ? 0 : pressure > 1 ? 1 : pressure;
|
|
39
|
+
const v = Math.round(preserveRecent - (preserveRecent - preserveRecentMin) * p);
|
|
40
|
+
return Math.max(preserveRecentMin, Math.min(preserveRecent, v));
|
|
41
|
+
}
|
|
@@ -89,10 +89,22 @@ export function recallRaptor(
|
|
|
89
89
|
opts: { embedder?: Embedder; stateDir: string; k?: number; topM?: number },
|
|
90
90
|
): string[] {
|
|
91
91
|
const embedder = opts.embedder ?? defaultEmbedder();
|
|
92
|
-
const
|
|
93
|
-
if (
|
|
94
|
-
|
|
95
|
-
|
|
92
|
+
const tree = rehydrateRaptorTree(sessionId, opts.stateDir);
|
|
93
|
+
if (!tree) return [];
|
|
94
|
+
return stagedExpansion(query, tree, { embedder, k: opts.k, topM: opts.topM });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Rehydrate a persisted RAPTOR tree from raptor_nodes (Fix D): rebuild the
|
|
99
|
+
* in-memory RaptorTree + parent links so vectorStore.search can serve it live.
|
|
100
|
+
* Returns null when no tree exists (caller falls back to the flat path).
|
|
101
|
+
*/
|
|
102
|
+
export function rehydrateRaptorTree(
|
|
103
|
+
sessionId: string,
|
|
104
|
+
stateDir: string,
|
|
105
|
+
): RaptorTree | null {
|
|
106
|
+
const nodes = listRaptorNodes(sessionId, stateDir);
|
|
107
|
+
if (nodes.length === 0) return null;
|
|
96
108
|
const tree: RaptorTree = {
|
|
97
109
|
nodes: new Map(
|
|
98
110
|
nodes.map((n) => [
|
|
@@ -109,10 +121,33 @@ export function recallRaptor(
|
|
|
109
121
|
},
|
|
110
122
|
]),
|
|
111
123
|
),
|
|
112
|
-
rootId:
|
|
124
|
+
rootId:
|
|
125
|
+
nodes.reduce<typeof nodes[number] | null>(
|
|
126
|
+
(best, n) => (!best || n.level > (best?.level ?? -1) ? n : best),
|
|
127
|
+
null,
|
|
128
|
+
)?.id ?? null,
|
|
113
129
|
levels: Math.max(1, ...nodes.map((n) => n.level + 1)),
|
|
114
130
|
timedOut: false,
|
|
115
131
|
};
|
|
116
|
-
|
|
117
|
-
|
|
132
|
+
return tree;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Return the RAPTOR root summary for a session, if a tree has been built.
|
|
137
|
+
* Used by the durable-trim driver (Fix B/D) to supply pi a session-level
|
|
138
|
+
* compressed summary instead of one slice's extractive summary. Returns
|
|
139
|
+
* undefined when no tree exists yet (caller falls back to the slice summary).
|
|
140
|
+
*/
|
|
141
|
+
export function recallRaptorRootSummary(
|
|
142
|
+
sessionId: string,
|
|
143
|
+
stateDir: string,
|
|
144
|
+
): string | undefined {
|
|
145
|
+
const nodes = listRaptorNodes(sessionId, stateDir);
|
|
146
|
+
if (nodes.length === 0) return undefined;
|
|
147
|
+
// Highest-level node = the root (covers all leaves).
|
|
148
|
+
const root = nodes.reduce<(typeof nodes)[number] | null>(
|
|
149
|
+
(best, n) => (!best || n.level > best.level ? n : best),
|
|
150
|
+
null,
|
|
151
|
+
);
|
|
152
|
+
return root?.summary || undefined;
|
|
118
153
|
}
|