import { spawnSync } from "child_process" import { createRequire } from "node:module" import fs from "fs" import os from "os" import path from "path" const require2 = createRequire(import.meta.url) /** * Resolve the memory directory from OPENCODE_MEMORY_DIR env var or fall back * to the default ~/.local/share/opencode/opencode-memory. Identical behaviour * across all 5 memory tools — previously duplicated 5×. */ export function resolveMemoryDir(): string { const env = process.env.OPENCODE_MEMORY_DIR if (env && env.length > 0) return path.normalize(env) return path.join(os.homedir(), ".local/share/opencode/opencode-memory") } /** * Memory categories (also the directories auto-created by memory-save). * Previously duplicated in memory-save and memory-list. */ export const CATEGORIES = [ "preferences", "repos", "technical", "people", "workflows", "snippets", "notes", ] /** * Frontmatter delimiter regex (flat YAML between --- fences). Previously * duplicated in memory-search, memory-list, memory-access. */ export const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/ export interface FrontmatterMeta { title?: string summary?: string importance?: string updated?: string tags?: string[] related?: string[] access_count?: number } /** * Parse flat frontmatter (regex per line, NOT real YAML — see memory note * "Frontmatter parser is regex, not YAML"). Returns typed meta; callers * ignore fields they don't use. Previously duplicated in memory-search (full) * and memory-list (partial) — unified to the full version. */ export function parseFrontmatter(content: string): FrontmatterMeta { const match = content.match(FRONTMATTER_RE) if (!match) return {} const yaml = match[1] const meta: FrontmatterMeta = {} for (const line of yaml.split("\n")) { const kv = line.match(/^(\w+):\s*(.+)$/) if (!kv) continue const [, key, rawValue] = kv const value = rawValue.trim() if (key === "title") meta.title = value else if (key === "summary") meta.summary = value else if (key === "importance") meta.importance = value else if (key === "updated") meta.updated = value else if (key === "access_count") { const parsed = parseInt(value, 10) meta.access_count = Number.isFinite(parsed) ? parsed : 0 } else if (key === "tags" || key === "related") { const arr = value.match(/\[([^\]]*)\]/) if (arr && arr[1].trim()) { meta[key] = arr[1].split(",").map((t) => t.trim()).filter(Boolean) } else { meta[key] = [] } } } return meta } /** * Resolve the ripgrep binary path. Checks @vscode/ripgrep npm package first * (platform-specific binary). When `allowSystemFallback` is true (memory-search), * falls back to a system `rg` on $PATH. When false (memory-doctor), returns * null if the npm package is unavailable so the doctor can report the two * cases separately. Previously duplicated with divergent behaviour. */ export function resolveRgBinary(opts?: { allowSystemFallback?: boolean }): string | null { try { const mod = require2("@vscode/ripgrep") if (typeof mod.rgPath === "string" && mod.rgPath.length > 0 && fs.existsSync(mod.rgPath)) { return mod.rgPath } } catch (e) { process.stderr.write(`[memory] @vscode/ripgrep require failed: ${(e as Error).message}\n`) } if (opts?.allowSystemFallback) { const sys = spawnSync("rg", ["--version"], { encoding: "utf-8" }) if (sys.status === 0) return "rg" } return null } /** * Recursively collect absolute paths of *.md files under `dir`, skipping * dotfiles, .git/, .rag/, and INDEX.md. Previously duplicated in memory-list * (collecting relative paths) and memory-doctor (counting only); unified to * return absolute paths — callers convert with path.relative() as needed. */ export function walkMd(dir: string): string[] { const out: string[] = [] const walk = (d: string) => { let entries: fs.Dirent[] try { entries = fs.readdirSync(d, { withFileTypes: true }) } catch (e) { process.stderr.write(`[memory] walkMd readdir failed on ${d}: ${(e as Error).message}\n`) return } for (const e of entries) { if (e.name.startsWith(".")) continue const full = path.join(d, e.name) if (e.isDirectory()) { if (e.name === ".git" || e.name === ".rag") continue walk(full) } else if (e.isFile() && e.name.endsWith(".md") && e.name !== "INDEX.md") { out.push(full) } } } walk(dir) return out }