@zosmaai/pi-llm-wiki 0.12.1 → 0.12.2

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/README.md +16 -8
  3. package/dist/extensions/llm-wiki/lib/bootstrap.js +2 -0
  4. package/dist/extensions/llm-wiki/lib/indexing.js +24 -1
  5. package/dist/extensions/llm-wiki/lib/ingest-worker.js +3 -1
  6. package/dist/extensions/llm-wiki/lib/knowledge-links.js +41 -6
  7. package/dist/extensions/llm-wiki/lib/model-command.js +45 -8
  8. package/dist/extensions/llm-wiki/lib/qmd-indexing.js +1024 -0
  9. package/dist/extensions/llm-wiki/lib/qmd-mirror.js +418 -0
  10. package/dist/extensions/llm-wiki/lib/qmd-store.js +112 -0
  11. package/dist/extensions/llm-wiki/lib/recall.js +77 -3
  12. package/dist/extensions/llm-wiki/lib/runtime.js +25 -1
  13. package/dist/extensions/llm-wiki/lib/subagent.js +47 -7
  14. package/dist/extensions/llm-wiki/lib/tools.js +165 -5
  15. package/dist/extensions/llm-wiki/lib/utils.js +16 -2
  16. package/dist/extensions/llm-wiki/lib/wiki-service.js +104 -5
  17. package/dist/mcp/index.js +66 -2
  18. package/dist/mcp/operations.js +26 -2
  19. package/docs/api.md +43 -1
  20. package/docs/architecture.md +28 -0
  21. package/docs/commands.md +1 -0
  22. package/docs/qmd-compatibility.md +47 -0
  23. package/docs/retrieval-benchmark.md +47 -0
  24. package/docs/superpowers/benchmarks/phase-1-current-baseline.json +53 -0
  25. package/docs/superpowers/plans/2026-08-09-qmd-retrieval-phase-2-remediation.md +549 -0
  26. package/docs/superpowers/plans/2026-08-09-qmd-retrieval-phase-2-validated-indexing.md +1493 -0
  27. package/docs/superpowers/plans/2026-08-11-qmd-retrieval-phase-3-retrieval-modes-and-recall-cutover.md +678 -0
  28. package/docs/superpowers/plans/2026-09-05-wikilink-alias-pipe-table-only.md +257 -0
  29. package/extensions/llm-wiki/index.ts +14 -1
  30. package/extensions/llm-wiki/lib/bootstrap.ts +2 -0
  31. package/extensions/llm-wiki/lib/indexing.ts +24 -1
  32. package/extensions/llm-wiki/lib/ingest-worker.ts +10 -2
  33. package/extensions/llm-wiki/lib/knowledge-document.ts +8 -1
  34. package/extensions/llm-wiki/lib/knowledge-links.ts +39 -7
  35. package/extensions/llm-wiki/lib/model-command.ts +57 -12
  36. package/extensions/llm-wiki/lib/qmd-indexing.ts +1304 -0
  37. package/extensions/llm-wiki/lib/qmd-mirror.ts +496 -0
  38. package/extensions/llm-wiki/lib/qmd-store.ts +222 -0
  39. package/extensions/llm-wiki/lib/recall.ts +77 -3
  40. package/extensions/llm-wiki/lib/runtime.ts +57 -5
  41. package/extensions/llm-wiki/lib/subagent.ts +73 -10
  42. package/extensions/llm-wiki/lib/tools.ts +188 -4
  43. package/extensions/llm-wiki/lib/utils.ts +21 -2
  44. package/extensions/llm-wiki/lib/wiki-service.ts +160 -4
  45. package/mcp/index.ts +78 -1
  46. package/mcp/operations.ts +41 -2
  47. package/package.json +9 -6
  48. package/skills/llm-wiki/SKILL.md +7 -1
@@ -8,6 +8,8 @@ import { scheduleReindex } from "./indexing.js";
8
8
  import { runIngestSynthesis } from "./ingest-worker.js";
9
9
  import {
10
10
  createKnowledgeDocument,
11
+ type KnowledgeValue,
12
+ parseMarkdownFrontmatter,
11
13
  serializeKnowledgeDocument,
12
14
  writeKnowledgeDocumentFile,
13
15
  } from "./knowledge-document.js";
@@ -18,6 +20,7 @@ import {
18
20
  } from "./knowledge-links.js";
19
21
  import { repairLegacyKnowledgeDocuments } from "./legacy-repair.js";
20
22
  import { appendEvent, type Registry, rebuildMetadata, rebuildMetadataLight } from "./metadata.js";
