* 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>
335 lines
No EOL
11 KiB
TypeScript
335 lines
No EOL
11 KiB
TypeScript
import { spawnSync } from "child_process"
|
|
import fs from "fs"
|
|
import path from "path"
|
|
import { tool } from "@opencode-ai/plugin"
|
|
import {
|
|
parseFrontmatter,
|
|
resolveMemoryDir,
|
|
resolveRgBinary,
|
|
type FrontmatterMeta,
|
|
} from "./_memory-shared"
|
|
|
|
const STOP_WORDS = new Set(["the", "a", "an", "is", "of", "to", "in", "for", "on", "and", "or"])
|
|
|
|
function parseSearchTerms(query: string): string[] {
|
|
const terms: string[] = []
|
|
const regex = /"([^"]+)"|(\S+)/g
|
|
let match
|
|
while ((match = regex.exec(query)) !== null) {
|
|
const original = match[1] || match[2]
|
|
const lower = original.toLowerCase()
|
|
if (lower.length < 2) continue
|
|
if (STOP_WORDS.has(lower)) continue
|
|
terms.push(original)
|
|
}
|
|
return terms
|
|
}
|
|
|
|
function countTermMatches(text: string, terms: string[]): number {
|
|
if (terms.length === 0) return 0
|
|
const lowerText = text.toLowerCase()
|
|
return terms.filter((t) => lowerText.includes(t.toLowerCase())).length
|
|
}
|
|
|
|
interface ScoreInput {
|
|
rgMatch: boolean
|
|
ragScore?: number
|
|
termMatches: number
|
|
totalTerms: number
|
|
tags?: string[]
|
|
path: string
|
|
importance?: string
|
|
accessCount?: number
|
|
terms: string[]
|
|
}
|
|
|
|
function scoreCandidate(input: ScoreInput): number {
|
|
let score = 0
|
|
if (input.rgMatch) {
|
|
const termCoverage = input.totalTerms > 0 ? input.termMatches / input.totalTerms : 1
|
|
score += 0.15 + 0.35 * termCoverage
|
|
}
|
|
if (input.ragScore) score += input.ragScore * 1.4
|
|
if (input.rgMatch && input.ragScore) score += 0.1
|
|
if (input.tags && input.tags.length > 0 && input.terms.length > 0) {
|
|
const lowerTags = input.tags.map((t) => t.toLowerCase())
|
|
const tagHits = input.terms.filter((t) =>
|
|
lowerTags.some((tag) => tag.includes(t.toLowerCase()))
|
|
).length
|
|
if (tagHits > 0) score += 0.2 * (tagHits / input.terms.length)
|
|
}
|
|
const pathLower = input.path.toLowerCase().replace(/[-_/.]/g, " ")
|
|
const pathHits = input.terms.filter((t) => pathLower.includes(t.toLowerCase())).length
|
|
if (pathHits > 0 && input.terms.length > 0) {
|
|
score += 0.15 * (pathHits / input.terms.length)
|
|
}
|
|
if (input.importance === "high") score += 0.15
|
|
else if (input.importance === "low") score -= 0.1
|
|
if (input.accessCount && input.accessCount >= 5) score += 0.1
|
|
else if (input.accessCount && input.accessCount >= 2) score += 0.05
|
|
return score
|
|
}
|
|
|
|
interface RagHit {
|
|
source: string
|
|
score: number
|
|
text: string
|
|
}
|
|
|
|
function parseRagHits(ragText: string): RagHit[] {
|
|
if (!ragText) return []
|
|
try {
|
|
const parsed = JSON.parse(ragText)
|
|
if (!Array.isArray(parsed)) return []
|
|
return parsed.filter(
|
|
(hit): hit is RagHit =>
|
|
hit &&
|
|
typeof hit.source === "string" &&
|
|
typeof hit.score === "number" &&
|
|
typeof hit.text === "string"
|
|
)
|
|
} catch (e) {
|
|
process.stderr.write(`[memory-search] parseRagHits JSON parse failed: ${(e as Error).message}\n`)
|
|
return []
|
|
}
|
|
}
|
|
|
|
function buildRgArgs(terms: string[]): string[] {
|
|
const args = ["-il", "--glob", "*.md", "--glob", "!.git", "--glob", "!.rag", "--glob", "!**/INDEX.md"]
|
|
for (const term of terms) args.push("-e", term)
|
|
return args
|
|
}
|
|
|
|
function toRelPath(memoryDir: string, absPath: string): string {
|
|
return path.relative(memoryDir, path.normalize(absPath))
|
|
}
|
|
|
|
interface CandInfo {
|
|
rgMatch: boolean
|
|
ragScore?: number
|
|
ragText?: string
|
|
}
|
|
|
|
function runKeywordSearch(
|
|
rgBin: string,
|
|
terms: string[],
|
|
searchDir: string,
|
|
memoryDir: string
|
|
): Map<string, CandInfo> {
|
|
const resultMap = new Map<string, CandInfo>()
|
|
const rgR = spawnSync(rgBin, [...buildRgArgs(terms), searchDir], { encoding: "utf-8" })
|
|
if (rgR.status === 0) {
|
|
for (const line of (rgR.stdout || "").trim().split("\n").filter(Boolean)) {
|
|
const rel = toRelPath(memoryDir, line)
|
|
if (rel.endsWith(".md") && !rel.startsWith(".")) {
|
|
resultMap.set(rel, { rgMatch: true })
|
|
}
|
|
}
|
|
}
|
|
return resultMap
|
|
}
|
|
|
|
function runSemanticSearch(
|
|
query: string,
|
|
indexDir: string,
|
|
worktree: string
|
|
): RagHit[] {
|
|
if (!process.env.OPENAI_BASE_URL) return []
|
|
if (!fs.existsSync(path.join(indexDir, "index.json"))) return []
|
|
const r = spawnSync(
|
|
"python3",
|
|
["-m", "src.memory", "search", query, "-i", indexDir, "-k", "15", "--json"],
|
|
{ cwd: worktree, encoding: "utf-8" }
|
|
)
|
|
if (r.status !== 0) {
|
|
if (r.stderr) process.stderr.write(`[memory-search] rag search failed (exit ${r.status}): ${r.stderr.trim()}\n`)
|
|
return []
|
|
}
|
|
return parseRagHits(r.stdout || "")
|
|
}
|
|
|
|
function mergeRagHits(
|
|
resultMap: Map<string, CandInfo>,
|
|
ragHits: RagHit[],
|
|
category?: string
|
|
): void {
|
|
for (const hit of ragHits) {
|
|
if (hit.source.endsWith("INDEX.md")) continue
|
|
if (category && !hit.source.startsWith(category + "/")) continue
|
|
const existing = resultMap.get(hit.source) || { rgMatch: false }
|
|
if (!existing.ragScore || hit.score > existing.ragScore) {
|
|
existing.ragScore = hit.score
|
|
existing.ragText = hit.text
|
|
}
|
|
resultMap.set(hit.source, existing)
|
|
}
|
|
}
|
|
|
|
interface ResultRow {
|
|
path: string
|
|
meta: FrontmatterMeta
|
|
score: number
|
|
termMatches: number
|
|
rgMatch: boolean
|
|
ragScore?: number
|
|
ragText?: string
|
|
}
|
|
|
|
function scoreAndCombine(
|
|
resultMap: Map<string, CandInfo>,
|
|
terms: string[],
|
|
memoryDir: string
|
|
): ResultRow[] {
|
|
const results: ResultRow[] = []
|
|
for (const [p, info] of resultMap) {
|
|
try {
|
|
const content = fs.readFileSync(path.join(memoryDir, p), "utf-8")
|
|
const meta = parseFrontmatter(content)
|
|
const termMatches =
|
|
terms.length > 0 ? countTermMatches(content, terms) : info.rgMatch ? 1 : 0
|
|
const score = scoreCandidate({
|
|
rgMatch: info.rgMatch,
|
|
ragScore: info.ragScore,
|
|
termMatches,
|
|
totalTerms: terms.length,
|
|
tags: meta.tags,
|
|
path: p,
|
|
importance: meta.importance,
|
|
accessCount: meta.access_count,
|
|
terms,
|
|
})
|
|
results.push({
|
|
path: p,
|
|
meta,
|
|
score,
|
|
termMatches,
|
|
rgMatch: info.rgMatch,
|
|
ragScore: info.ragScore,
|
|
ragText: info.ragText,
|
|
})
|
|
} catch (e) {
|
|
process.stderr.write(`[memory-search] score read failed ${p}: ${(e as Error).message}\n`)
|
|
}
|
|
}
|
|
results.sort((a, b) => b.score - a.score)
|
|
return results
|
|
}
|
|
|
|
function formatResults(
|
|
results: ResultRow[],
|
|
query: string,
|
|
terms: string[],
|
|
category: string | undefined,
|
|
limit: number,
|
|
memoryDir: string
|
|
): string {
|
|
const lines = [`## Results for "${query}" (${results.length} matches)`]
|
|
if (category && results.length > 0) {
|
|
lines.push(`_Filtered to **${category}/**_`)
|
|
}
|
|
if (terms.length > 1) lines.push(`_Searching for: ${terms.join(", ")}_`)
|
|
|
|
const filtered = results.filter((r) => r.score >= 0.2)
|
|
const topResults = filtered.length > 0 ? filtered : results.slice(0, 3)
|
|
const MAX = Math.min(limit, 7)
|
|
for (const [i, r] of topResults.slice(0, MAX).entries()) {
|
|
const isDirectHit =
|
|
r.rgMatch && r.ragScore !== undefined && r.ragScore > 0.4 && r.termMatches === terms.length
|
|
const hitLabel = isDirectHit ? " ★ DIRECT HIT" : ""
|
|
const importance = r.meta.importance || "medium"
|
|
lines.push(`${i + 1}. **${r.path}** [${importance}]${hitLabel}`)
|
|
if (r.meta.tags?.length) lines.push(` Tags: ${r.meta.tags.join(", ")}`)
|
|
if (r.meta.summary) lines.push(` ${r.meta.summary}`)
|
|
const sources: string[] = []
|
|
if (r.rgMatch) {
|
|
const termInfo = terms.length > 1 ? ` (${r.termMatches}/${terms.length} terms)` : ""
|
|
sources.push(`keyword${termInfo}`)
|
|
}
|
|
if (r.ragScore) sources.push(`semantic: ${r.ragScore.toFixed(2)}`)
|
|
lines.push(` Match: ${sources.join(" + ")} | score: ${r.score.toFixed(2)}`)
|
|
if (r.meta.related?.length) lines.push(` Related: ${r.meta.related.join(", ")}`)
|
|
if (r.ragText) lines.push(` Preview: "...${r.ragText.slice(0, 200).trim()}..."`)
|
|
lines.push("")
|
|
}
|
|
|
|
if (topResults.length > 0) {
|
|
lines.push(`_Read the top result: ${topResults[0].path}_`)
|
|
lines.push("")
|
|
}
|
|
|
|
const shownPaths = new Set(topResults.slice(0, 7).map((r) => r.path))
|
|
const relatedSuggestions: string[] = []
|
|
for (const r of topResults.slice(0, 7)) {
|
|
for (const rel of r.meta.related || []) {
|
|
if (!shownPaths.has(rel) && !relatedSuggestions.includes(rel)) relatedSuggestions.push(rel)
|
|
}
|
|
}
|
|
const verified: string[] = []
|
|
for (const rel of relatedSuggestions) {
|
|
try {
|
|
if (fs.existsSync(path.join(memoryDir, rel))) verified.push(rel)
|
|
} catch (e) {
|
|
process.stderr.write(`[memory-search] related exists check failed ${rel}: ${(e as Error).message}\n`)
|
|
}
|
|
}
|
|
if (verified.length > 0) {
|
|
lines.push("---")
|
|
lines.push(`**Related files** (not in results — use the Read tool):`)
|
|
for (const rel of verified) lines.push(` - ${rel}`)
|
|
lines.push("")
|
|
}
|
|
return lines.join("\n")
|
|
}
|
|
|
|
export default tool({
|
|
description:
|
|
"Search memories in the memory directory using both keyword (ripgrep) and semantic (Python rag) search. " +
|
|
"Multi-term queries match files containing ANY search term (OR logic); files matching more terms rank higher. " +
|
|
"Results are summaries only (path, tags, importance, short context). Use the Read tool on the memory path to get the full content. " +
|
|
"WHEN TO SEARCH (do this BEFORE starting work): starting work on any repo, using unfamiliar tool/API, encountering unfamiliar codebase, debugging a problem, looking up people/team info, before writing new memory. " +
|
|
"FALLBACK: if semantic search is unavailable (OPENAI_BASE_URL not set, Python crash, no index), keyword-only results are returned — never fails.",
|
|
args: {
|
|
query: tool.schema.string().describe("Search terms or natural language query"),
|
|
category: tool.schema
|
|
.string()
|
|
.optional()
|
|
.describe("Filter to a category: preferences, repos, technical, people, workflows, snippets, notes"),
|
|
limit: tool.schema.number().optional().describe("Max results (default 15)"),
|
|
},
|
|
async execute(args, context) {
|
|
const memoryDir = resolveMemoryDir()
|
|
const searchDir = args.category ? path.join(memoryDir, args.category) : memoryDir
|
|
const terms = parseSearchTerms(args.query)
|
|
const rgTerms = terms.length > 0 ? terms : [args.query]
|
|
const limit = args.limit ?? 15
|
|
const rgBin = resolveRgBinary({ allowSystemFallback: true })
|
|
|
|
if (!fs.existsSync(memoryDir)) {
|
|
return `No memories found for: "${args.query}" (memory directory not initialized — run memory_save to auto-setup)`
|
|
}
|
|
|
|
let resultMap = new Map<string, CandInfo>()
|
|
if (rgBin) {
|
|
resultMap = runKeywordSearch(rgBin, rgTerms, searchDir, memoryDir)
|
|
}
|
|
|
|
const indexDir = path.join(memoryDir, ".rag")
|
|
const ragHits = runSemanticSearch(args.query, indexDir, context.worktree)
|
|
mergeRagHits(resultMap, ragHits, args.category)
|
|
|
|
if (resultMap.size === 0 && args.category) {
|
|
if (rgBin) {
|
|
const fallback = runKeywordSearch(rgBin, rgTerms, memoryDir, memoryDir)
|
|
for (const [k, v] of fallback) resultMap.set(k, v)
|
|
}
|
|
mergeRagHits(resultMap, ragHits)
|
|
}
|
|
|
|
if (resultMap.size === 0) {
|
|
return `No memories found for: "${args.query}"`
|
|
}
|
|
|
|
const results = scoreAndCombine(resultMap, terms, memoryDir)
|
|
return formatResults(results, args.query, terms, args.category, limit, memoryDir)
|
|
},
|
|
}) |