opencode archivist
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
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
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user