pi-mega-compact 0.4.27 → 0.5.0
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/README.md +47 -2
- package/dist/extensions/dashboard-server.js +58 -2
- package/dist/extensions/dashboard-server.test.js +95 -3
- package/dist/extensions/mega-commands.js +25 -9
- package/dist/extensions/mega-compact.test.js +161 -29
- package/dist/extensions/mega-config.js +5 -0
- package/dist/extensions/mega-conflict-cmds.js +79 -0
- package/dist/extensions/mega-dashboard-cmds.js +6 -4
- package/dist/extensions/mega-events.js +145 -20
- package/dist/extensions/mega-pipeline.js +179 -1
- package/dist/extensions/mega-runtime.js +14 -0
- package/dist/extensions/mega-trim.js +48 -0
- package/dist/extensions/mega-trim.test.js +58 -0
- package/dist/src/config/dedup.js +1 -0
- package/dist/src/driftDetection.js +103 -0
- package/dist/src/driftDetection.test.js +87 -0
- package/dist/src/memory.js +147 -0
- package/dist/src/memory.test.js +41 -0
- package/dist/src/memoryConsolidate.test.js +38 -0
- package/dist/src/memoryOps.js +58 -0
- package/dist/src/memoryOps.test.js +41 -0
- package/dist/src/memoryRecall.js +60 -0
- package/dist/src/memoryRecall.test.js +92 -0
- package/dist/src/recall.js +70 -1
- package/dist/src/recall.test.js +69 -1
- package/dist/src/store/sqlite.js +127 -11
- package/dist/src/vectorStore.js +6 -1
- package/extensions/dashboard-server.test.ts +115 -3
- package/extensions/dashboard-server.ts +63 -2
- package/extensions/mega-commands.ts +24 -9
- package/extensions/mega-compact.test.ts +162 -29
- package/extensions/mega-config.ts +22 -0
- package/extensions/mega-conflict-cmds.ts +81 -0
- package/extensions/mega-dashboard-cmds.ts +6 -4
- package/extensions/mega-events.ts +139 -20
- package/extensions/mega-pipeline.ts +179 -1
- package/extensions/mega-runtime.ts +15 -0
- package/extensions/mega-trim.test.ts +64 -0
- package/extensions/mega-trim.ts +75 -0
- package/extensions/openclaw-mega-compact.ts +24 -9
- package/package.json +2 -2
- package/src/config/dedup.ts +2 -0
- package/src/driftDetection.test.ts +100 -0
- package/src/driftDetection.ts +136 -0
- package/src/memory.test.ts +46 -0
- package/src/memory.ts +164 -0
- package/src/memoryConsolidate.test.ts +47 -0
- package/src/memoryOps.test.ts +53 -0
- package/src/memoryOps.ts +75 -0
- package/src/memoryRecall.test.ts +100 -0
- package/src/memoryRecall.ts +83 -0
- package/src/recall.test.ts +77 -1
- package/src/recall.ts +94 -1
- package/src/store/sqlite.ts +188 -11
- package/src/store.ts +3 -0
- package/src/vectorStore.ts +10 -1
- package/dist/extensions/openclaw-mega-compact.js +0 -291
- package/dist/src/minilm.js +0 -92
- package/dist/src/wordpiece.js +0 -129
|
@@ -8,12 +8,15 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { ExtensionAPI, ExtensionContext, ContextEvent, SessionBeforeCompactEvent } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
11
12
|
import { normalizeSessionId } from "../src/store.js";
|
|
12
13
|
import { autoCompactCheck } from "../src/compact.js";
|
|
13
14
|
import { estimateSessionTokens } from "../src/tokens.js";
|
|
14
15
|
import { MegaRuntime, recentUserQuery, WIDGET_KEY } from "./mega-runtime.js";
|
|
15
|
-
import { runCompact, doRecall } from "./mega-pipeline.js";
|
|
16
|
+
import { runCompact, doRecall, doRecallAsync, piCompactWouldNoop } from "./mega-pipeline.js";
|
|
17
|
+
import { recallMemoriesAndInline } from "../src/recall.js";
|
|
16
18
|
import { driveNativeCompaction } from "./mega-compact-driver.js";
|
|
19
|
+
import { computeLiveTrimCut, liveTrimSummaryMessage } from "./mega-trim.js";
|
|
17
20
|
import { pressureFromPct, type MegaConfig } from "./mega-config.js";
|
|
18
21
|
|
|
19
22
|
/** Register all pi lifecycle event handlers. */
|
|
@@ -29,6 +32,8 @@ export function registerEventHandlers(pi: ExtensionAPI, runtime: MegaRuntime, co
|
|
|
29
32
|
runtime.resetRuntime(ctx.sessionManager.getSessionId());
|
|
30
33
|
runtime.captureModel(ctx); // best-effort: ctx.model may be set by session start
|
|
31
34
|
runtime.setStatus(ctx, config.auto ? "mega-compact: ready" : "mega-compact: manual only");
|
|
35
|
+
// S21: clear any stale memory block from a prior session.
|
|
36
|
+
runtime.pendingMemoryRecallBlock = undefined;
|
|
32
37
|
// Auto-inline on resume/fork/continue: stage the most relevant checkpoints
|
|
33
38
|
// so the next before_agent_start prepends them to the system prompt.
|
|
34
39
|
// Triggered whenever this session already has persisted checkpoints AND a
|
|
@@ -40,13 +45,27 @@ export function registerEventHandlers(pi: ExtensionAPI, runtime: MegaRuntime, co
|
|
|
40
45
|
const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
|
|
41
46
|
const query = recentUserQuery(ctx);
|
|
42
47
|
if (query && runtime.store.stats(sid).checkpointCount > 0) {
|
|
43
|
-
|
|
48
|
+
// S17: use the async variant on resume so cross-repo HNSW recall can
|
|
49
|
+
// augment when this repo's store is thin. session_start is an async-safe
|
|
50
|
+
// point (unlike the mid-turn context handler, which stays sync).
|
|
51
|
+
const r = await doRecallAsync(runtime, config, ctx, query, "resume", { crossRepo: config.crossRepoEnabled });
|
|
44
52
|
if (!r.empty) {
|
|
45
53
|
runtime.pendingRecallBlock = r.block;
|
|
46
|
-
|
|
47
|
-
runtime.
|
|
54
|
+
const crossLabel = r.toInject.some((h) => h.repoId) ? " (cross-repo)" : "";
|
|
55
|
+
runtime.setStatus(ctx, `mega-compact: recalled ${r.toInject.length} chkpt${crossLabel}`);
|
|
56
|
+
runtime.logger.info("auto-inline", { reason: event.reason, query, injected: r.toInject.map((h) => h.checkpoint.checkpointId), crossRepo: r.toInject.some((h) => h.repoId) });
|
|
48
57
|
}
|
|
49
58
|
}
|
|
59
|
+
// S21: parallel memory recall. Same async context so we can await without
|
|
60
|
+
// breaking the handler contract. Best-effort — never throws.
|
|
61
|
+
try {
|
|
62
|
+
const mr = await recallMemoriesAndInline({
|
|
63
|
+
query, stateDir: runtime.getStateDir(), limit: 5,
|
|
64
|
+
});
|
|
65
|
+
if (!mr.empty) runtime.pendingMemoryRecallBlock = mr.block;
|
|
66
|
+
} catch (err) {
|
|
67
|
+
runtime.logger.warn("memory-recall skipped", { err: String(err) });
|
|
68
|
+
}
|
|
50
69
|
}
|
|
51
70
|
runtime.dashboard.event("session_start", { reason: event.reason, sessionId: runtime.rt.sessionId });
|
|
52
71
|
runtime.snapshot(ctx);
|
|
@@ -65,6 +84,13 @@ export function registerEventHandlers(pi: ExtensionAPI, runtime: MegaRuntime, co
|
|
|
65
84
|
runtime.pendingRecallBlock = r.block;
|
|
66
85
|
runtime.logger.info("auto-inline", { reason: "session_tree", query, injected: r.toInject.map((h) => h.checkpoint.checkpointId) });
|
|
67
86
|
}
|
|
87
|
+
// S21: parallel memory recall. Trigram embedder is sub-ms; await is fine.
|
|
88
|
+
try {
|
|
89
|
+
const mr = await recallMemoriesAndInline({ query, stateDir: runtime.getStateDir(), limit: 5 });
|
|
90
|
+
if (!mr.empty) runtime.pendingMemoryRecallBlock = mr.block;
|
|
91
|
+
} catch (err) {
|
|
92
|
+
runtime.logger.warn("memory-recall skipped", { err: String(err) });
|
|
93
|
+
}
|
|
68
94
|
}
|
|
69
95
|
}
|
|
70
96
|
runtime.dashboard.event("session_tree", { sessionId: runtime.rt.sessionId });
|
|
@@ -74,10 +100,13 @@ export function registerEventHandlers(pi: ExtensionAPI, runtime: MegaRuntime, co
|
|
|
74
100
|
// ---- Auto-inline injection point: prepend staged recall to systemPrompt ----
|
|
75
101
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
76
102
|
runtime.captureModel(ctx); // most reliable point ctx.model is populated
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
103
|
+
const cpBlock = runtime.pendingRecallBlock;
|
|
104
|
+
const memBlock = runtime.pendingMemoryRecallBlock;
|
|
105
|
+
if (!cpBlock && !memBlock) return;
|
|
106
|
+
runtime.pendingRecallBlock = undefined;
|
|
107
|
+
runtime.pendingMemoryRecallBlock = undefined;
|
|
108
|
+
const composed = [cpBlock, memBlock].filter(Boolean).join("\n\n");
|
|
109
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${composed}` };
|
|
81
110
|
});
|
|
82
111
|
|
|
83
112
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
@@ -105,6 +134,23 @@ export function registerEventHandlers(pi: ExtensionAPI, runtime: MegaRuntime, co
|
|
|
105
134
|
} else {
|
|
106
135
|
runtime.setStatus(ctx, config.auto ? "mega-compact: ready" : "mega-compact: manual only");
|
|
107
136
|
}
|
|
137
|
+
// S16 continuation fallback: if the turn settled idle right after a live-trim
|
|
138
|
+
// compaction AND there is queued work AND we haven't nudged recently, nudge
|
|
139
|
+
// once so the agent continues (the live trim should make this rare). Guarded
|
|
140
|
+
// to never busy-loop: one nudge per 30s, only when truly idle + queued.
|
|
141
|
+
if (config.auto && runtime.activeAgents === 0) {
|
|
142
|
+
try {
|
|
143
|
+
const idle = ctx.isIdle?.() ?? true;
|
|
144
|
+
const queued = ctx.hasPendingMessages?.() ?? false;
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
if (idle && queued && now >= runtime.resumeNudgeUntil) {
|
|
147
|
+
runtime.resumeNudgeUntil = now + 30_000;
|
|
148
|
+
pi.sendUserMessage("[mega-compact] continue from the compacted context above.");
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
/* non-fatal: a failed nudge never blocks */
|
|
152
|
+
}
|
|
153
|
+
}
|
|
108
154
|
runtime.snapshot(ctx);
|
|
109
155
|
});
|
|
110
156
|
|
|
@@ -117,17 +163,44 @@ export function registerEventHandlers(pi: ExtensionAPI, runtime: MegaRuntime, co
|
|
|
117
163
|
pi.on("turn_end", async (event, ctx) => {
|
|
118
164
|
runtime.dashboard.event("turn_end", { turnIndex: event.turnIndex });
|
|
119
165
|
runtime.snapshot(ctx);
|
|
166
|
+
|
|
167
|
+
// S20: auto-review the conversation every N turns and persist durable
|
|
168
|
+
// memories. Best-effort + non-fatal: a review failure must never break the
|
|
169
|
+
// agent loop. Debounced by memoryReviewInterval turns.
|
|
170
|
+
if (config.memoryAutoReview && runtime.currentTurn > 0 && runtime.currentTurn % config.memoryReviewInterval === 0) {
|
|
171
|
+
try {
|
|
172
|
+
const { reviewConversation } = await import("../src/memory.js");
|
|
173
|
+
const { applyMemoryOps } = await import("../src/memoryOps.js");
|
|
174
|
+
const entries = ctx.sessionManager.getEntries();
|
|
175
|
+
const view = runtime.engineView(entries.flatMap((e: any) => (e.message ? [e.message] : [])));
|
|
176
|
+
const ops = reviewConversation(view, []);
|
|
177
|
+
if (ops.length) {
|
|
178
|
+
await applyMemoryOps(ops, runtime.currentStateDir);
|
|
179
|
+
// S21.2: a memory op landed in this turn window. The pipeline reads
|
|
180
|
+
// this counter after a successful compaction and fires
|
|
181
|
+
// `consolidateMemories` only when it's > 0.
|
|
182
|
+
runtime.memoriesTouchedThisCompaction += ops.length;
|
|
183
|
+
}
|
|
184
|
+
} catch {
|
|
185
|
+
/* non-fatal — auto-review must not break the turn loop */
|
|
186
|
+
}
|
|
187
|
+
}
|
|
120
188
|
});
|
|
121
189
|
|
|
122
|
-
// ---- Auto-trigger:
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
// We
|
|
130
|
-
//
|
|
190
|
+
// ---- Auto-trigger: live trim (compact and continue) + native durable ----
|
|
191
|
+
// S16 redesign: we NO LONGER call ctx.compact() from the auto-trigger by
|
|
192
|
+
// default. That mapped to pi's MANUAL compaction path, which abort()s the
|
|
193
|
+
// in-flight turn (agent-session.js:1345) and stops the agent. Instead:
|
|
194
|
+
// - LIVE: return { messages: trimmedView } from the context event. This
|
|
195
|
+
// feeds pi's transformContext (sdk.js:226 → agent-loop.js:180) so the
|
|
196
|
+
// model sees a compacted window EVERY LLM call, with no abort. The turn
|
|
197
|
+
// continues. We persist our recall checkpoint (the durable value) first.
|
|
198
|
+
// - DURABLE: pi's NATIVE auto-compaction fires at agent-end
|
|
199
|
+
// (agent-session.js:1565), continues (return hasQueuedMessages()), and
|
|
200
|
+
// emits session_before_compact — where OUR driveNativeCompaction supplies
|
|
201
|
+
// the summary and pi truncates the transcript on disk. No ctx.compact().
|
|
202
|
+
// Legacy: MEGACOMPACT_LEGACY_DURABLE_TRIM=true restores the v0.4.28 ctx.compact
|
|
203
|
+
// path (kept one release as rollback).
|
|
131
204
|
pi.on("context", async (event: ContextEvent, ctx: ExtensionContext) => {
|
|
132
205
|
if (!config.auto) return;
|
|
133
206
|
const usage = ctx.getContextUsage();
|
|
@@ -162,9 +235,55 @@ export function registerEventHandlers(pi: ExtensionAPI, runtime: MegaRuntime, co
|
|
|
162
235
|
const ran = runCompact(pi, runtime, config, ctx, messages, { compressionPressure: pressure });
|
|
163
236
|
if (ran.skipped) return;
|
|
164
237
|
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
|
|
238
|
+
// LEGACY path (rollback): v0.4.28 ctx.compact() + the no-op gate. The
|
|
239
|
+
// manual compact path aborts the in-flight turn — only used behind the flag.
|
|
240
|
+
// Read live from env (in addition to the load-time config) so the flag can be
|
|
241
|
+
// toggled per-test without reloading the module; config.legacyDurableTrim is
|
|
242
|
+
// the cached default. (Mirrors how piCompactWouldNoop re-reads its floor.)
|
|
243
|
+
const legacy = config.legacyDurableTrim || process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM === "true" || process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM === "1";
|
|
244
|
+
if (legacy) {
|
|
245
|
+
if (piCompactWouldNoop(ctx)) return;
|
|
246
|
+
ctx.compact({ customInstructions: undefined });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// S16 LIVE trim: collapse the compacted region to a summary + recent anchor.
|
|
251
|
+
// Non-destructive: pi keeps the real transcript; only this LLM call sees the
|
|
252
|
+
// trimmed window. We compute the cut on the engine view (pure, tested) then
|
|
253
|
+
// slice the ORIGINAL pi AgentMessage[] from that index (lossless alignment,
|
|
254
|
+
// mirroring dropCompactedRange) and prepend a user-role summary message.
|
|
255
|
+
// A build failure or unsafe cut returns nothing (no trim this call — the
|
|
256
|
+
// next context event retries). The anchor floor is read live from env (the
|
|
257
|
+
// config value is the cached default) so it can be tuned per-test / per-run
|
|
258
|
+
// without reloading the module.
|
|
259
|
+
try {
|
|
260
|
+
const anchorEnv = process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES;
|
|
261
|
+
const anchorUserMessages = (anchorEnv != null && anchorEnv !== "" && Number.isFinite(Number(anchorEnv)))
|
|
262
|
+
? Number(anchorEnv)
|
|
263
|
+
: config.anchorUserMessages;
|
|
264
|
+
const cut = computeLiveTrimCut(view, {
|
|
265
|
+
compactedFrom: ran.result.compactedFrom,
|
|
266
|
+
summary: ran.result.summary,
|
|
267
|
+
anchorUserMessages,
|
|
268
|
+
});
|
|
269
|
+
if (cut === null) return; // unsafe / below anchor floor — no trim this call
|
|
270
|
+
const summaryMsg = liveTrimSummaryMessage({
|
|
271
|
+
compactedFrom: ran.result.compactedFrom,
|
|
272
|
+
summary: ran.result.summary,
|
|
273
|
+
anchorUserMessages: config.anchorUserMessages,
|
|
274
|
+
});
|
|
275
|
+
// Synthesize a user-role AgentMessage carrying the compacted summary.
|
|
276
|
+
const summaryAgentMsg = {
|
|
277
|
+
role: "user" as const,
|
|
278
|
+
content: summaryMsg.text,
|
|
279
|
+
timestamp: Date.now(),
|
|
280
|
+
} as unknown as AgentMessage;
|
|
281
|
+
const recent = messages.slice(cut); // guardrails-allow PREVENT-PI-002: `cut` is the pre-sanitized `compactedFrom` produced by src/boundary.ts computeDropRange, so the preserved run begins on a toolPair-safe index.
|
|
282
|
+
runtime.snapshot(ctx);
|
|
283
|
+
return { messages: [summaryAgentMsg, ...recent] };
|
|
284
|
+
} catch {
|
|
285
|
+
return; // non-fatal: no trim this call; the next context event retries
|
|
286
|
+
}
|
|
168
287
|
});
|
|
169
288
|
|
|
170
289
|
// ---- Supply a DURABLE trim to pi's native compaction (Fix B) ----------
|
|
@@ -12,9 +12,11 @@ import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
|
|
|
12
12
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
13
13
|
import { compactSession } from "../src/engine.js";
|
|
14
14
|
import type { EngineMessage } from "../src/types.js";
|
|
15
|
-
import { recallAndInline } from "../src/recall.js";
|
|
15
|
+
import { recallAndInline, recallAndInlineAsync, formatRecallBlock, type RecallInjectResult } from "../src/recall.js";
|
|
16
16
|
import { normalizeSessionId } from "../src/store.js";
|
|
17
|
+
import { estimateBlockTokens } from "../src/tokens.js";
|
|
17
18
|
import { touchSession, logDaily } from "../src/store/sqlite.js";
|
|
19
|
+
import { consolidateMemories } from "../src/memory.js";
|
|
18
20
|
import {
|
|
19
21
|
MegaRuntime,
|
|
20
22
|
C,
|
|
@@ -76,6 +78,10 @@ function doCompact(
|
|
|
76
78
|
runtime: MegaRuntime,
|
|
77
79
|
): RunCompactResult {
|
|
78
80
|
runtime.pulsing = true; // animate the status line while the (sync) pipeline runs
|
|
81
|
+
// S21.2: reset the per-compaction memory-op counter so the post-compact
|
|
82
|
+
// consolidate pass only fires when memory rows actually changed during the
|
|
83
|
+
// compaction window (turn_end → auto-review may have written some).
|
|
84
|
+
runtime.memoriesTouchedThisCompaction = 0;
|
|
79
85
|
const result = compactSession(
|
|
80
86
|
{
|
|
81
87
|
sessionId: sid,
|
|
@@ -147,6 +153,28 @@ function doCompact(
|
|
|
147
153
|
/* non-fatal: stats bookkeeping only */
|
|
148
154
|
}
|
|
149
155
|
|
|
156
|
+
// S21.2: best-effort consolidation of near-duplicate memories for this repo.
|
|
157
|
+
// Runs after the per-repo stats touch so `consolidateMemories` can use the
|
|
158
|
+
// same stateDir. Non-fatal — a failed consolidate never blocks a compaction.
|
|
159
|
+
// Only runs when new memory ops landed in this pass (otherwise the prior
|
|
160
|
+
// compaction's consolidate already had its shot — re-running would just
|
|
161
|
+
// touch every row again with no merges).
|
|
162
|
+
if (!result.deduped && runtime.memoriesTouchedThisCompaction > 0) {
|
|
163
|
+
try {
|
|
164
|
+
const root = resolveRepoRoot(ctx.cwd);
|
|
165
|
+
void consolidateMemories(runtime.currentStateDir, root).then(
|
|
166
|
+
(n) => {
|
|
167
|
+
if (n > 0) runtime.pushTicker(`${C.green}∫${C.reset} consolidated ${n} memory dup${n === 1 ? "" : "s"}`);
|
|
168
|
+
},
|
|
169
|
+
() => {
|
|
170
|
+
/* swallow: consolidate failures must never surface to the user */
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
} catch {
|
|
174
|
+
/* non-fatal */
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
150
178
|
// Sentinel marker: a non-LLM bookkeeping entry so subsequent triggers can
|
|
151
179
|
// skip re-vectorizing an already-compacted region (zero token cost).
|
|
152
180
|
pi.appendEntry(MARKER_TYPE, {
|
|
@@ -234,6 +262,90 @@ function doCompact(
|
|
|
234
262
|
return { skipped: false, result, keepFrom, saved };
|
|
235
263
|
}
|
|
236
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Predict whether pi's `ctx.compact()` would throw a no-op error — "Already
|
|
267
|
+
* compacted" or "Nothing to compact (session too small)" — so the auto-trigger
|
|
268
|
+
* can SKIP the call instead of surfacing a hard, user-facing error.
|
|
269
|
+
*
|
|
270
|
+
* Why we can't intercept or suppress it: pi's public `compact()` computes
|
|
271
|
+
* `prepareCompaction()` and throws *before* it emits `session_before_compact`,
|
|
272
|
+
* so our handler there never runs on the no-op path. And `ctx.compact()`'s
|
|
273
|
+
* `onError` callback fires only AFTER pi has already emitted a `compaction_end`
|
|
274
|
+
* event carrying the error message (which the interactive UI renders) — so
|
|
275
|
+
* `onError` cannot mute it either. The only robust fix is to not call
|
|
276
|
+
* `ctx.compact()` when pi would no-op. (pi's own `_runAutoCompaction` path is
|
|
277
|
+
* silent on this same condition; the public path we're forced through is the
|
|
278
|
+
* one that throws.)
|
|
279
|
+
*
|
|
280
|
+
* Skipping is correct, not a compromise: by the time this runs, `runCompact()`
|
|
281
|
+
* has already persisted the recall checkpoint (Path A). The durable on-disk
|
|
282
|
+
* trim is only useful when pi can actually summarize a region; a transcript
|
|
283
|
+
* under pi's `keepRecentTokens` budget is small enough that reloading it on
|
|
284
|
+
* resume isn't a token-growth problem, so the durable trim is unnecessary
|
|
285
|
+
* there anyway.
|
|
286
|
+
*
|
|
287
|
+
* Mirrors pi's `prepareCompaction()` return-undefined conditions (compaction.js):
|
|
288
|
+
* (1) last entry is a compaction → "Already compacted"
|
|
289
|
+
* (2) <2 cut-point messages since the last compaction → nothing to summarize
|
|
290
|
+
* (a cut point = any non-toolResult message — user/assistant/bash/custom/
|
|
291
|
+
* branchSummary/compactionSummary — matching pi's isCutPointMessage)
|
|
292
|
+
* (3) transcript tokens since the last compaction < keepRecentTokens → pi
|
|
293
|
+
* keeps everything → nothing to summarize
|
|
294
|
+
* `keepRecentTokens` isn't readable from the extension API, so (3) uses the pi
|
|
295
|
+
* default (20000) as a conservative floor; raise it via
|
|
296
|
+
* `MEGACOMPACT_DURABLE_TRIM_FLOOR` if you raise pi's `compact.keepRecentTokens`.
|
|
297
|
+
*
|
|
298
|
+
* Best-effort: on any read error returns true (skip) — skipping a durable trim
|
|
299
|
+
* is always safe; calling `ctx.compact()` on a no-op throws to the user.
|
|
300
|
+
*/
|
|
301
|
+
export function piCompactWouldNoop(ctx: ExtensionContext): boolean {
|
|
302
|
+
try {
|
|
303
|
+
const branch = ctx.sessionManager.getBranch();
|
|
304
|
+
if (branch.length === 0) return true;
|
|
305
|
+
// (1) already compacted — pi throws "Already compacted"
|
|
306
|
+
if (branch[branch.length - 1].type === "compaction") return true;
|
|
307
|
+
// boundaryStart = index just after the most recent compaction entry (or 0)
|
|
308
|
+
let boundaryStart = 0;
|
|
309
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
310
|
+
if (branch[i].type === "compaction") { boundaryStart = i + 1; break; }
|
|
311
|
+
}
|
|
312
|
+
let cutPoints = 0;
|
|
313
|
+
let tokens = 0;
|
|
314
|
+
for (let i = boundaryStart; i < branch.length; i++) {
|
|
315
|
+
const e = branch[i];
|
|
316
|
+
if (e.type === "compaction") continue;
|
|
317
|
+
let isCut = false;
|
|
318
|
+
for (const m of sessionEntryToContextMessages(e)) {
|
|
319
|
+
// pi's isCutPointMessage: every role except toolResult
|
|
320
|
+
if ((m as { role?: string }).role !== "toolResult") isCut = true;
|
|
321
|
+
const c = (m as { content?: unknown }).content;
|
|
322
|
+
const text =
|
|
323
|
+
typeof c === "string" ? c
|
|
324
|
+
: Array.isArray(c)
|
|
325
|
+
? (c as { text?: string }[]).map((b) => b?.text ?? "").join(" ")
|
|
326
|
+
: "";
|
|
327
|
+
if (text) tokens += estimateBlockTokens(text);
|
|
328
|
+
}
|
|
329
|
+
if (isCut) cutPoints++;
|
|
330
|
+
}
|
|
331
|
+
// (2) need >=2 cut points so the kept cut isn't the first message
|
|
332
|
+
if (cutPoints < 2) return true;
|
|
333
|
+
// (3) transcript under pi's keepRecentTokens budget → pi keeps everything
|
|
334
|
+
if (tokens < durableTrimFloorTokens()) return true;
|
|
335
|
+
return false;
|
|
336
|
+
} catch {
|
|
337
|
+
return true; // safe: skip the durable trim rather than risk a user-facing throw
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** pi's default keepRecentTokens (compaction settings). Override with
|
|
342
|
+
* MEGACOMPACT_DURABLE_TRIM_FLOOR if you raise pi's compact.keepRecentTokens. */
|
|
343
|
+
function durableTrimFloorTokens(): number {
|
|
344
|
+
const raw = process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
|
|
345
|
+
if (raw !== undefined && Number.isFinite(Number(raw))) return Number(raw);
|
|
346
|
+
return 20_000;
|
|
347
|
+
}
|
|
348
|
+
|
|
237
349
|
/**
|
|
238
350
|
* Unified recall (Layer 5). The ONE path that injects. Returns the recall
|
|
239
351
|
* result; callers decide whether to stage it for before_agent_start (resume)
|
|
@@ -278,6 +390,72 @@ export function doRecall(
|
|
|
278
390
|
return result;
|
|
279
391
|
}
|
|
280
392
|
|
|
393
|
+
/**
|
|
394
|
+
* S17: async recall with optional cross-repo augmentation. Used on resume
|
|
395
|
+
* (session_start) and /mega-recall --cross-repo — NEVER from the mid-turn
|
|
396
|
+
* context handler (that stays sync). Runs the sync same-repo scan first; if it
|
|
397
|
+
* returns < config.autoInlineK hits AND crossRepo is enabled, awaits the PGlite
|
|
398
|
+
* HNSW cross-repo path and merges (source-labeled, deduped by checkpointId). The
|
|
399
|
+
* recallMaxTokens cap + windowDedupe apply to the merged set so cross-repo can
|
|
400
|
+
* never net-inflate the window. Cross-repo uses a stricter cosine floor
|
|
401
|
+
* (config.crossRepoCosine) than same-repo. Non-fatal: any async failure returns
|
|
402
|
+
* the same-repo result unchanged.
|
|
403
|
+
*/
|
|
404
|
+
export async function doRecallAsync(
|
|
405
|
+
runtime: MegaRuntime,
|
|
406
|
+
config: MegaConfig,
|
|
407
|
+
ctx: ExtensionContext,
|
|
408
|
+
query: string,
|
|
409
|
+
source: "resume" | "command",
|
|
410
|
+
opts: { crossRepo?: boolean } = {},
|
|
411
|
+
): Promise<RecallInjectResult> {
|
|
412
|
+
runtime.bindRepo(ctx.cwd);
|
|
413
|
+
const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
|
|
414
|
+
const liveWindow = config.windowDedupe ? extractLiveWindow(ctx) : undefined;
|
|
415
|
+
// Sync same-repo first (fast, never blocks).
|
|
416
|
+
const sameRepo = recallAndInline(
|
|
417
|
+
{
|
|
418
|
+
sessionId: sid, query, limit: config.autoInlineK, source, skipInjected: true,
|
|
419
|
+
recallMaxTokens: config.recallMaxTokens, windowDedupe: config.windowDedupe,
|
|
420
|
+
liveWindow, dedupSim: config.dedupSim,
|
|
421
|
+
},
|
|
422
|
+
runtime.store,
|
|
423
|
+
);
|
|
424
|
+
if (!config.crossRepoEnabled || !opts.crossRepo) return sameRepo;
|
|
425
|
+
if (sameRepo.toInject.length >= config.autoInlineK) return sameRepo; // same-repo satisfied
|
|
426
|
+
// Augment: cross-repo HNSW (async) with the stricter floor. Non-fatal.
|
|
427
|
+
try {
|
|
428
|
+
const x = await recallAndInlineAsync(
|
|
429
|
+
{
|
|
430
|
+
sessionId: sid, query, limit: config.autoInlineK, source, skipInjected: true,
|
|
431
|
+
recallMaxTokens: config.recallMaxTokens, windowDedupe: config.windowDedupe,
|
|
432
|
+
liveWindow, dedupSim: config.crossRepoCosine, crossRepo: true,
|
|
433
|
+
globalIndexDir: process.env.MEGACOMPACT_INDEX_DIR,
|
|
434
|
+
},
|
|
435
|
+
runtime.store,
|
|
436
|
+
);
|
|
437
|
+
runtime.dashboard.event("recall-crossrepo", {
|
|
438
|
+
source, query: query.slice(0, 120), injected: x.toInject.length,
|
|
439
|
+
sourceRepos: x.toInject.map((h) => h.repoId).filter(Boolean),
|
|
440
|
+
});
|
|
441
|
+
// Merge, dedup by checkpointId, respect the same token cap by reformatting.
|
|
442
|
+
const seen = new Set(sameRepo.toInject.map((h) => h.checkpoint.checkpointId));
|
|
443
|
+
const merged = [...sameRepo.toInject];
|
|
444
|
+
for (const h of x.toInject) {
|
|
445
|
+
if (!seen.has(h.checkpoint.checkpointId)) { merged.push(h); seen.add(h.checkpoint.checkpointId); }
|
|
446
|
+
}
|
|
447
|
+
const block = merged.length ? formatRecallBlock(merged) : "";
|
|
448
|
+
return {
|
|
449
|
+
toInject: merged,
|
|
450
|
+
report: merged.map((h) => ` • ${h.checkpoint.checkpointId}${h.repoId ? ` (from ${h.repoId.split("/").filter(Boolean).pop()})` : ""}`),
|
|
451
|
+
block,
|
|
452
|
+
empty: merged.length === 0,
|
|
453
|
+
};
|
|
454
|
+
} catch {
|
|
455
|
+
return sameRepo; // cross-repo failure → same-repo only (non-fatal)
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
281
459
|
/**
|
|
282
460
|
* Extract the live-window message texts from the session manager (Fix C),
|
|
283
461
|
* for inline-dedupe of recalled checkpoints. Best-effort: returns [] on any
|
|
@@ -79,12 +79,17 @@ export class MegaRuntime {
|
|
|
79
79
|
tokensSaved: 0,
|
|
80
80
|
};
|
|
81
81
|
debounceUntil = 0;
|
|
82
|
+
// S16: debounce for the agent_end resume nudge (avoid busy-loops).
|
|
83
|
+
resumeNudgeUntil = 0;
|
|
82
84
|
// Agent tracking for real-time widget updates
|
|
83
85
|
activeAgents = 0;
|
|
84
86
|
currentTurn = 0;
|
|
85
87
|
// Recall block produced by auto-inline (resume/branch) that the next
|
|
86
88
|
// before_agent_start should prepend to the system prompt. Unset after use.
|
|
87
89
|
pendingRecallBlock: string | undefined;
|
|
90
|
+
// S21: memory recall block, parallel to pendingRecallBlock. Same one-shot
|
|
91
|
+
// semantics; composed with the checkpoint block in before_agent_start.
|
|
92
|
+
pendingMemoryRecallBlock: string | undefined;
|
|
88
93
|
statusKey: string | undefined; // current status text for dashboard
|
|
89
94
|
// Active model/provider (for real cost estimation). Captured from ctx.model
|
|
90
95
|
// on model_select + session_start; persisted to SQL so cost + the dashboard
|
|
@@ -103,6 +108,11 @@ export class MegaRuntime {
|
|
|
103
108
|
readonly TICKER_MAX = 5;
|
|
104
109
|
// Pulsing status: set true while a compaction is in flight, cleared on result.
|
|
105
110
|
pulsing = false;
|
|
111
|
+
// S21.2: set by `applyMemoryOps` when a memory add/replace/remove lands in
|
|
112
|
+
// the current compaction. The pipeline reads this after a successful compact
|
|
113
|
+
// to decide whether to fire `consolidateMemories` (skip the work entirely
|
|
114
|
+
// when no memory rows changed).
|
|
115
|
+
memoriesTouchedThisCompaction = 0;
|
|
106
116
|
// Rolling "saved" goal for the progress bar — grows as we save more, so the
|
|
107
117
|
// bar always has a meaningful denominator (never sits at 100% forever).
|
|
108
118
|
savedGoal = 50_000;
|
|
@@ -364,6 +374,11 @@ export class MegaRuntime {
|
|
|
364
374
|
} catch { /* non-fatal: cost estimation degrades to model-in-memory only */ }
|
|
365
375
|
}
|
|
366
376
|
|
|
377
|
+
/** S21: state dir of the currently bound repo (where memories live). */
|
|
378
|
+
getStateDir(): string {
|
|
379
|
+
return this.currentStateDir;
|
|
380
|
+
}
|
|
381
|
+
|
|
367
382
|
/** Build the sync onTier callback that paints the live per-tier trace. */
|
|
368
383
|
makeTierCallback(ctx: ExtensionContext): (ev: { tier: "L0" | "L1" | "L2" | "new"; status: "scanning" | "deduped" | "passed" | "stored"; detail?: string }) => void {
|
|
369
384
|
const order: Array<"L0" | "L1" | "L2" | "new"> = ["L0", "L1", "L2", "new"];
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mega-trim.test.ts — tests for the live compaction view builder (S16).
|
|
3
|
+
*/
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { buildLiveTrimmedView } from "./mega-trim.js";
|
|
7
|
+
import type { EngineMessage } from "../src/types.js";
|
|
8
|
+
|
|
9
|
+
function m(role: EngineMessage["role"], text: string, extra: Partial<EngineMessage> = {}): EngineMessage {
|
|
10
|
+
return { role, text, toolName: undefined, input: undefined, output: undefined, ...extra };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test("buildLiveTrimmedView: prepends a compacted summary and keeps the recent anchor", () => {
|
|
14
|
+
const view: EngineMessage[] = [
|
|
15
|
+
m("user", "old request one"), m("assistant", "old answer one"),
|
|
16
|
+
m("user", "old request two"), m("assistant", "old answer two"),
|
|
17
|
+
m("user", "recent keep me"), m("assistant", "recent keep me too"),
|
|
18
|
+
];
|
|
19
|
+
// Compacted region = first 4; recent anchor = last 2.
|
|
20
|
+
const result = buildLiveTrimmedView(view, {
|
|
21
|
+
compactedFrom: 4, // index where the compacted region ends
|
|
22
|
+
summary: "<summary>earlier work on old requests</summary>",
|
|
23
|
+
anchorUserMessages: 1,
|
|
24
|
+
});
|
|
25
|
+
// First element is the injected compacted summary as a user-role message.
|
|
26
|
+
assert.equal(result[0].role, "user");
|
|
27
|
+
assert.ok(String(result[0].text).includes("earlier work on old requests"));
|
|
28
|
+
// Recent anchor preserved in order, no older messages leak through.
|
|
29
|
+
assert.equal(result.length, 1 + 2, "summary + 2 recent");
|
|
30
|
+
assert.ok(result.slice(1).some((x) => String(x.text).includes("recent keep me")));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("buildLiveTrimmedView: empty summary returns the original view unchanged", () => {
|
|
34
|
+
const view = [m("user", "x"), m("assistant", "y")];
|
|
35
|
+
const result = buildLiveTrimmedView(view, { compactedFrom: 0, summary: "", anchorUserMessages: 1 });
|
|
36
|
+
assert.deepEqual(result, view);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("buildLiveTrimmedView: never splits a toolCall/toolResult pair (PREVENT-PI-002)", () => {
|
|
40
|
+
const view: EngineMessage[] = [
|
|
41
|
+
m("user", "q"), m("assistant", "calls tool", { toolName: "read" }), m("tool", "result"),
|
|
42
|
+
m("user", "keep"), m("assistant", "ok"),
|
|
43
|
+
];
|
|
44
|
+
// cut=3 would start the preserved run on the orphaned tool result at index 2 —
|
|
45
|
+
// the builder must snap back so the toolCall/toolResult pair is not split.
|
|
46
|
+
const result = buildLiveTrimmedView(view, { compactedFrom: 3, summary: "<summary>s</summary>", anchorUserMessages: 1 });
|
|
47
|
+
// The tool result must never appear preserved WITHOUT its preceding toolCall.
|
|
48
|
+
const preserved = result.slice(1);
|
|
49
|
+
const hasToolResult = preserved.some((x) => x.role === "tool");
|
|
50
|
+
const hasToolCall = preserved.some((x) => x.role === "assistant" && x.toolName);
|
|
51
|
+
// Either the tool pair is kept together, or the tool result is dropped into
|
|
52
|
+
// the compacted region — it is never left orphaned.
|
|
53
|
+
assert.ok(!(hasToolResult && !hasToolCall), "no orphaned tool result in the preserved run");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("buildLiveTrimmedView: honors the anchor floor (PREVENT-PI-001)", () => {
|
|
57
|
+
// cut would leave zero user messages in the anchor — must skip the trim.
|
|
58
|
+
const view: EngineMessage[] = [
|
|
59
|
+
m("user", "old q"), m("assistant", "old a"),
|
|
60
|
+
m("assistant", "only assistant kept"),
|
|
61
|
+
];
|
|
62
|
+
const result = buildLiveTrimmedView(view, { compactedFrom: 2, summary: "<summary>s</summary>", anchorUserMessages: 1 });
|
|
63
|
+
assert.deepEqual(result, view, "below anchor floor → no trim this call");
|
|
64
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mega-trim.ts — the LIVE compaction view builder (S16).
|
|
3
|
+
*
|
|
4
|
+
* Produces the message list returned from the `context` event so the model sees
|
|
5
|
+
* a compacted window every LLM call WITHOUT aborting the turn (ctx.compact()
|
|
6
|
+
* would abort; the context-event return feeds pi's transformContext per call).
|
|
7
|
+
*
|
|
8
|
+
* Shape: [compactSummaryMessage, ...recentAnchor]. The compacted region
|
|
9
|
+
* [0, compactedFrom) is collapsed to a single user-role summary; the recent
|
|
10
|
+
* anchor [compactedFrom, end) is kept verbatim. Honors PREVENT-PI-002 (never
|
|
11
|
+
* splits a toolCall/toolResult pair) by snapping compactedFrom back to a
|
|
12
|
+
* boundary-safe index, and PREVENT-PI-001 (anchor floor) via the anchor knob.
|
|
13
|
+
*
|
|
14
|
+
* Pure + pi-agnostic: takes EngineMessage[], returns EngineMessage[]. No pi
|
|
15
|
+
* imports. Non-destructive: the caller still owns the real messages.
|
|
16
|
+
*/
|
|
17
|
+
import type { EngineMessage } from "../src/types.js";
|
|
18
|
+
import { isBoundarySafe } from "../src/boundary.js";
|
|
19
|
+
import { formatCompactSummary } from "../src/compact.js";
|
|
20
|
+
|
|
21
|
+
export interface BuildLiveTrimViewOpts {
|
|
22
|
+
/** Index where the compacted region ends (the recent anchor starts here). */
|
|
23
|
+
compactedFrom: number;
|
|
24
|
+
/** The compacted-region summary text (already generated by runCompact). */
|
|
25
|
+
summary: string;
|
|
26
|
+
/** Min recent user messages to keep as the anchor (PREVENT-PI-001). */
|
|
27
|
+
anchorUserMessages: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Compute the safe cut index for the live trim. Snaps `compactedFrom` back to a
|
|
32
|
+
* boundary-safe index (PREVENT-PI-002: never start the preserved run on an
|
|
33
|
+
* orphaned tool result), and enforces the anchor floor (PREVENT-PI-001: keep at
|
|
34
|
+
* least `anchorUserMessages` user-role messages). Returns `null` when no trim is
|
|
35
|
+
* safe this call (empty summary, unsafe boundary, or below the anchor floor) so
|
|
36
|
+
* the caller keeps the original view and retries on the next context event.
|
|
37
|
+
*
|
|
38
|
+
* Exposed separately from `buildLiveTrimmedView` so the context handler can map
|
|
39
|
+
* the cut back onto the original pi `AgentMessage[]` (lossless index alignment,
|
|
40
|
+
* mirroring `dropCompactedRange` in src/adapt.ts).
|
|
41
|
+
*/
|
|
42
|
+
export function computeLiveTrimCut(view: EngineMessage[], opts: BuildLiveTrimViewOpts): number | null {
|
|
43
|
+
if (!opts.summary || !opts.summary.trim()) return null;
|
|
44
|
+
let cut = opts.compactedFrom;
|
|
45
|
+
while (cut > 0 && !isBoundarySafe(view, cut)) cut--;
|
|
46
|
+
if (cut <= 0) return null; // nothing safe to cut — keep everything this call
|
|
47
|
+
const recent = view.slice(cut);
|
|
48
|
+
const userCount = recent.filter((m) => m.role === "user").length;
|
|
49
|
+
if (userCount < opts.anchorUserMessages) return null;
|
|
50
|
+
return cut;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The formatted compacted-region summary as a user-role engine message. */
|
|
54
|
+
export function liveTrimSummaryMessage(opts: BuildLiveTrimViewOpts): EngineMessage {
|
|
55
|
+
return {
|
|
56
|
+
role: "user",
|
|
57
|
+
text: formatCompactSummary(opts.summary),
|
|
58
|
+
toolName: undefined,
|
|
59
|
+
input: undefined,
|
|
60
|
+
output: undefined,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Build the live trimmed view. Returns the original view if summary is empty
|
|
65
|
+
* or the boundary is unsafe (no trim this call — try next). Pure + tested. */
|
|
66
|
+
export function buildLiveTrimmedView(
|
|
67
|
+
view: EngineMessage[],
|
|
68
|
+
opts: BuildLiveTrimViewOpts,
|
|
69
|
+
): EngineMessage[] {
|
|
70
|
+
const cut = computeLiveTrimCut(view, opts);
|
|
71
|
+
if (cut === null) return view;
|
|
72
|
+
const recent = view.slice(cut);
|
|
73
|
+
const summaryMsg = liveTrimSummaryMessage(opts);
|
|
74
|
+
return [summaryMsg, ...recent];
|
|
75
|
+
}
|