@zosmaai/pi-llm-wiki 0.11.4 → 0.11.6
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 +8 -0
- package/README.es.md +8 -0
- package/README.fr.md +8 -0
- package/README.hi.md +8 -0
- package/README.ja.md +8 -0
- package/README.ko.md +8 -0
- package/README.md +8 -0
- package/README.pt.md +8 -0
- package/README.ru.md +8 -0
- package/README.zh.md +8 -0
- package/assets/wiki-dashboard.png +0 -0
- package/commands/wiki-ingest.md +1 -0
- package/commands/wiki-req.md +1 -0
- package/commands/wiki-retro.md +1 -0
- package/dist/extensions/llm-wiki/lib/dashboard-command.js +86 -0
- package/dist/extensions/llm-wiki/lib/dashboard.js +175 -0
- package/dist/extensions/llm-wiki/lib/guardrails.js +30 -1
- package/dist/extensions/llm-wiki/lib/host.js +21 -1
- package/dist/extensions/llm-wiki/lib/ingest-worker.js +44 -20
- package/dist/extensions/llm-wiki/lib/knowledge-document.js +20 -2
- package/dist/extensions/llm-wiki/lib/knowledge-links.js +133 -27
- package/dist/extensions/llm-wiki/lib/metadata.js +6 -6
- package/dist/extensions/llm-wiki/lib/observation.js +22 -3
- 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/settings-command.js +377 -0
- package/dist/extensions/llm-wiki/lib/task-config.js +100 -1
- package/dist/extensions/llm-wiki/lib/tools.js +47 -8
- package/dist/mcp/index.js +2 -1
- package/dist/mcp/operations.js +21 -2
- package/docs/api.md +24 -1
- package/docs/commands.md +6 -1
- package/docs/configuration.md +11 -0
- package/docs/obsidian.md +6 -6
- package/docs/superpowers/plans/2026-08-09-qmd-retrieval-phase-1-quality-baseline-and-compatibility.md +1520 -0
- 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/docs/superpowers/roadmaps/2026-08-09-qmd-retrieval-roadmap.md +448 -0
- package/docs/superpowers/specs/2026-08-08-qmd-retrieval-design.md +806 -0
- package/extensions/llm-wiki/index.ts +4 -0
- package/extensions/llm-wiki/lib/dashboard-command.ts +106 -0
- package/extensions/llm-wiki/lib/dashboard.ts +210 -0
- package/extensions/llm-wiki/lib/guardrails.ts +26 -1
- package/extensions/llm-wiki/lib/host.ts +21 -1
- package/extensions/llm-wiki/lib/ingest-worker.ts +64 -27
- package/extensions/llm-wiki/lib/knowledge-document.ts +21 -2
- package/extensions/llm-wiki/lib/knowledge-links.ts +208 -35
- package/extensions/llm-wiki/lib/metadata.ts +10 -6
- package/extensions/llm-wiki/lib/observation.ts +23 -3
- package/extensions/llm-wiki/lib/retro.ts +48 -4
- package/extensions/llm-wiki/lib/runtime.ts +2 -2
- package/extensions/llm-wiki/lib/settings-command.ts +483 -0
- package/extensions/llm-wiki/lib/task-config.ts +138 -0
- package/extensions/llm-wiki/lib/tools.ts +62 -8
- package/mcp/index.ts +12 -1
- package/mcp/operations.ts +32 -2
- package/package.json +4 -4
- package/prompts/wiki-ingest.md +1 -0
- package/prompts/wiki-req.md +1 -0
- package/prompts/wiki-retro.md +1 -0
- package/skills/llm-wiki/SKILL.md +11 -1
|
@@ -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, } = 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}` }],
|
|
@@ -402,6 +425,7 @@ export async function runIngestSynthesis(args) {
|
|
|
402
425
|
systemPrompt,
|
|
403
426
|
userPrompt,
|
|
404
427
|
tools: [commitTool],
|
|
428
|
+
maxTokens: synthesisMaxTokens ?? 16384,
|
|
405
429
|
signal,
|
|
406
430
|
});
|
|
407
431
|
if (committed)
|
|
@@ -383,6 +383,22 @@ export function serializeKnowledgeDocument(document) {
|
|
|
383
383
|
const body = document.body.replace(/\r\n?/g, "\n").replace(/\n*$/, "");
|
|
384
384
|
return body ? `---\n${yaml}---\n\n${body}\n` : `---\n${yaml}---\n`;
|
|
385
385
|
}
|
|
386
|
+
/** Escape wikilink alias pipes so generated content remains valid in Markdown tables. */
|
|
387
|
+
function escapeWikilinkAliasPipes(body) {
|
|
388
|
+
let inFence = false;
|
|
389
|
+
return body
|
|
390
|
+
.split("\n")
|
|
391
|
+
.map((line) => {
|
|
392
|
+
if (/^\s*(`{3,}|~{3,})/.test(line)) {
|
|
393
|
+
inFence = !inFence;
|
|
394
|
+
return line;
|
|
395
|
+
}
|
|
396
|
+
if (inFence)
|
|
397
|
+
return line;
|
|
398
|
+
return line.replace(/\[\[([^\]\n]*?)(?<!\\)\|([^\]\n]*?)\]\]/g, "[[$1\\|$2]]");
|
|
399
|
+
})
|
|
400
|
+
.join("\n");
|
|
401
|
+
}
|
|
386
402
|
export function createKnowledgeDocument(path, fields, body, sources) {
|
|
387
403
|
if (Object.hasOwn(fields, "sources")) {
|
|
388
404
|
throw new Error("Pass canonical sources as the fourth argument");
|
|
@@ -402,7 +418,7 @@ export function createKnowledgeDocument(path, fields, body, sources) {
|
|
|
402
418
|
const sourcesUnion = sources
|
|
403
419
|
? { kind: "canonical", value: sources }
|
|
404
420
|
: { kind: "absent" };
|
|
405
|
-
const normalizedBody = body.replace(/\r\n?/g, "\n").replace(/\n*$/, "");
|
|
421
|
+
const normalizedBody = escapeWikilinkAliasPipes(body.replace(/\r\n?/g, "\n")).replace(/\n*$/, "");
|
|
406
422
|
return {
|
|
407
423
|
id: path.replace(/\.md$/, ""),
|
|
408
424
|
path,
|
|
@@ -425,7 +441,9 @@ export function patchKnowledgeDocument(document, patch) {
|
|
|
425
441
|
}
|
|
426
442
|
}
|
|
427
443
|
}
|
|
428
|
-
const newBody = patch.body
|
|
444
|
+
const newBody = patch.body
|
|
445
|
+
? escapeWikilinkAliasPipes(patch.body.replace(/\r\n?/g, "\n"))
|
|
446
|
+
: document.body;
|
|
429
447
|
return {
|
|
430
448
|
...document,
|
|
431
449
|
frontmatter: newFrontmatter,
|
|
@@ -1,14 +1,18 @@
|
|
|
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 };
|
|
5
6
|
}
|
|
7
|
+
function normalizeWikilinkTarget(target) {
|
|
8
|
+
return target.trim().replace(/\\$/, "");
|
|
9
|
+
}
|
|
6
10
|
export function extractKnowledgeLinks(body) {
|
|
7
11
|
const markdown = [];
|
|
8
12
|
const wikilinks = [];
|
|
9
|
-
// Extract legacy wikilinks
|
|
13
|
+
// Extract legacy wikilinks. A table-safe alias uses an escaped pipe: [[target\\|alias]].
|
|
10
14
|
for (const match of body.matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g)) {
|
|
11
|
-
wikilinks.push({ target: match[1]
|
|
15
|
+
wikilinks.push({ target: normalizeWikilinkTarget(match[1]), offset: match.index ?? 0 });
|
|
12
16
|
}
|
|
13
17
|
// Parse with CommonMark AST
|
|
14
18
|
let tree;
|
|
@@ -72,10 +76,59 @@ export function extractKnowledgeLinks(body) {
|
|
|
72
76
|
export function extractLegacyWikilinks(body) {
|
|
73
77
|
const links = [];
|
|
74
78
|
for (const match of body.matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g)) {
|
|
75
|
-
links.push({ target: match[1]
|
|
79
|
+
links.push({ target: normalizeWikilinkTarget(match[1]), offset: match.index ?? 0 });
|
|
76
80
|
}
|
|
77
81
|
return links;
|
|
78
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
|
+
}
|
|
79
132
|
function resolveMarkdownTarget(target, sourceId) {
|
|
80
133
|
// Strip query and fragment (earliest delimiter)
|
|
81
134
|
const qIndex = target.indexOf("?");
|
|
@@ -153,14 +206,7 @@ function resolveMarkdownTarget(target, sourceId) {
|
|
|
153
206
|
}
|
|
154
207
|
return { kind: "empty" };
|
|
155
208
|
}
|
|
156
|
-
function
|
|
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) {
|
|
209
|
+
export function buildResolvedBacklinks(sourceId, body, index) {
|
|
164
210
|
const diagnostics = [];
|
|
165
211
|
const unresolved = [];
|
|
166
212
|
const targets = new Set();
|
|
@@ -175,32 +221,92 @@ export function buildResolvedBacklinks(sourceId, body, knownIds) {
|
|
|
175
221
|
diagnostics.push(diag("warning", "link_unresolved", `${sourceId}.md`, `Malformed percent-encoded link: ${link.target}`));
|
|
176
222
|
}
|
|
177
223
|
else if (resolved.kind === "concept") {
|
|
178
|
-
const
|
|
179
|
-
if (
|
|
180
|
-
targets.add(
|
|
224
|
+
const canonical = index.byExact.get(resolved.id.normalize("NFC"));
|
|
225
|
+
if (canonical) {
|
|
226
|
+
targets.add(canonical);
|
|
181
227
|
}
|
|
182
228
|
else {
|
|
183
|
-
unresolved.push({ target:
|
|
184
|
-
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}`));
|
|
185
231
|
}
|
|
186
232
|
}
|
|
187
233
|
// external and empty are silently ignored
|
|
188
234
|
}
|
|
189
|
-
// Process wikilinks
|
|
235
|
+
// Process wikilinks (lenient: exact → normalized path → bare title)
|
|
190
236
|
for (const link of allLinks.wikilinks) {
|
|
191
|
-
const
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
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}`));
|
|
201
247
|
}
|
|
202
248
|
}
|
|
203
249
|
// Sort and deduplicate
|
|
204
250
|
const sorted = [...targets].sort(compareCodePoint);
|
|
205
251
|
return { targets: sorted, unresolved, diagnostics };
|
|
206
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)) {
|
|
@@ -386,7 +386,7 @@ export function buildDirectoryIndexes(documents, config) {
|
|
|
386
386
|
lines.push("## Directories");
|
|
387
387
|
lines.push("");
|
|
388
388
|
for (const subDir of [...dirs].sort(compareCodePoint)) {
|
|
389
|
-
const encoded = `${
|
|
389
|
+
const encoded = encodeRelativePath(`${subDir}/index.md`);
|
|
390
390
|
lines.push(`- [${escapeLabel(subDir)}/](${encoded})`);
|
|
391
391
|
}
|
|
392
392
|
}
|
|
@@ -256,9 +256,11 @@ export function registerObservationReminder(pi, reminderState, options) {
|
|
|
256
256
|
return true;
|
|
257
257
|
};
|
|
258
258
|
let turnsSinceLastReminder = 0;
|
|
259
|
+
let agentEndsInUserTurn = 0;
|
|
259
260
|
pi.on("session_start", async () => {
|
|
260
261
|
turnsSinceLastReminder = 0;
|
|
261
262
|
reminderState.observeDoneThisSession = false;
|
|
263
|
+
agentEndsInUserTurn = 0;
|
|
262
264
|
});
|
|
263
265
|
// After compaction, reset turn counter so reminders resume
|
|
264
266
|
// BUT preserve observeDoneThisSession — if the model already called
|
|
@@ -266,10 +268,18 @@ export function registerObservationReminder(pi, reminderState, options) {
|
|
|
266
268
|
pi.on("session_compact", async () => {
|
|
267
269
|
turnsSinceLastReminder = 0;
|
|
268
270
|
});
|
|
271
|
+
// A retry re-runs the agent within the same user turn, and pi does not
|
|
272
|
+
// forward `willRetry` to extensions (zosmaai/pi-llm-wiki#151). Only the
|
|
273
|
+
// user-role message_start separates real user turns from retried agent
|
|
274
|
+
// runs, so the per-turn agent_end count resets there.
|
|
275
|
+
pi.on("message_start", async (event) => {
|
|
276
|
+
if (event.message.role === "user")
|
|
277
|
+
agentEndsInUserTurn = 0;
|
|
278
|
+
});
|
|
269
279
|
pi.on("agent_end", async (event, _ctx) => {
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
280
|
+
// Legacy guard: this pi build does not forward `willRetry` to extension
|
|
281
|
+
// events, but keep the check in case a future one does. The dedup that
|
|
282
|
+
// actually works is the per-turn agent_end count below.
|
|
273
283
|
if ("willRetry" in event && event.willRetry)
|
|
274
284
|
return;
|
|
275
285
|
// No wiki applies here: never nag, and never accumulate a pending reminder
|
|
@@ -281,6 +291,12 @@ export function registerObservationReminder(pi, reminderState, options) {
|
|
|
281
291
|
return;
|
|
282
292
|
if (reminderState.observeDoneThisSession)
|
|
283
293
|
return;
|
|
294
|
+
// One agent_end per user turn may queue a reminder. A rate-limit storm
|
|
295
|
+
// re-runs the agent several times within the same turn, each firing
|
|
296
|
+
// agent_end, so count them and let only the first through.
|
|
297
|
+
agentEndsInUserTurn++;
|
|
298
|
+
if (agentEndsInUserTurn > 1)
|
|
299
|
+
return;
|
|
284
300
|
pi.sendMessage({
|
|
285
301
|
customType: "wiki-observe-reminder",
|
|
286
302
|
content: buildReminderText(),
|
|
@@ -288,5 +304,8 @@ export function registerObservationReminder(pi, reminderState, options) {
|
|
|
288
304
|
}, {
|
|
289
305
|
deliverAs: "nextTurn",
|
|
290
306
|
});
|
|
307
|
+
// Reset the interval counter after queueing: without it the count stays
|
|
308
|
+
// at/above the threshold and every later agent_end queues a reminder.
|
|
309
|
+
turnsSinceLastReminder = 0;
|
|
291
310
|
});
|
|
292
311
|
}
|
|
@@ -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.
|