276 lines
8.1 KiB
TypeScript
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
|
|
},
|
|
})
|