@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,9 +2,11 @@ import { existsSync, mkdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { Type } from "typebox";
4
4
  import { createKnowledgeDocument, patchKnowledgeDocument, readKnowledgeDocumentFile, serializeKnowledgeDocument, writeKnowledgeDocumentFile, } from "./knowledge-document.js";
5
+ import { auditWikilinks, buildWikilinkIndex, } from "./knowledge-links.js";
5
6
  import { appendEvent, rebuildMetadataLight } from "./metadata.js";
6
7
  import { runSubAgent } from "./subagent.js";
7
- import { fmtDate, slugify } from "./utils.js";
8
+ import { resolveWikilinkValidation } from "./task-config.js";
9
+ import { fmtDate, readJson, slugify } from "./utils.js";
8
10
  import { VaultWriteError, assertWritableVault } from "./vault-format.js";
9
11
  /**
10
12
  * Background ingest synthesis (issue #65, part of epic #63).
@@ -141,32 +143,22 @@ function getHeadings(lang) {
141
143
  contradiction: "⚠️ **Contradiction**",
142
144
  };
143
145
  }
144
- function buildEntityPageBody(title, description, sourceId, lang) {
146
+ function buildEntityPageBody(title, description, sourceId) {
145
147
  const desc = description.trim() || "One-line description.";
146
- const h = getHeadings(lang);
147
148
  return `# ${title}
148
149
 
149
150
  ${desc}
150
151
 
151
- ## ${h.overview}
152
-
153
- [Key facts]
154
-
155
152
  ## Links
156
153
 
157
154
  - [${sourceId}](/sources/${sourceId}.md)`;
158
155
  }
159
- function buildConceptPageBody(title, definition, sourceId, lang) {
156
+ function buildConceptPageBody(title, definition, sourceId) {
160
157
  const def = definition.trim() || "One-line definition.";
161
- const h = getHeadings(lang);
162
158
  return `# ${title}
163
159
 
164
160
  ${def}
165
161
 
166
- ## ${h.definition}
167
-
168
- [Clear explanation]
169
-
170
162
  ## Links
171
163
 
172
164
  - [${sourceId}](/sources/${sourceId}.md)`;
@@ -241,12 +233,29 @@ export function buildIngestedSourcePage(manifest, data, date, lang) {
241
233
  }, body);
242
234
  return serializeKnowledgeDocument(doc);
243
235
  }
236
+ /**
237
+ * Build the wikilink index used by the pre-write gate: every existing page id
238
+ * (from the registry) plus the page ids this commit is about to create, so a
239
+ * link to a sibling created in the same ingest resolves instead of
240
+ * false-positiving as missing.
241
+ */
242
+ function buildIngestAuditIndex(paths, sourceId, data) {
243
+ const registry = readJson(join(paths.meta, "registry.json"), {
244
+ pages: {},
245
+ });
246
+ const newIds = [
247
+ `sources/${sourceId}`,
248
+ ...data.entities.filter((e) => slugify(e.title)).map((e) => `entities/${slugify(e.title)}`),
249
+ ...data.concepts.filter((c) => slugify(c.title)).map((c) => `concepts/${slugify(c.title)}`),
250
+ ];
251
+ return buildWikilinkIndex([...Object.keys(registry.pages), ...newIds]);
252
+ }
244
253
  /**
245
254
  * Persist a synthesis deterministically: rewrite the source page (status →
246
255
  * ingested), create missing entity/concept pages (existing pages are linked,
247
256
  * never overwritten), and log the event. Pure file I/O — no LLM, no network.
248
257
  */
249
- export function commitSynthesis(paths, sourceId, manifest, data, date = fmtDate(), lang) {
258
+ export function commitSynthesis(paths, sourceId, manifest, data, date = fmtDate(), lang, wikilinkValidation) {
250
259
  const result = {
251
260
  sourceId,
252
261
  sourcePage: join(paths.wiki, "sources", `${sourceId}.md`),
@@ -265,6 +274,20 @@ export function commitSynthesis(paths, sourceId, manifest, data, date = fmtDate(
265
274
  }
266
275
  throw error;
267
276
  }
277
+ // Pre-write wikilink gate (issue #172, Layer 2). Applies only to the
278
+ // model-authored source body; entity/concept pages are generated templates.
279
+ const mode = resolveWikilinkValidation({ wikilinkValidation });
280
+ let sourceBody = buildIngestedSourcePageBody(manifest, data, date, lang);
281
+ if (mode !== "off") {
282
+ const audit = auditWikilinks(sourceBody, buildIngestAuditIndex(paths, sourceId, data), sourceId, mode);
283
+ if (mode === "strict" && audit.diagnostics.length > 0) {
284
+ return { ok: false, sourceId, diagnostics: audit.diagnostics };
285
+ }
286
+ if (mode === "normalize")
287
+ sourceBody = audit.body;
288
+ if (audit.diagnostics.length > 0)
289
+ result.wikilinkDiagnostics = audit.diagnostics;
290
+ }
268
291
  // Patch existing documents so unknown fields, legacy sources, and titles survive.
269
292
  let sourceDocument;
270
293
  if (existsSync(result.sourcePage)) {
@@ -273,7 +296,7 @@ export function commitSynthesis(paths, sourceId, manifest, data, date = fmtDate(
273
296
  return { ok: false, sourceId, diagnostics: parsed.diagnostics };
274
297
  sourceDocument = patchKnowledgeDocument(parsed.document, {
275
298
  fields: { status: "ingested", updated: date },
276
- body: buildIngestedSourcePageBody(manifest, data, date, lang),
299
+ body: sourceBody,
277
300
  });
278
301
  }
279
302
  else {
@@ -286,7 +309,7 @@ export function commitSynthesis(paths, sourceId, manifest, data, date = fmtDate(
286
309
  captured: String(manifest.captured || date),
287
310
  status: "ingested",
288
311
  updated: date,
289
- }, buildIngestedSourcePageBody(manifest, data, date, lang));
312
+ }, sourceBody);
290
313
  }
291
314
  mkdirSync(join(paths.wiki, "sources"), { recursive: true });
292
315
  writeKnowledgeDocumentFile(result.sourcePage, sourceDocument);
@@ -307,7 +330,7 @@ export function commitSynthesis(paths, sourceId, manifest, data, date = fmtDate(
307
330
  description: e.description.trim() || "One-line description.",
308
331
  created: date,
309
332
  updated: date,
310
- }, buildEntityPageBody(e.title, e.description, sourceId, lang), [{ id: sourceId, resource: `/sources/${sourceId}.md` }]);
333
+ }, buildEntityPageBody(e.title, e.description, sourceId), [{ id: sourceId, resource: `/sources/${sourceId}.md` }]);
311
334
  writeKnowledgeDocumentFile(pagePath, entityDoc);
312
335
  result.entitiesCreated.push(slug);
313
336
  }
@@ -329,7 +352,7 @@ export function commitSynthesis(paths, sourceId, manifest, data, date = fmtDate(
329
352
  description: c.definition.trim() || "One-line definition.",
330
353
  created: date,
331
354
  updated: date,
332
- }, buildConceptPageBody(c.title, c.definition, sourceId, lang), [{ id: sourceId, resource: `/sources/${sourceId}.md` }]);
355
+ }, buildConceptPageBody(c.title, c.definition, sourceId), [{ id: sourceId, resource: `/sources/${sourceId}.md` }]);
333
356
  writeKnowledgeDocumentFile(pagePath, conceptDoc);
