import { spawnSync } from "child_process" import fs from "fs" import os from "os" import path from "path" import { tool } from "@opencode-ai/plugin" import { resolveMemoryDir, resolveRgBinary, walkMd } from "./_memory-shared" function mark(ok: boolean): string { return ok ? "✅" : "❌" } interface RipgrepCheck { lines: string[] rgWorks: boolean } function detectArchMismatch(rgBin: string): string | null { const plat = process.platform const lower = rgBin.toLowerCase() if (plat === "linux" && (lower.includes("darwin") || lower.includes("win32") || lower.includes("win64"))) { return "arch mismatch: binary platform != runtime platform (linux)" } if (plat === "darwin" && (lower.includes("linux") || lower.includes("win32") || lower.includes("win64"))) { return "arch mismatch: binary platform != runtime platform (darwin)" } if (plat === "win32" && (lower.includes("linux") || lower.includes("darwin"))) { return "arch mismatch: binary platform != runtime platform (win32)" } const arch = os.arch() if (arch === "arm64" && lower.includes("x64")) return "arch mismatch: binary is x64 but runtime is arm64" if ((arch === "x64" || arch === "x86_64") && lower.includes("arm64")) return "arch mismatch: binary is arm64 but runtime is x64" return null } function checkRipgrep(rgBin: string | null): RipgrepCheck { const lines: string[] = [] if (rgBin) { const r = spawnSync(rgBin, ["--version"], { encoding: "utf-8" }) const ver = (r.stdout || "").split("\n")[0].trim() const works = r.status === 0 lines.push( `- ${mark(works)} ripgrep (keyword search): ${rgBin}${ver ? ` — ${ver}` : ""}` ) if (!works) { const archMismatch = detectArchMismatch(rgBin) if (archMismatch) { lines.push(` - ${archMismatch}`) } const errMsg = (r.stderr || "").trim() if (errMsg) lines.push(` - binary execution failed: ${errMsg.split("\n")[0]}`) } return { lines, rgWorks: works } } // rgBin === null: npm package missing — try system rg as a hint. const sys = spawnSync("rg", ["--version"], { encoding: "utf-8" }) if (sys.status === 0) { lines.push(`- ${mark(true)} ripgrep (keyword search): system rg on $PATH`) return { lines, rgWorks: true } } lines.push( "- ❌ ripgrep (keyword search): NOT resolvable — install @vscode/ripgrep or system rg" ) lines.push( " - npm package missing — run `npm ci` in .opencode/ (or `apt-get install ripgrep` for system fallback)" ) return { lines, rgWorks: false } } function checkPythonMemory(worktree: string): { lines: string[]; ok: boolean } { const pyR = spawnSync("python3", ["-c", "import src.memory; print('ok')"], { encoding: "utf-8", cwd: worktree, }) const lines = [ `- ${mark(pyR.status === 0)} Python src.memory importable (semantic search): ${ pyR.status === 0 ? "yes" : `no — ${(pyR.stderr || pyR.stdout || "").trim().split("\n").pop() || ""}` }`, ] return { lines, ok: pyR.status === 0 } } function checkEnvVars(): string[] { return [ `- ${mark(!!process.env.OPENAI_BASE_URL)} OPENAI_BASE_URL set (embeddings endpoint): ${ process.env.OPENAI_BASE_URL ? "✓" : "not set — semantic search disabled, keyword still works" }`, `- ${mark(!!process.env.OPENAI_API_KEY)} OPENAI_API_KEY set (embeddings auth): ${ process.env.OPENAI_API_KEY ? "✓" : "not set" }`, `- ${mark(!!process.env.OPENCODE_MEMORY_REMOTE)} OPENCODE_MEMORY_REMOTE set (auto-push to git): ${ process.env.OPENCODE_MEMORY_REMOTE ? "✓" : "not set — auto-push disabled" }`, ] } function checkIndex(memoryDir: string): string[] { const lines: string[] = [] lines.push( `- ${mark(fs.existsSync(memoryDir))} OPENCODE_MEMORY_DIR exists: ${memoryDir}${ fs.existsSync(memoryDir) ? "" : "— run memory-save to auto-setup" }` ) if (!fs.existsSync(memoryDir)) return lines const gitDir = path.join(memoryDir, ".git") lines.push( `- ${mark(fs.existsSync(gitDir))} memory dir is a git repo: ${ fs.existsSync(gitDir) ? "yes" : "no — will be init on first memory-save" }` ) const indexJson = path.join(memoryDir, ".rag", "index.json") lines.push( `- ${mark(fs.existsSync(indexJson))} RAG index exists: ${ fs.existsSync(indexJson) ? path.relative(memoryDir, indexJson) : "not built — first memory-save with OPENAI_BASE_URL will trigger reindex" }` ) if (fs.existsSync(indexJson)) { try { const stat = fs.statSync(indexJson) const sizeMb = (stat.size / 1024 / 1024).toFixed(1) lines.push(` (size: ${sizeMb} MB, modified: ${stat.mtime.toISOString().slice(0, 10)})`) } catch (e) { process.stderr.write(`[memory-doctor] stat index.json failed: ${(e as Error).message}\n`) } } try { const mdCount = walkMd(memoryDir).length lines.push(`- ℹ️ memory files (.md): ${mdCount}`) } catch (e) { process.stderr.write(`[memory-doctor] walkMd failed: ${(e as Error).message}\n`) } return lines } function formatDoctorReport( rgWorks: boolean, pyOk: boolean, memoryDir: string ): string { const indexJson = path.join(memoryDir, ".rag", "index.json") const allGreen = rgWorks && pyOk && fs.existsSync(memoryDir) && fs.existsSync(indexJson) if (allGreen) return "All green — both keyword and semantic search available." if (rgWorks) return "Keyword search works. Semantic search unavailable — see ❌ items above." return "ripgrep missing or broken — keyword search will return no results. Install @vscode/ripgrep or system rg." } export default tool({ description: "Read-only diagnostic for the memory subsystem. Reports: ripgrep (keyword) availability, Python src.memory importability, " + "env vars (OPENAI_BASE_URL, OPENAI_API_KEY, OPENCODE_MEMORY_REMOTE), memory dir existence, RAG index existence. " + "Does NOT modify anything. Run when memory-search/memory-save misbehave or to verify setup.", args: {}, async execute(_args, context) { const memoryDir = resolveMemoryDir() const lines: string[] = ["## Memory Doctor\n"] const rgBin = resolveRgBinary({ allowSystemFallback: true }) const rg = checkRipgrep(rgBin) lines.push(...rg.lines) const py = checkPythonMemory(context.worktree) lines.push(...py.lines) lines.push(...checkEnvVars()) lines.push(...checkIndex(memoryDir)) lines.push("") lines.push(formatDoctorReport(rg.rgWorks, py.ok, memoryDir)) return lines.join("\n") }, })