@zosmaai/pi-llm-wiki 0.11.4 → 0.11.6

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 (62) 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 +44 -20
  20. package/dist/extensions/llm-wiki/lib/knowledge-document.js +20 -2
  21. package/dist/extensions/llm-wiki/lib/knowledge-links.js +133 -27
  22. package/dist/extensions/llm-wiki/lib/metadata.js +6 -6
  23. package/dist/extensions/llm-wiki/lib/observation.js +22 -3
  24. package/dist/extensions/llm-wiki/lib/retro.js +38 -4
  25. package/dist/extensions/llm-wiki/lib/runtime.js +2 -2
  26. package/dist/extensions/llm-wiki/lib/settings-command.js +377 -0
  27. package/dist/extensions/llm-wiki/lib/task-config.js +100 -1
  28. package/dist/extensions/llm-wiki/lib/tools.js +47 -8
  29. package/dist/mcp/index.js +2 -1
  30. package/dist/mcp/operations.js +21 -2
  31. package/docs/api.md +24 -1
  32. package/docs/commands.md +6 -1
  33. package/docs/configuration.md +11 -0
  34. package/docs/obsidian.md +6 -6
  35. package/docs/superpowers/plans/2026-08-09-qmd-retrieval-phase-1-quality-baseline-and-compatibility.md +1520 -0
  36. package/docs/superpowers/plans/2026-08-27-wikilink-resolver-normalization.md +735 -0
  37. package/docs/superpowers/plans/2026-08-29-wikilink-gate-ensure-page-retro.md +642 -0
  38. package/docs/superpowers/plans/2026-08-29-wikilink-write-validation.md +695 -0
  39. package/docs/superpowers/roadmaps/2026-08-09-qmd-retrieval-roadmap.md +448 -0
  40. package/docs/superpowers/specs/2026-08-08-qmd-retrieval-design.md +806 -0
  41. package/extensions/llm-wiki/index.ts +4 -0
  42. package/extensions/llm-wiki/lib/dashboard-command.ts +106 -0
  43. package/extensions/llm-wiki/lib/dashboard.ts +210 -0
  44. package/extensions/llm-wiki/lib/guardrails.ts +26 -1
  45. package/extensions/llm-wiki/lib/host.ts +21 -1
  46. package/extensions/llm-wiki/lib/ingest-worker.ts +64 -27
  47. package/extensions/llm-wiki/lib/knowledge-document.ts +21 -2
  48. package/extensions/llm-wiki/lib/knowledge-links.ts +208 -35
  49. package/extensions/llm-wiki/lib/metadata.ts +10 -6
  50. package/extensions/llm-wiki/lib/observation.ts +23 -3
  51. package/extensions/llm-wiki/lib/retro.ts +48 -4
  52. package/extensions/llm-wiki/lib/runtime.ts +2 -2
  53. package/extensions/llm-wiki/lib/settings-command.ts +483 -0
  54. package/extensions/llm-wiki/lib/task-config.ts +138 -0
  55. package/extensions/llm-wiki/lib/tools.ts +62 -8
  56. package/mcp/index.ts +12 -1
  57. package/mcp/operations.ts +32 -2
  58. package/package.json +4 -4
  59. package/prompts/wiki-ingest.md +1 -0
  60. package/prompts/wiki-req.md +1 -0
  61. package/prompts/wiki-retro.md +1 -0
  62. package/skills/llm-wiki/SKILL.md +11 -1
@@ -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
@@ -10,6 +10,23 @@ export const TASK_DEFAULTS = {};
10
10
  export function noticesEnabled(config) {
11
11
  return config?.notices !== false;
12
12
  }
