@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
|
@@ -13,9 +13,15 @@ import {
|
|
|
13
13
|
serializeKnowledgeDocument,
|
|
14
14
|
writeKnowledgeDocumentFile,
|
|
15
15
|
} from "./knowledge-document.js";
|
|
16
|
+
import {
|
|
17
|
+
type WikilinkValidationMode,
|
|
18
|
+
auditWikilinks,
|
|
19
|
+
buildWikilinkIndex,
|
|
20
|
+
} from "./knowledge-links.js";
|
|
16
21
|
import { appendEvent, rebuildMetadataLight } from "./metadata.js";
|
|
17
22
|
import { runSubAgent } from "./subagent.js";
|
|
18
|
-
import {
|
|
23
|
+
import { resolveWikilinkValidation } from "./task-config.js";
|
|
24
|
+
import { type VaultPaths, fmtDate, readJson, slugify } from "./utils.js";
|
|
19
25
|
import { VaultWriteError, assertWritableVault } from "./vault-format.js";
|
|
20
26
|
|
|
21
27
|
/**
|
|
@@ -84,6 +90,8 @@ export interface CommitResult {
|
|
|
84
90
|
entitiesLinked: string[];
|
|
85
91
|
conceptsLinked: string[];
|
|
86
92
|
contradictions: number;
|
|
93
|
+
/** Wikilink gate diagnostics from `commitSynthesis` (warn/normalize modes). */
|
|
94
|
+
wikilinkDiagnostics?: KnowledgeDiagnostic[];
|
|
87
95
|
}
|
|
88
96
|
|
|
89
97
|
export type CommitSynthesisOutcome =
|
|
@@ -186,43 +194,23 @@ function getHeadings(lang?: string): Record<string, string> {
|
|
|
186
194
|
};
|
|
187
195
|
}
|
|
188
196
|
|
|
189
|
-
function buildEntityPageBody(
|
|
190
|
-
title: string,
|
|
191
|
-
description: string,
|
|
192
|
-
sourceId: string,
|
|
193
|
-
lang?: string,
|
|
194
|
-
): string {
|
|
197
|
+
function buildEntityPageBody(title: string, description: string, sourceId: string): string {
|
|
195
198
|
const desc = description.trim() || "One-line description.";
|
|
196
|
-
const h = getHeadings(lang);
|
|
197
199
|
return `# ${title}
|
|
198
200
|
|
|
199
201
|
${desc}
|
|
200
202
|
|
|
201
|
-
## ${h.overview}
|
|
202
|
-
|
|
203
|
-
[Key facts]
|
|
204
|
-
|
|
205
203
|
## Links
|
|
206
204
|
|
|
207
205
|
- [${sourceId}](/sources/${sourceId}.md)`;
|
|
208
206
|
}
|
|
209
207
|
|
|
210
|
-
function buildConceptPageBody(
|
|
211
|
-
title: string,
|
|
212
|
-
definition: string,
|
|
213
|
-
sourceId: string,
|
|
214
|
-
lang?: string,
|
|
215
|
-
): string {
|
|
208
|
+
function buildConceptPageBody(title: string, definition: string, sourceId: string): string {
|
|
216
209
|
const def = definition.trim() || "One-line definition.";
|
|
217
|
-
const h = getHeadings(lang);
|
|
218
210
|
return `# ${title}
|
|
219
211
|
|
|
220
212
|
${def}
|
|
221
213
|
|
|
222
|
-
## ${h.definition}
|
|
223
|
-
|
|
224
|
-
[Clear explanation]
|
|
225
|
-
|
|
226
214
|
## Links
|
|
227
215
|
|
|
228
216
|
- [${sourceId}](/sources/${sourceId}.md)`;
|
|
@@ -321,6 +309,28 @@ export function buildIngestedSourcePage(
|
|
|
321
309
|
return serializeKnowledgeDocument(doc);
|
|
322
310
|
}
|
|
323
311
|
|
|
312
|
+
/**
|
|
313
|
+
* Build the wikilink index used by the pre-write gate: every existing page id
|
|
314
|
+
* (from the registry) plus the page ids this commit is about to create, so a
|
|
315
|
+
* link to a sibling created in the same ingest resolves instead of
|
|
316
|
+
* false-positiving as missing.
|
|
317
|
+
*/
|
|
318
|
+
function buildIngestAuditIndex(
|
|
319
|
+
paths: VaultPaths,
|
|
320
|
+
sourceId: string,
|
|
321
|
+
data: SynthesisData,
|
|
322
|
+
): ReturnType<typeof buildWikilinkIndex> {
|
|
323
|
+
const registry = readJson<{ pages: Record<string, unknown> }>(join(paths.meta, "registry.json"), {
|
|
324
|
+
pages: {},
|
|
325
|
+
});
|
|
326
|
+
const newIds = [
|
|
327
|
+
`sources/${sourceId}`,
|
|
328
|
+
...data.entities.filter((e) => slugify(e.title)).map((e) => `entities/${slugify(e.title)}`),
|
|
329
|
+
...data.concepts.filter((c) => slugify(c.title)).map((c) => `concepts/${slugify(c.title)}`),
|
|
330
|
+
];
|
|
331
|
+
return buildWikilinkIndex([...Object.keys(registry.pages), ...newIds]);
|
|
332
|
+
}
|
|
333
|
+
|
|
324
334
|
/**
|
|
325
335
|
* Persist a synthesis deterministically: rewrite the source page (status →
|
|
326
336
|
* ingested), create missing entity/concept pages (existing pages are linked,
|
|
@@ -333,6 +343,7 @@ export function commitSynthesis(
|
|
|
333
343
|
data: SynthesisData,
|
|
334
344
|
date: string = fmtDate(),
|
|
335
345
|
lang?: string,
|
|
346
|
+
wikilinkValidation?: WikilinkValidationMode,
|
|
336
347
|
): CommitSynthesisOutcome {
|
|
337
348
|
const result: CommitResult = {
|
|
338
349
|
sourceId,
|
|
@@ -353,6 +364,24 @@ export function commitSynthesis(
|
|
|
353
364
|
throw error;
|
|
354
365
|
}
|
|
355
366
|
|
|
367
|
+
// Pre-write wikilink gate (issue #172, Layer 2). Applies only to the
|
|
368
|
+
// model-authored source body; entity/concept pages are generated templates.
|
|
369
|
+
const mode = resolveWikilinkValidation({ wikilinkValidation });
|
|
370
|
+
let sourceBody = buildIngestedSourcePageBody(manifest, data, date, lang);
|
|
371
|
+
if (mode !== "off") {
|
|
372
|
+
const audit = auditWikilinks(
|
|
373
|
+
sourceBody,
|
|
374
|
+
buildIngestAuditIndex(paths, sourceId, data),
|
|
375
|
+
sourceId,
|
|
376
|
+
mode,
|
|
377
|
+
);
|
|
378
|
+
if (mode === "strict" && audit.diagnostics.length > 0) {
|
|
379
|
+
return { ok: false, sourceId, diagnostics: audit.diagnostics };
|
|
380
|
+
}
|
|
381
|
+
if (mode === "normalize") sourceBody = audit.body;
|
|
382
|
+
if (audit.diagnostics.length > 0) result.wikilinkDiagnostics = audit.diagnostics;
|
|
383
|
+
}
|
|
384
|
+
|
|
356
385
|
// Patch existing documents so unknown fields, legacy sources, and titles survive.
|
|
357
386
|
let sourceDocument: KnowledgeDocument;
|
|
358
387
|
if (existsSync(result.sourcePage)) {
|
|
@@ -360,7 +389,7 @@ export function commitSynthesis(
|
|
|
360
389
|
if (!parsed.ok) return { ok: false, sourceId, diagnostics: parsed.diagnostics };
|
|
361
390
|
sourceDocument = patchKnowledgeDocument(parsed.document, {
|
|
362
391
|
fields: { status: "ingested", updated: date },
|
|
363
|
-
body:
|
|
392
|
+
body: sourceBody,
|
|
364
393
|
});
|
|
365
394
|
} else {
|
|
366
395
|
sourceDocument = createKnowledgeDocument(
|
|
@@ -375,7 +404,7 @@ export function commitSynthesis(
|
|
|
375
404
|
status: "ingested",
|
|
376
405
|
updated: date,
|
|
377
406
|
},
|
|
378
|
-
|
|
407
|
+
sourceBody,
|
|
379
408
|
);
|
|
380
409
|
}
|
|
381
410
|
mkdirSync(join(paths.wiki, "sources"), { recursive: true });
|
|
@@ -399,7 +428,7 @@ export function commitSynthesis(
|
|
|
399
428
|
created: date,
|
|
400
429
|
updated: date,
|
|
401
430
|
},
|
|
402
|
-
buildEntityPageBody(e.title, e.description, sourceId
|
|
431
|
+
buildEntityPageBody(e.title, e.description, sourceId),
|
|
403
432
|
[{ id: sourceId, resource: `/sources/${sourceId}.md` }],
|
|
404
433
|
);
|
|
405
434
|
writeKnowledgeDocumentFile(pagePath, entityDoc);
|
|
@@ -425,7 +454,7 @@ export function commitSynthesis(
|
|
|
425
454
|
created: date,
|
|
426
455
|
updated: date,
|
|
427
456
|
},
|
|
428
|
-
buildConceptPageBody(c.title, c.definition, sourceId
|
|
457
|
+
buildConceptPageBody(c.title, c.definition, sourceId),
|
|
429
458
|
[{ id: sourceId, resource: `/sources/${sourceId}.md` }],
|
|
430
459
|
);
|
|
431
460
|
writeKnowledgeDocumentFile(pagePath, conceptDoc);
|
|
@@ -477,6 +506,8 @@ export interface RunIngestSynthesisArgs {
|
|
|
477
506
|
synthesisLanguage?: string;
|
|
478
507
|
/** Max output tokens for synthesizer sub-agent (issue #160). Default 16384. */
|
|
479
508
|
synthesisMaxTokens?: number;
|
|
509
|
+
/** Wikilink write-gate mode for the ingested source body (issue #172). */
|
|
510
|
+
wikilinkValidation?: WikilinkValidationMode;
|
|
480
511
|
}
|
|
481
512
|
|
|
482
513
|
/**
|
|
@@ -499,6 +530,7 @@ export async function runIngestSynthesis(
|
|
|
499
530
|
signal,
|
|
500
531
|
synthesisLanguage,
|
|
501
532
|
synthesisMaxTokens,
|
|
533
|
+
wikilinkValidation,
|
|
502
534
|
} = args;
|
|
503
535
|
const content = extracted.slice(0, maxChars ?? 24_000);
|
|
504
536
|
if (!content.trim()) return undefined;
|
|
@@ -524,6 +556,7 @@ export async function runIngestSynthesis(
|
|
|
524
556
|
params,
|
|
525
557
|
undefined,
|
|
526
558
|
synthesisLanguage,
|
|
559
|
+
wikilinkValidation,
|
|
527
560
|
);
|
|
528
561
|
if (!outcome.ok) {
|
|
529
562
|
return {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Definition, Link, LinkReference, Nodes, Root } from "mdast";
|
|
2
2
|
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
3
3
|
import type { KnowledgeDiagnostic } from "./knowledge-document.js";
|
|
4
|
+
import { slugify } from "./utils.js";
|
|
4
5
|
import { compareCodePoint } from "./vault-format.js";
|
|
5
6
|
|
|
6
7
|
export interface ExtractedLink {
|
|
@@ -120,6 +121,74 @@ export function extractLegacyWikilinks(body: string): ExtractedLink[] {
|
|
|
120
121
|
return links;
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
// ── Wikilink normalization ──────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
export interface WikilinkIndex {
|
|
127
|
+
/** NFC-normalized id → canonical id. */
|
|
128
|
+
byExact: Map<string, string>;
|
|
129
|
+
/** Slugified full path → matching canonical ids. */
|
|
130
|
+
byNormPath: Map<string, string[]>;
|
|
131
|
+
/** Slugified basename (no folder) → matching canonical ids. */
|
|
132
|
+
byNormSlug: Map<string, string[]>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function buildWikilinkIndex(ids: Iterable<string>): WikilinkIndex {
|
|
136
|
+
const byExact = new Map<string, string>();
|
|
137
|
+
const byNormPath = new Map<string, string[]>();
|
|
138
|
+
const byNormSlug = new Map<string, string[]>();
|
|
139
|
+
|
|
140
|
+
function push<K>(map: Map<K, string[]>, key: K, value: string): void {
|
|
141
|
+
const existing = map.get(key);
|
|
142
|
+
if (existing) existing.push(value);
|
|
143
|
+
else map.set(key, [value]);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const id of ids) {
|
|
147
|
+
byExact.set(id.normalize("NFC"), id);
|
|
148
|
+
const segments = id.split("/");
|
|
149
|
+
const normFull = segments.map((s) => slugify(s)).join("/");
|
|
150
|
+
const normBase = slugify(segments[segments.length - 1]);
|
|
151
|
+
push(byNormPath, normFull, id);
|
|
152
|
+
push(byNormSlug, normBase, id);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { byExact, byNormPath, byNormSlug };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export type WikilinkResolution =
|
|
159
|
+
| { kind: "resolved"; id: string }
|
|
160
|
+
| { kind: "ambiguous"; target: string; candidates: string[] }
|
|
161
|
+
| { kind: "missing"; target: string };
|
|
162
|
+
|
|
163
|
+
export function resolveWikilink(target: string, index: WikilinkIndex): WikilinkResolution {
|
|
164
|
+
const cleaned = target.trim().replace(/\\$/, "");
|
|
165
|
+
if (!cleaned) return { kind: "missing", target: "" };
|
|
166
|
+
|
|
167
|
+
// Fast path: exact NFC match
|
|
168
|
+
const exact = index.byExact.get(cleaned.normalize("NFC"));
|
|
169
|
+
if (exact) return { kind: "resolved", id: exact };
|
|
170
|
+
|
|
171
|
+
// Normalized full path (fixes case/space/slug drift in folder-qualified links)
|
|
172
|
+
const normFull = cleaned
|
|
173
|
+
.split("/")
|
|
174
|
+
.map((s) => slugify(s))
|
|
175
|
+
.join("/");
|
|
176
|
+
const pathHits = index.byNormPath.get(normFull);
|
|
177
|
+
if (pathHits && pathHits.length === 1) return { kind: "resolved", id: pathHits[0] };
|
|
178
|
+
if (pathHits && pathHits.length > 1)
|
|
179
|
+
return { kind: "ambiguous", target: cleaned, candidates: pathHits };
|
|
180
|
+
|
|
181
|
+
// Bare title: match by slugified basename (handles [[zosma harness]] → entities/zosma-harness)
|
|
182
|
+
if (!cleaned.includes("/")) {
|
|
183
|
+
const baseHits = index.byNormSlug.get(slugify(cleaned));
|
|
184
|
+
if (baseHits && baseHits.length === 1) return { kind: "resolved", id: baseHits[0] };
|
|
185
|
+
if (baseHits && baseHits.length > 1)
|
|
186
|
+
return { kind: "ambiguous", target: cleaned, candidates: baseHits };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { kind: "missing", target: cleaned };
|
|
190
|
+
}
|
|
191
|
+
|
|
123
192
|
function resolveMarkdownTarget(
|
|
124
193
|
target: string,
|
|
125
194
|
sourceId: string,
|
|
@@ -204,19 +273,10 @@ function resolveMarkdownTarget(
|
|
|
204
273
|
return { kind: "empty" };
|
|
205
274
|
}
|
|
206
275
|
|
|
207
|
-
function resolveWikilinkTarget(
|
|
208
|
-
target: string,
|
|
209
|
-
): { kind: "concept"; id: string } | { kind: "empty" } {
|
|
210
|
-
// Wikilinks are already bundle-relative concept IDs
|
|
211
|
-
const cleaned = target.trim();
|
|
212
|
-
if (!cleaned) return { kind: "empty" };
|
|
213
|
-
return { kind: "concept", id: cleaned };
|
|
214
|
-
}
|
|
215
|
-
|
|
216
276
|
export function buildResolvedBacklinks(
|
|
217
277
|
sourceId: string,
|
|
218
278
|
body: string,
|
|
219
|
-
|
|
279
|
+
index: WikilinkIndex,
|
|
220
280
|
): ResolvedBacklinks {
|
|
221
281
|
const diagnostics: KnowledgeDiagnostic[] = [];
|
|
222
282
|
const unresolved: UnresolvedKnowledgeLink[] = [];
|
|
@@ -245,37 +305,38 @@ export function buildResolvedBacklinks(
|
|
|
245
305
|
),
|
|
246
306
|
);
|
|
247
307
|
} else if (resolved.kind === "concept") {
|
|
248
|
-
const
|
|
249
|
-
if (
|
|
250
|
-
targets.add(
|
|
308
|
+
const canonical = index.byExact.get(resolved.id.normalize("NFC"));
|
|
309
|
+
if (canonical) {
|
|
310
|
+
targets.add(canonical);
|
|
251
311
|
} else {
|
|
252
|
-
unresolved.push({ target:
|
|
312
|
+
unresolved.push({ target: resolved.id, syntax: "markdown" });
|
|
253
313
|
diagnostics.push(
|
|
254
|
-
diag("warning", "link_unresolved", `${sourceId}.md`, `Unresolved link: ${
|
|
314
|
+
diag("warning", "link_unresolved", `${sourceId}.md`, `Unresolved link: ${resolved.id}`),
|
|
255
315
|
);
|
|
256
316
|
}
|
|
257
317
|
}
|
|
258
318
|
// external and empty are silently ignored
|
|
259
319
|
}
|
|
260
320
|
|
|
261
|
-
// Process wikilinks
|
|
321
|
+
// Process wikilinks (lenient: exact → normalized path → bare title)
|
|
262
322
|
for (const link of allLinks.wikilinks) {
|
|
263
|
-
const
|
|
264
|
-
if (
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
323
|
+
const res = resolveWikilink(link.target, index);
|
|
324
|
+
if (res.kind === "resolved") {
|
|
325
|
+
targets.add(res.id);
|
|
326
|
+
} else if (res.kind === "ambiguous") {
|
|
327
|
+
diagnostics.push(
|
|
328
|
+
diag(
|
|
329
|
+
"warning",
|
|
330
|
+
"link_ambiguous",
|
|
331
|
+
`${sourceId}.md`,
|
|
332
|
+
`Ambiguous wikilink: ${res.target} (candidates: ${res.candidates.join(", ")})`,
|
|
333
|
+
),
|
|
334
|
+
);
|
|
335
|
+
} else {
|
|
336
|
+
unresolved.push({ target: res.target, syntax: "wikilink" });
|
|
337
|
+
diagnostics.push(
|
|
338
|
+
diag("warning", "link_unresolved", `${sourceId}.md`, `Unresolved wikilink: ${res.target}`),
|
|
339
|
+
);
|
|
279
340
|
}
|
|
280
341
|
}
|
|
281
342
|
|
|
@@ -284,3 +345,111 @@ export function buildResolvedBacklinks(
|
|
|
284
345
|
|
|
285
346
|
return { targets: sorted, unresolved, diagnostics };
|
|
286
347
|
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Pre-write wikilink gate mode (issue #172). Two independent behaviors, not a
|
|
351
|
+
* severity ladder:
|
|
352
|
+
* 1. resolvable-but-drifted links (target exists): leave vs. rewrite-to-canonical
|
|
353
|
+
* 2. unresolvable links (target absent — a forward reference / gap): ignore vs. report vs. reject
|
|
354
|
+
*
|
|
355
|
+
* off = leave + ignore (opt-out; zero behavior change)
|
|
356
|
+
* warn = leave + report (default; non-mutating, non-blocking, surfaces issues)
|
|
357
|
+
* normalize = rewrite + report (fixes resolvable links; still reports gaps)
|
|
358
|
+
* strict = leave + reject (blocks the write with the bad links named; agent retry signal)
|
|
359
|
+
*
|
|
360
|
+
* A link that RESOLVES is never flagged — only normalized. Only missing/ambiguous targets
|
|
361
|
+
* produce diagnostics.
|
|
362
|
+
*/
|
|
363
|
+
export type WikilinkValidationMode = "off" | "warn" | "strict" | "normalize";
|
|
364
|
+
|
|
365
|
+
// ── Pre-write validation & normalization (issue #172, Layer 2) ─────────
|
|
366
|
+
|
|
367
|
+
export interface WikilinkAuditResult {
|
|
368
|
+
/** Unresolved / ambiguous link diagnostics (empty for "off" / clean bodies). */
|
|
369
|
+
diagnostics: KnowledgeDiagnostic[];
|
|
370
|
+
/** The body after normalization (identical to input unless normalize rewrote links). */
|
|
371
|
+
body: string;
|
|
372
|
+
/** True only when normalize changed the body. */
|
|
373
|
+
changed: boolean;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const WIKILINK_REPLACE_RE = /\[\[([^\]|]+)(\|[^\]]*)?\]\]/g;
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Audit a markdown body's wikilinks against the page index.
|
|
380
|
+
*
|
|
381
|
+
* - Collects `link_unresolved` / `link_ambiguous` diagnostics for every link
|
|
382
|
+
* that does not resolve to exactly one page (skipped for "off").
|
|
383
|
+
* - In "normalize" mode, additionally rewrites each link that DOES resolve to
|
|
384
|
+
* its canonical page id. Only the target token is replaced (the parser's own
|
|
385
|
+
* regex is reused), so `|alias`, escaping, and surrounding text are preserved.
|
|
386
|
+
*
|
|
387
|
+
* Pure: no I/O. The caller supplies the index (typically `buildWikilinkIndex`
|
|
388
|
+
* over existing page ids plus the ids created by the same commit).
|
|
389
|
+
*/
|
|
390
|
+
export function auditWikilinks(
|
|
391
|
+
body: string,
|
|
392
|
+
index: WikilinkIndex,
|
|
393
|
+
sourceId: string,
|
|
394
|
+
mode: WikilinkValidationMode,
|
|
395
|
+
): WikilinkAuditResult {
|
|
396
|
+
if (mode === "off") return { diagnostics: [], body, changed: false };
|
|
397
|
+
|
|
398
|
+
const diagnostics: KnowledgeDiagnostic[] = [];
|
|
399
|
+
for (const { target } of extractKnowledgeLinks(body).wikilinks) {
|
|
400
|
+
const resolved = resolveWikilink(target, index);
|
|
401
|
+
if (resolved.kind === "ambiguous") {
|
|
402
|
+
diagnostics.push({
|
|
403
|
+
severity: "warning",
|
|
404
|
+
code: "link_ambiguous",
|
|
405
|
+
path: sourceId,
|
|
406
|
+
message: `Wikilink target "${target}" matches multiple pages (${resolved.candidates.join(", ")}).`,
|
|
407
|
+
});
|
|
408
|
+
} else if (resolved.kind === "missing") {
|
|
409
|
+
diagnostics.push({
|
|
410
|
+
severity: "warning",
|
|
411
|
+
code: "link_unresolved",
|
|
412
|
+
path: sourceId,
|
|
413
|
+
message: `Wikilink target "${target}" does not match any page.`,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
let out = body;
|
|
419
|
+
if (mode === "normalize") {
|
|
420
|
+
out = body.replace(WIKILINK_REPLACE_RE, (full, raw: string, alias: string | undefined) => {
|
|
421
|
+
const resolved = resolveWikilink(normalizeWikilinkTarget(raw), index);
|
|
422
|
+
return resolved.kind === "resolved" ? `[[${resolved.id}${alias ?? ""}]]` : full;
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return { diagnostics, body: out, changed: out !== body };
|
|
427
|
+
}
|
|
428
|
+
export interface WikilinkGateResult {
|
|
429
|
+
/** false only when mode === "strict" AND there are unresolvable/ambiguous links. */
|
|
430
|
+
ok: boolean;
|
|
431
|
+
/** The body to write (normalized when mode === "normalize", else the input). */
|
|
432
|
+
body: string;
|
|
433
|
+
/** Unresolved / ambiguous link diagnostics (empty for "off" / clean bodies). */
|
|
434
|
+
diagnostics: KnowledgeDiagnostic[];
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Apply the pre-write wikilink gate to a body. Wraps {@link auditWikilinks}:
|
|
439
|
+
* blocks (ok:false) only in strict mode with issues, rewrites in normalize mode,
|
|
440
|
+
* and always returns the diagnostics so callers can surface them (warn/normalize).
|
|
441
|
+
*/
|
|
442
|
+
export function applyWikilinkGate(
|
|
443
|
+
body: string,
|
|
444
|
+
index: WikilinkIndex,
|
|
445
|
+
sourceId: string,
|
|
446
|
+
mode: WikilinkValidationMode,
|
|
447
|
+
): WikilinkGateResult {
|
|
448
|
+
if (mode === "off") return { ok: true, body, diagnostics: [] };
|
|
449
|
+
const audit = auditWikilinks(body, index, sourceId, mode);
|
|
450
|
+
return {
|
|
451
|
+
ok: !(mode === "strict" && audit.diagnostics.length > 0),
|
|
452
|
+
body: mode === "normalize" ? audit.body : body,
|
|
453
|
+
diagnostics: audit.diagnostics,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
@@ -11,7 +11,11 @@ import {
|
|
|
11
11
|
} from "node:fs";
|
|
12
12
|
import { dirname, join } from "node:path";
|
|
13
13
|
import type { KnowledgeDiagnostic, KnowledgeDocument } from "./knowledge-document.js";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
type WikilinkIndex,
|
|
16
|
+
buildResolvedBacklinks,
|
|
17
|
+
buildWikilinkIndex,
|
|
18
|
+
} from "./knowledge-links.js";
|
|
15
19
|
import { type VaultPaths, isPathWithin, readJson } from "./utils.js";
|
|
16
20
|
import {
|
|
17
21
|
assertWritableVault,
|
|
@@ -86,8 +90,8 @@ export function rebuildMetadata(paths: VaultPaths): ProjectionResult {
|
|
|
86
90
|
const registry = buildRegistry(paths, documents);
|
|
87
91
|
|
|
88
92
|
// Step 4: Build backlinks from discovered documents
|
|
89
|
-
const
|
|
90
|
-
const backlinks = buildBacklinks(documents,
|
|
93
|
+
const wikilinkIndex = buildWikilinkIndex(documents.map((d) => d.id));
|
|
94
|
+
const backlinks = buildBacklinks(documents, wikilinkIndex, allDiagnostics);
|
|
91
95
|
|
|
92
96
|
const eventSource = readEventSource(join(paths.meta, "events.jsonl"));
|
|
93
97
|
const eventLogResult = eventSource.available ? buildOkfLog(eventSource.content) : undefined;
|
|
@@ -245,7 +249,7 @@ function getSemanticTitle(doc: KnowledgeDocument): string {
|
|
|
245
249
|
/** Build backlinks from discovered documents using shared link resolution. */
|
|
246
250
|
function buildBacklinks(
|
|
247
251
|
documents: KnowledgeDocument[],
|
|
248
|
-
|
|
252
|
+
index: WikilinkIndex,
|
|
249
253
|
diagnostics: KnowledgeDiagnostic[],
|
|
250
254
|
): Backlinks {
|
|
251
255
|
const inbound: Backlinks = {};
|
|
@@ -257,7 +261,7 @@ function buildBacklinks(
|
|
|
257
261
|
|
|
258
262
|
// Resolve links for each document
|
|
259
263
|
for (const doc of documents) {
|
|
260
|
-
const result = buildResolvedBacklinks(doc.id, doc.body,
|
|
264
|
+
const result = buildResolvedBacklinks(doc.id, doc.body, index);
|
|
261
265
|
diagnostics.push(...result.diagnostics);
|
|
262
266
|
for (const target of result.targets) {
|
|
263
267
|
if (inbound[target] && !inbound[target].includes(doc.id)) {
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { dirname, resolve } from "node:path";
|
|
1
|
+
import { dirname, join, resolve } from "node:path";
|
|
2
2
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import { scheduleReindex } from "./indexing.js";
|
|
5
5
|
import { createKnowledgeDocument, writeKnowledgeDocumentFile } from "./knowledge-document.js";
|
|
6
|
+
import { applyWikilinkGate, buildWikilinkIndex } from "./knowledge-links.js";
|
|
6
7
|
import { appendEvent, rebuildMetadataLight } from "./metadata.js";
|
|
7
8
|
import type { Runtime } from "./runtime.js";
|
|
8
|
-
import {
|
|
9
|
+
import { loadTaskConfig, resolveWikilinkValidation } from "./task-config.js";
|
|
10
|
+
import { type VaultPaths, fmtDate, readJson, resolveVaultPaths } from "./utils.js";
|
|
9
11
|
import { assertWritableVault, inspectWritableVault } from "./vault-format.js";
|
|
10
12
|
|
|
11
13
|
// ─── Public API ────────────────────────────────────────
|
|
@@ -150,9 +152,44 @@ export function registerWikiRetro(pi: ExtensionAPI, runtime?: Runtime): void {
|
|
|
150
152
|
};
|
|
151
153
|
}
|
|
152
154
|
|
|
155
|
+
// Pre-write wikilink gate (#172): validate/normalize caller-supplied body.
|
|
156
|
+
const mode = resolveWikilinkValidation(loadTaskConfig(ctx.cwd ?? process.cwd()));
|
|
157
|
+
let body = params.body;
|
|
158
|
+
let wikilinkIssues: string[] = [];
|
|
159
|
+
if (mode !== "off") {
|
|
160
|
+
const registry = readJson<{ pages: Record<string, unknown> }>(
|
|
161
|
+
join(paths.meta, "registry.json"),
|
|
162
|
+
{ pages: {} },
|
|
163
|
+
);
|
|
164
|
+
const gate = applyWikilinkGate(
|
|
165
|
+
body,
|
|
166
|
+
buildWikilinkIndex(Object.keys(registry.pages)),
|
|
167
|
+
`sources/${params.slug}`,
|
|
168
|
+
mode,
|
|
169
|
+
);
|
|
170
|
+
wikilinkIssues = gate.diagnostics.map((d) => d.message);
|
|
171
|
+
if (!gate.ok) {
|
|
172
|
+
return {
|
|
173
|
+
content: [
|
|
174
|
+
{
|
|
175
|
+
type: "text",
|
|
176
|
+
text: `Rejected write — unresolved/ambiguous wikilinks:\n${wikilinkIssues
|
|
177
|
+
.map((m) => `- ${m}`)
|
|
178
|
+
.join("\n")}`,
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
details: { error: "link_validation", issues: wikilinkIssues } as Record<
|
|
182
|
+
string,
|
|
183
|
+
unknown
|
|
184
|
+
>,
|
|
185
|
+
isError: true,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (mode === "normalize") body = gate.body;
|
|
189
|
+
}
|
|
153
190
|
let result: RetroResult;
|
|
154
191
|
try {
|
|
155
|
-
result = saveInsight(paths, params.slug, params.title,
|
|
192
|
+
result = saveInsight(paths, params.slug, params.title, body, params.category, {
|
|
156
193
|
rebuild: !runtime,
|
|
157
194
|
});
|
|
158
195
|
} catch (error: unknown) {
|
|
@@ -169,6 +206,9 @@ export function registerWikiRetro(pi: ExtensionAPI, runtime?: Runtime): void {
|
|
|
169
206
|
scheduleReindex(runtime, { hasUI: ctx.hasUI, ui: ctx.ui }, paths);
|
|
170
207
|
}
|
|
171
208
|
|
|
209
|
+
const gateNote = wikilinkIssues.length
|
|
210
|
+
? `\n\n⚠️ ${wikilinkIssues.length} wikilink issue(s):\n${wikilinkIssues.map((m) => `- ${m}`).join("\n")}`
|
|
211
|
+
: "";
|
|
172
212
|
return {
|
|
173
213
|
content: [
|
|
174
214
|
{
|
|
@@ -179,13 +219,17 @@ export function registerWikiRetro(pi: ExtensionAPI, runtime?: Runtime): void {
|
|
|
179
219
|
`- Page: \`${result.sourcePagePath}\``,
|
|
180
220
|
"",
|
|
181
221
|
"This insight will be auto-surfaced by wiki_recall in future sessions.",
|
|
182
|
-
|
|
222
|
+
gateNote,
|
|
223
|
+
]
|
|
224
|
+
.filter(Boolean)
|
|
225
|
+
.join("\n"),
|
|
183
226
|
},
|
|
184
227
|
],
|
|
185
228
|
details: {
|
|
186
229
|
slug: params.slug,
|
|
187
230
|
title: params.title,
|
|
188
231
|
category: params.category || null,
|
|
232
|
+
wikilinkIssues,
|
|
189
233
|
} as Record<string, unknown>,
|
|
190
234
|
};
|
|
191
235
|
},
|
|
@@ -133,11 +133,11 @@ export class Runtime {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
136
|
-
if (!auth.ok
|
|
136
|
+
if (!auth.ok) {
|
|
137
137
|
const provider = (model as { provider?: string }).provider ?? "unknown";
|
|
138
138
|
return { ok: false, reason: `no API key for provider "${provider}"` };
|
|
139
139
|
}
|
|
140
|
-
return { ok: true, model, apiKey: auth.apiKey, headers: auth.headers };
|
|
140
|
+
return { ok: true, model, apiKey: auth.apiKey ?? "", headers: auth.headers };
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
/**
|