@zosmaai/pi-llm-wiki 0.10.4 → 0.10.5

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.
@@ -285,14 +285,94 @@ function embeddingsRequestPath(basePath: string): string {
285
285
  }
286
286
 
287
287
  interface EmbeddingApiResponse {
288
- data?: Array<{ index: number; embedding: number[] }>;
288
+ data?: Array<{ index?: number; embedding: number[] }>;
289
289
  error?: { message?: string };
290
+ /** Some OpenAI-compatible gateways (e.g. GSA USAi / Vertex) nest errors here. */
291
+ detail?: unknown;
292
+ }
293
+
294
+ /**
295
+ * Max inputs per embeddings request. Providers cap batch size server-side
296
+ * (OpenAI 2048, Google Vertex `text-embedding-*` 250, Cohere v3 96); we chunk
297
+ * under the smallest common limit so a reindex of hundreds of pages never
298
+ * overruns the cap. Exported so tests and callers can reference the default.
299
+ */
300
+ export const DEFAULT_MAX_EMBED_BATCH = 96;
301
+
302
+ /**
303
+ * Approx per-request character budget. Providers also cap *total tokens* per
304
+ * request (Google Vertex `text-embedding-*` = 20,000 tokens). Tokenizing here
305
+ * would add a dependency, so we approximate with a conservative char budget
306
+ * (~2.8 chars/token observed for wiki prose, with headroom) — the request
307
+ * stays well under the token cap without counting tokens.
308
+ */
309
+ export const DEFAULT_MAX_EMBED_BATCH_CHARS = 45_000;
310
+
311
+ /**
312
+ * Split `texts` into batches that stay under BOTH a count cap and a total
313
+ * char budget. A single oversized text is emitted as its own batch rather than
314
+ * dropped, so every input is always embedded. Pure — unit-testable with no
315
+ * network.
316
+ */
317
+ export function chunkByBudget(texts: string[], maxCount: number, maxChars: number): string[][] {
318
+ const batches: string[][] = [];
319
+ let batch: string[] = [];
320
+ let chars = 0;
321
+ for (const text of texts) {
322
+ // Close the current batch before adding a text that would exceed either
323
+ // cap — but never emit an empty batch (an oversized text stands alone).
324
+ if (batch.length > 0 && (batch.length >= maxCount || chars + text.length > maxChars)) {
325
+ batches.push(batch);
326
+ batch = [];
327
+ chars = 0;
328
+ }
329
+ batch.push(text);
330
+ chars += text.length;
331
+ }
332
+ if (batch.length > 0) batches.push(batch);
333
+ return batches;
334
+ }
335
+
336
+ /**
337
+ * Parse an OpenAI-compatible embeddings response into row-ordered vectors,
338
+ * throwing on any error shape so failures are never silently stored as empty
339
+ * vectors. Handles gateways that report errors via a nested `detail` field
340
+ * (GSA USAi / Vertex) or via HTTP status alone, and providers that omit the
341
+ * per-row `index` (returning rows in request order). Pure — unit-testable.
342
+ */
343
+ export function parseEmbeddingResponse(
344
+ status: number,
345
+ body: EmbeddingApiResponse,
346
+ expectedCount: number,
347
+ ): number[][] {
348
+ if (body.error || body.detail !== undefined || status < 200 || status >= 300) {
349
+ const message =
350
+ body.error?.message ??
351
+ (typeof body.detail === "string"
352
+ ? body.detail
353
+ : body.detail !== undefined
354
+ ? JSON.stringify(body.detail)
355
+ : `HTTP ${status}`);
356
+ throw new Error(`embedding API error: ${message}`);
357
+ }
358
+ const rows = body.data ?? [];
359
+ if (rows.length !== expectedCount) {
360
+ throw new Error(`embedding API returned ${rows.length} vectors for ${expectedCount} inputs`);
361
+ }
362
+ const ordered = rows.every((r) => typeof r.index === "number")
363
+ ? [...rows].sort((a, b) => (a.index as number) - (b.index as number))
364
+ : rows;
365
+ return ordered.map((r) => r.embedding);
290
366
  }
