@zosmaai/pi-llm-wiki 0.11.4 → 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 (48) hide show
  1. package/README.de.md +8 -0
  2. package/README.es.md +8 -0
  3. package/README.fr.md +8 -0
  4. package/README.hi.md +8 -0
  5. package/README.ja.md +8 -0
  6. package/README.ko.md +8 -0
  7. package/README.md +8 -0
  8. package/README.pt.md +8 -0
  9. package/README.ru.md +8 -0
  10. package/README.zh.md +8 -0
  11. package/assets/wiki-dashboard.png +0 -0
  12. package/commands/wiki-ingest.md +1 -0
  13. package/commands/wiki-req.md +1 -0
  14. package/commands/wiki-retro.md +1 -0
  15. package/dist/extensions/llm-wiki/lib/dashboard-command.js +86 -0
  16. package/dist/extensions/llm-wiki/lib/dashboard.js +175 -0
  17. package/dist/extensions/llm-wiki/lib/guardrails.js +30 -1
  18. package/dist/extensions/llm-wiki/lib/host.js +21 -1
  19. package/dist/extensions/llm-wiki/lib/ingest-worker.js +2 -1
  20. package/dist/extensions/llm-wiki/lib/knowledge-document.js +20 -2
  21. package/dist/extensions/llm-wiki/lib/knowledge-links.js +6 -3
  22. package/dist/extensions/llm-wiki/lib/metadata.js +1 -1
  23. package/dist/extensions/llm-wiki/lib/observation.js +22 -3
  24. package/dist/extensions/llm-wiki/lib/settings-command.js +377 -0
  25. package/dist/extensions/llm-wiki/lib/task-config.js +78 -1
  26. package/docs/api.md +24 -1
  27. package/docs/commands.md +6 -1
  28. package/docs/configuration.md +10 -0
  29. package/docs/superpowers/plans/2026-08-09-qmd-retrieval-phase-1-quality-baseline-and-compatibility.md +1520 -0
  30. package/docs/superpowers/roadmaps/2026-08-09-qmd-retrieval-roadmap.md +448 -0
  31. package/docs/superpowers/specs/2026-08-08-qmd-retrieval-design.md +806 -0
  32. package/extensions/llm-wiki/index.ts +4 -0
  33. package/extensions/llm-wiki/lib/dashboard-command.ts +106 -0
  34. package/extensions/llm-wiki/lib/dashboard.ts +210 -0
  35. package/extensions/llm-wiki/lib/guardrails.ts +26 -1
  36. package/extensions/llm-wiki/lib/host.ts +21 -1
  37. package/extensions/llm-wiki/lib/ingest-worker.ts +4 -0
  38. package/extensions/llm-wiki/lib/knowledge-document.ts +20 -2
  39. package/extensions/llm-wiki/lib/knowledge-links.ts +7 -3
  40. package/extensions/llm-wiki/lib/metadata.ts +1 -1
  41. package/extensions/llm-wiki/lib/observation.ts +23 -3
  42. package/extensions/llm-wiki/lib/settings-command.ts +483 -0
  43. package/extensions/llm-wiki/lib/task-config.ts +102 -0
  44. package/package.json +4 -4
  45. package/prompts/wiki-ingest.md +1 -0
  46. package/prompts/wiki-req.md +1 -0
  47. package/prompts/wiki-retro.md +1 -0
  48. package/skills/llm-wiki/SKILL.md +11 -1
@@ -365,7 +365,7 @@ Rules:
365
365
  * synthesis.
366
366
  */
367
367
  export async function runIngestSynthesis(args) {
368
- const { model, apiKey, headers, paths, sourceId, manifest, extracted, maxChars, signal, synthesisLanguage, } = args;
368
+ const { model, apiKey, headers, paths, sourceId, manifest, extracted, maxChars, signal, synthesisLanguage, synthesisMaxTokens, } = args;
369
369
  const content = extracted.slice(0, maxChars ?? 24_000);
370
370
  if (!content.trim())
371
371
  return undefined;
@@ -402,6 +402,7 @@ export async function runIngestSynthesis(args) {
402
402
  systemPrompt,
403
403
  userPrompt,
404
404
  tools: [commitTool],
405
+ maxTokens: synthesisMaxTokens ?? 16384,
405
406
  signal,
406
407
  });
