@zosmaai/pi-llm-wiki 0.10.3 → 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
|
|
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
|
-
|
|
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
|
-
|
|
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,13 +348,19 @@ export function registerObservationReminder(
|
|
|
348
348
|
reminderState.observeDoneThisSession = false;
|
|
349
349
|
});
|
|
350
350
|
|
|
351
|
-
// After compaction, reset
|
|
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
|
-
pi.on("agent_end", async (
|
|
358
|
+
pi.on("agent_end", async (event, _ctx) => {
|
|
359
|
+
// Skip reminder on retries — willRetry means pi will re-run the agent,
|
|
360
|
+
// and queuing another reminder would duplicate them (issue: connection
|
|
361
|
+
// errors cause multiple retries, each firing agent_end).
|
|
362
|
+
if ("willRetry" in event && (event as { willRetry?: boolean }).willRetry) return;
|
|
363
|
+
|
|
358
364
|
turnsSinceLastReminder++;
|
|
359
365
|
if (turnsSinceLastReminder < REMINDER_INTERVAL) return;
|
|
360
366
|
if (reminderState.observeDoneThisSession) return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zosmaai/pi-llm-wiki",
|
|
3
|
-
"version": "0.10.
|
|
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",
|