@zosmaai/pi-llm-wiki 0.12.0 → 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.
Files changed (49) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/README.md +16 -8
  3. package/dist/extensions/llm-wiki/lib/bootstrap.js +2 -0
  4. package/dist/extensions/llm-wiki/lib/indexing.js +24 -1
  5. package/dist/extensions/llm-wiki/lib/ingest-worker.js +3 -1
  6. package/dist/extensions/llm-wiki/lib/knowledge-document.js +11 -2
  7. package/dist/extensions/llm-wiki/lib/knowledge-links.js +41 -6
  8. package/dist/extensions/llm-wiki/lib/model-command.js +45 -8
  9. package/dist/extensions/llm-wiki/lib/qmd-indexing.js +1024 -0
  10. package/dist/extensions/llm-wiki/lib/qmd-mirror.js +418 -0
  11. package/dist/extensions/llm-wiki/lib/qmd-store.js +112 -0
  12. package/dist/extensions/llm-wiki/lib/recall.js +77 -3
  13. package/dist/extensions/llm-wiki/lib/runtime.js +25 -1
  14. package/dist/extensions/llm-wiki/lib/subagent.js +47 -7
  15. package/dist/extensions/llm-wiki/lib/tools.js +165 -5
  16. package/dist/extensions/llm-wiki/lib/utils.js +16 -2
  17. package/dist/extensions/llm-wiki/lib/wiki-service.js +104 -5
  18. package/dist/mcp/index.js +66 -2
  19. package/dist/mcp/operations.js +26 -2
  20. package/docs/api.md +43 -1
  21. package/docs/architecture.md +28 -0
  22. package/docs/commands.md +1 -0
  23. package/docs/qmd-compatibility.md +47 -0
  24. package/docs/retrieval-benchmark.md +47 -0
  25. package/docs/superpowers/benchmarks/phase-1-current-baseline.json +53 -0
  26. package/docs/superpowers/plans/2026-08-09-qmd-retrieval-phase-2-remediation.md +549 -0
  27. package/docs/superpowers/plans/2026-08-09-qmd-retrieval-phase-2-validated-indexing.md +1493 -0
  28. package/docs/superpowers/plans/2026-08-11-qmd-retrieval-phase-3-retrieval-modes-and-recall-cutover.md +678 -0
  29. package/docs/superpowers/plans/2026-09-05-wikilink-alias-pipe-table-only.md +257 -0
  30. package/extensions/llm-wiki/index.ts +14 -1
  31. package/extensions/llm-wiki/lib/bootstrap.ts +2 -0
  32. package/extensions/llm-wiki/lib/indexing.ts +24 -1
  33. package/extensions/llm-wiki/lib/ingest-worker.ts +10 -2
  34. package/extensions/llm-wiki/lib/knowledge-document.ts +20 -3
  35. package/extensions/llm-wiki/lib/knowledge-links.ts +39 -7
  36. package/extensions/llm-wiki/lib/model-command.ts +57 -12
  37. package/extensions/llm-wiki/lib/qmd-indexing.ts +1304 -0
  38. package/extensions/llm-wiki/lib/qmd-mirror.ts +496 -0
  39. package/extensions/llm-wiki/lib/qmd-store.ts +222 -0
  40. package/extensions/llm-wiki/lib/recall.ts +77 -3
  41. package/extensions/llm-wiki/lib/runtime.ts +57 -5
  42. package/extensions/llm-wiki/lib/subagent.ts +73 -10
  43. package/extensions/llm-wiki/lib/tools.ts +188 -4
  44. package/extensions/llm-wiki/lib/utils.ts +21 -2
  45. package/extensions/llm-wiki/lib/wiki-service.ts +160 -4
  46. package/mcp/index.ts +78 -1
  47. package/mcp/operations.ts +41 -2
  48. package/package.json +9 -6
  49. 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
- terms.push(...latinRuns);
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
- return haystack.includes(term) || compactText(haystack).includes(compactText(term));
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
- | { ok: true; model: unknown; apiKey: string; headers?: Record<string, string> }
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
- model: unknown,
41
- ): Promise<{ ok: boolean; apiKey?: string; headers?: Record<string, string> }>;
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
- return { ok: true, model, apiKey: auth.apiKey ?? "", headers: auth.headers };
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
- agentLoop,
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 `agentLoop` so background tasks (ingest synthesis, topic inference,
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 and drains its event stream.
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?: Record<string, string>;
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 { model, apiKey, headers, systemPrompt, userPrompt, tools, maxTokens, signal } = args;
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
- const stream = agentLoop(prompts, context, config, signal);
78
- for await (const _event of stream) {
79
- // Drain events; tool `execute` callbacks collect results caller-side.
80
- }
81
- await stream.result();
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
  }