13
+ const WIKILINK_VALIDATION_MODES = [
14
+ "off",
15
+ "warn",
16
+ "strict",
17
+ "normalize",
18
+ ];
19
+ /**
20
+ * Resolve the wikilink write-gate mode (issue #172, Layer 2). Defaults to
21
+ * `warn` — ingest always writes and reports; only an explicit `strict` blocks.
22
+ * Unknown values fall back to `warn` rather than failing the ingest.
23
+ */
24
+ export function resolveWikilinkValidation(config) {
25
+ const v = config?.wikilinkValidation;
26
+ if (v && WIKILINK_VALIDATION_MODES.includes(v))
27
+ return v;
28
+ return "warn";
29
+ }
13
30
  /**
14
31
  * Resolve whether the personal vault may serve as this project's ambient
15
32
  * vault. Explicit `ambientPersonalVault` wins; otherwise the host decides
@@ -87,6 +104,14 @@ function readNamespacedConfig(path) {
87
104
  if (canonical)
88
105
  out.synthesisLanguage = canonical;
89
106
  }
107
+ const maxTokens = section.synthesisMaxTokens;
108
+ if (typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0) {
109
+ out.synthesisMaxTokens = Math.floor(maxTokens);
110
+ }
111
+ const wl = section.wikilinkValidation;
112
+ if (typeof wl === "string" && WIKILINK_VALIDATION_MODES.includes(wl)) {
113
+ out.wikilinkValidation = wl;
114
+ }
90
115
  return out;
91
116
  }
92
117
  catch {
@@ -156,6 +181,19 @@ function readSettingsObject(path) {
156
181
  }
157
182
  return {};
158
183
  }
184
+ /**
185
+ * Rewrite the `llm-wiki` section of the global settings file.
186
+ */
187
+ function updateGlobalSection(mutate) {
188
+ const settingsPath = resolveGlobalSettingsPath();
189
+ const raw = readSettingsObject(settingsPath);
190
+ const existing = raw[SETTINGS_KEY];
191
+ const section = existing && typeof existing === "object" ? { ...existing } : {};
192
+ mutate(section);
193
+ raw[SETTINGS_KEY] = section;
194
+ mkdirSync(dirname(settingsPath), { recursive: true });
195
+ writeFileSync(settingsPath, `${JSON.stringify(raw, null, 2)}\n`, "utf-8");
196
+ }
159
197
  /**
160
198
  * Rewrite the `llm-wiki` section of the project settings file, preserving every
161
199
  * other top-level key and every other setting in the section.
@@ -224,3 +262,64 @@ export function loadTaskConfig(cwd) {
224
262
  }
225
263
  return config;
226
264
  }
265
+ /**
266
+ * Resolve where each setting is defined: project > global > default.
267
+ */
268
+ /** All known setting keys — needed because TASK_DEFAULTS is {} (zero-config). */
269
+ const KNOWN_KEYS = [
270
+ "taskModel",
271
+ "embeddingProvider",
272
+ "embeddingModel",
273
+ "embeddingBaseUrl",
274
+ "embeddingApiKey",
275
+ "embeddingApiKeyEnv",
276
+ "semanticWeight",
277
+ "recallLinksThreshold",
278
+ "recallSkillInlineMax",
279
+ "notices",
280
+ "ambientPersonalVault",
281
+ "trajectories",
282
+ "synthesisLanguage",
283
+ "synthesisMaxTokens",
284
+ "wikilinkValidation",
285
+ ];
286
+ export function loadTaskConfigSources(cwd) {
287
+ const globalResult = {};
288
+ for (const path of listGlobalSettingsFiles()) {
289
+ Object.assign(globalResult, readNamespacedConfig(path));
290
+ }
291
+ const projectResult = {};
292
+ for (const path of listProjectSettingsFiles(cwd)) {
293
+ Object.assign(projectResult, readNamespacedConfig(path));
294
+ }
295
+ const effective = loadTaskConfig(cwd);
296
+ const out = {};
297
+ for (const key of KNOWN_KEYS) {
298
+ if (key in projectResult)
299
+ out[key] = { value: projectResult[key], source: "project" };
300
+ else if (key in globalResult)
301
+ out[key] = { value: globalResult[key], source: "global" };
302
+ else
303
+ out[key] = { value: effective[key], source: "default" };
304
+ }
305
+ return out;
306
+ }
307
+ /**
308
+ * Generic setting persist: writes any single setting to the chosen scope.
309
+ */
310
+ export function persistSetting(cwd, scope, key, value) {
311
+ const mutate = (section) => {
312
+ if (value === undefined || value === null) {
313
+ delete section[key];
314
+ }
315
+ else {
316
+ section[key] = value;
317
+ }
318
+ };
319
+ if (scope === "project") {
320
+ updateProjectSection(cwd, mutate);
321
+ }
322
+ else if (scope === "global") {
323
+ updateGlobalSection(mutate);
324
+ }
325
+ }
@@ -6,11 +6,11 @@ import { launchEmbedPages, reindexEmbeddings, resolveEmbedder } from "./embeddin
6
6
  import { scheduleReindex } from "./indexing.js";
