@zosmaai/pi-llm-wiki 0.8.2 → 0.9.1

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.
@@ -1,6 +1,8 @@
1
1
  import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
2
2
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
+ import { scheduleReindex } from "./indexing.js";
3
4
  import { rebuildMetadataLight } from "./metadata.js";
5
+ import type { Runtime } from "./runtime.js";
4
6
  import { isProtectedPath, resolveVaultPaths } from "./utils.js";
5
7
 
6
8
  /**
@@ -10,7 +12,7 @@ import { isProtectedPath, resolveVaultPaths } from "./utils.js";
10
12
  let pendingRebuild = false;
11
13
 
12
14
  /** Install guardrails on the extension API. */
13
- export function installGuardrails(pi: ExtensionAPI): void {
15
+ export function installGuardrails(pi: ExtensionAPI, runtime?: Runtime): void {
14
16
  // Block direct edits to raw/ and meta/
15
17
  pi.on("tool_call", async (event) => {
16
18
  if (isToolCallEventType("write", event)) {
@@ -44,13 +46,22 @@ export function installGuardrails(pi: ExtensionAPI): void {
44
46
  }
45
47
  });
46
48
 
47
- // Rebuild metadata at end of turn if wiki was modified
48
- pi.on("turn_end", async (_event) => {
49
+ // Rebuild metadata at end of turn if wiki was modified, then refresh
50
+ // semantic embeddings in the background (#66) so manual page edits get
51
+ // re-embedded. Both are best-effort no-ops when nothing is configured.
52
+ pi.on("turn_end", async (_event, ctx) => {
49
53
  if (pendingRebuild) {
50
54
  pendingRebuild = false;
51
55
  try {
52
56
  const paths = resolveVaultPaths(process.cwd());
53
- rebuildMetadataLight(paths);
57
+ // Manual page edits also rebuild off the critical path. Without a
58
+ // runtime (shouldn't happen in normal wiring) fall back to inline.
59
+ if (runtime) {
60
+ const launchCtx = ctx ? { hasUI: ctx.hasUI, ui: ctx.ui } : { hasUI: false as const };
61
+ scheduleReindex(runtime, launchCtx, paths);
62
+ } else {
63
+ rebuildMetadataLight(paths);
64
+ }
54
65
  } catch {
55
66
  // Silently fail — metadata rebuild is best-effort
56
67
  }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Non-blocking vault (re)indexing.
3
+ *
4
+ * Writing a wiki page (observe / retro / capture / ensure_page) and editing one
5
+ * by hand both require the derived metadata — `meta/registry.json`,
6
+ * `backlinks.json`, `index.md`, `log.md` — to be rebuilt, plus (optionally) the
7
+ * semantic embedding store to be refreshed. That rebuild is O(pages): it
8
+ * rescans every page in the vault. Doing it inline on the tool's / turn's
9
+ * critical path makes every write get slower as the vault grows.
10
+ *
11
+ * `scheduleReindex` moves that work off the caller's stack onto the shared
12
+ * background Runtime (#64) and coalesces a burst of writes into a single pass:
13
+ *
14
+ * - A leading micro-yield guarantees the caller (a tool's `execute`, or the
15
+ * turn_end handler) returns BEFORE the heavy rebuild runs.
16
+ * - A per-vault `dirty` flag + drain loop means writes that land while a pass
17
+ * is in flight are folded into a trailing rebuild instead of being lost —
18
+ * this also covers the async window of the embeddings refresh.
19
+ * - A per-vault `inflight` guard collapses concurrent schedule calls onto the
20
+ * same promise (single-flight), so N writes in a turn cost one rebuild.
21
+ *
22
+ * Errors are isolated by `Runtime.launchTask`; the promise never rejects. The
23
+ * embeddings step is a no-op unless an embedder is configured (#66/#67).
24
+ */
25
+
26
+ import { reindexEmbeddings, resolveEmbedder } from "./embeddings.js";
27
+ import { rebuildMetadataLight } from "./metadata.js";
28
+ import type { LaunchCtx, Runtime } from "./runtime.js";
29
+ import type { VaultPaths } from "./utils.js";
30
+
31
+ /** Promise of the current background pass, keyed by vault root. */
32
+ const inflight = new Map<string, Promise<void>>();
33
+ /** Vault roots with writes awaiting a (re)build. */
34
+ const dirty = new Set<string>();
35
+
36
+ /** Stable single-flight label for a vault's background index pass. */
37
+ export function indexLabel(root: string): string {
38
+ return `index:${root}`;
39
+ }
40
+
41
+ /**
42
+ * Schedule a non-blocking metadata rebuild (+ embeddings refresh) for a vault.
43
+ * Returns the promise of the in-flight pass so callers/tests can await drainage
44
+ * (the agent loop itself never awaits it). Safe to call on every write.
45
+ */
46
+ export function scheduleReindex(
47
+ runtime: Runtime,
48
+ ctx: LaunchCtx,
49
+ paths: VaultPaths,
50
+ ): Promise<void> {
51
+ const root = paths.root;
52
+ dirty.add(root);
53
+
54
+ const active = inflight.get(root);
55
+ if (active) return active;
56
+
57
+ const pass = runtime.launchTask(ctx, indexLabel(root), async () => {
58
+ // Yield once so the caller returns before the O(pages) rebuild runs. This
59
+ // is what makes the surrounding write non-blocking.
60
+ await Promise.resolve();
61
+ try {
62
+ // Drain: keep rebuilding until no new write arrived during the previous
63
+ // pass. The loop re-checks AFTER the awaited embeddings step, so writes
64
+ // that land during embedding are not lost.
65
+ while (dirty.has(root)) {
66
+ dirty.delete(root);
67
+ rebuildMetadataLight(paths);
68
+
69
+ // Refresh embeddings only after metadata is consistent. Stale-aware and
70
+ // a no-op unless an embedder is configured.
71
+ runtime.ensureConfig(root);
72
+ const embedder = resolveEmbedder(runtime.config);
73
+ if (embedder) await reindexEmbeddings(paths, embedder);
74
+ }
75
+ } finally {
76
+ inflight.delete(root);
77
+ }
78
+ });
79
+
80
+ inflight.set(root, pass);
81
+ return pass;
82
+ }
83
+
84
+ /** Test-only: clear coalescing state between cases. */
85
+ export function __resetIndexingState(): void {
86
+ inflight.clear();
87
+ dirty.clear();
88
+ }
@@ -0,0 +1,281 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { AgentTool } from "@mariozechner/pi-agent-core";
4
+ import type { Api, Model } from "@mariozechner/pi-ai";
5
+ import { Type } from "typebox";
6
+ import type { Static } from "typebox";
7
+ import { appendEvent, rebuildMetadataLight } from "./metadata.js";
8
+ import { runSubAgent } from "./subagent.js";
9
+ import { type VaultPaths, fmtDate, slugify } from "./utils.js";
10
+
11
+ /**
12
+ * Background ingest synthesis (issue #65, part of epic #63).
13
+ *
14
+ * Moves the work the main agent used to do during `wiki_ingest` — reading a
15
+ * captured source's extracted text and writing the source page + entity /
16
+ * concept pages — onto a background sub-agent, so capturing/ingesting never
17
+ * stalls the user.
18
+ *
19
+ * Design: the sub-agent produces ONE structured `commit_synthesis` call; the
20
+ * persistence (`commitSynthesis`) is fully deterministic and unit-testable
21
+ * without an LLM. This mirrors pi-observational-memory's single-structured-tool
22
+ * pattern and keeps the file-writing logic verifiable in isolation.
23
+ */
24
+
25
+ // ── structured synthesis schema ───────────────────────────
26
+ export const CommitSynthesisSchema = Type.Object({
27
+ summary: Type.String({
28
+ minLength: 1,
29
+ description: "2-3 paragraph summary of the source's key content.",
30
+ }),
31
+ key_takeaways: Type.Array(Type.String({ minLength: 1 }), {
32
+ description: "The most important points, one per item.",
33
+ }),
34
+ entities: Type.Array(
35
+ Type.Object({
36
+ title: Type.String({
37
+ minLength: 1,
38
+ description: "Entity name (person, org, tool, product).",
39
+ }),
40
+ description: Type.String({ description: "One-line description of the entity." }),
41
+ }),
42
+ { description: "Named entities mentioned in the source." },
43
+ ),
44
+ concepts: Type.Array(
45
+ Type.Object({
46
+ title: Type.String({ minLength: 1, description: "Concept name (idea, pattern, framework)." }),
47
+ definition: Type.String({ description: "One-line definition of the concept." }),
48
+ }),
49
+ { description: "Concepts discussed in the source." },
50
+ ),
51
+ quotes: Type.Optional(
52
+ Type.Array(
53
+ Type.Object({
54
+ text: Type.String({ minLength: 1 }),
55
+ attribution: Type.Optional(Type.String()),
56
+ }),
57
+ { description: "Notable verbatim quotes." },
58
+ ),
59
+ ),
60
+ contradictions: Type.Optional(
61
+ Type.Array(Type.String({ minLength: 1 }), {
62
+ description: "Tensions/contradictions with existing wiki content, if any.",
63
+ }),
64
+ ),
65
+ });
66
+
67
+ export type SynthesisData = Static<typeof CommitSynthesisSchema>;
68
+
69
+ export interface CommitResult {
70
+ sourceId: string;
71
+ sourcePage: string;
72
+ entitiesCreated: string[];
73
+ conceptsCreated: string[];
74
+ entitiesLinked: string[];
75
+ conceptsLinked: string[];
76
+ contradictions: number;
77
+ }
78
+
79
+ // ── deterministic persistence (no LLM) ────────────────────
80
+
81
+ function buildEntityPage(
82
+ title: string,
83
+ description: string,
84
+ date: string,
85
+ sourceId: string,
86
+ ): string {
87
+ const desc = description.trim() || "One-line description.";
88
+ return `---\ntype: entity\ncreated: ${date}\nupdated: ${date}\nsources: [[[sources/${sourceId}]]]\n---\n\n# ${title}\n\n${desc}\n\n## Overview\n\n[Key facts]\n\n## Links\n\n- [[sources/${sourceId}]]\n`;
89
+ }
90
+
91
+ function buildConceptPage(
92
+ title: string,
93
+ definition: string,
94
+ date: string,
95
+ sourceId: string,
96
+ ): string {
97
+ const def = definition.trim() || "One-line definition.";
98
+ return `---\ntype: concept\ncreated: ${date}\nupdated: ${date}\nsources: [[[sources/${sourceId}]]]\n---\n\n# ${title}\n\n${def}\n\n## Definition\n\n[Clear explanation]\n\n## Links\n\n- [[sources/${sourceId}]]\n`;
99
+ }
100
+
101
+ /** Rebuild the source page from synthesis data, marking it ingested. */
102
+ export function buildIngestedSourcePage(
103
+ manifest: Record<string, unknown>,
104
+ data: SynthesisData,
105
+ date: string,
106
+ ): string {
107
+ const id = String(manifest.id);
108
+ const title = String(manifest.title || id);
109
+ const url = manifest.url ? `\n> _Original: [${manifest.url}](${manifest.url})_` : "";
110
+ const format = String(manifest.format || "unknown");
111
+ const captured = String(manifest.captured || date);
112
+
113
+ const takeaways =
114
+ data.key_takeaways.length > 0
115
+ ? data.key_takeaways.map((t) => `- ${t.trim()}`).join("\n")
116
+ : "- [None recorded]";
117
+ const entities =
118
+ data.entities.length > 0
119
+ ? data.entities.map((e) => `- [[${slugify(e.title)}]]`).join("\n")
120
+ : "- [None]";
121
+ const concepts =
122
+ data.concepts.length > 0
123
+ ? data.concepts.map((c) => `- [[${slugify(c.title)}]]`).join("\n")
124
+ : "- [None]";
125
+ const quotes =
126
+ data.quotes && data.quotes.length > 0
127
+ ? data.quotes
128
+ .map((q) => `> ${q.text.trim()}${q.attribution ? ` — ${q.attribution}` : ""}`)
129
+ .join("\n\n")
130
+ : "> [None recorded]";
131
+ const contradictions =
132
+ data.contradictions && data.contradictions.length > 0
133
+ ? `\n## Contradictions\n\n${data.contradictions.map((c) => `⚠️ **Contradiction**: ${c.trim()}`).join("\n")}\n`
134
+ : "";
135
+
136
+ return `---\ntype: source\nformat: ${format}\nsource_id: ${id}\nraw_path: raw/sources/${id}/extracted.md\ncaptured: ${captured}\nstatus: ingested\nupdated: ${date}\n---\n\n# ${title}${url}\n\n## Summary\n\n${data.summary.trim()}\n\n## Key Takeaways\n\n${takeaways}\n\n## Entities Mentioned\n\n${entities}\n\n## Concepts Mentioned\n\n${concepts}\n\n## Notable Quotes\n\n${quotes}\n${contradictions}\n## Source Packet\n\n- **ID:** \`[[sources/${id}]]\`\n- **Extracted:** [raw/sources/${id}/extracted.md](../raw/sources/${id}/extracted.md)\n- **Manifest:** [raw/sources/${id}/manifest.json](../raw/sources/${id}/manifest.json)\n`;
137
+ }
138
+
139
+ /**
140
+ * Persist a synthesis deterministically: rewrite the source page (status →
141
+ * ingested), create missing entity/concept pages (existing pages are linked,
142
+ * never overwritten), and log the event. Pure file I/O — no LLM, no network.
143
+ */
144
+ export function commitSynthesis(
145
+ paths: VaultPaths,
146
+ sourceId: string,
147
+ manifest: Record<string, unknown>,
148
+ data: SynthesisData,
149
+ date: string = fmtDate(),
150
+ ): CommitResult {
151
+ const result: CommitResult = {
152
+ sourceId,
153
+ sourcePage: join(paths.wiki, "sources", `${sourceId}.md`),
154
+ entitiesCreated: [],
155
+ conceptsCreated: [],
156
+ entitiesLinked: [],
157
+ conceptsLinked: [],
158
+ contradictions: data.contradictions?.length ?? 0,
159
+ };
160
+
161
+ // Source page (always rewritten from skeleton → ingested).
162
+ mkdirSync(join(paths.wiki, "sources"), { recursive: true });
163
+ writeFileSync(result.sourcePage, buildIngestedSourcePage(manifest, data, date), "utf-8");
164
+
165
+ // Entity pages — create if absent, link if present.
166
+ mkdirSync(join(paths.wiki, "entities"), { recursive: true });
167
+ for (const e of data.entities) {
168
+ const slug = slugify(e.title);
169
+ if (!slug) continue;
170
+ const pagePath = join(paths.wiki, "entities", `${slug}.md`);
171
+ if (existsSync(pagePath)) {
172
+ result.entitiesLinked.push(slug);
173
+ } else {
174
+ writeFileSync(pagePath, buildEntityPage(e.title, e.description, date, sourceId), "utf-8");
175
+ result.entitiesCreated.push(slug);
176
+ }
177
+ }
178
+
179
+ // Concept pages — create if absent, link if present.
180
+ mkdirSync(join(paths.wiki, "concepts"), { recursive: true });
181
+ for (const c of data.concepts) {
182
+ const slug = slugify(c.title);
183
+ if (!slug) continue;
184
+ const pagePath = join(paths.wiki, "concepts", `${slug}.md`);
185
+ if (existsSync(pagePath)) {
186
+ result.conceptsLinked.push(slug);
187
+ } else {
188
+ writeFileSync(pagePath, buildConceptPage(c.title, c.definition, date, sourceId), "utf-8");
189
+ result.conceptsCreated.push(slug);
190
+ }
191
+ }
192
+
193
+ appendEvent(paths, {
194
+ kind: "ingest",
195
+ source_id: sourceId,
196
+ entities_created: result.entitiesCreated.length,
197
+ concepts_created: result.conceptsCreated.length,
198
+ contradictions: result.contradictions,
199
+ background: true,
200
+ });
201
+
202
+ return result;
203
+ }
204
+
205
+ // ── sub-agent synthesis (LLM) ─────────────────────────────
206
+
207
+ export const INGEST_SYSTEM = `You are the LLM Wiki ingestion synthesizer. You turn a single captured source's extracted text into structured wiki knowledge.
208
+
209
+ Read the source content, then call \`commit_synthesis\` EXACTLY ONCE with:
210
+ - summary: a faithful 2-3 paragraph summary (no fabrication).
211
+ - key_takeaways: the most important points.
212
+ - entities: named people, organizations, tools, products actually mentioned.
213
+ - concepts: ideas, patterns, frameworks actually discussed.
214
+ - quotes: notable verbatim quotes (optional).
215
+ - contradictions: tensions with general knowledge or noted in the text (optional).
216
+
217
+ Rules:
218
+ - Never fabricate. Only include entities/concepts present in the source.
219
+ - Keep descriptions to one line.
220
+ - After calling commit_synthesis once, reply with a one-line confirmation and stop.`;
221
+
222
+ export interface RunIngestSynthesisArgs {
223
+ model: Model<Api>;
224
+ apiKey: string;
225
+ headers?: Record<string, string>;
226
+ paths: VaultPaths;
227
+ sourceId: string;
228
+ manifest: Record<string, unknown>;
229
+ extracted: string;
230
+ /** Cap on extracted chars fed to the model (avoid huge prompts). Default 24k. */
231
+ maxChars?: number;
232
+ signal?: AbortSignal;
233
+ }
234
+
235
+ /**
236
+ * Run the synthesis sub-agent for a single source, then commit + rebuild
237
+ * metadata. Returns the commit result, or undefined if the model produced no
238
+ * synthesis.
239
+ */
240
+ export async function runIngestSynthesis(
241
+ args: RunIngestSynthesisArgs,
242
+ ): Promise<CommitResult | undefined> {
243
+ const { model, apiKey, headers, paths, sourceId, manifest, extracted, maxChars, signal } = args;
244
+ const content = extracted.slice(0, maxChars ?? 24_000);
245
+ if (!content.trim()) return undefined;
246
+
247
+ let committed: CommitResult | undefined;
248
+
249
+ const commitTool: AgentTool<typeof CommitSynthesisSchema> = {
250
+ name: "commit_synthesis",
251
+ label: "Commit synthesis",
252
+ description:
253
+ "Persist the structured synthesis of this source into wiki pages. Call exactly once.",
254
+ parameters: CommitSynthesisSchema,
255
+ execute: async (_id, params) => {
256
+ committed = commitSynthesis(paths, sourceId, manifest, params);
257
+ const ack = `Committed: source page + ${committed.entitiesCreated.length} new entit${
258
+ committed.entitiesCreated.length === 1 ? "y" : "ies"
259
+ }, ${committed.conceptsCreated.length} new concept${
260
+ committed.conceptsCreated.length === 1 ? "" : "s"
261
+ }. Reply with a one-line confirmation and stop.`;
262
+ return { content: [{ type: "text", text: ack }], details: { sourceId } };
263
+ },
264
+ };
265
+
266
+ const title = String(manifest.title || sourceId);
267
+ const userPrompt = `Synthesize this captured source into wiki knowledge by calling commit_synthesis once.\n\nSOURCE: ${title} (${sourceId})\n\nEXTRACTED CONTENT:\n${content}`;
268
+
269
+ await runSubAgent({
270
+ model,
271
+ apiKey,
272
+ headers,
273
+ systemPrompt: INGEST_SYSTEM,
274
+ userPrompt,
275
+ tools: [commitTool as AgentTool],
276
+ signal,
277
+ });
278
+
279
+ if (committed) rebuildMetadataLight(paths);
280
+ return committed;
281
+ }
@@ -0,0 +1,128 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import type { Runtime } from "./runtime.js";
3
+ import { type TaskConfig, parseModelRef, persistTaskModel } from "./task-config.js";
4
+
5
+ /**
6
+ * Model selection surface for the wiki background lane (issue #69, epic #63).
7
+ *
8
+ * The `taskModel` config field (read by `Runtime.resolveModel`) already exists;
9
+ * this module adds the user-facing *surface* to view and set it:
10
+ * - the `/wiki-model` slash command (interactive picker + scriptable arg),
11
+ * - a status-bar label of the active task model,
12
+ * - the per-call override is wired on heavy tools (e.g. `wiki_ingest`).
13
+ *
14
+ * The default is always the session model: when no `taskModel` is configured
15
+ * and no override is passed, background work runs on the current session model.
16
+ */
17
+
18
+ /** A minimal view of a registry model (provider + id, optional display name). */
19
+ interface ModelLike {
20
+ provider: string;
21
+ id: string;
22
+ name?: string;
23
+ }
24
+
25
+ /** Words that clear the override and revert to the session model. */
26
+ const CLEAR_WORDS = new Set(["session", "default", "reset", "clear", "none", "unset"]);
27
+
28
+ /** The status-bar key for the active-model label (so we can update it in place). */
29
+ export const MODEL_STATUS_KEY = "llm-wiki-model";
30
+
31
+ /**
32
+ * Human-readable label for the active background task model. Shows the
33
+ * configured `provider/id` when set, otherwise the session model (with its id
34
+ * when known). Pure — safe to unit test and reuse for the status line.
35
+ */
36
+ export function formatActiveModelLabel(config: TaskConfig, sessionModelId?: string): string {
37
+ if (config.taskModel) return `${config.taskModel.provider}/${config.taskModel.id}`;
38
+ return sessionModelId ? `session model (${sessionModelId})` : "session model";
39
+ }
40
+
41
+ /** "provider/id" ref for a model. */
42
+ function modelRef(m: ModelLike): string {
43
+ return `${m.provider}/${m.id}`;
44
+ }
45
+
46
+ /**
47
+ * Register the `/wiki-model` slash command. Lets the user view the active
48
+ * background task model and choose another (or revert to the session model).
49
+ * The choice is persisted to project settings and applied immediately.
50
+ *
51
+ * /wiki-model → interactive picker (lists available models)
52
+ * /wiki-model provider/id → set directly (scriptable / no UI needed)
53
+ * /wiki-model session|clear → clear the override, use the session model
54
+ */
55
+ export function registerWikiModelCommand(pi: ExtensionAPI, runtime: Runtime): void {
56
+ pi.registerCommand("wiki-model", {
57
+ description:
58
+ "View or set the model used for LLM Wiki background tasks (default: session model)",
59
+ handler: async (args, ctx) => {
60
+ runtime.ensureConfig(ctx.cwd);
61
+ const sessionId = (ctx.model as ModelLike | undefined)?.id;
62
+
63
+ const apply = (model: { provider: string; id: string } | undefined): void => {
64
+ persistTaskModel(ctx.cwd, model);
65
+ runtime.config = { ...runtime.config, taskModel: model };
66
+ runtime.configLoaded = true;
67
+ const label = formatActiveModelLabel(runtime.config, sessionId);
68
+ ctx.ui.setStatus(MODEL_STATUS_KEY, `🧠 wiki model: ${label}`);
69
+ ctx.ui.notify(`LLM Wiki: background tasks now use ${label}`, "info");
70
+ };
71
+
72
+ const trimmed = args.trim();
73
+
74
+ // Explicit clear → session model.
75
+ if (trimmed && CLEAR_WORDS.has(trimmed.toLowerCase())) {
76
+ apply(undefined);
77
+ return;
78
+ }
79
+
80
+ // Direct "provider/id" set (works without UI).
81
+ if (trimmed) {
82
+ const ref = parseModelRef(trimmed);
83
+ if (!ref) {
84
+ ctx.ui.notify(
85
+ `LLM Wiki: could not parse "${trimmed}". Use provider/id (e.g. anthropic/claude-haiku) or "session".`,
86
+ "error",
87
+ );
88
+ return;
89
+ }
90
+ const found = ctx.modelRegistry.find(ref.provider, ref.id) as ModelLike | undefined;
91
+ if (!found) {
92
+ ctx.ui.notify(
93
+ `LLM Wiki: model ${ref.provider}/${ref.id} is not in the registry (run /wiki-model with no argument to pick from available models).`,
94
+ "error",
95
+ );
96
+ return;
97
+ }
98
+ apply({ provider: found.provider, id: found.id });
99
+ return;
100
+ }
101
+
102
+ // No argument: interactive picker.
103
+ const current = formatActiveModelLabel(runtime.config, sessionId);
104
+ if (!ctx.hasUI) {
105
+ ctx.ui.notify(
106
+ `LLM Wiki: active background model is ${current}. Pass provider/id to change it (no interactive UI here).`,
107
+ "info",
108
+ );
109
+ return;
110
+ }
111
+
112
+ const available = (ctx.modelRegistry.getAvailable() as ModelLike[]) ?? [];
113
+ const pool = available.length > 0 ? available : (ctx.modelRegistry.getAll() as ModelLike[]);
114
+ const sessionOption = "↩ Use session model (clear override)";
115
+ const options = [sessionOption, ...pool.map(modelRef)];
116
+
117
+ const picked = await ctx.ui.select(`Wiki background model (current: ${current})`, options);
118
+ if (picked === undefined) return; // cancelled
119
+
120
+ if (picked === sessionOption) {
121
+ apply(undefined);
122
+ return;
123
+ }
124
+ const ref = parseModelRef(picked);
125
+ if (ref) apply(ref);
126
+ },
127
+ });
128
+ }