* 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>
174 lines
No EOL
6.4 KiB
TypeScript
174 lines
No EOL
6.4 KiB
TypeScript
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")
|
||
},
|
||
}) |