7
7
  import { runIngestSynthesis } from "./ingest-worker.js";
8
8
  import { createKnowledgeDocument, serializeKnowledgeDocument, writeKnowledgeDocumentFile, } from "./knowledge-document.js";
9
- import { buildResolvedBacklinks } from "./knowledge-links.js";
9
+ import { applyWikilinkGate, buildResolvedBacklinks, buildWikilinkIndex, } from "./knowledge-links.js";
10
10
  import { repairLegacyKnowledgeDocuments } from "./legacy-repair.js";
11
11
  import { appendEvent, rebuildMetadata, rebuildMetadataLight } from "./metadata.js";
12
12
  import { captureFile, captureText, captureUrl } from "./source-packet.js";
13
- import { parseModelRef } from "./task-config.js";
13
+ import { loadTaskConfig, parseModelRef, resolveWikilinkValidation } from "./task-config.js";
14
14
  import { detectVaultFormat, fmtDate, getVaultPaths, readJson, resolveVaultPaths, slugify, writeJson, } from "./utils.js";
15
15
  import { assertWritableVault, compareCodePoint, discoverKnowledgeDocuments, inspectVaultFormat, inspectWritableVault, } from "./vault-format.js";
16
16
  import { getWikiStatus, searchRegistry } from "./wiki-service.js";
@@ -328,6 +328,7 @@ export function registerWikiIngest(pi, runtime) {
328
328
  manifest: s.manifest,
329
329
  extracted: s.extracted,
330
330
  synthesisLanguage: runtime.config.synthesisLanguage,
331
+ wikilinkValidation: runtime.config.wikilinkValidation,
331
332
  });
332
333
  if (committed) {
333
334
  // Background semantic embeddings (#66): embed the pages this
@@ -341,8 +342,10 @@ export function registerWikiIngest(pi, runtime) {
341
342
  ];
342
343
  launchEmbedPages(runtime, launchCtx, paths, pageIds, `embed:ingest:${s.id}`);
343
344
  }
345
+ const wl = committed?.wikilinkDiagnostics?.length ?? 0;
346
+ const wlNote = wl > 0 ? `, ${wl} wikilink issue${wl === 1 ? "" : "s"}` : "";
344
347
  const summary = committed
345
- ? `LLM Wiki: ingested ${s.id} → ${committed.entitiesCreated.length} entit${committed.entitiesCreated.length === 1 ? "y" : "ies"}, ${committed.conceptsCreated.length} concept${committed.conceptsCreated.length === 1 ? "" : "s"}`
348
+ ? `LLM Wiki: ingested ${s.id} → ${committed.entitiesCreated.length} entit${committed.entitiesCreated.length === 1 ? "y" : "ies"}, ${committed.conceptsCreated.length} concept${committed.conceptsCreated.length === 1 ? "" : "s"}${wlNote}`
346
349
  : `LLM Wiki: ${s.id} produced no synthesis`;
347
350
  if (ctx.hasUI) {
348
351
  ctx.ui.notify(summary, committed ? "info" : "warning");
@@ -459,7 +462,31 @@ export function registerWikiEnsurePage(pi, runtime) {
459
462
  };
460
463
  }
461
464
  const today = fmtDate();
