@zosmaai/pi-llm-wiki 0.11.5 → 0.11.7

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 (35) hide show
  1. package/README.de.md +1 -0
  2. package/README.es.md +1 -0
  3. package/README.fr.md +1 -0
  4. package/README.hi.md +1 -0
  5. package/README.ja.md +1 -0
  6. package/README.ko.md +1 -0
  7. package/README.md +1 -0
  8. package/README.pt.md +1 -0
  9. package/README.ru.md +1 -0
  10. package/README.zh.md +1 -0
  11. package/dist/extensions/llm-wiki/lib/ingest-worker.js +43 -20
  12. package/dist/extensions/llm-wiki/lib/knowledge-links.js +127 -24
  13. package/dist/extensions/llm-wiki/lib/metadata.js +5 -5
  14. package/dist/extensions/llm-wiki/lib/retro.js +38 -4
  15. package/dist/extensions/llm-wiki/lib/runtime.js +2 -2
  16. package/dist/extensions/llm-wiki/lib/task-config.js +35 -0
  17. package/dist/extensions/llm-wiki/lib/tools.js +53 -12
  18. package/dist/mcp/index.js +2 -1
  19. package/dist/mcp/operations.js +21 -2
  20. package/docs/configuration.md +1 -0
  21. package/docs/obsidian.md +6 -6
  22. package/docs/superpowers/plans/2026-08-27-wikilink-resolver-normalization.md +735 -0
  23. package/docs/superpowers/plans/2026-08-29-wikilink-gate-ensure-page-retro.md +642 -0
  24. package/docs/superpowers/plans/2026-08-29-wikilink-write-validation.md +695 -0
  25. package/extensions/llm-wiki/lib/ingest-worker.ts +60 -27
  26. package/extensions/llm-wiki/lib/knowledge-document.ts +1 -0
  27. package/extensions/llm-wiki/lib/knowledge-links.ts +201 -32
  28. package/extensions/llm-wiki/lib/metadata.ts +9 -5
  29. package/extensions/llm-wiki/lib/retro.ts +48 -4
  30. package/extensions/llm-wiki/lib/runtime.ts +2 -2
  31. package/extensions/llm-wiki/lib/task-config.ts +59 -0
  32. package/extensions/llm-wiki/lib/tools.ts +68 -20
  33. package/mcp/index.ts +12 -1
  34. package/mcp/operations.ts +32 -2
  35. package/package.json +8 -3
@@ -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);
@@ -477,6 +506,8 @@ export interface RunIngestSynthesisArgs {
477
506
  synthesisLanguage?: string;
478
507
  /** Max output tokens for synthesizer sub-agent (issue #160). Default 16384. */
479
508
  synthesisMaxTokens?: number;
509
+ /** Wikilink write-gate mode for the ingested source body (issue #172). */
510
+ wikilinkValidation?: WikilinkValidationMode;
480
511
  }
481
512
 
