@zosmaai/pi-llm-wiki 0.10.4 → 0.10.6
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/README.md
CHANGED
|
@@ -440,6 +440,13 @@ Thanks to everyone who has contributed! This list is regenerated automatically b
|
|
|
440
440
|
<sub><b>Akshay</b></sub>
|
|
441
441
|
</a>
|
|
442
442
|
</td>
|
|
443
|
+
<td align="center">
|
|
444
|
+
<a href="https://github.com/danielnaab">
|
|
445
|
+
<img src="https://avatars.githubusercontent.com/u/136512?v=4" width="64;" alt="danielnaab"/>
|
|
446
|
+
<br />
|
|
447
|
+
<sub><b>Daniel Naab</b></sub>
|
|
448
|
+
</a>
|
|
449
|
+
</td>
|
|
443
450
|
<td align="center">
|
|
444
451
|
<a href="https://github.com/mystery4f">
|
|
445
452
|
<img src="https://avatars.githubusercontent.com/u/40482524?v=4" width="64;" alt="mystery4f"/>
|
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { basename, join } from "node:path";
|
|
3
3
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
4
4
|
import { installGuardrails } from "./lib/guardrails.js";
|
|
5
|
-
import { buildAgentStartInjection } from "./lib/inject.js";
|
|
5
|
+
import { buildAgentStartInjection, normalizeSystemPrompt } from "./lib/inject.js";
|
|
6
6
|
import { registerWikiModelCommand } from "./lib/model-command.js";
|
|
7
7
|
import {
|
|
8
8
|
buildSessionNotice,
|
|
@@ -303,13 +303,12 @@ Then call wiki_bootstrap with the inferred topic and mode to finalize the setup.
|
|
|
303
303
|
|
|
304
304
|
// Split into a cache-stable system prompt (static footer only) and a
|
|
305
305
|
// volatile tail message (issue #92). See lib/inject.ts for the contract.
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
]);
|
|
306
|
+
const priorSystemPrompt = normalizeSystemPrompt(event.systemPrompt);
|
|
307
|
+
const { systemPrompt, message } = buildAgentStartInjection(priorSystemPrompt, [dynamicContext]);
|
|
309
308
|
|
|
310
309
|
// Only claim a systemPrompt change when the footer actually altered the
|
|
311
310
|
// string (a carry-forward turn already carries it, so this no-ops).
|
|
312
|
-
const systemPromptChanged = systemPrompt !==
|
|
311
|
+
const systemPromptChanged = systemPrompt !== priorSystemPrompt;
|
|
313
312
|
if (!systemPromptChanged && !message) return;
|
|
314
313
|
return {
|
|
315
314
|
...(systemPromptChanged ? { systemPrompt } : {}),
|
|
@@ -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
|
/**
|
|
@@ -33,6 +33,14 @@ export function appendWikiStatus(systemPrompt: string): string {
|
|
|
33
33
|
return `${base}\n\n${WIKI_STATUS_BLOCK}`;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** Normalize upstream Pi and OMP system-prompt representations. */
|
|
37
|
+
export function normalizeSystemPrompt(
|
|
38
|
+
systemPrompt: string | readonly string[] | null | undefined,
|
|
39
|
+
): string {
|
|
40
|
+
if (Array.isArray(systemPrompt)) return systemPrompt.join("\n\n");
|
|
41
|
+
return typeof systemPrompt === "string" ? systemPrompt : "";
|
|
42
|
+
}
|
|
43
|
+
|
|
36
44
|
/** customType of the hidden tail message carrying volatile per-turn context. */
|
|
37
45
|
export const WIKI_RECALL_MESSAGE_TYPE = "wiki-recall-context";
|
|
38
46
|
|
|
@@ -69,10 +77,10 @@ export interface AgentStartInjection {
|
|
|
69
77
|
* Pure and side-effect free — see test/agent-start-injection.test.ts.
|
|
70
78
|
*/
|
|
71
79
|
export function buildAgentStartInjection(
|
|
72
|
-
baseSystemPrompt: string,
|
|
80
|
+
baseSystemPrompt: string | readonly string[] | null | undefined,
|
|
73
81
|
dynamicBlocks: Array<string | undefined>,
|
|
74
82
|
): AgentStartInjection {
|
|
75
|
-
const systemPrompt = appendWikiStatus(baseSystemPrompt);
|
|
83
|
+
const systemPrompt = appendWikiStatus(normalizeSystemPrompt(baseSystemPrompt));
|
|
76
84
|
const content = dynamicBlocks
|
|
77
85
|
.map((b) => b?.trim())
|
|
78
86
|
.filter((b): b is string => Boolean(b))
|
|
@@ -348,10 +348,11 @@ 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
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.
|
|
3
|
+
"version": "0.10.6",
|
|
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",
|