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.
@@ -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.1",
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",
@@ -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 })
@@ -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).