pi-mega-compact 0.4.21 → 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/dashboard-server.js +3 -3
- 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-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.ts +4 -4
- 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-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
|
@@ -15,7 +15,7 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync }
|
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
16
|
import { join, dirname } from "node:path";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
|
-
import
|
|
18
|
+
import { DatabaseSync } from "node:sqlite";
|
|
19
19
|
// --- Multi-repo index (Phase 5b) ------------------------------------------------
|
|
20
20
|
// The extension writes a machine-wide repo registry into a single SQLite DB
|
|
21
21
|
// (<indexDir>/index.sqlite) as the concurrency-safe write path; the dashboard
|
|
@@ -42,8 +42,8 @@ function readIndex() {
|
|
|
42
42
|
let db;
|
|
43
43
|
try {
|
|
44
44
|
// Read-only + immutable WAL so a concurrent writer's WAL never blocks us.
|
|
45
|
-
db = new
|
|
46
|
-
db.
|
|
45
|
+
db = new DatabaseSync(indexPath, { readOnly: true });
|
|
46
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
47
47
|
const rows = db
|
|
48
48
|
.prepare("SELECT * FROM repo_registry ORDER BY last_seen DESC")
|
|
49
49
|
.all();
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mega-compact-driver.ts — the durable-trim driver (Fix B).
|
|
3
|
+
*
|
|
4
|
+
* The read-path token-growth bug: the old design cancelled pi's native
|
|
5
|
+
* compaction (`{ cancel: true }`) and did its own ephemeral `context`-hook
|
|
6
|
+
* drop. That drop only affected the outgoing request — the on-disk transcript
|
|
7
|
+
* was never trimmed (the session manager is read-only for extensions). So on
|
|
8
|
+
* resume pi reloaded the FULL transcript and we ADDED a recall block on top →
|
|
9
|
+
* more tokens than before compaction.
|
|
10
|
+
*
|
|
11
|
+
* The fix: on `session_before_compact` we RUN the Trident pipeline to produce a
|
|
12
|
+
* genuinely compressed summary, then RETURN it as a `CompactionResult`. pi
|
|
13
|
+
* durably writes our summary into a `compactionSummary` entry AND truncates the
|
|
14
|
+
* transcript from `firstKeptEntryId`. After that, resume reloads the already-
|
|
15
|
+
* trimmed transcript (summary baked in) — no additive re-injection, no token
|
|
16
|
+
* growth.
|
|
17
|
+
*
|
|
18
|
+
* We reuse pi's `preparation.firstKeptEntryId` (pi already computed the cut
|
|
19
|
+
* honoring the anchor-floor + tool-pair guards — PREVENT-PI-002) rather than
|
|
20
|
+
* recomputing it, so we cannot hand pi a boundary that splits a tool pair.
|
|
21
|
+
*/
|
|
22
|
+
import { compactSession } from "../src/engine.js";
|
|
23
|
+
import { toEngineMessages } from "../src/adapt.js";
|
|
24
|
+
import { estimateBlockTokens, estimateSessionTokens } from "../src/tokens.js";
|
|
25
|
+
import { recallRaptorRootSummary } from "../src/dedup/raptor/index.js";
|
|
26
|
+
/**
|
|
27
|
+
* Build our durable compaction result from pi's pre-computed preparation.
|
|
28
|
+
*
|
|
29
|
+
* Returns undefined when there is nothing to summarize (pi will then run its
|
|
30
|
+
* own native compaction, or skip). Never throws for "empty" — best-effort.
|
|
31
|
+
*/
|
|
32
|
+
export function driveNativeCompaction(event, runtime, config) {
|
|
33
|
+
const prep = event.preparation;
|
|
34
|
+
if (!prep)
|
|
35
|
+
return undefined;
|
|
36
|
+
const sid = runtime.rt.sessionId;
|
|
37
|
+
const messagesToSummarize = prep.messagesToSummarize ?? [];
|
|
38
|
+
if (messagesToSummarize.length === 0)
|
|
39
|
+
return undefined;
|
|
40
|
+
const engineView = toEngineMessages(messagesToSummarize);
|
|
41
|
+
// We don't drop anything here — pi keeps from prep.firstKeptEntryId. We only
|
|
42
|
+
// summarize the region pi is about to discard.
|
|
43
|
+
const keepFrom = engineView.length;
|
|
44
|
+
const result = compactSession({
|
|
45
|
+
sessionId: sid,
|
|
46
|
+
messages: engineView,
|
|
47
|
+
keepFrom,
|
|
48
|
+
timestamp: Date.now(),
|
|
49
|
+
useExtractiveSummary: true,
|
|
50
|
+
}, runtime.store);
|
|
51
|
+
if (result.skipped)
|
|
52
|
+
return undefined;
|
|
53
|
+
// Prefer the RAPTOR root summary when the tree is built + enabled (Fix D):
|
|
54
|
+
// it is a session-level compressed summary, broader than one slice's. Fall
|
|
55
|
+
// back to the extractive topicSummary of this slice.
|
|
56
|
+
let summary = result.summary;
|
|
57
|
+
if (config.raptorEnabled) {
|
|
58
|
+
const root = recallRaptorRootSummary(sid, runtime.currentStateDir);
|
|
59
|
+
if (root)
|
|
60
|
+
summary = root;
|
|
61
|
+
}
|
|
62
|
+
const tokensBefore = prep.tokensBefore ?? estimateSessionTokens(engineView);
|
|
63
|
+
const summaryTokens = estimateBlockTokens(summary);
|
|
64
|
+
// pi keeps the tail from firstKeptEntryId; our summary replaces the discarded
|
|
65
|
+
// region. Honest saved = discarded-region tokens − our summary tokens.
|
|
66
|
+
const savedTokens = Math.max(0, tokensBefore - summaryTokens);
|
|
67
|
+
runtime.rt.lastCompactedFrom = keepFrom;
|
|
68
|
+
runtime.rt.lastCompactedTokens = tokensBefore;
|
|
69
|
+
runtime.rt.tokensSaved += savedTokens;
|
|
70
|
+
runtime.rt.persistedThisSession = true;
|
|
71
|
+
return {
|
|
72
|
+
compaction: {
|
|
73
|
+
summary,
|
|
74
|
+
firstKeptEntryId: prep.firstKeptEntryId,
|
|
75
|
+
tokensBefore,
|
|
76
|
+
estimatedTokensAfter: summaryTokens,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -42,6 +42,7 @@ function harness(opts = {}) {
|
|
|
42
42
|
let statusKey;
|
|
43
43
|
let statusText;
|
|
44
44
|
const notifies = [];
|
|
45
|
+
const compactCalls = [];
|
|
45
46
|
// Minimal AgentMessage factory for the session we project into the extension.
|
|
46
47
|
function msg(role, text, toolName) {
|
|
47
48
|
if (role === "assistant" && toolName) {
|
|
@@ -100,7 +101,27 @@ function harness(opts = {}) {
|
|
|
100
101
|
hasPendingMessages: () => false,
|
|
101
102
|
shutdown: () => { },
|
|
102
103
|
getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }),
|
|
103
|
-
|
|
104
|
+
// Faithful mock: ctx.compact() starts pi's flow, which fires the
|
|
105
|
+
// session_before_compact handler (where WE supply the durable trim).
|
|
106
|
+
compact: (opts) => {
|
|
107
|
+
compactCalls.push(opts);
|
|
108
|
+
if (handlers["session_before_compact"]) {
|
|
109
|
+
return handlers["session_before_compact"]({
|
|
110
|
+
type: "session_before_compact",
|
|
111
|
+
reason: "threshold",
|
|
112
|
+
willRetry: false,
|
|
113
|
+
signal: undefined,
|
|
114
|
+
// pi computed the cut honoring anchor floor + tool-pair (PREVENT-PI-002);
|
|
115
|
+
// our handler reuses it as firstKeptEntryId.
|
|
116
|
+
preparation: {
|
|
117
|
+
firstKeptEntryId: "e2",
|
|
118
|
+
messagesToSummarize: session.slice(0, 2),
|
|
119
|
+
tokensBefore: 500,
|
|
120
|
+
},
|
|
121
|
+
}, makeCtx());
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
},
|
|
104
125
|
getSystemPrompt: () => "system base",
|
|
105
126
|
...over,
|
|
106
127
|
};
|
|
@@ -133,13 +154,13 @@ function harness(opts = {}) {
|
|
|
133
154
|
const mod = require("./mega-compact.js");
|
|
134
155
|
mod.default(pi);
|
|
135
156
|
return {
|
|
136
|
-
stateDir, handlers, commands, appended, get status() { return { statusKey, statusText }; }, notifies,
|
|
157
|
+
stateDir, handlers, commands, appended, get status() { return { statusKey, statusText }; }, notifies, compactCalls,
|
|
137
158
|
fire: (ev, event, ctx) => handlers[ev](event, ctx),
|
|
138
159
|
ctx: makeCtx,
|
|
139
160
|
session,
|
|
140
161
|
};
|
|
141
162
|
}
|
|
142
|
-
test("auto-trigger: past threshold persists a chkpt and
|
|
163
|
+
test("auto-trigger: past threshold persists a chkpt and starts a durable trim", async () => {
|
|
143
164
|
const h = harness();
|
|
144
165
|
const messages = h.session;
|
|
145
166
|
const ctx = h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
|
|
@@ -148,25 +169,40 @@ test("auto-trigger: past threshold persists a chkpt and drops context", async ()
|
|
|
148
169
|
const { listCheckpoints } = await import("../src/store/sqlite.js");
|
|
149
170
|
assert.ok(listCheckpoints("sess_ext_001", h.stateDir).length > 0, "checkpoint persisted to local vector db");
|
|
150
171
|
assert.equal(h.appended.some((a) => a.t === "mega-compact-marker"), true, "marker sentinel appended");
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
172
|
+
// The context handler no longer drops messages itself (that was ephemeral —
|
|
173
|
+
// the read-path token-growth bug). It triggers pi's compaction flow, which
|
|
174
|
+
// calls our session_before_compact handler to supply the DURABLE trim.
|
|
175
|
+
assert.equal(res, undefined, "context handler returns nothing (no local drop)");
|
|
176
|
+
assert.equal(h.compactCalls.length, 1, "ctx.compact() called to start durable trim");
|
|
177
|
+
// The durable trim was supplied (summary + firstKeptEntryId from pi's prep).
|
|
178
|
+
assert.ok(h.compactCalls[0] !== undefined, "compaction flow executed");
|
|
154
179
|
});
|
|
155
|
-
test("session_before_compact
|
|
180
|
+
test("session_before_compact supplies our durable trim (not pi's summary)", async () => {
|
|
156
181
|
const h = harness();
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
182
|
+
// pi fires session_before_compact with its own computed preparation.
|
|
183
|
+
const res = await h.fire("session_before_compact", {
|
|
184
|
+
type: "session_before_compact",
|
|
185
|
+
reason: "overflow",
|
|
186
|
+
willRetry: true,
|
|
187
|
+
preparation: { firstKeptEntryId: "e2", messagesToSummarize: h.session.slice(0, 2), tokensBefore: 500 },
|
|
188
|
+
signal: undefined,
|
|
189
|
+
}, h.ctx());
|
|
190
|
+
assert.ok(res && res.compaction, "returns a compaction result");
|
|
191
|
+
assert.equal(res.compaction.firstKeptEntryId, "e2", "reuses pi's cut boundary (PREVENT-PI-002 safe)");
|
|
192
|
+
assert.ok(typeof res.compaction.summary === "string" && res.compaction.summary.length > 0, "our summary supplied");
|
|
193
|
+
assert.ok(res.compaction.tokensBefore >= 0, "tokensBefore reported");
|
|
163
194
|
});
|
|
164
|
-
test("session_before_compact
|
|
195
|
+
test("session_before_compact falls back to pi when nothing to summarize", async () => {
|
|
165
196
|
const h = harness();
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
197
|
+
// Empty preparation → no messages to summarize → return {} so pi compacts natively.
|
|
198
|
+
const res = await h.fire("session_before_compact", {
|
|
199
|
+
type: "session_before_compact",
|
|
200
|
+
reason: "threshold",
|
|
201
|
+
willRetry: false,
|
|
202
|
+
preparation: { firstKeptEntryId: "e0", messagesToSummarize: [], tokensBefore: 0 },
|
|
203
|
+
signal: undefined,
|
|
204
|
+
}, h.ctx());
|
|
205
|
+
assert.deepEqual(res, {}, "no compaction supplied → pi runs its own");
|
|
170
206
|
});
|
|
171
207
|
test("resume auto-inline stages recall into the system prompt", async () => {
|
|
172
208
|
const h = harness();
|
|
@@ -46,6 +46,12 @@ function resolveThreshold() {
|
|
|
46
46
|
const tier = (raw in COMPACT_TIERS ? raw : "low");
|
|
47
47
|
return { tier, thresholdTokens: COMPACT_TIERS[tier] };
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Pressure helpers for adaptive compression (Fix E) live in src/config.ts
|
|
51
|
+
* (pi-agnostic) so unit tests can import them without the pi runtime. Re-export
|
|
52
|
+
* here so the extension has one import surface.
|
|
53
|
+
*/
|
|
54
|
+
export { pressureFromPct, preserveRecentForPressure } from "../src/config.js";
|
|
49
55
|
/** Build the resolved config from env + defaults. */
|
|
50
56
|
export function loadConfig() {
|
|
51
57
|
const { tier, thresholdTokens } = resolveThreshold();
|
|
@@ -58,10 +64,14 @@ export function loadConfig() {
|
|
|
58
64
|
thresholdTokens,
|
|
59
65
|
anchorUserMessages: envFlag("MEGACOMPACT_ANCHOR_USER_MESSAGES", 3),
|
|
60
66
|
preserveRecent: envFlag("MEGACOMPACT_PRESERVE_RECENT", 4),
|
|
67
|
+
preserveRecentMin: envFlag("MEGACOMPACT_PRESERVE_RECENT_MIN", 2),
|
|
61
68
|
auto: envBool("MEGACOMPACT_AUTO", true),
|
|
62
69
|
autoInline: envBool("MEGACOMPACT_AUTO_INLINE", true),
|
|
63
70
|
autoInlineK: envFlag("MEGACOMPACT_AUTO_INLINE_K", 3),
|
|
64
71
|
dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),
|
|
72
|
+
raptorEnabled: envBool("MEGACOMPACT_RAPTOR_ENABLED", true),
|
|
73
|
+
recallMaxTokens: envFlag("MEGACOMPACT_RECALL_MAX_TOKENS", 1500),
|
|
74
|
+
windowDedupe: envBool("MEGACOMPACT_WINDOW_DEDUPE", true),
|
|
65
75
|
debug: envBool("MEGACOMPACT_DEBUG", false),
|
|
66
76
|
};
|
|
67
77
|
}
|
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
import { normalizeSessionId } from "../src/store.js";
|
|
10
10
|
import { autoCompactCheck } from "../src/compact.js";
|
|
11
11
|
import { estimateSessionTokens } from "../src/tokens.js";
|
|
12
|
-
import { dropCompactedRange } from "../src/adapt.js";
|
|
13
12
|
import { recentUserQuery, WIDGET_KEY } from "./mega-runtime.js";
|
|
14
13
|
import { runCompact, doRecall } from "./mega-pipeline.js";
|
|
14
|
+
import { driveNativeCompaction } from "./mega-compact-driver.js";
|
|
15
|
+
import { pressureFromPct } from "./mega-config.js";
|
|
15
16
|
/** Register all pi lifecycle event handlers. */
|
|
16
17
|
export function registerEventHandlers(pi, runtime, config) {
|
|
17
18
|
// ---- Session lifecycle (state reset points) -------------------------------
|
|
@@ -108,7 +109,15 @@ export function registerEventHandlers(pi, runtime, config) {
|
|
|
108
109
|
runtime.dashboard.event("turn_end", { turnIndex: event.turnIndex });
|
|
109
110
|
runtime.snapshot(ctx);
|
|
110
111
|
});
|
|
111
|
-
// ---- Auto-trigger:
|
|
112
|
+
// ---- Auto-trigger: own the decision, pi owns the durable write ----------
|
|
113
|
+
// OUR auto-trigger (over threshold + debounce): persist our Trident checkpoint,
|
|
114
|
+
// then start pi's compaction flow via ctx.compact(). That fires
|
|
115
|
+
// `session_before_compact`, where OUR handler returns our summary +
|
|
116
|
+
// firstKeptEntryId, and pi durably writes the trim to disk (appendCompaction).
|
|
117
|
+
// Result: auto-compact AND a durable trim — resume reloads the trimmed window,
|
|
118
|
+
// no full-reload + additive recall inflation (Fix B kills the token-growth bug).
|
|
119
|
+
// We do NOT drop messages here (that would be ephemeral; the read-only session
|
|
120
|
+
// manager can't trim disk, so the trim has to come through pi).
|
|
112
121
|
pi.on("context", async (event, ctx) => {
|
|
113
122
|
if (!config.auto)
|
|
114
123
|
return;
|
|
@@ -123,13 +132,9 @@ export function registerEventHandlers(pi, runtime, config) {
|
|
|
123
132
|
return;
|
|
124
133
|
const messages = event.messages;
|
|
125
134
|
const view = runtime.engineView(messages);
|
|
126
|
-
// Prefer the runtime's real token estimate; fall back to our heuristic
|
|
127
|
-
// (and to a percent-of-window proxy when tokens is unknown).
|
|
128
135
|
const currentTokens = usage?.tokens ?? estimateSessionTokens(view) ??
|
|
129
136
|
Math.round((pct / 100) * (usage?.contextWindow ?? 0));
|
|
130
137
|
// FAST GATE: token-based (tier threshold), not percentage-based.
|
|
131
|
-
// A 20% gate on a 2M window = 400k, which is way above the 50k low-tier
|
|
132
|
-
// threshold. Gate on the actual token count instead.
|
|
133
138
|
if (currentTokens < config.thresholdTokens)
|
|
134
139
|
return;
|
|
135
140
|
const check = autoCompactCheck(currentTokens, config.thresholdTokens); // SERVER-STYLE CONFIRM (local)
|
|
@@ -140,28 +145,45 @@ export function registerEventHandlers(pi, runtime, config) {
|
|
|
140
145
|
if (now < runtime.debounceUntil)
|
|
141
146
|
return;
|
|
142
147
|
runtime.debounceUntil = now + 2000;
|
|
143
|
-
|
|
148
|
+
// Adaptive compression (Fix E): scale compression strength + keepFrom depth
|
|
149
|
+
// with how close we are to the model context limit.
|
|
150
|
+
const pressure = pressureFromPct(pct);
|
|
151
|
+
const ran = runCompact(pi, runtime, config, ctx, messages, { compressionPressure: pressure });
|
|
144
152
|
if (ran.skipped)
|
|
145
153
|
return;
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
if (kept.length < messages.length) {
|
|
150
|
-
return { messages: kept };
|
|
151
|
-
}
|
|
154
|
+
// Start pi's compaction flow so our session_before_compact handler can
|
|
155
|
+
// supply the durable trim (pi writes it to disk). We never use pi's summary.
|
|
156
|
+
ctx.compact({ customInstructions: undefined });
|
|
152
157
|
});
|
|
153
|
-
// ----
|
|
154
|
-
|
|
158
|
+
// ---- Supply a DURABLE trim to pi's native compaction (Fix B) ----------
|
|
159
|
+
// We run the Trident pipeline to produce a compressed summary, then return
|
|
160
|
+
// it as a CompactionResult. pi writes the summary into a compactionSummary
|
|
161
|
+
// entry AND truncates the on-disk transcript from firstKeptEntryId. This is
|
|
162
|
+
// the durable fix for "tokens grow on read": the trim survives resume, so
|
|
163
|
+
// there is no full-reload + additive recall inflation.
|
|
164
|
+
pi.on("session_before_compact", async (event, ctx) => {
|
|
155
165
|
runtime.resetRuntime(ctx.sessionManager.getSessionId());
|
|
156
|
-
if (
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
166
|
+
if (!config.auto)
|
|
167
|
+
return {}; // let pi run its own native compaction
|
|
168
|
+
try {
|
|
169
|
+
const result = driveNativeCompaction(event, runtime, config);
|
|
170
|
+
if (result) {
|
|
171
|
+
runtime.logger.info("native-compact", {
|
|
172
|
+
sessionId: runtime.rt.sessionId,
|
|
173
|
+
firstKeptEntryId: result.compaction.firstKeptEntryId,
|
|
174
|
+
tokensBefore: result.compaction.tokensBefore,
|
|
175
|
+
summaryTokens: result.compaction.estimatedTokensAfter,
|
|
176
|
+
});
|
|
177
|
+
return { compaction: result.compaction };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
runtime.logger.error("native-compact-failed", {
|
|
182
|
+
sessionId: runtime.rt.sessionId,
|
|
183
|
+
error: String(err instanceof Error ? err.message : err),
|
|
184
|
+
});
|
|
161
185
|
}
|
|
162
|
-
//
|
|
163
|
-
// (Our auto-trigger only fires again past the threshold, and will then
|
|
164
|
-
// capture a checkpoint next time around.)
|
|
186
|
+
// Fall back to pi's own native compaction if we can't supply one.
|
|
165
187
|
return {};
|
|
166
188
|
});
|
|
167
189
|
}
|
|
@@ -6,12 +6,15 @@
|
|
|
6
6
|
* the shared MegaRuntime (token accounting, ticker, status, events) and are
|
|
7
7
|
* driven by the event + command handlers in mega-events.ts / mega-commands.ts.
|
|
8
8
|
*/
|
|
9
|
+
import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
|
|
9
10
|
import { compactSession } from "../src/engine.js";
|
|
10
11
|
import { recallAndInline } from "../src/recall.js";
|
|
11
12
|
import { normalizeSessionId } from "../src/store.js";
|
|
12
13
|
import { touchSession, logDaily } from "../src/store/sqlite.js";
|
|
13
14
|
import { C, MARKER_TYPE, } from "./mega-runtime.js";
|
|
14
|
-
import { resolveRepoRoot } from "./mega-config.js";
|
|
15
|
+
import { resolveRepoRoot, preserveRecentForPressure } from "./mega-config.js";
|
|
16
|
+
import { runRaptor } from "../src/dedup/raptor/index.js";
|
|
17
|
+
import { loadDedupConfig } from "../src/config/dedup.js";
|
|
15
18
|
/** Run the full compaction pipeline and persist a checkpoint. Returns the result. */
|
|
16
19
|
export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
|
|
17
20
|
runtime.bindRepo(ctx.cwd);
|
|
@@ -19,7 +22,10 @@ export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
|
|
|
19
22
|
runtime.resetRuntime(sid);
|
|
20
23
|
runtime.rt.sessionId = sid;
|
|
21
24
|
const view = runtime.engineView(messages);
|
|
22
|
-
|
|
25
|
+
// keepFrom deepens with context pressure (Fix E): under high pressure we
|
|
26
|
+
// compact more of the session, down to the preserveRecentMin floor.
|
|
27
|
+
const preserve = preserveRecentForPressure(opts.compressionPressure ?? 0, config.preserveRecent, config.preserveRecentMin);
|
|
28
|
+
const keepFrom = opts.keepFrom ?? Math.max(0, view.length - preserve);
|
|
23
29
|
if (keepFrom <= 0)
|
|
24
30
|
return { skipped: true };
|
|
25
31
|
runtime.pulsing = true; // animate the status line while the (sync) pipeline runs
|
|
@@ -30,6 +36,7 @@ export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
|
|
|
30
36
|
summary: opts.summary,
|
|
31
37
|
timestamp: Date.now(),
|
|
32
38
|
onTier: runtime.makeTierCallback(ctx),
|
|
39
|
+
compressionPressure: opts.compressionPressure,
|
|
33
40
|
}, runtime.store);
|
|
34
41
|
runtime.pulsing = false;
|
|
35
42
|
if (result.skipped)
|
|
@@ -96,6 +103,34 @@ export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
|
|
|
96
103
|
tokenEstimate: result.tokenEstimate,
|
|
97
104
|
deduped: result.deduped,
|
|
98
105
|
});
|
|
106
|
+
// Fix D: refresh the RAPTOR tree for this session so live recall (search) can
|
|
107
|
+
// serve high-level summaries. Best-effort + non-fatal: never block compaction.
|
|
108
|
+
// Budget-guarded (RAPTOR_BUDGET_MS) so it can't hang a large session.
|
|
109
|
+
if (config.raptorEnabled && !result.deduped) {
|
|
110
|
+
try {
|
|
111
|
+
const dd = loadDedupConfig();
|
|
112
|
+
const all = runtime.store.list(sid);
|
|
113
|
+
const leaves = all.map((cp) => ({
|
|
114
|
+
id: cp.checkpointId,
|
|
115
|
+
messages: [],
|
|
116
|
+
sourceText: cp.normalizedText ?? cp.summary ?? cp.regionHash,
|
|
117
|
+
embedding: cp.embedding,
|
|
118
|
+
}));
|
|
119
|
+
if (leaves.length >= 2) {
|
|
120
|
+
runRaptor(leaves, {
|
|
121
|
+
stateDir: runtime.currentStateDir,
|
|
122
|
+
sessionId: sid,
|
|
123
|
+
budgetMs: dd.RAPTOR_BUDGET_MS,
|
|
124
|
+
clustersPerLevel: dd.RAPTOR_CLUSTERS_PER_LEVEL,
|
|
125
|
+
consistencyThreshold: dd.RAPTOR_CONSISTENCY,
|
|
126
|
+
logger: runtime.logger,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
/* non-fatal: tree refresh never blocks a compaction */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
99
134
|
runtime.setStatus(ctx, runtime.rt.persistedThisSession
|
|
100
135
|
? `mega-compact: ${result.checkpointId} · ${saved} tok saved`
|
|
101
136
|
: `mega-compact: ready`);
|
|
@@ -124,7 +159,21 @@ export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
|
|
|
124
159
|
export function doRecall(runtime, config, ctx, query, source) {
|
|
125
160
|
runtime.bindRepo(ctx.cwd);
|
|
126
161
|
const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
|
|
127
|
-
|
|
162
|
+
// Live window text for inline dedupe (Fix C): drop recalled checkpoints that
|
|
163
|
+
// are already resident in the session, so recall never re-injects context the
|
|
164
|
+
// model can already see. Best-effort — an empty window just skips dedupe.
|
|
165
|
+
const liveWindow = config.windowDedupe ? extractLiveWindow(ctx) : undefined;
|
|
166
|
+
const result = recallAndInline({
|
|
167
|
+
sessionId: sid,
|
|
168
|
+
query,
|
|
169
|
+
limit: config.autoInlineK,
|
|
170
|
+
source,
|
|
171
|
+
skipInjected: true,
|
|
172
|
+
recallMaxTokens: config.recallMaxTokens,
|
|
173
|
+
windowDedupe: config.windowDedupe,
|
|
174
|
+
liveWindow,
|
|
175
|
+
dedupSim: config.dedupSim,
|
|
176
|
+
}, runtime.store);
|
|
128
177
|
runtime.dashboard.event("recall", { source, query: query.slice(0, 120), injected: result.toInject.length, empty: result.empty });
|
|
129
178
|
if (!result.empty && result.toInject.length > 0) {
|
|
130
179
|
const top = result.toInject[0];
|
|
@@ -136,3 +185,28 @@ export function doRecall(runtime, config, ctx, query, source) {
|
|
|
136
185
|
}
|
|
137
186
|
return result;
|
|
138
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* Extract the live-window message texts from the session manager (Fix C),
|
|
190
|
+
* for inline-dedupe of recalled checkpoints. Best-effort: returns [] on any
|
|
191
|
+
* error so recall falls back to unbounded (still correct, just no dedupe).
|
|
192
|
+
* Mirrors recentUserQuery's use of sessionEntryToContextMessages.
|
|
193
|
+
*/
|
|
194
|
+
function extractLiveWindow(ctx) {
|
|
195
|
+
try {
|
|
196
|
+
const entries = ctx.sessionManager.getEntries();
|
|
197
|
+
const texts = [];
|
|
198
|
+
for (const e of entries) {
|
|
199
|
+
for (const m of sessionEntryToContextMessages(e)) {
|
|
200
|
+
const c = m.content;
|
|
201
|
+
if (typeof c === "string")
|
|
202
|
+
texts.push(c);
|
|
203
|
+
else if (Array.isArray(c))
|
|
204
|
+
texts.push(c.map((b) => b.text).join(" "));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return texts;
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
}
|
package/dist/src/config/dedup.js
CHANGED
|
@@ -33,7 +33,10 @@ export function loadDedupConfig() {
|
|
|
33
33
|
L0_ENABLED: envBool("MEGACOMPACT_L0_ENABLED", true),
|
|
34
34
|
L1_ENABLED: envBool("MEGACOMPACT_L1_ENABLED", true),
|
|
35
35
|
L2_ENABLED: envBool("MEGACOMPACT_L2_ENABLED", true),
|
|
36
|
-
|
|
36
|
+
// Fix D: RAPTOR promoted to live recall. Default ON; canary.ts sequences it
|
|
37
|
+
// last (L0→L1→L2→RAPTOR) and auto-disables on p95 breach, so promotion is
|
|
38
|
+
// safe. `RAPTOR_SHADOW_MODE=false` still gates serving during transition.
|
|
39
|
+
RAPTOR_ENABLED: envBool("MEGACOMPACT_RAPTOR_ENABLED", true),
|
|
37
40
|
MARK_ONLY_L0: envBool("MEGACOMPACT_MARK_ONLY_L0", false),
|
|
38
41
|
MARK_ONLY_L1: envBool("MEGACOMPACT_MARK_ONLY_L1", false),
|
|
39
42
|
MARK_ONLY_L2: envBool("MEGACOMPACT_MARK_ONLY_L2", false),
|
package/dist/src/config.js
CHANGED
|
@@ -10,3 +10,24 @@ import { homedir } from "node:os";
|
|
|
10
10
|
export const STATE_DIR_DEFAULT = join(homedir(), ".pi", "agent", "extensions", "pi-mega-compact");
|
|
11
11
|
/** Pi custom message / entry type used as the dedup sentinel. */
|
|
12
12
|
export const MARKER_TYPE = "mega-compact-marker";
|
|
13
|
+
/**
|
|
14
|
+
* Derive context-window pressure (0–1) from a usage percentage. Used to scale
|
|
15
|
+
* compression strength + keepFrom depth (Fix E): low pct = room to spare,
|
|
16
|
+
* high pct = near the limit. Deterministic; clamps to [0,1].
|
|
17
|
+
*/
|
|
18
|
+
export function pressureFromPct(pct) {
|
|
19
|
+
if (pct == null || Number.isNaN(pct))
|
|
20
|
+
return 0;
|
|
21
|
+
return pct < 0 ? 0 : pct > 100 ? 1 : pct / 100;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Map pressure → how many recent messages to preserve verbatim. Under low
|
|
25
|
+
* pressure we keep `preserveRecent`; under high pressure we compact deeper,
|
|
26
|
+
* down to `preserveRecentMin`. Never splits a tool pair / anchor floor — the
|
|
27
|
+
* boundary guard (computeDropRange) enforces that downstream.
|
|
28
|
+
*/
|
|
29
|
+
export function preserveRecentForPressure(pressure, preserveRecent, preserveRecentMin) {
|
|
30
|
+
const p = pressure < 0 ? 0 : pressure > 1 ? 1 : pressure;
|
|
31
|
+
const v = Math.round(preserveRecent - (preserveRecent - preserveRecentMin) * p);
|
|
32
|
+
return Math.max(preserveRecentMin, Math.min(preserveRecent, v));
|
|
33
|
+
}
|
|
@@ -66,11 +66,20 @@ export function runRaptor(leaves, opts) {
|
|
|
66
66
|
*/
|
|
67
67
|
export function recallRaptor(query, sessionId, opts) {
|
|
68
68
|
const embedder = opts.embedder ?? defaultEmbedder();
|
|
69
|
-
const
|
|
70
|
-
if (
|
|
69
|
+
const tree = rehydrateRaptorTree(sessionId, opts.stateDir);
|
|
70
|
+
if (!tree)
|
|
71
71
|
return [];
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
return stagedExpansion(query, tree, { embedder, k: opts.k, topM: opts.topM });
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Rehydrate a persisted RAPTOR tree from raptor_nodes (Fix D): rebuild the
|
|
76
|
+
* in-memory RaptorTree + parent links so vectorStore.search can serve it live.
|
|
77
|
+
* Returns null when no tree exists (caller falls back to the flat path).
|
|
78
|
+
*/
|
|
79
|
+
export function rehydrateRaptorTree(sessionId, stateDir) {
|
|
80
|
+
const nodes = listRaptorNodes(sessionId, stateDir);
|
|
81
|
+
if (nodes.length === 0)
|
|
82
|
+
return null;
|
|
74
83
|
const tree = {
|
|
75
84
|
nodes: new Map(nodes.map((n) => [
|
|
76
85
|
n.id,
|
|
@@ -89,6 +98,19 @@ export function recallRaptor(query, sessionId, opts) {
|
|
|
89
98
|
levels: Math.max(1, ...nodes.map((n) => n.level + 1)),
|
|
90
99
|
timedOut: false,
|
|
91
100
|
};
|
|
92
|
-
|
|
93
|
-
|
|
101
|
+
return tree;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Return the RAPTOR root summary for a session, if a tree has been built.
|
|
105
|
+
* Used by the durable-trim driver (Fix B/D) to supply pi a session-level
|
|
106
|
+
* compressed summary instead of one slice's extractive summary. Returns
|
|
107
|
+
* undefined when no tree exists yet (caller falls back to the slice summary).
|
|
108
|
+
*/
|
|
109
|
+
export function recallRaptorRootSummary(sessionId, stateDir) {
|
|
110
|
+
const nodes = listRaptorNodes(sessionId, stateDir);
|
|
111
|
+
if (nodes.length === 0)
|
|
112
|
+
return undefined;
|
|
113
|
+
// Highest-level node = the root (covers all leaves).
|
|
114
|
+
const root = nodes.reduce((best, n) => (!best || n.level > best.level ? n : best), null);
|
|
115
|
+
return root?.summary || undefined;
|
|
94
116
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
import { test } from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { VectorStore } from "../../vectorStore.js";
|
|
15
|
+
import { runRaptor } from "./index.js";
|
|
16
|
+
import { compactSession } from "../../engine.js";
|
|
17
|
+
import { Logger } from "../../log.js";
|
|
18
|
+
import { loadDedupConfig } from "../../config/dedup.js";
|
|
19
|
+
import { listRaptorNodes } from "../../store/sqlite.js";
|
|
20
|
+
const baseTmp = mkdtempSync(join(tmpdir(), "mc-promote-"));
|
|
21
|
+
let counter = 0;
|
|
22
|
+
function raptorConfig() {
|
|
23
|
+
return { ...loadDedupConfig(), RAPTOR_ENABLED: true };
|
|
24
|
+
}
|
|
25
|
+
function msg(text, toolName) {
|
|
26
|
+
return toolName ? { role: "assistant", text, toolName, input: text, output: text } : { role: "user", text };
|
|
27
|
+
}
|
|
28
|
+
const SESS = "sess_promote";
|
|
29
|
+
test("Fix D: vectorStore.search serves a persisted RAPTOR tree (broader recall)", () => {
|
|
30
|
+
const stateDir = join(baseTmp, `run-${counter++}`);
|
|
31
|
+
const s = new VectorStore({ dedupSim: 0.9, stateDir, config: raptorConfig() });
|
|
32
|
+
// Persist several distinct checkpoints.
|
|
33
|
+
for (let i = 1; i <= 5; i++) {
|
|
34
|
+
compactSession({ sessionId: SESS, messages: [msg(`topic alpha wire ${i} and bootstrap sequence`), msg(`ok ${i}`, "Edit")], keepFrom: 2, timestamp: i }, s);
|
|
35
|
+
}
|
|
36
|
+
// No tree yet → flat search only, returns hits, no RAPTOR coverage.
|
|
37
|
+
assert.equal(listRaptorNodes(SESS, stateDir).length, 0, "no tree initially");
|
|
38
|
+
const flat = s.search(SESS, "alpha wire bootstrap", 3);
|
|
39
|
+
assert.ok(flat.length > 0, "flat search returns hits");
|
|
40
|
+
// Build + persist a RAPTOR tree for the session (mirrors runCompact refresh).
|
|
41
|
+
const all = s.list(SESS);
|
|
42
|
+
const leaves = all.map((cp) => ({
|
|
43
|
+
id: cp.checkpointId,
|
|
44
|
+
messages: [],
|
|
45
|
+
sourceText: cp.normalizedText ?? cp.summary ?? cp.regionHash,
|
|
46
|
+
embedding: cp.embedding,
|
|
47
|
+
}));
|
|
48
|
+
const tree = runRaptor(leaves, { stateDir, sessionId: SESS, logger: new Logger() });
|
|
49
|
+
assert.ok(tree && listRaptorNodes(SESS, stateDir).length > 0, "tree persisted");
|
|
50
|
+
// With the tree live + RAPTOR_ENABLED, search still returns hits and now
|
|
51
|
+
// exercises the RAPTOR-served path without regression.
|
|
52
|
+
const withTree = s.search(SESS, "alpha wire bootstrap", 3);
|
|
53
|
+
assert.ok(withTree.length > 0, "search returns hits with RAPTOR promoted");
|
|
54
|
+
// Every returned hit is a real checkpoint in the session.
|
|
55
|
+
for (const h of withTree) {
|
|
56
|
+
assert.ok(all.some((cp) => cp.checkpointId === h.checkpoint.checkpointId), "hit is a real checkpoint");
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
test("Fix D: search still works for a session with <2 leaves (no tree)", () => {
|
|
60
|
+
const stateDir = join(baseTmp, `run-${counter++}`);
|
|
61
|
+
const s = new VectorStore({ dedupSim: 0.9, stateDir, config: raptorConfig() });
|
|
62
|
+
compactSession({ sessionId: SESS, messages: [msg("only one topic here"), msg("ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
|
|
63
|
+
const r = s.search(SESS, "only one topic", 3);
|
|
64
|
+
assert.ok(r.length > 0, "single-checkpoint search still works (no tree)");
|
|
65
|
+
assert.equal(listRaptorNodes(SESS, stateDir).length, 0, "no tree built for <2 leaves");
|
|
66
|
+
});
|
|
67
|
+
test("cleanup", () => {
|
|
68
|
+
rmSync(baseTmp, { recursive: true, force: true });
|
|
69
|
+
});
|
package/dist/src/engine.js
CHANGED