// Store - LanceDB Storage Layer for Agent Memory via Python CLI // Uses subprocess to call the Python CLI for all operations import { randomUUID } from "node:crypto"; import * as path from "path"; import * as fs from "fs"; import { spawnSync } from "node:child_process"; import { MemoryEntry, MemorySearchResult, MemoryStats, StoreMemoryParams, RecallMemoriesParams, ForgetMemoriesParams, ListMemoriesParams, UpdateMemoryParams, Category, MemoryConfig, DEFAULT_CONFIG, } from "./types.js"; // Check if a path is a valid vault root (must contain .opencode/) function isValidVaultRoot(p: string): boolean { try { return !!p && p !== "/" && p !== "\\" && fs.existsSync(path.join(p, ".opencode")); } catch { return false; } } // Resolve vault root with validation: // 1. OPENCODE_WORKDIR env (set by OpenCode for the active workspace) // 2. ctx.worktree passed from plugin context // 3. process.cwd() — only if it actually contains .opencode/ // 4. Walk up from __dirname looking for .opencode/ (handles global npm installs) function resolveVaultRoot(ctxWorktree?: string): string { const candidates = [ process.env.OPENCODE_WORKDIR, ctxWorktree, process.cwd(), ].filter(Boolean) as string[]; for (const c of candidates) { if (isValidVaultRoot(c)) return c; } // Walk up from __dirname — handles global npm where cwd() != vault let dir = __dirname; for (let i = 0; i < 10; i++) { if (isValidVaultRoot(dir)) return dir; const parent = path.dirname(dir); if (parent === dir) break; // reached filesystem root dir = parent; } // Last resort: OPENCODE_WORKDIR or cwd regardless of validation return process.env.OPENCODE_WORKDIR || process.cwd(); } // Load CLI timeout from memory-config.json, fallback to 30s function getTimeoutMs(worktree?: string): number { try { const root = resolveVaultRoot(worktree); const configPath = path.join(root, ".opencode", "memory-config.json"); if (fs.existsSync(configPath)) { const raw = fs.readFileSync(configPath, "utf-8"); const cfg = JSON.parse(raw); const seconds = cfg?.cli?.timeout_seconds; if (typeof seconds === "number" && seconds > 0) { return seconds * 1000; } } } catch { // silently fall through to default } return 30000; } // Detect which Python executable is available on this system // On Windows, "python" is often a Microsoft Store stub that does nothing. // "py" (Python Launcher) is the reliable entry point on Windows. function getPythonExecutable(): string { const { spawnSync: spawn } = require("node:child_process"); // Try candidates in order of preference const candidates = process.platform === "win32" ? ["py", "python3", "python"] : ["python3", "python"]; for (const cmd of candidates) { const probe = spawn(cmd, ["--version"], { encoding: "utf-8", timeout: 5000 }); if (!probe.error && probe.status === 0) { return cmd; } } // Last resort — caller will surface the error return "python"; } // Determine Python CLI path — accepts worktree so it can search vault bin/ first function getPythonCLI(worktree?: string): string { const vaultRoot = resolveVaultRoot(worktree); const possiblePaths = [ path.join(vaultRoot, "bin", "agent-memory.py"), path.join(__dirname, "..", "bin", "agent-memory.py"), path.join(process.cwd(), "bin", "agent-memory.py"), ]; for (const p of possiblePaths) { if (fs.existsSync(p)) { return p; } } // Fallback: let the shell resolve python + script via workdir return "python"; } function runCLI(args: string[], ctx?: { worktree?: string }): string { // ctx.worktree is the authoritative vault root set by OpenCode — thread it everywhere const worktree = ctx?.worktree; const workdir = resolveVaultRoot(worktree); // Resolve CLI path with worktree so vault bin/ is found correctly let cliPath = getPythonCLI(worktree); if (cliPath === "python") { cliPath = path.join(workdir, "bin", "agent-memory.py"); } // Timeout driven by memory-config.json — also needs vault root const TIMEOUT_MS = getTimeoutMs(worktree); const timeoutSeconds = Math.round(TIMEOUT_MS / 1000); // Use spawnSync with array args to avoid shell interpolation issues on Windows // (execSync with a string goes through cmd.exe which mangles backslash paths) // getPythonExecutable() probes candidates so we never silently call the MS Store stub. const pythonExe = getPythonExecutable(); const result = spawnSync(pythonExe, [cliPath, ...args], { cwd: workdir, encoding: "utf-8", timeout: TIMEOUT_MS, }); if (result.error) { const msg = result.error.message || ""; if (msg.includes("ETIMEDOUT") || msg.includes("timeout")) { console.error(`[agent-memory] CLI timeout after ${timeoutSeconds} seconds`); return `Error: Memory recall timed out after ${timeoutSeconds} seconds. Please try a shorter query.`; } throw result.error; } if (result.status !== 0 && result.stderr) { // Some CLI commands write to stderr but also produce useful stdout if (result.stdout) return result.stdout; throw new Error(`CLI error: ${result.stderr}`); } return result.stdout || ""; } export class MemoryStore { private config: MemoryConfig; private ctx?: { worktree?: string }; constructor(config: Partial = {}, ctx?: { worktree?: string }) { this.config = { ...DEFAULT_CONFIG, ...config }; this.ctx = ctx; // Ensure CLI is available const cliPath = getPythonCLI(ctx?.worktree); if (!cliPath && !fs.existsSync(path.join(resolveVaultRoot(ctx?.worktree), "bin", "agent-memory.py"))) { console.warn("[agent-memory] Python CLI not found at bin/agent-memory.py"); } } async store(params: StoreMemoryParams): Promise { const { text, category = Category.FACT, scope = "admin:global", importance = 0.5, metadata, source = "manual", } = params; const args = [ "store", text, "--category", category, "--scope", scope, "--importance", String(importance), ]; if (metadata) { args.push("--metadata", JSON.stringify(metadata)); } if (source) { args.push("--source", source); } const output = runCLI(args, this.ctx); // Extract ID from output like "Stored memory: abc123... [category]" const match = output.match(/Stored memory: ([a-f0-9-]+)/); return match ? match[1] : randomUUID(); } async recall(params: RecallMemoriesParams): Promise { const { query, scope, category, limit = 10, minImportance = 0.0, } = params; const args = [ "recall", query, "--limit", String(limit), ]; if (scope) { args.push("--scope", scope); } if (category) { args.push("--category", category); } if (minImportance > 0) { args.push("--min-importance", String(minImportance)); } const output = runCLI(args, this.ctx); // Parse output const results: MemorySearchResult[] = []; const lines = output.split("\n"); let currentEntry: any = null; for (const line of lines) { // Match numbered entries const numMatch = line.match(/^\d+\.\s+\[(\w+)\]\s+(.+)/); if (numMatch) { if (currentEntry) { results.push(currentEntry); } currentEntry = { category: numMatch[1], text: numMatch[2], scope: "", importance: 0, timestamp: 0, score: 0, }; } // Match score line const scoreMatch = line.match(/Score:\s*([\d.]+)%/); if (scoreMatch && currentEntry) { currentEntry.score = parseFloat(scoreMatch[1]); } // Match scope const scopeMatch = line.match(/Scope:\s*(\S+)/); if (scopeMatch && currentEntry) { currentEntry.scope = scopeMatch[1]; } // Match importance const impMatch = line.match(/Importance:\s*([\d.]+)/); if (impMatch && currentEntry) { currentEntry.importance = parseFloat(impMatch[1]); } } if (currentEntry) { results.push(currentEntry); } // Convert to MemorySearchResult format return results.map(r => ({ id: r.id || randomUUID(), text: r.text, vector: [], category: r.category as Category, scope: r.scope || "admin:global", importance: r.importance || 0.5, timestamp: r.timestamp || Date.now(), metadata: JSON.stringify(r.metadata || {}), source: "manual", score: r.score, })); } async forget(params: ForgetMemoriesParams): Promise { const { query, memoryId, scope } = params; // SAFETY: Disable query-based deletion to prevent accidental bulk deletes. // Use memoryId for precise single-memory deletion instead. if (query && !memoryId) { throw new Error("Query-based deletion is disabled. Use memoryId for precise deletion. First run memory_recall or memory_list to find the memory ID."); } const args = ["forget"]; if (memoryId) { args.push("--id", memoryId); } else if (scope) { args.push("--scope", scope); } else { return 0; } const output = runCLI(args, this.ctx); // Extract count from output const match = output.match(/Deleted (\d+) memory/); return match ? parseInt(match[1], 10) : 0; } async update(params: UpdateMemoryParams): Promise { const { memoryId, text, category, importance, scope } = params; const args = ["update", memoryId]; if (text !== undefined) { args.push("--text", text); } if (category !== undefined) { args.push("--category", category); } if (importance !== undefined) { args.push("--importance", String(importance)); } if (scope !== undefined) { args.push("--scope", scope); } const output = runCLI(args, this.ctx); // Check for success message return output.includes("Updated memory:") || output.includes("No changes specified"); } async list(params: ListMemoriesParams): Promise { const { scope, category, limit = 50 } = params; const args = ["list", "--limit", String(limit)]; if (scope) { args.push("--scope", scope); } if (category) { args.push("--category", category); } const output = runCLI(args, this.ctx); // Parse list output const entries: MemoryEntry[] = []; const lines = output.split("\n"); let current: any = null; for (const line of lines) { const numMatch = line.match(/^\d+\.\s+\[(\w+)\]\s+(.+)/); if (numMatch) { if (current) { entries.push(current as MemoryEntry); } current = { id: "", // will be filled by ID line below text: numMatch[2], category: numMatch[1] as Category, scope: "", importance: 0.5, timestamp: Date.now(), metadata: {}, }; } // Extract real ID from " ID: | ..." line const idMatch = line.match(/ID:\s*([0-9a-f-]{36})/i); if (idMatch && current) { current.id = idMatch[1]; } const scopeMatch = line.match(/Scope:\s*(\S+)/); if (scopeMatch && current) { current.scope = scopeMatch[1].replace(/\s*\|.*$/, "").trim(); } // Extract importance value const impMatch = line.match(/Importance:\s*([\d.]+)/); if (impMatch && current) { current.importance = parseFloat(impMatch[1]); } // Extract access count const accessMatch = line.match(/Accessed:\s*(\d+)x/); if (accessMatch && current) { current.access_count = parseInt(accessMatch[1], 10); } } if (current) { entries.push(current as MemoryEntry); } return entries; } async stats(): Promise { const output = runCLI(["stats"], this.ctx); // Try to parse JSON format first const jsonMatch = output.match(/__JSON_START__\s*(\{.*?\})\s*__JSON_END__/s); if (jsonMatch) { try { const parsed = JSON.parse(jsonMatch[1]); return { total: parsed.total || 0, byCategory: parsed.byCategory || {}, byScope: parsed.byScope || {}, oldestTimestamp: parsed.oldestTimestamp || undefined, newestTimestamp: parsed.newestTimestamp || undefined, }; } catch (e) { // Fall through to default } } // Default: return empty stats return { total: 0, byCategory: {}, byScope: {}, oldestTimestamp: undefined, newestTimestamp: undefined, }; } close(): void { // No-op for CLI-based store } }