462
- const body = params.content ?? buildPageBody(type, params.title);
465
+ let body = params.content ?? buildPageBody(type, params.title);
466
+ // Pre-write wikilink gate (#172): validate/normalize caller-supplied content.
467
+ const mode = resolveWikilinkValidation(loadTaskConfig(ctx.cwd));
468
+ let wikilinkIssues = [];
469
+ if (mode !== "off") {
470
+ const registry = readJson(join(paths.meta, "registry.json"), { pages: {} });
471
+ const gate = applyWikilinkGate(body, buildWikilinkIndex(Object.keys(registry.pages)), `${folder}/${slug}`, mode);
472
+ wikilinkIssues = gate.diagnostics.map((d) => d.message);
473
+ if (!gate.ok) {
474
+ return {
475
+ content: [
476
+ {
477
+ type: "text",
478
+ text: `Rejected write — unresolved/ambiguous wikilinks:\n${wikilinkIssues
479
+ .map((m) => `- ${m}`)
480
+ .join("\n")}`,
481
+ },
482
+ ],
483
+ details: { error: "link_validation", issues: wikilinkIssues },
484
+ isError: true,
485
+ };
486
+ }
487
+ if (mode === "normalize")
488
+ body = gate.body;
489
+ }
463
490
  const doc = createKnowledgeDocument(`${folder}/${slug}.md`, {
464
491
  type,
465
492
  title: params.title,
@@ -483,9 +510,16 @@ export function registerWikiEnsurePage(pi, runtime) {
483
510
  else {
484
511
  rebuildMetadataLight(paths);
485
512
  }
513
+ const gateNote = wikilinkIssues.length
514
+ ? `\n\n⚠️ ${wikilinkIssues.length} wikilink issue(s):\n${wikilinkIssues.map((m) => `- ${m}`).join("\n")}`
515
+ : "";
486
516
  return {
487
- content: [{ type: "text", text: `✅ Created ${type} page: \`${pagePath}\`` }],
488
- details: { path: pagePath, created: true },
517
+ content: [{ type: "text", text: `✅ Created ${type} page: \`${pagePath}\`${gateNote}` }],
518
+ details: {
519
+ path: pagePath,
520
+ created: true,
521
+ wikilinkIssues,
522
+ },
489
523
  };
490
524
  },
491
525
  });
@@ -740,14 +774,14 @@ function runWikiLint(paths, autoFix) {
740
774
  }
741
775
  const discovery = discoverKnowledgeDocuments(paths);
742
776
  const pages = discovery.documents;
743
- const knownIds = new Set(pages.map((page) => page.id));
777
+ const wikilinkIndex = buildWikilinkIndex(pages.map((page) => page.id));
744
778
  const inbound = Object.fromEntries(pages.map((page) => [page.id, 0]));
745
779
  const gapSources = new Map();
746
780
  const findings = [];
747
781
  let missingPages = 0;
748
782
  let contradictions = 0;
749
783
  for (const page of pages) {
750
- const resolved = buildResolvedBacklinks(page.id, page.body, knownIds);
784
+ const resolved = buildResolvedBacklinks(page.id, page.body, wikilinkIndex);
751
785
  for (const target of resolved.targets)
752
786
  inbound[target]++;
753
787
  for (const unresolved of resolved.unresolved) {
@@ -757,6 +791,11 @@ function runWikiLint(paths, autoFix) {
757
791
  missingPages++;
758
792
  findings.push(`Missing page: ${unresolved.target} (in ${page.id})`);
759
793
  }
794
+ for (const d of resolved.diagnostics) {
795
+ if (d.code === "link_ambiguous") {
796
+ findings.push(d.message.replace("Ambiguous wikilink: ", "Ambiguous: "));
797
+ }
798
+ }
760
799
  }
761
800
  let orphans = 0;
762
801
  for (const page of pages) {
package/dist/mcp/index.js CHANGED
@@ -13,6 +13,7 @@ import { join } from "node:path";
13
13
  import { McpServer } from "@modelcontextprotocol/server";
14
14
  import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
15
15
  import * as z from "zod/v4";
16
+ import { loadTaskConfig, resolveWikilinkValidation, } from "../extensions/llm-wiki/lib/task-config.js";
16
17
  import { getVaultPaths, resolveVaultPaths } from "../extensions/llm-wiki/lib/utils.js";
17
18
  import { createExecApi } from "./exec.js";
18
19
  import { bootstrapOperation, captureSourceOperation, recallOperation, retroOperation, searchOperation, statusOperation, } from "./operations.js";
@@ -206,7 +207,7 @@ server.registerTool("wiki_retro", {
206
207
  };
207
208
  }
208
209
  const paths = getPaths();
209
- const result = await retroOperation(paths, slug, title, body, category);
210
+ const result = await retroOperation(paths, slug, title, body, category, resolveWikilinkValidation(loadTaskConfig(process.cwd())));
210
211
  if (!result.ok) {
211
212
  return {
212
213
  content: [