pi-mega-compact 0.6.2 → 0.6.3
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 +39 -23
- package/dist/extensions/conflict-scan.test.js +115 -0
- package/dist/src/store/memoryIndex.js +32 -7
- package/dist/src/store/vectorIndex.js +32 -7
- package/package.json +1 -1
- package/src/store/memoryIndex.ts +40 -6
- package/src/store/vectorIndex.ts +40 -6
- package/dist/extensions/openclaw-mega-compact.js +0 -291
- package/dist/src/minilm.js +0 -92
- package/dist/src/wordpiece.js +0 -129
package/README.md
CHANGED
|
@@ -6,28 +6,39 @@ sessions into a **local SQLite store** and offers **deduped inline recall** —
|
|
|
6
6
|
running **locally inside the extension**, with **no remote MCP server** and
|
|
7
7
|
**zero network calls at runtime** (PREVENT-PI-004).
|
|
8
8
|
|
|
9
|
-
> **Current version:** `v0.6.
|
|
9
|
+
> **Current version:** `v0.6.2` — storage backend is **`node:sqlite`**
|
|
10
10
|
> (`DatabaseSync`, a Node ≥22.13 built-in), replacing the old `better-sqlite3`
|
|
11
11
|
> native addon and the per-session gzipped JSON checkpoint files. **Zero native
|
|
12
12
|
> build step, fully local, zero network at runtime.** Legacy
|
|
13
13
|
> `.checkpoints.json.gz` snapshots are retained as disaster-recovery fallbacks
|
|
14
|
-
> and auto-imported on first run.
|
|
15
|
-
>
|
|
14
|
+
> and auto-imported on first run. The S24 line ties auto-compact, the tier
|
|
15
|
+
> label, trim depth, and durable-memory review to one **unified pressure
|
|
16
|
+
> signal**, adds a **cross-repo memory-RAG index**, and relieves context
|
|
17
|
+
> **during team runs** (not just at the end).
|
|
16
18
|
|
|
17
19
|
---
|
|
18
20
|
|
|
19
21
|
## What this is (the 30-second version)
|
|
20
22
|
|
|
21
|
-
pi's context window is finite. When a session gets long
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
23
|
+
pi's context window is finite. When a session gets long — especially a team run
|
|
24
|
+
with sub-agents — pi-mega-compact keeps it going without overflowing:
|
|
25
|
+
|
|
26
|
+
1. **Watches one signal.** A single live `pressure = currentTokens / thresholdTokens`
|
|
27
|
+
drives everything — the tier label, how aggressively the live trim drops
|
|
28
|
+
context, and how often durable memory is reviewed. As context fills, the whole
|
|
29
|
+
system reacts together; as it's relieved, it backs off.
|
|
30
|
+
2. **Compacts in two layers.** On every LLM call it returns a **live, compacted
|
|
31
|
+
view** (the model sees a summary + recent anchor, non-destructively). And it
|
|
32
|
+
persists a durable **checkpoint** — and, at each agent settle during a team
|
|
33
|
+
run, fires pi's **native durable trim** so the on-disk transcript is actually
|
|
34
|
+
truncated (context relieves mid-run, and resume reloads the trimmed transcript
|
|
35
|
+
instead of a 150k window).
|
|
36
|
+
3. **Stores** each checkpoint in a **local vector database** (SQLite) with an
|
|
37
|
+
embedding, so similar regions are found later and **duplicate work is never
|
|
38
|
+
stored twice**.
|
|
39
|
+
4. **Recalls** the right context automatically — same-repo checkpoints on resume,
|
|
40
|
+
plus **cross-repo memory-RAG**: decisions you saved in one repo are inlined as
|
|
41
|
+
context when you start a session in another.
|
|
31
42
|
|
|
32
43
|
Everything lives on **your disk**. No telemetry, no API, no MCP server, no cloud.
|
|
33
44
|
The only optional network surface is a **user-triggered localhost dashboard** you
|
|
@@ -35,11 +46,13 @@ open yourself.
|
|
|
35
46
|
|
|
36
47
|
### Why "mega"?
|
|
37
48
|
|
|
38
|
-
The compaction pipeline is a **Trident** — three deterministic stages
|
|
39
|
-
|
|
40
|
-
is small (a summary + key decisions +
|
|
41
|
-
|
|
42
|
-
tokens.
|
|
49
|
+
The compaction pipeline is a **Trident** — three deterministic stages
|
|
50
|
+
(supersede → collapse → cluster) that run over your conversation before anything
|
|
51
|
+
is persisted. The checkpoint it produces is small (a summary + key decisions +
|
|
52
|
+
next steps + files touched), so the same session that would otherwise overflow
|
|
53
|
+
its window keeps going on a fraction of the tokens. On top of the Trident, a
|
|
54
|
+
single pressure signal orchestrates the live trim, the durable trim, and memory
|
|
55
|
+
review as one coherent system rather than four independent triggers.
|
|
43
56
|
|
|
44
57
|
---
|
|
45
58
|
|
|
@@ -52,9 +65,12 @@ Layer 3 Cluster (vectorize) local vector index → semantic dedup + recall
|
|
|
52
65
|
Layer 2 Collapse (summarize) summarizeMessages() heuristic + agent summary on /mega-compact
|
|
53
66
|
Layer 1 Supersede (prune) drop obsolete file-reads / superseded turns (zero cost)
|
|
54
67
|
─────────────────────────────────────────────────────────────────────────
|
|
55
|
-
Trigger context
|
|
68
|
+
Trigger context → token fast-gate → autoCompactCheck → live trim (per call)
|
|
69
|
+
Durable agent_end (idle + over threshold) → ctx.compact() → session_before_compact
|
|
70
|
+
supplies the summary; pi truncates the transcript (relieves context)
|
|
71
|
+
Live context handler returns { messages:[summary, …recent] } — model sees a
|
|
72
|
+
compacted window every LLM call; the on-disk transcript is untouched
|
|
56
73
|
Marker insert compact-marker; dedupe so repeated triggers cost ~0 tokens
|
|
57
|
-
Cancel session_before_compact → { cancel:true } once persisted (no double-compact)
|
|
58
74
|
```
|
|
59
75
|
|
|
60
76
|
**One store, three ways to read it back — one dedup engine:**
|
|
@@ -168,7 +184,7 @@ building.
|
|
|
168
184
|
pi-mega-compact uses a dual local backend — **zero network, no native build step**:
|
|
169
185
|
|
|
170
186
|
- **`node:sqlite`** (`DatabaseSync`, Node ≥22.13 built-in) — the synchronous source of truth for checkpoints, session state, and the dedup index. No dependency, no install script, survives pi's `install-scripts` block.
|
|
171
|
-
- **PGlite + `@electric-sql/pglite-pgvector`** (WASM Postgres + HNSW `vector_cosine_ops`) — an optional, best-effort async vector index for **cross-repo recall** at `~/.pi/mega-compact-vector`. The sync store stays authoritative; the index degrades to the sync per-session scan on any failure.
|
|
187
|
+
- **PGlite + `@electric-sql/pglite-pgvector`** (WASM Postgres + HNSW `vector_cosine_ops`) — an optional, best-effort async vector index for **cross-repo recall** at `~/.pi/mega-compact-vector`. It holds both checkpoint embeddings and durable-memory embeddings, so decisions saved in one repo are findable from another. The sync store stays authoritative; the index degrades to the sync per-session scan on any failure.
|
|
172
188
|
|
|
173
189
|
Kill-switch: `MEGACOMPACT_PGLITE_DISABLED=1` fully disables the PGlite index (falls back to sync scan). Requires Node ≥22.13 (`engines.node`).
|
|
174
190
|
|
|
@@ -178,7 +194,7 @@ On resume, recall augments from other repos' checkpoints when this repo's store
|
|
|
178
194
|
|
|
179
195
|
### Memory
|
|
180
196
|
|
|
181
|
-
pi-mega-compact auto-reviews the conversation every 10 turns and writes durable `decision`/`fact`/`preference` memories to SQLite (local, hallucination-guarded). Relevant memories are injected as RAG context on recall (capped, deduped). Manual: `/mega-memory save|list|forget
|
|
197
|
+
pi-mega-compact auto-reviews the conversation every 10 turns (the cadence shortens as pressure climbs) and writes durable `decision`/`fact`/`preference` memories to SQLite (local, hallucination-guarded). Relevant memories are injected as RAG context on recall (capped, deduped). **Cross-repo memory-RAG (S24):** every memory write is mirrored into the PGlite/HNSW index, so when same-repo recall is thin the system augments with the nearest memories from *other* repos (stricter `MEGACOMPACT_CROSSREPO_COSINE` floor, deduped against what's already in view). Manual: `/mega-memory save|list|forget` (or `/m`).
|
|
182
198
|
|
|
183
199
|
### Uninstall
|
|
184
200
|
|
|
@@ -352,7 +368,7 @@ The extension entry adapts between the engine and pi's runtime types.
|
|
|
352
368
|
|
|
353
369
|
```bash
|
|
354
370
|
npm run build # tsc
|
|
355
|
-
npm test # build + node --test on dist/**/*.test.js (
|
|
371
|
+
npm test # build + node --test on dist/**/*.test.js (353 tests)
|
|
356
372
|
npm run lint # tsc --noEmit + guardrails-scan
|
|
357
373
|
npm run guardrails # regression_check + guardrails-scan
|
|
358
374
|
```
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* conflict-scan.test.ts — unit tests for the extension-conflict scanner.
|
|
3
|
+
*
|
|
4
|
+
* Fixture trees are written under a temp dir and scanned via
|
|
5
|
+
* MEGACOMPACT_EXT_SCAN_DIR (which makes collectScanRoots() return that
|
|
6
|
+
* single root). This covers the S24 follow-up fix:
|
|
7
|
+
*
|
|
8
|
+
* 1. node_modules-style code extensions (package.json + pi.extensions) are
|
|
9
|
+
* still detected by source-marker grep (regression).
|
|
10
|
+
* 2. USER-LEVEL extensions installed outside npm (e.g. pi-hermes-memory)
|
|
11
|
+
* now get scanned too — previously only `node_modules` was walked, so a
|
|
12
|
+
* data-only memory store (MEMORY.md + sessions.db, no package.json)
|
|
13
|
+
* was never flagged (the 5000-char file-buffer error slipped through).
|
|
14
|
+
* 3. The data-only memory-store signature is detected even with no source.
|
|
15
|
+
* 4. pi-mega-compact (selfName) is always skipped.
|
|
16
|
+
*/
|
|
17
|
+
import { test, after } from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { detectConflicts, collectScanRoots } from "./conflict-scan.js";
|
|
23
|
+
const base = mkdtempSync(join(tmpdir(), "mc-scan-"));
|
|
24
|
+
let n = 0;
|
|
25
|
+
/** Make a fixture root containing one or more fake extensions, return its path. */
|
|
26
|
+
function fixture(build) {
|
|
27
|
+
const root = join(base, `case-${n++}`);
|
|
28
|
+
mkdirSync(root, { recursive: true });
|
|
29
|
+
build(root);
|
|
30
|
+
return root;
|
|
31
|
+
}
|
|
32
|
+
after(() => {
|
|
33
|
+
rmSync(base, { recursive: true, force: true });
|
|
34
|
+
});
|
|
35
|
+
test("scans a user-level, data-only memory store (no package.json)", () => {
|
|
36
|
+
const root = fixture((r) => {
|
|
37
|
+
const ext = join(r, "pi-hermes-memory");
|
|
38
|
+
mkdirSync(ext, { recursive: true });
|
|
39
|
+
// No package.json, no source — just pi's memory-store signature.
|
|
40
|
+
writeFileSync(join(ext, "MEMORY.md"), "# memory\n");
|
|
41
|
+
writeFileSync(join(ext, "sessions.db"), "");
|
|
42
|
+
});
|
|
43
|
+
process.env.MEGACOMPACT_EXT_SCAN_DIR = root;
|
|
44
|
+
try {
|
|
45
|
+
const { conflicts } = detectConflicts();
|
|
46
|
+
assert.ok(conflicts.length >= 1, "expected a memory conflict");
|
|
47
|
+
const hit = conflicts.find((c) => c.kind === "memory");
|
|
48
|
+
assert.ok(hit, "expected a memory-kind conflict");
|
|
49
|
+
assert.equal(hit.severity, "high");
|
|
50
|
+
assert.ok(hit.evidence.includes("MEMORY.md") ||
|
|
51
|
+
hit.evidence.includes("sessions.db"), "evidence should name the on-disk memory signature");
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
delete process.env.MEGACOMPACT_EXT_SCAN_DIR;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
test("still detects a code extension by source marker (regression)", () => {
|
|
58
|
+
const root = fixture((r) => {
|
|
59
|
+
// A code extension is a DIRECT child of the scan root (mirrors the
|
|
60
|
+
// node_modules layout: packages live one level under the root).
|
|
61
|
+
const ext = join(r, "some-memory-ext");
|
|
62
|
+
mkdirSync(ext, { recursive: true });
|
|
63
|
+
writeFileSync(join(ext, "package.json"), JSON.stringify({ name: "some-memory-ext", pi: { extensions: ["x.ts"] } }));
|
|
64
|
+
writeFileSync(join(ext, "index.ts"), "export const MEMORY_TOOL = true;");
|
|
65
|
+
});
|
|
66
|
+
process.env.MEGACOMPACT_EXT_SCAN_DIR = root;
|
|
67
|
+
try {
|
|
68
|
+
const { conflicts } = detectConflicts();
|
|
69
|
+
const hit = conflicts.find((c) => c.package === "some-memory-ext");
|
|
70
|
+
assert.ok(hit, "expected some-memory-ext to be flagged");
|
|
71
|
+
assert.equal(hit.kind, "memory");
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
delete process.env.MEGACOMPACT_EXT_SCAN_DIR;
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
test("skips pi-mega-compact (selfName) and non-extension dirs", () => {
|
|
78
|
+
const root = fixture((r) => {
|
|
79
|
+
// selfName dir with a memory signature — must be ignored.
|
|
80
|
+
const me = join(r, "node_modules", "pi-mega-compact");
|
|
81
|
+
mkdirSync(me, { recursive: true });
|
|
82
|
+
writeFileSync(join(me, "sessions.db"), "");
|
|
83
|
+
// unrelated dir with no pi.extensions and no memory signature.
|
|
84
|
+
mkdirSync(join(r, "node_modules", "totally-fine"), { recursive: true });
|
|
85
|
+
});
|
|
86
|
+
process.env.MEGACOMPACT_EXT_SCAN_DIR = root;
|
|
87
|
+
try {
|
|
88
|
+
const { scanned, conflicts } = detectConflicts();
|
|
89
|
+
assert.equal(conflicts.length, 0, "no conflicts expected");
|
|
90
|
+
assert.ok(!scanned.some((s) => s.includes("pi-mega-compact")), "selfName should not appear in scanned");
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
delete process.env.MEGACOMPACT_EXT_SCAN_DIR;
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
test("collectScanRoots honors MEGACOMPACT_EXT_SCAN_DIR override", () => {
|
|
97
|
+
const root = fixture(() => { });
|
|
98
|
+
process.env.MEGACOMPACT_EXT_SCAN_DIR = root;
|
|
99
|
+
try {
|
|
100
|
+
const roots = collectScanRoots();
|
|
101
|
+
assert.deepEqual(roots, [root], "override replaces the whole root list");
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
delete process.env.MEGACOMPACT_EXT_SCAN_DIR;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
test("collectScanRoots falls back to node_modules + user dir when no override", () => {
|
|
108
|
+
delete process.env.MEGACOMPACT_EXT_SCAN_DIR;
|
|
109
|
+
delete process.env.MEGACOMPACT_EXT_USER_DIR;
|
|
110
|
+
// No override set and this test file lives under extensions/, so node_modules
|
|
111
|
+
// resolution walks up from here; the user dir (~/.pi/agent) may or may
|
|
112
|
+
// not exist in CI. We only assert the call returns a non-throwing array.
|
|
113
|
+
const roots = collectScanRoots();
|
|
114
|
+
assert.ok(Array.isArray(roots), "collectScanRoots must return an array");
|
|
115
|
+
});
|
|
@@ -22,17 +22,15 @@
|
|
|
22
22
|
import { homedir } from "node:os";
|
|
23
23
|
import { join } from "node:path";
|
|
24
24
|
import { mkdirSync, rmSync, existsSync } from "node:fs";
|
|
25
|
-
// PGlite + pgvector are script-free WASM (no native build) → survive pi's
|
|
26
|
-
// install-script block. Imported lazily so a missing/broken package degrades
|
|
27
|
-
// gracefully instead of crashing module load.
|
|
28
|
-
import { PGlite } from "@electric-sql/pglite";
|
|
29
|
-
import { vector } from "@electric-sql/pglite-pgvector";
|
|
30
25
|
/** Vector dimension produced by the default TrigramEmbedder (src/embedder.ts). */
|
|
31
26
|
export const MEMORY_INDEX_DIM = 512;
|
|
32
27
|
let db;
|
|
33
28
|
let initPromise;
|
|
34
29
|
let disabled = false;
|
|
35
30
|
let warned = false;
|
|
31
|
+
/** Lazily-loaded PGlite module + pgvector extension (see loadPgLite). */
|
|
32
|
+
let pgliteMod;
|
|
33
|
+
let pgliteLoadFailed = false;
|
|
36
34
|
function indexDir() {
|
|
37
35
|
const override = process.env.MEGACOMPACT_INDEX_DIR;
|
|
38
36
|
if (override && override.trim() !== "")
|
|
@@ -77,17 +75,44 @@ export function initMemoryIndex() {
|
|
|
77
75
|
initPromise = openPgLite(/* retryOnCorrupt */ true);
|
|
78
76
|
return initPromise;
|
|
79
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Lazily load the PGlite module + pgvector extension via dynamic import. Caches
|
|
80
|
+
* success and permanent failure. Returns undefined (once, then forever) when the
|
|
81
|
+
* package is missing/broken so callers fall back to the same-repo scan. Never throws.
|
|
82
|
+
*/
|
|
83
|
+
async function loadPgLite() {
|
|
84
|
+
if (pgliteMod)
|
|
85
|
+
return pgliteMod;
|
|
86
|
+
if (pgliteLoadFailed)
|
|
87
|
+
return undefined;
|
|
88
|
+
try {
|
|
89
|
+
const [pglitePkg, pgvectorPkg] = await Promise.all([
|
|
90
|
+
import("@electric-sql/pglite"),
|
|
91
|
+
import("@electric-sql/pglite-pgvector"),
|
|
92
|
+
]);
|
|
93
|
+
pgliteMod = { PGlite: pglitePkg.PGlite, vector: pgvectorPkg.vector };
|
|
94
|
+
return pgliteMod;
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
pgliteLoadFailed = true;
|
|
98
|
+
logWarn(`package unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
80
102
|
/**
|
|
81
103
|
* Open + schema-init PGlite. When `retryOnCorrupt` is true, a WASM-level abort
|
|
82
104
|
* (typically from a corrupted/torn data dir) triggers a delete + one retry.
|
|
83
105
|
*/
|
|
84
106
|
async function openPgLite(retryOnCorrupt) {
|
|
85
107
|
try {
|
|
108
|
+
const mod = await loadPgLite();
|
|
109
|
+
if (!mod)
|
|
110
|
+
return undefined;
|
|
86
111
|
const dir = indexDir();
|
|
87
112
|
mkdirSync(dir, { recursive: true });
|
|
88
|
-
const pg = await new PGlite({
|
|
113
|
+
const pg = await new mod.PGlite({
|
|
89
114
|
dataDir: dir,
|
|
90
|
-
extensions: { vector },
|
|
115
|
+
extensions: { vector: mod.vector },
|
|
91
116
|
});
|
|
92
117
|
await pg.exec("CREATE EXTENSION IF NOT EXISTS vector;");
|
|
93
118
|
await pg.exec(`
|
|
@@ -18,17 +18,15 @@
|
|
|
18
18
|
import { homedir } from "node:os";
|
|
19
19
|
import { join } from "node:path";
|
|
20
20
|
import { mkdirSync, rmSync, existsSync } from "node:fs";
|
|
21
|
-
// PGlite + pgvector are script-free WASM (no native build) → survive pi's
|
|
22
|
-
// install-script block. Imported lazily so a missing/broken package degrades
|
|
23
|
-
// gracefully instead of crashing module load.
|
|
24
|
-
import { PGlite } from "@electric-sql/pglite";
|
|
25
|
-
import { vector } from "@electric-sql/pglite-pgvector";
|
|
26
21
|
/** Vector dimension produced by the default TrigramEmbedder (src/embedder.ts). */
|
|
27
22
|
export const EMBEDDING_DIM = 512;
|
|
28
23
|
let db;
|
|
29
24
|
let initPromise;
|
|
30
25
|
let disabled = false;
|
|
31
26
|
let warned = false;
|
|
27
|
+
/** Lazily-loaded PGlite module + pgvector extension (see loadPgLite). */
|
|
28
|
+
let pgliteMod;
|
|
29
|
+
let pgliteLoadFailed = false;
|
|
32
30
|
function indexDir() {
|
|
33
31
|
const override = process.env.MEGACOMPACT_VECTOR_INDEX_DIR;
|
|
34
32
|
if (override && override.trim() !== "")
|
|
@@ -73,6 +71,30 @@ export function initVectorIndex() {
|
|
|
73
71
|
initPromise = openPgLite(/* retryOnCorrupt */ true);
|
|
74
72
|
return initPromise;
|
|
75
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Lazily load the PGlite module + pgvector extension via dynamic import. Caches
|
|
76
|
+
* success and permanent failure. Returns undefined (once, then forever) when the
|
|
77
|
+
* package is missing/broken so callers fall back to the sync scan. Never throws.
|
|
78
|
+
*/
|
|
79
|
+
async function loadPgLite() {
|
|
80
|
+
if (pgliteMod)
|
|
81
|
+
return pgliteMod;
|
|
82
|
+
if (pgliteLoadFailed)
|
|
83
|
+
return undefined;
|
|
84
|
+
try {
|
|
85
|
+
const [pglitePkg, pgvectorPkg] = await Promise.all([
|
|
86
|
+
import("@electric-sql/pglite"),
|
|
87
|
+
import("@electric-sql/pglite-pgvector"),
|
|
88
|
+
]);
|
|
89
|
+
pgliteMod = { PGlite: pglitePkg.PGlite, vector: pgvectorPkg.vector };
|
|
90
|
+
return pgliteMod;
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
pgliteLoadFailed = true;
|
|
94
|
+
logWarn(`package unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
76
98
|
/**
|
|
77
99
|
* Open + schema-init PGlite. When `retryOnCorrupt` is true, a WASM-level
|
|
78
100
|
* abort (typically from a corrupted/torn data dir) triggers a delete + one
|
|
@@ -80,11 +102,14 @@ export function initVectorIndex() {
|
|
|
80
102
|
*/
|
|
81
103
|
async function openPgLite(retryOnCorrupt) {
|
|
82
104
|
try {
|
|
105
|
+
const mod = await loadPgLite();
|
|
106
|
+
if (!mod)
|
|
107
|
+
return undefined;
|
|
83
108
|
const dir = indexDir();
|
|
84
109
|
mkdirSync(dir, { recursive: true });
|
|
85
|
-
const pg = await new PGlite({
|
|
110
|
+
const pg = await new mod.PGlite({
|
|
86
111
|
dataDir: dir,
|
|
87
|
-
extensions: { vector },
|
|
112
|
+
extensions: { vector: mod.vector },
|
|
88
113
|
});
|
|
89
114
|
await pg.exec("CREATE EXTENSION IF NOT EXISTS vector;");
|
|
90
115
|
await pg.exec(`
|
package/package.json
CHANGED
package/src/store/memoryIndex.ts
CHANGED
|
@@ -25,10 +25,12 @@ import { join } from "node:path";
|
|
|
25
25
|
import { mkdirSync, rmSync, existsSync } from "node:fs";
|
|
26
26
|
|
|
27
27
|
// PGlite + pgvector are script-free WASM (no native build) → survive pi's
|
|
28
|
-
// install-script block.
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
// install-script block. The VALUE import is LAZY (dynamic import inside
|
|
29
|
+
// openPgLite) so a missing/broken package degrades to the same-repo scan instead
|
|
30
|
+
// of crashing module load. A static top-level `import { PGlite }` would throw
|
|
31
|
+
// "Cannot find module" at pi startup and take down the whole extension. The
|
|
32
|
+
// `import type` below is erased at compile time and emits NO runtime load.
|
|
33
|
+
import type { PGlite as PGliteInstance, Extension } from "@electric-sql/pglite";
|
|
32
34
|
|
|
33
35
|
/** Vector dimension produced by the default TrigramEmbedder (src/embedder.ts). */
|
|
34
36
|
export const MEMORY_INDEX_DIM = 512;
|
|
@@ -47,6 +49,12 @@ let db: PGliteInstance | undefined;
|
|
|
47
49
|
let initPromise: Promise<PGliteInstance | undefined> | undefined;
|
|
48
50
|
let disabled = false;
|
|
49
51
|
let warned = false;
|
|
52
|
+
/** Lazily-loaded PGlite module + pgvector extension (see loadPgLite). */
|
|
53
|
+
let pgliteMod: {
|
|
54
|
+
PGlite: typeof import("@electric-sql/pglite")["PGlite"];
|
|
55
|
+
vector: Extension;
|
|
56
|
+
} | undefined;
|
|
57
|
+
let pgliteLoadFailed = false;
|
|
50
58
|
|
|
51
59
|
function indexDir(): string {
|
|
52
60
|
const override = process.env.MEGACOMPACT_INDEX_DIR;
|
|
@@ -91,6 +99,30 @@ export function initMemoryIndex(): Promise<PGliteInstance | undefined> {
|
|
|
91
99
|
return initPromise;
|
|
92
100
|
}
|
|
93
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Lazily load the PGlite module + pgvector extension via dynamic import. Caches
|
|
104
|
+
* success and permanent failure. Returns undefined (once, then forever) when the
|
|
105
|
+
* package is missing/broken so callers fall back to the same-repo scan. Never throws.
|
|
106
|
+
*/
|
|
107
|
+
async function loadPgLite(): Promise<
|
|
108
|
+
{ PGlite: typeof import("@electric-sql/pglite")["PGlite"]; vector: Extension } | undefined
|
|
109
|
+
> {
|
|
110
|
+
if (pgliteMod) return pgliteMod;
|
|
111
|
+
if (pgliteLoadFailed) return undefined;
|
|
112
|
+
try {
|
|
113
|
+
const [pglitePkg, pgvectorPkg] = await Promise.all([
|
|
114
|
+
import("@electric-sql/pglite"),
|
|
115
|
+
import("@electric-sql/pglite-pgvector"),
|
|
116
|
+
]);
|
|
117
|
+
pgliteMod = { PGlite: pglitePkg.PGlite, vector: pgvectorPkg.vector };
|
|
118
|
+
return pgliteMod;
|
|
119
|
+
} catch (err) {
|
|
120
|
+
pgliteLoadFailed = true;
|
|
121
|
+
logWarn(`package unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
94
126
|
/**
|
|
95
127
|
* Open + schema-init PGlite. When `retryOnCorrupt` is true, a WASM-level abort
|
|
96
128
|
* (typically from a corrupted/torn data dir) triggers a delete + one retry.
|
|
@@ -99,11 +131,13 @@ async function openPgLite(
|
|
|
99
131
|
retryOnCorrupt: boolean,
|
|
100
132
|
): Promise<PGliteInstance | undefined> {
|
|
101
133
|
try {
|
|
134
|
+
const mod = await loadPgLite();
|
|
135
|
+
if (!mod) return undefined;
|
|
102
136
|
const dir = indexDir();
|
|
103
137
|
mkdirSync(dir, { recursive: true });
|
|
104
|
-
const pg = await new PGlite({
|
|
138
|
+
const pg = await new mod.PGlite({
|
|
105
139
|
dataDir: dir,
|
|
106
|
-
extensions: { vector },
|
|
140
|
+
extensions: { vector: mod.vector },
|
|
107
141
|
});
|
|
108
142
|
await pg.exec("CREATE EXTENSION IF NOT EXISTS vector;");
|
|
109
143
|
await pg.exec(`
|
package/src/store/vectorIndex.ts
CHANGED
|
@@ -21,10 +21,12 @@ import { join } from "node:path";
|
|
|
21
21
|
import { mkdirSync, rmSync, existsSync } from "node:fs";
|
|
22
22
|
|
|
23
23
|
// PGlite + pgvector are script-free WASM (no native build) → survive pi's
|
|
24
|
-
// install-script block.
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
// install-script block. The VALUE import is LAZY (dynamic import inside
|
|
25
|
+
// openPgLite) so a missing/broken package degrades to the sync scan instead of
|
|
26
|
+
// crashing module load. A static top-level `import { PGlite }` would throw
|
|
27
|
+
// "Cannot find module" at pi startup and take down the whole extension. The
|
|
28
|
+
// `import type` below is erased at compile time and emits NO runtime load.
|
|
29
|
+
import type { PGlite as PGliteInstance, Extension } from "@electric-sql/pglite";
|
|
28
30
|
|
|
29
31
|
/** Vector dimension produced by the default TrigramEmbedder (src/embedder.ts). */
|
|
30
32
|
export const EMBEDDING_DIM = 512;
|
|
@@ -42,6 +44,12 @@ let db: PGliteInstance | undefined;
|
|
|
42
44
|
let initPromise: Promise<PGliteInstance | undefined> | undefined;
|
|
43
45
|
let disabled = false;
|
|
44
46
|
let warned = false;
|
|
47
|
+
/** Lazily-loaded PGlite module + pgvector extension (see loadPgLite). */
|
|
48
|
+
let pgliteMod: {
|
|
49
|
+
PGlite: typeof import("@electric-sql/pglite")["PGlite"];
|
|
50
|
+
vector: Extension;
|
|
51
|
+
} | undefined;
|
|
52
|
+
let pgliteLoadFailed = false;
|
|
45
53
|
|
|
46
54
|
function indexDir(): string {
|
|
47
55
|
const override = process.env.MEGACOMPACT_VECTOR_INDEX_DIR;
|
|
@@ -86,6 +94,30 @@ export function initVectorIndex(): Promise<PGliteInstance | undefined> {
|
|
|
86
94
|
return initPromise;
|
|
87
95
|
}
|
|
88
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Lazily load the PGlite module + pgvector extension via dynamic import. Caches
|
|
99
|
+
* success and permanent failure. Returns undefined (once, then forever) when the
|
|
100
|
+
* package is missing/broken so callers fall back to the sync scan. Never throws.
|
|
101
|
+
*/
|
|
102
|
+
async function loadPgLite(): Promise<
|
|
103
|
+
{ PGlite: typeof import("@electric-sql/pglite")["PGlite"]; vector: Extension } | undefined
|
|
104
|
+
> {
|
|
105
|
+
if (pgliteMod) return pgliteMod;
|
|
106
|
+
if (pgliteLoadFailed) return undefined;
|
|
107
|
+
try {
|
|
108
|
+
const [pglitePkg, pgvectorPkg] = await Promise.all([
|
|
109
|
+
import("@electric-sql/pglite"),
|
|
110
|
+
import("@electric-sql/pglite-pgvector"),
|
|
111
|
+
]);
|
|
112
|
+
pgliteMod = { PGlite: pglitePkg.PGlite, vector: pgvectorPkg.vector };
|
|
113
|
+
return pgliteMod;
|
|
114
|
+
} catch (err) {
|
|
115
|
+
pgliteLoadFailed = true;
|
|
116
|
+
logWarn(`package unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
89
121
|
/**
|
|
90
122
|
* Open + schema-init PGlite. When `retryOnCorrupt` is true, a WASM-level
|
|
91
123
|
* abort (typically from a corrupted/torn data dir) triggers a delete + one
|
|
@@ -95,11 +127,13 @@ async function openPgLite(
|
|
|
95
127
|
retryOnCorrupt: boolean,
|
|
96
128
|
): Promise<PGliteInstance | undefined> {
|
|
97
129
|
try {
|
|
130
|
+
const mod = await loadPgLite();
|
|
131
|
+
if (!mod) return undefined;
|
|
98
132
|
const dir = indexDir();
|
|
99
133
|
mkdirSync(dir, { recursive: true });
|
|
100
|
-
const pg = await new PGlite({
|
|
134
|
+
const pg = await new mod.PGlite({
|
|
101
135
|
dataDir: dir,
|
|
102
|
-
extensions: { vector },
|
|
136
|
+
extensions: { vector: mod.vector },
|
|
103
137
|
});
|
|
104
138
|
await pg.exec("CREATE EXTENSION IF NOT EXISTS vector;");
|
|
105
139
|
await pg.exec(`
|
|
@@ -1,291 +0,0 @@
|
|
|
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
|
-
});
|
package/dist/src/minilm.js
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
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
|
-
}
|
package/dist/src/wordpiece.js
DELETED
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* wordpiece.ts — a self-contained WordPiece tokenizer for BERT/MiniLM.
|
|
3
|
-
*
|
|
4
|
-
* Loads the canonical `vocab.txt` (bert-base-uncased, ~30K tokens) from disk and
|
|
5
|
-
* implements the standard uncased BERT preprocessing + greedy longest-match
|
|
6
|
-
* WordPiece segmentation. No native dependency, no network — the vocab file is a
|
|
7
|
-
* local artifact fetched once by scripts/setup-minilm.mjs (PREVENT-PI-004).
|
|
8
|
-
*
|
|
9
|
-
* This mirrors HuggingFace `BertTokenizer` closely enough for sentence-embedding
|
|
10
|
-
* use: lowercase, strip accents, split on whitespace + punctuation, then
|
|
11
|
-
* WordPiece each token with the `##` continuation convention. Special tokens
|
|
12
|
-
* [CLS]/[SEP] are added by the caller's encode().
|
|
13
|
-
*/
|
|
14
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
15
|
-
const UNK = "[UNK]";
|
|
16
|
-
const CLS = "[CLS]";
|
|
17
|
-
const SEP = "[SEP]";
|
|
18
|
-
const PAD = "[PAD]";
|
|
19
|
-
const MAX_INPUT_CHARS_PER_WORD = 200;
|
|
20
|
-
export class WordPieceTokenizer {
|
|
21
|
-
vocab;
|
|
22
|
-
clsId;
|
|
23
|
-
sepId;
|
|
24
|
-
padId;
|
|
25
|
-
unkId;
|
|
26
|
-
constructor(vocab) {
|
|
27
|
-
this.vocab = vocab;
|
|
28
|
-
this.clsId = vocab.get(CLS) ?? 101;
|
|
29
|
-
this.sepId = vocab.get(SEP) ?? 102;
|
|
30
|
-
this.padId = vocab.get(PAD) ?? 0;
|
|
31
|
-
this.unkId = vocab.get(UNK) ?? 100;
|
|
32
|
-
}
|
|
33
|
-
/** Build a tokenizer from a vocab.txt file (one token per line, index = line). */
|
|
34
|
-
static fromVocabFile(path) {
|
|
35
|
-
if (!existsSync(path)) {
|
|
36
|
-
throw new Error(`WordPiece vocab not found at ${path}. Run: node scripts/setup-minilm.mjs`);
|
|
37
|
-
}
|
|
38
|
-
const lines = readFileSync(path, "utf-8").split("\n");
|
|
39
|
-
const vocab = new Map();
|
|
40
|
-
for (let i = 0; i < lines.length; i++) {
|
|
41
|
-
const tok = lines[i].replace(/\r$/, "");
|
|
42
|
-
if (tok.length > 0 || i < lines.length - 1)
|
|
43
|
-
vocab.set(tok, i);
|
|
44
|
-
}
|
|
45
|
-
return new WordPieceTokenizer(vocab);
|
|
46
|
-
}
|
|
47
|
-
/** Uncased BERT basic tokenization: lowercase, strip accents, split on ws+punct. */
|
|
48
|
-
basicTokenize(text) {
|
|
49
|
-
// NFD + strip combining marks (accent removal), then lowercase.
|
|
50
|
-
const cleaned = text
|
|
51
|
-
.normalize("NFD")
|
|
52
|
-
.replace(/[̀-ͯ]/g, "")
|
|
53
|
-
.toLowerCase();
|
|
54
|
-
const tokens = [];
|
|
55
|
-
let buf = "";
|
|
56
|
-
const flush = () => {
|
|
57
|
-
if (buf.length > 0) {
|
|
58
|
-
tokens.push(buf);
|
|
59
|
-
buf = "";
|
|
60
|
-
}
|
|
61
|
-
};
|
|
62
|
-
for (const ch of cleaned) {
|
|
63
|
-
if (/\s/.test(ch)) {
|
|
64
|
-
flush();
|
|
65
|
-
}
|
|
66
|
-
else if (/[!-/:-@[-`{-~¡-¿]/.test(ch)) {
|
|
67
|
-
// Punctuation becomes its own token.
|
|
68
|
-
flush();
|
|
69
|
-
tokens.push(ch);
|
|
70
|
-
}
|
|
71
|
-
else {
|
|
72
|
-
buf += ch;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
flush();
|
|
76
|
-
return tokens;
|
|
77
|
-
}
|
|
78
|
-
/** Greedy longest-match WordPiece for a single word. */
|
|
79
|
-
wordpiece(word) {
|
|
80
|
-
if (word.length > MAX_INPUT_CHARS_PER_WORD)
|
|
81
|
-
return [UNK];
|
|
82
|
-
const pieces = [];
|
|
83
|
-
let start = 0;
|
|
84
|
-
while (start < word.length) {
|
|
85
|
-
let end = word.length;
|
|
86
|
-
let cur = null;
|
|
87
|
-
while (start < end) {
|
|
88
|
-
let sub = word.slice(start, end);
|
|
89
|
-
if (start > 0)
|
|
90
|
-
sub = "##" + sub;
|
|
91
|
-
if (this.vocab.has(sub)) {
|
|
92
|
-
cur = sub;
|
|
93
|
-
break;
|
|
94
|
-
}
|
|
95
|
-
end--;
|
|
96
|
-
}
|
|
97
|
-
if (cur === null)
|
|
98
|
-
return [UNK]; // any unmatchable piece → whole word is UNK
|
|
99
|
-
pieces.push(cur);
|
|
100
|
-
start = end;
|
|
101
|
-
}
|
|
102
|
-
return pieces;
|
|
103
|
-
}
|
|
104
|
-
/** Tokenize text into WordPiece token strings (no special tokens). */
|
|
105
|
-
tokenize(text) {
|
|
106
|
-
const out = [];
|
|
107
|
-
for (const word of this.basicTokenize(text)) {
|
|
108
|
-
for (const piece of this.wordpiece(word))
|
|
109
|
-
out.push(piece);
|
|
110
|
-
}
|
|
111
|
-
return out;
|
|
112
|
-
}
|
|
113
|
-
/**
|
|
114
|
-
* Encode text into model inputs with [CLS]…[SEP], truncated to `maxLen`.
|
|
115
|
-
* attention_mask is all 1s (no padding for single-sequence inference).
|
|
116
|
-
*/
|
|
117
|
-
encode(text, maxLen = 256) {
|
|
118
|
-
const pieces = this.tokenize(text).slice(0, Math.max(0, maxLen - 2));
|
|
119
|
-
const inputIds = [this.clsId];
|
|
120
|
-
for (const p of pieces)
|
|
121
|
-
inputIds.push(this.vocab.get(p) ?? this.unkId);
|
|
122
|
-
inputIds.push(this.sepId);
|
|
123
|
-
return {
|
|
124
|
-
inputIds,
|
|
125
|
-
attentionMask: inputIds.map(() => 1),
|
|
126
|
-
tokenTypeIds: inputIds.map(() => 0),
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
}
|