482
513
  /**
@@ -499,6 +530,7 @@ export async function runIngestSynthesis(
499
530
  signal,
500
531
  synthesisLanguage,
501
532
  synthesisMaxTokens,
533
+ wikilinkValidation,
502
534
  } = args;
503
535
  const content = extracted.slice(0, maxChars ?? 24_000);
504
536
  if (!content.trim()) return undefined;
@@ -524,6 +556,7 @@ export async function runIngestSynthesis(
524
556
  params,
525
557
  undefined,
526
558
  synthesisLanguage,
559
+ wikilinkValidation,
527
560
  );
528
561
  if (!outcome.ok) {
529
562
  return {
@@ -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"
@@ -1,6 +1,7 @@
1
1
  import type { Definition, Link, LinkReference, Nodes, Root } from "mdast";
2
2
  import { fromMarkdown } from "mdast-util-from-markdown";
3
3
  import type { KnowledgeDiagnostic } from "./knowledge-document.js";
4
+ import { slugify } from "./utils.js";
4
5
  import { compareCodePoint } from "./vault-format.js";
5
6
 
6
7
  export interface ExtractedLink {
@@ -120,6 +121,74 @@ export function extractLegacyWikilinks(body: string): ExtractedLink[] {
120
121
  return links;
121
122
  }
122
123
 
124
+ // ── Wikilink normalization ──────────────────────────────────────────
125
+
126
+ export interface WikilinkIndex {
127
+ /** NFC-normalized id → canonical id. */
128
+ byExact: Map<string, string>;
129
+ /** Slugified full path → matching canonical ids. */
130
+ byNormPath: Map<string, string[]>;
131
+ /** Slugified basename (no folder) → matching canonical ids. */
132
+ byNormSlug: Map<string, string[]>;
133
+ }
134
+
135
+ export function buildWikilinkIndex(ids: Iterable<string>): WikilinkIndex {
136
+ const byExact = new Map<string, string>();
137
+ const byNormPath = new Map<string, string[]>();
138
+ const byNormSlug = new Map<string, string[]>();
139
+
140
+ function push<K>(map: Map<K, string[]>, key: K, value: string): void {
141
+ const existing = map.get(key);
142
+ if (existing) existing.push(value);
143
+ else map.set(key, [value]);
144
+ }
145
+
146
+ for (const id of ids) {
147
+ byExact.set(id.normalize("NFC"), id);
148
+ const segments = id.split("/");
149
+ const normFull = segments.map((s) => slugify(s)).join("/");
150
+ const normBase = slugify(segments[segments.length - 1]);
151
+ push(byNormPath, normFull, id);
152
+ push(byNormSlug, normBase, id);
153
+ }
154
+
155
+ return { byExact, byNormPath, byNormSlug };
156
+ }
157
+
158
+ export type WikilinkResolution =
159
+ | { kind: "resolved"; id: string }
160
+ | { kind: "ambiguous"; target: string; candidates: string[] }
161
+ | { kind: "missing"; target: string };
162
+
163
+ export function resolveWikilink(target: string, index: WikilinkIndex): WikilinkResolution {
164
+ const cleaned = target.trim().replace(/\\$/, "");
165
+ if (!cleaned) return { kind: "missing", target: "" };
166
+
167
+ // Fast path: exact NFC match
168
+ const exact = index.byExact.get(cleaned.normalize("NFC"));
169
+ if (exact) return { kind: "resolved", id: exact };
170
+
171
+ // Normalized full path (fixes case/space/slug drift in folder-qualified links)
172
+ const normFull = cleaned
173
+ .split("/")
174
+ .map((s) => slugify(s))
175
+ .join("/");
176
+ const pathHits = index.byNormPath.get(normFull);
177
+ if (pathHits && pathHits.length === 1) return { kind: "resolved", id: pathHits[0] };
178
+ if (pathHits && pathHits.length > 1)
179
+ return { kind: "ambiguous", target: cleaned, candidates: pathHits };
180
+
181
+ // Bare title: match by slugified basename (handles [[zosma harness]] → entities/zosma-harness)
182
+ if (!cleaned.includes("/")) {
183
+ const baseHits = index.byNormSlug.get(slugify(cleaned));
184
+ if (baseHits && baseHits.length === 1) return { kind: "resolved", id: baseHits[0] };
185
+ if (baseHits && baseHits.length > 1)
186
+ return { kind: "ambiguous", target: cleaned, candidates: baseHits };
187
+ }
188
+
189
+ return { kind: "missing", target: cleaned };
190
+ }
191
+
123
192
  function resolveMarkdownTarget(
124
193
  target: string,
125
194
  sourceId: string,
@@ -204,19 +273,10 @@ function resolveMarkdownTarget(
204
273
  return { kind: "empty" };
205
274
  }
206
275
 
207
- function resolveWikilinkTarget(
208
- target: string,
209
- ): { kind: "concept"; id: string } | { kind: "empty" } {
210
- // Wikilinks are already bundle-relative concept IDs
211
- const cleaned = target.trim();
212
- if (!cleaned) return { kind: "empty" };
213
- return { kind: "concept", id: cleaned };
214
- }
215
-
216
276
  export function buildResolvedBacklinks(
217
277
  sourceId: string,
218
278
  body: string,
219
- knownIds: Set<string>,
279
+ index: WikilinkIndex,
220
280
  ): ResolvedBacklinks {
221
281
  const diagnostics: KnowledgeDiagnostic[] = [];
222
282
  const unresolved: UnresolvedKnowledgeLink[] = [];
@@ -245,37 +305,38 @@ export function buildResolvedBacklinks(
245
305
  ),
246
306
  );
247
307
  } else if (resolved.kind === "concept") {
248
- const normalizedId = resolved.id.normalize("NFC");
249
- if (knownIds.has(normalizedId)) {
250
- targets.add(normalizedId);
308
+ const canonical = index.byExact.get(resolved.id.normalize("NFC"));
309
+ if (canonical) {
310
+ targets.add(canonical);
251
311
  } else {
252
- unresolved.push({ target: normalizedId, syntax: "markdown" });
312
+ unresolved.push({ target: resolved.id, syntax: "markdown" });
253
313
  diagnostics.push(
254
- diag("warning", "link_unresolved", `${sourceId}.md`, `Unresolved link: ${normalizedId}`),
314
+ diag("warning", "link_unresolved", `${sourceId}.md`, `Unresolved link: ${resolved.id}`),
255
315
  );
256
316
  }
257
317
  }
258
318
  // external and empty are silently ignored
259
319
  }
