pi-mega-compact 0.4.20 → 0.4.23
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/conflict-scan.js +201 -0
- package/dist/extensions/dashboard-server.js +3 -3
- package/dist/extensions/mega-compact-driver.js +79 -0
- package/dist/extensions/mega-compact.js +2 -0
- package/dist/extensions/mega-compact.test.js +54 -18
- package/dist/extensions/mega-config.js +10 -0
- package/dist/extensions/mega-conflict-cmds.js +121 -0
- package/dist/extensions/mega-events.js +45 -23
- package/dist/extensions/mega-pipeline.js +80 -8
- package/dist/extensions/mega-runtime.js +14 -20
- 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 +123 -41
- package/dist/src/store.test.js +19 -0
- package/dist/src/vectorStore.js +56 -1
- package/extensions/DASHBOARD.md +3 -3
- package/extensions/conflict-scan.ts +209 -0
- package/extensions/dashboard-server.ts +4 -4
- package/extensions/mega-compact-driver.ts +105 -0
- package/extensions/mega-compact.test.ts +65 -18
- package/extensions/mega-compact.ts +2 -0
- package/extensions/mega-config.ts +25 -0
- package/extensions/mega-conflict-cmds.ts +129 -0
- package/extensions/mega-events.ts +43 -24
- package/extensions/mega-pipeline.ts +86 -9
- package/extensions/mega-runtime.ts +14 -18
- 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 +156 -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
|
@@ -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
|
);
|
|
@@ -78,15 +89,13 @@ export function runCompact(
|
|
|
78
89
|
// denominator (we don't want it pinned at 100% once we pass an old target).
|
|
79
90
|
if (runtime.rt.tokensSaved > runtime.savedGoal) runtime.savedGoal = Math.ceil((runtime.rt.tokensSaved * 1.25) / 10_000) * 10_000;
|
|
80
91
|
|
|
81
|
-
// Live toolbar
|
|
82
|
-
//
|
|
92
|
+
// Live toolbar activity: what file/region just got compacted or deduped.
|
|
93
|
+
// Rendered via the rotating ticker line (see snapshot); the ring buffer is
|
|
94
|
+
// cycled one-per-repaint so the single line scrolls through recent files.
|
|
83
95
|
const files = result.filesModified ?? [];
|
|
84
96
|
const fileLabel = files.length
|
|
85
97
|
? files.map((f) => f.split("/").pop() ?? f).slice(0, 2).join(", ")
|
|
86
98
|
: result.regionHash.slice(0, 8);
|
|
87
|
-
runtime.currentActivity = result.deduped
|
|
88
|
-
? `♻ deduped ${fileLabel}`
|
|
89
|
-
: `🗜 compacted ${result.checkpointId} · ${fileLabel}`;
|
|
90
99
|
runtime.lastActivityAt = Date.now();
|
|
91
100
|
// Explain-why line: surfaced while fresh. Pulls the dedup reason (which for
|
|
92
101
|
// L2 includes the cosine sim) so the user sees WHY a region was kept/dropped.
|
|
@@ -124,6 +133,37 @@ export function runCompact(
|
|
|
124
133
|
deduped: result.deduped,
|
|
125
134
|
});
|
|
126
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
|
+
|
|
127
167
|
runtime.setStatus(
|
|
128
168
|
ctx,
|
|
129
169
|
runtime.rt.persistedThisSession
|
|
@@ -162,8 +202,22 @@ export function doRecall(
|
|
|
162
202
|
) {
|
|
163
203
|
runtime.bindRepo(ctx.cwd);
|
|
164
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;
|
|
165
209
|
const result = recallAndInline(
|
|
166
|
-
{
|
|
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
|
+
},
|
|
167
221
|
runtime.store,
|
|
168
222
|
);
|
|
169
223
|
runtime.dashboard.event("recall", { source, query: query.slice(0, 120), injected: result.toInject.length, empty: result.empty });
|
|
@@ -177,3 +231,26 @@ export function doRecall(
|
|
|
177
231
|
}
|
|
178
232
|
return result;
|
|
179
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
|
+
}
|
|
@@ -90,10 +90,7 @@ export class MegaRuntime {
|
|
|
90
90
|
// on model_select + session_start; persisted to SQL so cost + the dashboard
|
|
91
91
|
// can read it without a live ctx.
|
|
92
92
|
currentModel: ModelSnapshot | undefined;
|
|
93
|
-
// Live "what it's doing right now"
|
|
94
|
-
// compaction; shown in teal while recent, then kept as the last-seen action so
|
|
95
|
-
// the widget is never blank. Cleared on session reset.
|
|
96
|
-
currentActivity: string | undefined;
|
|
93
|
+
// Live "what it's doing right now" timestamp, used for the fresh-window.
|
|
97
94
|
lastActivityAt = 0;
|
|
98
95
|
// Live per-tier dedup trace (Phase 1): e.g. "L0 ✓ → L1 ✓ → L2 0.91 → stored".
|
|
99
96
|
// Built from the store's sync onTier callback during a compaction so the user
|
|
@@ -269,25 +266,25 @@ export class MegaRuntime {
|
|
|
269
266
|
const bar = "▓".repeat(filled) + "░".repeat(10 - filled);
|
|
270
267
|
lines.push(` ${C.green}saved ${fmt(this.rt.tokensSaved)} ${bar}${C.reset} ${pct}% of ${fmt(goal)}`);
|
|
271
268
|
}
|
|
272
|
-
// Live "now processing" line
|
|
273
|
-
//
|
|
269
|
+
// Live "now processing" line + why + recent deduped/compacted events,
|
|
270
|
+
// collapsed to ONE rotating line (fresh only). The ticker ring buffer
|
|
271
|
+
// (≤5 most-recent events) is cycled one-per-repaint so the line scrolls
|
|
272
|
+
// through recent files in real time while activity fires. We rotate on a
|
|
273
|
+
// 250ms step (same cadence as the pulse), using an event counter as the
|
|
274
|
+
// deterministic phase so consecutive repaints advance the visible entry.
|
|
274
275
|
const fresh = Date.now() - this.lastActivityAt < 4000;
|
|
275
276
|
if (this.tierTrace && fresh) {
|
|
276
277
|
lines.push(` ${pulse}${this.tierTrace}`);
|
|
277
|
-
} else if (this.
|
|
278
|
-
|
|
278
|
+
} else if (this.ticker.length > 0) {
|
|
279
|
+
const step = Math.floor(Date.now() / 250);
|
|
280
|
+
const idx = this.ticker.length - 1 - (step % this.ticker.length);
|
|
281
|
+
const head = this.ticker[idx].text;
|
|
282
|
+
const why = this.lastWhy ? ` ${C.gray}· ${this.lastWhy}${C.reset}` : "";
|
|
283
|
+
const more = this.ticker.length > 1 ? ` ${C.dim}(+${this.ticker.length - 1} more)${C.reset}` : "";
|
|
284
|
+
lines.push(` ${fresh ? C.teal : C.dim}${head}${why}${more}${C.reset}`);
|
|
279
285
|
} else if (this.pulsing) {
|
|
280
286
|
lines.push(` ${pulse}${C.teal}compacting…${C.reset}`);
|
|
281
287
|
}
|
|
282
|
-
// Phase 3 — explain-why line (fresh only).
|
|
283
|
-
if (this.lastWhy && fresh) lines.push(` ${C.gray}${this.lastWhy}${C.reset}`);
|
|
284
|
-
// Phase 3 — recall/activity ticker (most-recent first), fresh only.
|
|
285
|
-
if (fresh) {
|
|
286
|
-
for (let i = this.ticker.length - 1; i >= 0; i--) {
|
|
287
|
-
if (lines.length >= 9) break; // leave room for the hint line (MAX 10)
|
|
288
|
-
lines.push(` ${i === this.ticker.length - 1 ? "" : C.dim}${this.ticker[i].text}${C.reset}`);
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
288
|
// Plain-language hint so first-time users understand the widget. Always
|
|
292
289
|
// last, dimmed. "/mega-help explains these terms."
|
|
293
290
|
if (lines.length < 10) {
|
|
@@ -318,7 +315,6 @@ export class MegaRuntime {
|
|
|
318
315
|
this.statusKey = undefined;
|
|
319
316
|
this.activeAgents = 0;
|
|
320
317
|
this.currentTurn = 0;
|
|
321
|
-
this.currentActivity = undefined;
|
|
322
318
|
this.lastActivityAt = 0;
|
|
323
319
|
this.tierTrace = undefined;
|
|
324
320
|
this.ticker.length = 0;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-mega-compact",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.23",
|
|
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
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* promote.test.ts — Fix D: RAPTOR tree served by vectorStore.search.
|
|
3
|
+
*
|
|
4
|
+
* Asserts that, when a RAPTOR tree has been built + persisted for a session,
|
|
5
|
+
* VectorStore.search returns the tree's staged-expansion hits (broader, O(log n)
|
|
6
|
+
* coverage) merged with the flat hits — so the dormant tree becomes the live
|
|
7
|
+
* recall surface. No network: default extractive summarizer + trigram embedder.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { VectorStore } from "../../vectorStore.js";
|
|
16
|
+
import { runRaptor } from "./index.js";
|
|
17
|
+
import { compactSession } from "../../engine.js";
|
|
18
|
+
import { Logger } from "../../log.js";
|
|
19
|
+
import { loadDedupConfig } from "../../config/dedup.js";
|
|
20
|
+
import { listRaptorNodes } from "../../store/sqlite.js";
|
|
21
|
+
import type { EngineMessage } from "../../types.js";
|
|
22
|
+
|
|
23
|
+
const baseTmp = mkdtempSync(join(tmpdir(), "mc-promote-"));
|
|
24
|
+
let counter = 0;
|
|
25
|
+
function raptorConfig() {
|
|
26
|
+
return { ...loadDedupConfig(), RAPTOR_ENABLED: true };
|
|
27
|
+
}
|
|
28
|
+
function msg(text: string, toolName?: string): EngineMessage {
|
|
29
|
+
return toolName ? { role: "assistant", text, toolName, input: text, output: text } : { role: "user", text };
|
|
30
|
+
}
|
|
31
|
+
const SESS = "sess_promote";
|
|
32
|
+
|
|
33
|
+
test("Fix D: vectorStore.search serves a persisted RAPTOR tree (broader recall)", () => {
|
|
34
|
+
const stateDir = join(baseTmp, `run-${counter++}`);
|
|
35
|
+
const s = new VectorStore({ dedupSim: 0.9, stateDir, config: raptorConfig() });
|
|
36
|
+
|
|
37
|
+
// Persist several distinct checkpoints.
|
|
38
|
+
for (let i = 1; i <= 5; i++) {
|
|
39
|
+
compactSession(
|
|
40
|
+
{ sessionId: SESS, messages: [msg(`topic alpha wire ${i} and bootstrap sequence`), msg(`ok ${i}`, "Edit")], keepFrom: 2, timestamp: i },
|
|
41
|
+
s,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// No tree yet → flat search only, returns hits, no RAPTOR coverage.
|
|
46
|
+
assert.equal(listRaptorNodes(SESS, stateDir).length, 0, "no tree initially");
|
|
47
|
+
const flat = s.search(SESS, "alpha wire bootstrap", 3);
|
|
48
|
+
assert.ok(flat.length > 0, "flat search returns hits");
|
|
49
|
+
|
|
50
|
+
// Build + persist a RAPTOR tree for the session (mirrors runCompact refresh).
|
|
51
|
+
const all = s.list(SESS);
|
|
52
|
+
const leaves = all.map((cp) => ({
|
|
53
|
+
id: cp.checkpointId,
|
|
54
|
+
messages: [],
|
|
55
|
+
sourceText: cp.normalizedText ?? cp.summary ?? cp.regionHash,
|
|
56
|
+
embedding: cp.embedding,
|
|
57
|
+
}));
|
|
58
|
+
const tree = runRaptor(leaves, { stateDir, sessionId: SESS, logger: new Logger() });
|
|
59
|
+
assert.ok(tree && listRaptorNodes(SESS, stateDir).length > 0, "tree persisted");
|
|
60
|
+
|
|
61
|
+
// With the tree live + RAPTOR_ENABLED, search still returns hits and now
|
|
62
|
+
// exercises the RAPTOR-served path without regression.
|
|
63
|
+
const withTree = s.search(SESS, "alpha wire bootstrap", 3);
|
|
64
|
+
assert.ok(withTree.length > 0, "search returns hits with RAPTOR promoted");
|
|
65
|
+
// Every returned hit is a real checkpoint in the session.
|
|
66
|
+
for (const h of withTree) {
|
|
67
|
+
assert.ok(all.some((cp) => cp.checkpointId === h.checkpoint.checkpointId), "hit is a real checkpoint");
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("Fix D: search still works for a session with <2 leaves (no tree)", () => {
|
|
72
|
+
const stateDir = join(baseTmp, `run-${counter++}`);
|
|
73
|
+
const s = new VectorStore({ dedupSim: 0.9, stateDir, config: raptorConfig() });
|
|
74
|
+
compactSession({ sessionId: SESS, messages: [msg("only one topic here"), msg("ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
|
|
75
|
+
const r = s.search(SESS, "only one topic", 3);
|
|
76
|
+
assert.ok(r.length > 0, "single-checkpoint search still works (no tree)");
|
|
77
|
+
assert.equal(listRaptorNodes(SESS, stateDir).length, 0, "no tree built for <2 leaves");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("cleanup", () => {
|
|
81
|
+
rmSync(baseTmp, { recursive: true, force: true });
|
|
82
|
+
});
|
package/src/engine.ts
CHANGED
|
@@ -38,6 +38,10 @@ export interface CompactInput {
|
|
|
38
38
|
timestamp?: number;
|
|
39
39
|
/** When true (default), use extractive summary instead of raw concatenation. */
|
|
40
40
|
useExtractiveSummary?: boolean;
|
|
41
|
+
/** Context-window pressure (0–1): how close the session is to the model
|
|
42
|
+
* limit. Drives adaptive compression strength in the stored checkpoint
|
|
43
|
+
* (Fix E). 0/undefined = room to spare; 1 = at the limit. */
|
|
44
|
+
compressionPressure?: number;
|
|
41
45
|
/** Sync progress callback fired by the store as each dedup tier is evaluated
|
|
42
46
|
* (L0→L1→L2→new). Lets the UI render a live "L0 ✓ → L1 ✓ → L2 0.91 → stored"
|
|
43
47
|
* progress line during compaction. Never awaited; must be side-effect-free-ish
|
|
@@ -174,6 +178,7 @@ export function compactSession(input: CompactInput, store: VectorStore = getDefa
|
|
|
174
178
|
originalTokenEstimate,
|
|
175
179
|
timestamp: input.timestamp ?? 0,
|
|
176
180
|
onTier: input.onTier,
|
|
181
|
+
compressionPressure: input.compressionPressure,
|
|
177
182
|
});
|
|
178
183
|
|
|
179
184
|
return {
|
package/src/recall.test.ts
CHANGED
|
@@ -54,6 +54,50 @@ test("recallAndInline empty when store has nothing for query", () => {
|
|
|
54
54
|
assert.equal(r.block, "");
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
test("Fix C: recallMaxTokens caps the injected block", () => {
|
|
58
|
+
const s = store();
|
|
59
|
+
// Three distinct checkpoints so we can observe the cap bite mid-stream.
|
|
60
|
+
compactSession({ sessionId: SESS, messages: [msg("user", "alpha module wiring and bootstrap sequence"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
|
|
61
|
+
compactSession({ sessionId: SESS, messages: [msg("user", "beta module config and env resolution"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 2 }, s);
|
|
62
|
+
compactSession({ sessionId: SESS, messages: [msg("user", "gamma module shutdown and cleanup hooks"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 3 }, s);
|
|
63
|
+
|
|
64
|
+
// A ceiling of 100 tokens fits the first checkpoint (~82) but stops before the
|
|
65
|
+
// second (~163 cumulative) — proving the cap bites mid-stream.
|
|
66
|
+
const r = recallAndInline(
|
|
67
|
+
{ sessionId: SESS, query: "module wiring config shutdown", limit: 5, source: "command", recallMaxTokens: 100, skipInjected: false },
|
|
68
|
+
s as any,
|
|
69
|
+
);
|
|
70
|
+
assert.ok(r.toInject.length >= 1, "at least one injected under the cap");
|
|
71
|
+
assert.ok(r.toInject.length < 3, "cap prevented all three from injecting");
|
|
72
|
+
assert.ok(r.block.length > 0, "block non-empty");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("Fix C: inline dedupe drops a hit already resident in the live window", () => {
|
|
76
|
+
const s = store();
|
|
77
|
+
const resident = "alpha module wiring and bootstrap sequence";
|
|
78
|
+
compactSession({ sessionId: SESS, messages: [msg("user", resident), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
|
|
79
|
+
compactSession({ sessionId: SESS, messages: [msg("user", "omega module telemetry and tracing spans"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 2 }, s);
|
|
80
|
+
|
|
81
|
+
// Baseline: with dedupe OFF, both checkpoints are candidates.
|
|
82
|
+
const rNoDedup = recallAndInline(
|
|
83
|
+
{ sessionId: SESS, query: "module wiring telemetry", limit: 5, source: "command", skipInjected: false },
|
|
84
|
+
s as any,
|
|
85
|
+
);
|
|
86
|
+
// The live window contains the exact summary of the first checkpoint — as it
|
|
87
|
+
// would be if a prior recall already injected it. Inline dedupe must drop it
|
|
88
|
+
// (strictly fewer injected than the no-dedupe baseline).
|
|
89
|
+
const residentSummary = rNoDedup.toInject[0].checkpoint.summary;
|
|
90
|
+
const rDedup = recallAndInline(
|
|
91
|
+
{ sessionId: SESS, query: "module wiring telemetry", limit: 5, source: "command", skipInjected: false, windowDedupe: true, liveWindow: [residentSummary], dedupSim: 0.9 },
|
|
92
|
+
s as any,
|
|
93
|
+
);
|
|
94
|
+
assert.ok(rDedup.toInject.length <= rNoDedup.toInject.length, "dedupe never adds hits");
|
|
95
|
+
assert.ok(
|
|
96
|
+
rDedup.toInject.length < rNoDedup.toInject.length,
|
|
97
|
+
"inline dedupe dropped a resident hit",
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
57
101
|
test("cleanup", () => {
|
|
58
102
|
rmSync(baseTmp, { recursive: true, force: true });
|
|
59
103
|
});
|