* feat(memory): memory-save tool with auto-setup * feat(memory): memory-search with keyword and semantic fallback * feat(memory): memory-list and memory-access tools * feat(memory): memory-doctor diagnostic tool * docs(handoff): add handoff and ADR for ts memory tools * docs(handoff): set PR number * docs(handoff): finalize pr-101 frontmatter and pending * docs(project-map): add 5 memory TS tools after PR#101 * refactor(memory): extract shared helpers to _memory-shared.ts * refactor(memory): split oversized execute functions * fix(memory): handle git checkout and catch blocks --------- Co-authored-by: opencode-agent <agent@opencode.local>
137 lines
No EOL
4.5 KiB
TypeScript
137 lines
No EOL
4.5 KiB
TypeScript
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
|
||
} |