Files
helm/home/profiles/opencode/tool/search-history.ts
T
2026-01-19 22:37:40 -08:00

276 lines
8.1 KiB
TypeScript

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<SessionInfo | null> {
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<SearchResult> {
const matches: MessageMatch[] = []
const sessionIDs = new Set<string>()
// 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<SessionInfo[]> {
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
},
})