pi-mega-compact 0.4.23 → 0.4.25
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 +57 -6
- package/dist/extensions/dashboard-server.test.js +77 -0
- package/dist/extensions/mega-dashboard-cmds.js +32 -2
- package/dist/extensions/openclaw-mega-compact.js +291 -0
- package/dist/src/minilm.js +92 -0
- package/dist/src/recall.js +55 -0
- package/dist/src/store/sqlite.js +8 -0
- package/dist/src/store/vectorIndex.js +210 -0
- package/dist/src/store/vectorIndex.test.js +99 -0
- package/dist/src/vectorStore.js +66 -1
- package/dist/src/wordpiece.js +129 -0
- package/extensions/dashboard-server.test.ts +77 -0
- package/extensions/dashboard-server.ts +53 -7
- package/extensions/mega-dashboard-cmds.ts +23 -2
- package/package.json +3 -1
- package/src/recall.ts +63 -0
- package/src/store/sqlite.ts +13 -0
- package/src/store/vectorIndex.test.ts +116 -0
- package/src/store/vectorIndex.ts +243 -0
- package/src/vectorStore.ts +77 -0
|
@@ -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
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
|
|
@@ -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
|
+
});
|
|
@@ -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
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* openclaw-mega-compact — OpenClaw plugin adapter for the pi-mega-compact engine.
|
|
3
|
+
*
|
|
4
|
+
* Wires the pi-agnostic Trident engine (src/) into OpenClaw's plugin lifecycle:
|
|
5
|
+
* - Registers a CompactionProvider that replaces the built-in summarizeInStages.
|
|
6
|
+
* - Exposes `mega_status` and `mega_recall` tools for on-demand inspection.
|
|
7
|
+
* - Hooks into `before_compaction` / `after_compaction` for diagnostics.
|
|
8
|
+
*
|
|
9
|
+
* Design constraints:
|
|
10
|
+
* - NO imports from `@earendil-works/pi-coding-agent` or pi-agent-core.
|
|
11
|
+
* - The engine core (src/) is pi-agnostic; this file is the sole OpenClaw boundary.
|
|
12
|
+
* - No network at runtime — everything is local (stores + extractive summarizer).
|
|
13
|
+
*/
|
|
14
|
+
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
15
|
+
import { compactSession, setDefaultStore, } from "../src/engine.js";
|
|
16
|
+
import { recallAndInline } from "../src/recall.js";
|
|
17
|
+
import { VectorStore } from "../src/vectorStore.js";
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Constants
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
const PLUGIN_ID = "mega-compact";
|
|
22
|
+
const PLUGIN_LABEL = "Mega Compact (Trident)";
|
|
23
|
+
/** Default state directory for vector store persistence. */
|
|
24
|
+
const STATE_DIR = process.env.MEGA_COMPACT_STATE_DIR ?? undefined;
|
|
25
|
+
/** Minimum messages before we bother compacting. */
|
|
26
|
+
const MIN_MESSAGES_FOR_COMPACT = 6;
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Message conversion — OpenClaw unknown[] → EngineMessage[]
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
/**
|
|
31
|
+
* Best-effort conversion from OpenClaw's opaque message array to our
|
|
32
|
+
* EngineMessage shape. OpenClaw messages are typed as `unknown[]` so we
|
|
33
|
+
* handle whatever shape comes through gracefully.
|
|
34
|
+
*/
|
|
35
|
+
function toEngineMessages(messages) {
|
|
36
|
+
return messages.map((msg) => {
|
|
37
|
+
if (!msg || typeof msg !== "object") {
|
|
38
|
+
// Primitive fallback — treat as custom text.
|
|
39
|
+
return {
|
|
40
|
+
role: "custom",
|
|
41
|
+
text: String(msg ?? ""),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const m = msg;
|
|
45
|
+
const role = typeof m.role === "string" ? m.role : "custom";
|
|
46
|
+
// Normalize role to one of our four engine roles.
|
|
47
|
+
let engineRole;
|
|
48
|
+
switch (role) {
|
|
49
|
+
case "user":
|
|
50
|
+
engineRole = "user";
|
|
51
|
+
break;
|
|
52
|
+
case "assistant":
|
|
53
|
+
engineRole = "assistant";
|
|
54
|
+
break;
|
|
55
|
+
case "tool":
|
|
56
|
+
case "function":
|
|
57
|
+
engineRole = "tool";
|
|
58
|
+
break;
|
|
59
|
+
default:
|
|
60
|
+
engineRole = "custom";
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
// Extract text content from common message shapes.
|
|
64
|
+
const text = typeof m.content === "string"
|
|
65
|
+
? m.content
|
|
66
|
+
: typeof m.text === "string"
|
|
67
|
+
? m.text
|
|
68
|
+
: Array.isArray(m.content)
|
|
69
|
+
? m.content
|
|
70
|
+
.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
71
|
+
.map((part) => part.text)
|
|
72
|
+
.join("\n")
|
|
73
|
+
: "";
|
|
74
|
+
// Preserve tool metadata when present.
|
|
75
|
+
const toolName = typeof m.name === "string"
|
|
76
|
+
? m.name
|
|
77
|
+
: typeof m.toolName === "string"
|
|
78
|
+
? m.toolName
|
|
79
|
+
: undefined;
|
|
80
|
+
const input = typeof m.input === "string"
|
|
81
|
+
? m.input
|
|
82
|
+
: typeof m.arguments === "string"
|
|
83
|
+
? m.arguments
|
|
84
|
+
: m.arguments !== undefined
|
|
85
|
+
? JSON.stringify(m.arguments)
|
|
86
|
+
: undefined;
|
|
87
|
+
const output = typeof m.output === "string"
|
|
88
|
+
? m.output
|
|
89
|
+
: engineRole === "tool" && typeof m.content === "string"
|
|
90
|
+
? m.content
|
|
91
|
+
: undefined;
|
|
92
|
+
return { role: engineRole, text, toolName, input, output };
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Compaction provider
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
function createCompactionProvider(store) {
|
|
99
|
+
return {
|
|
100
|
+
id: PLUGIN_ID,
|
|
101
|
+
label: PLUGIN_LABEL,
|
|
102
|
+
async summarize({ messages, signal, compressionRatio, }) {
|
|
103
|
+
// Abort check — bail early if the caller cancelled.
|
|
104
|
+
if (signal?.aborted) {
|
|
105
|
+
throw new DOMException("Aborted", "AbortError");
|
|
106
|
+
}
|
|
107
|
+
const engineMessages = toEngineMessages(messages);
|
|
108
|
+
// Nothing meaningful to compact.
|
|
109
|
+
if (engineMessages.length < MIN_MESSAGES_FOR_COMPACT) {
|
|
110
|
+
return "";
|
|
111
|
+
}
|
|
112
|
+
// Map compression ratio → keepFrom boundary.
|
|
113
|
+
// compressionRatio=0.5 means "compact the oldest 50%".
|
|
114
|
+
// Default to compacting the oldest half if not specified.
|
|
115
|
+
const ratio = compressionRatio ?? 0.5;
|
|
116
|
+
const keepFrom = Math.max(MIN_MESSAGES_FOR_COMPACT, Math.floor(engineMessages.length * (1 - ratio)));
|
|
117
|
+
// Abort check after conversion (conversion is cheap but check anyway).
|
|
118
|
+
if (signal?.aborted) {
|
|
119
|
+
throw new DOMException("Aborted", "AbortError");
|
|
120
|
+
}
|
|
121
|
+
const sessionId = `openclaw-${Date.now()}`;
|
|
122
|
+
const input = {
|
|
123
|
+
sessionId,
|
|
124
|
+
messages: engineMessages,
|
|
125
|
+
keepFrom,
|
|
126
|
+
};
|
|
127
|
+
const result = compactSession(input, store);
|
|
128
|
+
if (result.skipped) {
|
|
129
|
+
return "";
|
|
130
|
+
}
|
|
131
|
+
return result.summary;
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Plugin entry
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
export default definePluginEntry({
|
|
139
|
+
id: PLUGIN_ID,
|
|
140
|
+
name: "Mega Compact",
|
|
141
|
+
description: "Layered, local, vector-backed context compressor (Trident engine) for OpenClaw compaction.",
|
|
142
|
+
register(api) {
|
|
143
|
+
const logger = api.logger;
|
|
144
|
+
// Resolve state directory — prefer plugin config override.
|
|
145
|
+
const pluginCfg = (api.pluginConfig ?? {});
|
|
146
|
+
const stateDir = typeof pluginCfg.stateDir === "string" && pluginCfg.stateDir.length > 0
|
|
147
|
+
? pluginCfg.stateDir
|
|
148
|
+
: STATE_DIR;
|
|
149
|
+
// Initialize vector store.
|
|
150
|
+
let store;
|
|
151
|
+
try {
|
|
152
|
+
store = new VectorStore({ stateDir });
|
|
153
|
+
setDefaultStore(store);
|
|
154
|
+
logger.info?.(`${PLUGIN_ID}: vector store initialized (stateDir=${stateDir ?? "default"})`);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
logger.error?.(`${PLUGIN_ID}: failed to init vector store:`, err);
|
|
158
|
+
return; // Hard bail — no point registering if store is broken.
|
|
159
|
+
}
|
|
160
|
+
// -----------------------------------------------------------------------
|
|
161
|
+
// Register compaction provider
|
|
162
|
+
// -----------------------------------------------------------------------
|
|
163
|
+
const provider = createCompactionProvider(store);
|
|
164
|
+
api.registerCompactionProvider(provider);
|
|
165
|
+
logger.info?.(`${PLUGIN_ID}: registered compaction provider "${provider.id}"`);
|
|
166
|
+
// -----------------------------------------------------------------------
|
|
167
|
+
// Hooks — before / after compaction diagnostics
|
|
168
|
+
// -----------------------------------------------------------------------
|
|
169
|
+
api.registerHook({
|
|
170
|
+
event: "before_compaction",
|
|
171
|
+
handler: async (ctx) => {
|
|
172
|
+
const msgCount = Array.isArray(ctx?.messages) ? ctx.messages.length : 0;
|
|
173
|
+
logger.info?.(`${PLUGIN_ID}: before_compaction — ${msgCount} messages in scope`);
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
api.registerHook({
|
|
177
|
+
event: "after_compaction",
|
|
178
|
+
handler: async (ctx) => {
|
|
179
|
+
const summaryLen = typeof ctx?.summary === "string" ? ctx.summary.length : 0;
|
|
180
|
+
logger.info?.(`${PLUGIN_ID}: after_compaction — summary ${summaryLen} chars`);
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
// -----------------------------------------------------------------------
|
|
184
|
+
// Tool: mega_status
|
|
185
|
+
// -----------------------------------------------------------------------
|
|
186
|
+
api.registerTool({
|
|
187
|
+
name: "mega_status",
|
|
188
|
+
description: "Show the current status of the mega-compact engine: vector store stats, checkpoint count, and recent compaction activity.",
|
|
189
|
+
parameters: {
|
|
190
|
+
type: "object",
|
|
191
|
+
properties: {
|
|
192
|
+
sessionId: {
|
|
193
|
+
type: "string",
|
|
194
|
+
description: "Optional session ID to scope stats to.",
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
additionalProperties: false,
|
|
198
|
+
},
|
|
199
|
+
handler: async (args) => {
|
|
200
|
+
const sessionId = args?.sessionId ?? "global";
|
|
201
|
+
try {
|
|
202
|
+
const stats = store.stats(sessionId);
|
|
203
|
+
const parts = [
|
|
204
|
+
`**Mega Compact Status**`,
|
|
205
|
+
`Session: ${sessionId}`,
|
|
206
|
+
`Checkpoints: ${stats.checkpointCount}`,
|
|
207
|
+
`Total tokens saved: ${stats.totalTokenEstimate}`,
|
|
208
|
+
`Last checkpoint: ${stats.lastCheckpointId ?? "—"}`,
|
|
209
|
+
`Injected count: ${stats.injectedCount}`,
|
|
210
|
+
`Dedup hit rate: ${(stats.dedupHitRate * 100).toFixed(0)}%`,
|
|
211
|
+
];
|
|
212
|
+
if (stats.lastSummary) {
|
|
213
|
+
parts.push(`\nLast summary (truncated):\n ${stats.lastSummary.slice(0, 120).replace(/\n/g, " ")}…`);
|
|
214
|
+
}
|
|
215
|
+
return { content: [{ type: "text", text: parts.join("\n") }] };
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
return {
|
|
219
|
+
content: [{ type: "text", text: `Error reading mega-compact status: ${err}` }],
|
|
220
|
+
isError: true,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
// -----------------------------------------------------------------------
|
|
226
|
+
// Tool: mega_recall
|
|
227
|
+
// -----------------------------------------------------------------------
|
|
228
|
+
api.registerTool({
|
|
229
|
+
name: "mega_recall",
|
|
230
|
+
description: "Recall and inline relevant context from the mega-compact vector store for the current session.",
|
|
231
|
+
parameters: {
|
|
232
|
+
type: "object",
|
|
233
|
+
properties: {
|
|
234
|
+
sessionId: {
|
|
235
|
+
type: "string",
|
|
236
|
+
description: "Session ID to recall context for.",
|
|
237
|
+
},
|
|
238
|
+
query: {
|
|
239
|
+
type: "string",
|
|
240
|
+
description: "Natural language query for relevant context.",
|
|
241
|
+
},
|
|
242
|
+
limit: {
|
|
243
|
+
type: "number",
|
|
244
|
+
description: "Max checkpoints to recall (default 3).",
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
required: ["sessionId", "query"],
|
|
248
|
+
additionalProperties: false,
|
|
249
|
+
},
|
|
250
|
+
handler: async (args) => {
|
|
251
|
+
const { sessionId, query, limit } = args;
|
|
252
|
+
if (!sessionId || !query) {
|
|
253
|
+
return {
|
|
254
|
+
content: [{ type: "text", text: "Both `sessionId` and `query` are required." }],
|
|
255
|
+
isError: true,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
const result = recallAndInline({ sessionId, query, limit: limit ?? 3, source: "command", skipInjected: false }, store);
|
|
260
|
+
if (result.toInject.length === 0) {
|
|
261
|
+
return {
|
|
262
|
+
content: [{ type: "text", text: "No relevant context found in the mega-compact store." }],
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
const parts = [
|
|
266
|
+
`**Recalled ${result.toInject.length} checkpoint(s):**`,
|
|
267
|
+
...result.report,
|
|
268
|
+
"",
|
|
269
|
+
"---",
|
|
270
|
+
result.block,
|
|
271
|
+
];
|
|
272
|
+
return { content: [{ type: "text", text: parts.join("\n") }] };
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
return {
|
|
276
|
+
content: [{ type: "text", text: `Error during mega-recall: ${err}` }],
|
|
277
|
+
isError: true,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
// -----------------------------------------------------------------------
|
|
283
|
+
// Cleanup on shutdown
|
|
284
|
+
// -----------------------------------------------------------------------
|
|
285
|
+
api.on("shutdown", () => {
|
|
286
|
+
logger.info?.(`${PLUGIN_ID}: shutting down — clearing default store`);
|
|
287
|
+
setDefaultStore(undefined);
|
|
288
|
+
});
|
|
289
|
+
logger.info?.(`${PLUGIN_ID}: plugin registered (tools: mega_status, mega_recall)`);
|
|
290
|
+
},
|
|
291
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* minilm.ts — local MiniLM (all-MiniLM-L6-v2) sentence embedder (Sprint 12).
|
|
3
|
+
*
|
|
4
|
+
* Implements the `Embedder` interface so it drops into the existing VectorStore
|
|
5
|
+
* dedup cascade and search with no call-site changes. Inference is 100% local:
|
|
6
|
+
* the ONNX model + WordPiece vocab are on-disk artifacts fetched once by
|
|
7
|
+
* scripts/setup-minilm.mjs. There is NO network call at runtime (PREVENT-PI-004).
|
|
8
|
+
*
|
|
9
|
+
* Inputs (dynamic): input_ids, attention_mask, token_type_ids (int64).
|
|
10
|
+
* Output: last_hidden_state (batch, seq, 384). We mean-pool over non-padded
|
|
11
|
+
* tokens (attention_mask == 1) and L2-normalize → 384-dim unit vector.
|
|
12
|
+
*
|
|
13
|
+
* The ONNX session + tokenizer are loaded LAZILY on first embed() so the default
|
|
14
|
+
* TrigramEmbedder path (and its zero native-init cost) is untouched unless
|
|
15
|
+
* MEGACOMPACT_EMBEDDER=minilm is selected.
|
|
16
|
+
*/
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { existsSync } from "node:fs";
|
|
20
|
+
import { l2Normalize, awaitSync } from "./embedder.js";
|
|
21
|
+
import { WordPieceTokenizer } from "./wordpiece.js";
|
|
22
|
+
export const MINILM_DIM = 384;
|
|
23
|
+
export const MINILM_MAX_LEN = 256;
|
|
24
|
+
/** Resolve the model directory: MEGACOMPACT_MINILM_DIR > ./models/minilm > ~/.pi … */
|
|
25
|
+
function resolveModelDir() {
|
|
26
|
+
if (process.env.MEGACOMPACT_MINILM_DIR)
|
|
27
|
+
return process.env.MEGACOMPACT_MINILM_DIR;
|
|
28
|
+
// Repo-local vendored path (gitignored).
|
|
29
|
+
const local = join(process.cwd(), "models", "minilm");
|
|
30
|
+
if (existsSync(local))
|
|
31
|
+
return local;
|
|
32
|
+
return join(homedir(), ".pi", "agent", "extensions", "mega-compact", "models", "minilm");
|
|
33
|
+
}
|
|
34
|
+
export class MiniLMEmbedder {
|
|
35
|
+
dim = MINILM_DIM;
|
|
36
|
+
session = null;
|
|
37
|
+
tokenizer = null;
|
|
38
|
+
modelDir;
|
|
39
|
+
loadPromise = null;
|
|
40
|
+
constructor(modelDir = resolveModelDir()) {
|
|
41
|
+
this.modelDir = modelDir;
|
|
42
|
+
}
|
|
43
|
+
async ensureLoaded() {
|
|
44
|
+
if (this.session && this.tokenizer)
|
|
45
|
+
return;
|
|
46
|
+
if (this.loadPromise)
|
|
47
|
+
return this.loadPromise;
|
|
48
|
+
this.loadPromise = (async () => {
|
|
49
|
+
const ort = await import("onnxruntime-node");
|
|
50
|
+
const modelPath = join(this.modelDir, "model_quantized.onnx");
|
|
51
|
+
const vocabPath = join(this.modelDir, "vocab.txt");
|
|
52
|
+
if (!existsSync(modelPath) || !existsSync(vocabPath)) {
|
|
53
|
+
throw new Error(`MiniLM artifacts missing in ${this.modelDir}. Run: node scripts/setup-minilm.mjs`);
|
|
54
|
+
}
|
|
55
|
+
// 1 thread is plenty for a single short-region embed and bounds CPU.
|
|
56
|
+
this.session = await ort.InferenceSession.create(modelPath, {
|
|
57
|
+
executionProviders: ["cpu"],
|
|
58
|
+
graphOptimizationLevel: "all",
|
|
59
|
+
});
|
|
60
|
+
this.tokenizer = WordPieceTokenizer.fromVocabFile(vocabPath);
|
|
61
|
+
})();
|
|
62
|
+
return this.loadPromise;
|
|
63
|
+
}
|
|
64
|
+
embed(text) {
|
|
65
|
+
awaitSync(this.ensureLoaded());
|
|
66
|
+
const enc = this.tokenizer.encode(text, MINILM_MAX_LEN);
|
|
67
|
+
const n = enc.inputIds.length;
|
|
68
|
+
const BigInt64 = (arr) => arr.map((x) => BigInt(x));
|
|
69
|
+
const ort = awaitSync(import("onnxruntime-node"));
|
|
70
|
+
const tensors = {
|
|
71
|
+
input_ids: new ort.Tensor("int64", BigInt64(enc.inputIds), [1, n]),
|
|
72
|
+
attention_mask: new ort.Tensor("int64", BigInt64(enc.attentionMask), [1, n]),
|
|
73
|
+
token_type_ids: new ort.Tensor("int64", BigInt64(enc.tokenTypeIds), [1, n]),
|
|
74
|
+
};
|
|
75
|
+
const out = awaitSync(this.session.run(tensors));
|
|
76
|
+
const hidden = out.last_hidden_state.data;
|
|
77
|
+
// hidden shape: [1, n, 384]. Mean-pool over non-padded positions.
|
|
78
|
+
const pooled = new Array(MINILM_DIM).fill(0);
|
|
79
|
+
let count = 0;
|
|
80
|
+
for (let i = 0; i < n; i++) {
|
|
81
|
+
if (enc.attentionMask[i] === 0)
|
|
82
|
+
continue;
|
|
83
|
+
const base = i * MINILM_DIM;
|
|
84
|
+
for (let d = 0; d < MINILM_DIM; d++)
|
|
85
|
+
pooled[d] += hidden[base + d];
|
|
86
|
+
count++;
|
|
87
|
+
}
|
|
88
|
+
if (count === 0)
|
|
89
|
+
return l2Normalize(new Array(MINILM_DIM).fill(0));
|
|
90
|
+
return l2Normalize(pooled.map((x) => x / count));
|
|
91
|
+
}
|
|
92
|
+
}
|