import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "typebox"; import { launchEmbedPages, reindexEmbeddings, resolveEmbedder } from "./embeddings.js"; import { scheduleReindex } from "./indexing.js"; import { runIngestSynthesis } from "./ingest-worker.js"; import { type Registry, appendEvent, buildBacklinks, buildRegistry, 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, ensureVaultStructure, extractWikilinks, findWikiPages, fmtDate, getVaultPaths, readJson, resolveVaultPaths, writeJson, } from "./utils.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); ensureVaultStructure(paths); const config = { name: params.topic, mode, topic: params.topic, created: fmtDate(), version: "1.0", }; writeJson(join(paths.dotWiki, "config.json"), config); const schema = [ "# LLM Wiki Schema", "", "## Ownership Rules", "", "| Path | Owner | Rule |", "|------|-------|------|", "| raw/** | extension | immutable after capture |", "| wiki/** | model + user | editable knowledge pages |", "| meta/* | extension | auto-generated |", "| . | human + explicit request | operating rules |", "", "## Source Packet Format", "", "```", "raw/sources/SRC-YYYY-MM-DD-NNN/", " manifest.json", " original/", " extracted.md", " attachments/", "```", "", "## Page Types", "", "- **source** — what this specific source says", "- **entity** — people, orgs, tools, products", "- **concept** — ideas, patterns, frameworks", "- **synthesis** — cross-source theses and tensions", "- **analysis** — durable filed answers from queries", "- **requirement** — atomic requirements with status, priority, and traceability", "", "## Linking Style", "", "- Internal: [[folder/page-name]]", "- Citation: [[sources/SRC-YYYY-MM-DD-NNN]]", "", ].join("\n"); writeFileSync(join(paths.dotWiki, "WIKI_SCHEMA.md"), schema, "utf-8"); rebuildMetadata(paths); appendEvent(paths, { kind: "bootstrap", topic: params.topic, mode }); 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 = requireVault(paths); if (!vaultCheck.ok) { return { content: [{ type: "text", text: vaultCheck.reason }], details: { error: vaultCheck.reason } 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 = requireVault(paths); if (!vaultCheck.ok) { return { content: [{ type: "text", text: vaultCheck.reason }], details: { error: vaultCheck.reason } 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, {}); return { id, extracted, manifest }; }); // ── 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, }); 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: \`raw/sources/${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 = requireVault(paths); if (!vaultCheck.ok) { return { content: [{ type: "text", text: vaultCheck.reason }], details: { error: vaultCheck.reason } as Record, isError: true, }; } const type = params.type as | "entity" | "concept" | "synthesis" | "analysis" | "requirement" | "skill" | "case"; const slug = params.title .toLowerCase() .replace(/[^a-z0-9\s-]/g, "") .trim() .replace(/\s+/g, "-") .slice(0, 80); 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 template = buildPageTemplate(type, params.title, today, params.content); mkdirSync(join(paths.wiki, folder), { recursive: true }); writeFileSync(pagePath, template, "utf-8"); 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 buildPageTemplate( type: string, title: string, date: string, customContent?: string, ): string { if (customContent) return customContent; const base = `---\ntype: ${type}\ncreated: ${date}\nupdated: ${date}\nsources: []\n---\n\n# ${title}\n\n[Description to be filled]\n\n## Links\n\n- [[related-page]]\n`; if (type === "entity") { return base .replace("[Description to be filled]", "One-line description.\n\n## Overview\n\n[Key facts]") .replace("type: entity", "type: entity\ncategory: organization"); } if (type === "concept") { return base .replace( "[Description to be filled]", "One-line definition.\n\n## Definition\n\n[Clear explanation]", ) .replace("type: concept", "type: concept\ndomain: ai"); } if (type === "synthesis") { return base .replace( "[Description to be filled]", "Cross-cutting analysis.\n\n## Question\n\n[What drove this?]", ) .replace("sources: []", "sources_count: 0"); } if (type === "analysis") { return base.replace( "[Description to be filled]", "Durable answer from a query.\n\n## Question\n\n[Original question]", ); } if (type === "skill") { return [ "---", "type: skill", `created: ${date}`, `updated: ${date}`, "status: draft", "trajectories: []", "tags: []", "---", "", `# ${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-...]]", "", ].join("\n"); } if (type === "case") { return [ "---", "type: case", `created: ${date}`, `updated: ${date}`, "status: draft", "outcome: success", "trajectory_id: ", "tags: []", "---", "", `# ${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-...]] — captured tool-call run", "", ].join("\n"); } if (type === "requirement") { return [ "---", "type: requirement", `created: ${date}`, `updated: ${date}`, "status: draft", "priority: p2", "source_id: ", "depends_on: []", "---", "", `# ${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-...]] — original concept capture", "", ].join("\n"); } return base; } // ─── 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 registry = readJson(join(paths.meta, "registry.json"), { version: "1.0", last_updated: "", pages: {}, }); const q = params.query.toLowerCase(); const matches = Object.entries(registry.pages) .filter(([id, entry]) => { const matchesQuery = id.toLowerCase().includes(q) || String(entry.title).toLowerCase().includes(q) || String(entry.type).toLowerCase().includes(q); const matchesType = !params.type || String(entry.type).toLowerCase() === params.type.toLowerCase(); return matchesQuery && matchesType; }) .map(([id, entry]) => ({ id, title: entry.title, type: entry.type })); if (matches.length === 0) { return { content: [{ type: "text", text: `No pages found for "${params.query}"` }], details: { query: params.query, matches: [] } as Record, }; } return { content: [ { type: "text", text: [ `🔍 **${matches.length} result(s)** for "${params.query}":`, "", ...matches.map((m) => `- [[${m.id}]] — *${m.type}* — ${m.title}`), ].join("\n"), }, ], details: { query: params.query, matches } 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 = requireVault(paths); if (!vaultCheck.ok) { return { content: [{ type: "text", text: vaultCheck.reason }], details: { error: vaultCheck.reason } 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 when it completes.", 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 { const pages = findWikiPages(paths.wiki); const registry = buildRegistry(paths); buildBacklinks(paths, registry); // ensures backlinks.json is current const findings: string[] = []; let orphans = 0; let missingPages = 0; let contradictions = 0; const gaps: Array<{ topic: string; mentionedBy: string[] }> = []; const allPageIds = new Set(pages.map((p) => p.relative)); const linkCounts: Record = {}; for (const page of pages) { const links = extractWikilinks(page.content); for (const link of links) { if (!allPageIds.has(link)) { missingPages++; findings.push(`Missing page: [[${link}]] (in [[${page.relative}]])`); const existing = gaps.find((g) => g.topic === link); if (existing) { if (!existing.mentionedBy.includes(page.relative)) existing.mentionedBy.push(page.relative); } else { gaps.push({ topic: link, mentionedBy: [page.relative] }); } } else { linkCounts[link] = (linkCounts[link] || 0) + 1; } } } for (const page of pages) { if (!linkCounts[page.relative] || linkCounts[page.relative] === 0) { orphans++; findings.push(`Orphan: [[${page.relative}]] has no inbound links`); } } for (const page of pages) { if (page.content.includes("⚠️ **Contradiction")) { contradictions++; findings.push(`Contradiction flagged in [[${page.relative}]]`); } } let fixesApplied = 0; if (autoFix) { for (const gap of gaps) { if (gap.mentionedBy.length >= 2) { const folder = gap.topic.includes("/") ? gap.topic.split("/")[0] : "concepts"; const name = gap.topic.includes("/") ? gap.topic.split("/").pop()! : gap.topic; const pagePath = join(paths.wiki, folder, `${name}.md`); mkdirSync(join(paths.wiki, folder), { recursive: true }); try { // Atomic create-if-absent: the `wx` flag fails with EEXIST instead of // overwriting, avoiding the existsSync→write TOCTOU race (CodeQL). writeFileSync( pagePath, `---\ntype: concept\ncreated: ${fmtDate()}\nupdated: ${fmtDate()}\nsources: []\nstatus: stub\n---\n\n# ${name.replace(/-/g, " ")}\n\n_Stub auto-created by lint. Expand with content from: ${gap.mentionedBy.map((r) => `[[${r}]]`).join(", ")}_\n`, { encoding: "utf-8", flag: "wx" }, ); fixesApplied++; } catch (err) { // Page already exists — nothing to fix. Re-throw anything else. if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; } } } } writeJson(join(paths.discoveries, "gaps.json"), { gaps, generated: new Date().toISOString(), }); const reportLines = [ "# Wiki Lint Report", `Generated: ${fmtDate()}`, "", "## Summary", `- Total pages: ${pages.length}`, `- Orphans: ${orphans}`, `- Missing pages: ${missingPages}`, `- Contradictions: ${contradictions}`, autoFix ? `- Fixes applied: ${fixesApplied}` : "", "", "## Findings", findings.length > 0 ? findings.map((f) => `- ${f}`).join("\n") : "✅ No issues found!", "", ].filter(Boolean); const reportPath = join(paths.outputs, `lint-${fmtDate()}.md`); mkdirSync(paths.outputs, { recursive: true }); writeFileSync(reportPath, `${reportLines.join("\n")}\n`, "utf-8"); appendEvent(paths, { kind: "lint", orphans, missing_pages: missingPages, contradictions, auto_fix: autoFix, }); rebuildMetadataLight(paths); return [ "🧹 **LLM Wiki lint complete**", "", `- Pages: ${pages.length}`, `- Orphans: ${orphans}`, `- Missing: ${missingPages}`, `- Contradictions: ${contradictions}`, autoFix ? `- Auto-fixes: ${fixesApplied}` : "", "", `📄 Report: \`${reportPath}\``, gaps.length > 0 ? `💡 ${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 registry = readJson(join(paths.meta, "registry.json"), { version: "1.0", last_updated: "", pages: {}, }); const backlinks = readJson>(join(paths.meta, "backlinks.json"), {}); const config = readJson>(join(paths.dotWiki, "config.json"), {}); const byType: Record = {}; for (const entry of Object.values(registry.pages)) { byType[entry.type] = (byType[entry.type] || 0) + 1; } const orphanCount = Object.entries(backlinks).filter( ([, inbound]) => inbound.length === 0, ).length; const gaps = readJson<{ gaps?: unknown[] }>(join(paths.discoveries, "gaps.json"), { gaps: [], }); const health = Object.keys(registry.pages).length === 0 ? "🔴 Empty" : orphanCount > 5 ? "⚠️ Warning" : "✅ Good"; const lines = [ "📊 LLM Wiki Status", "══════════════════", `Topic: ${config.topic || "Unknown"}`, `Mode: ${config.mode || "personal"}`, `Pages: ${Object.keys(registry.pages).length}`, ...Object.entries(byType).map(([t, c]) => ` - ${t}s: ${c}`), `Orphans: ${orphanCount}`, `Gaps: ${gaps.gaps?.length || 0}`, `Health: ${health}`, `Last updated: ${registry.last_updated || "Never"}`, ]; return { content: [{ type: "text", text: lines.join("\n") }], details: { topic: config.topic, mode: config.mode, totalPages: Object.keys(registry.pages).length, byType, orphans: orphanCount, gaps: gaps.gaps?.length || 0, health, } 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 = requireVault(paths); if (!vaultCheck.ok) { return { content: [{ type: "text", text: vaultCheck.reason }], details: { error: vaultCheck.reason } 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 when it completes.", work: async () => { rebuildMetadata(paths); appendEvent(paths, { kind: "rebuild_meta" }); 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 = requireVault(paths); if (!vaultCheck.ok) { return { content: [{ type: "text", text: vaultCheck.reason }], details: { error: vaultCheck.reason } 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 when it completes.`, 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 = requireVault(paths); if (!vaultCheck.ok) { return { content: [{ type: "text", text: vaultCheck.reason }], details: { error: vaultCheck.reason } as Record, isError: true, }; } appendEvent(paths, { kind: params.kind, ...params.details }); // Regenerate log.md const { buildLogMarkdown } = await import("./metadata.js"); const log = buildLogMarkdown(paths); writeFileSync(join(paths.meta, "log.md"), log, "utf-8"); return { content: [{ type: "text", text: `✅ Event logged: ${params.kind}` }], details: { kind: params.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, }; }, }); }