@zosmaai/pi-llm-wiki 0.10.7 → 0.11.0

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 (73) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.de.md +35 -4
  3. package/README.es.md +260 -170
  4. package/README.fr.md +35 -4
  5. package/README.hi.md +35 -4
  6. package/README.ja.md +35 -4
  7. package/README.ko.md +35 -4
  8. package/README.md +38 -3
  9. package/README.pt.md +35 -4
  10. package/README.ru.md +35 -4
  11. package/README.zh.md +260 -170
  12. package/assets/demo.gif +0 -0
  13. package/dist/extensions/llm-wiki/lib/bootstrap.js +71 -0
  14. package/dist/extensions/llm-wiki/lib/embeddings.js +401 -0
  15. package/dist/extensions/llm-wiki/lib/guardrails.js +232 -0
  16. package/dist/extensions/llm-wiki/lib/indexing.js +78 -0
  17. package/dist/extensions/llm-wiki/lib/ingest-worker.js +310 -0
  18. package/dist/extensions/llm-wiki/lib/inject.js +65 -0
  19. package/dist/extensions/llm-wiki/lib/knowledge-document.js +442 -0
  20. package/dist/extensions/llm-wiki/lib/knowledge-links.js +206 -0
  21. package/dist/extensions/llm-wiki/lib/legacy-repair.js +443 -0
  22. package/dist/extensions/llm-wiki/lib/metadata.js +499 -0
  23. package/dist/extensions/llm-wiki/lib/model-command.js +86 -0
  24. package/dist/extensions/llm-wiki/lib/observation.js +283 -0
  25. package/dist/extensions/llm-wiki/lib/recall.js +875 -0
  26. package/dist/extensions/llm-wiki/lib/retro.js +158 -0
  27. package/dist/extensions/llm-wiki/lib/runtime.js +191 -0
  28. package/dist/extensions/llm-wiki/lib/source-extractors.js +426 -0
  29. package/dist/extensions/llm-wiki/lib/source-packet.js +229 -0
  30. package/dist/extensions/llm-wiki/lib/subagent.js +41 -0
  31. package/dist/extensions/llm-wiki/lib/task-config.js +172 -0
  32. package/dist/extensions/llm-wiki/lib/tools.js +1192 -0
  33. package/dist/extensions/llm-wiki/lib/trajectories-command.js +51 -0
  34. package/dist/extensions/llm-wiki/lib/trajectory.js +467 -0
  35. package/dist/extensions/llm-wiki/lib/utils.js +347 -0
  36. package/dist/extensions/llm-wiki/lib/vault-format.js +247 -0
  37. package/dist/extensions/llm-wiki/lib/visible-status.js +31 -0
  38. package/dist/extensions/llm-wiki/lib/wiki-service.js +128 -0
  39. package/dist/mcp/exec.js +121 -0
  40. package/dist/mcp/index.js +229 -0
  41. package/dist/mcp/operations.js +130 -0
  42. package/dist/package.json +1 -0
  43. package/docs/superpowers/plans/2026-08-02-okf-foundation.md +1579 -0
  44. package/docs/superpowers/plans/2026-08-03-okf-foundation-remediation.md +3005 -0
  45. package/docs/superpowers/plans/2026-08-06-okf-foundation-release-remediation.md +1174 -0
  46. package/docs/superpowers/specs/2026-08-02-okf-foundation-design.md +578 -0
  47. package/docs/superpowers/specs/2026-08-02-okf-v0.2-interoperability-design.md +538 -0
  48. package/extensions/llm-wiki/index.ts +22 -36
  49. package/extensions/llm-wiki/lib/bootstrap.ts +84 -0
  50. package/extensions/llm-wiki/lib/embeddings.ts +9 -3
  51. package/extensions/llm-wiki/lib/guardrails.ts +174 -29
  52. package/extensions/llm-wiki/lib/indexing.ts +2 -1
  53. package/extensions/llm-wiki/lib/ingest-worker.ts +170 -29
  54. package/extensions/llm-wiki/lib/knowledge-document.ts +661 -0
  55. package/extensions/llm-wiki/lib/knowledge-links.ts +282 -0
  56. package/extensions/llm-wiki/lib/legacy-repair.ts +572 -0
  57. package/extensions/llm-wiki/lib/metadata.ts +531 -116
  58. package/extensions/llm-wiki/lib/observation.ts +37 -43
  59. package/extensions/llm-wiki/lib/recall.ts +61 -33
  60. package/extensions/llm-wiki/lib/retro.ts +65 -41
  61. package/extensions/llm-wiki/lib/source-extractors.ts +12 -17
  62. package/extensions/llm-wiki/lib/source-packet.ts +44 -31
  63. package/extensions/llm-wiki/lib/tools.ts +406 -348
  64. package/extensions/llm-wiki/lib/trajectory.ts +15 -1
  65. package/extensions/llm-wiki/lib/utils.ts +121 -130
  66. package/extensions/llm-wiki/lib/vault-format.ts +363 -0
  67. package/extensions/llm-wiki/lib/wiki-service.ts +183 -0
  68. package/mcp/exec.ts +122 -0
  69. package/mcp/index.ts +60 -250
  70. package/mcp/operations.ts +176 -0
  71. package/package.json +8 -2
  72. package/scripts/migrate-llm-wiki.js +801 -0
  73. package/skills/llm-wiki/SKILL.md +8 -6
