pi-mega-compact 0.4.24 → 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/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/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
|
@@ -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
|
+
}
|
package/dist/src/recall.js
CHANGED
|
@@ -84,3 +84,58 @@ export function recallAndInline(opts, store) {
|
|
|
84
84
|
empty: toInject.length === 0,
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Slice 2 async cross-repo recall. Same dedup/bound/inline contract as
|
|
89
|
+
* `recallAndInline`, but backed by `VectorStore.searchAsync` so it can recall
|
|
90
|
+
* across repos (HNSW NN over the global PGlite index) when `opts.crossRepo` is
|
|
91
|
+
* set. The synchronous `recallAndInline` is unchanged and remains the default
|
|
92
|
+
* per-session path. Inline-window dedupe + token cap (Fix C) apply here too.
|
|
93
|
+
*
|
|
94
|
+
* `store` must provide `searchAsync` (the live VectorStore does). Errors fall
|
|
95
|
+
* back to an empty result — recall is a bonus, never a hard dependency.
|
|
96
|
+
*/
|
|
97
|
+
export async function recallAndInlineAsync(opts, store) {
|
|
98
|
+
const limit = opts.limit ?? 3;
|
|
99
|
+
const skip = opts.skipInjected ?? true;
|
|
100
|
+
const maxTokens = opts.recallMaxTokens ?? 0;
|
|
101
|
+
const doWindowDedupe = opts.windowDedupe ?? false;
|
|
102
|
+
const dedupSim = opts.dedupSim ?? 0.9;
|
|
103
|
+
let hits = [];
|
|
104
|
+
try {
|
|
105
|
+
hits = await store.searchAsync(opts.sessionId, opts.query, limit, {
|
|
106
|
+
crossRepo: opts.crossRepo,
|
|
107
|
+
repoId: opts.repoId,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
hits = [];
|
|
112
|
+
}
|
|
113
|
+
let liveEmbeddings = [];
|
|
114
|
+
if (doWindowDedupe && opts.liveWindow && opts.liveWindow.length > 0) {
|
|
115
|
+
const embedder = defaultEmbedder();
|
|
116
|
+
liveEmbeddings = opts.liveWindow.map((m) => embedder.embed(m));
|
|
117
|
+
}
|
|
118
|
+
const toInject = [];
|
|
119
|
+
const parts = [];
|
|
120
|
+
let blockTokens = 0;
|
|
121
|
+
for (const h of hits) {
|
|
122
|
+
if (skip && store.wasInjected(opts.sessionId, h.checkpoint.checkpointId))
|
|
123
|
+
continue;
|
|
124
|
+
if (doWindowDedupe && liveEmbeddings.length > 0) {
|
|
125
|
+
const hitVec = defaultEmbedder().embed(h.checkpoint.summary);
|
|
126
|
+
if (liveEmbeddings.some((v) => cosineSimilarity(v, hitVec) >= dedupSim))
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const part = formatRecallBlock([h]);
|
|
130
|
+
const partTokens = estimateBlockTokens(part);
|
|
131
|
+
if (maxTokens > 0 && blockTokens + partTokens > maxTokens)
|
|
132
|
+
break;
|
|
133
|
+
parts.push(part);
|
|
134
|
+
toInject.push(h);
|
|
135
|
+
blockTokens += partTokens;
|
|
136
|
+
store.markInjected(opts.sessionId, h.checkpoint.checkpointId);
|
|
137
|
+
}
|
|
138
|
+
const block = parts.join("\n");
|
|
139
|
+
const report = toInject.map((h) => ` • ${h.checkpoint.checkpointId} (${h.checkpoint.summary.slice(0, 60).replace(/\n/g, " ")}…)`);
|
|
140
|
+
return { toInject, report, block, empty: toInject.length === 0 };
|
|
141
|
+
}
|
package/dist/src/store/sqlite.js
CHANGED
|
@@ -705,6 +705,14 @@ export function hasCheckpoint(sessionId, checkpointId, stateDir = getStateDir())
|
|
|
705
705
|
.get(normalizeSessionId(sessionId), checkpointId);
|
|
706
706
|
return row !== undefined;
|
|
707
707
|
}
|
|
708
|
+
/** Fetch a single checkpoint by (session, id), or undefined if absent. */
|
|
709
|
+
export function getCheckpoint(sessionId, checkpointId, stateDir = getStateDir()) {
|
|
710
|
+
const db = openStore(stateDir);
|
|
711
|
+
const row = db
|
|
712
|
+
.prepare("SELECT * FROM context_chunks WHERE session_id = ? AND id = ? LIMIT 1")
|
|
713
|
+
.get(normalizeSessionId(sessionId), checkpointId);
|
|
714
|
+
return row ? rowToCheckpoint(row) : undefined;
|
|
715
|
+
}
|
|
708
716
|
/** Mark a checkpoint's dedup_status (e.g. 'removed' by SemDeDup). */
|
|
709
717
|
export function setDedupStatus(checkpointId, sessionId, status, stateDir = getStateDir()) {
|
|
710
718
|
const db = openStore(stateDir);
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vectorIndex.ts — Slice 2 async vector index (PGlite/pgvector HNSW).
|
|
3
|
+
*
|
|
4
|
+
* A REDUNDANT, additive, ASYNC index layered over the synchronous node:sqlite
|
|
5
|
+
* store (which remains the authoritative source of truth). The sync linear
|
|
6
|
+
* cosine scan over `embedding_blob` stays the DEFAULT recall path; this index
|
|
7
|
+
* exists only to provide real cross-repo / cross-session HNSW nearest-neighbor
|
|
8
|
+
* recall. It is best-effort and non-fatal: any init/write failure degrades to
|
|
9
|
+
* the sync scan and must NEVER break add(), compaction, or extension load.
|
|
10
|
+
*
|
|
11
|
+
* PREVENT-PI-004: PGlite is WASM Postgres — fully local, zero network.
|
|
12
|
+
*
|
|
13
|
+
* Index topology (decision 2026-07-15): ONE global PGlite DB, `repo_id` is a
|
|
14
|
+
* first-class column. `searchAsync(q, k, {repoId?})` → omit repoId for cross-repo
|
|
15
|
+
* NN, pass repoId to scope to a single repo. The sync store is per-repo (state
|
|
16
|
+
* dir); this global index is the thing that makes cross-repo recall possible.
|
|
17
|
+
*/
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { mkdirSync } 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
|
+
/** Vector dimension produced by the default TrigramEmbedder (src/embedder.ts). */
|
|
27
|
+
export const EMBEDDING_DIM = 512;
|
|
28
|
+
let db;
|
|
29
|
+
let initPromise;
|
|
30
|
+
let disabled = false;
|
|
31
|
+
let warned = false;
|
|
32
|
+
function indexDir() {
|
|
33
|
+
const override = process.env.MEGACOMPACT_VECTOR_INDEX_DIR;
|
|
34
|
+
if (override && override.trim() !== "")
|
|
35
|
+
return override;
|
|
36
|
+
try {
|
|
37
|
+
return join(homedir(), ".pi", "mega-compact-vector");
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return join("/tmp", ".mega-compact-vector");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function logWarn(msg) {
|
|
44
|
+
// Never throw — degradation is the whole point. One warning per process.
|
|
45
|
+
if (warned)
|
|
46
|
+
return;
|
|
47
|
+
warned = true;
|
|
48
|
+
try {
|
|
49
|
+
console.warn(`[mega-compact:vectorIndex] ${msg} (falling back to sync scan)`);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
/* ignore */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Honor the emergency kill-switch. When set, the index is fully disabled. */
|
|
56
|
+
export function isVectorIndexDisabled() {
|
|
57
|
+
return (disabled ||
|
|
58
|
+
process.env.MEGACOMPACT_PGLITE_DISABLED === "true" ||
|
|
59
|
+
process.env.MEGACOMPACT_PGLITE_DISABLED === "1");
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Lazily open + schema-init the global PGlite DB. Idempotent and safe to call
|
|
63
|
+
* from many places. Returns undefined when disabled/unavailable so callers can
|
|
64
|
+
* fall back to the synchronous scan. Never throws.
|
|
65
|
+
*/
|
|
66
|
+
export function initVectorIndex() {
|
|
67
|
+
if (isVectorIndexDisabled())
|
|
68
|
+
return Promise.resolve(undefined);
|
|
69
|
+
if (db)
|
|
70
|
+
return Promise.resolve(db);
|
|
71
|
+
if (initPromise)
|
|
72
|
+
return initPromise;
|
|
73
|
+
initPromise = (async () => {
|
|
74
|
+
try {
|
|
75
|
+
const dir = indexDir();
|
|
76
|
+
mkdirSync(dir, { recursive: true });
|
|
77
|
+
const pg = await new PGlite({
|
|
78
|
+
dataDir: dir,
|
|
79
|
+
extensions: { vector },
|
|
80
|
+
});
|
|
81
|
+
await pg.exec("CREATE EXTENSION IF NOT EXISTS vector;");
|
|
82
|
+
await pg.exec(`
|
|
83
|
+
CREATE TABLE IF NOT EXISTS vector_index (
|
|
84
|
+
repo_id TEXT NOT NULL,
|
|
85
|
+
session_id TEXT NOT NULL,
|
|
86
|
+
checkpoint_id TEXT NOT NULL,
|
|
87
|
+
embedding vector(${EMBEDDING_DIM}) NOT NULL,
|
|
88
|
+
PRIMARY KEY (repo_id, session_id, checkpoint_id)
|
|
89
|
+
);
|
|
90
|
+
`);
|
|
91
|
+
// HNSW index over cosine distance for fast NN. Created idempotently.
|
|
92
|
+
await pg.exec("CREATE INDEX IF NOT EXISTS vector_index_hnsw ON vector_index USING hnsw (embedding vector_cosine_ops);");
|
|
93
|
+
db = pg;
|
|
94
|
+
return pg;
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
disabled = true;
|
|
98
|
+
logWarn(`init failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
})();
|
|
102
|
+
return initPromise;
|
|
103
|
+
}
|
|
104
|
+
function toVectorLiteral(v) {
|
|
105
|
+
// pgvector text form: [a,b,c]. Guard against NaN/Inf for a clean literal.
|
|
106
|
+
const parts = v.map((x) => (Number.isFinite(x) ? x : 0));
|
|
107
|
+
return `[${parts.join(",")}]`;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Best-effort upsert of one checkpoint embedding into the global index.
|
|
111
|
+
* Dimension-mismatched vectors (e.g. a BYO embedder with dim ≠ 512) are skipped
|
|
112
|
+
* rather than corrupting the index. Fire-and-forget: resolved promise only;
|
|
113
|
+
* callers must NOT await this on the sync path. Never throws.
|
|
114
|
+
*/
|
|
115
|
+
export async function upsertEmbedding(repoId, sessionId, checkpointId, embedding) {
|
|
116
|
+
if (isVectorIndexDisabled())
|
|
117
|
+
return;
|
|
118
|
+
if (!embedding || embedding.length !== EMBEDDING_DIM) {
|
|
119
|
+
// Dimension guard: skip without corrupting the fixed-dim index.
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const pg = await initVectorIndex();
|
|
124
|
+
if (!pg)
|
|
125
|
+
return;
|
|
126
|
+
const lit = toVectorLiteral(embedding);
|
|
127
|
+
await pg.query(`INSERT INTO vector_index (repo_id, session_id, checkpoint_id, embedding)
|
|
128
|
+
VALUES ($1, $2, $3, $4::vector)
|
|
129
|
+
ON CONFLICT (repo_id, session_id, checkpoint_id)
|
|
130
|
+
DO UPDATE SET embedding = EXCLUDED.embedding;`, [repoId, sessionId, checkpointId, lit]);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
disabled = true;
|
|
134
|
+
logWarn(`upsert failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Cross-repo (or single-repo) HNSW nearest-neighbor search. Returns hits sorted
|
|
139
|
+
* by descending similarity. Never throws — on any failure returns [].
|
|
140
|
+
*/
|
|
141
|
+
export async function searchAsync(query, opts = {}) {
|
|
142
|
+
if (isVectorIndexDisabled() || !query || query.length !== EMBEDDING_DIM)
|
|
143
|
+
return [];
|
|
144
|
+
const k = opts.k ?? 3;
|
|
145
|
+
const repoId = opts.repoId;
|
|
146
|
+
try {
|
|
147
|
+
const pg = await initVectorIndex();
|
|
148
|
+
if (!pg)
|
|
149
|
+
return [];
|
|
150
|
+
const lit = toVectorLiteral(query);
|
|
151
|
+
const params = [lit, k];
|
|
152
|
+
let sql = "SELECT repo_id, session_id, checkpoint_id, 1 - (embedding <=> $1::vector) AS score " +
|
|
153
|
+
"FROM vector_index";
|
|
154
|
+
if (repoId) {
|
|
155
|
+
sql += " WHERE repo_id = $3";
|
|
156
|
+
params.push(repoId);
|
|
157
|
+
}
|
|
158
|
+
sql += " ORDER BY embedding <=> $1::vector LIMIT $2";
|
|
159
|
+
const res = await pg.query(sql, params);
|
|
160
|
+
return res.rows.map((r) => ({
|
|
161
|
+
repoId: r.repo_id,
|
|
162
|
+
sessionId: r.session_id,
|
|
163
|
+
checkpointId: r.checkpoint_id,
|
|
164
|
+
score: r.score,
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
disabled = true;
|
|
169
|
+
logWarn(`search failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/** Close the index (test teardown / shutdown). Safe to call when unopened. */
|
|
174
|
+
export async function closeVectorIndex() {
|
|
175
|
+
if (db) {
|
|
176
|
+
try {
|
|
177
|
+
await db.close();
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
/* ignore */
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
db = undefined;
|
|
184
|
+
initPromise = undefined;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Rebuild the entire index from the authoritative node:sqlite store. Used for
|
|
188
|
+
* backfill + DR. `enumerateRepoStateDirs` yields each repo's state dir; we read
|
|
189
|
+
* its checkpoint embeddings and bulk upsert. Best-effort: counts successes and
|
|
190
|
+
* skips failures. Returns {upserted, errors}.
|
|
191
|
+
*/
|
|
192
|
+
export async function rebuildFromSqlite(enumerateRepoStateDirs, readCheckpoints) {
|
|
193
|
+
let upserted = 0;
|
|
194
|
+
let errors = 0;
|
|
195
|
+
const pg = await initVectorIndex();
|
|
196
|
+
if (!pg)
|
|
197
|
+
return { upserted, errors: 1 };
|
|
198
|
+
for (const repo of enumerateRepoStateDirs()) {
|
|
199
|
+
for (const cp of readCheckpoints(repo.stateDir)) {
|
|
200
|
+
try {
|
|
201
|
+
await upsertEmbedding(repo.repoId, cp.sessionId, cp.checkpointId, cp.embedding);
|
|
202
|
+
upserted++;
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
errors++;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return { upserted, errors };
|
|
210
|
+
}
|