@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
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { basename, join } from "node:path";
3
3
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
4
  import { bootstrapVault } from "./lib/bootstrap.js";
5
+ import { registerWikiDashboardCommand } from "./lib/dashboard-command.js";
5
6
  import { installGuardrails } from "./lib/guardrails.js";
6
7
  import { buildAgentStartInjection, normalizeSystemPrompt } from "./lib/inject.js";
7
8
  import { registerWikiModelCommand } from "./lib/model-command.js";
@@ -20,6 +21,7 @@ import {
20
21
  } from "./lib/recall.js";
21
22
  import { registerWikiRetro } from "./lib/retro.js";
22
23
  import { registerBackgroundRuntime } from "./lib/runtime.js";
24
+ import { registerWikiSettingsCommand } from "./lib/settings-command.js";
23
25
  import {
24
26
  loadTaskConfig,
25
27
  noticesEnabled,
@@ -131,6 +133,8 @@ export default function (pi: ExtensionAPI) {
131
133
  // background task model. The taskModel config field + resolveModel already
132
134
  // exist; this exposes them to the user (default stays the session model).
133
135
  registerWikiModelCommand(pi, runtime);
136
+ registerWikiSettingsCommand(pi, runtime);
137
+ registerWikiDashboardCommand(pi, runtime);
134
138
  const reminderState = createReminderState();
135
139
  registerWikiObserve(pi, runtime, reminderState);
136
140
  // Visible observe/retro reminder by default (issue #77); silenced when the
@@ -0,0 +1,106 @@
1
+ /**
2
+ * /wiki-dashboard command.
3
+ *
4
+ * Read-only mirror of /wiki-settings: one persistent screen, Esc closes.
5
+ * Unlike settings there is no per-key change — every line is computed once
6
+ * from on-disk vault state (see lib/dashboard.ts) and rendered as plain
7
+ * text through a Container + Text.
8
+ */
9
+ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
10
+ import { Container, Text, matchesKey } from "@mariozechner/pi-tui";
11
+ import { type DashboardStats, collectDashboardStats } from "./dashboard.js";
12
+ import type { Runtime } from "./runtime.js";
13
+
14
+ type App = ExtensionAPI;
15
+
16
+ const TYPE_ORDER = [
17
+ "concept",
18
+ "entity",
19
+ "source",
20
+ "skill",
21
+ "analysis",
22
+ "synthesis",
23
+ "requirement",
24
+ "short",
25
+ "shared",
26
+ ];
27
+ const KIND_ORDER = ["observe", "retro", "synth", "intake"];
28
+
29
+ function orderedPairs(counts: Record<string, number>): string {
30
+ const keys = Object.keys(counts);
31
+ const known = TYPE_ORDER.filter((k) => counts[k] !== undefined);
32
+ const rest = keys
33
+ .filter((k) => !TYPE_ORDER.includes(k))
34
+ .sort()
35
+ .slice(0, 3); // cap the tail: the dashboard is a glance, not a census
36
+ const all = [...known, ...rest];
37
+ if (all.length === 0) return "—";
38
+ return all.map((k) => `${k} ${counts[k]}`).join(" · ");
39
+ }
40
+
41
+ function orderedKinds(counts: Record<string, number>): string {
42
+ const keys = Object.keys(counts);
43
+ const known = KIND_ORDER.filter((k) => counts[k] !== undefined);
44
+ const rest = keys
45
+ .filter((k) => !KIND_ORDER.includes(k))
46
+ .sort()
47
+ .slice(0, 3);
48
+ return [...known, ...rest].map((k) => `${k} ${counts[k]}`).join(" · ");
49
+ }
50
+
51
+ function renderStatsLines(s: DashboardStats): string[] {
52
+ const emb = s.embEnabled ? `${s.embFiles}/${s.pageCount}` : "—";
53
+ return [
54
+ `Wiki Dashboard · ${s.root}`,
55
+ "",
56
+ `Pages ${s.pageCount} · ${s.sizeKB}KB · ${orderedPairs(s.byType)}`,
57
+ `Fresh last touch ${s.lastTouch || "never"} · stale(>30d) ${s.staleCount}`,
58
+ `Acts 7d ${s.last7dTotal > 0 ? orderedKinds(s.last7dByKind) : "none"} · total ${s.last7dTotal}/${s.totalEvents}`,
59
+ `Queue raw ${s.rawQueue} · emb ${emb}`,
60
+ `Links zero-backlink ${s.zeroBacklinks} · full scan: /wiki-lint`,
61
+ "",
62
+ "Esc to close",
63
+ ];
64
+ }
65
+
66
+ /** One-screen read-only TUI; handles Esc and `q`. */
67
+ export class DashboardScreen extends Container {
68
+ private doneFn: (result?: unknown) => void;
69
+
70
+ constructor(lines: string[], close: (result?: unknown) => void) {
71
+ super();
72
+ this.doneFn = close;
73
+ this.addChild(new Text(lines.join("\n"), 1, 1));
74
+ }
75
+
76
+ handleInput(data: string): void {
77
+ // ponytail: matchesKey handles every terminal key shape (raw bytes,
78
+ // kitty CSI-u like \u001b[27u for Esc, ctrl combos) — a raw === "\u001b"
79
+ // check only works in terminals without the kitty keyboard protocol,
80
+ // which is exactly where Ghostty users' Esc went nowhere.
81
+ if (matchesKey(data, "escape") || matchesKey(data, "q")) {
82
+ this.doneFn();
83
+ }
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Register the /wiki-dashboard command.
89
+ */
90
+ export function registerWikiDashboardCommand(pi: ExtensionAPI, runtime: Runtime): void {
91
+ pi.registerCommand("wiki-dashboard", {
92
+ description:
93
+ "Show a read-only LLM Wiki dashboard (pages, freshness, activity, queue, links, emb)",
94
+ handler: async (_args: string, ctx: ExtensionCommandContext): Promise<void> => {
95
+ runtime.ensureConfig(ctx.cwd);
96
+ if (!ctx.hasUI) {
97
+ ctx.ui.notify("LLM Wiki: /wiki-dashboard requires an interactive UI.", "warning");
98
+ return;
99
+ }
100
+ const stats = await collectDashboardStats(ctx.cwd);
101
+ await ctx.ui.custom((_tui, _theme, _keybindings, close) => {
102
+ return new DashboardScreen(renderStatsLines(stats), close);
103
+ });
104
+ },
105
+ });
106
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Read-only vault statistics for the /wiki-dashboard screen.
3
+ *
4
+ * Everything is computed from pre-existing on-disk state:
5
+ * - page tree (paths.wiki + skills dir): counts, mtimes, sizes
6
+ * - meta/registry.json: page types (values; keys can carry legacy prefix noise)
7
+ * - meta/backlinks.json: zero-inbound pages
8
+ * - meta/events.jsonl: activity stream (observes/retros/syntheses...)
9
+ * - raw/sources: pending ingest queue
10
+ * - emb/: embedding coverage
11
+ *
12
+ * No writes, no LLM calls, no new files. Pure reader.
13
+ */
14
+ import { readFile, readdir, stat } from "node:fs/promises";
15
+ import type { VaultPaths } from "./utils.js";
16
+ import { readJson, resolveVaultPaths } from "./utils.js";
17
+
18
+ interface PageInfo {
19
+ rel: string;
20
+ mtime: number;
21
+ bytes: number;
22
+ }
23
+
24
+ export interface DashboardStats {
25
+ /** Vault root directory, as shown in the header. */
26
+ root: string;
27
+ /** Total .md pages across wiki + skills dirs (filesystem truth). */
28
+ pageCount: number;
29
+ /** Page count by registry type; unknown types fall back to the folder name. */
30
+ byType: Record<string, number>;
31
+ /** Total page size in KB (rounded up to at least 1). */
32
+ sizeKB: number;
33
+ /** Human age of the most recent page touch: "now" | "Xm" | "Xh" | "Xd" | "". */
34
+ lastTouch: string;
35
+ /** Pages untouched for 30+ days. */
36
+ staleCount: number;
37
+ /** Event kinds (observe/retro/synth/intake/...) seen in the last 7 days. */
38
+ last7dByKind: Record<string, number>;
39
+ /** Total events in the last 7 days. */
40
+ last7dTotal: number;
41
+ /** Total parseable events recorded. */
42
+ totalEvents: number;
43
+ /** Number of raw source packets awaiting ingest (subdirs of raw/sources). */
44
+ rawQueue: number;
45
+ /** Pages with zero backlinks (from backlinks.json). */
46
+ zeroBacklinks: number;
47
+ /** .bin files in emb/ (meaningful when embeddings are enabled). */
48
+ embFiles: number;
49
+ /** Emb dir is populated — embeddings appear enabled. */
50
+ embEnabled: boolean;
51
+ }
52
+
53
+ /** Directories excluded from the page walk (infrastructure, not pages). */
54
+ const SKIP_DIRS = new Set([
55
+ "templates",
56
+ "cases",
57
+ "outputs",
58
+ ".discoveries",
59
+ "raw",
60
+ "meta",
61
+ "emb",
62
+ ".git",
63
+ ]);
64
+
65
+ async function walkPages(dir: string, rel: string, out: PageInfo[]): Promise<void> {
66
+ try {
67
+ const entries = await readdir(dir, { withFileTypes: true });
68
+ for (const e of entries) {
69
+ const childRel = rel === "" ? e.name : `${rel}/${e.name}`;
70
+ if (e.isDirectory()) {
71
+ if (SKIP_DIRS.has(e.name)) continue;
72
+ await walkPages(`${dir}/${e.name}`, childRel, out);
73
+ continue;
74
+ }
75
+ if (!e.name.endsWith(".md")) continue;
76
+ try {
77
+ const st = await stat(`${dir}/${e.name}`);
78
+ out.push({ rel: childRel, mtime: st.mtimeMs, bytes: st.size });
79
+ } catch {
80
+ // unreadable file: skip
81
+ }
82
+ }
83
+ } catch {
84
+ // missing dir (empty vault): nothing to walk
85
+ }
86
+ }
87
+
88
+ function humanAge(ms: number): string {
89
+ const mins = Math.floor(ms / 60_000);
90
+ if (mins < 1) return "now";
91
+ if (mins < 60) return `${mins}m`;
92
+ const hrs = Math.floor(mins / 60);
93
+ if (hrs < 48) return `${hrs}h`;
94
+ return `${Math.floor(hrs / 24)}d`;
95
+ }
96
+
97
+ async function readEvents(metaDir: string) {
98
+ const byKind: Record<string, number> = {};
99
+ let recent = 0;
100
+ let total = 0;
101
+ try {
102
+ const raw = await readFile(`${metaDir}/events.jsonl`, "utf-8");
103
+ const cutoff = Date.now() - 7 * 86400_000;
104
+ for (const line of raw.split("\n")) {
105
+ if (!line.trim()) continue;
106
+ let ev: { timestamp?: unknown; kind?: unknown };
107
+ try {
108
+ ev = JSON.parse(line);
109
+ } catch {
110
+ continue; // corrupted line: skip, not fatal
111
+ }
112
+ const ts = typeof ev.timestamp === "string" ? Date.parse(ev.timestamp) : Number.NaN;
113
+ const kind = typeof ev.kind === "string" && ev.kind ? ev.kind : "event";
114
+ total += 1;
115
+ if (Number.isFinite(ts) && ts >= cutoff) {
116
+ byKind[kind] = (byKind[kind] ?? 0) + 1;
117
+ recent += 1;
118
+ }
119
+ }
120
+ } catch {
121
+ // no events file: zeros
122
+ }
123
+ return { byKind, recent, total };
124
+ }
125
+
126
+ /**
127
+ * Collect all dashboard stats for the vault reachable from `cwd`.
128
+ * Reuses the extension's own path resolution (same root the tools see).
129
+ */
130
+ export async function collectDashboardStats(cwd: string): Promise<DashboardStats> {
131
+ const paths: VaultPaths = resolveVaultPaths(cwd);
132
+
133
+ const pages: PageInfo[] = [];
134
+ await walkPages(paths.wiki, "", pages);
135
+ // skill pages register as type:skill and live under <dotWiki>/skills
136
+ try {
137
+ const st = await stat(`${paths.dotWiki}/skills`);
138
+ if (st.isDirectory()) {
139
+ await walkPages(`${paths.dotWiki}/skills`, "skills", pages);
140
+ }
141
+ } catch {
142
+ // no skills dir: fine
143
+ }
144
+
145
+ // type map from registry values — keys may carry legacy noise, so normalize
146
+ const registry = readJson<{ pages?: Record<string, { type?: string }> }>(
147
+ `${paths.meta}/registry.json`,
148
+ { pages: {} },
149
+ );
150
+ const typeByKey: Record<string, string> = {};
151
+ for (const [key, val] of Object.entries(registry.pages ?? {})) {
152
+ const norm = key.startsWith('"') ? key.slice(1) : key;
153
+ if (val?.type) typeByKey[norm] = val.type;
154
+ }
155
+ const byType: Record<string, number> = {};
156
+ for (const p of pages) {
157
+ const slug = p.rel.endsWith(".md") ? p.rel.slice(0, -3) : p.rel;
158
+ const type = typeByKey[slug] ?? p.rel.split("/")[0] ?? "other";
159
+ byType[type] = (byType[type] ?? 0) + 1;
160
+ }
161
+
162
+ let sizeBytes = 0;
163
+ let latestMtime = 0;
164
+ const staleCutoff = Date.now() - 30 * 86400_000;
165
+ let stale = 0;
166
+ for (const p of pages) {
167
+ sizeBytes += p.bytes;
168
+ if (p.mtime > latestMtime) latestMtime = p.mtime;
169
+ if (p.mtime < staleCutoff) stale += 1;
170
+ }
171
+
172
+ const events = await readEvents(paths.meta);
173
+
174
+ let rawQueue = 0;
175
+ try {
176
+ const pkts = await readdir(paths.rawSources, { withFileTypes: true });
177
+ for (const e of pkts) {
178
+ if (e.isDirectory()) rawQueue += 1; // each subdirectory is one packet
179
+ }
180
+ } catch {
181
+ // no raw/sources: empty queue
182
+ }
183
+
184
+ const backlinks = readJson<Record<string, string[]>>(`${paths.meta}/backlinks.json`, {});
185
+ const zeroBacklinks = Object.values(backlinks).filter((v) => (v ?? []).length === 0).length;
186
+
187
+ let embFiles = 0;
188
+ try {
189
+ const embEntries = await readdir(`${paths.dotWiki}/emb`);
190
+ embFiles = embEntries.filter((f) => f.endsWith(".bin")).length;
191
+ } catch {
192
+ // no emb dir: none
193
+ }
194
+
195
+ return {
196
+ root: paths.root,
197
+ pageCount: pages.length,
198
+ byType,
199
+ sizeKB: Math.max(1, Math.round(sizeBytes / 1024)),
200
+ lastTouch: pages.length === 0 ? "" : humanAge(Date.now() - latestMtime),
201
+ staleCount: stale,
202
+ last7dByKind: events.byKind,
203
+ last7dTotal: events.recent,
204
+ totalEvents: events.total,
205
+ rawQueue,
206
+ zeroBacklinks,
207
+ embFiles,
208
+ embEnabled: embFiles > 0,
209
+ };
210
+ }
@@ -79,12 +79,34 @@ function parseMoveDestination(line: string): string | undefined {
79
79
  function scanPatchString(input: string): MutationScan {
80
80
  const paths: string[] = [];
81
81
  let sawHeader = false;
82
+ let sawApplyPatchHeader = false;
83
+ let applyPatchComplete = true;
82
84
  let sectionHasMove = false;
83
85
  let complete = true;
84
86
  const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
85
87
 
86
88
  for (const line of stripped.split("\n")) {
87
- const trimmed = line.replace(/\r$/, "").trim();
89
+ const raw = line.replace(/\r$/, "");
90
+ // apply_patch envelope headers (#162): matched on the raw (untrimmed)
91
+ // line so body rows (space/+/- prefixed) that quote the envelope shape
92
+ // are never mistaken for headers.
93
+ const fileOp = /^\*{3}\s+(?:update|add|delete)\s+file:\s*(\S.*)$/i.exec(raw);
94
+ if (fileOp) {
95
+ sawApplyPatchHeader = true;
96
+ const path = normalizeMutationPath(fileOp[1]);
97
+ if (path) paths.push(path);
98
+ else applyPatchComplete = false;
99
+ continue;
100
+ }
101
+ const moveTo = /^\*{3}\s+move\s+to:\s*(\S.*)$/i.exec(raw);
102
+ if (moveTo) {
103
+ sawApplyPatchHeader = true;
104
+ const destination = normalizeMutationPath(moveTo[1]);
105
+ if (destination) paths.push(destination);
106
+ else applyPatchComplete = false;
107
+ continue;
108
+ }
109
+ const trimmed = raw.trim();
88
110
  if (trimmed.startsWith("[")) {
89
111
  sawHeader = true;
90
112
  sectionHasMove = false;
@@ -102,6 +124,9 @@ function scanPatchString(input: string): MutationScan {
102
124
  }
103
125
  }
104
126
 
127
+ if (sawApplyPatchHeader) {
128
+ return { paths, complete: applyPatchComplete && paths.length > 0 };
129
+ }
105
130
  return { paths, complete: sawHeader && complete };
106
131
  }
107
132
 
@@ -63,7 +63,7 @@ export function detectHost(): HostKind {
63
63
  if (segments.includes(".pi")) return "pi";
64
64
  }
65
65
 
66
- if (process.env.OMP_PROFILE !== undefined) return "omp";
66
+ if (process.env.OMP_PROFILE) return "omp";
67
67
  return "pi";
68
68
  }
69
69
 
@@ -123,3 +123,23 @@ export function resolveProjectSettingsPath(cwd: string, host: HostKind = detectH
123
123
 
124
124
  return join(native, "settings.json");
125
125
  }
126
+
127
+ /**
128
+ * The global (user-level) settings file this extension writes to.
129
+ *
130
+ * Always writes to `settings.json` inside the agent dir — both hosts read it.
131
+ */
132
+ export function resolveGlobalSettingsPath(host: HostKind = detectHost()): string {
133
+ let agentDir = "";
134
+ try {
135
+ agentDir = getAgentDir();
136
+ } catch {
137
+ agentDir = "";
138
+ }
139
+ if (!agentDir) {
140
+ // Fallback: ~/.pi/agent/settings.json
141
+ const home = process.env.HOME || "~";
142
+ return join(home, ".pi", "agent", "settings.json");
143
+ }
144
+ return join(agentDir, "settings.json");
145
+ }
@@ -13,9 +13,15 @@ import {
13
13
  serializeKnowledgeDocument,
14
14
  writeKnowledgeDocumentFile,
15
15
  } from "./knowledge-document.js";
16
+ import {
17
+ type WikilinkValidationMode,
18
+ auditWikilinks,
19
+ buildWikilinkIndex,
20
+ } from "./knowledge-links.js";
16
21
  import { appendEvent, rebuildMetadataLight } from "./metadata.js";
17
22
  import { runSubAgent } from "./subagent.js";
18
- import { type VaultPaths, fmtDate, slugify } from "./utils.js";
23
+ import { resolveWikilinkValidation } from "./task-config.js";
24
+ import { type VaultPaths, fmtDate, readJson, slugify } from "./utils.js";
19
25
  import { VaultWriteError, assertWritableVault } from "./vault-format.js";
20
26
 
21
27
  /**
@@ -84,6 +90,8 @@ export interface CommitResult {
84
90
  entitiesLinked: string[];
85
91
  conceptsLinked: string[];
86
92
  contradictions: number;
93
+ /** Wikilink gate diagnostics from `commitSynthesis` (warn/normalize modes). */
94
+ wikilinkDiagnostics?: KnowledgeDiagnostic[];
87
95
  }
88
96
 
89
97
  export type CommitSynthesisOutcome =
@@ -186,43 +194,23 @@ function getHeadings(lang?: string): Record<string, string> {
186
194
  };
187
195
  }
188
196
 
189
- function buildEntityPageBody(
190
- title: string,
191
- description: string,
192
- sourceId: string,
193
- lang?: string,
194
- ): string {
197
+ function buildEntityPageBody(title: string, description: string, sourceId: string): string {
195
198
  const desc = description.trim() || "One-line description.";
196
- const h = getHeadings(lang);
197
199
  return `# ${title}
198
200
 
199
201
  ${desc}
200
202
 
201
- ## ${h.overview}
202
-
203
- [Key facts]
204
-
205
203
  ## Links
206
204
 
207
205
  - [${sourceId}](/sources/${sourceId}.md)`;
208
206
  }
209
207
 
210
- function buildConceptPageBody(
211
- title: string,
212
- definition: string,
213
- sourceId: string,
214
- lang?: string,
215
- ): string {
208
+ function buildConceptPageBody(title: string, definition: string, sourceId: string): string {
216
209
  const def = definition.trim() || "One-line definition.";
217
- const h = getHeadings(lang);
218
210
  return `# ${title}
219
211
 
220
212
  ${def}
221
213
 
222
- ## ${h.definition}
223
-
224
- [Clear explanation]
225
-
226
214
  ## Links
227
215
 
228
216
  - [${sourceId}](/sources/${sourceId}.md)`;
@@ -321,6 +309,28 @@ export function buildIngestedSourcePage(
321
309
  return serializeKnowledgeDocument(doc);
322
310
  }
323
311
 
312
+ /**
313
+ * Build the wikilink index used by the pre-write gate: every existing page id
314
+ * (from the registry) plus the page ids this commit is about to create, so a
315
+ * link to a sibling created in the same ingest resolves instead of
316
+ * false-positiving as missing.
317
+ */
318
+ function buildIngestAuditIndex(
319
+ paths: VaultPaths,
320
+ sourceId: string,
321
+ data: SynthesisData,
322
+ ): ReturnType<typeof buildWikilinkIndex> {
323
+ const registry = readJson<{ pages: Record<string, unknown> }>(join(paths.meta, "registry.json"), {
324
+ pages: {},
325
+ });
326
+ const newIds = [
327
+ `sources/${sourceId}`,
328
+ ...data.entities.filter((e) => slugify(e.title)).map((e) => `entities/${slugify(e.title)}`),
329
+ ...data.concepts.filter((c) => slugify(c.title)).map((c) => `concepts/${slugify(c.title)}`),
330
+ ];
331
+ return buildWikilinkIndex([...Object.keys(registry.pages), ...newIds]);
332
+ }
333
+
324
334
  /**
325
335
  * Persist a synthesis deterministically: rewrite the source page (status →
326
336
  * ingested), create missing entity/concept pages (existing pages are linked,
@@ -333,6 +343,7 @@ export function commitSynthesis(
333
343
  data: SynthesisData,
334
344
  date: string = fmtDate(),
335
345
  lang?: string,
346
+ wikilinkValidation?: WikilinkValidationMode,
336
347
  ): CommitSynthesisOutcome {
337
348
  const result: CommitResult = {
338
349
  sourceId,
@@ -353,6 +364,24 @@ export function commitSynthesis(
353
364
  throw error;
354
365
  }
355
366
 
367
+ // Pre-write wikilink gate (issue #172, Layer 2). Applies only to the
368
+ // model-authored source body; entity/concept pages are generated templates.
369
+ const mode = resolveWikilinkValidation({ wikilinkValidation });
370
+ let sourceBody = buildIngestedSourcePageBody(manifest, data, date, lang);
371
+ if (mode !== "off") {
372
+ const audit = auditWikilinks(
373
+ sourceBody,
374
+ buildIngestAuditIndex(paths, sourceId, data),
375
+ sourceId,
376
+ mode,
377
+ );
378
+ if (mode === "strict" && audit.diagnostics.length > 0) {
379
+ return { ok: false, sourceId, diagnostics: audit.diagnostics };
380
+ }
381
+ if (mode === "normalize") sourceBody = audit.body;
382
+ if (audit.diagnostics.length > 0) result.wikilinkDiagnostics = audit.diagnostics;
383
+ }
384
+
356
385
  // Patch existing documents so unknown fields, legacy sources, and titles survive.
357
386
  let sourceDocument: KnowledgeDocument;
358
387
  if (existsSync(result.sourcePage)) {
@@ -360,7 +389,7 @@ export function commitSynthesis(
360
389
  if (!parsed.ok) return { ok: false, sourceId, diagnostics: parsed.diagnostics };
361
390
  sourceDocument = patchKnowledgeDocument(parsed.document, {
362
391
  fields: { status: "ingested", updated: date },
363
- body: buildIngestedSourcePageBody(manifest, data, date, lang),
392
+ body: sourceBody,
364
393
  });
365
394
  } else {
366
395
  sourceDocument = createKnowledgeDocument(
@@ -375,7 +404,7 @@ export function commitSynthesis(
375
404
  status: "ingested",
376
405
  updated: date,
377
406
  },
378
- buildIngestedSourcePageBody(manifest, data, date, lang),
407
+ sourceBody,
379
408
  );
380
409
  }
381
410
  mkdirSync(join(paths.wiki, "sources"), { recursive: true });
@@ -399,7 +428,7 @@ export function commitSynthesis(
399
428
  created: date,
400
429
  updated: date,
401
430
  },
402
- buildEntityPageBody(e.title, e.description, sourceId, lang),
431
+ buildEntityPageBody(e.title, e.description, sourceId),
403
432
  [{ id: sourceId, resource: `/sources/${sourceId}.md` }],
404
433
  );
405
434
  writeKnowledgeDocumentFile(pagePath, entityDoc);
@@ -425,7 +454,7 @@ export function commitSynthesis(
425
454
  created: date,
426
455
  updated: date,
427
456
  },
428
- buildConceptPageBody(c.title, c.definition, sourceId, lang),
457
+ buildConceptPageBody(c.title, c.definition, sourceId),
429
458
  [{ id: sourceId, resource: `/sources/${sourceId}.md` }],
430
459
  );
431
460
  writeKnowledgeDocumentFile(pagePath, conceptDoc);
@@ -475,6 +504,10 @@ export interface RunIngestSynthesisArgs {
475
504
  signal?: AbortSignal;
476
505
  /** BCP 47 language tag for narrative content (issue #124). */
477
506
  synthesisLanguage?: string;
507
+ /** Max output tokens for synthesizer sub-agent (issue #160). Default 16384. */
508
+ synthesisMaxTokens?: number;
509
+ /** Wikilink write-gate mode for the ingested source body (issue #172). */
510
+ wikilinkValidation?: WikilinkValidationMode;
478
511
  }
479
512
 
480
513
  /**
@@ -496,6 +529,8 @@ export async function runIngestSynthesis(
496
529
  maxChars,
497
530
  signal,
498
531
  synthesisLanguage,
532
+ synthesisMaxTokens,
533
+ wikilinkValidation,
499
534
  } = args;
500
535
  const content = extracted.slice(0, maxChars ?? 24_000);
501
536
  if (!content.trim()) return undefined;
@@ -521,6 +556,7 @@ export async function runIngestSynthesis(
521
556
  params,
522
557
  undefined,
523
558
  synthesisLanguage,
559
+ wikilinkValidation,
524
560
  );
525
561
  if (!outcome.ok) {
526
562
  return {
@@ -549,6 +585,7 @@ export async function runIngestSynthesis(
549
585
  systemPrompt,
550
586
  userPrompt,
551
587
  tools: [commitTool as AgentTool],
588
+ maxTokens: synthesisMaxTokens ?? 16384,
552
589
  signal,
553
590
  });
554
591
 
@@ -21,6 +21,7 @@ export type DiagnosticCode =
21
21
  | "okf_version_mismatch"
22
22
  | "link_path_escape"
23
23
  | "link_unresolved"
24
+ | "link_ambiguous"
24
25
  | "event_source_missing"
25
26
  | "event_source_unreadable"
26
27
  | "event_invalid_json"
@@ -588,6 +589,22 @@ export function serializeKnowledgeDocument(document: KnowledgeDocument): string
588
589
  return body ? `---\n${yaml}---\n\n${body}\n` : `---\n${yaml}---\n`;
589
590
  }
590
591
 
592
+ /** Escape wikilink alias pipes so generated content remains valid in Markdown tables. */
593
+ function escapeWikilinkAliasPipes(body: string): string {
594
+ let inFence = false;
595
+ return body
596
+ .split("\n")
597
+ .map((line) => {
598
+ if (/^\s*(`{3,}|~{3,})/.test(line)) {
599
+ inFence = !inFence;
600
+ return line;
601
+ }
602
+ if (inFence) return line;
603
+ return line.replace(/\[\[([^\]\n]*?)(?<!\\)\|([^\]\n]*?)\]\]/g, "[[$1\\|$2]]");
604
+ })
605
+ .join("\n");
606
+ }
607
+
591
608
  export function createKnowledgeDocument<T extends KnowledgeCreationFields>(
592
609
  path: string,
593
610
  fields: T & NoInfer<KnowledgeCreationFields>,
@@ -614,7 +631,7 @@ export function createKnowledgeDocument<T extends KnowledgeCreationFields>(
614
631
  ? { kind: "canonical" as const, value: sources }
615
632
  : { kind: "absent" as const };
616
633
 
617
- const normalizedBody = body.replace(/\r\n?/g, "\n").replace(/\n*$/, "");
634
+ const normalizedBody = escapeWikilinkAliasPipes(body.replace(/\r\n?/g, "\n")).replace(/\n*$/, "");
618
635
 
619
636
  return {
620
637
  id: path.replace(/\.md$/, ""),
@@ -643,7 +660,9 @@ export function patchKnowledgeDocument(
643
660
  }
644
661
  }
645
662
 
646
- const newBody = patch.body ?? document.body;
663
+ const newBody = patch.body
664
+ ? escapeWikilinkAliasPipes(patch.body.replace(/\r\n?/g, "\n"))
665
+ : document.body;
647
666
 
648
667
  return {
649
668
  ...document,