@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,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
+ }
@@ -6,8 +6,10 @@ import {
6
6
  detectHost,
7
7
  listGlobalSettingsFiles,
8
8
  listProjectSettingsFiles,
9
+ resolveGlobalSettingsPath,
9
10
  resolveProjectSettingsPath,
10
11
  } from "./host.js";
12
+ import type { WikilinkValidationMode } from "./knowledge-links.js";
11
13
 
12
14
  /**
13
15
  * Configuration for the background-task lane (issue #64, part of #63).
@@ -139,6 +141,24 @@ export interface TaskConfig {
139
141
  * source content and technical identifiers remain unchanged.
140
142
  */
141
143
  synthesisLanguage?: string;
144
+
145
+ /**
146
+ * Max output tokens for the synthesizer sub-agent (issue #160). Default 16384.
147
+ * Reasoning models consume tokens on thinking, so 4096 is too low — the
148
+ * response truncates before commit_synthesis can be called.
149
+ */
150
+ synthesisMaxTokens?: number;
151
+
152
+ /**
153
+ * Wikilink gate applied to the ingested source body before pages are
154
+ * written (issue #172, Layer 2). Reuses the Layer 1 resolver.
155
+ * - "off" : no-op.
156
+ * - "warn" : write proceeds; unresolved/ambiguous links are reported.
157
+ * - "strict" : block the write (commit returns ok:false) if any link is unresolvable.
158
+ * - "normalize" : rewrite resolvable links to their canonical id, then write.
159
+ * Default "warn". See `resolveWikilinkValidation`.
160
+ */
161
+ wikilinkValidation?: WikilinkValidationMode;
142
162
  }
143
163
 
144
164
  export const TASK_DEFAULTS: TaskConfig = {};
@@ -151,6 +171,24 @@ export function noticesEnabled(config: TaskConfig | undefined): boolean {
151
171
  return config?.notices !== false;
152
172
  }
153
173
 
