@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.
- package/CHANGELOG.md +4 -0
- package/README.de.md +35 -4
- package/README.es.md +260 -170
- package/README.fr.md +35 -4
- package/README.hi.md +35 -4
- package/README.ja.md +35 -4
- package/README.ko.md +35 -4
- package/README.md +38 -3
- package/README.pt.md +35 -4
- package/README.ru.md +35 -4
- package/README.zh.md +260 -170
- package/assets/demo.gif +0 -0
- package/dist/extensions/llm-wiki/lib/bootstrap.js +71 -0
- package/dist/extensions/llm-wiki/lib/embeddings.js +401 -0
- package/dist/extensions/llm-wiki/lib/guardrails.js +232 -0
- package/dist/extensions/llm-wiki/lib/indexing.js +78 -0
- package/dist/extensions/llm-wiki/lib/ingest-worker.js +310 -0
- package/dist/extensions/llm-wiki/lib/inject.js +65 -0
- package/dist/extensions/llm-wiki/lib/knowledge-document.js +442 -0
- package/dist/extensions/llm-wiki/lib/knowledge-links.js +206 -0
- package/dist/extensions/llm-wiki/lib/legacy-repair.js +443 -0
- package/dist/extensions/llm-wiki/lib/metadata.js +499 -0
- package/dist/extensions/llm-wiki/lib/model-command.js +86 -0
- package/dist/extensions/llm-wiki/lib/observation.js +283 -0
- package/dist/extensions/llm-wiki/lib/recall.js +875 -0
- package/dist/extensions/llm-wiki/lib/retro.js +158 -0
- package/dist/extensions/llm-wiki/lib/runtime.js +191 -0
- package/dist/extensions/llm-wiki/lib/source-extractors.js +426 -0
- package/dist/extensions/llm-wiki/lib/source-packet.js +229 -0
- package/dist/extensions/llm-wiki/lib/subagent.js +41 -0
- package/dist/extensions/llm-wiki/lib/task-config.js +172 -0
- package/dist/extensions/llm-wiki/lib/tools.js +1192 -0
- package/dist/extensions/llm-wiki/lib/trajectories-command.js +51 -0
- package/dist/extensions/llm-wiki/lib/trajectory.js +467 -0
- package/dist/extensions/llm-wiki/lib/utils.js +347 -0
- package/dist/extensions/llm-wiki/lib/vault-format.js +247 -0
- package/dist/extensions/llm-wiki/lib/visible-status.js +31 -0
- package/dist/extensions/llm-wiki/lib/wiki-service.js +128 -0
- package/dist/mcp/exec.js +121 -0
- package/dist/mcp/index.js +229 -0
- package/dist/mcp/operations.js +130 -0
- package/dist/package.json +1 -0
- package/docs/superpowers/plans/2026-08-02-okf-foundation.md +1579 -0
- package/docs/superpowers/plans/2026-08-03-okf-foundation-remediation.md +3005 -0
- package/docs/superpowers/plans/2026-08-06-okf-foundation-release-remediation.md +1174 -0
- package/docs/superpowers/specs/2026-08-02-okf-foundation-design.md +578 -0
- package/docs/superpowers/specs/2026-08-02-okf-v0.2-interoperability-design.md +538 -0
- package/extensions/llm-wiki/index.ts +22 -36
- package/extensions/llm-wiki/lib/bootstrap.ts +84 -0
- package/extensions/llm-wiki/lib/embeddings.ts +9 -3
- package/extensions/llm-wiki/lib/guardrails.ts +174 -29
- package/extensions/llm-wiki/lib/indexing.ts +2 -1
- package/extensions/llm-wiki/lib/ingest-worker.ts +170 -29
- package/extensions/llm-wiki/lib/knowledge-document.ts +661 -0
- package/extensions/llm-wiki/lib/knowledge-links.ts +282 -0
- package/extensions/llm-wiki/lib/legacy-repair.ts +572 -0
- package/extensions/llm-wiki/lib/metadata.ts +531 -116
- package/extensions/llm-wiki/lib/observation.ts +37 -43
- package/extensions/llm-wiki/lib/recall.ts +61 -33
- package/extensions/llm-wiki/lib/retro.ts +65 -41
- package/extensions/llm-wiki/lib/source-extractors.ts +12 -17
- package/extensions/llm-wiki/lib/source-packet.ts +44 -31
- package/extensions/llm-wiki/lib/tools.ts +406 -348
- package/extensions/llm-wiki/lib/trajectory.ts +15 -1
- package/extensions/llm-wiki/lib/utils.ts +121 -130
- package/extensions/llm-wiki/lib/vault-format.ts +363 -0
- package/extensions/llm-wiki/lib/wiki-service.ts +183 -0
- package/mcp/exec.ts +122 -0
- package/mcp/index.ts +60 -250
- package/mcp/operations.ts +176 -0
- package/package.json +8 -2
- package/scripts/migrate-llm-wiki.js +801 -0
- 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
|
+
}
|