@zosmaai/pi-llm-wiki 0.11.5 → 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.
@@ -9,6 +9,7 @@ import {
9
9
  resolveGlobalSettingsPath,
10
10
  resolveProjectSettingsPath,
11
11
  } from "./host.js";
12
+ import type { WikilinkValidationMode } from "./knowledge-links.js";
12
13
 
13
14
  /**
14
15
  * Configuration for the background-task lane (issue #64, part of #63).
@@ -147,6 +148,17 @@ export interface TaskConfig {
147
148
  * response truncates before commit_synthesis can be called.
148
149
  */
149
150
  synthesisMaxTokens?: number;
151
+
152
+ /**
153
+ * Wikilink gate applied to the ingested source body before pages are
154
+ * written (issue #172, Layer 2). Reuses the Layer 1 resolver.
155
+ * - "off" : no-op.
156
+ * - "warn" : write proceeds; unresolved/ambiguous links are reported.
157
+ * - "strict" : block the write (commit returns ok:false) if any link is unresolvable.
158
+ * - "normalize" : rewrite resolvable links to their canonical id, then write.
159
+ * Default "warn". See `resolveWikilinkValidation`.
160
+ */
161
+ wikilinkValidation?: WikilinkValidationMode;
150
162
  }
151
163
 
152
164
  export const TASK_DEFAULTS: TaskConfig = {};
@@ -159,6 +171,24 @@ export function noticesEnabled(config: TaskConfig | undefined): boolean {
159
171
  return config?.notices !== false;
160
172
  }
161
173
 
