/** * MCP operation adapters over shared wiki services. * * Each operation is a thin, testable wrapper around the same services * used by Pi tools. No operation parses YAML, scans files, scores * registry entries, or builds page strings itself. */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { bootstrapVault } from "../extensions/llm-wiki/lib/bootstrap.js"; import { type ProjectionResult, rebuildMetadata } from "../extensions/llm-wiki/lib/metadata.js"; import { type RecallResult, searchWikiLayered } from "../extensions/llm-wiki/lib/recall.js"; import { saveInsight } from "../extensions/llm-wiki/lib/retro.js"; import { captureFile, captureText, captureUrl } from "../extensions/llm-wiki/lib/source-packet.js"; import type { VaultPaths } from "../extensions/llm-wiki/lib/utils.js"; import { VaultWriteError, inspectVaultFormat, inspectWritableVault, } from "../extensions/llm-wiki/lib/vault-format.js"; import { getWikiStatus, searchRegistry } from "../extensions/llm-wiki/lib/wiki-service.js"; function projectionOutcome( projection: ProjectionResult, ): { ok: true } | { ok: false; diagnostics: Array<{ code: string; message: string }> } { return projection.ok ? { ok: true } : { ok: false, diagnostics: projection.diagnostics.map(({ code, message }) => ({ code, message })), }; } /** * Shared bootstrap operation: create (or update) the vault at `paths`. * * This is the one operation that must work when no vault exists — every other * one fails closed naming it. `bootstrapVault` is pure Node (`node:fs`, * `node:path` and sibling lib modules), so it needs no model and no * credentials, which is what makes it fit the MCP surface. * * A failed projection rebuild is reported as diagnostics alongside `ok: true`: * the vault has been written to disk by then, and `wiki_lint` is the repair * path, so failing the call outright would misreport what happened. */ export async function bootstrapOperation( paths: VaultPaths, input: { topic: string; mode?: string }, ): Promise< | { ok: true; created: boolean; diagnostics: Array<{ code: string; message: string }> } | { ok: false; diagnostics: Array<{ code: string; message: string }> } > { const result = bootstrapVault(paths, { topic: input.topic, mode: input.mode ?? "personal" }); if (!result.ok) { return { ok: false, diagnostics: result.diagnostics.map(({ code, message }) => ({ code, message })), }; } const projection = projectionOutcome(result.projection); return { ok: true, created: result.created, diagnostics: projection.ok ? [] : projection.diagnostics, }; } /** * Shared recall operation: layered search plus vault diagnostics. * * Layering is the shared contract, not an extension-only feature: MCP clients * get the same personal + project merge the Pi `wiki_recall` tool does. * `searchWikiLayered` appends personal-vault hits, deduplicates by page ID and * tags personal results with `vaultLabel`. It is a no-op when no personal vault * exists, or when the resolved vault IS the personal vault. */ export async function recallOperation( paths: VaultPaths, query: string, maxResults = 5, ): Promise<{ results: RecallResult[]; diagnostics: Array<{ code: string; message: string }>; }> { const results = searchWikiLayered(paths, query, maxResults); const vaultState = inspectVaultFormat(paths); return { results, diagnostics: vaultState.diagnostics.map((d) => ({ code: d.code, message: d.message })), }; } /** Shared search operation: delegates directly to wiki-service. */ export async function searchOperation( paths: VaultPaths, query: string, type?: string, ): Promise<{ matches: Array<{ id: string; title: string; type: string }>; diagnostics: Array<{ code: string; message: string }>; }> { const result = searchRegistry(paths, query, type); return { matches: result.matches, diagnostics: result.diagnostics.map((d) => ({ code: d.code, message: d.message })), }; } /** Shared status operation: delegates directly to wiki-service. */ export async function statusOperation(paths: VaultPaths): Promise<{ knowledgeFormat: string; totalPages: number; byType: Record; blockingDiagnostics: Array<{ code: string; message: string }>; lastUpdated: string; }> { const status = getWikiStatus(paths); return { knowledgeFormat: status.knowledgeFormat, totalPages: status.totalPages, byType: status.byType, blockingDiagnostics: status.blockingDiagnostics.map((d) => ({ code: d.code, message: d.message, })), lastUpdated: status.lastUpdated, }; } /** Shared retro operation: validates vault then delegates to saveInsight. */ export async function retroOperation( paths: VaultPaths, slug: string, title: string, body: string, category?: string, ): Promise< | { ok: true; slug: string; sourcePagePath: string } | { ok: false; diagnostics: Array<{ code: string; message: string }> } > { const vaultCheck = inspectWritableVault(paths); if (!vaultCheck.ok) { return { ok: false, diagnostics: vaultCheck.diagnostics.map((d) => ({ code: d.code, message: d.message })), }; } try { const result = saveInsight(paths, slug, title, body, category, { rebuild: false }); const projection = projectionOutcome(rebuildMetadata(paths)); if (!projection.ok) return projection; return { ok: true, slug: result.slug, sourcePagePath: result.sourcePagePath }; } catch (error: unknown) { if (error instanceof VaultWriteError) { return { ok: false, diagnostics: error.diagnostics.map((d) => ({ code: d.code, message: d.message })), }; } if ((error as Error).message.startsWith("Invalid insight slug:")) { return { ok: false, diagnostics: [{ code: "invalid_insight_slug", message: (error as Error).message }], }; } throw error; } } /** Shared capture operation: validates vault then delegates to capture functions. */ export async function captureSourceOperation( paths: VaultPaths, input: { text?: string; url?: string; filePath?: string; title?: string }, execApi: Pick, ): Promise< | { ok: true; sourceId: string } | { ok: false; diagnostics: Array<{ code: string; message: string }> } > { const vaultCheck = inspectWritableVault(paths); if (!vaultCheck.ok) { return { ok: false, diagnostics: vaultCheck.diagnostics.map((d) => ({ code: d.code, message: d.message })), }; } try { let sourceId: string; if (input.url) { sourceId = (await captureUrl(execApi, paths, input.url)).sourceId; } else if (input.filePath) { sourceId = (await captureFile(execApi, paths, input.filePath)).sourceId; } else if (input.text) { sourceId = captureText(paths, input.text, input.title).sourceId; } else { return { ok: false, diagnostics: [ { code: "event_missing_kind" as const, message: "Provide one of: text, url, or filePath", }, ], }; } const projection = projectionOutcome(rebuildMetadata(paths)); if (!projection.ok) return projection; return { ok: true, sourceId }; } catch (error: unknown) { if (error instanceof VaultWriteError) { return { ok: false, diagnostics: error.diagnostics.map((d) => ({ code: d.code, message: d.message })), }; } throw error; } }