@zosmaai/pi-llm-wiki 0.11.3 → 0.11.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.de.md +8 -0
  3. package/README.es.md +8 -0
  4. package/README.fr.md +8 -0
  5. package/README.hi.md +8 -0
  6. package/README.ja.md +8 -0
  7. package/README.ko.md +8 -0
  8. package/README.md +88 -2
  9. package/README.pt.md +8 -0
  10. package/README.ru.md +8 -0
  11. package/README.zh.md +8 -0
  12. package/assets/wiki-dashboard.png +0 -0
  13. package/commands/wiki-digest.md +28 -0
  14. package/commands/wiki-discover.md +30 -0
  15. package/commands/wiki-ingest.md +37 -0
  16. package/commands/wiki-init.md +30 -0
  17. package/commands/wiki-lint.md +25 -0
  18. package/commands/wiki-query.md +37 -0
  19. package/commands/wiki-record.md +36 -0
  20. package/commands/wiki-req.md +56 -0
  21. package/commands/wiki-retro.md +35 -0
  22. package/commands/wiki-run.md +31 -0
  23. package/commands/wiki-skills.md +26 -0
  24. package/commands/wiki-status.md +16 -0
  25. package/dist/extensions/llm-wiki/lib/dashboard-command.js +86 -0
  26. package/dist/extensions/llm-wiki/lib/dashboard.js +175 -0
  27. package/dist/extensions/llm-wiki/lib/guardrails.js +30 -1
  28. package/dist/extensions/llm-wiki/lib/host.js +117 -0
  29. package/dist/extensions/llm-wiki/lib/ingest-worker.js +2 -1
  30. package/dist/extensions/llm-wiki/lib/knowledge-document.js +20 -2
  31. package/dist/extensions/llm-wiki/lib/knowledge-links.js +6 -3
  32. package/dist/extensions/llm-wiki/lib/metadata.js +1 -1
  33. package/dist/extensions/llm-wiki/lib/observation.js +31 -3
  34. package/dist/extensions/llm-wiki/lib/settings-command.js +377 -0
  35. package/dist/extensions/llm-wiki/lib/task-config.js +145 -43
  36. package/dist/extensions/llm-wiki/lib/utils.js +59 -16
  37. package/docs/api.md +24 -1
  38. package/docs/commands.md +6 -1
  39. package/docs/configuration.md +62 -11
  40. package/docs/superpowers/plans/2026-08-09-qmd-retrieval-phase-1-quality-baseline-and-compatibility.md +1520 -0
  41. package/docs/superpowers/roadmaps/2026-08-09-qmd-retrieval-roadmap.md +448 -0
  42. package/docs/superpowers/specs/2026-08-08-qmd-retrieval-design.md +806 -0
  43. package/extensions/llm-wiki/index.ts +48 -6
  44. package/extensions/llm-wiki/lib/dashboard-command.ts +106 -0
  45. package/extensions/llm-wiki/lib/dashboard.ts +210 -0
  46. package/extensions/llm-wiki/lib/guardrails.ts +26 -1
  47. package/extensions/llm-wiki/lib/host.ts +145 -0
  48. package/extensions/llm-wiki/lib/ingest-worker.ts +4 -0
  49. package/extensions/llm-wiki/lib/knowledge-document.ts +20 -2
  50. package/extensions/llm-wiki/lib/knowledge-links.ts +7 -3
  51. package/extensions/llm-wiki/lib/metadata.ts +1 -1
  52. package/extensions/llm-wiki/lib/observation.ts +37 -4
  53. package/extensions/llm-wiki/lib/settings-command.ts +483 -0
  54. package/extensions/llm-wiki/lib/task-config.ts +208 -46
  55. package/extensions/llm-wiki/lib/utils.ts +55 -14
  56. package/package.json +15 -4
  57. package/prompts/wiki-ingest.md +1 -0
  58. package/prompts/wiki-req.md +1 -0
  59. package/prompts/wiki-retro.md +1 -0
  60. package/skills/llm-wiki/SKILL.md +11 -1
@@ -33,13 +33,17 @@ function diag(
33
33
  return { severity, code, path, message };
34
34
  }
35
35
 
