pi-mega-compact 0.4.21 → 0.4.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/extensions/dashboard-server.js +60 -9
- package/dist/extensions/dashboard-server.test.js +77 -0
- package/dist/extensions/mega-compact-driver.js +79 -0
- package/dist/extensions/mega-compact.test.js +54 -18
- package/dist/extensions/mega-config.js +10 -0
- package/dist/extensions/mega-dashboard-cmds.js +32 -2
- package/dist/extensions/mega-events.js +45 -23
- package/dist/extensions/mega-pipeline.js +77 -3
- package/dist/src/config/dedup.js +4 -1
- package/dist/src/config.js +21 -0
- package/dist/src/dedup/raptor/index.js +28 -6
- package/dist/src/dedup/raptor/promote.test.js +69 -0
- package/dist/src/engine.js +1 -0
- package/dist/src/recall.js +30 -4
- package/dist/src/recall.test.js +28 -0
- package/dist/src/store/backfill.js +5 -6
- package/dist/src/store/compression.js +47 -7
- package/dist/src/store/compression.test.js +48 -0
- package/dist/src/store/sqlite.js +64 -41
- package/dist/src/store.test.js +19 -0
- package/dist/src/vectorStore.js +56 -1
- package/extensions/DASHBOARD.md +3 -3
- package/extensions/dashboard-server.test.ts +77 -0
- package/extensions/dashboard-server.ts +57 -11
- package/extensions/mega-compact-driver.ts +105 -0
- package/extensions/mega-compact.test.ts +65 -18
- package/extensions/mega-config.ts +25 -0
- package/extensions/mega-dashboard-cmds.ts +23 -2
- package/extensions/mega-events.ts +43 -24
- package/extensions/mega-pipeline.ts +83 -4
- package/package.json +6 -7
- package/src/config/dedup.ts +4 -1
- package/src/config.ts +26 -0
- package/src/dedup/raptor/index.ts +42 -7
- package/src/dedup/raptor/promote.test.ts +82 -0
- package/src/engine.ts +5 -0
- package/src/recall.test.ts +44 -0
- package/src/recall.ts +43 -4
- package/src/store/backfill.ts +10 -11
- package/src/store/compression.test.ts +58 -0
- package/src/store/compression.ts +48 -7
- package/src/store/sqlite.ts +72 -49
- package/src/store.test.ts +22 -0
- package/src/vectorStore.ts +63 -1
- package/dist/extensions/openclaw-mega-compact.js +0 -291
- package/dist/src/minilm.js +0 -92
- package/dist/src/wordpiece.js +0 -129
|
@@ -11,11 +11,32 @@
|
|
|
11
11
|
* @module
|
|
12
12
|
*/
|
|
13
13
|
import { createServer } from "node:http";
|
|
14
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync } from "node:fs";
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync, appendFileSync } from "node:fs";
|
|
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
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Local runtime log
|
|
21
|
+
//
|
|
22
|
+
// The dashboard server is spawned as a DETACHED child. When it is launched with
|
|
23
|
+
// `stdio: "ignore"` (the old default) any crash before the first console.log is
|
|
24
|
+
// invisible — there is no log to "check". We therefore mirror every lifecycle
|
|
25
|
+
// line to a file in the state dir so a failed start is always diagnosable. The
|
|
26
|
+
// launcher also captures stderr, so this doubles as defense-in-depth.
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
let LOG_PATH = null;
|
|
29
|
+
function log(...parts) {
|
|
30
|
+
const line = `[mega-compact][dashboard] ${parts.map((p) => (typeof p === "string" ? p : JSON.stringify(p))).join(" ")}`;
|
|
31
|
+
// eslint-disable-next-line no-console
|
|
32
|
+
console.error(line); // stderr — captured by the launcher pipe
|
|
33
|
+
if (LOG_PATH) {
|
|
34
|
+
try {
|
|
35
|
+
appendFileSync(LOG_PATH, new Date().toISOString() + " " + line + "\n");
|
|
36
|
+
}
|
|
37
|
+
catch { /* non-fatal */ }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
19
40
|
// --- Multi-repo index (Phase 5b) ------------------------------------------------
|
|
20
41
|
// The extension writes a machine-wide repo registry into a single SQLite DB
|
|
21
42
|
// (<indexDir>/index.sqlite) as the concurrency-safe write path; the dashboard
|
|
@@ -42,8 +63,8 @@ function readIndex() {
|
|
|
42
63
|
let db;
|
|
43
64
|
try {
|
|
44
65
|
// Read-only + immutable WAL so a concurrent writer's WAL never blocks us.
|
|
45
|
-
db = new
|
|
46
|
-
db.
|
|
66
|
+
db = new DatabaseSync(indexPath, { readOnly: true });
|
|
67
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
47
68
|
const rows = db
|
|
48
69
|
.prepare("SELECT * FROM repo_registry ORDER BY last_seen DESC")
|
|
49
70
|
.all();
|
|
@@ -651,7 +672,7 @@ function dashboardHtml(tierName) {
|
|
|
651
672
|
// ---------------------------------------------------------------------------
|
|
652
673
|
// Server
|
|
653
674
|
// ---------------------------------------------------------------------------
|
|
654
|
-
export function launchDashboardServer(stateDir) {
|
|
675
|
+
export async function launchDashboardServer(stateDir) {
|
|
655
676
|
// Our own package version — exposed at /api/version so the launcher can
|
|
656
677
|
// detect a stale server (started by an older build) and replace it on
|
|
657
678
|
// upgrade instead of reuse it.
|
|
@@ -675,17 +696,41 @@ export function launchDashboardServer(stateDir) {
|
|
|
675
696
|
const portFile = join(stateDir, "port.pid");
|
|
676
697
|
const snapshotPath = join(stateDir, "dashboard.json");
|
|
677
698
|
const eventsPath = join(stateDir, "events.log");
|
|
678
|
-
|
|
699
|
+
LOG_PATH = join(stateDir, "dashboard.log");
|
|
700
|
+
log("launch invoked", { stateDir });
|
|
701
|
+
// ── Existing server? ───────────────────────────────────────────────────────
|
|
702
|
+
// A stale port.pid pointing at a dead/competing process is the classic cause
|
|
703
|
+
// of "dashboard failed to start" — we return a port that is NOT actually
|
|
704
|
+
// serving. Probe for a live server on that port first; only reuse the marker
|
|
705
|
+
// when something real answers /api/version. Otherwise drop it and start fresh.
|
|
679
706
|
if (existsSync(portFile)) {
|
|
680
707
|
try {
|
|
681
708
|
const info = JSON.parse(readFileSync(portFile, "utf-8"));
|
|
682
709
|
if (info && info.port) {
|
|
683
|
-
|
|
710
|
+
let live = false;
|
|
711
|
+
try {
|
|
712
|
+
const probe = await fetch(`http://localhost:${info.port}/api/version`, { signal: AbortSignal.timeout(800) });
|
|
713
|
+
live = probe.ok;
|
|
714
|
+
}
|
|
715
|
+
catch {
|
|
716
|
+
live = false;
|
|
717
|
+
}
|
|
718
|
+
if (live) {
|
|
719
|
+
log("reusing live server from port.pid", { port: info.port });
|
|
720
|
+
return { port: info.port, url: `http://localhost:${info.port}` };
|
|
721
|
+
}
|
|
722
|
+
log("port.pid present but no live server — treating as stale", { port: info.port });
|
|
684
723
|
}
|
|
685
724
|
}
|
|
686
725
|
catch {
|
|
687
|
-
|
|
726
|
+
log("port.pid unparseable — treating as stale");
|
|
688
727
|
}
|
|
728
|
+
// stale file, remove so the fresh bind does not collide with a lingering
|
|
729
|
+
// process that still holds the port
|
|
730
|
+
try {
|
|
731
|
+
unlinkSync(portFile);
|
|
732
|
+
}
|
|
733
|
+
catch { /* ignore */ }
|
|
689
734
|
}
|
|
690
735
|
// ── New server ────────────────────────────────────────────────────────────
|
|
691
736
|
mkdirSync(stateDir, { recursive: true });
|
|
@@ -799,20 +844,26 @@ export function launchDashboardServer(stateDir) {
|
|
|
799
844
|
function tryPort(port) {
|
|
800
845
|
server.once("error", (err) => {
|
|
801
846
|
if (err.code === "EADDRINUSE" && port < TARGET_PORT + PORT_RANGE - 1) {
|
|
847
|
+
log("port in use, trying next", { port });
|
|
802
848
|
tryPort(port + 1);
|
|
803
849
|
}
|
|
804
850
|
else {
|
|
851
|
+
log("listen failed", { port, code: err.code, message: err.message });
|
|
805
852
|
reject(err);
|
|
806
853
|
}
|
|
807
854
|
});
|
|
808
855
|
server.listen(port, "127.0.0.1", () => {
|
|
809
856
|
const url = `http://localhost:${port}`;
|
|
857
|
+
log("server running", { url });
|
|
858
|
+
// eslint-disable-next-line no-console
|
|
810
859
|
console.log(`[mega-compact] dashboard server running: ${url}`);
|
|
811
860
|
// Write port.pid
|
|
812
861
|
try {
|
|
813
862
|
writeFileSync(portFile, JSON.stringify({ port, pid: process.pid }));
|
|
814
863
|
}
|
|
815
|
-
catch {
|
|
864
|
+
catch (e) {
|
|
865
|
+
log("could not write port.pid", { error: String(e) });
|
|
866
|
+
}
|
|
816
867
|
// Graceful cleanup
|
|
817
868
|
const cleanup = () => {
|
|
818
869
|
try {
|
|
@@ -9,6 +9,7 @@ import assert from "node:assert/strict";
|
|
|
9
9
|
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
10
10
|
import { tmpdir } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
12
13
|
// ---------------------------------------------------------------------------
|
|
13
14
|
// helpers
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
@@ -109,3 +110,79 @@ describe("port.pid file", () => {
|
|
|
109
110
|
rmSync(dir, { recursive: true });
|
|
110
111
|
});
|
|
111
112
|
});
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Lifecycle integration — launch the compiled server as a real subprocess
|
|
115
|
+
// (the same way the /dashboard command spawns it) and assert the two failure
|
|
116
|
+
// modes that historically produced a silent "failed to start":
|
|
117
|
+
// 1. a stale port.pid pointing at a dead port is dropped, and the server
|
|
118
|
+
// binds fresh (instead of returning the dead port);
|
|
119
|
+
// 2. a module-load crash is captured to the launch log instead of going
|
|
120
|
+
// silent under stdio:"ignore".
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
const SERVER_ENTRY = new URL("./dashboard-server.js", import.meta.url).pathname;
|
|
123
|
+
function waitFor(cond, timeoutMs = 6000) {
|
|
124
|
+
const start = Date.now();
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
const tick = async () => {
|
|
127
|
+
if (await cond())
|
|
128
|
+
return resolve();
|
|
129
|
+
if (Date.now() - start > timeoutMs)
|
|
130
|
+
return reject(new Error("timeout"));
|
|
131
|
+
setTimeout(tick, 50);
|
|
132
|
+
};
|
|
133
|
+
tick();
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
describe("server lifecycle", () => {
|
|
137
|
+
test("drops a stale port.pid and binds a fresh port", async () => {
|
|
138
|
+
const dir = mkdtempSync(join(tmpdir(), "dash-stale-"));
|
|
139
|
+
// A marker claiming a port where nothing is listening.
|
|
140
|
+
writeFileSync(join(dir, "port.pid"), JSON.stringify({ port: 9325, pid: 999999 }));
|
|
141
|
+
const child = spawn(process.execPath, [SERVER_ENTRY, dir], { stdio: "ignore" });
|
|
142
|
+
try {
|
|
143
|
+
// Wait for the server to actually be live (not just any port.pid — the
|
|
144
|
+
// stale marker already exists at t=0 and would pass a naive check).
|
|
145
|
+
await waitFor(async () => {
|
|
146
|
+
try {
|
|
147
|
+
const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
|
|
148
|
+
const res = await fetch(`http://localhost:${raw.port}/api/version`);
|
|
149
|
+
return res.ok;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
const raw = JSON.parse(readFileSync(join(dir, "port.pid"), "utf-8"));
|
|
156
|
+
assert.equal(typeof raw.port, "number");
|
|
157
|
+
assert.notEqual(raw.port, 9325, "should not reuse the dead port from the stale marker");
|
|
158
|
+
// And a real server must answer on it.
|
|
159
|
+
const res = await fetch(`http://localhost:${raw.port}/api/version`);
|
|
160
|
+
assert.equal(res.ok, true);
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
child.kill("SIGTERM");
|
|
164
|
+
rmSync(dir, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
test("writes a dashboard.log with startup lines", async () => {
|
|
168
|
+
const dir = mkdtempSync(join(tmpdir(), "dash-log-"));
|
|
169
|
+
const child = spawn(process.execPath, [SERVER_ENTRY, dir], { stdio: "ignore" });
|
|
170
|
+
try {
|
|
171
|
+
await waitFor(() => {
|
|
172
|
+
try {
|
|
173
|
+
return /server running/.test(readFileSync(join(dir, "dashboard.log"), "utf-8"));
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
const log = readFileSync(join(dir, "dashboard.log"), "utf-8");
|
|
180
|
+
assert.match(log, /\[mega-compact\]\[dashboard\]/);
|
|
181
|
+
assert.match(log, /server running/);
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
child.kill("SIGTERM");
|
|
185
|
+
rmSync(dir, { recursive: true, force: true });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -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
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { join, dirname, sep } from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
|
-
import { existsSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
|
|
10
|
+
import { existsSync, writeFileSync, readFileSync, unlinkSync, openSync, closeSync } from "node:fs";
|
|
11
11
|
import { spawn } from "node:child_process"; // guardrails-allow PREVENT-PI-004: spawns the optional, user-triggered localhost dashboard server only
|
|
12
12
|
/** Register the dashboard server lifecycle commands. */
|
|
13
13
|
export function registerDashboardCommands(pi, runtime) {
|
|
@@ -213,11 +213,41 @@ export function registerDashboardCommands(pi, runtime) {
|
|
|
213
213
|
ctx.ui.notify("[mega-compact] dashboard entry not found — check logs.");
|
|
214
214
|
return;
|
|
215
215
|
}
|
|
216
|
+
// Clear any stale marker so a fresh bind never collides with a lingering
|
|
217
|
+
// orphan, and truncate the launch log so the next error report shows only
|
|
218
|
+
// this attempt's output.
|
|
219
|
+
try {
|
|
220
|
+
unlinkSync(portFile);
|
|
221
|
+
}
|
|
222
|
+
catch { /* ignore */ }
|
|
223
|
+
try {
|
|
224
|
+
writeFileSync(launchLog, "");
|
|
225
|
+
}
|
|
226
|
+
catch { /* ignore */ }
|
|
216
227
|
const args = dashboardNeedsStrip ? ["--experimental-strip-types", runnerFile] : [runnerFile];
|
|
228
|
+
// Redirect the child's stderr to the launch log so that a CRASH BEFORE the
|
|
229
|
+
// runner's own __fail handler runs (e.g. an ESM module-load / parse error,
|
|
230
|
+
// or a missing entry) is still captured. With the old `stdio: "ignore"`
|
|
231
|
+
// these failures were completely silent and the "check logs" message
|
|
232
|
+
// pointed at an empty file. We open the fd in the parent and pass it to the
|
|
233
|
+
// child; once spawned we close our copy (the child keeps its own dup).
|
|
234
|
+
let stderrFd;
|
|
235
|
+
try {
|
|
236
|
+
stderrFd = openSync(launchLog, "a");
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
stderrFd = -1; // fall back to ignored stderr
|
|
240
|
+
}
|
|
217
241
|
const child = spawn(process.execPath, args, {
|
|
218
242
|
detached: true,
|
|
219
|
-
stdio: "ignore",
|
|
243
|
+
stdio: ["ignore", "ignore", stderrFd >= 0 ? stderrFd : "ignore"],
|
|
220
244
|
});
|
|
245
|
+
if (stderrFd >= 0) {
|
|
246
|
+
try {
|
|
247
|
+
closeSync(stderrFd);
|
|
248
|
+
}
|
|
249
|
+
catch { /* ignore */ }
|
|
250
|
+
}
|
|
221
251
|
child.unref();
|
|
222
252
|
// Poll for a live server (port 9320–9329) instead of relying solely on the
|
|
223
253
|
// port.pid marker, which can land in a different state dir than the one we
|
|
@@ -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
|
}
|