opencode-config/.opencode/tools/memory-doctor.ts
Sergey 7249f1fcb5
fix(memory): keyword search out-of-box + doctor gaps + tests (#124)
* fix(docker): add system ripgrep via apt for keyword search fallback

* fix(ci): install ripgrep and npm deps for keyword search in CI

* fix(memory-search): warn when ripgrep not resolvable instead of silent skip

* fix(memory-doctor): check rg status, arch mismatch, and require errors

* test(memory): add keyword search and doctor unit tests

* test(memory): unskip e2e keyword tests without RUN_LIVE

* docs(handoff): add handoff and ADR for keyword search fix

* test(memory): remove unused var in keyword search test

* docs(handoff): set PR number to 124

* docs(project-map): update after structural changes (PR#124)

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-07-29 17:20:59 +03:00

174 lines
No EOL
6.4 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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")
},
})