407
408
  if (committed)
@@ -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
  }
@@ -256,9 +256,11 @@ export function registerObservationReminder(pi, reminderState, options) {
256
256
  return true;
257
257
  };
258
258
  let turnsSinceLastReminder = 0;
259
+ let agentEndsInUserTurn = 0;
259
260
  pi.on("session_start", async () => {
260
261
  turnsSinceLastReminder = 0;
261
262
  reminderState.observeDoneThisSession = false;
263
+ agentEndsInUserTurn = 0;
262
264
  });
263
265
  // After compaction, reset turn counter so reminders resume
264
266
  // BUT preserve observeDoneThisSession — if the model already called
@@ -266,10 +268,18 @@ export function registerObservationReminder(pi, reminderState, options) {
266
268
  pi.on("session_compact", async () => {
267
269
  turnsSinceLastReminder = 0;
268
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
+ });
269
279
  pi.on("agent_end", async (event, _ctx) => {
270
- // Skip reminder on retries willRetry means pi will re-run the agent,
271
- // and queuing another reminder would duplicate them (issue: connection
272
- // 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.
273
283
  if ("willRetry" in event && event.willRetry)
274
284
  return;
275
285
  // No wiki applies here: never nag, and never accumulate a pending reminder
@@ -281,6 +291,12 @@ export function registerObservationReminder(pi, reminderState, options) {
281
291
  return;
282
292
  if (reminderState.observeDoneThisSession)
283
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;
284
300
  pi.sendMessage({
285
301
  customType: "wiki-observe-reminder",
286
302
  content: buildReminderText(),
@@ -288,5 +304,8 @@ export function registerObservationReminder(pi, reminderState, options) {
288
304
  }, {
289
305
  deliverAs: "nextTurn",
290
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;
291
310
  });
292
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
+ }
@@ -1,7 +1,7 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { parse as parseYaml } from "yaml";
4
- import { detectHost, listGlobalSettingsFiles, listProjectSettingsFiles, resolveProjectSettingsPath, } from "./host.js";
4
+ import { detectHost, listGlobalSettingsFiles, listProjectSettingsFiles, resolveGlobalSettingsPath, resolveProjectSettingsPath, } from "./host.js";
5
5
  export const TASK_DEFAULTS = {};
6
6
  /**
7
7
  * Resolve whether user-facing wiki notices are enabled (issue #77). Defaults
@@ -87,6 +87,10 @@ function readNamespacedConfig(path) {
87
87
  if (canonical)
88
88
  out.synthesisLanguage = canonical;
89
89
  }
90
+ const maxTokens = section.synthesisMaxTokens;
91
+ if (typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0) {
92
+ out.synthesisMaxTokens = Math.floor(maxTokens);
93
+ }
90
94
  return out;
91
95
  }
92
96
  catch {
@@ -156,6 +160,19 @@ function readSettingsObject(path) {
156
160
  }
157
161
  return {};
158
162
  }
163
+ /**
164
+ * Rewrite the `llm-wiki` section of the global settings file.
165
+ */
166
+ function updateGlobalSection(mutate) {
167
+ const settingsPath = resolveGlobalSettingsPath();
168
+ const raw = readSettingsObject(settingsPath);
169
+ const existing = raw[SETTINGS_KEY];
170
+ const section = existing && typeof existing === "object" ? { ...existing } : {};
171
+ mutate(section);
172
+ raw[SETTINGS_KEY] = section;
173
+ mkdirSync(dirname(settingsPath), { recursive: true });
174
+ writeFileSync(settingsPath, `${JSON.stringify(raw, null, 2)}\n`, "utf-8");
175
+ }
159
176
  /**
160
177
  * Rewrite the `llm-wiki` section of the project settings file, preserving every
161
178
  * other top-level key and every other setting in the section.
@@ -224,3 +241,63 @@ export function loadTaskConfig(cwd) {
224
241
  }
225
242
  return config;
226
243
  }
244
+ /**
245
+ * Resolve where each setting is defined: project > global > default.
246
+ */
247
+ /** All known setting keys — needed because TASK_DEFAULTS is {} (zero-config). */
248
+ const KNOWN_KEYS = [
249
+ "taskModel",
250
+ "embeddingProvider",
251
+ "embeddingModel",
252
+ "embeddingBaseUrl",
253
+ "embeddingApiKey",
254
+ "embeddingApiKeyEnv",
255
+ "semanticWeight",
256
+ "recallLinksThreshold",
257
+ "recallSkillInlineMax",
258
+ "notices",
259
+ "ambientPersonalVault",
260
+ "trajectories",
261
+ "synthesisLanguage",
262
+ "synthesisMaxTokens",
263
+ ];
264
+ export function loadTaskConfigSources(cwd) {
265
+ const globalResult = {};
266
+ for (const path of listGlobalSettingsFiles()) {
267
+ Object.assign(globalResult, readNamespacedConfig(path));
268
+ }
269
+ const projectResult = {};
270
+ for (const path of listProjectSettingsFiles(cwd)) {
271
+ Object.assign(projectResult, readNamespacedConfig(path));
272
+ }
273
+ const effective = loadTaskConfig(cwd);
274
+ const out = {};
275
+ for (const key of KNOWN_KEYS) {
276
+ if (key in projectResult)
277
+ out[key] = { value: projectResult[key], source: "project" };
278
+ else if (key in globalResult)
279
+ out[key] = { value: globalResult[key], source: "global" };
280
+ else
281
+ out[key] = { value: effective[key], source: "default" };
282
+ }
283
+ return out;
284
+ }
285
+ /**
286
+ * Generic setting persist: writes any single setting to the chosen scope.
287
+ */
288
+ export function persistSetting(cwd, scope, key, value) {
289
+ const mutate = (section) => {
290
+ if (value === undefined || value === null) {
291
+ delete section[key];
292
+ }
293
+ else {
294
+ section[key] = value;
295
+ }
296
+ };
297
+ if (scope === "project") {
298
+ updateProjectSection(cwd, mutate);
299
+ }
300
+ else if (scope === "global") {
301
+ updateGlobalSection(mutate);
302
+ }
303
+ }
package/docs/api.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  All tools registered by the extension. Parameters marked `?` are optional.
4
4
 
5
- 13 tools are always registered. The 3 agent-trajectory tools
5
+ 14 tools are always registered. The 3 agent-trajectory tools
6
6
  (`wiki_capture_trajectory`, `wiki_distill_skills`, `wiki_recall_skill`) are **opt-in,
7
7
  off by default** (issue #80) — they are only registered when `llm-wiki.trajectories`
8
8
  is `true`; enable with `/wiki-trajectories on`.
@@ -458,6 +458,29 @@ Returns empty `matches: []` with a hint to capture work via `wiki_capture_trajec
458
458
 
459
459
  ---
460
460
 
461
+ ## wiki_reindex_embeddings
462
+
463
+ Backfill or refresh semantic embeddings for the vault. Embeds pages that are new or stale
464
+ (content changed); pass `force` to re-embed everything. No-op when no embedding provider is
465
+ configured — set `llm-wiki.embeddingProvider` first (see `docs/configuration.md`).
466
+
467
+ **Parameters**
468
+
469
+ | Name | Type | Required | Description |
470
+ |------|------|----------|-------------|
471
+ | `force` | `boolean` | — | Re-embed every page, ignoring staleness (default: `false`) |
472
+
473
+ **Returns**
474
+
475
+ ```
476
+ details: { enabled: true, model: string, embedded: number, skipped: number, pruned: number }
477
+ ```
478
+
479
+ Fails soft with `details: { enabled: false }` plus a hint to configure `embeddingProvider` when
480
+ no embedding provider is set.
481
+
482
+ ---
483
+
461
484
  ## Error Shape
462
485
 
463
486
  All tools return `isError: true` in their result when a hard error occurs (no vault found, missing