@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
package/README.de.md CHANGED
@@ -9,6 +9,7 @@
9
9
  [![npm downloads](https://img.shields.io/npm/dm/@zosmaai/pi-llm-wiki)](https://www.npmjs.com/package/@zosmaai/pi-llm-wiki)
10
10
  [![Coverage](https://img.shields.io/badge/coverage-85.09%25-brightgreen.svg)](https://codecov.io/gh/zosmaai/pi-llm-wiki)
11
11
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
12
+ [![Docs](https://img.shields.io/badge/docs-online-blue.svg)](https://zosmaai.github.io/pi-llm-wiki/)
12
13
  [![CodeQL](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml/badge.svg)](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml)
13
14
  [![GitHub Repo Stars](https://img.shields.io/github/stars/zosmaai/pi-llm-wiki?style=social)](https://github.com/zosmaai/pi-llm-wiki/stargazers)
14
15
 
package/README.es.md CHANGED
@@ -9,6 +9,7 @@
9
9
  [![npm downloads](https://img.shields.io/npm/dm/@zosmaai/pi-llm-wiki)](https://www.npmjs.com/package/@zosmaai/pi-llm-wiki)
10
10
  [![Coverage](https://img.shields.io/badge/coverage-85.09%25-brightgreen.svg)](https://codecov.io/gh/zosmaai/pi-llm-wiki)
11
11
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
12
+ [![Docs](https://img.shields.io/badge/docs-online-blue.svg)](https://zosmaai.github.io/pi-llm-wiki/)
12
13
  [![CodeQL](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml/badge.svg)](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml)
13
14
  [![GitHub Repo Stars](https://img.shields.io/github/stars/zosmaai/pi-llm-wiki?style=social)](https://github.com/zosmaai/pi-llm-wiki/stargazers)
14
15
 
package/README.fr.md CHANGED
@@ -9,6 +9,7 @@
9
9
  [![npm downloads](https://img.shields.io/npm/dm/@zosmaai/pi-llm-wiki)](https://www.npmjs.com/package/@zosmaai/pi-llm-wiki)
10
10
  [![Coverage](https://img.shields.io/badge/coverage-85.09%25-brightgreen.svg)](https://codecov.io/gh/zosmaai/pi-llm-wiki)
11
11
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
12
+ [![Docs](https://img.shields.io/badge/docs-online-blue.svg)](https://zosmaai.github.io/pi-llm-wiki/)
12
13
  [![CodeQL](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml/badge.svg)](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml)
13
14
  [![GitHub Repo Stars](https://img.shields.io/github/stars/zosmaai/pi-llm-wiki?style=social)](https://github.com/zosmaai/pi-llm-wiki/stargazers)
14
15
 
package/README.hi.md CHANGED
@@ -9,6 +9,7 @@
9
9
  [![npm downloads](https://img.shields.io/npm/dm/@zosmaai/pi-llm-wiki)](https://www.npmjs.com/package/@zosmaai/pi-llm-wiki)
10
10
  [![Coverage](https://img.shields.io/badge/coverage-85.09%25-brightgreen.svg)](https://codecov.io/gh/zosmaai/pi-llm-wiki)
11
11
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
12
+ [![Docs](https://img.shields.io/badge/docs-online-blue.svg)](https://zosmaai.github.io/pi-llm-wiki/)
12
13
  [![CodeQL](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml/badge.svg)](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml)
13
14
  [![GitHub Repo Stars](https://img.shields.io/github/stars/zosmaai/pi-llm-wiki?style=social)](https://github.com/zosmaai/pi-llm-wiki/stargazers)
14
15
 
package/README.ja.md CHANGED
@@ -9,6 +9,7 @@
9
9
  [![npm downloads](https://img.shields.io/npm/dm/@zosmaai/pi-llm-wiki)](https://www.npmjs.com/package/@zosmaai/pi-llm-wiki)
10
10
  [![Coverage](https://img.shields.io/badge/coverage-85.09%25-brightgreen.svg)](https://codecov.io/gh/zosmaai/pi-llm-wiki)
11
11
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
12
+ [![Docs](https://img.shields.io/badge/docs-online-blue.svg)](https://zosmaai.github.io/pi-llm-wiki/)
12
13
  [![CodeQL](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml/badge.svg)](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml)
13
14
  [![GitHub Repo Stars](https://img.shields.io/github/stars/zosmaai/pi-llm-wiki?style=social)](https://github.com/zosmaai/pi-llm-wiki/stargazers)
14
15
 
package/README.ko.md CHANGED
@@ -9,6 +9,7 @@
9
9
  [![npm downloads](https://img.shields.io/npm/dm/@zosmaai/pi-llm-wiki)](https://www.npmjs.com/package/@zosmaai/pi-llm-wiki)
10
10
  [![Coverage](https://img.shields.io/badge/coverage-85.09%25-brightgreen.svg)](https://codecov.io/gh/zosmaai/pi-llm-wiki)
11
11
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
12
+ [![Docs](https://img.shields.io/badge/docs-online-blue.svg)](https://zosmaai.github.io/pi-llm-wiki/)
12
13
  [![CodeQL](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml/badge.svg)](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml)
13
14
  [![GitHub Repo Stars](https://img.shields.io/github/stars/zosmaai/pi-llm-wiki?style=social)](https://github.com/zosmaai/pi-llm-wiki/stargazers)
14
15
 
package/README.md CHANGED
@@ -9,6 +9,7 @@
9
9
  [![npm downloads](https://img.shields.io/npm/dm/@zosmaai/pi-llm-wiki)](https://www.npmjs.com/package/@zosmaai/pi-llm-wiki)
10
10
  [![Coverage](https://img.shields.io/badge/coverage-85.09%25-brightgreen.svg)](https://codecov.io/gh/zosmaai/pi-llm-wiki)
11
11
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
12
+ [![Docs](https://img.shields.io/badge/docs-online-blue.svg)](https://zosmaai.github.io/pi-llm-wiki/)
12
13
  [![CodeQL](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml/badge.svg)](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml)
13
14
  [![GitHub Repo Stars](https://img.shields.io/github/stars/zosmaai/pi-llm-wiki?style=social)](https://github.com/zosmaai/pi-llm-wiki/stargazers)
14
15
 
package/README.pt.md CHANGED
@@ -9,6 +9,7 @@
9
9
  [![npm downloads](https://img.shields.io/npm/dm/@zosmaai/pi-llm-wiki)](https://www.npmjs.com/package/@zosmaai/pi-llm-wiki)
10
10
  [![Coverage](https://img.shields.io/badge/coverage-85.09%25-brightgreen.svg)](https://codecov.io/gh/zosmaai/pi-llm-wiki)
11
11
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
12
+ [![Docs](https://img.shields.io/badge/docs-online-blue.svg)](https://zosmaai.github.io/pi-llm-wiki/)
12
13
  [![CodeQL](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml/badge.svg)](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml)
13
14
  [![GitHub Repo Stars](https://img.shields.io/github/stars/zosmaai/pi-llm-wiki?style=social)](https://github.com/zosmaai/pi-llm-wiki/stargazers)
14
15
 
package/README.ru.md CHANGED
@@ -9,6 +9,7 @@
9
9
  [![npm downloads](https://img.shields.io/npm/dm/@zosmaai/pi-llm-wiki)](https://www.npmjs.com/package/@zosmaai/pi-llm-wiki)
10
10
  [![Coverage](https://img.shields.io/badge/coverage-85.09%25-brightgreen.svg)](https://codecov.io/gh/zosmaai/pi-llm-wiki)
11
11
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
12
+ [![Docs](https://img.shields.io/badge/docs-online-blue.svg)](https://zosmaai.github.io/pi-llm-wiki/)
12
13
  [![CodeQL](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml/badge.svg)](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml)
13
14
  [![GitHub Repo Stars](https://img.shields.io/github/stars/zosmaai/pi-llm-wiki?style=social)](https://github.com/zosmaai/pi-llm-wiki/stargazers)
14
15
 
package/README.zh.md CHANGED
@@ -9,6 +9,7 @@
9
9
  [![npm downloads](https://img.shields.io/npm/dm/@zosmaai/pi-llm-wiki)](https://www.npmjs.com/package/@zosmaai/pi-llm-wiki)
10
10
  [![Coverage](https://img.shields.io/badge/coverage-85.09%25-brightgreen.svg)](https://codecov.io/gh/zosmaai/pi-llm-wiki)
11
11
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
12
+ [![Docs](https://img.shields.io/badge/docs-online-blue.svg)](https://zosmaai.github.io/pi-llm-wiki/)
12
13
  [![CodeQL](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml/badge.svg)](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml)
13
14
  [![GitHub Repo Stars](https://img.shields.io/github/stars/zosmaai/pi-llm-wiki?style=social)](https://github.com/zosmaai/pi-llm-wiki/stargazers)
14
15
 
@@ -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, synthesisMaxTokens, } = 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}` }],
@@ -1,4 +1,5 @@
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 };
@@ -79,6 +80,55 @@ export function extractLegacyWikilinks(body) {
79
80
  }
80
81
  return links;
81
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
+ }
82
132
  function resolveMarkdownTarget(target, sourceId) {
83
133
  // Strip query and fragment (earliest delimiter)
84
134
  const qIndex = target.indexOf("?");
@@ -156,14 +206,7 @@ function resolveMarkdownTarget(target, sourceId) {
156
206
  }
157
207
  return { kind: "empty" };
158
208
  }
159
- function resolveWikilinkTarget(target) {
160
- // Wikilinks are already bundle-relative concept IDs
161
- const cleaned = target.trim();
162
- if (!cleaned)
163
- return { kind: "empty" };
164
- return { kind: "concept", id: cleaned };
165
- }
166
- export function buildResolvedBacklinks(sourceId, body, knownIds) {
209
+ export function buildResolvedBacklinks(sourceId, body, index) {
167
210
  const diagnostics = [];
168
211
  const unresolved = [];
169
212
  const targets = new Set();
@@ -178,32 +221,92 @@ export function buildResolvedBacklinks(sourceId, body, knownIds) {
178
221
  diagnostics.push(diag("warning", "link_unresolved", `${sourceId}.md`, `Malformed percent-encoded link: ${link.target}`));
179
222
  }
180
223
  else if (resolved.kind === "concept") {
181
- const normalizedId = resolved.id.normalize("NFC");
182
- if (knownIds.has(normalizedId)) {
183
- targets.add(normalizedId);
224
+ const canonical = index.byExact.get(resolved.id.normalize("NFC"));
225
+ if (canonical) {
226
+ targets.add(canonical);
184
227
  }
185
228
  else {
186
- unresolved.push({ target: normalizedId, syntax: "markdown" });
187
- 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}`));
188
231
  }
189
232
  }
190
233
  // external and empty are silently ignored
191
234
  }
192
- // Process wikilinks
235
+ // Process wikilinks (lenient: exact → normalized path → bare title)
193
236
  for (const link of allLinks.wikilinks) {
194
- const resolved = resolveWikilinkTarget(link.target);
195
- if (resolved.kind === "concept") {
196
- const normalizedId = resolved.id.normalize("NFC");
197
- if (knownIds.has(normalizedId)) {
198
- targets.add(normalizedId);
199
- }
200
- else {
201
- unresolved.push({ target: normalizedId, syntax: "wikilink" });
202
- diagnostics.push(diag("warning", "link_unresolved", `${sourceId}.md`, `Unresolved wikilink: ${normalizedId}`));
203
- }
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}`));
204
247
  }
205
248
  }
206
249
  // Sort and deduplicate
207
250
  const sorted = [...targets].sort(compareCodePoint);
208
251
  return { targets: sorted, unresolved, diagnostics };
209
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)) {
@@ -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.
@@ -10,6 +10,23 @@ export const TASK_DEFAULTS = {};
10
10
  export function noticesEnabled(config) {
11
11
  return config?.notices !== false;
12
12
  }
13
+ const WIKILINK_VALIDATION_MODES = [
14
+ "off",
15
+ "warn",
16
+ "strict",
17
+ "normalize",
18
+ ];
19
+ /**
20
+ * Resolve the wikilink write-gate mode (issue #172, Layer 2). Defaults to
21
+ * `warn` — ingest always writes and reports; only an explicit `strict` blocks.
22
+ * Unknown values fall back to `warn` rather than failing the ingest.
23
+ */
24
+ export function resolveWikilinkValidation(config) {
25
+ const v = config?.wikilinkValidation;
26
+ if (v && WIKILINK_VALIDATION_MODES.includes(v))
27
+ return v;
28
+ return "warn";
29
+ }
13
30
  /**
14
31
  * Resolve whether the personal vault may serve as this project's ambient
15
32
  * vault. Explicit `ambientPersonalVault` wins; otherwise the host decides
@@ -91,6 +108,22 @@ function readNamespacedConfig(path) {
91
108
  if (typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0) {
92
109
  out.synthesisMaxTokens = Math.floor(maxTokens);
93
110
  }
111
+ const wl = section.wikilinkValidation;
112
+ if (typeof wl === "string" && WIKILINK_VALIDATION_MODES.includes(wl)) {
113
+ out.wikilinkValidation = wl;
114
+ }
115
+ const ct = section.customTypes;
116
+ if (ct && typeof ct === "object" && !Array.isArray(ct)) {
117
+ const entries = Object.entries(ct);
118
+ const valid = {};
119
+ for (const [k, v] of entries) {
120
+ if (typeof k === "string" && typeof v === "string" && k && v) {
121
+ valid[k] = v;
122
+ }
123
+ }
124
+ if (Object.keys(valid).length)
125
+ out.customTypes = valid;
126
+ }
94
127
  return out;
95
128
  }
96
129
  catch {
@@ -260,6 +293,8 @@ const KNOWN_KEYS = [
260
293
  "trajectories",
261
294
  "synthesisLanguage",
262
295
  "synthesisMaxTokens",
296
+ "wikilinkValidation",
297
+ "customTypes",
263
298
  ];
264
299
  export function loadTaskConfigSources(cwd) {
265
300
  const globalResult = {};