import { tool } from "@opencode-ai/plugin" import { $ } from "bun" import { readdir, readFile } from "fs/promises" import { join } from "path" import { homedir } from "os" const STORAGE_PATH = join(homedir(), ".local/share/opencode/storage") interface SessionInfo { id: string title: string directory: string projectID: string created: number updated: number } interface MessageMatch { sessionID: string messageID: string snippet: string role: string timestamp?: number } interface SearchResult { sessions: SessionInfo[] matches: MessageMatch[] totalMatches: number } async function getSessionInfo(sessionID: string): Promise { try { // Sessions are stored in directories named by project hash const sessionDirs = await readdir(join(STORAGE_PATH, "session")) for (const dir of sessionDirs) { const sessionPath = join(STORAGE_PATH, "session", dir) const files = await readdir(sessionPath).catch(() => []) for (const file of files) { if (file.startsWith(sessionID) || file.includes(sessionID)) { const content = await readFile(join(sessionPath, file), "utf-8") const data = JSON.parse(content) return { id: data.id, title: data.title || "Untitled", directory: data.directory || "", projectID: data.projectID || "", created: data.time?.created || 0, updated: data.time?.updated || 0, } } } } } catch { // Fall back to searching message directories } return null } async function searchWithRipgrep( pattern: string, directory?: string, limit: number = 50 ): Promise { const matches: MessageMatch[] = [] const sessionIDs = new Set() // Search through part storage (contains actual message content) const partPath = join(STORAGE_PATH, "part") try { // Use ripgrep to search JSON files, extracting context around matches const rgResult = await $`rg -i -l ${pattern} ${partPath} --type json 2>/dev/null || true`.text() const matchingFiles = rgResult.trim().split("\n").filter(Boolean) for (const file of matchingFiles.slice(0, limit * 2)) { try { const content = await readFile(file, "utf-8") const data = JSON.parse(content) // Filter by directory if specified if (directory) { const sessionInfo = await getSessionInfo(data.sessionID) if (sessionInfo && !sessionInfo.directory.includes(directory)) { continue } } // Extract snippet around the match const text = data.text || data.content || JSON.stringify(data) const lowerText = text.toLowerCase() const lowerPattern = pattern.toLowerCase() const matchIndex = lowerText.indexOf(lowerPattern) if (matchIndex !== -1) { const start = Math.max(0, matchIndex - 100) const end = Math.min(text.length, matchIndex + pattern.length + 100) const snippet = (start > 0 ? "..." : "") + text.slice(start, end) + (end < text.length ? "..." : "") matches.push({ sessionID: data.sessionID, messageID: data.messageID, snippet: snippet.replace(/\n/g, " ").trim(), role: data.role || "unknown", timestamp: data.time?.created, }) sessionIDs.add(data.sessionID) if (matches.length >= limit) break } } catch { // Skip files that can't be parsed } } } catch (e) { // ripgrep not found or error } // Also search message metadata for titles const messagePath = join(STORAGE_PATH, "message") try { const rgResult = await $`rg -i -l ${pattern} ${messagePath} --type json 2>/dev/null || true`.text() const matchingFiles = rgResult.trim().split("\n").filter(Boolean) for (const file of matchingFiles.slice(0, 20)) { try { const content = await readFile(file, "utf-8") const data = JSON.parse(content) if (data.sessionID) { sessionIDs.add(data.sessionID) } } catch { // Skip } } } catch { // Ignore errors } // Get session info for all matched sessions const sessions: SessionInfo[] = [] for (const sessionID of sessionIDs) { const info = await getSessionInfo(sessionID) if (info) { // Apply directory filter for sessions too if (!directory || info.directory.includes(directory)) { sessions.push(info) } } } // Sort sessions by most recent sessions.sort((a, b) => b.updated - a.updated) return { sessions: sessions.slice(0, 20), matches: matches.slice(0, limit), totalMatches: matches.length, } } async function listRecentSessions( directory?: string, limit: number = 20 ): Promise { const sessions: SessionInfo[] = [] try { const sessionDirs = await readdir(join(STORAGE_PATH, "session")) for (const dir of sessionDirs) { const sessionPath = join(STORAGE_PATH, "session", dir) const files = await readdir(sessionPath).catch(() => []) for (const file of files) { if (!file.endsWith(".json")) continue try { const content = await readFile(join(sessionPath, file), "utf-8") const data = JSON.parse(content) if (directory && !data.directory?.includes(directory)) { continue } sessions.push({ id: data.id, title: data.title || "Untitled", directory: data.directory || "", projectID: data.projectID || "", created: data.time?.created || 0, updated: data.time?.updated || 0, }) } catch { // Skip invalid files } } } } catch { // Storage doesn't exist } sessions.sort((a, b) => b.updated - a.updated) return sessions.slice(0, limit) } export default tool({ description: "Search through OpenCode session history to find past conversations, code changes, and decisions. Use this to find relevant context from previous sessions.", args: { query: tool.schema .string() .describe( "Search pattern to find in session history. Searches message content, titles, and tool outputs." ), directory: tool.schema .string() .optional() .describe( "Optional: Filter results to sessions from a specific project directory path (partial match)" ), limit: tool.schema .number() .optional() .describe("Maximum number of matches to return (default: 30)"), }, async execute(args) { const limit = args.limit || 30 if (!args.query || args.query.trim() === "") { // List recent sessions if no query const sessions = await listRecentSessions(args.directory, limit) return JSON.stringify( { type: "recent_sessions", sessions, message: `Found ${sessions.length} recent sessions${args.directory ? ` in ${args.directory}` : ""}`, }, null, 2 ) } const results = await searchWithRipgrep(args.query, args.directory, limit) // Format output for the agent let output = `## Search Results for "${args.query}"\n\n` if (results.sessions.length > 0) { output += `### Relevant Sessions (${results.sessions.length})\n\n` for (const session of results.sessions) { const date = new Date(session.updated).toLocaleDateString() output += `- **${session.title}** (${session.id})\n` output += ` - Directory: \`${session.directory}\`\n` output += ` - Last updated: ${date}\n\n` } } if (results.matches.length > 0) { output += `### Content Matches (${results.totalMatches})\n\n` for (const match of results.matches.slice(0, 15)) { output += `**Session:** ${match.sessionID}\n` output += `> ${match.snippet}\n\n` } } if (results.sessions.length === 0 && results.matches.length === 0) { output += `No matches found for "${args.query}"${args.directory ? ` in ${args.directory}` : ""}\n` } return output }, })