260
320
 
261
- // Process wikilinks
321
+ // Process wikilinks (lenient: exact → normalized path → bare title)
262
322
  for (const link of allLinks.wikilinks) {
263
- const resolved = resolveWikilinkTarget(link.target);
264
- if (resolved.kind === "concept") {
265
- const normalizedId = resolved.id.normalize("NFC");
266
- if (knownIds.has(normalizedId)) {
267
- targets.add(normalizedId);
268
- } else {
269
- unresolved.push({ target: normalizedId, syntax: "wikilink" });
270
- diagnostics.push(
271
- diag(
272
- "warning",
273
- "link_unresolved",
274
- `${sourceId}.md`,
275
- `Unresolved wikilink: ${normalizedId}`,
276
- ),
277
- );
278
- }
323
+ const res = resolveWikilink(link.target, index);
324
+ if (res.kind === "resolved") {
325
+ targets.add(res.id);
326
+ } else if (res.kind === "ambiguous") {
327
+ diagnostics.push(
328
+ diag(
329
+ "warning",
330
+ "link_ambiguous",
331
+ `${sourceId}.md`,
332
+ `Ambiguous wikilink: ${res.target} (candidates: ${res.candidates.join(", ")})`,
333
+ ),
334
+ );
335
+ } else {
336
+ unresolved.push({ target: res.target, syntax: "wikilink" });
337
+ diagnostics.push(
338
+ diag("warning", "link_unresolved", `${sourceId}.md`, `Unresolved wikilink: ${res.target}`),
339
+ );
279
340
  }
280
341
  }
281
342
 
@@ -284,3 +345,111 @@ export function buildResolvedBacklinks(
284
345
 
285
346
  return { targets: sorted, unresolved, diagnostics };
286
347
  }
348
+
349
+ /**
350
+ * Pre-write wikilink gate mode (issue #172). Two independent behaviors, not a
351
+ * severity ladder:
352
+ * 1. resolvable-but-drifted links (target exists): leave vs. rewrite-to-canonical
353
+ * 2. unresolvable links (target absent — a forward reference / gap): ignore vs. report vs. reject
354
+ *
355
+ * off = leave + ignore (opt-out; zero behavior change)
356
+ * warn = leave + report (default; non-mutating, non-blocking, surfaces issues)
357
+ * normalize = rewrite + report (fixes resolvable links; still reports gaps)
358
+ * strict = leave + reject (blocks the write with the bad links named; agent retry signal)
359
+ *
360
+ * A link that RESOLVES is never flagged — only normalized. Only missing/ambiguous targets
361
+ * produce diagnostics.
362
+ */
363
+ export type WikilinkValidationMode = "off" | "warn" | "strict" | "normalize";
364
+
365
+ // ── Pre-write validation & normalization (issue #172, Layer 2) ─────────
366
+
367
+ export interface WikilinkAuditResult {
368
+ /** Unresolved / ambiguous link diagnostics (empty for "off" / clean bodies). */
369
+ diagnostics: KnowledgeDiagnostic[];
370
+ /** The body after normalization (identical to input unless normalize rewrote links). */
371
+ body: string;
372
+ /** True only when normalize changed the body. */
373
+ changed: boolean;
374
+ }
375
+
376
+ const WIKILINK_REPLACE_RE = /\[\[([^\]|]+)(\|[^\]]*)?\]\]/g;
377
+
378
+ /**
379
+ * Audit a markdown body's wikilinks against the page index.
380
+ *
381
+ * - Collects `link_unresolved` / `link_ambiguous` diagnostics for every link
382
+ * that does not resolve to exactly one page (skipped for "off").
383
+ * - In "normalize" mode, additionally rewrites each link that DOES resolve to
384
+ * its canonical page id. Only the target token is replaced (the parser's own
385
+ * regex is reused), so `|alias`, escaping, and surrounding text are preserved.
386
+ *
387
+ * Pure: no I/O. The caller supplies the index (typically `buildWikilinkIndex`
388
+ * over existing page ids plus the ids created by the same commit).
389
+ */
390
+ export function auditWikilinks(
391
+ body: string,
392
+ index: WikilinkIndex,
393
+ sourceId: string,
394
+ mode: WikilinkValidationMode,
395
+ ): WikilinkAuditResult {
396
+ if (mode === "off") return { diagnostics: [], body, changed: false };
397
+
398
+ const diagnostics: KnowledgeDiagnostic[] = [];
399
+ for (const { target } of extractKnowledgeLinks(body).wikilinks) {
400
+ const resolved = resolveWikilink(target, index);
401
+ if (resolved.kind === "ambiguous") {
402
+ diagnostics.push({
403
+ severity: "warning",
404
+ code: "link_ambiguous",
405
+ path: sourceId,
406
+ message: `Wikilink target "${target}" matches multiple pages (${resolved.candidates.join(", ")}).`,
407
+ });
408
+ } else if (resolved.kind === "missing") {
409
+ diagnostics.push({
410
+ severity: "warning",
411
+ code: "link_unresolved",
412
+ path: sourceId,
413
+ message: `Wikilink target "${target}" does not match any page.`,
414
+ });
415
+ }
416
+ }
417
+
418
+ let out = body;
419
+ if (mode === "normalize") {
420
+ out = body.replace(WIKILINK_REPLACE_RE, (full, raw: string, alias: string | undefined) => {
421
+ const resolved = resolveWikilink(normalizeWikilinkTarget(raw), index);
422
+ return resolved.kind === "resolved" ? `[[${resolved.id}${alias ?? ""}]]` : full;
423
+ });
424
+ }
425
+
426
+ return { diagnostics, body: out, changed: out !== body };
427
+ }
428
+ export interface WikilinkGateResult {
429
+ /** false only when mode === "strict" AND there are unresolvable/ambiguous links. */
430
+ ok: boolean;
431
+ /** The body to write (normalized when mode === "normalize", else the input). */
432
+ body: string;
433
+ /** Unresolved / ambiguous link diagnostics (empty for "off" / clean bodies). */
434
+ diagnostics: KnowledgeDiagnostic[];
435
+ }
436
+
437
+ /**
438
+ * Apply the pre-write wikilink gate to a body. Wraps {@link auditWikilinks}:
439
+ * blocks (ok:false) only in strict mode with issues, rewrites in normalize mode,
440
+ * and always returns the diagnostics so callers can surface them (warn/normalize).
441
+ */
442
+ export function applyWikilinkGate(
443
+ body: string,
444
+ index: WikilinkIndex,
445
+ sourceId: string,
446
+ mode: WikilinkValidationMode,
447
+ ): WikilinkGateResult {
448
+ if (mode === "off") return { ok: true, body, diagnostics: [] };
449
+ const audit = auditWikilinks(body, index, sourceId, mode);
450
+ return {
451
+ ok: !(mode === "strict" && audit.diagnostics.length > 0),
452
+ body: mode === "normalize" ? audit.body : body,
453
+ diagnostics: audit.diagnostics,
454
+ };
455
+ }
@@ -11,7 +11,11 @@ import {
11
11
  } from "node:fs";
