@zosmaai/pi-llm-wiki 0.12.1 → 0.12.2
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/CHANGELOG.md +1 -0
- package/README.md +16 -8
- package/dist/extensions/llm-wiki/lib/bootstrap.js +2 -0
- package/dist/extensions/llm-wiki/lib/indexing.js +24 -1
- package/dist/extensions/llm-wiki/lib/ingest-worker.js +3 -1
- package/dist/extensions/llm-wiki/lib/knowledge-links.js +41 -6
- package/dist/extensions/llm-wiki/lib/model-command.js +45 -8
- package/dist/extensions/llm-wiki/lib/qmd-indexing.js +1024 -0
- package/dist/extensions/llm-wiki/lib/qmd-mirror.js +418 -0
- package/dist/extensions/llm-wiki/lib/qmd-store.js +112 -0
- package/dist/extensions/llm-wiki/lib/recall.js +77 -3
- package/dist/extensions/llm-wiki/lib/runtime.js +25 -1
- package/dist/extensions/llm-wiki/lib/subagent.js +47 -7
- package/dist/extensions/llm-wiki/lib/tools.js +165 -5
- package/dist/extensions/llm-wiki/lib/utils.js +16 -2
- package/dist/extensions/llm-wiki/lib/wiki-service.js +104 -5
- package/dist/mcp/index.js +66 -2
- package/dist/mcp/operations.js +26 -2
- package/docs/api.md +43 -1
- package/docs/architecture.md +28 -0
- package/docs/commands.md +1 -0
- package/docs/qmd-compatibility.md +47 -0
- package/docs/retrieval-benchmark.md +47 -0
- package/docs/superpowers/benchmarks/phase-1-current-baseline.json +53 -0
- package/docs/superpowers/plans/2026-08-09-qmd-retrieval-phase-2-remediation.md +549 -0
- package/docs/superpowers/plans/2026-08-09-qmd-retrieval-phase-2-validated-indexing.md +1493 -0
- package/docs/superpowers/plans/2026-08-11-qmd-retrieval-phase-3-retrieval-modes-and-recall-cutover.md +678 -0
- package/docs/superpowers/plans/2026-09-05-wikilink-alias-pipe-table-only.md +257 -0
- package/extensions/llm-wiki/index.ts +14 -1
- package/extensions/llm-wiki/lib/bootstrap.ts +2 -0
- package/extensions/llm-wiki/lib/indexing.ts +24 -1
- package/extensions/llm-wiki/lib/ingest-worker.ts +10 -2
- package/extensions/llm-wiki/lib/knowledge-document.ts +8 -1
- package/extensions/llm-wiki/lib/knowledge-links.ts +39 -7
- package/extensions/llm-wiki/lib/model-command.ts +57 -12
- package/extensions/llm-wiki/lib/qmd-indexing.ts +1304 -0
- package/extensions/llm-wiki/lib/qmd-mirror.ts +496 -0
- package/extensions/llm-wiki/lib/qmd-store.ts +222 -0
- package/extensions/llm-wiki/lib/recall.ts +77 -3
- package/extensions/llm-wiki/lib/runtime.ts +57 -5
- package/extensions/llm-wiki/lib/subagent.ts +73 -10
- package/extensions/llm-wiki/lib/tools.ts +188 -4
- package/extensions/llm-wiki/lib/utils.ts +21 -2
- package/extensions/llm-wiki/lib/wiki-service.ts +160 -4
- package/mcp/index.ts +78 -1
- package/mcp/operations.ts +41 -2
- package/package.json +9 -6
- package/skills/llm-wiki/SKILL.md +7 -1
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { createStore, type HybridQueryResult, type QMDStore, type SearchResult } from "@tobilu/qmd";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Package-private normalized adapter over the pinned @tobilu/qmd SDK.
|
|
6
|
+
*
|
|
7
|
+
* This is the ONLY production module allowed to import @tobilu/qmd. It hides
|
|
8
|
+
* SDK-specific types, collection config, model identity, and close behavior so
|
|
9
|
+
* the rest of the extension never touches QMD internals or tables directly.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const QMD_PACKAGE_VERSION = "2.5.3";
|
|
13
|
+
export const QMD_DEFAULT_MODELS = {
|
|
14
|
+
embed: "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf",
|
|
15
|
+
generate: "hf:tobil/qmd-query-expansion-1.7B-gguf/qmd-query-expansion-1.7B-q4_k_m.gguf",
|
|
16
|
+
rerank: "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf",
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
export interface QmdResolvedModels {
|
|
20
|
+
embed: string;
|
|
21
|
+
generate: string;
|
|
22
|
+
rerank: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface QmdStoreUpdateResult {
|
|
26
|
+
collections: number;
|
|
27
|
+
indexed: number;
|
|
28
|
+
updated: number;
|
|
29
|
+
unchanged: number;
|
|
30
|
+
removed: number;
|
|
31
|
+
needsEmbedding: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface QmdStoreEmbedResult {
|
|
35
|
+
docsProcessed: number;
|
|
36
|
+
chunksEmbedded: number;
|
|
37
|
+
errors: number;
|
|
38
|
+
durationMs: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface QmdStoreStatus {
|
|
42
|
+
totalDocuments: number;
|
|
43
|
+
needsEmbedding: number;
|
|
44
|
+
hasVectorIndex: boolean;
|
|
45
|
+
canonicalDocuments: number;
|
|
46
|
+
evidenceDocuments: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface QmdIndexStore {
|
|
50
|
+
update(
|
|
51
|
+
onProgress?: (progress: {
|
|
52
|
+
collection: string;
|
|
53
|
+
file: string;
|
|
54
|
+
current: number;
|
|
55
|
+
total: number;
|
|
56
|
+
}) => void,
|
|
57
|
+
): Promise<QmdStoreUpdateResult>;
|
|
58
|
+
embed(options: {
|
|
59
|
+
force: boolean;
|
|
60
|
+
onProgress?: (progress: {
|
|
61
|
+
chunksEmbedded: number;
|
|
62
|
+
totalChunks: number;
|
|
63
|
+
errors: number;
|
|
64
|
+
}) => void;
|
|
65
|
+
}): Promise<QmdStoreEmbedResult>;
|
|
66
|
+
status(): Promise<QmdStoreStatus>;
|
|
67
|
+
/** lexical: store.searchLex(query, { limit }) — BM25 only, no model load. */
|
|
68
|
+
searchLex(query: string, limit?: number): Promise<QmdSearchHit[]>;
|
|
69
|
+
/** hybrid + adaptive-initial: typed lex/vec queries, NO LLM expansion, NO rerank. */
|
|
70
|
+
searchTyped(query: string, limit?: number): Promise<QmdSearchHit[]>;
|
|
71
|
+
/** adaptive-uncertain + quality: plain query, LLM expansion + rerank, with intent. */
|
|
72
|
+
searchExpanded(
|
|
73
|
+
query: string,
|
|
74
|
+
intent: string | undefined,
|
|
75
|
+
limit?: number,
|
|
76
|
+
): Promise<QmdSearchHit[]>;
|
|
77
|
+
close(): Promise<void>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Normalized, SDK-free retrieval hit. `score` is 0..1 relative to this result
|
|
82
|
+
* list's max (within-store confidence only — never comparable across stores).
|
|
83
|
+
*/
|
|
84
|
+
export interface QmdSearchHit {
|
|
85
|
+
/** Mirror collection the hit came from ("canonical" | "evidence"). */
|
|
86
|
+
collection: "canonical" | "evidence";
|
|
87
|
+
/** Mirror-relative file, e.g. "qmd://canonical/concepts/rag.md". */
|
|
88
|
+
file: string;
|
|
89
|
+
title: string;
|
|
90
|
+
/** 0..1, normalized to this result list's max. Within-store confidence only. */
|
|
91
|
+
score: number;
|
|
92
|
+
source: "fts" | "vec";
|
|
93
|
+
/** Best chunk body when the SDK returned one (hybrid results). */
|
|
94
|
+
body?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Mirror collection a hit came from: the SDK reports virtual paths
|
|
99
|
+
* "qmd://<collection>/<path>.md", and the mirror layout is
|
|
100
|
+
* ".../documents/<role>/<pageId>.md". Either form resolves the role.
|
|
101
|
+
*/
|
|
102
|
+
function roleFromFile(file: string): QmdSearchHit["collection"] {
|
|
103
|
+
return /(?:^|\/)documents\/canonical\/|^qmd:\/\/canonical\//.test(file)
|
|
104
|
+
? "canonical"
|
|
105
|
+
: "evidence";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
type RawHit = Omit<QmdSearchHit, "score"> & { raw: number };
|
|
109
|
+
|
|
110
|
+
/** Normalize raw within-store scores to 0..1 against this list's max. */
|
|
111
|
+
function withNormalizedScores(hits: RawHit[]): QmdSearchHit[] {
|
|
112
|
+
const max = Math.max(...hits.map((h) => h.raw), 1e-9);
|
|
113
|
+
return hits.map(({ raw, ...rest }) => ({ ...rest, score: raw / max }));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const mapLexHit = (r: SearchResult): RawHit => ({
|
|
117
|
+
collection: roleFromFile(r.filepath),
|
|
118
|
+
file: r.filepath,
|
|
119
|
+
title: r.title,
|
|
120
|
+
source: r.source,
|
|
121
|
+
raw: r.score,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const mapHybridHit = (r: HybridQueryResult): RawHit => ({
|
|
125
|
+
collection: roleFromFile(r.file),
|
|
126
|
+
file: r.file,
|
|
127
|
+
title: r.title,
|
|
128
|
+
source: "fts" as const,
|
|
129
|
+
body: r.bestChunk,
|
|
130
|
+
raw: r.score,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
export type QmdStoreFactory = (input: {
|
|
134
|
+
dbPath: string;
|
|
135
|
+
documentsPath: string;
|
|
136
|
+
}) => Promise<QmdIndexStore>;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Open a QMD index store over the mirror documents directory, with two
|
|
140
|
+
* non-overlapping collections (canonical and evidence).
|
|
141
|
+
*/
|
|
142
|
+
export async function openQmdIndexStore(input: {
|
|
143
|
+
dbPath: string;
|
|
144
|
+
documentsPath: string;
|
|
145
|
+
}): Promise<QmdIndexStore> {
|
|
146
|
+
const store: QMDStore = await createStore({
|
|
147
|
+
dbPath: input.dbPath,
|
|
148
|
+
config: {
|
|
149
|
+
global_context: "Validated LLM Wiki knowledge",
|
|
150
|
+
collections: {
|
|
151
|
+
canonical: {
|
|
152
|
+
path: join(input.documentsPath, "canonical"),
|
|
153
|
+
pattern: "**/*.md",
|
|
154
|
+
context: { "/": "Reusable conclusions, entities, requirements, and procedures" },
|
|
155
|
+
},
|
|
156
|
+
evidence: {
|
|
157
|
+
path: join(input.documentsPath, "evidence"),
|
|
158
|
+
pattern: "**/*.md",
|
|
159
|
+
context: { "/": "Source evidence, observations, trajectories, and unpromoted notes" },
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
update: (onProgress) => store.update({ onProgress }),
|
|
167
|
+
embed: ({ force, onProgress }) => store.embed({ force, chunkStrategy: "regex", onProgress }),
|
|
168
|
+
status: async () => {
|
|
169
|
+
const status = await store.getStatus();
|
|
170
|
+
const counts = Object.fromEntries(
|
|
171
|
+
status.collections.map((collection) => [collection.name, collection.documents]),
|
|
172
|
+
);
|
|
173
|
+
return {
|
|
174
|
+
totalDocuments: status.totalDocuments,
|
|
175
|
+
needsEmbedding: status.needsEmbedding,
|
|
176
|
+
hasVectorIndex: status.hasVectorIndex,
|
|
177
|
+
canonicalDocuments: counts.canonical ?? 0,
|
|
178
|
+
evidenceDocuments: counts.evidence ?? 0,
|
|
179
|
+
};
|
|
180
|
+
},
|
|
181
|
+
close: () => store.close(),
|
|
182
|
+
searchLex: async (query, limit = 40) =>
|
|
183
|
+
withNormalizedScores((await store.searchLex(query, { limit })).map(mapLexHit)),
|
|
184
|
+
searchTyped: async (query, limit = 10) =>
|
|
185
|
+
withNormalizedScores(
|
|
186
|
+
(
|
|
187
|
+
await store.search({
|
|
188
|
+
queries: [
|
|
189
|
+
{ type: "lex", query },
|
|
190
|
+
{ type: "vec", query },
|
|
191
|
+
],
|
|
192
|
+
rerank: false,
|
|
193
|
+
candidateLimit: 40,
|
|
194
|
+
limit,
|
|
195
|
+
explain: true,
|
|
196
|
+
})
|
|
197
|
+
).map(mapHybridHit),
|
|
198
|
+
),
|
|
199
|
+
searchExpanded: async (query, intent, limit = 10) =>
|
|
200
|
+
withNormalizedScores(
|
|
201
|
+
(
|
|
202
|
+
await store.search({
|
|
203
|
+
query,
|
|
204
|
+
intent,
|
|
205
|
+
rerank: true,
|
|
206
|
+
candidateLimit: 40,
|
|
207
|
+
limit,
|
|
208
|
+
explain: true,
|
|
209
|
+
})
|
|
210
|
+
).map(mapHybridHit),
|
|
211
|
+
),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Resolve model identities from env, defaulting to pinned models. No downloads. */
|
|
216
|
+
export function resolveQmdModels(env: NodeJS.ProcessEnv = process.env): QmdResolvedModels {
|
|
217
|
+
return {
|
|
218
|
+
embed: env.QMD_EMBED_MODEL?.trim() || QMD_DEFAULT_MODELS.embed,
|
|
219
|
+
generate: env.QMD_GENERATE_MODEL?.trim() || QMD_DEFAULT_MODELS.generate,
|
|
220
|
+
rerank: env.QMD_RERANK_MODEL?.trim() || QMD_DEFAULT_MODELS.rerank,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
@@ -148,11 +148,13 @@ function queryTerms(query: string): string[] {
|
|
|
148
148
|
if (compact && compact !== normalized) terms.push(compact);
|
|
149
149
|
|
|
150
150
|
for (const part of normalized.split(/\s+/)) {
|
|
151
|
-
if (part.length >= 2) terms.push(part);
|
|
151
|
+
if (part.length >= 2 && !STOPWORDS.has(part)) terms.push(part);
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
const latinRuns = normalized.match(/[a-z0-9]{2,}/g) ?? [];
|
|
155
|
-
|
|
155
|
+
for (const run of latinRuns) {
|
|
156
|
+
if (!STOPWORDS.has(run)) terms.push(run);
|
|
157
|
+
}
|
|
156
158
|
|
|
157
159
|
const cjkRuns =
|
|
158
160
|
normalized.match(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]+/gu) ?? [];
|
|
@@ -168,9 +170,36 @@ function queryTerms(query: string): string[] {
|
|
|
168
170
|
return unique(terms).slice(0, 30);
|
|
169
171
|
}
|
|
170
172
|
|
|
173
|
+
/** Matches any CJK (Han / Hiragana / Katakana) character. */
|
|
174
|
+
const CJK_RE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Whether `haystack` contains `term`.
|
|
178
|
+
*
|
|
179
|
+
* Root-cause fix for issue #223. Previously this was a raw substring
|
|
180
|
+
* containment, so a 2-char query token like "to" or "so" matched thousands of
|
|
181
|
+
* unrelated words ("to" ⊂ "history"/"story"; "so" ⊂ "person"/"wilson"), and
|
|
182
|
+
* weighted field stacking pushed that junk past the auto-injection gate.
|
|
183
|
+
*
|
|
184
|
+
* Now:
|
|
185
|
+
* - CJK terms (Han/Kana) have no whitespace word boundaries, so they still
|
|
186
|
+
* match by compact substring — a CJK bigram matches inside a longer glued
|
|
187
|
+
* CJK run, preserving the multilingual recall the vaults depend on.
|
|
188
|
+
* - Latin/ASCII terms require a WHOLE-WORD match: a spaceless haystack is a
|
|
189
|
+
* single word (term must equal it), and a spaced haystack must contain the
|
|
190
|
+
* term as a whole whitespace-delimited token. "to" still matches the word
|
|
191
|
+
* "to" and "Go" still matches "Go", but no longer "history"/"story"/"person".
|
|
192
|
+
*/
|
|
171
193
|
function includesTerm(haystack: string, term: string): boolean {
|
|
172
194
|
if (!haystack || !term) return false;
|
|
173
|
-
|
|
195
|
+
const termCompact = compactText(term);
|
|
196
|
+
if (CJK_RE.test(termCompact)) {
|
|
197
|
+
return compactText(haystack).includes(termCompact);
|
|
198
|
+
}
|
|
199
|
+
if (!/\s/.test(haystack)) {
|
|
200
|
+
return compactText(haystack) === termCompact;
|
|
201
|
+
}
|
|
202
|
+
return haystack.split(/\s+/).some((t) => t === term || t === termCompact);
|
|
174
203
|
}
|
|
175
204
|
|
|
176
205
|
function scoreField(value: unknown, terms: string[], weight: number): number {
|
|
@@ -238,6 +267,51 @@ const STOPWORDS = new Set([
|
|
|
238
267
|
"type",
|
|
239
268
|
"used",
|
|
240
269
|
"using",
|
|
270
|
+
// Common English function words (issue #223). These must not become scoring
|
|
271
|
+
// terms, or a natural-language prompt full of "to"/"so"/"the"/"did" pushes
|
|
272
|
+
// unrelated pages past the auto-injection gate. 2-letter acronyms that are
|
|
273
|
+
// NOT stopwords ("go", "pi", "sso") still match, as intended.
|
|
274
|
+
"to",
|
|
275
|
+
"so",
|
|
276
|
+
"did",
|
|
277
|
+
"do",
|
|
278
|
+
"does",
|
|
279
|
+
"and",
|
|
280
|
+
"or",
|
|
281
|
+
"but",
|
|
282
|
+
"a",
|
|
283
|
+
"an",
|
|
284
|
+
"i",
|
|
285
|
+
"it",
|
|
286
|
+
"its",
|
|
287
|
+
"is",
|
|
288
|
+
"are",
|
|
289
|
+
"was",
|
|
290
|
+
"be",
|
|
291
|
+
"am",
|
|
292
|
+
"for",
|
|
293
|
+
"on",
|
|
294
|
+
"at",
|
|
295
|
+
"by",
|
|
296
|
+
"as",
|
|
297
|
+
"if",
|
|
298
|
+
"in",
|
|
299
|
+
"of",
|
|
300
|
+
"up",
|
|
301
|
+
"out",
|
|
302
|
+
"not",
|
|
303
|
+
"no",
|
|
304
|
+
"can",
|
|
305
|
+
"has",
|
|
306
|
+
"had",
|
|
307
|
+
"me",
|
|
308
|
+
"my",
|
|
309
|
+
"you",
|
|
310
|
+
"your",
|
|
311
|
+
"we",
|
|
312
|
+
"our",
|
|
313
|
+
"us",
|
|
314
|
+
"too",
|
|
241
315
|
]);
|
|
242
316
|
|
|
243
317
|
// ─── Chunk-Level Indexing ────────────────────────────
|
|
@@ -25,7 +25,20 @@ import { loadTaskConfig, noticesEnabled, TASK_DEFAULTS, type TaskConfig } from "
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
export type ResolveResult =
|
|
28
|
-
| {
|
|
28
|
+
| {
|
|
29
|
+
ok: true;
|
|
30
|
+
model: unknown;
|
|
31
|
+
apiKey: string;
|
|
32
|
+
headers?: Record<string, string | null>;
|
|
33
|
+
/**
|
|
34
|
+
* Stream function for the model's API (issue #222): the provider's own
|
|
35
|
+
* `streamSimple` when the model belongs to an extension-registered
|
|
36
|
+
* provider whose api pi-ai's default stream path cannot resolve.
|
|
37
|
+
*/
|
|
38
|
+
streamFn?: unknown;
|
|
39
|
+
/** Provider-scoped env from auth resolution (pi >= 0.85). */
|
|
40
|
+
env?: Record<string, string>;
|
|
41
|
+
}
|
|
29
42
|
| { ok: false; reason: string };
|
|
30
43
|
|
|
31
44
|
type NotifyLevel = "info" | "warning" | "error";
|
|
@@ -36,9 +49,23 @@ export interface ResolveCtx {
|
|
|
36
49
|
model: unknown;
|
|
37
50
|
modelRegistry: {
|
|
38
51
|
find(provider: string, id: string): unknown;
|
|
39
|
-
getApiKeyAndHeaders(
|
|
40
|
-
|
|
41
|
-
|
|
52
|
+
getApiKeyAndHeaders(model: unknown): Promise<{
|
|
53
|
+
ok: boolean;
|
|
54
|
+
apiKey?: string;
|
|
55
|
+
headers?: Record<string, string | null>;
|
|
56
|
+
/** Auth-provided endpoint redirect (pi >= 0.85); beats the catalogue baseUrl. */
|
|
57
|
+
baseUrl?: string;
|
|
58
|
+
/** Provider-scoped env values (pi >= 0.85); must reach the stream options. */
|
|
59
|
+
env?: Record<string, string>;
|
|
60
|
+
}>;
|
|
61
|
+
/**
|
|
62
|
+
* Registered extension provider config (issue #222). Optional: pi < 0.85
|
|
63
|
+
* has no such method (it registers provider streamSimples directly into
|
|
64
|
+
* pi-ai's registry instead), in which case the default stream path is used.
|
|
65
|
+
*/
|
|
66
|
+
getRegisteredProviderConfig?(
|
|
67
|
+
provider: string,
|
|
68
|
+
): { api?: string; streamSimple?: unknown } | undefined;
|
|
42
69
|
};
|
|
43
70
|
hasUI: boolean;
|
|
44
71
|
ui?: { notify: Notify };
|
|
@@ -137,7 +164,32 @@ export class Runtime {
|
|
|
137
164
|
const provider = (model as { provider?: string }).provider ?? "unknown";
|
|
138
165
|
return { ok: false, reason: `no API key for provider "${provider}"` };
|
|
139
166
|
}
|
|
140
|
-
|
|
167
|
+
// Extension-registered providers (issue #222): pi-ai's default stream path
|
|
168
|
+
// may not be able to resolve their api (e.g. claude-bridge on pi 0.85+), so
|
|
169
|
+
// surface the provider's own streamSimple for the sub-agent stream. On pi
|
|
170
|
+
// < 0.85 the registry method is absent (?.) and the default path — which
|
|
171
|
+
// already knows extension streamSimples — is used instead.
|
|
172
|
+
const modelProvider = (model as { provider?: string }).provider;
|
|
173
|
+
const registered = modelProvider
|
|
174
|
+
? ctx.modelRegistry.getRegisteredProviderConfig?.(modelProvider)
|
|
175
|
+
: undefined;
|
|
176
|
+
const streamFn =
|
|
177
|
+
registered?.streamSimple && registered.api === (model as { api?: string }).api
|
|
178
|
+
? registered.streamSimple
|
|
179
|
+
: undefined;
|
|
180
|
+
// Auth can redirect the endpoint (e.g. GitHub Copilot business vs
|
|
181
|
+
// individual accounts) and/or carry provider-scoped env values (pi >=
|
|
182
|
+
// 0.85). Streaming against the catalogue values yields 421 Misdirected
|
|
183
|
+
// Request and the synthesis silently produces nothing (issue #222).
|
|
184
|
+
const authedModel = auth.baseUrl ? { ...(model as object), baseUrl: auth.baseUrl } : model;
|
|
185
|
+
return {
|
|
186
|
+
ok: true,
|
|
187
|
+
model: authedModel,
|
|
188
|
+
apiKey: auth.apiKey ?? "",
|
|
189
|
+
headers: auth.headers,
|
|
190
|
+
env: auth.env,
|
|
191
|
+
streamFn,
|
|
192
|
+
};
|
|
141
193
|
}
|
|
142
194
|
|
|
143
195
|
/**
|
|
@@ -2,19 +2,46 @@ import {
|
|
|
2
2
|
type AgentContext,
|
|
3
3
|
type AgentLoopConfig,
|
|
4
4
|
type AgentTool,
|
|
5
|
-
|
|
5
|
+
runAgentLoop,
|
|
6
|
+
type StreamFn,
|
|
6
7
|
} from "@earendil-works/pi-agent-core";
|
|
7
8
|
import type { Api, Message, Model } from "@earendil-works/pi-ai";
|
|
8
9
|
|
|
10
|
+
let cachedDefaultStreamFn: StreamFn | undefined;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The default pi-ai stream function (dispatches to registered API providers).
|
|
14
|
+
* It moved from the package root to the `./compat` subpath in pi-ai 0.85, so
|
|
15
|
+
* it is resolved lazily — a static import of either path breaks the other pi
|
|
16
|
+
* version at load time. Cached after the first resolution.
|
|
17
|
+
*/
|
|
18
|
+
async function resolveDefaultStreamFn(): Promise<StreamFn> {
|
|
19
|
+
if (cachedDefaultStreamFn) return cachedDefaultStreamFn;
|
|
20
|
+
const root = await import("@earendil-works/pi-ai");
|
|
21
|
+
const rootFn = (root as { streamSimple?: StreamFn }).streamSimple;
|
|
22
|
+
if (rootFn) {
|
|
23
|
+
cachedDefaultStreamFn = rootFn;
|
|
24
|
+
return rootFn;
|
|
25
|
+
}
|
|
26
|
+
// pi-ai >= 0.85 exposes streamSimple via the ./compat subpath. The
|
|
27
|
+
// specifier is a variable so static tooling (vite in vitest, jiti in pi)
|
|
28
|
+
// cannot resolve a subpath that does not exist in pi < 0.85; at runtime
|
|
29
|
+
// this branch is only reached when the root import lacks streamSimple.
|
|
30
|
+
const compatSpecifier = "@earendil-works/pi-ai/compat";
|
|
31
|
+
const compat = await import(compatSpecifier);
|
|
32
|
+
cachedDefaultStreamFn = (compat as { streamSimple: StreamFn }).streamSimple;
|
|
33
|
+
return cachedDefaultStreamFn;
|
|
34
|
+
}
|
|
35
|
+
|
|
9
36
|
/**
|
|
10
37
|
* Thin sub-agent runner for the LLM Wiki background lane (issue #64, part of #63).
|
|
11
38
|
*
|
|
12
|
-
* Wraps
|
|
39
|
+
* Wraps the agent loop so background tasks (ingest synthesis, topic inference,
|
|
13
40
|
* etc.) can run a focused, single-purpose agent on a resolved model with its
|
|
14
41
|
* own system prompt and tools — mirroring pi-observational-memory's
|
|
15
42
|
* `runObserver`. The caller drives behavior entirely through `tools`
|
|
16
43
|
* (tool-side effects accumulate results); this wrapper just drives the loop to
|
|
17
|
-
* completion
|
|
44
|
+
* completion.
|
|
18
45
|
*
|
|
19
46
|
* This is infrastructure: it makes no wiki-specific decisions. Concrete
|
|
20
47
|
* background workers (issues #65, #66) supply the prompts and tools.
|
|
@@ -22,7 +49,8 @@ import type { Api, Message, Model } from "@earendil-works/pi-ai";
|
|
|
22
49
|
export interface RunSubAgentArgs<TApi extends Api = Api> {
|
|
23
50
|
model: Model<TApi>;
|
|
24
51
|
apiKey: string;
|
|
25
|
-
headers
|
|
52
|
+
/** Auth-provided request headers; pi >= 0.85 may carry null (unset) values. */
|
|
53
|
+
headers?: Record<string, string | null>;
|
|
26
54
|
/** System prompt that defines the sub-agent's role. */
|
|
27
55
|
systemPrompt: string;
|
|
28
56
|
/** The user-turn instruction/payload to process. */
|
|
@@ -32,6 +60,15 @@ export interface RunSubAgentArgs<TApi extends Api = Api> {
|
|
|
32
60
|
/** Max output tokens per model call. Default 4096. */
|
|
33
61
|
maxTokens?: number;
|
|
34
62
|
signal?: AbortSignal;
|
|
63
|
+
/**
|
|
64
|
+
* Stream function for the model's API (issue #222). Providers registered by
|
|
65
|
+
* extensions through `pi.registerProvider()` may not be resolvable by pi-ai's
|
|
66
|
+
* default stream path; the caller (Runtime.resolveModel) supplies the
|
|
67
|
+
* provider's own `streamSimple` when the model belongs to such a provider.
|
|
68
|
+
*/
|
|
69
|
+
streamFn?: StreamFn;
|
|
70
|
+
/** Provider-scoped env from auth resolution (issue #222; pi >= 0.85). */
|
|
71
|
+
env?: Record<string, string>;
|
|
35
72
|
}
|
|
36
73
|
|
|
37
74
|
/**
|
|
@@ -40,11 +77,26 @@ export interface RunSubAgentArgs<TApi extends Api = Api> {
|
|
|
40
77
|
* Returns nothing useful directly — by design, results are collected by the
|
|
41
78
|
* `tools` the caller passes (their `execute` accumulates into caller-owned
|
|
42
79
|
* state). This keeps the runner generic across every background task type.
|
|
80
|
+
*
|
|
81
|
+
* Rejections from the loop (provider errors, auth failures, a streamFn that
|
|
82
|
+
* throws) reject this promise, so `BackgroundRuntime.launchTask`'s try/catch
|
|
83
|
+
* degrades them to a warning toast.
|
|
43
84
|
*/
|
|
44
85
|
export async function runSubAgent<TApi extends Api = Api>(
|
|
45
86
|
args: RunSubAgentArgs<TApi>,
|
|
46
87
|
): Promise<void> {
|
|
47
|
-
const {
|
|
88
|
+
const {
|
|
89
|
+
model,
|
|
90
|
+
apiKey,
|
|
91
|
+
headers,
|
|
92
|
+
systemPrompt,
|
|
93
|
+
userPrompt,
|
|
94
|
+
tools,
|
|
95
|
+
maxTokens,
|
|
96
|
+
signal,
|
|
97
|
+
streamFn,
|
|
98
|
+
env,
|
|
99
|
+
} = args;
|
|
48
100
|
|
|
49
101
|
const text = userPrompt.trim();
|
|
50
102
|
if (!text) return;
|
|
@@ -72,11 +124,22 @@ export async function runSubAgent<TApi extends Api = Api>(
|
|
|
72
124
|
convertToLlm: (msgs) => msgs as Message[],
|
|
73
125
|
toolExecution: "sequential",
|
|
74
126
|
...(reasoning ? { reasoning: "high" as const } : {}),
|
|
127
|
+
// Provider-scoped env from auth resolution (issue #222). pi-agent-core
|
|
128
|
+
// spreads the config into the stream options, where pi-ai >= 0.85 honors
|
|
129
|
+
// it; the conditional spread keeps this compiling on pi < 0.85.
|
|
130
|
+
...(env ? { env } : {}),
|
|
75
131
|
};
|
|
76
132
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
133
|
+
// Drive the loop directly instead of agentLoop(): agentLoop() wraps the
|
|
134
|
+
// loop in a detached promise (`void runAgentLoop(...).then(...)` with no
|
|
135
|
+
// .catch), so a rejection from the stream path — e.g. "No API provider
|
|
136
|
+
// registered for api: X" for a model from an extension-registered provider
|
|
137
|
+
// — escaped as an uncaughtException and killed the whole pi process while
|
|
138
|
+
// this function's stream drain hung forever (issue #222). runAgentLoop is
|
|
139
|
+
// the same loop with the rejection propagating to THIS promise.
|
|
140
|
+
// pi >= 0.85 requires streamFn explicitly (its internal fallback throws
|
|
141
|
+
// unless the host configured a default), so we always pass one: the
|
|
142
|
+
// provider-specific function when available, else pi-ai's default.
|
|
143
|
+
const activeStreamFn = streamFn ?? (await resolveDefaultStreamFn());
|
|
144
|
+
await runAgentLoop(prompts, context, config, async () => {}, signal, activeStreamFn);
|
|
82
145
|
}
|