@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
|
@@ -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 {
|
|
@@ -33,13 +34,17 @@ function diag(
|
|
|
33
34
|
return { severity, code, path, message };
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
function normalizeWikilinkTarget(target: string): string {
|
|
38
|
+
return target.trim().replace(/\\$/, "");
|
|
39
|
+
}
|
|
40
|
+
|
|
36
41
|
export function extractKnowledgeLinks(body: string): KnowledgeLinks {
|
|
37
42
|
const markdown: ExtractedLink[] = [];
|
|
38
43
|
const wikilinks: ExtractedLink[] = [];
|
|
39
44
|
|
|
40
|
-
// Extract legacy wikilinks
|
|
45
|
+
// Extract legacy wikilinks. A table-safe alias uses an escaped pipe: [[target\\|alias]].
|
|
41
46
|
for (const match of body.matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g)) {
|
|
42
|
-
wikilinks.push({ target: match[1]
|
|
47
|
+
wikilinks.push({ target: normalizeWikilinkTarget(match[1]), offset: match.index ?? 0 });
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
// Parse with CommonMark AST
|
|
@@ -111,11 +116,79 @@ export function extractKnowledgeLinks(body: string): KnowledgeLinks {
|
|
|
111
116
|
export function extractLegacyWikilinks(body: string): ExtractedLink[] {
|
|
112
117
|
const links: ExtractedLink[] = [];
|
|
113
118
|
for (const match of body.matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g)) {
|
|
114
|
-
links.push({ target: match[1]
|
|
119
|
+
links.push({ target: normalizeWikilinkTarget(match[1]), offset: match.index ?? 0 });
|
|
115
120
|
}
|
|
116
121
|
return links;
|
|
117
122
|
}
|
|
118
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
|
+
|
|
119
192
|
function resolveMarkdownTarget(
|
|
120
193
|
target: string,
|
|
121
194
|
sourceId: string,
|
|
@@ -200,19 +273,10 @@ function resolveMarkdownTarget(
|
|
|
200
273
|
return { kind: "empty" };
|
|
201
274
|
}
|
|
202
275
|
|
|
203
|
-
function resolveWikilinkTarget(
|
|
204
|
-
target: string,
|
|
205
|
-
): { kind: "concept"; id: string } | { kind: "empty" } {
|
|
206
|
-
// Wikilinks are already bundle-relative concept IDs
|
|
207
|
-
const cleaned = target.trim();
|
|
208
|
-
if (!cleaned) return { kind: "empty" };
|
|
209
|
-
return { kind: "concept", id: cleaned };
|
|
210
|
-
}
|
|
211
|
-
|
|
212
276
|
export function buildResolvedBacklinks(
|
|
213
277
|
sourceId: string,
|
|
214
278
|
body: string,
|
|
215
|
-
|
|
279
|
+
index: WikilinkIndex,
|
|
216
280
|
): ResolvedBacklinks {
|
|
217
281
|
const diagnostics: KnowledgeDiagnostic[] = [];
|
|
218
282
|
const unresolved: UnresolvedKnowledgeLink[] = [];
|
|
@@ -241,37 +305,38 @@ export function buildResolvedBacklinks(
|
|
|
241
305
|
),
|
|
242
306
|
);
|
|
243
307
|
} else if (resolved.kind === "concept") {
|
|
244
|
-
const
|
|
245
|
-
if (
|
|
246
|
-
targets.add(
|
|
308
|
+
const canonical = index.byExact.get(resolved.id.normalize("NFC"));
|
|
309
|
+
if (canonical) {
|
|
310
|
+
targets.add(canonical);
|
|
247
311
|
} else {
|
|
248
|
-
unresolved.push({ target:
|
|
312
|
+
unresolved.push({ target: resolved.id, syntax: "markdown" });
|
|
249
313
|
diagnostics.push(
|
|
250
|
-
diag("warning", "link_unresolved", `${sourceId}.md`, `Unresolved link: ${
|
|
314
|
+
diag("warning", "link_unresolved", `${sourceId}.md`, `Unresolved link: ${resolved.id}`),
|
|
251
315
|
);
|
|
252
316
|
}
|
|
253
317
|
}
|
|
254
318
|
// external and empty are silently ignored
|
|
255
319
|
}
|
|
256
320
|
|
|
257
|
-
// Process wikilinks
|
|
321
|
+
// Process wikilinks (lenient: exact → normalized path → bare title)
|
|
258
322
|
for (const link of allLinks.wikilinks) {
|
|
259
|
-
const
|
|
260
|
-
if (
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
+
);
|
|
275
340
|
}
|
|
276
341
|
}
|
|
277
342
|
|
|
@@ -280,3 +345,111 @@ export function buildResolvedBacklinks(
|
|
|
280
345
|
|
|
281
346
|
return { targets: sorted, unresolved, diagnostics };
|
|
282
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)) {
|
|
@@ -508,7 +512,7 @@ export function buildDirectoryIndexes(
|
|
|
508
512
|
lines.push("## Directories");
|
|
509
513
|
lines.push("");
|
|
510
514
|
for (const subDir of [...dirs].sort(compareCodePoint)) {
|
|
511
|
-
const encoded = `${
|
|
515
|
+
const encoded = encodeRelativePath(`${subDir}/index.md`);
|
|
512
516
|
lines.push(`- [${escapeLabel(subDir)}/](${encoded})`);
|
|
513
517
|
}
|
|
514
518
|
}
|
|
@@ -345,10 +345,12 @@ export function registerObservationReminder(
|
|
|
345
345
|
return true;
|
|
346
346
|
};
|
|
347
347
|
let turnsSinceLastReminder = 0;
|
|
348
|
+
let agentEndsInUserTurn = 0;
|
|
348
349
|
|
|
349
350
|
pi.on("session_start", async () => {
|
|
350
351
|
turnsSinceLastReminder = 0;
|
|
351
352
|
reminderState.observeDoneThisSession = false;
|
|
353
|
+
agentEndsInUserTurn = 0;
|
|
352
354
|
});
|
|
353
355
|
|
|
354
356
|
// After compaction, reset turn counter so reminders resume
|
|
@@ -358,10 +360,18 @@ export function registerObservationReminder(
|
|
|
358
360
|
turnsSinceLastReminder = 0;
|
|
359
361
|
});
|
|
360
362
|
|
|
363
|
+
// A retry re-runs the agent within the same user turn, and pi does not
|
|
364
|
+
// forward `willRetry` to extensions (zosmaai/pi-llm-wiki#151). Only the
|
|
365
|
+
// user-role message_start separates real user turns from retried agent
|
|
366
|
+
// runs, so the per-turn agent_end count resets there.
|
|
367
|
+
pi.on("message_start", async (event) => {
|
|
368
|
+
if (event.message.role === "user") agentEndsInUserTurn = 0;
|
|
369
|
+
});
|
|
370
|
+
|
|
361
371
|
pi.on("agent_end", async (event, _ctx) => {
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
372
|
+
// Legacy guard: this pi build does not forward `willRetry` to extension
|
|
373
|
+
// events, but keep the check in case a future one does. The dedup that
|
|
374
|
+
// actually works is the per-turn agent_end count below.
|
|
365
375
|
if ("willRetry" in event && (event as { willRetry?: boolean }).willRetry) return;
|
|
366
376
|
|
|
367
377
|
// No wiki applies here: never nag, and never accumulate a pending reminder
|
|
@@ -372,6 +382,12 @@ export function registerObservationReminder(
|
|
|
372
382
|
if (turnsSinceLastReminder < REMINDER_INTERVAL) return;
|
|
373
383
|
if (reminderState.observeDoneThisSession) return;
|
|
374
384
|
|
|
385
|
+
// One agent_end per user turn may queue a reminder. A rate-limit storm
|
|
386
|
+
// re-runs the agent several times within the same turn, each firing
|
|
387
|
+
// agent_end, so count them and let only the first through.
|
|
388
|
+
agentEndsInUserTurn++;
|
|
389
|
+
if (agentEndsInUserTurn > 1) return;
|
|
390
|
+
|
|
375
391
|
pi.sendMessage(
|
|
376
392
|
{
|
|
377
393
|
customType: "wiki-observe-reminder",
|
|
@@ -382,5 +398,9 @@ export function registerObservationReminder(
|
|
|
382
398
|
deliverAs: "nextTurn",
|
|
383
399
|
},
|
|
384
400
|
);
|
|
401
|
+
|
|
402
|
+
// Reset the interval counter after queueing: without it the count stays
|
|
403
|
+
// at/above the threshold and every later agent_end queues a reminder.
|
|
404
|
+
turnsSinceLastReminder = 0;
|
|
385
405
|
});
|
|
386
406
|
}
|
|
@@ -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
|
/**
|