174
+ const WIKILINK_VALIDATION_MODES: readonly WikilinkValidationMode[] = [
175
+ "off",
176
+ "warn",
177
+ "strict",
178
+ "normalize",
179
+ ];
180
+
181
+ /**
182
+ * Resolve the wikilink write-gate mode (issue #172, Layer 2). Defaults to
183
+ * `warn` — ingest always writes and reports; only an explicit `strict` blocks.
184
+ * Unknown values fall back to `warn` rather than failing the ingest.
185
+ */
186
+ export function resolveWikilinkValidation(config: TaskConfig | undefined): WikilinkValidationMode {
187
+ const v = config?.wikilinkValidation;
188
+ if (v && (WIKILINK_VALIDATION_MODES as readonly string[]).includes(v)) return v;
189
+ return "warn";
190
+ }
191
+
162
192
  /**
163
193
  * Resolve whether the personal vault may serve as this project's ambient
164
194
  * vault. Explicit `ambientPersonalVault` wins; otherwise the host decides
@@ -251,6 +281,11 @@ function readNamespacedConfig(path: string): Partial<TaskConfig> {
251
281
  out.synthesisMaxTokens = Math.floor(maxTokens);
252
282
  }
253
283
 
284
+ const wl = section.wikilinkValidation;
285
+ if (typeof wl === "string" && (WIKILINK_VALIDATION_MODES as readonly string[]).includes(wl)) {
286
+ out.wikilinkValidation = wl as WikilinkValidationMode;
287
+ }
288
+
254
289
  return out;
255
290
  } catch {
256
291
  return {};
@@ -438,6 +473,7 @@ const KNOWN_KEYS = [
438
473
  "trajectories",
439
474
  "synthesisLanguage",
440
475
  "synthesisMaxTokens",
476
+ "wikilinkValidation",
441
477
  ] as const;
442
478
 
443
479
  export function loadTaskConfigSources(
@@ -11,12 +11,16 @@ import {
11
11
  serializeKnowledgeDocument,
12
12
  writeKnowledgeDocumentFile,
13
13
  } from "./knowledge-document.js";
14
- import { buildResolvedBacklinks } from "./knowledge-links.js";
14
+ import {
15
+ applyWikilinkGate,
16
+ buildResolvedBacklinks,
17
+ buildWikilinkIndex,
18
+ } from "./knowledge-links.js";
15
19
  import { repairLegacyKnowledgeDocuments } from "./legacy-repair.js";
16
20
  import { type Registry, appendEvent, rebuildMetadata, rebuildMetadataLight } from "./metadata.js";
17
21
  import type { Runtime } from "./runtime.js";
18
22
  import { captureFile, captureText, captureUrl } from "./source-packet.js";
19
- import { parseModelRef } from "./task-config.js";
23
+ import { loadTaskConfig, parseModelRef, resolveWikilinkValidation } from "./task-config.js";
20
24
  import {
21
25
  type VaultPaths,
22
26
  detectVaultFormat,
@@ -415,6 +419,7 @@ export function registerWikiIngest(pi: ExtensionAPI, runtime?: Runtime): void {
415
419
  manifest: s.manifest,
416
420
  extracted: s.extracted,
417
421
  synthesisLanguage: runtime.config.synthesisLanguage,
422
+ wikilinkValidation: runtime.config.wikilinkValidation,
418
423
  });
419
424
  if (committed) {
420
425
  // Background semantic embeddings (#66): embed the pages this
@@ -428,8 +433,10 @@ export function registerWikiIngest(pi: ExtensionAPI, runtime?: Runtime): void {
428
433
  ];
429
434
  launchEmbedPages(runtime, launchCtx, paths, pageIds, `embed:ingest:${s.id}`);
430
435
  }
436
+ const wl = committed?.wikilinkDiagnostics?.length ?? 0;
437
+ const wlNote = wl > 0 ? `, ${wl} wikilink issue${wl === 1 ? "" : "s"}` : "";
431
438
  const summary = committed
432
- ? `LLM Wiki: ingested ${s.id} → ${committed.entitiesCreated.length} entit${committed.entitiesCreated.length === 1 ? "y" : "ies"}, ${committed.conceptsCreated.length} concept${committed.conceptsCreated.length === 1 ? "" : "s"}`
439
+ ? `LLM Wiki: ingested ${s.id} → ${committed.entitiesCreated.length} entit${committed.entitiesCreated.length === 1 ? "y" : "ies"}, ${committed.conceptsCreated.length} concept${committed.conceptsCreated.length === 1 ? "" : "s"}${wlNote}`
433
440
  : `LLM Wiki: ${s.id} produced no synthesis`;
434
441
  if (ctx.hasUI) {
435
442
  ctx.ui.notify(summary, committed ? "info" : "warning");
@@ -565,7 +572,42 @@ export function registerWikiEnsurePage(pi: ExtensionAPI, runtime?: Runtime): voi
565
572
  }
566
573
 
567
574
  const today = fmtDate();
568
- const body = params.content ?? buildPageBody(type, params.title);
575
+ let body = params.content ?? buildPageBody(type, params.title);
576
+
577
+ // Pre-write wikilink gate (#172): validate/normalize caller-supplied content.
578
+ const mode = resolveWikilinkValidation(loadTaskConfig(ctx.cwd));
579
+ let wikilinkIssues: string[] = [];
580
+ if (mode !== "off") {
581
+ const registry = readJson<{ pages: Record<string, unknown> }>(
582
+ join(paths.meta, "registry.json"),
583
+ { pages: {} },
584
+ );
585
+ const gate = applyWikilinkGate(
586
+ body,
587
+ buildWikilinkIndex(Object.keys(registry.pages)),
588
+ `${folder}/${slug}`,
589
+ mode,
590
+ );
591
+ wikilinkIssues = gate.diagnostics.map((d) => d.message);
592
+ if (!gate.ok) {
593
+ return {
594
+ content: [
595
+ {
596
+ type: "text",
597
+ text: `Rejected write — unresolved/ambiguous wikilinks:\n${wikilinkIssues
598
+ .map((m) => `- ${m}`)
599
+ .join("\n")}`,
600
+ },
601
+ ],
602
+ details: { error: "link_validation", issues: wikilinkIssues } as Record<
603
+ string,
604
+ unknown
605
+ >,
606
+ isError: true,
607
+ };
608
+ }
609
+ if (mode === "normalize") body = gate.body;
610
+ }
569
611
  const doc = createKnowledgeDocument(
570
612
  `${folder}/${slug}.md`,
571
613
  {
@@ -595,9 +637,16 @@ export function registerWikiEnsurePage(pi: ExtensionAPI, runtime?: Runtime): voi
595
637
  rebuildMetadataLight(paths);
596
638
  }
597
639
 
640
+ const gateNote = wikilinkIssues.length
641
+ ? `\n\n⚠️ ${wikilinkIssues.length} wikilink issue(s):\n${wikilinkIssues.map((m) => `- ${m}`).join("\n")}`
642
+ : "";
598
643
  return {
599
- content: [{ type: "text", text: `✅ Created ${type} page: \`${pagePath}\`` }],
600
- details: { path: pagePath, created: true } as Record<string, unknown>,
644
+ content: [{ type: "text", text: `✅ Created ${type} page: \`${pagePath}\`${gateNote}` }],
645
+ details: {
646
+ path: pagePath,
647
+ created: true,
648
+ wikilinkIssues,
649
+ } as Record<string, unknown>,
601
650
  };
602
651
  },
603
652
  });
@@ -871,7 +920,7 @@ function runWikiLint(paths: VaultPaths, autoFix: boolean): string {
871
920
 
872
921
  const discovery = discoverKnowledgeDocuments(paths);
873
922
  const pages = discovery.documents;
874
- const knownIds = new Set(pages.map((page) => page.id));
923
+ const wikilinkIndex = buildWikilinkIndex(pages.map((page) => page.id));
875
924
  const inbound = Object.fromEntries(pages.map((page) => [page.id, 0]));
876
925
  const gapSources = new Map<string, Set<string>>();
877
926
  const findings: string[] = [];
@@ -879,7 +928,7 @@ function runWikiLint(paths: VaultPaths, autoFix: boolean): string {
879
928
  let contradictions = 0;
880
929
 
881
930
  for (const page of pages) {
882
- const resolved = buildResolvedBacklinks(page.id, page.body, knownIds);
931
+ const resolved = buildResolvedBacklinks(page.id, page.body, wikilinkIndex);
883
932
  for (const target of resolved.targets) inbound[target]++;
884
933
  for (const unresolved of resolved.unresolved) {
885
934
  const sources = gapSources.get(unresolved.target) ?? new Set<string>();
@@ -888,6 +937,11 @@ function runWikiLint(paths: VaultPaths, autoFix: boolean): string {
888
937
  missingPages++;
889
938
  findings.push(`Missing page: ${unresolved.target} (in ${page.id})`);
890
939
  }
940
+ for (const d of resolved.diagnostics) {
941
+ if (d.code === "link_ambiguous") {
942
+ findings.push(d.message.replace("Ambiguous wikilink: ", "Ambiguous: "));
943
+ }
944
+ }
891
945
  }
892
946
 
893
947
  let orphans = 0;
package/mcp/index.ts CHANGED
@@ -15,6 +15,10 @@ import { join } from "node:path";
15
15
  import { McpServer } from "@modelcontextprotocol/server";
16
16
  import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
17
17
  import * as z from "zod/v4";
18
+ import {
19
+ loadTaskConfig,
20
+ resolveWikilinkValidation,
21
+ } from "../extensions/llm-wiki/lib/task-config.js";
18
22
  import { getVaultPaths, resolveVaultPaths } from "../extensions/llm-wiki/lib/utils.js";
19
23
  import { createExecApi } from "./exec.js";
20
24
  import {
@@ -265,7 +269,14 @@ server.registerTool(
265
269
  }
266
270
 
267
271
  const paths = getPaths();
268
- const result = await retroOperation(paths, slug, title, body, category);
272
+ const result = await retroOperation(
273
+ paths,
274
+ slug,
275
+ title,
276
+ body,
277
+ category,
278
+ resolveWikilinkValidation(loadTaskConfig(process.cwd())),
279
+ );
269
280
 
270
281
  if (!result.ok) {
271
282
  return {
package/mcp/operations.ts CHANGED
@@ -6,13 +6,20 @@
6
6
  * registry entries, or builds page strings itself.
7
7
  */
