@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.
- package/README.de.md +1 -0
- package/README.es.md +1 -0
- package/README.fr.md +1 -0
- package/README.hi.md +1 -0
- package/README.ja.md +1 -0
- package/README.ko.md +1 -0
- package/README.md +1 -0
- package/README.pt.md +1 -0
- package/README.ru.md +1 -0
- package/README.zh.md +1 -0
- package/dist/extensions/llm-wiki/lib/ingest-worker.js +43 -20
- package/dist/extensions/llm-wiki/lib/knowledge-links.js +127 -24
- package/dist/extensions/llm-wiki/lib/metadata.js +5 -5
- package/dist/extensions/llm-wiki/lib/retro.js +38 -4
- package/dist/extensions/llm-wiki/lib/runtime.js +2 -2
- package/dist/extensions/llm-wiki/lib/task-config.js +35 -0
- package/dist/extensions/llm-wiki/lib/tools.js +53 -12
- package/dist/mcp/index.js +2 -1
- package/dist/mcp/operations.js +21 -2
- package/docs/configuration.md +1 -0
- package/docs/obsidian.md +6 -6
- package/docs/superpowers/plans/2026-08-27-wikilink-resolver-normalization.md +735 -0
- package/docs/superpowers/plans/2026-08-29-wikilink-gate-ensure-page-retro.md +642 -0
- package/docs/superpowers/plans/2026-08-29-wikilink-write-validation.md +695 -0
- package/extensions/llm-wiki/lib/ingest-worker.ts +60 -27
- package/extensions/llm-wiki/lib/knowledge-document.ts +1 -0
- package/extensions/llm-wiki/lib/knowledge-links.ts +201 -32
- package/extensions/llm-wiki/lib/metadata.ts +9 -5
- package/extensions/llm-wiki/lib/retro.ts +48 -4
- package/extensions/llm-wiki/lib/runtime.ts +2 -2
- package/extensions/llm-wiki/lib/task-config.ts +59 -0
- package/extensions/llm-wiki/lib/tools.ts +68 -20
- package/mcp/index.ts +12 -1
- package/mcp/operations.ts +32 -2
- package/package.json +8 -3
package/README.de.md
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
[](https://www.npmjs.com/package/@zosmaai/pi-llm-wiki)
|
|
10
10
|
[](https://codecov.io/gh/zosmaai/pi-llm-wiki)
|
|
11
11
|
[](LICENSE)
|
|
12
|
+
[](https://zosmaai.github.io/pi-llm-wiki/)
|
|
12
13
|
[](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml)
|
|
13
14
|
[](https://github.com/zosmaai/pi-llm-wiki/stargazers)
|
|
14
15
|
|
package/README.es.md
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
[](https://www.npmjs.com/package/@zosmaai/pi-llm-wiki)
|
|
10
10
|
[](https://codecov.io/gh/zosmaai/pi-llm-wiki)
|
|
11
11
|
[](LICENSE)
|
|
12
|
+
[](https://zosmaai.github.io/pi-llm-wiki/)
|
|
12
13
|
[](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml)
|
|
13
14
|
[](https://github.com/zosmaai/pi-llm-wiki/stargazers)
|
|
14
15
|
|
package/README.fr.md
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
[](https://www.npmjs.com/package/@zosmaai/pi-llm-wiki)
|
|
10
10
|
[](https://codecov.io/gh/zosmaai/pi-llm-wiki)
|
|
11
11
|
[](LICENSE)
|
|
12
|
+
[](https://zosmaai.github.io/pi-llm-wiki/)
|
|
12
13
|
[](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml)
|
|
13
14
|
[](https://github.com/zosmaai/pi-llm-wiki/stargazers)
|
|
14
15
|
|
package/README.hi.md
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
[](https://www.npmjs.com/package/@zosmaai/pi-llm-wiki)
|
|
10
10
|
[](https://codecov.io/gh/zosmaai/pi-llm-wiki)
|
|
11
11
|
[](LICENSE)
|
|
12
|
+
[](https://zosmaai.github.io/pi-llm-wiki/)
|
|
12
13
|
[](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml)
|
|
13
14
|
[](https://github.com/zosmaai/pi-llm-wiki/stargazers)
|
|
14
15
|
|
package/README.ja.md
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
[](https://www.npmjs.com/package/@zosmaai/pi-llm-wiki)
|
|
10
10
|
[](https://codecov.io/gh/zosmaai/pi-llm-wiki)
|
|
11
11
|
[](LICENSE)
|
|
12
|
+
[](https://zosmaai.github.io/pi-llm-wiki/)
|
|
12
13
|
[](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml)
|
|
13
14
|
[](https://github.com/zosmaai/pi-llm-wiki/stargazers)
|
|
14
15
|
|
package/README.ko.md
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
[](https://www.npmjs.com/package/@zosmaai/pi-llm-wiki)
|
|
10
10
|
[](https://codecov.io/gh/zosmaai/pi-llm-wiki)
|
|
11
11
|
[](LICENSE)
|
|
12
|
+
[](https://zosmaai.github.io/pi-llm-wiki/)
|
|
12
13
|
[](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml)
|
|
13
14
|
[](https://github.com/zosmaai/pi-llm-wiki/stargazers)
|
|
14
15
|
|
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
[](https://www.npmjs.com/package/@zosmaai/pi-llm-wiki)
|
|
10
10
|
[](https://codecov.io/gh/zosmaai/pi-llm-wiki)
|
|
11
11
|
[](LICENSE)
|
|
12
|
+
[](https://zosmaai.github.io/pi-llm-wiki/)
|
|
12
13
|
[](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml)
|
|
13
14
|
[](https://github.com/zosmaai/pi-llm-wiki/stargazers)
|
|
14
15
|
|
package/README.pt.md
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
[](https://www.npmjs.com/package/@zosmaai/pi-llm-wiki)
|
|
10
10
|
[](https://codecov.io/gh/zosmaai/pi-llm-wiki)
|
|
11
11
|
[](LICENSE)
|
|
12
|
+
[](https://zosmaai.github.io/pi-llm-wiki/)
|
|
12
13
|
[](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml)
|
|
13
14
|
[](https://github.com/zosmaai/pi-llm-wiki/stargazers)
|
|
14
15
|
|
package/README.ru.md
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
[](https://www.npmjs.com/package/@zosmaai/pi-llm-wiki)
|
|
10
10
|
[](https://codecov.io/gh/zosmaai/pi-llm-wiki)
|
|
11
11
|
[](LICENSE)
|
|
12
|
+
[](https://zosmaai.github.io/pi-llm-wiki/)
|
|
12
13
|
[](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml)
|
|
13
14
|
[](https://github.com/zosmaai/pi-llm-wiki/stargazers)
|
|
14
15
|
|
package/README.zh.md
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
[](https://www.npmjs.com/package/@zosmaai/pi-llm-wiki)
|
|
10
10
|
[](https://codecov.io/gh/zosmaai/pi-llm-wiki)
|
|
11
11
|
[](LICENSE)
|
|
12
|
+
[](https://zosmaai.github.io/pi-llm-wiki/)
|
|
12
13
|
[](https://github.com/zosmaai/pi-llm-wiki/actions/workflows/codeql.yml)
|
|
13
14
|
[](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 {
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
},
|
|
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
|
|
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
|
|
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
|
|
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
|
|
182
|
-
if (
|
|
183
|
-
targets.add(
|
|
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:
|
|
187
|
-
diagnostics.push(diag("warning", "link_unresolved", `${sourceId}.md`, `Unresolved link: ${
|
|
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
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
28
|
-
const backlinks = buildBacklinks(documents,
|
|
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,
|
|
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,
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 = {};
|