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.
@@ -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
- // ── Existing server? ──────────────────────────────────────────────────────
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
- return Promise.resolve({ port: info.port, url: `http://localhost:${info.port}` });
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
- // stale file, overwrite
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 { /* non-fatal */ }
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
+ }