23
+ import { readQmdIndexStatus, reindexQmdVault } from "./qmd-indexing.js";
21
24
  import type { Runtime } from "./runtime.js";
22
25
  import { captureFile, captureText, captureUrl } from "./source-packet.js";
23
26
  import { loadTaskConfig, parseModelRef, resolveWikilinkValidation } from "./task-config.js";
@@ -38,7 +41,7 @@ import {
38
41
  inspectVaultFormat,
39
42
  inspectWritableVault,
40
43
  } from "./vault-format.js";
41
- import { getWikiStatus, searchRegistry } from "./wiki-service.js";
44
+ import { getWikiStatus, reindexWiki, searchRegistry } from "./wiki-service.js";
42
45
 
43
46
  /**
44
47
  * All LLM Wiki custom tools.
@@ -414,6 +417,8 @@ export function registerWikiIngest(pi: ExtensionAPI, runtime?: Runtime): void {
414
417
  model: resolved.model as Parameters<typeof runIngestSynthesis>[0]["model"],
415
418
  apiKey: resolved.apiKey,
416
419
  headers: resolved.headers,
420
+ streamFn: resolved.streamFn as Parameters<typeof runIngestSynthesis>[0]["streamFn"],
421
+ env: resolved.env,
417
422
  paths,
418
423
  sourceId: s.id,
419
424
  manifest: s.manifest,
@@ -506,6 +511,11 @@ export function registerWikiIngest(pi: ExtensionAPI, runtime?: Runtime): void {
506
511
 
507
512
  // ─── 4. wiki_ensure_page ────────────────────────────────
508
513
 
514
+ // Frontmatter fields wiki_ensure_page generates or derives itself (title also
515
+ // determines the filename). Model-supplied values for these are ignored rather
516
+ // than merged (issue #241).
517
+ const RESERVED_FRONTMATTER = new Set(["type", "title", "created", "updated", "sources", "id"]);
518
+
509
519
  export function registerWikiEnsurePage(pi: ExtensionAPI, runtime?: Runtime): void {
510
520
  pi.registerTool({
511
521
  name: "wiki_ensure_page",
@@ -515,6 +525,7 @@ export function registerWikiEnsurePage(pi: ExtensionAPI, runtime?: Runtime): voi
515
525
  promptGuidelines: [
516
526
  "Use wiki_ensure_page before creating pages to avoid duplicates.",
517
527
  "Search existing pages first with wiki_search.",
528
+ "Content may begin with a YAML frontmatter block; its fields are merged into the page frontmatter, and generated fields are reserved.",
518
529
  ],
519
530
  parameters: Type.Object({
520
531
  type: Type.String({
@@ -523,7 +534,10 @@ export function registerWikiEnsurePage(pi: ExtensionAPI, runtime?: Runtime): voi
523
534
  }),
524
535
  title: Type.String({ description: "Page title" }),
525
536
  content: Type.Optional(
526
- Type.String({ description: "Optional initial content (otherwise uses template)" }),
537
+ Type.String({
538
+ description:
539
+ "Optional Markdown body. May begin with a YAML frontmatter block, whose fields are merged into the page frontmatter; generated fields (type, title, created, updated, sources) are reserved and ignored.",
540
+ }),
527
541
  ),
528
542
  }),
529
543
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -568,6 +582,25 @@ export function registerWikiEnsurePage(pi: ExtensionAPI, runtime?: Runtime): voi
568
582
  const today = fmtDate();
569
583
  let body = params.content ?? buildPageBody(type, params.title);
570
584
 
585
+ // #241: models sometimes pass a YAML frontmatter block inside content.
586
+ // Consume it (with the same hardened parser used for page reads) and merge
587
+ // its fields into the generated frontmatter, instead of writing the block
588
+ // verbatim into the body (duplicate frontmatter, and the model's fields
589
+ // silently never took effect). Generated fields are reserved; a
590
+ // frontmatter-only content falls back to the template body.
591
+ const extraFrontmatter: Record<string, KnowledgeValue> = {};
592
+ if (body.trimStart().startsWith("---")) {
593
+ const parsed = parseMarkdownFrontmatter(body, `${folder}/${slug}.md`);
594
+ if (parsed.ok) {
595
+ for (const [key, value] of Object.entries(parsed.mapping)) {
596
+ if (!RESERVED_FRONTMATTER.has(key)) {
597
+ extraFrontmatter[key] = value;
598
+ }
599
+ }
600
+ body = parsed.body.trim() ? parsed.body : buildPageBody(type, params.title);
601
+ }
602
+ }
603
+
571
604
  // Pre-write wikilink gate (#172): validate/normalize caller-supplied content.
572
605
  const mode = resolveWikilinkValidation(loadTaskConfig(ctx.cwd));
573
606
  let wikilinkIssues: string[] = [];
@@ -609,6 +642,7 @@ export function registerWikiEnsurePage(pi: ExtensionAPI, runtime?: Runtime): voi
609
642
  title: params.title,
610
643
  created: today,
611
644
  updated: today,
645
+ ...extraFrontmatter,
612
646
  },
613
647
  body,
614
648
  );
@@ -875,8 +909,9 @@ export function registerWikiLint(pi: ExtensionAPI, runtime?: Runtime): void {
875
909
  * Run the wiki health scan (issue #77 extracted it from the tool body so it can
876
910
  * run off-thread via `dispatchReported`). Returns the human-readable summary.
877
911
  */
