import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { join, relative } from "node:path"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "typebox"; import { bootstrapVault } from "./bootstrap.js"; import { launchEmbedPages, reindexEmbeddings, resolveEmbedder } from "./embeddings.js"; import { scheduleReindex } from "./indexing.js"; import { runIngestSynthesis } from "./ingest-worker.js"; import { createKnowledgeDocument, serializeKnowledgeDocument, writeKnowledgeDocumentFile, } from "./knowledge-document.js"; import { buildResolvedBacklinks } from "./knowledge-links.js"; import { repairLegacyKnowledgeDocuments } from "./legacy-repair.js"; import { type Registry, appendEvent, rebuildMetadata, rebuildMetadataLight } from "./metadata.js"; import type { Runtime } from "./runtime.js"; import { captureFile, captureText, captureUrl } from "./source-packet.js"; import { parseModelRef } from "./task-config.js"; import { type VaultPaths, detectVaultFormat, fmtDate, getVaultPaths, readJson, resolveVaultPaths, slugify, writeJson, } from "./utils.js"; import { assertWritableVault, compareCodePoint, discoverKnowledgeDocuments, inspectVaultFormat, inspectWritableVault, } from "./vault-format.js"; import { getWikiStatus, searchRegistry } from "./wiki-service.js"; /** * All LLM Wiki custom tools. */ function getPaths(cwd?: string): VaultPaths { return resolveVaultPaths(cwd ?? process.cwd()); } function requireVault(paths: VaultPaths): { ok: true } | { ok: false; reason: string } { if (detectVaultFormat(paths.root) === "none") { return { ok: false, reason: `No wiki found at ${paths.root}. Run wiki_bootstrap first.` }; } return { ok: true }; } type WikiToolResult = { content: { type: "text"; text: string }[]; details: Record; isError?: boolean; }; type ToolCtx = { cwd?: string; hasUI: boolean; ui?: { notify: (message: string, type?: string) => void }; }; /** * Dispatch a heavy mutating action to the background runtime and report its * result (issue #77). The agent turn is never blocked: `work` runs off-thread * and the returned one-line summary is surfaced to the user via * `runtime.report()`. Returns an immediate, non-blocking tool result. * * When no runtime is available (unit tests / degraded mode), `work` runs * synchronously and its summary is returned inline, preserving prior behavior. * Retrieval tools (search/read/recall/status) never use this — the model needs * their output inline. */ async function dispatchReported( runtime: Runtime | undefined, ctx: ToolCtx, opts: { label: string; /** Immediate, non-blocking acknowledgement shown while work runs. */ started: string; /** Off-thread work; resolves to the human-readable completion summary. */ work: () => Promise; details?: Record; }, ): Promise { if (!runtime) { const summary = await opts.work(); return { content: [{ type: "text", text: summary }], details: { background: false, ...opts.details }, }; } runtime.launchReported({ hasUI: ctx.hasUI, ui: ctx.ui }, opts.label, opts.work); return { content: [{ type: "text", text: opts.started }], details: { background: true, ...opts.details }, }; } // ─── 1. wiki_bootstrap ────────────────────────────────── export function registerWikiBootstrap(pi: ExtensionAPI): void { pi.registerTool({ name: "wiki_bootstrap", label: "Wiki Bootstrap", description: "Initialize a new LLM Wiki vault with the 4-layer architecture. " + "Creates config, templates, schema, and metadata scaffolding.", promptSnippet: "Initialize a new LLM Wiki vault", promptGuidelines: ["Use wiki_bootstrap when the user wants to start a new wiki."], parameters: Type.Object({ topic: Type.String({ description: "Main topic of the wiki" }), mode: Type.Optional(Type.String({ description: "personal or company (default: personal)" })), root: Type.Optional( Type.String({ description: "Root directory (default: current directory)" }), ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const root = params.root ?? ctx.cwd ?? process.cwd(); const mode = params.mode || "personal"; const paths = getVaultPaths(root); const result = bootstrapVault(paths, { topic: params.topic, mode }); if (!result.ok) { return { content: [{ type: "text", text: `Wiki vault error: ${result.diagnostics[0].message}` }], details: { error: result.diagnostics[0].code, diagnostics: result.diagnostics, } as Record, isError: true, }; } if (!result.projection.ok) { return { content: [ { type: "text", text: `✅ Wiki bootstrapped but projection rebuild had issues: ${result.projection.diagnostics .map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`) .join("; ")}`, }, ], details: { root, mode, topic: params.topic, diagnostics: result.projection.diagnostics, } as Record, }; } return { content: [ { type: "text", text: [ `✅ Wiki bootstrapped at \`${paths.root}\``, "**Scope:** project-local", "", "**Structure:**", "- .llm-wiki/raw/sources/ — immutable source packets", "- .llm-wiki/wiki/ — editable knowledge pages", "- .llm-wiki/meta/ — auto-generated metadata", "- .llm-wiki/ — config and templates", "- .llm-wiki/WIKI_SCHEMA.md — operating rules", "", "Next: Use wiki_capture_source to add your first source.", ].join("\n"), }, ], details: { root, mode, topic: params.topic } as Record, }; }, }); } // ─── 2. wiki_capture_source ───────────────────────────── export function registerWikiCaptureSource(pi: ExtensionAPI, runtime?: Runtime): void { pi.registerTool({ name: "wiki_capture_source", label: "Wiki Capture Source", description: "Capture a URL, local file, or pasted text into an immutable source packet and skeleton source page.", promptSnippet: "Capture a source into the wiki as an immutable packet", promptGuidelines: [ "Use wiki_capture_source when the user provides a URL, file, or text to capture.", "After capture, read the extracted text and update the skeleton source page.", ], parameters: Type.Object({ url: Type.Optional(Type.String({ description: "URL to capture" })), file_path: Type.Optional(Type.String({ description: "Local file path to capture" })), text: Type.Optional(Type.String({ description: "Pasted text content" })), title: Type.Optional(Type.String({ description: "Title for pasted text" })), }), async execute(_toolCallId, params, signal, _onUpdate, ctx) { const paths = getPaths(ctx.cwd); const vaultCheck = inspectWritableVault(paths); if (!vaultCheck.ok) { return { content: [ { type: "text", text: `Wiki vault error: ${vaultCheck.diagnostics[0].message}` }, ], details: { error: vaultCheck.diagnostics[0].code, diagnostics: vaultCheck.diagnostics, } as Record, isError: true, }; } let result: { sourceId: string; packetPath: string; sourcePagePath: string; extracted: string; }; if (params.url) { result = await captureUrl(pi, paths, params.url, signal); } else if (params.file_path) { result = await captureFile(pi, paths, params.file_path, signal); } else if (params.text) { result = captureText(paths, params.text, params.title); } else { return { content: [{ type: "text", text: "❌ Provide one of: url, file_path, or text" }], details: { error: "missing_source" } as Record, isError: true, }; } if (runtime) { scheduleReindex(runtime, { hasUI: ctx.hasUI, ui: ctx.ui }, paths); } else { rebuildMetadataLight(paths); } return { content: [ { type: "text", text: [ `✅ Captured source **${result.sourceId}**`, "", `- Packet: \`${result.packetPath}\``, `- Skeleton page: \`${result.sourcePagePath}\``, "", "**Next:** Read the extracted text and update the source page with a proper summary, entities, and concepts.", ].join("\n"), }, ], details: { sourceId: result.sourceId, packetPath: result.packetPath, sourcePagePath: result.sourcePagePath, extractedPreview: result.extracted.slice(0, 300), } as Record, }; }, }); } // ─── 3. wiki_ingest ───────────────────────────────────── export function registerWikiIngest(pi: ExtensionAPI, runtime?: Runtime): void { pi.registerTool({ name: "wiki_ingest", label: "Wiki Ingest", description: "Process uningested source packets. By default synthesis runs in the background (non-blocking) on the configured task model; pass background=false to return extracted content for the main agent to synthesize itself.", promptSnippet: "Ingest source packets (background synthesis by default)", promptGuidelines: [ "Use wiki_ingest when the user wants to process captured sources.", "By default ingestion runs in the BACKGROUND — you'll get a notification, not extracted content. Do NOT synthesize those sources yourself.", "If the tool returns extracted content (background unavailable, or background=false), then read each source's extracted.md, update its source page, create entity/concept pages, and cross-reference.", "The extension auto-updates metadata — you do NOT need to edit meta/ files.", ], parameters: Type.Object({ source_id: Type.Optional( Type.String({ description: "Specific source ID to ingest. Leave empty for all new." }), ), batch_size: Type.Optional( Type.Number({ description: "Max sources to process (default: 3, max: 5)", default: 3 }), ), background: Type.Optional( Type.Boolean({ description: "Synthesize in the background without blocking (default: true). Set false to return extracted content for the main agent to synthesize.", default: true, }), ), model: Type.Optional( Type.String({ description: "Per-call model override as 'provider/id' (e.g. anthropic/claude-haiku). Overrides the configured wiki taskModel for this call; defaults to the configured/session model.", }), ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const paths = getPaths(ctx.cwd); const vaultCheck = inspectWritableVault(paths); if (!vaultCheck.ok) { return { content: [ { type: "text", text: `Wiki vault error: ${vaultCheck.diagnostics[0].message}` }, ], details: { error: vaultCheck.diagnostics[0].code, diagnostics: vaultCheck.diagnostics, } as Record, isError: true, }; } const batchSize = Math.min(params.batch_size ?? 3, 5); if (!existsSync(paths.rawSources)) { return { content: [ { type: "text", text: "No raw/sources/ directory. Capture sources first with wiki_capture_source.", }, ], details: { error: "no_sources" } as Record, }; } const packets = readdirSync(paths.rawSources) .filter((d) => d.startsWith("SRC-")) .sort(); const registry = readJson(join(paths.meta, "registry.json"), { version: "1.0", last_updated: "", pages: {}, }); const ingested = new Set(); for (const [id, entry] of Object.entries(registry.pages)) { if (entry.type === "source" && (entry as Record).status !== "skeleton") { const base = id.split("/").pop(); if (base) ingested.add(base); } } let toProcess = packets.filter((p) => !ingested.has(p)); if (params.source_id) { if (!toProcess.includes(params.source_id) && !packets.includes(params.source_id)) { return { content: [ { type: "text", text: `Source ${params.source_id} not found or already ingested.` }, ], details: { source_id: params.source_id, status: "not_found" } as Record< string, unknown >, }; } toProcess = [params.source_id]; } const batch = toProcess.slice(0, batchSize); if (batch.length === 0) { return { content: [ { type: "text", text: "✅ All sources ingested. Use wiki_capture_source to add new ones.", }, ], details: { ingested: ingested.size, total: packets.length } as Record, }; } const sources = batch.map((id) => { const extractedPath = join(paths.rawSources, id, "extracted.md"); const manifestPath = join(paths.rawSources, id, "manifest.json"); const extracted = existsSync(extractedPath) ? readFileSync(extractedPath, "utf-8") : ""; const manifest = readJson>(manifestPath, {}); // Vault-relative path used in tool messages so the read tool can open // the file from the vault root (fix #101: agent previously got // "raw/sources/..." and failed on new-layout vaults). const relRaw = relative(paths.root, paths.rawSources); return { id, extracted, manifest, relRaw }; }); // ── Background synthesis (issue #65) ────────────────── // Default path: dispatch each source to a background sub-agent so the // main agent is not blocked. Falls back to the synchronous return below // when no runtime/model is available (resolveModel ok:false). const wantBackground = params.background !== false; if (wantBackground && runtime) { runtime.ensureConfig(ctx.cwd); // Per-call model override (issue #69): 'provider/id' beats the // configured taskModel; a malformed/unknown ref degrades to the // configured/session model inside resolveModel. const override = params.model ? parseModelRef(params.model) : undefined; const resolved = await runtime.resolveModel(ctx, override); if (resolved.ok) { const launchCtx = { hasUI: ctx.hasUI, ui: ctx.ui }; for (const s of sources) { runtime.launchTask(launchCtx, `ingest:${s.id}`, async () => { const committed = await runIngestSynthesis({ model: resolved.model as Parameters[0]["model"], apiKey: resolved.apiKey, headers: resolved.headers, paths, sourceId: s.id, manifest: s.manifest, extracted: s.extracted, synthesisLanguage: runtime.config.synthesisLanguage, }); if (committed) { // Background semantic embeddings (#66): embed the pages this // ingest just wrote, off-thread. No-op when unconfigured. const pageIds = [ `sources/${committed.sourceId}`, ...committed.entitiesCreated.map((e) => `entities/${e}`), ...committed.entitiesLinked.map((e) => `entities/${e}`), ...committed.conceptsCreated.map((c) => `concepts/${c}`), ...committed.conceptsLinked.map((c) => `concepts/${c}`), ]; launchEmbedPages(runtime, launchCtx, paths, pageIds, `embed:ingest:${s.id}`); } const summary = committed ? `LLM Wiki: ingested ${s.id} → ${committed.entitiesCreated.length} entit${committed.entitiesCreated.length === 1 ? "y" : "ies"}, ${committed.conceptsCreated.length} concept${committed.conceptsCreated.length === 1 ? "" : "s"}` : `LLM Wiki: ${s.id} produced no synthesis`; if (ctx.hasUI) { ctx.ui.notify(summary, committed ? "info" : "warning"); } // Persistent, user-visible completion report (issue #77) in // addition to the transient toast above. Notices-gated. runtime.report(committed ? `✅ ${summary}` : `⚠️ ${summary}`); }); } return { content: [ { type: "text", text: [ `🔄 **Ingesting ${sources.length} source(s) in the background** (${toProcess.length - batch.length} remaining).`, "", ...sources.map((s) => `- **${s.id}**: ${s.manifest.title || s.id}`), "", "Synthesis runs on the configured task model without blocking. You'll be notified as each source completes — do NOT synthesize these yourself.", ].join("\n"), }, ], details: { background: true, dispatched: sources.map((s) => s.id), remaining: toProcess.length - batch.length, } as Record, }; } } return { content: [ { type: "text", text: [ `📥 **${batch.length} source(s) ready** (${toProcess.length - batch.length} remaining)`, "", ...sources.map((s) => [ `- **${s.id}**: ${s.manifest.title || s.id}`, ` - Extracted: ${s.extracted.length} chars`, ` - Read: \`${s.relRaw}/${s.id}/extracted.md\``, ].join("\n"), ), "", "**Next steps for each source:**", "1. Read extracted.md", "2. Update the skeleton source page in wiki/sources/", "3. Create/update entity pages in wiki/entities/", "4. Create/update concept pages in wiki/concepts/", "5. Add [[wikilinks]] cross-references", "6. Flag contradictions", "", "The extension will auto-update metadata when you're done.", ].join("\n"), }, ], details: { batch: sources.map((s) => s.id), remaining: toProcess.length - batch.length, } as Record, }; }, }); } // ─── 4. wiki_ensure_page ──────────────────────────────── export function registerWikiEnsurePage(pi: ExtensionAPI, runtime?: Runtime): void { pi.registerTool({ name: "wiki_ensure_page", label: "Wiki Ensure Page", description: "Resolve or safely create a canonical wiki page. Returns the page path.", promptSnippet: "Create a canonical wiki page if it doesn't exist", promptGuidelines: [ "Use wiki_ensure_page before creating pages to avoid duplicates.", "Search existing pages first with wiki_search.", ], parameters: Type.Object({ type: Type.String({ description: "Page type: entity | concept | synthesis | analysis | requirement | skill | case", }), title: Type.String({ description: "Page title" }), content: Type.Optional( Type.String({ description: "Optional initial content (otherwise uses template)" }), ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const paths = getPaths(ctx.cwd); const vaultCheck = inspectWritableVault(paths); if (!vaultCheck.ok) { return { content: [ { type: "text", text: `Wiki vault error: ${vaultCheck.diagnostics[0].message}` }, ], details: { error: vaultCheck.diagnostics[0].code, diagnostics: vaultCheck.diagnostics, } as Record, isError: true, }; } const type = params.type as | "entity" | "concept" | "synthesis" | "analysis" | "requirement" | "skill" | "case"; const slug = slugify(params.title); const folderMap: Record = { entity: "entities", concept: "concepts", synthesis: "syntheses", analysis: "analyses", requirement: "requirements", skill: "skills", case: "cases", }; const folder = folderMap[type] || "concepts"; const pagePath = join(paths.wiki, folder, `${slug}.md`); if (existsSync(pagePath)) { return { content: [{ type: "text", text: `✅ Page already exists: \`${pagePath}\`` }], details: { path: pagePath, created: false } as Record, }; } const today = fmtDate(); const body = params.content ?? buildPageBody(type, params.title); const doc = createKnowledgeDocument( `${folder}/${slug}.md`, { type, title: params.title, created: today, updated: today, }, body, ); mkdirSync(join(paths.wiki, folder), { recursive: true }); writeKnowledgeDocumentFile(pagePath, doc); appendEvent(paths, { kind: "ensure_page", page_type: type, title: params.title, path: `${folder}/${slug}`, }); // Register the new page so retrieval + embeddings can see it. When a // background runtime is available, the rebuild + embeddings run off the // tool's critical path; otherwise fall back to a synchronous rebuild. if (runtime) { scheduleReindex(runtime, { hasUI: ctx.hasUI, ui: ctx.ui }, paths); } else { rebuildMetadataLight(paths); } return { content: [{ type: "text", text: `✅ Created ${type} page: \`${pagePath}\`` }], details: { path: pagePath, created: true } as Record, }; }, }); } function buildPageBody(type: string, title: string): string { if (type === "entity") { return `# ${title} One-line description. ## Overview [Key facts] ## Links - [related-page](/concepts/related-page.md)`; } if (type === "concept") { return `# ${title} One-line definition. ## Definition [Clear explanation] ## Links - [related-page](/concepts/related-page.md)`; } if (type === "synthesis") { return `# ${title} Cross-cutting analysis. ## Question [What drove this?] ## Links - [related-page](/concepts/related-page.md)`; } if (type === "analysis") { return `# ${title} Durable answer from a query. ## Question [Original question] ## Links - [related-page](/concepts/related-page.md)`; } if (type === "skill") { return `# ${title} _One-line summary of the reusable pattern this skill captures._ ## When to Use [Trigger conditions — when this pattern applies] ## Procedure 1. [Step 1] 2. [Step 2] ## Pitfalls - [Known failure mode or caveat] ## Distilled From _Trajectories this skill was generalized from._ - [trajectories/TRJ-...](/trajectories/TRJ-....md)`; } if (type === "case") { return `# ${title} _One-line summary of the specific task this case records._ ## Task [What was requested] ## Approach [How the agent solved it — key steps and decisions] ## Outcome [Result, and anything worth reusing or avoiding next time] ## Trajectory - [trajectories/TRJ-...](/trajectories/TRJ-....md) — captured tool-call run`; } if (type === "requirement") { return `# ${title} ## Description [Clear description of what this requirement entails] ## Acceptance Criteria - [ ] [Criterion 1] - [ ] [Criterion 2] ## Dependencies _Pages this requirement depends on._ ## Implementation Notes [Optional notes] ## Sources - [sources/SRC-...](/sources/SRC-....md) — original concept capture`; } return `# ${title} [Description to be filled] ## Links - [related-page](/concepts/related-page.md)`; } // ─── 5. wiki_search ───────────────────────────────────── export function registerWikiSearch(pi: ExtensionAPI): void { pi.registerTool({ name: "wiki_search", label: "Wiki Search", description: "Search the wiki registry for pages matching a query.", promptSnippet: "Search the wiki registry for pages", promptGuidelines: ["Use wiki_search to find existing pages before creating duplicates."], parameters: Type.Object({ query: Type.String({ description: "Search term" }), type: Type.Optional(Type.String({ description: "Filter by page type" })), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const paths = getPaths(ctx.cwd); const result = searchRegistry(paths, params.query, params.type); if (result.matches.length === 0) { return { content: [{ type: "text", text: `No pages found for "${params.query}"` }], details: { query: params.query, matches: [], diagnostics: result.diagnostics } as Record< string, unknown >, }; } return { content: [ { type: "text", text: [ `🔍 **${result.matches.length} result(s)** for "${params.query}":`, "", ...result.matches.map((m) => `- [[${m.id}]] — *${m.type}* — ${m.title}`), ].join("\n"), }, ], details: { query: params.query, matches: result.matches, diagnostics: result.diagnostics, } as Record, }; }, }); } // ─── 6. wiki_lint ─────────────────────────────────────── export function registerWikiLint(pi: ExtensionAPI, runtime?: Runtime): void { pi.registerTool({ name: "wiki_lint", label: "Wiki Lint", description: "Health check the wiki. Scans for orphans, missing pages, contradictions, gaps. Optionally auto-fixes.", promptSnippet: "Lint the wiki for health issues", promptGuidelines: [ "Use wiki_lint when the user asks to check wiki health.", "Contradictions always need human review.", ], parameters: Type.Object({ auto_fix: Type.Optional( Type.Boolean({ description: "Auto-fix orphans and missing pages", default: false }), ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const paths = getPaths(ctx.cwd); const vaultCheck = inspectWritableVault(paths); if (!vaultCheck.ok) { return { content: [ { type: "text", text: `Wiki vault error: ${vaultCheck.diagnostics[0].message}` }, ], details: { error: vaultCheck.diagnostics[0].code, diagnostics: vaultCheck.diagnostics, } as Record, isError: true, }; } // Full-vault scan (+ optional auto-fix writes + reindex) is O(pages): // run it in the background and report the health summary (issue #77). return dispatchReported(runtime, ctx as ToolCtx, { label: `lint:${paths.root}`, started: "\u{1F9F9} LLM Wiki: lint started in the background — the health report will be posted with your next message.", work: async () => runWikiLint(paths, params.auto_fix === true), }); }, }); } /** * Run the wiki health scan (issue #77 extracted it from the tool body so it can * run off-thread via `dispatchReported`). Returns the human-readable summary. */ function runWikiLint(paths: VaultPaths, autoFix: boolean): string { assertWritableVault(paths); let repair: ReturnType | undefined; if (autoFix) { let projection = rebuildMetadata(paths); repair = !projection.ok ? repairLegacyKnowledgeDocuments(paths) : undefined; if (repair?.repaired) projection = rebuildMetadata(paths); if (!projection.ok) { return [ "# Wiki Lint Report", "", repair?.repaired ? `Legacy pages repaired: ${repair.repaired}` : "", repair?.manifestPath ? `Repair manifest: ${repair.manifestPath}` : "", "Projection-blocking diagnostics:", ...projection.diagnostics.map( (diagnostic) => `- ${diagnostic.code}: ${diagnostic.path}: ${diagnostic.message}`, ), ] .filter(Boolean) .join("\n"); } } else { const vault = inspectVaultFormat(paths); const audit = discoverKnowledgeDocuments(paths); const diagnostics = [...vault.diagnostics, ...audit.diagnostics]; if (vault.blocking || audit.blocking) { return [ "# Wiki Lint Report", "", "Projection-blocking diagnostics:", ...diagnostics.map( (diagnostic) => `- ${diagnostic.code}: ${diagnostic.path}: ${diagnostic.message}`, ), ].join("\n"); } } const discovery = discoverKnowledgeDocuments(paths); const pages = discovery.documents; const knownIds = new Set(pages.map((page) => page.id)); const inbound = Object.fromEntries(pages.map((page) => [page.id, 0])); const gapSources = new Map>(); const findings: string[] = []; let missingPages = 0; let contradictions = 0; for (const page of pages) { const resolved = buildResolvedBacklinks(page.id, page.body, knownIds); for (const target of resolved.targets) inbound[target]++; for (const unresolved of resolved.unresolved) { const sources = gapSources.get(unresolved.target) ?? new Set(); sources.add(page.id); gapSources.set(unresolved.target, sources); missingPages++; findings.push(`Missing page: ${unresolved.target} (in ${page.id})`); } } let orphans = 0; for (const page of pages) { if (inbound[page.id] === 0) { orphans++; findings.push(`Orphan: ${page.id} has no inbound links`); } if (page.body.includes("⚠️ **Contradiction")) { contradictions++; findings.push(`Contradiction flagged in ${page.id}`); } } const gaps = [...gapSources.entries()] .map(([topic, sources]) => ({ topic, mentionedBy: [...sources].sort(compareCodePoint) })) .sort((left, right) => compareCodePoint(left.topic, right.topic)); let fixesApplied = 0; if (autoFix) { for (const gap of gaps) { if (gap.mentionedBy.length < 2) continue; const parts = gap.topic.split("/"); const name = parts.length === 1 ? parts[0] : parts.length === 2 && parts[0] === "concepts" ? parts[1] : ""; if (!name || slugify(name) !== name) continue; const pagePath = join(paths.wiki, "concepts", `${name}.md`); mkdirSync(join(paths.wiki, "concepts"), { recursive: true }); const document = createKnowledgeDocument( `concepts/${name}.md`, { type: "concept", title: name.replace(/-/g, " "), created: fmtDate(), updated: fmtDate(), status: "stub", }, `_Stub auto-created by lint. Expand with content from: ${gap.mentionedBy .map((source) => `[${source}](/${source}.md)`) .join(", ")}_`, ); try { writeFileSync(pagePath, serializeKnowledgeDocument(document), { encoding: "utf8", flag: "wx", }); fixesApplied++; } catch (error: unknown) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } } } const reportLines = [ "# Wiki Lint Report", `Generated: ${fmtDate()}`, "", "## Summary", `- Total pages: ${pages.length}`, `- Orphans: ${orphans}`, `- Missing pages: ${missingPages}`, `- Contradictions: ${contradictions}`, autoFix ? `- Missing-page fixes applied: ${fixesApplied}` : "", repair?.repaired ? `- Legacy pages repaired: ${repair.repaired}` : "", repair?.manifestPath ? `- Repair manifest: ${repair.manifestPath}` : "", "", "## Findings", findings.length ? findings.map((finding) => `- ${finding}`).join("\n") : "✅ No issues found!", "", ].filter(Boolean); const reportPath = autoFix ? join(paths.outputs, `lint-${fmtDate()}.md`) : undefined; // The gap snapshot is generated discovery metadata consumed by wiki_status: // persist it on every successful lint so status never reports a stale count. // Corrective actions below (report, event, meta rebuild) stay autoFix-only. writeJson(join(paths.discoveries, "gaps.json"), { gaps, generated: new Date().toISOString(), }); if (autoFix && reportPath) { mkdirSync(paths.outputs, { recursive: true }); writeFileSync(reportPath, `${reportLines.join("\n")}\n`, "utf8"); appendEvent(paths, { kind: "lint", orphans, missing_pages: missingPages, contradictions, auto_fix: true, legacy_pages_repaired: repair?.repaired ?? 0, }); rebuildMetadataLight(paths); } return [ "🧹 **LLM Wiki lint complete**", "", `- Pages: ${pages.length}`, `- Orphans: ${orphans}`, `- Missing: ${missingPages}`, `- Contradictions: ${contradictions}`, autoFix ? `- Missing-page fixes: ${fixesApplied}` : "", repair?.repaired ? `- Legacy pages repaired: ${repair.repaired}` : "", "", reportPath ? `📄 Report: \`${reportPath}\`` : "", repair?.manifestPath ? `🛟 Repair manifest: \`${repair.manifestPath}\`` : "", gaps.length ? `💡 ${gaps.length} knowledge gap(s) tracked` : "", ] .filter(Boolean) .join("\n"); } // ─── 7. wiki_status ───────────────────────────────────── export function registerWikiStatus(pi: ExtensionAPI): void { pi.registerTool({ name: "wiki_status", label: "Wiki Status", description: "Report wiki health and stats instantly from generated registry.", promptSnippet: "Report wiki health and stats", promptGuidelines: ["Use wiki_status for a quick overview."], parameters: Type.Object({}), async execute(_toolCallId, _params, _signal, _onUpdate, ctx) { const paths = getPaths(ctx.cwd); const vaultCheck = requireVault(paths); if (!vaultCheck.ok) { return { content: [{ type: "text", text: vaultCheck.reason }], details: { error: vaultCheck.reason } as Record, isError: true, }; } const status = getWikiStatus(paths); const config = readJson>(join(paths.dotWiki, "config.json"), {}); const backlinks = readJson>(join(paths.meta, "backlinks.json"), {}); const orphanCount = Object.entries(backlinks).filter( ([, inbound]) => inbound.length === 0, ).length; const gaps = readJson<{ gaps?: unknown[] }>(join(paths.discoveries, "gaps.json"), { gaps: [], }); const health = status.totalPages === 0 ? "🔴 Empty" : orphanCount > 5 ? "⚠️ Warning" : "✅ Good"; const diagLines = status.blockingDiagnostics.length > 0 ? [ "", "⚠️ Blocking diagnostics:", ...status.blockingDiagnostics.map((d) => ` - ${d.code}: ${d.message}`), ] : []; const lines = [ "📊 LLM Wiki Status", "══════════════════", `Topic: ${config.topic || "Unknown"}`, `Mode: ${config.mode || "personal"}`, `Knowledge format: ${status.knowledgeFormat}`, `Pages: ${status.totalPages}`, ...Object.entries(status.byType).map(([t, c]) => ` - ${t}s: ${c}`), `Orphans: ${orphanCount}`, `Gaps: ${gaps.gaps?.length || 0}`, `Health: ${health}`, `Last updated: ${status.lastUpdated || "Never"}`, ...diagLines, ]; return { content: [{ type: "text", text: lines.join("\n") }], details: { topic: config.topic, mode: config.mode, knowledgeFormat: status.knowledgeFormat, totalPages: status.totalPages, byType: status.byType, orphans: orphanCount, gaps: gaps.gaps?.length || 0, health, blockingDiagnostics: status.blockingDiagnostics, } as Record, }; }, }); } // ─── 8. wiki_rebuild_meta ─────────────────────────────── export function registerWikiRebuildMeta(pi: ExtensionAPI, runtime?: Runtime): void { pi.registerTool({ name: "wiki_rebuild_meta", label: "Wiki Rebuild Meta", description: "Force a full metadata rebuild (registry, backlinks, index, log).", promptSnippet: "Rebuild all wiki metadata", promptGuidelines: ["Use wiki_rebuild_meta if metadata seems out of sync."], parameters: Type.Object({}), async execute(_toolCallId, _params, _signal, _onUpdate, ctx) { const paths = getPaths(ctx.cwd); const vaultCheck = inspectWritableVault(paths); if (!vaultCheck.ok) { return { content: [ { type: "text", text: `Wiki vault error: ${vaultCheck.diagnostics[0].message}` }, ], details: { error: vaultCheck.diagnostics[0].code, diagnostics: vaultCheck.diagnostics, } as Record, isError: true, }; } // Heavy O(pages) rebuild — dispatch off the agent's critical path and // report on completion (issue #77). return dispatchReported(runtime, ctx as ToolCtx, { label: `rebuild_meta:${paths.root}`, started: "\u{1F9E0} LLM Wiki: metadata rebuild started in the background — the result will be reported with your next message.", work: async () => { const result = rebuildMetadata(paths); // No rebuild_meta event — rebuild is a projection, not an authoritative mutation if (!result.ok) { return `⚠️ LLM Wiki: rebuild had issues — ${result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("; ")}`; } const warnings = result.diagnostics.filter( (diagnostic) => diagnostic.severity === "warning", ); if (warnings.length > 0) { return `⚠️ LLM Wiki: metadata rebuilt with warnings — ${warnings .map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`) .join("; ")}`; } const registry = readJson(join(paths.meta, "registry.json"), { version: "1.0", last_updated: "", pages: {}, }); return `✅ LLM Wiki: metadata rebuilt — ${Object.keys(registry.pages).length} pages indexed.`; }, }); }, }); } // ─── 9. wiki_log_event ────────────────────────────────── export function registerWikiReindexEmbeddings(pi: ExtensionAPI, runtime?: Runtime): void { pi.registerTool({ name: "wiki_reindex_embeddings", label: "Wiki Reindex Embeddings", description: "Backfill / refresh semantic embeddings for the vault. Embeds pages that " + "are new or stale (content changed); pass force to re-embed everything. " + "No-op when no embedding provider is configured.", promptSnippet: "Backfill semantic embeddings for the wiki", promptGuidelines: [ "Use wiki_reindex_embeddings to embed an existing vault or refresh stale embeddings.", "Embeddings are optional: this no-ops cleanly when no embedding provider is configured.", ], parameters: Type.Object({ force: Type.Optional( Type.Boolean({ description: "Re-embed every page, ignoring staleness (default: false)" }), ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const paths = getPaths(ctx.cwd); const vaultCheck = inspectWritableVault(paths); if (!vaultCheck.ok) { return { content: [ { type: "text", text: `Wiki vault error: ${vaultCheck.diagnostics[0].message}` }, ], details: { error: vaultCheck.diagnostics[0].code, diagnostics: vaultCheck.diagnostics, } as Record, isError: true, }; } if (runtime) runtime.ensureConfig(ctx.cwd ?? paths.root); const embedder = runtime ? resolveEmbedder(runtime.config) : undefined; if (!embedder) { return { content: [ { type: "text", text: 'ℹ️ No embedding provider configured — semantic embeddings are disabled. Set `llm-wiki.embeddingProvider` (e.g. "openai") in settings to enable.', }, ], details: { enabled: false } as Record, }; } // Embedding is network-bound and O(pages) — run it in the background and // report the stats on completion (issue #77). return dispatchReported(runtime, ctx as ToolCtx, { label: `reindex_embeddings:${paths.root}`, started: `\u{1F9E0} LLM Wiki: embedding reindex started in the background (${embedder.model}) — stats will be reported with your next message.`, details: { enabled: true, model: embedder.model }, work: async () => { const stats = await reindexEmbeddings(paths, embedder, { force: params.force === true }); appendEvent(paths, { kind: "reindex_embeddings", embedded: stats.embedded, skipped: stats.skipped, pruned: stats.pruned, model: embedder.model, }); return `✅ LLM Wiki: embeddings reindexed (${embedder.model}) — ${stats.embedded} embedded, ${stats.skipped} fresh, ${stats.pruned} pruned.`; }, }); }, }); } export function registerWikiLogEvent(pi: ExtensionAPI): void { pi.registerTool({ name: "wiki_log_event", label: "Wiki Log Event", description: "Append a structured event to meta/events.jsonl and regenerate meta/log.md.", promptSnippet: "Log an event to the wiki activity log", promptGuidelines: ["Use wiki_log_event to record significant actions manually."], parameters: Type.Object({ kind: Type.String({ description: "Event kind (e.g., ingest, query, decision)" }), details: Type.Optional(Type.Object({}, { description: "Additional event fields" })), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const paths = getPaths(ctx.cwd); const vaultCheck = inspectWritableVault(paths); if (!vaultCheck.ok) { return { content: [ { type: "text", text: `Wiki vault error: ${vaultCheck.diagnostics[0].message}` }, ], details: { error: vaultCheck.diagnostics[0].code, diagnostics: vaultCheck.diagnostics, } as Record, isError: true, }; } const kind = typeof params.kind === "string" ? params.kind.trim() : ""; if (!kind) { return { content: [{ type: "text", text: "Event kind must be a non-empty string" }], details: { error: "event_missing_kind" } as Record, isError: true, }; } const details = params.details ?? {}; if (Object.hasOwn(details, "kind") || Object.hasOwn(details, "timestamp")) { return { content: [{ type: "text", text: "Event details cannot override kind or timestamp" }], details: { error: "event_reserved_field" } as Record, isError: true, }; } appendEvent(paths, { kind, ...details }); // Regenerate projections rebuildMetadata(paths); return { content: [{ type: "text", text: `✅ Event logged: ${kind}` }], details: { kind } as Record, }; }, }); } // ─── 10. wiki_watch ───────────────────────────────────── export function registerWikiWatch(pi: ExtensionAPI): void { pi.registerTool({ name: "wiki_watch", label: "Wiki Watch", description: "Print a ready-to-paste crontab line for scheduling automatic wiki updates (discover → ingest → lint). Does NOT schedule anything itself — it returns the command for the user to install.", promptSnippet: "Schedule auto-updates for the wiki", promptGuidelines: [ "Use wiki_watch when the user wants the wiki to stay current automatically.", "wiki_watch only PRINTS a cron line — surface the output to the user verbatim so they can install it. Do not claim the schedule is active.", ], parameters: Type.Object({ interval: Type.String({ description: "daily, weekly, hourly, or stop" }), }), async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { if (params.interval === "stop") { return { content: [ { type: "text", text: [ "🛑 To stop wiki auto-updates, remove the cron line you installed earlier:", "", "```bash", "crontab -e # then delete the line tagged '# llm-wiki-autoupdate'", "```", "", "Or list current jobs to confirm:", "", "```bash", "crontab -l | grep llm-wiki-autoupdate", "```", ].join("\n"), }, ], details: { action: "stop_instructions" } as Record, }; } // 5-field POSIX crontab expressions (min hour dom month dow). const intervals: Record = { daily: { cron: "0 8 * * *", label: "Daily at 8:00 AM" }, weekly: { cron: "0 9 * * 1", label: "Weekly on Monday at 9:00 AM" }, hourly: { cron: "0 * * * *", label: "Every hour" }, }; const config = intervals[params.interval]; if (!config) { return { content: [ { type: "text", text: `❌ Unknown interval: "${params.interval}". Use: daily, weekly, hourly, or stop.`, }, ], details: { error: "bad_interval" } as Record, isError: true, }; } // Robustness for global crontab environments: // * `/bin/bash -lc` runs a LOGIN shell that sources /etc/profile + // ~/.profile / ~/.bash_profile, so npm-global / bun / nvm PATH // additions are imported — cron's default PATH is only // `/usr/bin:/bin` and would not find `pi`. // * `mkdir -p` makes the log dir self-healing for users with only // a project vault (no `~/.llm-wiki/` yet). // * All `$HOME` references are double-quoted to survive paths with spaces. // * `# llm-wiki-autoupdate` tags the line so the user can find and // remove it via `crontab -e` later (see `interval=stop`). const cronLine = `${config.cron} /bin/bash -lc 'mkdir -p "$HOME/.llm-wiki" && pi -p "/wiki-run" >> "$HOME/.llm-wiki/cron.log" 2>&1' # llm-wiki-autoupdate`; return { content: [ { type: "text", text: [ `⏰ To set up ${config.label} wiki updates, add this line to your crontab.`, "**This tool only prints the line — it does not install it.**", "", "```bash", "crontab -e", "```", "", "Then append:", "", "```cron", cronLine, "```", "", `The line uses \`/bin/bash -lc\` so your shell profile (and the \`pi\` binary on npm-global / bun PATH) is loaded. Output goes to \`~/.llm-wiki/cron.log\`. If your system has no \`/bin/bash\`, replace with \`/bin/sh -c\` and ensure \`pi\` is in cron's PATH yourself.`, ].join("\n"), }, ], details: { interval: params.interval, cronSchedule: config.cron, label: config.label, cronLine, installed: false, } as Record, }; }, }); }