12
12
  import { dirname, join } from "node:path";
13
13
  import type { KnowledgeDiagnostic, KnowledgeDocument } from "./knowledge-document.js";
14
- import { buildResolvedBacklinks } from "./knowledge-links.js";
14
+ import {
15
+ type WikilinkIndex,
16
+ buildResolvedBacklinks,
17
+ buildWikilinkIndex,
18
+ } from "./knowledge-links.js";
15
19
  import { type VaultPaths, isPathWithin, readJson } from "./utils.js";
16
20
  import {
17
21
  assertWritableVault,
@@ -86,8 +90,8 @@ export function rebuildMetadata(paths: VaultPaths): ProjectionResult {
86
90
  const registry = buildRegistry(paths, documents);
87
91
 
88
92
  // Step 4: Build backlinks from discovered documents
89
- const knownIds = new Set(documents.map((d) => d.id));
90
- const backlinks = buildBacklinks(documents, knownIds, allDiagnostics);
93
+ const wikilinkIndex = buildWikilinkIndex(documents.map((d) => d.id));
94
+ const backlinks = buildBacklinks(documents, wikilinkIndex, allDiagnostics);
91
95
 
92
96
  const eventSource = readEventSource(join(paths.meta, "events.jsonl"));
93
97
  const eventLogResult = eventSource.available ? buildOkfLog(eventSource.content) : undefined;
@@ -245,7 +249,7 @@ function getSemanticTitle(doc: KnowledgeDocument): string {
245
249
  /** Build backlinks from discovered documents using shared link resolution. */
246
250
  function buildBacklinks(
247
251
  documents: KnowledgeDocument[],
248
- knownIds: Set<string>,
252
+ index: WikilinkIndex,
249
253
  diagnostics: KnowledgeDiagnostic[],
250
254
  ): Backlinks {
251
255
  const inbound: Backlinks = {};
@@ -257,7 +261,7 @@ function buildBacklinks(
257
261
 
258
262
  // Resolve links for each document
259
263
  for (const doc of documents) {
260
- const result = buildResolvedBacklinks(doc.id, doc.body, knownIds);
264
+ const result = buildResolvedBacklinks(doc.id, doc.body, index);
261
265
  diagnostics.push(...result.diagnostics);
262
266
  for (const target of result.targets) {
263
267
  if (inbound[target] && !inbound[target].includes(doc.id)) {
@@ -1,11 +1,13 @@
1
- import { dirname, resolve } from "node:path";
1
+ import { dirname, join, resolve } from "node:path";
2
2
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
3
  import { Type } from "typebox";
4
4
  import { scheduleReindex } from "./indexing.js";
5
5
  import { createKnowledgeDocument, writeKnowledgeDocumentFile } from "./knowledge-document.js";
6
+ import { applyWikilinkGate, buildWikilinkIndex } from "./knowledge-links.js";
6
7
  import { appendEvent, rebuildMetadataLight } from "./metadata.js";
7
8
  import type { Runtime } from "./runtime.js";
8
- import { type VaultPaths, fmtDate, resolveVaultPaths } from "./utils.js";
9
+ import { loadTaskConfig, resolveWikilinkValidation } from "./task-config.js";
10
+ import { type VaultPaths, fmtDate, readJson, resolveVaultPaths } from "./utils.js";
9
11
  import { assertWritableVault, inspectWritableVault } from "./vault-format.js";
10
12
 
11
13
  // ─── Public API ────────────────────────────────────────
@@ -150,9 +152,44 @@ export function registerWikiRetro(pi: ExtensionAPI, runtime?: Runtime): void {
150
152
  };
151
153
  }
152
154
 
155
+ // Pre-write wikilink gate (#172): validate/normalize caller-supplied body.
156
+ const mode = resolveWikilinkValidation(loadTaskConfig(ctx.cwd ?? process.cwd()));
157
+ let body = params.body;
158
+ let wikilinkIssues: string[] = [];
159
+ if (mode !== "off") {
160
+ const registry = readJson<{ pages: Record<string, unknown> }>(
161
+ join(paths.meta, "registry.json"),
162
+ { pages: {} },
163
+ );
164
+ const gate = applyWikilinkGate(
165
+ body,
166
+ buildWikilinkIndex(Object.keys(registry.pages)),
167
+ `sources/${params.slug}`,
168
+ mode,
169
+ );
170
+ wikilinkIssues = gate.diagnostics.map((d) => d.message);
171
+ if (!gate.ok) {
172
+ return {
173
+ content: [
174
+ {
175
+ type: "text",
176
+ text: `Rejected write — unresolved/ambiguous wikilinks:\n${wikilinkIssues
177
+ .map((m) => `- ${m}`)
178
+ .join("\n")}`,
179
+ },
180
+ ],
181
+ details: { error: "link_validation", issues: wikilinkIssues } as Record<
182
+ string,
183
+ unknown
184
+ >,
185
+ isError: true,
186
+ };
187
+ }
188
+ if (mode === "normalize") body = gate.body;
189
+ }
153
190
  let result: RetroResult;
154
191
  try {
155
- result = saveInsight(paths, params.slug, params.title, params.body, params.category, {
192
+ result = saveInsight(paths, params.slug, params.title, body, params.category, {
156
193
  rebuild: !runtime,
157
194
  });
158
195
  } catch (error: unknown) {
@@ -169,6 +206,9 @@ export function registerWikiRetro(pi: ExtensionAPI, runtime?: Runtime): void {
169
206
  scheduleReindex(runtime, { hasUI: ctx.hasUI, ui: ctx.ui }, paths);
170
207
  }
171
208
 
209
+ const gateNote = wikilinkIssues.length
210
+ ? `\n\n⚠️ ${wikilinkIssues.length} wikilink issue(s):\n${wikilinkIssues.map((m) => `- ${m}`).join("\n")}`
211
+ : "";
172
212
  return {
173
213
  content: [
174
214
  {
@@ -179,13 +219,17 @@ export function registerWikiRetro(pi: ExtensionAPI, runtime?: Runtime): void {
179
219
  `- Page: \`${result.sourcePagePath}\``,
180
220
  "",
181
221
  "This insight will be auto-surfaced by wiki_recall in future sessions.",
182
- ].join("\n"),
222
+ gateNote,
223
+ ]
224
+ .filter(Boolean)
225
+ .join("\n"),
183
226
  },
184
227
  ],
185
228
  details: {
186
229
  slug: params.slug,
187
230
  title: params.title,
188
231
  category: params.category || null,
232
+ wikilinkIssues,
189
233
  } as Record<string, unknown>,
190
234
  };
191
235
  },
@@ -133,11 +133,11 @@ export class Runtime {
133
133
  }
134
134
 
135
135
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
136
- if (!auth.ok || !auth.apiKey) {
136
+ if (!auth.ok) {
137
137
  const provider = (model as { provider?: string }).provider ?? "unknown";
138
138
  return { ok: false, reason: `no API key for provider "${provider}"` };
139
139
  }
140
- return { ok: true, model, apiKey: auth.apiKey, headers: auth.headers };
140
+ return { ok: true, model, apiKey: auth.apiKey ?? "", headers: auth.headers };
141
141
  }
142
142
 
143
143
  /**