pi-mega-compact 0.16.1 → 0.16.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/dist/src/httpEmbedder.js +136 -1
- package/dist/src/vector-search.js +1 -1
- package/package.json +1 -1
- package/src/httpEmbedder.ts +134 -1
- package/src/vector-search.ts +1 -1
package/dist/src/httpEmbedder.js
CHANGED
|
@@ -24,9 +24,101 @@
|
|
|
24
24
|
* blocks without that deadlock. Only used when this embedder is selected; the
|
|
25
25
|
* default TrigramEmbedder path stays pure-sync, zero-network, zero-native.
|
|
26
26
|
*/
|
|
27
|
-
import { l2Normalize } from "./embedder.js";
|
|
27
|
+
import { l2Normalize, TrigramEmbedder } from "./embedder.js";
|
|
28
|
+
import { Logger } from "./log.js";
|
|
28
29
|
import { spawnSync } from "node:child_process"; // guardrails-allow PREVENT-PI-004: localhost-only user-spawned embedding server (BYO backend, never remote)
|
|
29
30
|
import { isIP } from "node:net"; // guardrails-allow PREVENT-PI-004: localhost-only loopback address validation in BYO embedding server
|
|
31
|
+
// ── Oversized-input chunking + graceful fallback (BowTiedDevil 500 report) ────
|
|
32
|
+
//
|
|
33
|
+
// The embedding server enforces a physical batch size (llama.cpp n_ctx_slot,
|
|
34
|
+
// commonly 2048 tokens); a single over-large prompt is rejected with HTTP 500
|
|
35
|
+
// ("input is too large to process"). Previously embed() sent the WHOLE region
|
|
36
|
+
// as one prompt and threw on the 500, propagating an unhandled rejection up
|
|
37
|
+
// through VectorStore.addCheckpoint → engine and crashing the checkpoint write.
|
|
38
|
+
//
|
|
39
|
+
// Two layered defenses now live inside embed():
|
|
40
|
+
// 1. CHUNKING — text whose estimated token count exceeds the configured batch
|
|
41
|
+
// limit is split into <=limit chunks (paragraph/sentence/word boundaries,
|
|
42
|
+
// never mid-word when avoidable), each chunk embedded, and the per-chunk
|
|
43
|
+
// vectors mean-pooled (weighted by chunk tokens) + L2-renormalized. This
|
|
44
|
+
// changes the vector for oversized docs — which previously crashed — and
|
|
45
|
+
// is the standard pooling approximation for long inputs.
|
|
46
|
+
// 2. FALLBACK — if the server is unreachable / returns an error / returns an
|
|
47
|
+
// unparseable body, embed() catches and falls back to the local
|
|
48
|
+
// TrigramEmbedder (logging one structured warn, never the text), so a
|
|
49
|
+
// server outage degrades semantic dedup instead of crashing the agent loop
|
|
50
|
+
// (repo non-fatal-store / fails-closed convention).
|
|
51
|
+
//
|
|
52
|
+
// Token count is ESTIMATED, not tokenized: chars / MEGACOMPACT_EMBEDDING_CHARS_PER_TOKEN
|
|
53
|
+
// (default 4, a conservative English/code average). Both knobs are env-tunable.
|
|
54
|
+
/** Default physical batch the typical local server accepts (llama.cpp n_ctx_slot). */
|
|
55
|
+
const DEFAULT_BATCH_TOKENS = 2048;
|
|
56
|
+
/** Conservative chars-per-token average for English + code. */
|
|
57
|
+
const DEFAULT_CHARS_PER_TOKEN = 4;
|
|
58
|
+
function envInt(name, fallback) {
|
|
59
|
+
const raw = process.env[name];
|
|
60
|
+
if (raw === undefined)
|
|
61
|
+
return fallback;
|
|
62
|
+
const n = Number(raw);
|
|
63
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
|
64
|
+
}
|
|
65
|
+
/** Estimated token count of `text` (chars / charsPerToken, rounded up). Exported for tests. */
|
|
66
|
+
export function estimateTokens(text, charsPerToken) {
|
|
67
|
+
return Math.ceil(text.length / charsPerToken);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Split `text` into chunks each estimated <= maxTokens. Prefers paragraph, then
|
|
71
|
+
* sentence, then word boundaries; falls back to a hard slice only when a single
|
|
72
|
+
* word/run still exceeds the limit (never splits a word unless unavoidable).
|
|
73
|
+
*/
|
|
74
|
+
export function chunkText(text, maxTokens, charsPerToken) {
|
|
75
|
+
const maxChars = maxTokens * charsPerToken;
|
|
76
|
+
if (text.length <= maxChars)
|
|
77
|
+
return [text];
|
|
78
|
+
const chunks = [];
|
|
79
|
+
let start = 0;
|
|
80
|
+
while (start < text.length) {
|
|
81
|
+
let end = Math.min(start + maxChars, text.length);
|
|
82
|
+
if (end < text.length) {
|
|
83
|
+
// Back off to the last nice boundary within the window: paragraph, then
|
|
84
|
+
// sentence, then whitespace. Require the boundary in the back half so we
|
|
85
|
+
// don't pathologically emit tiny chunks.
|
|
86
|
+
const windowStart = start + Math.floor(maxChars / 2);
|
|
87
|
+
const para = text.lastIndexOf("\n\n", end);
|
|
88
|
+
const sentence = text.lastIndexOf(". ", end);
|
|
89
|
+
const space = text.lastIndexOf(" ", end);
|
|
90
|
+
if (para >= windowStart)
|
|
91
|
+
end = para + 2;
|
|
92
|
+
else if (sentence >= windowStart)
|
|
93
|
+
end = sentence + 2;
|
|
94
|
+
else if (space >= windowStart)
|
|
95
|
+
end = space + 1;
|
|
96
|
+
// else: no good boundary in the back half — hard-slice at maxChars.
|
|
97
|
+
}
|
|
98
|
+
const piece = text.slice(start, end);
|
|
99
|
+
if (piece.trim().length > 0)
|
|
100
|
+
chunks.push(piece);
|
|
101
|
+
start = end;
|
|
102
|
+
}
|
|
103
|
+
return chunks;
|
|
104
|
+
}
|
|
105
|
+
/** Weighted mean-pool of equal-dim vectors (weights = chunk token estimates), then L2-renormalize. */
|
|
106
|
+
export function meanPool(vectors, weights) {
|
|
107
|
+
const dim = vectors[0]?.length ?? 0;
|
|
108
|
+
const acc = new Array(dim).fill(0);
|
|
109
|
+
let total = 0;
|
|
110
|
+
for (let i = 0; i < vectors.length; i++) {
|
|
111
|
+
const w = weights[i] ?? 0;
|
|
112
|
+
total += w;
|
|
113
|
+
const v = vectors[i];
|
|
114
|
+
for (let d = 0; d < dim; d++)
|
|
115
|
+
acc[d] += v[d] * w;
|
|
116
|
+
}
|
|
117
|
+
if (total > 0)
|
|
118
|
+
for (let d = 0; d < dim; d++)
|
|
119
|
+
acc[d] /= total;
|
|
120
|
+
return l2Normalize(acc);
|
|
121
|
+
}
|
|
30
122
|
// Inline worker script: resolves a hostname via dns.lookup in a child process
|
|
31
123
|
// (dns.lookup is callback-async; the child has its own event loop). Used to
|
|
32
124
|
// verify that a hostname in the embedding URL resolves to loopback ONLY.
|
|
@@ -204,11 +296,19 @@ export class HttpEmbedder {
|
|
|
204
296
|
apiKey;
|
|
205
297
|
headers;
|
|
206
298
|
resolvedDim;
|
|
299
|
+
batchTokens;
|
|
300
|
+
charsPerToken;
|
|
301
|
+
fallback;
|
|
302
|
+
logger;
|
|
207
303
|
constructor(opts) {
|
|
208
304
|
this.url = opts.url;
|
|
209
305
|
this.apiKey = opts.apiKey;
|
|
210
306
|
this.headers = opts.headers ?? {};
|
|
211
307
|
this.resolvedDim = opts.dim ?? 0; // resolved after the first embed
|
|
308
|
+
this.batchTokens = envInt("MEGACOMPACT_EMBEDDING_BATCH_TOKENS", DEFAULT_BATCH_TOKENS);
|
|
309
|
+
this.charsPerToken = envInt("MEGACOMPACT_EMBEDDING_CHARS_PER_TOKEN", DEFAULT_CHARS_PER_TOKEN);
|
|
310
|
+
this.fallback = new TrigramEmbedder();
|
|
311
|
+
this.logger = new Logger();
|
|
212
312
|
}
|
|
213
313
|
get dim() {
|
|
214
314
|
return this.resolvedDim;
|
|
@@ -218,7 +318,42 @@ export class HttpEmbedder {
|
|
|
218
318
|
get chatUrl() {
|
|
219
319
|
return new URL("/api/chat", this.url).href;
|
|
220
320
|
}
|
|
321
|
+
/**
|
|
322
|
+
* Embed `text`. Oversized inputs are chunked (each chunk <= the configured
|
|
323
|
+
* server batch) and mean-pooled; a server failure falls back to the local
|
|
324
|
+
* TrigramEmbedder. NEVER throws — a misbehaving server must degrade semantic
|
|
325
|
+
* dedup, not crash the checkpoint write path (VectorStore.addCheckpoint has
|
|
326
|
+
* no try/catch around embed()).
|
|
327
|
+
*/
|
|
221
328
|
embed(text) {
|
|
329
|
+
const est = estimateTokens(text, this.charsPerToken);
|
|
330
|
+
const chunks = est > this.batchTokens ? chunkText(text, this.batchTokens, this.charsPerToken) : [text];
|
|
331
|
+
try {
|
|
332
|
+
if (chunks.length === 1) {
|
|
333
|
+
return this.embedOne(chunks[0]);
|
|
334
|
+
}
|
|
335
|
+
const vectors = [];
|
|
336
|
+
const weights = [];
|
|
337
|
+
for (const c of chunks) {
|
|
338
|
+
vectors.push(this.embedOne(c));
|
|
339
|
+
weights.push(estimateTokens(c, this.charsPerToken));
|
|
340
|
+
}
|
|
341
|
+
return meanPool(vectors, weights);
|
|
342
|
+
}
|
|
343
|
+
catch (e) {
|
|
344
|
+
// Graceful fallback — degrade to the local embedder rather than crash.
|
|
345
|
+
// Never log the text itself (privacy / PREVENT-PI-004).
|
|
346
|
+
this.logger.warn("embedder_http_fallback", {
|
|
347
|
+
reason: e instanceof Error ? e.message : String(e),
|
|
348
|
+
chunks: chunks.length,
|
|
349
|
+
chars: text.length,
|
|
350
|
+
estTokens: est,
|
|
351
|
+
});
|
|
352
|
+
return this.fallback.embed(text);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/** Single request/response round-trip for one chunk. Throws on any failure. */
|
|
356
|
+
embedOne(text) {
|
|
222
357
|
const ollama = isOllamaEndpoint(this.url);
|
|
223
358
|
const body = ollama
|
|
224
359
|
? JSON.stringify({ model: process.env.MEGACOMPACT_OLLAMA_MODEL || "nomic-embed-text", prompt: text })
|
|
@@ -41,7 +41,7 @@ function raptorSearchHits(store, sid, query, k, checkpoints) {
|
|
|
41
41
|
const stateDir = store.stateDir;
|
|
42
42
|
const cfg = store.cfg;
|
|
43
43
|
const embedder = store.embedder;
|
|
44
|
-
const record = store.record;
|
|
44
|
+
const record = store.record.bind(store);
|
|
45
45
|
// S25 gate (a): honor shadow mode at SERVE time. When RAPTOR_SHADOW_MODE=true,
|
|
46
46
|
// the tree is built + persisted but NOT merged into recall (transition/eval).
|
|
47
47
|
// Default is live: shadow mode is opt-in, not the default.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-mega-compact",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.3",
|
|
4
4
|
"description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "BSD-3-Clause",
|
package/src/httpEmbedder.ts
CHANGED
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import type { Embedder, Vector } from "./embedder.js";
|
|
29
|
-
import { l2Normalize } from "./embedder.js";
|
|
29
|
+
import { l2Normalize, TrigramEmbedder } from "./embedder.js";
|
|
30
|
+
import { Logger } from "./log.js";
|
|
30
31
|
import { spawnSync } from "node:child_process"; // guardrails-allow PREVENT-PI-004: localhost-only user-spawned embedding server (BYO backend, never remote)
|
|
31
32
|
import { isIP } from "node:net"; // guardrails-allow PREVENT-PI-004: localhost-only loopback address validation in BYO embedding server
|
|
32
33
|
|
|
@@ -40,6 +41,94 @@ export interface HttpEmbedderOptions {
|
|
|
40
41
|
dim?: number;
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
// ── Oversized-input chunking + graceful fallback (BowTiedDevil 500 report) ────
|
|
45
|
+
//
|
|
46
|
+
// The embedding server enforces a physical batch size (llama.cpp n_ctx_slot,
|
|
47
|
+
// commonly 2048 tokens); a single over-large prompt is rejected with HTTP 500
|
|
48
|
+
// ("input is too large to process"). Previously embed() sent the WHOLE region
|
|
49
|
+
// as one prompt and threw on the 500, propagating an unhandled rejection up
|
|
50
|
+
// through VectorStore.addCheckpoint → engine and crashing the checkpoint write.
|
|
51
|
+
//
|
|
52
|
+
// Two layered defenses now live inside embed():
|
|
53
|
+
// 1. CHUNKING — text whose estimated token count exceeds the configured batch
|
|
54
|
+
// limit is split into <=limit chunks (paragraph/sentence/word boundaries,
|
|
55
|
+
// never mid-word when avoidable), each chunk embedded, and the per-chunk
|
|
56
|
+
// vectors mean-pooled (weighted by chunk tokens) + L2-renormalized. This
|
|
57
|
+
// changes the vector for oversized docs — which previously crashed — and
|
|
58
|
+
// is the standard pooling approximation for long inputs.
|
|
59
|
+
// 2. FALLBACK — if the server is unreachable / returns an error / returns an
|
|
60
|
+
// unparseable body, embed() catches and falls back to the local
|
|
61
|
+
// TrigramEmbedder (logging one structured warn, never the text), so a
|
|
62
|
+
// server outage degrades semantic dedup instead of crashing the agent loop
|
|
63
|
+
// (repo non-fatal-store / fails-closed convention).
|
|
64
|
+
//
|
|
65
|
+
// Token count is ESTIMATED, not tokenized: chars / MEGACOMPACT_EMBEDDING_CHARS_PER_TOKEN
|
|
66
|
+
// (default 4, a conservative English/code average). Both knobs are env-tunable.
|
|
67
|
+
|
|
68
|
+
/** Default physical batch the typical local server accepts (llama.cpp n_ctx_slot). */
|
|
69
|
+
const DEFAULT_BATCH_TOKENS = 2048;
|
|
70
|
+
/** Conservative chars-per-token average for English + code. */
|
|
71
|
+
const DEFAULT_CHARS_PER_TOKEN = 4;
|
|
72
|
+
|
|
73
|
+
function envInt(name: string, fallback: number): number {
|
|
74
|
+
const raw = process.env[name];
|
|
75
|
+
if (raw === undefined) return fallback;
|
|
76
|
+
const n = Number(raw);
|
|
77
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Estimated token count of `text` (chars / charsPerToken, rounded up). Exported for tests. */
|
|
81
|
+
export function estimateTokens(text: string, charsPerToken: number): number {
|
|
82
|
+
return Math.ceil(text.length / charsPerToken);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Split `text` into chunks each estimated <= maxTokens. Prefers paragraph, then
|
|
87
|
+
* sentence, then word boundaries; falls back to a hard slice only when a single
|
|
88
|
+
* word/run still exceeds the limit (never splits a word unless unavoidable).
|
|
89
|
+
*/
|
|
90
|
+
export function chunkText(text: string, maxTokens: number, charsPerToken: number): string[] {
|
|
91
|
+
const maxChars = maxTokens * charsPerToken;
|
|
92
|
+
if (text.length <= maxChars) return [text];
|
|
93
|
+
const chunks: string[] = [];
|
|
94
|
+
let start = 0;
|
|
95
|
+
while (start < text.length) {
|
|
96
|
+
let end = Math.min(start + maxChars, text.length);
|
|
97
|
+
if (end < text.length) {
|
|
98
|
+
// Back off to the last nice boundary within the window: paragraph, then
|
|
99
|
+
// sentence, then whitespace. Require the boundary in the back half so we
|
|
100
|
+
// don't pathologically emit tiny chunks.
|
|
101
|
+
const windowStart = start + Math.floor(maxChars / 2);
|
|
102
|
+
const para = text.lastIndexOf("\n\n", end);
|
|
103
|
+
const sentence = text.lastIndexOf(". ", end);
|
|
104
|
+
const space = text.lastIndexOf(" ", end);
|
|
105
|
+
if (para >= windowStart) end = para + 2;
|
|
106
|
+
else if (sentence >= windowStart) end = sentence + 2;
|
|
107
|
+
else if (space >= windowStart) end = space + 1;
|
|
108
|
+
// else: no good boundary in the back half — hard-slice at maxChars.
|
|
109
|
+
}
|
|
110
|
+
const piece = text.slice(start, end);
|
|
111
|
+
if (piece.trim().length > 0) chunks.push(piece);
|
|
112
|
+
start = end;
|
|
113
|
+
}
|
|
114
|
+
return chunks;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Weighted mean-pool of equal-dim vectors (weights = chunk token estimates), then L2-renormalize. */
|
|
118
|
+
export function meanPool(vectors: readonly Vector[], weights: readonly number[]): Vector {
|
|
119
|
+
const dim = vectors[0]?.length ?? 0;
|
|
120
|
+
const acc = new Array<number>(dim).fill(0);
|
|
121
|
+
let total = 0;
|
|
122
|
+
for (let i = 0; i < vectors.length; i++) {
|
|
123
|
+
const w = weights[i] ?? 0;
|
|
124
|
+
total += w;
|
|
125
|
+
const v = vectors[i];
|
|
126
|
+
for (let d = 0; d < dim; d++) acc[d] += v[d] * w;
|
|
127
|
+
}
|
|
128
|
+
if (total > 0) for (let d = 0; d < dim; d++) acc[d] /= total;
|
|
129
|
+
return l2Normalize(acc);
|
|
130
|
+
}
|
|
131
|
+
|
|
43
132
|
// Inline worker script: resolves a hostname via dns.lookup in a child process
|
|
44
133
|
// (dns.lookup is callback-async; the child has its own event loop). Used to
|
|
45
134
|
// verify that a hostname in the embedding URL resolves to loopback ONLY.
|
|
@@ -217,12 +306,20 @@ export class HttpEmbedder implements Embedder {
|
|
|
217
306
|
private readonly apiKey?: string;
|
|
218
307
|
private readonly headers: Record<string, string>;
|
|
219
308
|
private resolvedDim: number;
|
|
309
|
+
private readonly batchTokens: number;
|
|
310
|
+
private readonly charsPerToken: number;
|
|
311
|
+
private readonly fallback: TrigramEmbedder;
|
|
312
|
+
private readonly logger: Logger;
|
|
220
313
|
|
|
221
314
|
constructor(opts: HttpEmbedderOptions) {
|
|
222
315
|
this.url = opts.url;
|
|
223
316
|
this.apiKey = opts.apiKey;
|
|
224
317
|
this.headers = opts.headers ?? {};
|
|
225
318
|
this.resolvedDim = opts.dim ?? 0; // resolved after the first embed
|
|
319
|
+
this.batchTokens = envInt("MEGACOMPACT_EMBEDDING_BATCH_TOKENS", DEFAULT_BATCH_TOKENS);
|
|
320
|
+
this.charsPerToken = envInt("MEGACOMPACT_EMBEDDING_CHARS_PER_TOKEN", DEFAULT_CHARS_PER_TOKEN);
|
|
321
|
+
this.fallback = new TrigramEmbedder();
|
|
322
|
+
this.logger = new Logger();
|
|
226
323
|
}
|
|
227
324
|
|
|
228
325
|
get dim(): number {
|
|
@@ -235,7 +332,43 @@ export class HttpEmbedder implements Embedder {
|
|
|
235
332
|
return new URL("/api/chat", this.url).href;
|
|
236
333
|
}
|
|
237
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Embed `text`. Oversized inputs are chunked (each chunk <= the configured
|
|
337
|
+
* server batch) and mean-pooled; a server failure falls back to the local
|
|
338
|
+
* TrigramEmbedder. NEVER throws — a misbehaving server must degrade semantic
|
|
339
|
+
* dedup, not crash the checkpoint write path (VectorStore.addCheckpoint has
|
|
340
|
+
* no try/catch around embed()).
|
|
341
|
+
*/
|
|
238
342
|
embed(text: string): Vector {
|
|
343
|
+
const est = estimateTokens(text, this.charsPerToken);
|
|
344
|
+
const chunks =
|
|
345
|
+
est > this.batchTokens ? chunkText(text, this.batchTokens, this.charsPerToken) : [text];
|
|
346
|
+
try {
|
|
347
|
+
if (chunks.length === 1) {
|
|
348
|
+
return this.embedOne(chunks[0]);
|
|
349
|
+
}
|
|
350
|
+
const vectors: Vector[] = [];
|
|
351
|
+
const weights: number[] = [];
|
|
352
|
+
for (const c of chunks) {
|
|
353
|
+
vectors.push(this.embedOne(c));
|
|
354
|
+
weights.push(estimateTokens(c, this.charsPerToken));
|
|
355
|
+
}
|
|
356
|
+
return meanPool(vectors, weights);
|
|
357
|
+
} catch (e) {
|
|
358
|
+
// Graceful fallback — degrade to the local embedder rather than crash.
|
|
359
|
+
// Never log the text itself (privacy / PREVENT-PI-004).
|
|
360
|
+
this.logger.warn("embedder_http_fallback", {
|
|
361
|
+
reason: e instanceof Error ? e.message : String(e),
|
|
362
|
+
chunks: chunks.length,
|
|
363
|
+
chars: text.length,
|
|
364
|
+
estTokens: est,
|
|
365
|
+
});
|
|
366
|
+
return this.fallback.embed(text);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Single request/response round-trip for one chunk. Throws on any failure. */
|
|
371
|
+
private embedOne(text: string): Vector {
|
|
239
372
|
const ollama = isOllamaEndpoint(this.url);
|
|
240
373
|
const body = ollama
|
|
241
374
|
? JSON.stringify({ model: process.env.MEGACOMPACT_OLLAMA_MODEL || "nomic-embed-text", prompt: text })
|
package/src/vector-search.ts
CHANGED
|
@@ -62,7 +62,7 @@ function raptorSearchHits(
|
|
|
62
62
|
const stateDir = store.stateDir;
|
|
63
63
|
const cfg = store.cfg;
|
|
64
64
|
const embedder = store.embedder;
|
|
65
|
-
const record = store.record;
|
|
65
|
+
const record = store.record.bind(store);
|
|
66
66
|
|
|
67
67
|
// S25 gate (a): honor shadow mode at SERVE time. When RAPTOR_SHADOW_MODE=true,
|
|
68
68
|
// the tree is built + persisted but NOT merged into recall (transition/eval).
|