@@ -0,0 +1,442 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { isAlias, isMap, isScalar, isSeq, parseAllDocuments, stringify } from "yaml";
3
+ export const FRONTMATTER_MAX_BYTES = 128 * 1024;
4
+ export const FRONTMATTER_MAX_DEPTH = 32;
5
+ const STANDARD_FIELDS = new Set([
6
+ "type",
7
+ "title",
8
+ "description",
9
+ "resource",
10
+ "tags",
11
+ "sources",
12
+ "generated",
13
+ "verified",
14
+ "status",
15
+ "stale_after",
16
+ "category",
17
+ "domain",
18
+ "aliases",
19
+ "recall_triggers",
20
+ "created",
21
+ "updated",
22
+ "summary",
23
+ "raw_path",
24
+ "source_id",
25
+ ]);
26
+ const LEGACY_COMPAT_FIELDS = new Set(["created", "updated", "summary", "raw_path", "source_id"]);
27
+ function diag(severity, code, path, message, line, column) {
28
+ return { severity, code, path, message, line, column };
29
+ }
30
+ function classifySources(raw) {
31
+ if (raw === undefined)
32
+ return { kind: "absent" };
33
+ if (raw === null)
34
+ return { kind: "unknown-shape", value: null };
35
+ if (typeof raw === "string")
36
+ return { kind: "legacy-scalar", value: raw };
37
+ if (Array.isArray(raw)) {
38
+ if (raw.every((v) => typeof v === "string")) {
39
+ return { kind: "legacy-list", value: raw };
40
+ }
41
+ if (raw.every((v) => v && typeof v === "object" && !Array.isArray(v))) {
42
+ return { kind: "canonical", value: raw };
43
+ }
44
+ }
45
+ return { kind: "unknown-shape", value: raw };
46
+ }
47
+ function hasAlias(node) {
48
+ if (isAlias(node))
49
+ return true;
50
+ if (isMap(node)) {
51
+ return node.items.some((item) => hasAlias(item.key) || hasAlias(item.value));
52
+ }
53
+ if (isSeq(node)) {
54
+ return node.items.some((item) => hasAlias(item));
55
+ }
56
+ return false;
57
+ }
58
+ function hasCustomTag(node) {
59
+ if (node && typeof node === "object" && "tag" in node && typeof node.tag === "string") {
60
+ if (node.tag.startsWith("!"))
61
+ return true;
62
+ }
63
+ if (isMap(node)) {
64
+ return node.items.some((item) => hasCustomTag(item.key) || hasCustomTag(item.value));
65
+ }
66
+ if (isSeq(node)) {
67
+ return node.items.some((item) => hasCustomTag(item));
68
+ }
69
+ return false;
70
+ }
71
+ function maxDepth(node, current) {
72
+ if (isMap(node)) {
73
+ let deepest = current + 1;
74
+ for (const item of node.items) {
75
+ deepest = Math.max(deepest, maxDepth(item.key, current + 1), maxDepth(item.value, current + 1));
76
+ }
77
+ return deepest;
78
+ }
79
+ if (isSeq(node)) {
80
+ let deepest = current + 1;
81
+ for (const item of node.items) {
82
+ deepest = Math.max(deepest, maxDepth(item, current + 1));
83
+ }
84
+ return deepest;
85
+ }
86
+ return current;
87
+ }
88
+ function toKnowledgeValue(node) {
89
+ if (node === null)
90
+ return null;
91
+ if (isScalar(node)) {
92
+ const v = node.value;
93
+ if (v === null)
94
+ return null;
95
+ if (typeof v === "boolean" || typeof v === "number" || typeof v === "string")
96
+ return v;
97
+ return String(v);
98
+ }
99
+ if (isSeq(node)) {
100
+ return node.items.map(toKnowledgeValue);
101
+ }
102
+ if (isMap(node)) {
103
+ const obj = Object.create(null);
104
+ for (const item of node.items) {
105
+ const key = String(isScalar(item.key) ? item.key.value : item.key);
106
+ obj[key] = toKnowledgeValue(item.value);
107
+ }
108
+ return obj;
109
+ }
110
+ return null;
111
+ }
112
+ function detectLegacyWikilinks(body) {
113
+ return /\[\[[^\]]+\]\]/.test(body);
114
+ }
115
+ function parseFrontmatterBlock(content, path, requireType) {
116
+ const diagnostics = [];
117
+ // Normalize line endings
118
+ const normalized = content.replace(/\r\n?/g, "\n");
119
+ // Require opening --- on line 1
120
+ if (!normalized.startsWith("---\n")) {
121
+ return {
122
+ ok: false,
123
+ diagnostics: [
124
+ diag("error", "frontmatter_missing", path, "Missing frontmatter opening delimiter"),
125
+ ],
126
+ };
127
+ }
128
+ // Find closing ---
129
+ // Per spec: when a candidate immediately follows a YAML explicit-end line ...
130
+ // and another delimiter exists later, treat that candidate as an internal
131
+ // second-document marker and continue to the later closing fence.
132
+ let closingIndex = -1;
133
+ let i = 4; // skip opening ---\n
134
+ const candidates = [];
135
+ let lastLineWasExplicitEnd = false;
136
+ while (i < normalized.length) {
137
+ const newlinePos = normalized.indexOf("\n", i);
138
+ const lineEnd = newlinePos === -1 ? normalized.length : newlinePos;
139
+ const line = normalized.slice(i, lineEnd);
140
+ if (line === "---") {
141
+ candidates.push({ index: i, followsExplicitEnd: lastLineWasExplicitEnd });
142
+ lastLineWasExplicitEnd = false;
143
+ }
144
+ else if (line === "...") {
145
+ lastLineWasExplicitEnd = true;
146
+ }
147
+ else {
148
+ lastLineWasExplicitEnd = false;
149
+ }
150
+ i = lineEnd + 1;
151
+ }
152
+ // Find the proper closing fence
153
+ if (candidates.length === 1) {
154
+ closingIndex = candidates[0].index;
155
+ }
156
+ else if (candidates.length > 1) {
157
+ // If first candidate follows explicit end and there's another later,
158
+ // skip the first (it's a second-document marker)
159
+ if (candidates[0].followsExplicitEnd && candidates.length >= 2) {
160
+ closingIndex = candidates[1].index;
161
+ }
162
+ else {
163
+ closingIndex = candidates[0].index;
164
+ }
165
+ }
166
+ if (closingIndex === -1) {
167
+ return {
168
+ ok: false,
169
+ diagnostics: [
170
+ diag("error", "frontmatter_parse_error", path, "Missing frontmatter closing delimiter"),
171
+ ],
172
+ };
173
+ }
174
+ const yamlText = normalized.slice(4, closingIndex);
175
+ // Check byte limit
176
+ const byteLength = Buffer.byteLength(yamlText, "utf8");
177
+ if (byteLength > FRONTMATTER_MAX_BYTES) {
178
+ return {
179
+ ok: false,
180
+ diagnostics: [
181
+ diag("error", "frontmatter_limit_bytes", path, `Frontmatter exceeds ${FRONTMATTER_MAX_BYTES} bytes`),
182
+ ],
183
+ };
184
+ }
185
+ // Parse with yaml library - use lenient mode first, then validate
186
+ let docs;
187
+ try {
188
+ docs = parseAllDocuments(yamlText, {
189
+ schema: "core",
190
+ merge: false,
191
+ uniqueKeys: true,
192
+ });
193
+ }
194
+ catch (e) {
195
+ const err = e;
196
+ return {
197
+ ok: false,
198
+ diagnostics: [
199
+ diag("error", "frontmatter_parse_error", path, `YAML parse error: ${err.message}`, err.line ?? undefined, err.col ?? undefined),
200
+ ],
201
+ };
202
+ }
203
+ // YAML parser errors must be surfaced before conversion. `yaml` records
204
+ // malformed syntax and nested duplicate keys on the Document instead of
205
+ // throwing from parseAllDocuments.
206
+ const yamlErrors = docs.flatMap((document) => document.errors);
207
+ if (yamlErrors.length > 0) {
208
+ const duplicate = yamlErrors.find((error) => error.code === "DUPLICATE_KEY");
209
+ const error = duplicate ?? yamlErrors[0];
210
+ return {
211
+ ok: false,
212
+ diagnostics: [
213
+ diag("error", duplicate ? "frontmatter_duplicate_key" : "frontmatter_parse_error", path, `YAML parse error: ${error.message}`),
214
+ ],
215
+ };
216
+ }
217
+ // Check for multiple documents
218
+ if (docs.length !== 1) {
219
+ return {
220
+ ok: false,
221
+ diagnostics: [
222
+ diag("error", "frontmatter_multiple_documents", path, "Multiple YAML documents in frontmatter"),
223
+ ],
224
+ };
225
+ }
226
+ const doc = docs[0];
227
+ const contents = doc.contents;
228
+ // Must be a mapping
229
+ if (!isMap(contents)) {
230
+ return {
231
+ ok: false,
232
+ diagnostics: [
233
+ diag("error", "frontmatter_parse_error", path, "Frontmatter must be a YAML mapping"),
234
+ ],
235
+ };
236
+ }
237
+ // Check for aliases
238
+ if (hasAlias(contents)) {
239
+ return {
240
+ ok: false,
241
+ diagnostics: [
242
+ diag("error", "frontmatter_alias_forbidden", path, "YAML aliases are not allowed"),
243
+ ],
244
+ };
245
+ }
246
+ // Check for custom tags
247
+ if (hasCustomTag(contents)) {
248
+ return {
249
+ ok: false,
250
+ diagnostics: [
251
+ diag("error", "frontmatter_custom_tag_forbidden", path, "Custom YAML tags are not allowed"),
252
+ ],
253
+ };
254
+ }
255
+ // Check for duplicate keys
256
+ const seenKeys = new Set();
257
+ for (const item of contents.items) {
258
+ const key = String(isScalar(item.key) ? item.key.value : item.key);
259
+ if (seenKeys.has(key)) {
260
+ return {
261
+ ok: false,
262
+ diagnostics: [
263
+ diag("error", "frontmatter_duplicate_key", path, `Duplicate key: ${key}`, undefined, undefined),
264
+ ],
265
+ };
266
+ }
267
+ seenKeys.add(key);
268
+ }
269
+ // Check depth
270
+ const depth = maxDepth(contents, 0);
271
+ if (depth > FRONTMATTER_MAX_DEPTH) {
272
+ return {
273
+ ok: false,
274
+ diagnostics: [
275
+ diag("error", "frontmatter_limit_depth", path, `Frontmatter nesting exceeds ${FRONTMATTER_MAX_DEPTH}`),
276
+ ],
277
+ };
278
+ }
279
+ const mapping = toKnowledgeValue(contents);
280
+ // Build body: remove closing fence and one optional blank separator line
281
+ let body = normalized.slice(closingIndex + 4); // skip closing ---\n
282
+ if (body.startsWith("\n")) {
283
+ // Remove exactly one leading blank separator line; additional leading blanks belong to body
284
+ body = body.slice(1);
285
+ }
286
+ if (!requireType) {
287
+ return {
288
+ ok: true,
289
+ mapping,
290
+ body,
291
+ diagnostics,
292
+ };
293
+ }
294
+ // Require type field
295
+ const rawType = mapping.type;
296
+ if (typeof rawType !== "string" || rawType.trim() === "") {
297
+ return {
298
+ ok: false,
299
+ diagnostics: [diag("error", "concept_missing_type", path, "Missing or empty type field")],
300
+ };
301
+ }
302
+ // Classify sources
303
+ const sources = classifySources(mapping.sources);
304
+ // Split standard fields from extensions
305
+ const frontmatter = { type: rawType };
306
+ const extensions = Object.create(null);
307
+ const legacyFields = [];
308
+ for (const [key, value] of Object.entries(mapping)) {
309
+ if (key === "sources")
310
+ continue; // handled separately
311
+ if (STANDARD_FIELDS.has(key)) {
312
+ frontmatter[key] = value;
313
+ if (LEGACY_COMPAT_FIELDS.has(key)) {
314
+ legacyFields.push(key);
315
+ }
316
+ }
317
+ else {
318
+ extensions[key] = value;
319
+ }
320
+ }
321
+ // Record legacy source shape
322
+ if (sources.kind === "legacy-scalar" || sources.kind === "legacy-list") {
323
+ legacyFields.push("sources");
324
+ }
325
+ const hasLegacyWikilinks = detectLegacyWikilinks(body);
326
+ if (hasLegacyWikilinks) {
327
+ legacyFields.push("wikilinks");
328
+ }
329
+ return {
330
+ ok: true,
331
+ document: {
332
+ id: path.replace(/\.md$/, ""),
333
+ path,
334
+ frontmatter,
335
+ sources,
336
+ extensions,
337
+ body,
338
+ compatibility: {
339
+ legacyFields,
340
+ hasLegacyWikilinks,
341
+ },
342
+ },
343
+ diagnostics,
344
+ };
345
+ }
346
+ export function parseMarkdownFrontmatter(content, path) {
347
+ const result = parseFrontmatterBlock(content, path, false);
348
+ if ("ok" in result && result.ok && "mapping" in result) {
349
+ return result;
350
+ }
351
+ return result;
352
+ }
353
+ export function parseKnowledgeDocument(content, path) {
354
+ const result = parseFrontmatterBlock(content, path, true);
355
+ return result;
356
+ }
357
+ export function serializeKnowledgeDocument(document) {
358
+ // Rebuild mapping from frontmatter, sources, and extensions
359
+ const mapping = Object.create(null);
360
+ // Standard frontmatter fields (excluding sources)
361
+ for (const key of STANDARD_FIELDS) {
362
+ if (key === "sources")
363
+ continue;
364
+ const value = document.frontmatter[key];
365
+ if (value !== undefined) {
366
+ mapping[key] = value;
367
+ }
368
+ }
369
+ // Sources
370
+ if (document.sources.kind !== "absent") {
371
+ mapping.sources = document.sources.value;
372
+ }
373
+ // Extensions
374
+ for (const [key, value] of Object.entries(document.extensions)) {
375
+ mapping[key] = value;
376
+ }
377
+ const yaml = stringify(mapping, {
378
+ aliasDuplicateObjects: false,
379
+ lineWidth: 0,
380
+ })
381
+ .replace(/\r\n?/g, "\n")
382
+ .replace(/\n*$/, "\n");
383
+ const body = document.body.replace(/\r\n?/g, "\n").replace(/\n*$/, "");
384
+ return body ? `---\n${yaml}---\n\n${body}\n` : `---\n${yaml}---\n`;
385
+ }
386
+ export function createKnowledgeDocument(path, fields, body, sources) {
387
+ if (Object.hasOwn(fields, "sources")) {
388
+ throw new Error("Pass canonical sources as the fourth argument");
389
+ }
390
+ const frontmatter = { type: fields.type };
391
+ const extensions = Object.create(null);
392
+ for (const [key, value] of Object.entries(fields)) {
393
+ if (key === "type")
394
+ continue;
395
+ if (STANDARD_FIELDS.has(key)) {
396
+ frontmatter[key] = value;
397
+ }
398
+ else {
399
+ extensions[key] = value;
400
+ }
401
+ }
402
+ const sourcesUnion = sources
403
+ ? { kind: "canonical", value: sources }
404
+ : { kind: "absent" };
405
+ const normalizedBody = body.replace(/\r\n?/g, "\n").replace(/\n*$/, "");
406
+ return {
407
+ id: path.replace(/\.md$/, ""),
408
+ path,
409
+ frontmatter,
410
+ sources: sourcesUnion,
411
+ extensions,
412
+ body: normalizedBody,
413
+ compatibility: {
414
+ legacyFields: [],
415
+ hasLegacyWikilinks: detectLegacyWikilinks(normalizedBody),
416
+ },
417
+ };
418
+ }
419
+ export function patchKnowledgeDocument(document, patch) {
420
+ const newFrontmatter = { ...document.frontmatter };
421
+ if (patch.fields) {
422
+ for (const [key, value] of Object.entries(patch.fields)) {
423
+ if (value !== undefined) {
424
+ newFrontmatter[key] = value;
425
+ }
426
+ }
427
+ }
428
+ const newBody = patch.body ?? document.body;
429
+ return {
430
+ ...document,
431
+ frontmatter: newFrontmatter,
432
+ body: newBody,
433
+ };
434
+ }
435
+ export function readKnowledgeDocumentFile(path, id) {
436
+ const content = readFileSync(path, "utf8");
437
+ return parseKnowledgeDocument(content, id);
438
+ }
439
+ export function writeKnowledgeDocumentFile(path, document) {
440
+ const content = serializeKnowledgeDocument(document);
441
+ writeFileSync(path, content, "utf8");
442
+ }
@@ -0,0 +1,206 @@
1
+ import { fromMarkdown } from "mdast-util-from-markdown";
2
+ import { compareCodePoint } from "./vault-format.js";
3
+ function diag(severity, code, path, message) {
4
+ return { severity, code, path, message };
5
+ }
6
+ export function extractKnowledgeLinks(body) {
7
+ const markdown = [];
8
+ const wikilinks = [];
9
+ // Extract legacy wikilinks
10
+ for (const match of body.matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g)) {
11
+ wikilinks.push({ target: match[1].trim(), offset: match.index ?? 0 });
12
+ }
13
+ // Parse with CommonMark AST
14
+ let tree;
15
+ try {
16
+ tree = fromMarkdown(body);
17
+ }
18
+ catch {
19
+ return { markdown, wikilinks };
20
+ }
21
+ // Build definition map (case-insensitive)
22
+ const defs = new Map();
23
+ function collectDefs(node) {
24
+ if (node.type === "definition") {
25
+ defs.set(node.identifier.toLowerCase(), node);
26
+ }
27
+ if ("children" in node && Array.isArray(node.children)) {
28
+ for (const child of node.children) {
29
+ collectDefs(child);
30
+ }
31
+ }
32
+ }
33
+ collectDefs(tree);
34
+ // Track used definitions
35
+ const usedDefs = new Set();
36
+ // Visit nodes for links
37
+ function visit(node) {
38
+ // Skip code spans, fenced/indented code blocks, and raw HTML
39
+ if (node.type === "inlineCode" || node.type === "code" || node.type === "html")
40
+ return;
41
+ if (node.type === "link") {
42
+ const link = node;
43
+ // Skip autolinks (source starts with <)
44
+ if (link.url && link.position) {
45
+ const source = body.slice(link.position.start.offset, link.position.end.offset);
46
+ if (!source.startsWith("<")) {
47
+ markdown.push({ target: link.url, offset: link.position.start.offset ?? 0 });
48
+ }
49
+ }
50
+ }
51
+ if (node.type === "linkReference") {
52
+ const ref = node;
53
+ const ident = (ref.identifier ?? "").toLowerCase();
54
+ const def = defs.get(ident);
55
+ if (def && !usedDefs.has(ident)) {
56
+ usedDefs.add(ident);
57
+ if (ref.position) {
58
+ markdown.push({ target: def.url, offset: ref.position.start.offset ?? 0 });
59
+ }
60
+ }
61
+ }
62
+ // Recurse into children
63
+ if ("children" in node && Array.isArray(node.children)) {
64
+ for (const child of node.children) {
65
+ visit(child);
66
+ }
67
+ }
68
+ }
69
+ visit(tree);
70
+ return { markdown, wikilinks };
71
+ }
72
+ export function extractLegacyWikilinks(body) {
73
+ const links = [];
74
+ for (const match of body.matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g)) {
75
+ links.push({ target: match[1].trim(), offset: match.index ?? 0 });
76
+ }
77
+ return links;
78
+ }
79
+ function resolveMarkdownTarget(target, sourceId) {
80
+ // Strip query and fragment (earliest delimiter)
81
+ const qIndex = target.indexOf("?");
82
+ const fIndex = target.indexOf("#");
83
+ let clean = target;
84
+ let cut = -1;
85
+ if (qIndex !== -1 && (fIndex === -1 || qIndex < fIndex))
86
+ cut = qIndex;
87
+ else if (fIndex !== -1)
88
+ cut = fIndex;
89
+ if (cut !== -1)
90
+ clean = target.slice(0, cut);
91
+ // Ignore empty fragment-only targets
92
+ if (!clean || clean === "#")
93
+ return { kind: "empty" };
94
+ // Ignore external URI schemes
95
+ if (/^[a-z][a-z0-9+.-]*:/i.test(clean))
96
+ return { kind: "external" };
97
+ // Percent-decode each segment once, but never let malformed user input escape
98
+ // the link diagnostics boundary.
99
+ let decoded;
100
+ try {
101
+ decoded = clean.split("/").map((segment) => decodeURIComponent(segment));
102
+ }
103
+ catch {
104
+ return { kind: "invalid" };
105
+ }
106
+ // Convert decoded backslashes to /
107
+ const normalized = decoded.map((s) => s.replace(/\\/g, "/")).join("/");
108
+ const sourceDir = sourceId.replace(/[^\/]*$/, ""); // directory of source
109
+ const parts = normalized.split("/");
110
+ const isRootRelative = normalized.startsWith("/");
111
+ if (isRootRelative) {
112
+ // Root-relative: resolve with explicit stack
113
+ const stack = [];
114
+ for (const part of parts) {
115
+ if (part === "")
116
+ continue;
117
+ if (part === ".")
118
+ continue;
119
+ if (part === "..") {
120
+ if (stack.length === 0)
121
+ return { kind: "escape" };
122
+ stack.pop();
123
+ }
124
+ else {
125
+ stack.push(part);
126
+ }
127
+ }
128
+ const resolved = stack.join("/");
129
+ if (!resolved.endsWith(".md"))
130
+ return { kind: "empty" };
131
+ return { kind: "concept", id: resolved.slice(0, -3) };
132
+ }
133
+ // For file-relative paths, resolve against source directory
134
+ const baseParts = sourceDir.split("/").filter(Boolean);
135
+ const allParts = [...baseParts];
136
+ for (const part of parts) {
137
+ if (part === "" || part === ".") {
138
+ }
139
+ else if (part === "..") {
140
+ if (allParts.length === 0) {
141
+ return { kind: "escape" };
142
+ }
143
+ allParts.pop();
144
+ }
145
+ else {
146
+ allParts.push(part);
147
+ }
148
+ }
149
+ const resolved = allParts.join("/");
150
+ // Require .md suffix for Markdown links
151
+ if (resolved.endsWith(".md")) {
152
+ return { kind: "concept", id: resolved.slice(0, -3) };
153
+ }
154
+ return { kind: "empty" };
155
+ }
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) {
164
+ const diagnostics = [];
165
+ const unresolved = [];
166
+ const targets = new Set();
167
+ const allLinks = extractKnowledgeLinks(body);
168
+ // Process Markdown links
169
+ for (const link of allLinks.markdown) {
170
+ const resolved = resolveMarkdownTarget(link.target, sourceId);
171
+ if (resolved.kind === "escape") {
172
+ diagnostics.push(diag("warning", "link_path_escape", `${sourceId}.md`, `Link escapes bundle root: ${link.target}`));
173
+ }
174
+ else if (resolved.kind === "invalid") {
175
+ diagnostics.push(diag("warning", "link_unresolved", `${sourceId}.md`, `Malformed percent-encoded link: ${link.target}`));
176
+ }
177
+ else if (resolved.kind === "concept") {
178
+ const normalizedId = resolved.id.normalize("NFC");
179
+ if (knownIds.has(normalizedId)) {
180
+ targets.add(normalizedId);
181
+ }
182
+ else {
183
+ unresolved.push({ target: normalizedId, syntax: "markdown" });
184
+ diagnostics.push(diag("warning", "link_unresolved", `${sourceId}.md`, `Unresolved link: ${normalizedId}`));
185
+ }
186
+ }
187
+ // external and empty are silently ignored
188
+ }
189
+ // Process wikilinks
190
+ 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
+ }
201
+ }
202
+ }
203
+ // Sort and deduplicate
204
+ const sorted = [...targets].sort(compareCodePoint);
205
+ return { targets: sorted, unresolved, diagnostics };
206
+ }