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
|
@@ -16,7 +16,7 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync }
|
|
|
16
16
|
import { homedir } from "node:os";
|
|
17
17
|
import { join, dirname } from "node:path";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
|
-
import
|
|
19
|
+
import { DatabaseSync } from "node:sqlite";
|
|
20
20
|
|
|
21
21
|
// --- Multi-repo index (Phase 5b) ------------------------------------------------
|
|
22
22
|
// The extension writes a machine-wide repo registry into a single SQLite DB
|
|
@@ -54,11 +54,11 @@ interface IndexRepo {
|
|
|
54
54
|
function readIndex(): { updatedAt: string; summary: unknown; repos: unknown[] } | null {
|
|
55
55
|
const indexPath = join(getIndexDir(), "index.sqlite");
|
|
56
56
|
if (!existsSync(indexPath)) return null;
|
|
57
|
-
let db:
|
|
57
|
+
let db: DatabaseSync | undefined;
|
|
58
58
|
try {
|
|
59
59
|
// Read-only + immutable WAL so a concurrent writer's WAL never blocks us.
|
|
60
|
-
db = new
|
|
61
|
-
db.
|
|
60
|
+
db = new DatabaseSync(indexPath, { readOnly: true });
|
|
61
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
62
62
|
const rows = db
|
|
63
63
|
.prepare("SELECT * FROM repo_registry ORDER BY last_seen DESC")
|
|
64
64
|
.all() as Record<string, unknown>[];
|
|
@@ -0,0 +1,105 @@
|
|
|
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
|
+
|
|
23
|
+
import type { SessionBeforeCompactEvent } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
25
|
+
import { compactSession } from "../src/engine.js";
|
|
26
|
+
import { toEngineMessages } from "../src/adapt.js";
|
|
27
|
+
import { estimateBlockTokens, estimateSessionTokens } from "../src/tokens.js";
|
|
28
|
+
import type { MegaRuntime } from "./mega-runtime.js";
|
|
29
|
+
import type { MegaConfig } from "./mega-config.js";
|
|
30
|
+
import { recallRaptorRootSummary } from "../src/dedup/raptor/index.js";
|
|
31
|
+
|
|
32
|
+
export interface NativeCompactionResult {
|
|
33
|
+
/** Our trimmed summary + the pi entry to keep from (durable trim). */
|
|
34
|
+
compaction: {
|
|
35
|
+
summary: string;
|
|
36
|
+
firstKeptEntryId: string;
|
|
37
|
+
tokensBefore: number;
|
|
38
|
+
estimatedTokensAfter: number;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build our durable compaction result from pi's pre-computed preparation.
|
|
44
|
+
*
|
|
45
|
+
* Returns undefined when there is nothing to summarize (pi will then run its
|
|
46
|
+
* own native compaction, or skip). Never throws for "empty" — best-effort.
|
|
47
|
+
*/
|
|
48
|
+
export function driveNativeCompaction(
|
|
49
|
+
event: SessionBeforeCompactEvent,
|
|
50
|
+
runtime: MegaRuntime,
|
|
51
|
+
config: MegaConfig,
|
|
52
|
+
): NativeCompactionResult | undefined {
|
|
53
|
+
const prep = event.preparation;
|
|
54
|
+
if (!prep) return undefined;
|
|
55
|
+
|
|
56
|
+
const sid = runtime.rt.sessionId;
|
|
57
|
+
const messagesToSummarize: AgentMessage[] = prep.messagesToSummarize ?? [];
|
|
58
|
+
if (messagesToSummarize.length === 0) return undefined;
|
|
59
|
+
|
|
60
|
+
const engineView = toEngineMessages(messagesToSummarize);
|
|
61
|
+
// We don't drop anything here — pi keeps from prep.firstKeptEntryId. We only
|
|
62
|
+
// summarize the region pi is about to discard.
|
|
63
|
+
const keepFrom = engineView.length;
|
|
64
|
+
|
|
65
|
+
const result = compactSession(
|
|
66
|
+
{
|
|
67
|
+
sessionId: sid,
|
|
68
|
+
messages: engineView,
|
|
69
|
+
keepFrom,
|
|
70
|
+
timestamp: Date.now(),
|
|
71
|
+
useExtractiveSummary: true,
|
|
72
|
+
},
|
|
73
|
+
runtime.store,
|
|
74
|
+
);
|
|
75
|
+
if (result.skipped) return undefined;
|
|
76
|
+
|
|
77
|
+
// Prefer the RAPTOR root summary when the tree is built + enabled (Fix D):
|
|
78
|
+
// it is a session-level compressed summary, broader than one slice's. Fall
|
|
79
|
+
// back to the extractive topicSummary of this slice.
|
|
80
|
+
let summary = result.summary;
|
|
81
|
+
if (config.raptorEnabled) {
|
|
82
|
+
const root = recallRaptorRootSummary(sid, runtime.currentStateDir);
|
|
83
|
+
if (root) summary = root;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const tokensBefore = prep.tokensBefore ?? estimateSessionTokens(engineView);
|
|
87
|
+
const summaryTokens = estimateBlockTokens(summary);
|
|
88
|
+
// pi keeps the tail from firstKeptEntryId; our summary replaces the discarded
|
|
89
|
+
// region. Honest saved = discarded-region tokens − our summary tokens.
|
|
90
|
+
const savedTokens = Math.max(0, tokensBefore - summaryTokens);
|
|
91
|
+
|
|
92
|
+
runtime.rt.lastCompactedFrom = keepFrom;
|
|
93
|
+
runtime.rt.lastCompactedTokens = tokensBefore;
|
|
94
|
+
runtime.rt.tokensSaved += savedTokens;
|
|
95
|
+
runtime.rt.persistedThisSession = true;
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
compaction: {
|
|
99
|
+
summary,
|
|
100
|
+
firstKeptEntryId: prep.firstKeptEntryId,
|
|
101
|
+
tokensBefore,
|
|
102
|
+
estimatedTokensAfter: summaryTokens,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -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
|
}
|
|
@@ -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.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
|
+
}
|