import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmdirSync, statSync, writeFileSync, } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; /** * Vault utility functions for the LLM Wiki extension. */ export interface VaultPaths { root: string; raw: string; rawSources: string; rawTrajectories: string; wiki: string; meta: string; dotWiki: string; outputs: string; discoveries: string; } /** Detect whether a vault root uses new (.llm-wiki) or legacy (.wiki) layout. */ export type VaultFormat = "new" | "legacy" | "none"; /** * Detect the vault format at a given directory. * Returns "new" if .llm-wiki/config.json exists, * "legacy" if .wiki/config.json exists, * "none" otherwise. */ export function detectVaultFormat(dir: string): VaultFormat { if (existsSync(join(dir, ".llm-wiki", "config.json"))) return "new"; if (existsSync(join(dir, ".wiki", "config.json"))) return "legacy"; return "none"; } /** * Get the personal wiki root directory. * * The "root" follows the same contract as project wikis: it is the directory * that *contains* the `.llm-wiki/` dot-dir, NOT the dot-dir itself. * So the personal vault lives at `/.llm-wiki/`. * * Default root: `homedir()` → personal vault at `~/.llm-wiki/`. * Override: `WIKI_HOME` env var → personal vault at `$WIKI_HOME/.llm-wiki/`. * * NOTE: Previously this returned `~/.llm-wiki` (the dot-dir itself), which * caused `getVaultPaths()` to compose paths like `~/.llm-wiki/.llm-wiki/raw`. * See `migrateDoubledPersonalVault()` for the one-shot recovery. */ export function getPersonalWikiRoot(): string { const envWiki = process.env.WIKI_HOME; if (envWiki) return envWiki; return homedir(); } /** Get VaultPaths for the personal wiki. */ export function getPersonalWikiPaths(): VaultPaths { return getVaultPaths(getPersonalWikiRoot()); } /** * One-shot, idempotent migration for vaults that were created with the broken * `getPersonalWikiRoot()` (returned the dot-dir itself, so `getVaultPaths()` * composed `/.llm-wiki/.llm-wiki/...`). * * Detects a doubled layout at `/.llm-wiki/.llm-wiki/config.json` and * flattens it up by one level. Safe to call on every session start: if the * doubled sentinel is absent, this is a no-op. * * Returns a description of the action taken (or `null` if no migration was * needed) so callers can surface a one-line status message. */ export function migrateDoubledPersonalVault( parentRoot: string = getPersonalWikiRoot(), ): { moved: string[]; from: string; to: string; skipped: string[] } | null { const outerDotWiki = join(parentRoot, ".llm-wiki"); const innerDotWiki = join(outerDotWiki, ".llm-wiki"); const innerSentinel = join(innerDotWiki, "config.json"); if (!existsSync(innerSentinel)) return null; const moved: string[] = []; const skipped: string[] = []; for (const entry of readdirSync(innerDotWiki)) { const src = join(innerDotWiki, entry); const dest = join(outerDotWiki, entry); if (existsSync(dest)) { // Collision — leave the inner copy in place rather than clobber. skipped.push(entry); continue; } renameSync(src, dest); moved.push(entry); } // Only remove the inner dir if it is fully drained. if (skipped.length === 0) { try { rmdirSync(innerDotWiki); } catch { // Leave behind if something raced us; harmless. } } return { moved, from: innerDotWiki, to: outerDotWiki, skipped }; } /** * Check if a vault is the personal wiki location. * Used in layered recall to avoid double-counting. */ export function isPersonalVault(paths: VaultPaths): boolean { return paths.root === getPersonalWikiRoot(); } /** * Resolve vault root from cwd with personal fallback. * * Priority: * 1. cwd has .llm-wiki/ → project wiki (explicit) * 2. Walk up from cwd → parent project wiki * 3. ~/.llm-wiki/ exists → personal wiki * 4. Fallback: ~/.llm-wiki/ (create personal wiki) */ export function resolveVaultRoot(cwd: string): string { // Check for any vault format at cwd if (detectVaultFormat(cwd) !== "none") return cwd; // Walk up looking for a vault sentinel (new or legacy) let dir = cwd; while (dir !== dirname(dir)) { dir = dirname(dir); if (detectVaultFormat(dir) !== "none") return dir; } // Check personal wiki at ~/.llm-wiki/ const personalRoot = getPersonalWikiRoot(); if (detectVaultFormat(personalRoot) !== "none") return personalRoot; // Fallback: personal wiki return personalRoot; } /** Get all vault paths for the new (.llm-wiki) layout. */ export function getVaultPaths(root: string): VaultPaths { return { root, raw: join(root, ".llm-wiki", "raw"), rawSources: join(root, ".llm-wiki", "raw", "sources"), rawTrajectories: join(root, ".llm-wiki", "raw", "trajectories"), wiki: join(root, ".llm-wiki", "wiki"), meta: join(root, ".llm-wiki", "meta"), dotWiki: join(root, ".llm-wiki"), outputs: join(root, ".llm-wiki", "outputs"), discoveries: join(root, ".llm-wiki", ".discoveries"), }; } /** Get all vault paths for the legacy (.wiki) layout. */ export function getLegacyVaultPaths(root: string): VaultPaths { return { root, raw: join(root, "raw"), rawSources: join(root, "raw", "sources"), rawTrajectories: join(root, "raw", "trajectories"), wiki: join(root, "wiki"), meta: join(root, "meta"), dotWiki: join(root, ".wiki"), outputs: join(root, "outputs"), discoveries: join(root, ".discoveries"), }; } /** * Resolve vault paths, auto-detecting new vs legacy layout. * This is the main entry point: use this instead of resolveVaultRoot + getVaultPaths. */ export function resolveVaultPaths(cwd: string): VaultPaths { const root = resolveVaultRoot(cwd); const format = detectVaultFormat(root); if (format === "legacy") return getLegacyVaultPaths(root); return getVaultPaths(root); } /** Ensure all vault directories exist. */ export function ensureVaultStructure(paths: VaultPaths): void { // NOTE: the agent-trajectory dirs (raw/trajectories, wiki/skills, wiki/cases) // are intentionally NOT created here — they are created lazily on first // capture/distill (issue #80), so a vault with the feature off carries no // trace of it. All readers of these paths are existsSync-guarded. const dirs = [ paths.rawSources, join(paths.raw, "assets"), join(paths.wiki, "sources"), join(paths.wiki, "entities"), join(paths.wiki, "concepts"), join(paths.wiki, "syntheses"), join(paths.wiki, "analyses"), join(paths.wiki, "requirements"), paths.meta, paths.dotWiki, paths.outputs, paths.discoveries, join(paths.dotWiki, "templates"), join(paths.dotWiki, "templates", "pages"), ]; for (const d of dirs) mkdirSync(d, { recursive: true }); } /** Read JSON file or return default. */ export function readJson(path: string, defaultValue: T): T { try { if (!existsSync(path)) return defaultValue; return JSON.parse(readFileSync(path, "utf-8")) as T; } catch { return defaultValue; } } /** Write JSON file atomically. */ export function writeJson(path: string, data: unknown): void { writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf-8"); } /** Read text file or return empty string. */ export function readText(path: string): string { try { if (!existsSync(path)) return ""; return readFileSync(path, "utf-8"); } catch { return ""; } } /** Generate the next source ID. */ export function nextSourceId(paths: VaultPaths): string { return nextSequentialId(paths.rawSources, "SRC"); } /** Generate the next trajectory ID. */ export function nextTrajectoryId(paths: VaultPaths): string { return nextSequentialId(paths.rawTrajectories, "TRJ"); } /** Generate the next sequential, date-stamped packet ID for a raw subdir. */ function nextSequentialId(dir: string, kind: string): string { const today = new Date().toISOString().split("T")[0]; const prefix = `${kind}-${today}`; if (!existsSync(dir)) return `${prefix}-001`; const dirs = readdirSync(dir) .filter((d) => d.startsWith(prefix)) .sort(); if (dirs.length === 0) return `${prefix}-001`; const last = dirs[dirs.length - 1]; const num = Number.parseInt(last.slice(-3), 10); return `${prefix}-${String(num + 1).padStart(3, "0")}`; } /** Parse a small, dependency-free YAML scalar/inline-array value. */ function parseFrontmatterValue(raw: string, unquote = false): unknown { const trimmed = raw.trim(); const unquoted = (value: string) => value.replace(/^(["'])(.*)\1$/, "$2").trim(); if (!trimmed) return ""; if (trimmed.startsWith("[") && trimmed.endsWith("]")) { const inner = trimmed.slice(1, -1).trim(); if (!inner) return []; return inner.split(",").map((item) => unquoted(item.trim())); } return unquote ? unquoted(trimmed) : trimmed; } /** Extract frontmatter from markdown. */ export function parseFrontmatter(content: string): { frontmatter: Record; body: string; } { const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); if (!match) return { frontmatter: {}, body: content }; const frontmatter: Record = {}; const lines = match[1].split("\n"); let currentListKey: string | null = null; for (const line of lines) { const listMatch = line.match(/^\s*-\s+(.*)$/); if (listMatch && currentListKey) { const current = frontmatter[currentListKey]; const list = Array.isArray(current) ? current : []; list.push(parseFrontmatterValue(listMatch[1], true)); frontmatter[currentListKey] = list; continue; } const idx = line.indexOf(":"); if (idx <= 0) { currentListKey = null; continue; } const key = line.slice(0, idx).trim(); const val = line.slice(idx + 1).trim(); if (!val) { frontmatter[key] = []; currentListKey = key; } else { frontmatter[key] = parseFrontmatterValue(val); currentListKey = null; } } return { frontmatter, body: match[2] }; } /** Find all wiki pages recursively. */ export function findWikiPages( wikiDir: string, ): Array<{ path: string; relative: string; content: string }> { const results: Array<{ path: string; relative: string; content: string }> = []; function walk(dir: string, rel: string) { if (!existsSync(dir)) return; for (const entry of readdirSync(dir)) { const full = join(dir, entry); const stat = statSync(full); if (stat.isDirectory()) { walk(full, rel ? `${rel}/${entry}` : entry); } else if (entry.endsWith(".md")) { results.push({ path: full, relative: rel ? `${rel}/${entry.slice(0, -3)}` : entry.slice(0, -3), content: readFileSync(full, "utf-8"), }); } } } walk(wikiDir, ""); return results; } /** Extract all [[wikilinks]] from content. */ export function extractWikilinks(content: string): string[] { const links: string[] = []; const regex = /\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g; let m: RegExpExecArray | null = regex.exec(content); while (m !== null) { links.push(m[1]); m = regex.exec(content); } return links; } /** Slugify a title. */ export function slugify(title: string): string { return title .toLowerCase() .replace(/[^a-z0-9\s-]/g, "") .trim() .replace(/\s+/g, "-") .slice(0, 80); } /** Format date as YYYY-MM-DD. */ export function fmtDate(d = new Date()): string { return d.toISOString().split("T")[0]; } /** Run a shell command via pi.exec. */ export async function exec( pi: ExtensionAPI, command: string, args: string[], options?: { signal?: AbortSignal; timeout?: number; cwd?: string }, ): Promise<{ stdout: string; stderr: string; code: number }> { const result = await pi.exec(command, args, options ?? {}); return result; } /** Check if a path is inside a protected directory. */ export function isProtectedPath( absPath: string, paths: VaultPaths, ): { protected: boolean; reason?: string } { const rawPath = resolve(paths.raw); const metaPath = resolve(paths.meta); const norm = resolve(absPath); if (norm.startsWith(`${rawPath}/`) || norm === rawPath) { return { protected: true, reason: "Raw sources are immutable. Use wiki_capture_source to add sources.", }; } if (norm.startsWith(`${metaPath}/`) || norm === metaPath) { return { protected: true, reason: "Metadata is auto-generated. Use wiki_rebuild_meta or wiki_log_event instead.", }; } return { protected: false }; }