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