174
+ const WIKILINK_VALIDATION_MODES: readonly WikilinkValidationMode[] = [
175
+ "off",
176
+ "warn",
177
+ "strict",
178
+ "normalize",
179
+ ];
180
+
181
+ /**
182
+ * Resolve the wikilink write-gate mode (issue #172, Layer 2). Defaults to
183
+ * `warn` — ingest always writes and reports; only an explicit `strict` blocks.
184
+ * Unknown values fall back to `warn` rather than failing the ingest.
185
+ */
186
+ export function resolveWikilinkValidation(config: TaskConfig | undefined): WikilinkValidationMode {
187
+ const v = config?.wikilinkValidation;
188
+ if (v && (WIKILINK_VALIDATION_MODES as readonly string[]).includes(v)) return v;
189
+ return "warn";
190
+ }
191
+
154
192
  /**
155
193
  * Resolve whether the personal vault may serve as this project's ambient
156
194
  * vault. Explicit `ambientPersonalVault` wins; otherwise the host decides
@@ -238,6 +276,16 @@ function readNamespacedConfig(path: string): Partial<TaskConfig> {
238
276
  if (canonical) out.synthesisLanguage = canonical;
239
277
  }
240
278
 
279
+ const maxTokens = section.synthesisMaxTokens;
280
+ if (typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0) {
281
+ out.synthesisMaxTokens = Math.floor(maxTokens);
282
+ }
283
+
284
+ const wl = section.wikilinkValidation;
285
+ if (typeof wl === "string" && (WIKILINK_VALIDATION_MODES as readonly string[]).includes(wl)) {
286
+ out.wikilinkValidation = wl as WikilinkValidationMode;
287
+ }
288
+
241
289
  return out;
242
290
  } catch {
243
291
  return {};
@@ -304,6 +352,24 @@ function readSettingsObject(path: string): Record<string, unknown> {
304
352
  return {};
305
353
  }
306
354
 
355
+ /**
356
+ * Rewrite the `llm-wiki` section of the global settings file.
357
+ */
358
+ function updateGlobalSection(mutate: (section: Record<string, unknown>) => void): void {
359
+ const settingsPath = resolveGlobalSettingsPath();
360
+ const raw = readSettingsObject(settingsPath);
361
+
362
+ const existing = raw[SETTINGS_KEY];
363
+ const section: Record<string, unknown> =
364
+ existing && typeof existing === "object" ? { ...(existing as Record<string, unknown>) } : {};
365
+
366
+ mutate(section);
367
+ raw[SETTINGS_KEY] = section;
368
+
369
+ mkdirSync(dirname(settingsPath), { recursive: true });
370
+ writeFileSync(settingsPath, `${JSON.stringify(raw, null, 2)}\n`, "utf-8");
371
+ }
372
+
307
373
  /**
308
374
  * Rewrite the `llm-wiki` section of the project settings file, preserving every
309
375
  * other top-level key and every other setting in the section.
@@ -383,3 +449,75 @@ export function loadTaskConfig(cwd: string): TaskConfig {
383
449
  }
384
450
  return config;
385
451
  }
452
+
453
+ // ── Settings source tracking (for /wiki-settings TUI) ──────────
454
+
455
+ export type SettingScope = "default" | "global" | "project";
456
+
457
+ /**
458
+ * Resolve where each setting is defined: project > global > default.
459
+ */
460
+ /** All known setting keys — needed because TASK_DEFAULTS is {} (zero-config). */
461
+ const KNOWN_KEYS = [
462
+ "taskModel",
463
+ "embeddingProvider",
464
+ "embeddingModel",
465
+ "embeddingBaseUrl",
466
+ "embeddingApiKey",
467
+ "embeddingApiKeyEnv",
468
+ "semanticWeight",
469
+ "recallLinksThreshold",
470
+ "recallSkillInlineMax",
471
+ "notices",
472
+ "ambientPersonalVault",
473
+ "trajectories",
474
+ "synthesisLanguage",
475
+ "synthesisMaxTokens",
476
+ "wikilinkValidation",
477
+ ] as const;
478
+
479
+ export function loadTaskConfigSources(
480
+ cwd: string,
481
+ ): Record<string, { value: unknown; source: SettingScope }> {
482
+ const globalResult: Record<string, unknown> = {};
483
+ for (const path of listGlobalSettingsFiles()) {
484
+ Object.assign(globalResult, readNamespacedConfig(path));
485
+ }
486
+ const projectResult: Record<string, unknown> = {};
487
+ for (const path of listProjectSettingsFiles(cwd)) {
488
+ Object.assign(projectResult, readNamespacedConfig(path));
489
+ }
490
+ const effective = loadTaskConfig(cwd);
491
+
492
+ const out: Record<string, { value: unknown; source: SettingScope }> = {};
493
+ for (const key of KNOWN_KEYS) {
494
+ if (key in projectResult) out[key] = { value: projectResult[key], source: "project" };
495
+ else if (key in globalResult) out[key] = { value: globalResult[key], source: "global" };
496
+ else out[key] = { value: (effective as Record<string, unknown>)[key], source: "default" };
497
+ }
498
+ return out;
499
+ }
500
+
501
+ /**
502
+ * Generic setting persist: writes any single setting to the chosen scope.
503
+ */
504
+ export function persistSetting(
505
+ cwd: string,
506
+ scope: SettingScope,
507
+ key: string,
508
+ value: unknown,
509
+ ): void {
510
+ const mutate = (section: Record<string, unknown>) => {
511
+ if (value === undefined || value === null) {
512
+ delete section[key];
513
+ } else {
514
+ section[key] = value;
515
+ }
516
+ };
517
+
518
+ if (scope === "project") {
519
+ updateProjectSection(cwd, mutate);
520
+ } else if (scope === "global") {
521
+ updateGlobalSection(mutate);
522
+ }
523
+ }