291
367
 
292
368
  /**
293
369
  * Create an `EmbedFn` backed by an OpenAI-compatible `/v1/embeddings`
294
370
  * endpoint. Uses node's http/https directly (no SDK) so it works against
295
371
  * OpenAI, Azure (with an api-key header), or any compatible gateway.
372
+ *
373
+ * Inputs are split into batches under both the instance-count and total-char
374
+ * caps (see `chunkByBudget`) and re-joined in request order, so provider batch
375
+ * limits never truncate a reindex. Any request error is thrown, not swallowed.
296
376
  */
297
377
  export function createOpenAIEmbedFn(cfg: {
298
378
  apiKey: string;
@@ -306,7 +386,7 @@ export function createOpenAIEmbedFn(cfg: {
306
386
  const useHttp = parsed.protocol === "http:";
307
387
  const port = parsed.port ? Number(parsed.port) : undefined;
308
388
 
309
- return (texts) =>
389
+ const embedBatch = (texts: string[]): Promise<number[][]> =>
310
390
  new Promise<number[][]>((resolve, reject) => {
311
391
  if (texts.length === 0) {
312
392
  resolve([]);
@@ -333,17 +413,17 @@ export function createOpenAIEmbedFn(cfg: {
333
413
  data += chunk.toString();
334
414
  });
335
415
  res.on("end", () => {
416
+ let parsedBody: EmbeddingApiResponse;
336
417
  try {
337
- const parsedBody = JSON.parse(data) as EmbeddingApiResponse;
338
- if (parsedBody.error) {
339
- reject(new Error(`embedding API error: ${parsedBody.error.message ?? "unknown"}`));
340
- return;
341
- }
342
- const rows = parsedBody.data ?? [];
343
- const sorted = [...rows].sort((a, b) => a.index - b.index);
344
- resolve(sorted.map((d) => d.embedding));
418
+ parsedBody = JSON.parse(data) as EmbeddingApiResponse;
345
419
  } catch (err) {
346
420
  reject(new Error(`failed to parse embedding response: ${(err as Error).message}`));
421
+ return;
422
+ }
423
+ try {
424
+ resolve(parseEmbeddingResponse(res.statusCode ?? 0, parsedBody, texts.length));
425
+ } catch (err) {
426
+ reject(err as Error);
347
427
  }
348
428
  });
349
429
  },
@@ -352,6 +432,18 @@ export function createOpenAIEmbedFn(cfg: {
352
432
  req.write(body);
353
433
  req.end();
354
434
  });
435
+
436
+ return async (texts) => {
437
+ const out: number[][] = [];
438
+ for (const batch of chunkByBudget(
439
+ texts,
440
+ DEFAULT_MAX_EMBED_BATCH,
441
+ DEFAULT_MAX_EMBED_BATCH_CHARS,
442
+ )) {
443
+ out.push(...(await embedBatch(batch)));
444
+ }
445
+ return out;
446
+ };
355
447
  }
356
448
 
357
449
  /**
@@ -348,10 +348,11 @@ export function registerObservationReminder(
348
348
  reminderState.observeDoneThisSession = false;
349
349
  });
350
350
 
351
- // After compaction, reset the reminder state so reminders resume
351
+ // After compaction, reset turn counter so reminders resume
352
+ // BUT preserve observeDoneThisSession — if the model already called
353
+ // wiki_observe this session, compaction should not resurrect the nag.
352
354
  pi.on("session_compact", async () => {
353
355
  turnsSinceLastReminder = 0;
354
- reminderState.observeDoneThisSession = false;
355
356
  });
356
357
 
357
358
  pi.on("agent_end", async (event, _ctx) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zosmaai/pi-llm-wiki",
3
- "version": "0.10.4",
3
+ "version": "0.10.5",
4
4
  "description": "Self-maintaining LLM Wiki for Pi — Karpathy-pattern knowledge base with immutable source capture, automated ingestion, search, linting, and Obsidian-compatible vault. auto-updating personal & company wiki.",
5
5
  "keywords": [
6
6
  "pi",