334
357
  result.conceptsCreated.push(slug);
335
358
  }
@@ -365,7 +388,7 @@ Rules:
365
388
  * synthesis.
366
389
  */
367
390
  export async function runIngestSynthesis(args) {
368
- const { model, apiKey, headers, paths, sourceId, manifest, extracted, maxChars, signal, synthesisLanguage, } = args;
391
+ const { model, apiKey, headers, paths, sourceId, manifest, extracted, maxChars, signal, synthesisLanguage, synthesisMaxTokens, wikilinkValidation, } = args;
369
392
  const content = extracted.slice(0, maxChars ?? 24_000);
370
393
  if (!content.trim())
371
394
  return undefined;
@@ -380,7 +403,7 @@ export async function runIngestSynthesis(args) {
380
403
  description: "Persist the structured synthesis of this source into wiki pages. Call exactly once.",
381
404
  parameters: CommitSynthesisSchema,
382
405
  execute: async (_id, params) => {
383
- const outcome = commitSynthesis(paths, sourceId, manifest, params, undefined, synthesisLanguage);
406
+ const outcome = commitSynthesis(paths, sourceId, manifest, params, undefined, synthesisLanguage, wikilinkValidation);
384
407
  if (!outcome.ok) {
385
408
  return {
386
409
  content: [{ type: "text", text: `Failed: ${outcome.diagnostics[0].message}` }],
@@ -402,6 +425,7 @@ export async function runIngestSynthesis(args) {
402
425
  systemPrompt,
403
426
  userPrompt,
404
427
  tools: [commitTool],
428
+ maxTokens: synthesisMaxTokens ?? 16384,
405
429
  signal,
406
430
  });
407
431
  if (committed)
@@ -383,6 +383,22 @@ export function serializeKnowledgeDocument(document) {
383
383
  const body = document.body.replace(/\r\n?/g, "\n").replace(/\n*$/, "");
384
384
  return body ? `---\n${yaml}---\n\n${body}\n` : `---\n${yaml}---\n`;
385
385
  }
386
+ /** Escape wikilink alias pipes so generated content remains valid in Markdown tables. */
387
+ function escapeWikilinkAliasPipes(body) {
388
+ let inFence = false;
389
+ return body
390
+ .split("\n")
391
+ .map((line) => {
392
+ if (/^\s*(`{3,}|~{3,})/.test(line)) {
393
+ inFence = !inFence;
394
+ return line;
395
+ }
396
+ if (inFence)
397
+ return line;
398
+ return line.replace(/\[\[([^\]\n]*?)(?<!\\)\|([^\]\n]*?)\]\]/g, "[[$1\\|$2]]");
399
+ })
400
+ .join("\n");
401
+ }
386
402
  export function createKnowledgeDocument(path, fields, body, sources) {
387
403
  if (Object.hasOwn(fields, "sources")) {
388
404
  throw new Error("Pass canonical sources as the fourth argument");
@@ -402,7 +418,7 @@ export function createKnowledgeDocument(path, fields, body, sources) {
402
418
  const sourcesUnion = sources
403
419
  ? { kind: "canonical", value: sources }
404
420
  : { kind: "absent" };
405
- const normalizedBody = body.replace(/\r\n?/g, "\n").replace(/\n*$/, "");
421
+ const normalizedBody = escapeWikilinkAliasPipes(body.replace(/\r\n?/g, "\n")).replace(/\n*$/, "");
406
422
  return {
407
423
  id: path.replace(/\.md$/, ""),
408
424
  path,
@@ -425,7 +441,9 @@ export function patchKnowledgeDocument(document, patch) {
425
441
  }
426
442
  }
427
443
  }
428
- const newBody = patch.body ?? document.body;
444
+ const newBody = patch.body
445
+ ? escapeWikilinkAliasPipes(patch.body.replace(/\r\n?/g, "\n"))
446
+ : document.body;
429
447
  return {
430
448
  ...document,
431
449
  frontmatter: newFrontmatter,
@@ -1,14 +1,18 @@
1
1
  import { fromMarkdown } from "mdast-util-from-markdown";
2
+ import { slugify } from "./utils.js";
2
3
  import { compareCodePoint } from "./vault-format.js";
3
4
  function diag(severity, code, path, message) {
4
5
  return { severity, code, path, message };
5
6
  }
7
+ function normalizeWikilinkTarget(target) {
8
+ return target.trim().replace(/\\$/, "");
9
+ }
6
10
  export function extractKnowledgeLinks(body) {
7
11
  const markdown = [];
8
12
  const wikilinks = [];
9
- // Extract legacy wikilinks
13
+ // Extract legacy wikilinks. A table-safe alias uses an escaped pipe: [[target\\|alias]].
10
14
  for (const match of body.matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g)) {
11
- wikilinks.push({ target: match[1].trim(), offset: match.index ?? 0 });
15
+ wikilinks.push({ target: normalizeWikilinkTarget(match[1]), offset: match.index ?? 0 });
12
16
  }
13
17
  // Parse with CommonMark AST
14
18
  let tree;
@@ -72,10 +76,59 @@ export function extractKnowledgeLinks(body) {
72
76
  export function extractLegacyWikilinks(body) {
73
77
  const links = [];
74
78
  for (const match of body.matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g)) {
75
- links.push({ target: match[1].trim(), offset: match.index ?? 0 });
79
+ links.push({ target: normalizeWikilinkTarget(match[1]), offset: match.index ?? 0 });
76
80
  }
77
81
  return links;
78
82
  }
83
+ export function buildWikilinkIndex(ids) {
84
+ const byExact = new Map();
85
+ const byNormPath = new Map();
86
+ const byNormSlug = new Map();
87
+ function push(map, key, value) {
88
+ const existing = map.get(key);
89
+ if (existing)
90
+ existing.push(value);
91
+ else
92
+ map.set(key, [value]);
93
+ }
94
+ for (const id of ids) {
95
+ byExact.set(id.normalize("NFC"), id);
96
+ const segments = id.split("/");
97
+ const normFull = segments.map((s) => slugify(s)).join("/");
98
+ const normBase = slugify(segments[segments.length - 1]);
99
+ push(byNormPath, normFull, id);
100
+ push(byNormSlug, normBase, id);
101
+ }
102
+ return { byExact, byNormPath, byNormSlug };
103
+ }
104
+ export function resolveWikilink(target, index) {
105
+ const cleaned = target.trim().replace(/\\$/, "");
106
+ if (!cleaned)
107
+ return { kind: "missing", target: "" };
108
+ // Fast path: exact NFC match
109
+ const exact = index.byExact.get(cleaned.normalize("NFC"));
110
+ if (exact)
111
+ return { kind: "resolved", id: exact };
112
+ // Normalized full path (fixes case/space/slug drift in folder-qualified links)
113
+ const normFull = cleaned
114
+ .split("/")
115
+ .map((s) => slugify(s))
116
+ .join("/");
117
+ const pathHits = index.byNormPath.get(normFull);
118
+ if (pathHits && pathHits.length === 1)
119
+ return { kind: "resolved", id: pathHits[0] };
120
+ if (pathHits && pathHits.length > 1)
121
+ return { kind: "ambiguous", target: cleaned, candidates: pathHits };
122
+ // Bare title: match by slugified basename (handles [[zosma harness]] → entities/zosma-harness)
123
+ if (!cleaned.includes("/")) {
124
+ const baseHits = index.byNormSlug.get(slugify(cleaned));
125
+ if (baseHits && baseHits.length === 1)
126
+ return { kind: "resolved", id: baseHits[0] };
127
+ if (baseHits && baseHits.length > 1)
128
+ return { kind: "ambiguous", target: cleaned, candidates: baseHits };
129
+ }
130
+ return { kind: "missing", target: cleaned };
131
+ }
79
132
  function resolveMarkdownTarget(target, sourceId) {
80
133
  // Strip query and fragment (earliest delimiter)
81
134
  const qIndex = target.indexOf("?");
@@ -153,14 +206,7 @@ function resolveMarkdownTarget(target, sourceId) {
153
206
  }
154
207
  return { kind: "empty" };
155
208
  }
156
- function resolveWikilinkTarget(target) {
157
- // Wikilinks are already bundle-relative concept IDs
158
- const cleaned = target.trim();
159
- if (!cleaned)
160
- return { kind: "empty" };
161
- return { kind: "concept", id: cleaned };
162
- }
163
- export function buildResolvedBacklinks(sourceId, body, knownIds) {
209
+ export function buildResolvedBacklinks(sourceId, body, index) {
164
210
  const diagnostics = [];
165
211
  const unresolved = [];
166
212
  const targets = new Set();
@@ -175,32 +221,92 @@ export function buildResolvedBacklinks(sourceId, body, knownIds) {
175
221
  diagnostics.push(diag("warning", "link_unresolved", `${sourceId}.md`, `Malformed percent-encoded link: ${link.target}`));
176
222
  }
177
223
  else if (resolved.kind === "concept") {
178
- const normalizedId = resolved.id.normalize("NFC");
179
- if (knownIds.has(normalizedId)) {
180
- targets.add(normalizedId);
224
+ const canonical = index.byExact.get(resolved.id.normalize("NFC"));
225
+ if (canonical) {
226
+ targets.add(canonical);
181
227
  }
182
228
  else {
183
- unresolved.push({ target: normalizedId, syntax: "markdown" });
184
- diagnostics.push(diag("warning", "link_unresolved", `${sourceId}.md`, `Unresolved link: ${normalizedId}`));
229
+ unresolved.push({ target: resolved.id, syntax: "markdown" });
230
+ diagnostics.push(diag("warning", "link_unresolved", `${sourceId}.md`, `Unresolved link: ${resolved.id}`));
185
231
  }
186
232
  }
187
233
  // external and empty are silently ignored
188
234
  }
189
- // Process wikilinks
235
+ // Process wikilinks (lenient: exact → normalized path → bare title)
190
236
  for (const link of allLinks.wikilinks) {
191
- const resolved = resolveWikilinkTarget(link.target);
192
- if (resolved.kind === "concept") {
193
- const normalizedId = resolved.id.normalize("NFC");
194
- if (knownIds.has(normalizedId)) {
195
- targets.add(normalizedId);
196
- }
197
- else {
198
- unresolved.push({ target: normalizedId, syntax: "wikilink" });
199
- diagnostics.push(diag("warning", "link_unresolved", `${sourceId}.md`, `Unresolved wikilink: ${normalizedId}`));
200
- }
237
+ const res = resolveWikilink(link.target, index);
238
+ if (res.kind === "resolved") {
239
+ targets.add(res.id);
240
+ }
241
+ else if (res.kind === "ambiguous") {
242
+ diagnostics.push(diag("warning", "link_ambiguous", `${sourceId}.md`, `Ambiguous wikilink: ${res.target} (candidates: ${res.candidates.join(", ")})`));
243
+ }
244
+ else {
245
+ unresolved.push({ target: res.target, syntax: "wikilink" });
246
+ diagnostics.push(diag("warning", "link_unresolved", `${sourceId}.md`, `Unresolved wikilink: ${res.target}`));
201
247
  }
202
248
  }
203
249
  // Sort and deduplicate
204
250
  const sorted = [...targets].sort(compareCodePoint);
205
251
  return { targets: sorted, unresolved, diagnostics };
206
252
  }
253
+ const WIKILINK_REPLACE_RE = /\[\[([^\]|]+)(\|[^\]]*)?\]\]/g;
254
+ /**
255
+ * Audit a markdown body's wikilinks against the page index.
256
+ *
257
+ * - Collects `link_unresolved` / `link_ambiguous` diagnostics for every link
258
+ * that does not resolve to exactly one page (skipped for "off").
259
+ * - In "normalize" mode, additionally rewrites each link that DOES resolve to
260
+ * its canonical page id. Only the target token is replaced (the parser's own
261
+ * regex is reused), so `|alias`, escaping, and surrounding text are preserved.
262
+ *
263
+ * Pure: no I/O. The caller supplies the index (typically `buildWikilinkIndex`
264
+ * over existing page ids plus the ids created by the same commit).
265
+ */
266
+ export function auditWikilinks(body, index, sourceId, mode) {
267
+ if (mode === "off")
268
+ return { diagnostics: [], body, changed: false };
269
+ const diagnostics = [];
270
+ for (const { target } of extractKnowledgeLinks(body).wikilinks) {
271
+ const resolved = resolveWikilink(target, index);
272
+ if (resolved.kind === "ambiguous") {
273
+ diagnostics.push({
274
+ severity: "warning",
275
+ code: "link_ambiguous",
276
+ path: sourceId,
277
+ message: `Wikilink target "${target}" matches multiple pages (${resolved.candidates.join(", ")}).`,
278
+ });
279
+ }
280
+ else if (resolved.kind === "missing") {
281
+ diagnostics.push({
282
+ severity: "warning",
283
+ code: "link_unresolved",
284
+ path: sourceId,
285
+ message: `Wikilink target "${target}" does not match any page.`,
286
+ });
287
+ }
288
+ }
289
+ let out = body;
290
+ if (mode === "normalize") {
291
+ out = body.replace(WIKILINK_REPLACE_RE, (full, raw, alias) => {
292
+ const resolved = resolveWikilink(normalizeWikilinkTarget(raw), index);
293
+ return resolved.kind === "resolved" ? `[[${resolved.id}${alias ?? ""}]]` : full;
294
+ });
295
+ }
296
+ return { diagnostics, body: out, changed: out !== body };
297
+ }
298
+ /**
299
+ * Apply the pre-write wikilink gate to a body. Wraps {@link auditWikilinks}:
300
+ * blocks (ok:false) only in strict mode with issues, rewrites in normalize mode,
301
+ * and always returns the diagnostics so callers can surface them (warn/normalize).
302
+ */
303
+ export function applyWikilinkGate(body, index, sourceId, mode) {
304
+ if (mode === "off")
305
+ return { ok: true, body, diagnostics: [] };
306
+ const audit = auditWikilinks(body, index, sourceId, mode);
307
+ return {
308
+ ok: !(mode === "strict" && audit.diagnostics.length > 0),
309
+ body: mode === "normalize" ? audit.body : body,
310
+ diagnostics: audit.diagnostics,
311
+ };
312
+ }
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
- import { buildResolvedBacklinks } from "./knowledge-links.js";
4
+ import { buildResolvedBacklinks, buildWikilinkIndex, } from "./knowledge-links.js";
5
5
  import { isPathWithin, readJson } from "./utils.js";
6
6
  import { assertWritableVault, compareCodePoint, discoverKnowledgeDocuments, inspectVaultFormat, } from "./vault-format.js";
7
7
  /** Rebuild the complete metadata layer with fail-closed semantics. */
@@ -24,8 +24,8 @@ export function rebuildMetadata(paths) {
24
24
  // Step 3: Build registry from documents + raw fallbacks
25
25
  const registry = buildRegistry(paths, documents);
26
26
  // Step 4: Build backlinks from discovered documents
27
- const knownIds = new Set(documents.map((d) => d.id));
28
- const backlinks = buildBacklinks(documents, knownIds, allDiagnostics);
27
+ const wikilinkIndex = buildWikilinkIndex(documents.map((d) => d.id));
28
+ const backlinks = buildBacklinks(documents, wikilinkIndex, allDiagnostics);
29
29
  const eventSource = readEventSource(join(paths.meta, "events.jsonl"));
30
30
  const eventLogResult = eventSource.available ? buildOkfLog(eventSource.content) : undefined;
31
31
  if (eventLogResult)
@@ -157,7 +157,7 @@ function getSemanticTitle(doc) {
157
157
  return doc.id.split("/").pop() || "Untitled";
158
158
  }
159
159
  /** Build backlinks from discovered documents using shared link resolution. */
160
- function buildBacklinks(documents, knownIds, diagnostics) {
160
+ function buildBacklinks(documents, index, diagnostics) {
161
161
  const inbound = {};
162
162
  // Initialize parsed concept IDs with empty arrays
163
163
  for (const doc of documents) {
@@ -165,7 +165,7 @@ function buildBacklinks(documents, knownIds, diagnostics) {
165
165
  }
166
166
  // Resolve links for each document
167
167
  for (const doc of documents) {
168
- const result = buildResolvedBacklinks(doc.id, doc.body, knownIds);
168
+ const result = buildResolvedBacklinks(doc.id, doc.body, index);
169
169
  diagnostics.push(...result.diagnostics);
170
170
  for (const target of result.targets) {
171
171
  if (inbound[target] && !inbound[target].includes(doc.id)) {
@@ -386,7 +386,7 @@ export function buildDirectoryIndexes(documents, config) {
386
386
  lines.push("## Directories");
387
387
  lines.push("");
388
388
  for (const subDir of [...dirs].sort(compareCodePoint)) {
389
- const encoded = `${encodeRelativePath(subDir)}/`;
389
+ const encoded = encodeRelativePath(`${subDir}/index.md`);
390
390
  lines.push(`- [${escapeLabel(subDir)}/](${encoded})`);
391
391
  }
392
392
  }
@@ -256,9 +256,11 @@ export function registerObservationReminder(pi, reminderState, options) {
256
256
  return true;
257
257
  };
258
258
  let turnsSinceLastReminder = 0;
259
+ let agentEndsInUserTurn = 0;
259
260
  pi.on("session_start", async () => {
260
261
  turnsSinceLastReminder = 0;
261
262
  reminderState.observeDoneThisSession = false;
263
+ agentEndsInUserTurn = 0;
262
264
  });
263
265
  // After compaction, reset turn counter so reminders resume
264
266
  // BUT preserve observeDoneThisSession — if the model already called
@@ -266,10 +268,18 @@ export function registerObservationReminder(pi, reminderState, options) {
266
268
  pi.on("session_compact", async () => {
267
269
  turnsSinceLastReminder = 0;
268
270
  });
271
+ // A retry re-runs the agent within the same user turn, and pi does not
272
+ // forward `willRetry` to extensions (zosmaai/pi-llm-wiki#151). Only the
273
+ // user-role message_start separates real user turns from retried agent
274
+ // runs, so the per-turn agent_end count resets there.
275
+ pi.on("message_start", async (event) => {
276
+ if (event.message.role === "user")
277
+ agentEndsInUserTurn = 0;
278
+ });
269
279
  pi.on("agent_end", async (event, _ctx) => {
270
- // Skip reminder on retries willRetry means pi will re-run the agent,
271
- // and queuing another reminder would duplicate them (issue: connection
272
- // errors cause multiple retries, each firing agent_end).
280
+ // Legacy guard: this pi build does not forward `willRetry` to extension
281
+ // events, but keep the check in case a future one does. The dedup that
282
+ // actually works is the per-turn agent_end count below.
273
283
  if ("willRetry" in event && event.willRetry)
274
284
  return;
275
285
  // No wiki applies here: never nag, and never accumulate a pending reminder
@@ -281,6 +291,12 @@ export function registerObservationReminder(pi, reminderState, options) {
281
291
  return;
282
292
  if (reminderState.observeDoneThisSession)
283
293
  return;
294
+ // One agent_end per user turn may queue a reminder. A rate-limit storm
295
+ // re-runs the agent several times within the same turn, each firing
296
+ // agent_end, so count them and let only the first through.
297
+ agentEndsInUserTurn++;
298
+ if (agentEndsInUserTurn > 1)
299
+ return;
284
300
  pi.sendMessage({
285
301
  customType: "wiki-observe-reminder",
286
302
  content: buildReminderText(),
@@ -288,5 +304,8 @@ export function registerObservationReminder(pi, reminderState, options) {
288
304
  }, {
289
305
  deliverAs: "nextTurn",
290
306
  });
307
+ // Reset the interval counter after queueing: without it the count stays
308
+ // at/above the threshold and every later agent_end queues a reminder.
309
+ turnsSinceLastReminder = 0;
291
310
  });
292
311
  }
@@ -1,9 +1,11 @@
1
- import { dirname, resolve } from "node:path";
1
+ import { dirname, join, resolve } from "node:path";
2
2
  import { Type } from "typebox";
3
3
  import { scheduleReindex } from "./indexing.js";
4
4
  import { createKnowledgeDocument, writeKnowledgeDocumentFile } from "./knowledge-document.js";
5
+ import { applyWikilinkGate, buildWikilinkIndex } from "./knowledge-links.js";
5
6
  import { appendEvent, rebuildMetadataLight } from "./metadata.js";
6
- import { fmtDate, resolveVaultPaths } from "./utils.js";
7
+ import { loadTaskConfig, resolveWikilinkValidation } from "./task-config.js";
8
+ import { fmtDate, readJson, resolveVaultPaths } from "./utils.js";
7
9
  import { assertWritableVault, inspectWritableVault } from "./vault-format.js";
8
10
  /**
9
11
  * Save an atomic insight into the wiki as a single markdown file.
@@ -115,9 +117,34 @@ export function registerWikiRetro(pi, runtime) {
115
117
  isError: true,
116
118
  };
117
119
  }
120
+ // Pre-write wikilink gate (#172): validate/normalize caller-supplied body.
121
+ const mode = resolveWikilinkValidation(loadTaskConfig(ctx.cwd ?? process.cwd()));
122
+ let body = params.body;
123
+ let wikilinkIssues = [];
124
+ if (mode !== "off") {
125
+ const registry = readJson(join(paths.meta, "registry.json"), { pages: {} });
126
+ const gate = applyWikilinkGate(body, buildWikilinkIndex(Object.keys(registry.pages)), `sources/${params.slug}`, mode);
127
+ wikilinkIssues = gate.diagnostics.map((d) => d.message);
128
+ if (!gate.ok) {
129
+ return {
130
+ content: [
131
+ {
132
+ type: "text",
133
+ text: `Rejected write — unresolved/ambiguous wikilinks:\n${wikilinkIssues
134
+ .map((m) => `- ${m}`)
135
+ .join("\n")}`,
136
+ },
137
+ ],
138
+ details: { error: "link_validation", issues: wikilinkIssues },
139
+ isError: true,
140
+ };
141
+ }
142
+ if (mode === "normalize")
143
+ body = gate.body;
144
+ }
118
145
  let result;
119
146
  try {
120
- result = saveInsight(paths, params.slug, params.title, params.body, params.category, {
147
+ result = saveInsight(paths, params.slug, params.title, body, params.category, {
121
148
  rebuild: !runtime,
122
149
  });
123
150
  }
@@ -134,6 +161,9 @@ export function registerWikiRetro(pi, runtime) {
134
161
  if (runtime) {
135
162
  scheduleReindex(runtime, { hasUI: ctx.hasUI, ui: ctx.ui }, paths);
136
163
  }
164
+ const gateNote = wikilinkIssues.length
165
+ ? `\n\n⚠️ ${wikilinkIssues.length} wikilink issue(s):\n${wikilinkIssues.map((m) => `- ${m}`).join("\n")}`
166
+ : "";
137
167
  return {
138
168
  content: [
139
169
  {
@@ -144,13 +174,17 @@ export function registerWikiRetro(pi, runtime) {
144
174
  `- Page: \`${result.sourcePagePath}\``,
145
175
  "",
146
176
  "This insight will be auto-surfaced by wiki_recall in future sessions.",
147
- ].join("\n"),
177
+ gateNote,
178
+ ]
179
+ .filter(Boolean)
180
+ .join("\n"),
148
181
  },
149
182
  ],
150
183
  details: {
151
184
  slug: params.slug,
152
185
  title: params.title,
153
186
  category: params.category || null,
187
+ wikilinkIssues,
154
188
  },
155
189
  };
156
190
  },
@@ -64,11 +64,11 @@ export class Runtime {
64
64
  };
65
65
  }
66
66
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
67
- if (!auth.ok || !auth.apiKey) {
67
+ if (!auth.ok) {
68
68
  const provider = model.provider ?? "unknown";
69
69
  return { ok: false, reason: `no API key for provider "${provider}"` };
70
70
  }
71
- return { ok: true, model, apiKey: auth.apiKey, headers: auth.headers };
71
+ return { ok: true, model, apiKey: auth.apiKey ?? "", headers: auth.headers };
72
72
  }
73
73
  /**
74
74
  * Fire-and-forget a background task.