36
+ function normalizeWikilinkTarget(target: string): string {
37
+ return target.trim().replace(/\\$/, "");
38
+ }
39
+
36
40
  export function extractKnowledgeLinks(body: string): KnowledgeLinks {
37
41
  const markdown: ExtractedLink[] = [];
38
42
  const wikilinks: ExtractedLink[] = [];
39
43
 
40
- // Extract legacy wikilinks
44
+ // Extract legacy wikilinks. A table-safe alias uses an escaped pipe: [[target\\|alias]].
41
45
  for (const match of body.matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g)) {
42
- wikilinks.push({ target: match[1].trim(), offset: match.index ?? 0 });
46
+ wikilinks.push({ target: normalizeWikilinkTarget(match[1]), offset: match.index ?? 0 });
43
47
  }
44
48
 
45
49
  // Parse with CommonMark AST
@@ -111,7 +115,7 @@ export function extractKnowledgeLinks(body: string): KnowledgeLinks {
111
115
  export function extractLegacyWikilinks(body: string): ExtractedLink[] {
112
116
  const links: ExtractedLink[] = [];
113
117
  for (const match of body.matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g)) {
114
- links.push({ target: match[1].trim(), offset: match.index ?? 0 });
118
+ links.push({ target: normalizeWikilinkTarget(match[1]), offset: match.index ?? 0 });
115
119
  }
116
120
  return links;
117
121
  }
@@ -508,7 +508,7 @@ export function buildDirectoryIndexes(
508
508
  lines.push("## Directories");
509
509
  lines.push("");
510
510
  for (const subDir of [...dirs].sort(compareCodePoint)) {
511
- const encoded = `${encodeRelativePath(subDir)}/`;
511
+ const encoded = encodeRelativePath(`${subDir}/index.md`);
512
512
  lines.push(`- [${escapeLabel(subDir)}/](${encoded})`);
513
513
  }
514
514
  }
