* 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>
119 lines
No EOL
5.2 KiB
TypeScript
119 lines
No EOL
5.2 KiB
TypeScript
import { spawn, spawnSync } from "child_process"
|
|
import fs from "fs"
|
|
import path from "path"
|
|
import { tool } from "@opencode-ai/plugin"
|
|
import { CATEGORIES, resolveMemoryDir } from "./_memory-shared"
|
|
|
|
const HOOK_BODY = "#!/bin/bash\ngit push origin master 2>/dev/null || true\n"
|
|
|
|
function run(cmd: string, args: string[], opts?: { cwd?: string }) {
|
|
return spawnSync(cmd, args, { encoding: "utf-8", ...opts })
|
|
}
|
|
|
|
function isEmptyDir(dir: string): boolean {
|
|
if (!fs.existsSync(dir)) return true
|
|
return fs.readdirSync(dir).filter((n) => !n.startsWith(".")).length === 0
|
|
}
|
|
|
|
function autoSetup(memoryDir: string, remote: string | undefined): string[] {
|
|
const steps: string[] = []
|
|
if (!fs.existsSync(memoryDir)) {
|
|
fs.mkdirSync(memoryDir, { recursive: true })
|
|
steps.push(`created directory: ${memoryDir}`)
|
|
}
|
|
if (!fs.existsSync(path.join(memoryDir, ".git"))) {
|
|
if (remote && isEmptyDir(memoryDir)) {
|
|
const r = run("git", ["clone", "--origin", "origin", remote, memoryDir])
|
|
if (r.status !== 0) {
|
|
run("git", ["init", memoryDir])
|
|
steps.push(`git init (clone failed: ${(r.stderr || "").trim()})`)
|
|
} else {
|
|
const co = run("git", ["-C", memoryDir, "checkout", "master"])
|
|
if (co.status !== 0) {
|
|
process.stderr.write(
|
|
`[memory-save] git checkout master failed (exit ${co.status}): ${(co.stderr || co.stdout || "").trim()}\n`
|
|
)
|
|
steps.push("cloned remote (checkout master failed — check stderr)")
|
|
} else {
|
|
steps.push(`cloned remote: ${remote}`)
|
|
}
|
|
}
|
|
} else {
|
|
run("git", ["init", memoryDir])
|
|
steps.push("git init")
|
|
}
|
|
}
|
|
if (remote) {
|
|
const hookPath = path.join(memoryDir, ".git", "hooks", "post-commit")
|
|
let needHook = true
|
|
if (fs.existsSync(hookPath)) {
|
|
if (fs.readFileSync(hookPath, "utf-8") === HOOK_BODY) needHook = false
|
|
}
|
|
if (needHook) {
|
|
fs.mkdirSync(path.dirname(hookPath), { recursive: true })
|
|
fs.writeFileSync(hookPath, HOOK_BODY, { mode: 0o755 })
|
|
steps.push("installed post-commit hook (auto-push)")
|
|
}
|
|
const cur = run("git", ["-C", memoryDir, "remote", "get-url", "origin"])
|
|
if (cur.status !== 0 || (cur.stdout || "").trim() !== remote) {
|
|
run("git", ["-C", memoryDir, "remote", "set-url", "origin", remote])
|
|
steps.push(`set remote origin: ${remote}`)
|
|
}
|
|
}
|
|
for (const cat of CATEGORIES) {
|
|
const catDir = path.join(memoryDir, cat)
|
|
if (!fs.existsSync(catDir)) fs.mkdirSync(catDir, { recursive: true })
|
|
}
|
|
return steps
|
|
}
|
|
|
|
export default tool({
|
|
description:
|
|
"Commit and re-index all pending memory changes. Call AFTER using Write/Edit tools on ~/.local/share/opencode/opencode-memory/{category}/{filename}.md. " +
|
|
"Handles: auto-setup (mkdir + git init/clone + hook) on first call, git add -A, commit (message derived from changed files), async RAG re-index (fire-and-forget, log to .rag/reindex.log). " +
|
|
"WHEN TO SAVE (always save when you discover something reusable): learned non-obvious API pattern/tool quirk/workaround, discovered repo structure/conventions/gotchas, resolved tricky debugging with non-obvious root cause, learned team/ownership info. " +
|
|
"DO NOT save: one-off answers, things in public docs, context only relevant to the current task. " +
|
|
"CATEGORIES: preferences, repos, technical, people, workflows, snippets, notes. " +
|
|
"FRONTMATTER FORMAT: ---\\ntitle: ...\\ntags: [...]\\nsummary: ...\\ncreated: YYYY-MM-DD\\nupdated: YYYY-MM-DD\\nimportance: high|medium|low\\nrelated: [...]\\n---",
|
|
args: {},
|
|
async execute(_args, context) {
|
|
const memoryDir = resolveMemoryDir()
|
|
const remote = process.env.OPENCODE_MEMORY_REMOTE
|
|
const setupSteps = autoSetup(memoryDir, remote)
|
|
|
|
const addR = run("git", ["-C", memoryDir, "add", "-A"])
|
|
if (addR.status !== 0) {
|
|
return `⚠️ memory-save: git add failed (exit ${addR.status}): ${addR.stderr || addR.stdout}`
|
|
}
|
|
const diffR = run("git", ["-C", memoryDir, "diff", "--name-only", "--cached"])
|
|
if (diffR.status !== 0) {
|
|
return `⚠️ memory-save: git diff failed (exit ${diffR.status}): ${diffR.stderr || diffR.stdout}`
|
|
}
|
|
const changed = (diffR.stdout || "").trim().split("\n").filter(Boolean)
|
|
if (changed.length === 0) return "No changes to sync"
|
|
|
|
const msg = `memory: sync ${changed.join(", ")}`
|
|
const commitR = run("git", ["-C", memoryDir, "commit", "-m", msg])
|
|
if (commitR.status !== 0) {
|
|
return `⚠️ memory-save: git commit failed (exit ${commitR.status}): ${commitR.stderr || commitR.stdout}`
|
|
}
|
|
|
|
if (process.env.OPENAI_BASE_URL) {
|
|
const indexDir = path.join(memoryDir, ".rag")
|
|
fs.mkdirSync(indexDir, { recursive: true })
|
|
const child = spawn("python3", ["-m", "src.memory", "index", memoryDir, "-o", indexDir], {
|
|
cwd: context.worktree,
|
|
stdio: "ignore",
|
|
detached: true,
|
|
})
|
|
child.unref()
|
|
}
|
|
|
|
const parts = [`Synced: ${changed.join(", ")}`]
|
|
if (setupSteps.length > 0) parts.push(`Auto-setup: ${setupSteps.join("; ")}`)
|
|
if (!process.env.OPENAI_BASE_URL) {
|
|
parts.push("(semantic index skipped: OPENAI_BASE_URL not set — keyword search still works)")
|
|
}
|
|
return parts.join("\n")
|
|
},
|
|
}) |