8
8
 
9
+ import { join } from "node:path";
9
10
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
10
11
  import { bootstrapVault } from "../extensions/llm-wiki/lib/bootstrap.js";
12
+ import {
13
+ type WikilinkValidationMode,
14
+ applyWikilinkGate,
15
+ buildWikilinkIndex,
16
+ } from "../extensions/llm-wiki/lib/knowledge-links.js";
11
17
  import { type ProjectionResult, rebuildMetadata } from "../extensions/llm-wiki/lib/metadata.js";
12
18
  import { type RecallResult, searchWikiLayered } from "../extensions/llm-wiki/lib/recall.js";
13
19
  import { saveInsight } from "../extensions/llm-wiki/lib/retro.js";
14
20
  import { captureFile, captureText, captureUrl } from "../extensions/llm-wiki/lib/source-packet.js";
15
- import type { VaultPaths } from "../extensions/llm-wiki/lib/utils.js";
21
+ import { resolveWikilinkValidation } from "../extensions/llm-wiki/lib/task-config.js";
22
+ import { type VaultPaths, readJson } from "../extensions/llm-wiki/lib/utils.js";
16
23
  import {
17
24
  VaultWriteError,
18
25
  inspectVaultFormat,
@@ -134,6 +141,7 @@ export async function retroOperation(
134
141
  title: string,
135
142
  body: string,
136
143
  category?: string,
144
+ wikilinkValidation?: WikilinkValidationMode,
137
145
  ): Promise<
138
146
  | { ok: true; slug: string; sourcePagePath: string }
139
147
  | { ok: false; diagnostics: Array<{ code: string; message: string }> }
@@ -145,8 +153,30 @@ export async function retroOperation(
145
153
  diagnostics: vaultCheck.diagnostics.map((d) => ({ code: d.code, message: d.message })),
146
154
  };
147
155
  }
156
+ // Pre-write wikilink gate (#172): validate/normalize caller-supplied body.
157
+ const mode = resolveWikilinkValidation({ wikilinkValidation });
158
+ let gateBody = body;
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/${slug}`,
168
+ mode,
169
+ );
170
+ if (!gate.ok) {
171
+ return {
172
+ ok: false,
173
+ diagnostics: gate.diagnostics.map((d) => ({ code: "link_validation", message: d.message })),
174
+ };
175
+ }
176
+ if (mode === "normalize") gateBody = gate.body;
177
+ }
148
178
  try {
149
- const result = saveInsight(paths, slug, title, body, category, { rebuild: false });
179
+ const result = saveInsight(paths, slug, title, gateBody, category, { rebuild: false });
150
180
  const projection = projectionOutcome(rebuildMetadata(paths));
151
181
  if (!projection.ok) return projection;
152
182
  return { ok: true, slug: result.slug, sourcePagePath: result.sourcePagePath };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zosmaai/pi-llm-wiki",
3
- "version": "0.11.5",
3
+ "version": "0.11.6",
4
4
  "description": "Self-maintaining LLM Wiki for Pi — Karpathy-pattern knowledge base with immutable source capture, automated ingestion, search, linting, and Obsidian-compatible vault. auto-updating personal & company wiki.",
5
5
  "keywords": [
6
6
  "pi",