@@ -322,11 +322,20 @@ export function buildReminderText(): string {
322
322
  * `options.display` (issue #77) controls whether the reminder is shown to the
323
323
  * user (`true`, the default) or injected silently into model context only
324
324
  * (`false`). Pass a resolver so the live `notices` config is read at send time.
325
+ *
326
+ * `options.enabled` gates the reminder entirely — note that `display: false`
327
+ * still injects it into model context, so it is NOT a way to switch the
328
+ * reminder off. Callers pass a resolver that answers "does a wiki apply to the
329
+ * current working directory", evaluated per turn because the session can move.
325
330
  */
326
331
  export function registerObservationReminder(
327
332
  pi: ExtensionAPI,
328
333
  reminderState: ReminderState,
329
- options?: { turnsBetweenReminders?: number; display?: boolean | (() => boolean) },
334
+ options?: {
335
+ turnsBetweenReminders?: number;
336
+ display?: boolean | (() => boolean);
337
+ enabled?: () => boolean;
338
+ },
330
339
  ): void {
331
340
  const REMINDER_INTERVAL = options?.turnsBetweenReminders ?? 5;
332
341
  const resolveDisplay = (): boolean => {
@@ -336,10 +345,12 @@ export function registerObservationReminder(
336
345
  return true;
337
346
  };
338
347
  let turnsSinceLastReminder = 0;
348
+ let agentEndsInUserTurn = 0;
339
349
 
340
350
  pi.on("session_start", async () => {
341
351
  turnsSinceLastReminder = 0;
342
352
  reminderState.observeDoneThisSession = false;
353
+ agentEndsInUserTurn = 0;
343
354
  });
344
355
 
345
356
  // After compaction, reset turn counter so reminders resume
@@ -349,16 +360,34 @@ export function registerObservationReminder(
349
360
  turnsSinceLastReminder = 0;
350
361
  });
351
362
 
363
+ // A retry re-runs the agent within the same user turn, and pi does not
364
+ // forward `willRetry` to extensions (zosmaai/pi-llm-wiki#151). Only the
365
+ // user-role message_start separates real user turns from retried agent
366
+ // runs, so the per-turn agent_end count resets there.
367
+ pi.on("message_start", async (event) => {
368
+ if (event.message.role === "user") agentEndsInUserTurn = 0;
369
+ });
370
+
352
371
  pi.on("agent_end", async (event, _ctx) => {
353
- // Skip reminder on retries — willRetry means pi will re-run the agent,
354
- // and queuing another reminder would duplicate them (issue: connection
355
- // errors cause multiple retries, each firing agent_end).
372
+ // Legacy guard: this pi build does not forward `willRetry` to extension
373
+ // events, but keep the check in case a future one does. The dedup that
374
+ // actually works is the per-turn agent_end count below.
356
375
  if ("willRetry" in event && (event as { willRetry?: boolean }).willRetry) return;
357
376
 
377
+ // No wiki applies here: never nag, and never accumulate a pending reminder
378
+ // that would fire the moment the session moves into a wiki-bearing project.
379
+ if (options?.enabled && !options.enabled()) return;
380
+
358
381
  turnsSinceLastReminder++;
359
382
  if (turnsSinceLastReminder < REMINDER_INTERVAL) return;
360
383
  if (reminderState.observeDoneThisSession) return;
361
384
 
385
+ // One agent_end per user turn may queue a reminder. A rate-limit storm
386
+ // re-runs the agent several times within the same turn, each firing
387
+ // agent_end, so count them and let only the first through.
388
+ agentEndsInUserTurn++;
389
+ if (agentEndsInUserTurn > 1) return;
390
+
362
391
  pi.sendMessage(
363
392
  {
364
393
  customType: "wiki-observe-reminder",
@@ -369,5 +398,9 @@ export function registerObservationReminder(
369
398
  deliverAs: "nextTurn",
370
399
  },
371
400
  );
401
+
402
+ // Reset the interval counter after queueing: without it the count stays
403
+ // at/above the threshold and every later agent_end queues a reminder.
404
+ turnsSinceLastReminder = 0;
372
405
  });
373
406
  }
@@ -0,0 +1,483 @@
1
+ import { homedir } from "node:os";
2
+ import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
3
+ import {
4
+ Container,
5
+ Input,
6
+ type SettingItem,
7
+ SettingsList,
8
+ type SettingsListTheme,
9
+ Spacer,
10
+ Text,
11
+ } from "@mariozechner/pi-tui";
12
+ import type { Runtime } from "./runtime.js";
13
+ import {
14
+ type SettingScope,
15
+ loadTaskConfig,
16
+ loadTaskConfigSources,
17
+ parseModelRef,
18
+ persistSetting,
19
+ trajectoriesEnabled,
20
+ } from "./task-config.js";
21
+
22
+ /**
23
+ * Settings TUI for LLM Wiki.
24
+ *
25
+ * /wiki-settings renders the whole screen as ONE persistent pi-tui
26
+ * SettingsList (the same component pi's own /settings uses), shown through
27
+ * ui.custom():
28
+ *
29
+ * - booleans cycle OFF/ON in place (Enter/Space) — no screen reset,
30
+ * the cursor stays where it is
31
+ * - strings/numbers/model open an inline input submenu; closing it
32
+ * restores the cursor to the same item
33
+ * - every change persists immediately to the chosen scope
34
+ * (project or global) and the display normalizes in place
35
+ *
36
+ * Flow:
37
+ * 1. cwd inside ~/ → global scope; outside ~/ → scope picker first
38
+ * 2. persistent settings screen (Esc closes)
39
+ */
40
+
41
+ interface Ui {
42
+ select(title: string, options: string[]): Promise<string | undefined>;
43
+ notify(message: string, type?: "info" | "warning" | "error"): void;
44
+ /** Hosts that can render a focused custom component (pi ≥ 0.70 interactive). */
45
+ custom?(
46
+ factory: (
47
+ tui: unknown,
48
+ theme: Theme,
49
+ keybindings: unknown,
50
+ done: (result?: unknown) => void,
51
+ ) => unknown,
52
+ ): Promise<unknown>;
53
+ }
54
+
55
+ /** Setting definition — drives both the menu and its edit menu. */
56
+ interface SettingDef {
57
+ key: string;
58
+ label: string;
59
+ type: "boolean" | "number" | "string" | "model";
60
+ /** Shown with the item when it is selected. */
61
+ hint: string;
62
+ /** Edit-menu title override (defaults to `Set <label>`). */
63
+ menuLabel?: string;
64
+ /** Raw text to prefill the edit input with when the value is unset. */
65
+ defaultText?: string;
66
+ /** Pretty-print a value for display. */
67
+ format: (v: unknown) => string;
68
+ /** Raw text used to prefill the edit input. */
69
+ toEdit: (v: unknown) => string;
70
+ /** Parse user input into a typed value. undefined = invalid. */
71
+ parse: (input: string) => unknown | undefined;
72
+ }
73
+
74
+ const SETTINGS: SettingDef[] = [
75
+ {
76
+ key: "taskModel",
77
+ label: "Model",
78
+ type: "model",
79
+ hint: "Model for background wiki tasks (provider/id).",
80
+ menuLabel: "Task model — provider/id, empty = session model",
81
+ format: (v) => {
82
+ const m = v as { provider: string; id: string } | undefined;
83
+ return m ? `${m.provider}/${m.id}` : "(session model)";
84
+ },
85
+ toEdit: (v) => {
86
+ const m = v as { provider: string; id: string } | undefined;
87
+ return m ? `${m.provider}/${m.id}` : "";
88
+ },
89
+ parse: (input) => parseModelRef(input),
90
+ },
91
+ {
92
+ key: "synthesisMaxTokens",
93
+ label: "Synthesis Tokens",
94
+ type: "number",
95
+ hint: "Max output tokens for wiki synthesis runs.",
96
+ defaultText: "16384",
97
+ format: (v) => (v != null ? String(v) : "16384 (default)"),
98
+ toEdit: (v) => (v != null ? String(v) : "16384"),
99
+ parse: (input) => {
100
+ const n = Number(input);
101
+ if (!Number.isFinite(n) || n <= 0) return undefined;
102
+ return Math.floor(n);
103
+ },
104
+ },
105
+ {
106
+ key: "trajectories",
107
+ label: "Trajectories",
108
+ type: "boolean",
109
+ hint: "Capture agent trajectories into the vault.",
110
+ format: (v) => (v ? "ON" : "OFF"),
111
+ toEdit: () => "",
112
+ parse: (input) => Boolean(Boolean(input) && /on|true|1/i.test(input)),
113
+ },
114
+ {
115
+ key: "notices",
116
+ label: "Notices",
117
+ type: "boolean",
118
+ hint: "Show wiki recall/observation notice lines in chat.",
119
+ format: (v) => (v != null ? (v ? "ON" : "OFF") : "ON (default)"),
120
+ toEdit: () => "",
121
+ parse: (input) => Boolean(Boolean(input) && /on|true|1/i.test(input)),
122
+ },
123
+ {
124
+ key: "ambientPersonalVault",
125
+ label: "Ambient Personal",
126
+ type: "boolean",
127
+ hint: "Include the personal vault in ambient session context.",
128
+ format: (v) => (v != null ? (v ? "ON" : "OFF") : "host-dependent"),
129
+ toEdit: () => "",
130
+ parse: (input) => Boolean(Boolean(input) && /on|true|1/i.test(input)),
131
+ },
132
+ {
133
+ key: "synthesisLanguage",
134
+ label: "Synthesis Language",
135
+ type: "string",
136
+ hint: "Language for synthesized wiki content.",
137
+ defaultText: "en",
138
+ format: (v) => (v ? String(v) : "en (default)"),
139
+ toEdit: (v) => (v ? String(v) : "en"),
140
+ parse: (input) => {
141
+ const trimmed = input.trim();
142
+ return trimmed || undefined;
143
+ },
144
+ },
145
+ {
146
+ key: "semanticWeight",
147
+ label: "Semantic Weight",
148
+ type: "number",
149
+ hint: "0–1 weight for embedding (semantic) recall.",
150
+ defaultText: "0.5",
151
+ format: (v) => (v != null ? String(v) : "0.5 (default)"),
152
+ toEdit: (v) => (v != null ? String(v) : "0.5"),
153
+ parse: (input) => {
154
+ const n = Number(input);
155
+ if (!Number.isFinite(n) || n < 0 || n > 1) return undefined;
156
+ return n;
157
+ },
158
+ },
159
+ {
160
+ key: "recallLinksThreshold",
161
+ label: "Recall Links",
162
+ type: "number",
163
+ hint: "Max recall links shown when the vault is large.",
164
+ defaultText: "50",
165
+ format: (v) => (v != null ? String(v) : "50 (default)"),
166
+ toEdit: (v) => (v != null ? String(v) : "50"),
167
+ parse: (input) => {
168
+ const n = Number(input);
169
+ if (!Number.isFinite(n) || n < 0) return undefined;
170
+ return Math.floor(n);
171
+ },
172
+ },
173
+ {
174
+ key: "recallSkillInlineMax",
175
+ label: "Skill Inline Max",
176
+ type: "number",
177
+ hint: "Max characters inlined from recall into skill prompts.",
178
+ defaultText: "1600",
179
+ format: (v) => (v != null ? String(v) : "1600 (default)"),
180
+ toEdit: (v) => (v != null ? String(v) : "1600"),
181
+ parse: (input) => {
182
+ const n = Number(input);
183
+ if (!Number.isFinite(n) || n < 0) return undefined;
184
+ return Math.floor(n);
185
+ },
186
+ },
187
+ {
188
+ key: "embeddingProvider",
189
+ label: "Embedding Provider",
190
+ type: "string",
191
+ hint: "Embedding provider (e.g. openai). Empty = embeddings disabled.",
192
+ format: (v) => (v ? String(v) : "(disabled)"),
193
+ toEdit: (v) => (v ? String(v) : ""),
194
+ parse: (input) => input.trim() || undefined,
195
+ },
196
+ {
197
+ key: "embeddingModel",
198
+ label: "Embedding Model",
199
+ type: "string",
200
+ hint: "Embedding model id.",
201
+ defaultText: "text-embedding-3-small",
202
+ format: (v) => (v ? String(v) : "text-embedding-3-small (default)"),
203
+ toEdit: (v) => (v ? String(v) : "text-embedding-3-small"),
204
+ parse: (input) => input.trim() || undefined,
205
+ },
206
+ {
207
+ key: "embeddingBaseUrl",
208
+ label: "Embedding Base URL",
209
+ type: "string",
210
+ hint: "Custom embeddings API base URL (optional).",
211
+ format: (v) => (v ? String(v) : "—"),
212
+ toEdit: (v) => (v ? String(v) : ""),
213
+ parse: (input) => input.trim() || undefined,
214
+ },
215
+ {
216
+ key: "embeddingApiKeyEnv",
217
+ label: "Embedding API Key Env",
218
+ type: "string",
219
+ hint: "Environment variable name holding the embeddings API key.",
220
+ defaultText: "OPENAI_API_KEY",
221
+ format: (v) => (v ? String(v) : "OPENAI_API_KEY (default)"),
222
+ toEdit: (v) => (v ? String(v) : "OPENAI_API_KEY"),
223
+ parse: (input) => input.trim() || undefined,
224
+ },
225
+ ];
226
+
227
+ function isInsideHome(cwd: string): boolean {
228
+ return cwd.startsWith(homedir());
229
+ }
230
+
231
+ /**
232
+ * Map effective settings + sources to pi-tui SettingItems.
233
+ *
234
+ * Exported for tests. `notify` is used by edit menus to report invalid input
235
+ * without closing the menu.
236
+ */
237
+ export function buildSettingItems(
238
+ sources: Record<string, { value: unknown; source: string }>,
239
+ notify: (message: string, type?: "error") => void,
240
+ ): SettingItem[] {
241
+ return SETTINGS.map((def) => {
242
+ const entry = sources[def.key];
243
+ const value = entry?.value;
244
+ const source = entry?.source ?? "default";
245
+
246
+ const item: SettingItem = {
247
+ id: def.key,
248
+ label: def.label,
249
+ currentValue: def.format(value),
250
+ description: describeSetting(def, source),
251
+ };
252
+
253
+ if (def.type === "boolean") {
254
+ item.values = ["OFF", "ON"];
255
+ return item;
256
+ }
257
+
258
+ item.submenu = (_display, done) => {
259
+ const sub = new InputSubmenu(def.menuLabel ?? `Set ${def.label}`, def.toEdit(value));
260
+ sub.input.onSubmit = (raw) => {
261
+ const trimmed = raw.trim();
262
+ if (def.type === "model") {
263
+ if (!trimmed) {
264
+ done(""); // clear → back to session model
265
+ return;
266
+ }
267
+ const ref = parseModelRef(trimmed);
268
+ if (!ref) {
269
+ notify(`LLM Wiki: could not parse "${trimmed}". Use provider/id.`, "error");
270
+ return; // stay in the menu
271
+ }
272
+ done(`${ref.provider}/${ref.id}`);
273
+ return;
274
+ }
275
+ if (!trimmed) {
276
+ done(); // empty = no change
277
+ return;
278
+ }
279
+ const parsed = def.parse(trimmed);
280
+ if (parsed === undefined) {
281
+ notify(`LLM Wiki: invalid value "${trimmed}" for ${def.label}`, "error");
282
+ return; // stay in the menu
283
+ }
284
+ done(String(parsed));
285
+ };
286
+ sub.input.onEscape = () => done();
287
+ return sub;
288
+ };
289
+ return item;
290
+ });
291
+ }
292
+
293
+ function describeSetting(def: SettingDef, source: string): string {
294
+ const where = source === "default" ? "Not set — using default." : `Set in ${source} settings.`;
295
+ return `${def.hint}\n${where}`;
296
+ }
297
+
298
+ /**
299
+ * Parse the display string a SettingItem cycled/edited into back into the
300
+ * typed value to persist.
301
+ */
302
+ function parseDisplay(def: SettingDef, display: string): unknown {
303
+ if (def.type === "boolean") return display === "ON";
304
+ if (def.type === "model") return display ? parseModelRef(display) : undefined;
305
+ return def.parse(display);
306
+ }
307
+
308
+ function buildSettingsListTheme(theme: Theme): SettingsListTheme {
309
+ return {
310
+ label: (text, selected) => (selected ? theme.fg("accent", text) : text),
311
+ value: (text, selected) => (selected ? theme.fg("accent", text) : theme.fg("muted", text)),
312
+ description: (text) => theme.fg("dim", text),
313
+ cursor: theme.fg("accent", "→ "),
314
+ hint: (text) => theme.fg("dim", text),
315
+ };
316
+ }
317
+
318
+ /** Title + single-line input. SettingsList forwards all key input here. */
319
+ class InputSubmenu extends Container {
320
+ readonly input: Input;
321
+
322
+ constructor(label: string, initialValue: string) {
323
+ super();
324
+ this.addChild(new Text(label, 0, 0));
325
+ this.input = new Input();
326
+ // Type the prefill instead of setValue(): setValue keeps the cursor at 0,
327
+ // which makes backspace no-op and typed input land before the prefill.
328
+ if (initialValue) this.input.handleInput(initialValue);
329
+ this.addChild(this.input);
330
+ }
331
+
332
+ handleInput(data: string): void {
333
+ this.input.handleInput(data);
334
+ }
335
+ }
336
+
337
+ /** Header + settings list. ui.custom gives this component focus. */
338
+ class SettingsScreen extends Container {
339
+ private list: SettingsList;
340
+ private titleText: Text;
341
+
342
+ constructor(title: string, list: SettingsList) {
343
+ super();
344
+ this.list = list;
345
+ this.titleText = new Text(title, 0, 0);
346
+ this.addChild(this.titleText);
347
+ this.addChild(new Spacer(1));
348
+ this.addChild(list);
349
+ }
350
+
351
+ handleInput(data: string): void {
352
+ this.list.handleInput(data);
353
+ }
354
+
355
+ /** Update the header line in place (scope changes re-target it live). */
356
+ setTitle(title: string): void {
357
+ this.titleText.setText(title);
358
+ }
359
+ }
360
+
361
+ /** Build the header title for the scope currently being written to. */
362
+ function scopeTitle(scope: SettingScope): string {
363
+ return `\u{1F9E0} LLM Wiki Settings \u2014 ${scope === "global" ? "Global" : "Project"} (Esc to close)`;
364
+ }
365
+
366
+ async function showSettingsTui(ui: Ui, cwd: string, scope: SettingScope): Promise<void> {
367
+ if (typeof ui.custom !== "function") {
368
+ ui.notify(
369
+ "LLM Wiki: this host does not support the settings screen. Edit settings.json directly.",
370
+ "warning",
371
+ );
372
+ return;
373
+ }
374
+
375
+ // The editable Scope row re-targets where subsequent writes land; the
376
+ // picker / inside-home heuristic only chooses the starting value.
377
+ let writeScope = scope;
378
+ const scopeItem: SettingItem = {
379
+ id: "scope",
380
+ label: "Scope",
381
+ currentValue: writeScope === "global" ? "Global" : "Project",
382
+ values: ["Global", "Project"],
383
+ description:
384
+ "Where edits are written. Global \u2192 ~/.pi/agent/settings.json \u00b7 Project \u2192 .pi/settings.json (this folder).",
385
+ };
386
+ const items = [scopeItem, ...buildSettingItems(loadTaskConfigSources(cwd), ui.notify.bind(ui))];
387
+ // Tracks the effective value so the reload note fires on actual changes.
388
+ let prevTrajectories = trajectoriesEnabled(loadTaskConfig(cwd));
389
+
390
+ let list: SettingsList | undefined;
391
+ let screenRef: SettingsScreen | undefined;
392
+
393
+ await ui.custom((_tui, theme, _keybindings, close) => {
394
+ list = new SettingsList(
395
+ items,
396
+ items.length,
397
+ buildSettingsListTheme(theme),
398
+ (id, display) => {
399
+ if (!list) return;
400
+ // Scope row: re-target writes; nothing is persisted for it.
401
+ if (id === "scope") {
402
+ writeScope = display === "Global" ? "global" : "project";
403
+ list.updateValue("scope", display);
404
+ screenRef?.setTitle(scopeTitle(writeScope));
405
+ ui.notify(
406
+ `LLM Wiki: writing to ${
407
+ writeScope === "global"
408
+ ? "Global (~/.pi/agent/settings.json)"
409
+ : "Project (.pi/settings.json)"
410
+ }`,
411
+ );
412
+ return;
413
+ }
414
+ const def = SETTINGS.find((d) => d.key === id);
415
+ if (!def) return;
416
+ const value = parseDisplay(def, display);
417
+ // Non-model, non-boolean values are validated before done(); undefined
418
+ // here is only expected for the model-clear case.
419
+ if (value === undefined && def.type !== "model") return;
420
+ try {
421
+ persistSetting(cwd, writeScope, def.key, value);
422
+ } catch (err) {
423
+ ui.notify(
424
+ `LLM Wiki: failed to save ${def.label}: ${err instanceof Error ? err.message : String(err)}`,
425
+ "error",
426
+ );
427
+ return;
428
+ }
429
+ // Normalize the displayed value in place (e.g. cleared model shows
430
+ // "(session model)"; "0.50" becomes "0.5").
431
+ list.updateValue(id, def.format(value));
432
+ // trajectories gates tool registration at startup, so a live toggle
433
+ // cannot add/remove the 3 tools mid-session \u2014 say so on real changes.
434
+ if (def.key === "trajectories") {
435
+ const next = Boolean(value);
436
+ if (next !== prevTrajectories) {
437
+ prevTrajectories = next;
438
+ ui.notify(
439
+ "LLM Wiki: trajectory tools register at startup \u2014 new value applies after a reload",
440
+ );
441
+ }
442
+ }
443
+ },
444
+ () => close(),
445
+ );
446
+ screenRef = new SettingsScreen(scopeTitle(writeScope), list);
447
+ return screenRef;
448
+ });
449
+ }
450
+
451
+ /**
452
+ * Register the /wiki-settings command.
453
+ */
454
+ export function registerWikiSettingsCommand(pi: ExtensionAPI, runtime: Runtime): void {
455
+ pi.registerCommand("wiki-settings", {
456
+ description: "View and edit LLM Wiki settings (model, tokens, behaviors, embeddings)",
457
+ handler: async (_args: string, ctx: { cwd: string; hasUI: boolean; ui: Ui }) => {
458
+ runtime.ensureConfig(ctx.cwd);
459
+
460
+ if (!ctx.hasUI) {
461
+ ctx.ui.notify(
462
+ "LLM Wiki: /wiki-settings requires an interactive UI. Edit settings.json directly instead.",
463
+ "warning",
464
+ );
465
+ return;
466
+ }
467
+
468
+ let scope: SettingScope;
469
+ if (isInsideHome(ctx.cwd)) {
470
+ scope = "global";
471
+ } else {
472
+ const pick = await ctx.ui.select("LLM Wiki \u2014 Settings scope?", [
473
+ `Project (${ctx.cwd}/.pi/settings.json)`,
474
+ "Global (~/.pi/agent/settings.json)",
475
+ ]);
476
+ if (pick === undefined) return;
477
+ scope = pick.startsWith("Project") ? "project" : "global";
478
+ }
479
+
480
+ await showSettingsTui(ctx.ui, ctx.cwd, scope);
481
+ },
482
+ });
483
+ }