@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
@@ -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 {
@@ -33,13 +34,17 @@ function diag(
33
34
  return { severity, code, path, message };
34
35
  }
35
36
 
37
+ function normalizeWikilinkTarget(target: string): string {
38
+ return target.trim().replace(/\\$/, "");
39
+ }
40
+
36
41
  export function extractKnowledgeLinks(body: string): KnowledgeLinks {
37
42
  const markdown: ExtractedLink[] = [];
38
43
  const wikilinks: ExtractedLink[] = [];
39
44
 
40
- // Extract legacy wikilinks
45
+ // Extract legacy wikilinks. A table-safe alias uses an escaped pipe: [[target\\|alias]].
41
46
  for (const match of body.matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g)) {
42
- wikilinks.push({ target: match[1].trim(), offset: match.index ?? 0 });
47
+ wikilinks.push({ target: normalizeWikilinkTarget(match[1]), offset: match.index ?? 0 });
43
48
  }
44
49
 
45
50
  // Parse with CommonMark AST
@@ -111,11 +116,79 @@ export function extractKnowledgeLinks(body: string): KnowledgeLinks {
111
116
  export function extractLegacyWikilinks(body: string): ExtractedLink[] {
112
117
  const links: ExtractedLink[] = [];
113
118
  for (const match of body.matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g)) {
114
- links.push({ target: match[1].trim(), offset: match.index ?? 0 });
119
+ links.push({ target: normalizeWikilinkTarget(match[1]), offset: match.index ?? 0 });
115
120
  }
116
121
  return links;
117
122
  }
118
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
+
119
192
  function resolveMarkdownTarget(
120
193
  target: string,
121
194
  sourceId: string,
@@ -200,19 +273,10 @@ function resolveMarkdownTarget(
200
273
  return { kind: "empty" };
201
274
  }
202
275
 
203
- function resolveWikilinkTarget(
204
- target: string,
205
- ): { kind: "concept"; id: string } | { kind: "empty" } {
206
- // Wikilinks are already bundle-relative concept IDs
207
- const cleaned = target.trim();
208
- if (!cleaned) return { kind: "empty" };
209
- return { kind: "concept", id: cleaned };
210
- }
211
-
212
276
  export function buildResolvedBacklinks(
213
277
  sourceId: string,
214
278
  body: string,
215
- knownIds: Set<string>,
279
+ index: WikilinkIndex,
216
280
  ): ResolvedBacklinks {
217
281
  const diagnostics: KnowledgeDiagnostic[] = [];
218
282
  const unresolved: UnresolvedKnowledgeLink[] = [];
@@ -241,37 +305,38 @@ export function buildResolvedBacklinks(
241
305
  ),
242
306
  );
243
307
  } else if (resolved.kind === "concept") {
244
- const normalizedId = resolved.id.normalize("NFC");
245
- if (knownIds.has(normalizedId)) {
246
- targets.add(normalizedId);
308
+ const canonical = index.byExact.get(resolved.id.normalize("NFC"));
309
+ if (canonical) {
310
+ targets.add(canonical);
247
311
  } else {
248
- unresolved.push({ target: normalizedId, syntax: "markdown" });
312
+ unresolved.push({ target: resolved.id, syntax: "markdown" });
249
313
  diagnostics.push(
250
- diag("warning", "link_unresolved", `${sourceId}.md`, `Unresolved link: ${normalizedId}`),
314
+ diag("warning", "link_unresolved", `${sourceId}.md`, `Unresolved link: ${resolved.id}`),
251
315
  );
252
316
  }
253
317
  }
254
318
  // external and empty are silently ignored
255
319
  }
256
320
 
257
- // Process wikilinks
321
+ // Process wikilinks (lenient: exact → normalized path → bare title)
258
322
  for (const link of allLinks.wikilinks) {
259
- const resolved = resolveWikilinkTarget(link.target);
260
- if (resolved.kind === "concept") {
261
- const normalizedId = resolved.id.normalize("NFC");
262
- if (knownIds.has(normalizedId)) {
263
- targets.add(normalizedId);
264
- } else {
265
- unresolved.push({ target: normalizedId, syntax: "wikilink" });
266
- diagnostics.push(
267
- diag(
268
- "warning",
269
- "link_unresolved",
270
- `${sourceId}.md`,
271
- `Unresolved wikilink: ${normalizedId}`,
272
- ),
273
- );
274
- }
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
+ );
275
340
  }
276
341
  }