878
- function runWikiLint(paths: VaultPaths, autoFix: boolean): string {
912
+ async function runWikiLint(paths: VaultPaths, autoFix: boolean): Promise<string> {
879
913
  assertWritableVault(paths);
914
+ const qmdStatus = await readQmdIndexStatus(paths);
880
915
  let repair: ReturnType<typeof repairLegacyKnowledgeDocuments> | undefined;
881
916
  if (autoFix) {
882
917
  let projection = rebuildMetadata(paths);
@@ -1034,6 +1069,26 @@ function runWikiLint(paths: VaultPaths, autoFix: boolean): string {
1034
1069
  rebuildMetadataLight(paths);
1035
1070
  }
1036
1071
 
1072
+ const qmdFindings: string[] = [];
1073
+ if (qmdStatus.state === "stale") {
1074
+ const components = JSON.stringify(
1075
+ qmdStatus.repairComponents.length > 0 ? qmdStatus.repairComponents : ["lexical"],
1076
+ );
1077
+ qmdFindings.push(
1078
+ `- QMD index stale (${qmdStatus.indexedManifestHash ? "manifest or model changed" : ""}): repair with \`wiki_reindex(scope="changed", components=${components}, vault="active")\``,
1079
+ );
1080
+ } else if (qmdStatus.state === "recovering") {
1081
+ qmdFindings.push(
1082
+ `- QMD swap interrupted (${qmdStatus.swapPhase ?? ""}): restart recovery via \`wiki_reindex(vault="active")\``,
1083
+ );
1084
+ } else if (qmdStatus.state === "error") {
1085
+ qmdFindings.push(
1086
+ `- QMD index error: ${qmdStatus.issues[0]?.message ?? "repair with wiki_reindex"} — \`wiki_reindex(scope="changed", components=${JSON.stringify(qmdStatus.repairComponents.length > 0 ? qmdStatus.repairComponents : ["lexical"])}, vault="active")\``,
1087
+ );
1088
+ } else if (qmdStatus.state === "missing") {
1089
+ qmdFindings.push("- QMD index not built yet (informational): run wiki_reindex to build it");
1090
+ }
1091
+
1037
1092
  return [
1038
1093
  "🧹 **LLM Wiki lint complete**",
1039
1094
  "",
@@ -1047,6 +1102,10 @@ function runWikiLint(paths: VaultPaths, autoFix: boolean): string {
1047
1102
  reportPath ? `📄 Report: \`${reportPath}\`` : "",
1048
1103
  repair?.manifestPath ? `🛟 Repair manifest: \`${repair.manifestPath}\`` : "",
1049
1104
  gaps.length ? `💡 ${gaps.length} knowledge gap(s) tracked` : "",
1105
+ "",
1106
+ "## QMD Index",
1107
+ `- State: ${qmdStatus.state}`,
1108
+ ...qmdFindings,
1050
1109
  ]
1051
1110
  .filter(Boolean)
1052
1111
  .join("\n");
@@ -1073,7 +1132,7 @@ export function registerWikiStatus(pi: ExtensionAPI): void {
1073
1132
  };
1074
1133
  }
1075
1134
 
1076
- const status = getWikiStatus(paths);
1135
+ const status = await getWikiStatus(paths);
1077
1136
  const config = readJson<Record<string, unknown>>(join(paths.dotWiki, "config.json"), {});
1078
1137
  const backlinks = readJson<Record<string, string[]>>(join(paths.meta, "backlinks.json"), {});
1079
1138
 
@@ -1108,6 +1167,10 @@ export function registerWikiStatus(pi: ExtensionAPI): void {
1108
1167
  `Gaps: ${gaps.gaps?.length || 0}`,
1109
1168
  `Health: ${health}`,
1110
1169
  `Last updated: ${status.lastUpdated || "Never"}`,
1170
+ `QMD index: ${status.qmd.state}`,
1171
+ `QMD documents: ${status.qmd.totalDocuments} (${status.qmd.canonicalDocuments} canonical, ${status.qmd.evidenceDocuments} evidence)`,
1172
+ `QMD embeddings pending: ${status.qmd.needsEmbedding}`,
1173
+ `QMD package: ${status.qmd.qmdVersion}`,
1111
1174
  ...diagLines,
1112
1175
  ];
1113
1176
 
@@ -1123,6 +1186,7 @@ export function registerWikiStatus(pi: ExtensionAPI): void {
1123
1186
  gaps: gaps.gaps?.length || 0,
1124
1187
  health,
1125
1188
  blockingDiagnostics: status.blockingDiagnostics,
1189
+ qmd: status.qmd,
1126
1190
  } as Record<string, unknown>,
1127
1191
  };
1128
1192
  },
@@ -1170,6 +1234,22 @@ export function registerWikiRebuildMeta(pi: ExtensionAPI, runtime?: Runtime): vo
1170
1234
  const warnings = result.diagnostics.filter(
1171
1235
  (diagnostic) => diagnostic.severity === "warning",
1172
1236
  );
1237
+ // After a successful projection, keep the generated QMD index in sync
1238
+ // (model-free lexical pass). A QMD failure is a warning, never a
1239
+ // projection failure.
1240
+ const qmdResult = await reindexQmdVault(paths, {
1241
+ scope: "changed",
1242
+ components: ["lexical"],
1243
+ force: false,
1244
+ });
1245
+ if (!qmdResult.ok) {
1246
+ warnings.push({
1247
+ severity: "warning" as const,
1248
+ code: "qmd_index_error" as const,
1249
+ path: paths.qmd,
1250
+ message: qmdResult.errors[0]?.message ?? "QMD indexing failed",
1251
+ });
1252
+ }
1173
1253
  if (warnings.length > 0) {
1174
1254
  return `⚠️ LLM Wiki: metadata rebuilt with warnings — ${warnings
1175
1255
  .map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`)
@@ -1259,6 +1339,110 @@ export function registerWikiReindexEmbeddings(pi: ExtensionAPI, runtime?: Runtim
1259
1339
  });