277
342
 
@@ -280,3 +345,111 @@ export function buildResolvedBacklinks(
280
345
 
281
346
  return { targets: sorted, unresolved, diagnostics };
282
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)) {
@@ -508,7 +512,7 @@ export function buildDirectoryIndexes(
508
512
  lines.push("## Directories");
509
513
  lines.push("");
510
514
  for (const subDir of [...dirs].sort(compareCodePoint)) {
511
- const encoded = `${encodeRelativePath(subDir)}/`;
515
+ const encoded = encodeRelativePath(`${subDir}/index.md`);
512
516
  lines.push(`- [${escapeLabel(subDir)}/](${encoded})`);
513
517
  }
514
518
  }
@@ -345,10 +345,12 @@ export function registerObservationReminder(
345
345
  return true;
346
346
  };
347
347
  let turnsSinceLastReminder = 0;
348
+ let agentEndsInUserTurn = 0;
348
349
 
349
350
  pi.on("session_start", async () => {
350
351
  turnsSinceLastReminder = 0;
351
352
  reminderState.observeDoneThisSession = false;
353
+ agentEndsInUserTurn = 0;
352
354
  });
353
355
 
354
356
  // After compaction, reset turn counter so reminders resume
@@ -358,10 +360,18 @@ export function registerObservationReminder(
358
360
  turnsSinceLastReminder = 0;
359
361
  });
360
362
 
363
+ // A retry re-runs the agent within the same user turn, and pi does not
364
+ // forward `willRetry` to extensions (zosmaai/pi-llm-wiki#151). Only the
365
+ // user-role message_start separates real user turns from retried agent
366
+ // runs, so the per-turn agent_end count resets there.
367
+ pi.on("message_start", async (event) => {
368
+ if (event.message.role === "user") agentEndsInUserTurn = 0;
369
+ });
370
+
361
371
  pi.on("agent_end", async (event, _ctx) => {
362
- // Skip reminder on retries — willRetry means pi will re-run the agent,
363
- // and queuing another reminder would duplicate them (issue: connection
364
- // errors cause multiple retries, each firing agent_end).
372
+ // Legacy guard: this pi build does not forward `willRetry` to extension
373
+ // events, but keep the check in case a future one does. The dedup that
374
+ // actually works is the per-turn agent_end count below.
365
375
  if ("willRetry" in event && (event as { willRetry?: boolean }).willRetry) return;
366
376
 
367
377
  // No wiki applies here: never nag, and never accumulate a pending reminder
@@ -372,6 +382,12 @@ export function registerObservationReminder(
372
382
  if (turnsSinceLastReminder < REMINDER_INTERVAL) return;
373
383
  if (reminderState.observeDoneThisSession) return;
374
384
 
385
+ // One agent_end per user turn may queue a reminder. A rate-limit storm
386
+ // re-runs the agent several times within the same turn, each firing
387
+ // agent_end, so count them and let only the first through.
388
+ agentEndsInUserTurn++;
389
+ if (agentEndsInUserTurn > 1) return;
390
+
375
391
  pi.sendMessage(
376
392
  {
377
393
  customType: "wiki-observe-reminder",
@@ -382,5 +398,9 @@ export function registerObservationReminder(
382
398
  deliverAs: "nextTurn",
383
399
  },
384
400
  );
401
+
402
+ // Reset the interval counter after queueing: without it the count stays
403
+ // at/above the threshold and every later agent_end queues a reminder.
404
+ turnsSinceLastReminder = 0;
385
405
  });
386
406
  }
@@ -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
  /**