1260
1340
  }
1261
1341
 
1342
+ export function registerWikiReindex(pi: ExtensionAPI): void {
1343
+ pi.registerTool({
1344
+ name: "wiki_reindex",
1345
+ label: "Wiki Reindex QMD",
1346
+ description:
1347
+ "Rebuild or repair the generated QMD index (meta/qmd) for the vault. " +
1348
+ "Lexical indexing is model-free; selecting vectors may download " +
1349
+ "approximately 2 GB of models on first use. Repair stale/error state. " +
1350
+ "Active recall still uses the legacy heuristic until Phase 3.",
1351
+ promptSnippet: "Rebuild the QMD search index",
1352
+ promptGuidelines: [
1353
+ "Use wiki_reindex to repair a stale, error, or recovering QMD index.",
1354
+ "Lexical-only reindexing never loads a model.",
1355
+ "Vector reindexing may download approximately 2 GB of models on first use.",
1356
+ ],
1357
+ parameters: Type.Object({
1358
+ scope: Type.Optional(
1359
+ Type.Union([Type.Literal("changed"), Type.Literal("all")], { default: "changed" }),
1360
+ ),
1361
+ components: Type.Optional(
1362
+ Type.Array(Type.Union([Type.Literal("lexical"), Type.Literal("vectors")]), {
1363
+ minItems: 1,
1364
+ uniqueItems: true,
1365
+ default: ["lexical", "vectors"],
1366
+ }),
1367
+ ),
1368
+ force: Type.Optional(Type.Boolean({ default: false })),
1369
+ vault: Type.Optional(
1370
+ Type.Union(
1371
+ [
1372
+ Type.Literal("active"),
1373
+ Type.Literal("personal"),
1374
+ Type.Literal("project"),
1375
+ Type.Literal("all"),
1376
+ ],
1377
+ { default: "active" },
1378
+ ),
1379
+ ),
1380
+ }),
1381
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
1382
+ const paths = getPaths(ctx.cwd);
1383
+ const vaultCheck = inspectWritableVault(paths);
1384
+ if (!vaultCheck.ok) {
1385
+ return {
1386
+ content: [
1387
+ { type: "text", text: `Wiki vault error: ${vaultCheck.diagnostics[0].message}` },
1388
+ ],
1389
+ details: {
1390
+ error: vaultCheck.diagnostics[0].code,
1391
+ diagnostics: vaultCheck.diagnostics,
1392
+ } as Record<string, unknown>,
1393
+ isError: true,
1394
+ };
1395
+ }
1396
+
1397
+ const scope = params.scope ?? "changed";
1398
+ const components = params.components ?? ["lexical", "vectors"];
1399
+ const force = params.force === true;
1400
+ const vault = params.vault ?? "active";
1401
+ const lexicalOnly = components.length === 1 && components[0] === "lexical";
1402
+
1403
+ // Warn before any vector work so the operator expects a large download.
1404
+ if (components.includes("vectors") && signal && signal.aborted) {
1405
+ return {
1406
+ content: [{ type: "text", text: "QMD reindex cancelled before it started." }],
1407
+ details: { cancelled: true } as Record<string, unknown>,
1408
+ isError: true,
1409
+ };
1410
+ }
1411
+
1412
+ const result = await reindexWiki(paths, {
1413
+ scope,
1414
+ components,
1415
+ force,
1416
+ vault,
1417
+ signal,
1418
+ });
1419
+
1420
+ const ok = result.results.every((r) => r.result.ok);
1421
+ const lines = [
1422
+ ok ? "✅ QMD indexing complete" : "⚠️ QMD indexing completed with errors",
1423
+ ...result.results.map((r) => {
1424
+ const st = r.result.status;
1425
+ return `- ${r.label} (${r.root}): state=${st.state}, documents=${st.totalDocuments}, indexed=${r.result.documents.indexed}, updated=${r.result.documents.updated}, removed=${r.result.documents.removed}, vectors=${r.result.vectors.generated}`;
1426
+ }),
1427
+ ];
1428
+ if (lexicalOnly) lines.push("Model-free lexical indexing — no model was downloaded.");
1429
+ if (components.includes("vectors")) {
1430
+ lines.push("⚠️ Vector indexing may download approximately 2 GB of models on first use.");
1431
+ }
1432
+ for (const r of result.results) {
1433
+ for (const e of r.result.errors) lines.push(`- [${r.label}] ${e.code}: ${e.message}`);
1434
+ for (const w of r.result.warnings) lines.push(`- [${r.label}] ${w.code}: ${w.message}`);
1435
+ }
1436
+
1437
+ return {
1438
+ content: [{ type: "text", text: lines.join("\n") }],
1439
+ details: { scope, components, ...result } as Record<string, unknown>,
1440
+ ...(ok ? {} : { isError: true }),
1441
+ };
1442
+ },
1443
+ });
1444
+ }
1445
+
1262
1446
  export function registerWikiLogEvent(pi: ExtensionAPI): void {
1263
1447
  pi.registerTool({
1264
1448
  name: "wiki_log_event",
@@ -28,6 +28,11 @@ export interface VaultPaths {
28
28
  dotWiki: string;
29
29
  outputs: string;
30
30
  discoveries: string;
31
+ qmd: string;
32
+ qmdCurrent: string;
33
+ qmdDocuments: string;
34
+ qmdManifest: string;
35
+ qmdSwap: string;
31
36
  }
32
37
 
33
38
  /** Detect whether a vault root uses new (.llm-wiki) or legacy (.wiki) layout. */
@@ -200,31 +205,45 @@ export function resolveVaultRoot(cwd: string): string {
200
205
 
201
206
  /** Get all vault paths for the new (.llm-wiki) layout. */
202
207
  export function getVaultPaths(root: string): VaultPaths {
208
+ const meta = join(root, ".llm-wiki", "meta");
209
+ const qmd = join(meta, "qmd");
203
210
  return {
204
211
  root,
205
212
  raw: join(root, ".llm-wiki", "raw"),
206
213
  rawSources: join(root, ".llm-wiki", "raw", "sources"),
207
214
  rawTrajectories: join(root, ".llm-wiki", "raw", "trajectories"),
208
215
  wiki: join(root, ".llm-wiki", "wiki"),
209
- meta: join(root, ".llm-wiki", "meta"),
216
+ meta,
210
217
  dotWiki: join(root, ".llm-wiki"),
211
218
  outputs: join(root, ".llm-wiki", "outputs"),
212
219
  discoveries: join(root, ".llm-wiki", ".discoveries"),
220
+ qmd,
221
+ qmdCurrent: join(qmd, "current"),
222
+ qmdDocuments: join(qmd, "documents"),
223
+ qmdManifest: join(qmd, "manifest.json"),
224
+ qmdSwap: join(qmd, "swap.json"),
213
225
  };
214
226
  }
215
227
 
216
228
  /** Get all vault paths for the legacy (.wiki) layout. */
217
229
  export function getLegacyVaultPaths(root: string): VaultPaths {
230
+ const meta = join(root, "meta");
231
+ const qmd = join(meta, "qmd");
218
232
  return {
219
233
  root,
220
234
  raw: join(root, "raw"),
221
235
  rawSources: join(root, "raw", "sources"),
222
236
  rawTrajectories: join(root, "raw", "trajectories"),
223
237
  wiki: join(root, "wiki"),
224
- meta: join(root, "meta"),
238
+ meta,
225
239
  dotWiki: join(root, ".wiki"),
226
240
  outputs: join(root, "outputs"),
227
241
  discoveries: join(root, ".discoveries"),
242
+ qmd,
243
+ qmdCurrent: join(qmd, "current"),
244
+ qmdDocuments: join(qmd, "documents"),
245
+ qmdManifest: join(qmd, "manifest.json"),
246
+ qmdSwap: join(qmd, "swap.json"),
228
247
  };
229
248
  }
230
249
 
@@ -2,13 +2,24 @@ import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import type { KnowledgeDiagnostic } from "./knowledge-document.js";
4
4
  import type { Registry } from "./metadata.js";
5
+ import {
6
+ awaitQmdIndexQueue,
7
+ type QmdGeneratedStatus,
8
+ type QmdIndexProgress,
9
+ type QmdIndexState,
10
+ type QmdReindexResult,
11
+ readQmdIndexStatus,
12
+ reindexQmdVault,
13
+ } from "./qmd-indexing.js";
14
+ import { QMD_PACKAGE_VERSION, resolveQmdModels } from "./qmd-store.js";
5
15
  import type { VaultPaths } from "./utils.js";
6
- import { readJson } from "./utils.js";
16
+ import { getPersonalWikiPaths, isPersonalVault, readJson } from "./utils.js";
7
17
  import type { KnowledgeFormat } from "./vault-format.js";
8
18
  import {
9
19
  compareCodePoint,
10
20
  discoverKnowledgeDocuments,
11
21
  inspectVaultFormat,
22
+ inspectWritableVault,
12
23
  } from "./vault-format.js";
13
24
 
14
25
  /**
@@ -29,12 +40,13 @@ export interface WikiStatusSnapshot {
29
40
  byType: Record<string, number>;
30
41
  blockingDiagnostics: KnowledgeDiagnostic[];
31
42
  lastUpdated: string;
43
+ qmd: QmdGeneratedStatus;
32
44
  }
33
45
 
34
46
  /**
35
47
  * Search the registry for matching concepts.
36
48
  *
37
- * Matches ID, semantic title, type, category, domain, tags, aliases, and recall triggers.
49
+ * Matches ID, title, type, state, status, category, domain, tags, aliases, and recall triggers.
38
50
  * Preserves unknown types as strings.
39
51
  */
40
52
  export function searchRegistry(
@@ -102,6 +114,22 @@ function matchesField(id: string, entry: Record<string, unknown>, query: string)
102
114
  )
103
115
  return true;
104
116
 
117
+ // Match state
118
+ if (
119
+ String(entry.state || "")
120
+ .toLowerCase()
121
+ .includes(query)
122
+ )
123
+ return true;
124
+
125
+ // Match status
126
+ if (
127
+ String(entry.status || "")
128
+ .toLowerCase()
129
+ .includes(query)
130
+ )
131
+ return true;
132
+
105
133
  // Match category/domain
106
134
  if (
107
135
  String(entry.category || "")
@@ -146,9 +174,10 @@ function matchesField(id: string, entry: Record<string, unknown>, query: string)
146
174
  /**
147
175
  * Get a status snapshot of the wiki.
148
176
  *
149
- * Reports resolved knowledge_format, page counts, and blocking diagnostics.
177
+ * Reports resolved knowledge_format, page counts, blocking diagnostics, and
178
+ * generated QMD index status (read without opening any QMD store).
150
179
  */
151
- export function getWikiStatus(paths: VaultPaths): WikiStatusSnapshot {
180
+ export async function getWikiStatus(paths: VaultPaths): Promise<WikiStatusSnapshot> {
152
181
  const vaultState = inspectVaultFormat(paths);
153
182
  const diagnostics = [...vaultState.diagnostics];
154
183
 
@@ -179,5 +208,132 @@ export function getWikiStatus(paths: VaultPaths): WikiStatusSnapshot {
179
208
  byType,
180
209
  blockingDiagnostics: diagnostics.filter((d) => d.severity === "error"),
181
210
  lastUpdated: registry?.last_updated || "",
211
+ qmd: await readQmdIndexStatus(paths),
212
+ };
213
+ }
214
+
215
+ // ─── Shared QMD reindex operation ─────────────────────────
216
+
217
+ export type WikiReindexVault = "active" | "personal" | "project" | "all";
218
+
219
+ export interface WikiReindexInput {
220
+ scope?: "changed" | "all";
221
+ components?: Array<"lexical" | "vectors">;
222
+ force?: boolean;
223
+ vault?: WikiReindexVault;
224
+ signal?: AbortSignal;
225
+ onProgress?: (progress: { vault: string; progress: QmdIndexProgress }) => void;
226
+ }
227
+
228
+ export interface WikiReindexResult {
229
+ vault: WikiReindexVault;
230
+ results: Array<{
231
+ root: string;
232
+ label: "active" | "personal" | "project";
233
+ result: QmdReindexResult;
234
+ }>;
235
+ }
236
+
237
+ function blockedReindexResult(
238
+ scope: "changed" | "all",
239
+ components: Array<"lexical" | "vectors">,
240
+ code: string,
241
+ message: string,
242
+ ): QmdReindexResult {
243
+ return {
244
+ ok: false,
245
+ scope,
246
+ components,
247
+ documents: { indexed: 0, updated: 0, unchanged: 0, removed: 0 },
248
+ vectors: { generated: 0, skipped: 0, errors: 0 },
249
+ elapsedMs: 0,
250
+ status: {
251
+ state: "error" as QmdIndexState,
252
+ qmdVersion: QMD_PACKAGE_VERSION,
253
+ models: resolveQmdModels(),
254
+ totalDocuments: 0,
255
+ canonicalDocuments: 0,
256
+ evidenceDocuments: 0,
257
+ needsEmbedding: 0,
258
+ hasVectorIndex: false,
259
+ repairComponents: [],
260
+ issues: [{ code, message }],
261
+ },
262
+ warnings: [],
263
+ errors: [{ code, message }],
182
264
  };
183
265
  }
266
+
267
+ /**
268
+ * Reindex QMD stores for selected vaults, sequentially. Validates each vault
269
+ * immediately before work. One vault's failure does not prevent the others.
270
+ */
271
+ export async function reindexWiki(
272
+ activePaths: VaultPaths,
273
+ input: WikiReindexInput,
274
+ ): Promise<WikiReindexResult> {
275
+ const scope = input.scope ?? "changed";
276
+ const components = input.components ?? ["lexical", "vectors"];
277
+ const force = input.force ?? false;
278
+ const vault = input.vault ?? "active";
279
+ const signal = input.signal;
280
+ const onProgress = input.onProgress;
281
+
282
+ const personalPaths = getPersonalWikiPaths();
283
+ const activeIsPersonal = isPersonalVault(activePaths);
284
+
285
+ const targets: Array<{ paths: VaultPaths; label: "active" | "personal" | "project" }> = [];
286
+ switch (vault) {
287
+ case "active":
288
+ targets.push({ paths: activePaths, label: activeIsPersonal ? "personal" : "active" });
289
+ break;
290
+ case "personal":
291
+ targets.push({ paths: personalPaths, label: "personal" });
292
+ break;
293
+ case "project":
294
+ if (!activeIsPersonal) targets.push({ paths: activePaths, label: "project" });
295
+ break;
296
+ case "all":
297
+ if (!activeIsPersonal) targets.push({ paths: activePaths, label: "project" });
298
+ targets.push({ paths: personalPaths, label: "personal" });
299
+ break;
300
+ }
301
+
302
+ const seen = new Set<string>();
303
+ const results: WikiReindexResult["results"] = [];
304
+ for (const target of targets) {
305
+ if (seen.has(target.paths.root)) continue;
306
+ seen.add(target.paths.root);
307
+
308
+ const check = inspectWritableVault(target.paths);
309
+ if (!check.ok) {
310
+ results.push({
311
+ root: target.paths.root,
312
+ label: target.label,
313
+ result: blockedReindexResult(
314
+ scope,
315
+ components,
316
+ check.diagnostics[0]?.code ?? "config_invalid_knowledge_format",
317
+ check.diagnostics[0]?.message ?? "Vault is not writable",
318
+ ),
319
+ });
320
+ continue;
321
+ }
322
+
323
+ const result = await reindexQmdVault(target.paths, {
324
+ scope,
325
+ components,
326
+ force,
327
+ signal,
328
+ onProgress: (p) => onProgress?.({ vault: target.paths.root, progress: p }),
329
+ });
330
+ results.push({ root: target.paths.root, label: target.label, result });
331
+ }
332
+
333
+ return { vault, results };
334
+ }
335
+
336
+ /** Test-only: drain/await in-process QMD reindex queue work for a vault root. */
337
+ export function awaitWikiQmdIndexQueue(root: string): Promise<unknown> {
338
+ return awaitQmdIndexQueue(root);
339
+ }
package/mcp/index.ts CHANGED
@@ -15,6 +15,7 @@ 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 { recoverQmdIndex } from "../extensions/llm-wiki/lib/qmd-indexing.js";
18
19
  import {
19
20
  loadTaskConfig,
20
21
  resolveWikilinkValidation,
@@ -25,6 +26,7 @@ import {
25
26
  bootstrapOperation,
26
27
  captureSourceOperation,
27
28
  recallOperation,
29
+ reindexOperation,
28
30
  retroOperation,
29
31
  searchOperation,
30
32
  statusOperation,
@@ -68,7 +70,7 @@ const server = new McpServer({
68
70
  // ---- wiki_bootstrap ----
69
71
  //
70
72
  // Registered first, and the only tool not gated on an existing vault: the
71
- // other five fail closed with a message naming this one, which an MCP-only
73
+ // other tools fail closed with a message naming this one, which an MCP-only
72
74
  // client could not act on while it was extension-only (issue #130).
73
75
 
74
76
  server.registerTool(
@@ -238,6 +240,65 @@ server.registerTool(
238
240
  },
239
241
  );
240
242
 
243
+ // ---- wiki_reindex ----
244
+
245
+ server.registerTool(
246
+ "wiki_reindex",
247
+ {
248
+ description:
249
+ "Rebuild or repair the generated QMD search index (meta/qmd) for the vault. " +
250
+ "Lexical indexing is model-free; selecting vectors may download approximately 2 GB " +
251
+ "of models on first use. Options: scope (changed|all), components (lexical|vectors), " +
252
+ "force, vault (active|personal|project|all).",
253
+ inputSchema: z.object({
254
+ scope: z.enum(["changed", "all"]).optional().default("changed").describe("changed or all"),
255
+ components: z
256
+ .array(z.enum(["lexical", "vectors"]))
257
+ .min(1)
258
+ .optional()
259
+ .describe("Index components (lexical|vectors)"),
260
+ force: z.boolean().optional().describe("Force full rebuild of selected components"),
261
+ vault: z
262
+ .enum(["active", "personal", "project", "all"])
263
+ .optional()
264
+ .default("active")
265
+ .describe("Which vaults to reindex"),
266
+ }),
267
+ },
268
+ async ({ scope, components, force, vault }) => {
269
+ if (!hasVault()) {
270
+ return {
271
+ content: [
272
+ {
273
+ type: "text" as const,
274
+ text: "No wiki vault found. Set WIKI_ROOT or run wiki_bootstrap first.",
275
+ },
276
+ ],
277
+ isError: true,
278
+ };
279
+ }
280
+
281
+ const paths = getPaths();
282
+ const result = await reindexOperation(paths, {
283
+ scope,
284
+ components,
285
+ force,
286
+ vault,
287
+ });
288
+
289
+ const ok = result.results.every((r) => r.result.ok);
290
+ return {
291
+ content: [
292
+ {
293
+ type: "text" as const,
294
+ text: JSON.stringify(result, null, 2),
295
+ },
296
+ ],
297
+ ...(ok ? {} : { isError: true as const }),
298
+ };
299
+ },
300
+ );
301
+
241
302
  // ---- wiki_retro ----
242
303
 
243
304
  server.registerTool(
@@ -363,6 +424,22 @@ async function main() {
363
424
  const transport = new StdioServerTransport();
364
425
  await server.connect(transport);
365
426
  console.error("🧠 LLM Wiki MCP Server running on stdio");
427
+
428
+ // Fire-and-forget QMD index recovery AFTER the transport is connected so
429
+ // clients are never blocked. A busy/live lock just logs a warning and MCP
430
+ // continues with current state untouched.
431
+ if (hasVault()) {
432
+ const paths = getPaths();
433
+ recoverQmdIndex(paths)
434
+ .then((result) => {
435
+ if (!result.ok) {
436
+ console.error(
437
+ `[llm-wiki] QMD recovery skipped: ${result.diagnostics[0]?.message ?? "locked"}`,
438
+ );
439
+ }
440
+ })
441
+ .catch((err) => console.error(`[llm-wiki] QMD recovery failed: ${(err as Error).message}`));
442
+ }
366
443
  }
367
444
 
368
445
  main().catch((err) => {