opencode archivist
This commit is contained in:
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
description: Search through past OpenCode sessions to find relevant context, previous solutions, and historical decisions. Use this when you need to recall how something was done before or find related past work.
|
||||||
|
mode: subagent
|
||||||
|
model: anthropic/claude-haiku-4-5
|
||||||
|
temperature: 0.1
|
||||||
|
tools:
|
||||||
|
"*": false
|
||||||
|
search-history: true
|
||||||
|
skill: true
|
||||||
|
permission:
|
||||||
|
skill:
|
||||||
|
"session-search": allow
|
||||||
|
"*": deny
|
||||||
|
---
|
||||||
|
|
||||||
|
You are the Archivist, a specialized agent that searches through OpenCode session history to find relevant past conversations, code changes, and decisions.
|
||||||
|
|
||||||
|
You are running inside an AI coding system as a subagent. The main agent invokes you when it needs to find relevant context from previous sessions.
|
||||||
|
|
||||||
|
## Your Purpose
|
||||||
|
|
||||||
|
When invoked, you will:
|
||||||
|
1. Search through the local OpenCode session history
|
||||||
|
2. Find sessions and messages relevant to the query
|
||||||
|
3. Synthesize findings into a clear, actionable answer
|
||||||
|
|
||||||
|
## How to Search
|
||||||
|
|
||||||
|
First, load the `session-search` skill to understand the search strategies and storage structure.
|
||||||
|
|
||||||
|
Then use the `search-history` tool to find relevant sessions. You can:
|
||||||
|
- Search by keywords, code patterns, file names, or concepts
|
||||||
|
- Filter by project directory if the query is project-specific
|
||||||
|
- List recent sessions to get an overview
|
||||||
|
|
||||||
|
## Search Strategies
|
||||||
|
|
||||||
|
1. **Start broad**: Use general keywords related to the query
|
||||||
|
2. **Refine**: If too many results, add more specific terms or filter by directory
|
||||||
|
3. **Cross-reference**: Search for related terms (e.g., if searching for "auth", also try "login", "authentication")
|
||||||
|
4. **Check context**: Look at session titles and directories to understand the context
|
||||||
|
|
||||||
|
## Response Format
|
||||||
|
|
||||||
|
Your response should directly answer the question posed, using information from past sessions:
|
||||||
|
|
||||||
|
1. **Direct answer**: What was found that addresses the question
|
||||||
|
2. **Relevant sessions**: List session IDs where this was discussed (so user can resume if needed)
|
||||||
|
3. **Key details**: Important snippets or decisions from the history
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
```
|
||||||
|
Based on past sessions, authentication was implemented using JWT tokens with a 24-hour expiry.
|
||||||
|
|
||||||
|
**Relevant sessions:**
|
||||||
|
- ses_abc123 - "Implementing user auth" (2024-01-15)
|
||||||
|
- ses_def456 - "Auth token refresh" (2024-01-20)
|
||||||
|
|
||||||
|
**Key details:**
|
||||||
|
- Tokens are stored in httpOnly cookies
|
||||||
|
- Refresh endpoint at /api/auth/refresh
|
||||||
|
- Used jose library for JWT handling
|
||||||
|
```
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
- Be concise and direct - the main agent needs actionable information
|
||||||
|
- Include session IDs so the user can explore further if needed
|
||||||
|
- If nothing relevant is found, say so clearly
|
||||||
|
- Focus on answering the specific question, not providing exhaustive history
|
||||||
|
- Never fabricate information - only report what's actually in the history
|
||||||
|
|
||||||
|
IMPORTANT: Your final message is returned to the main agent. Make it comprehensive but focused on answering the original question.
|
||||||
@@ -29,6 +29,10 @@ in
|
|||||||
autoupdate = false;
|
autoupdate = false;
|
||||||
permission = {
|
permission = {
|
||||||
external_directory = "allow";
|
external_directory = "allow";
|
||||||
|
# Restrict session-search skill to archivist only
|
||||||
|
skill = {
|
||||||
|
"session-search" = "deny";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
provider = {
|
provider = {
|
||||||
anthropic = {
|
anthropic = {
|
||||||
@@ -59,6 +63,9 @@ in
|
|||||||
"opencode/themes/ayu-mirage.json".source = ./themes/ayu-mirage.json;
|
"opencode/themes/ayu-mirage.json".source = ./themes/ayu-mirage.json;
|
||||||
"opencode/agent/librarian.md".source = ./agent/librarian.md;
|
"opencode/agent/librarian.md".source = ./agent/librarian.md;
|
||||||
"opencode/agent/adversary.md".source = ./agent/adversary.md;
|
"opencode/agent/adversary.md".source = ./agent/adversary.md;
|
||||||
|
"opencode/agent/archivist.md".source = ./agent/archivist.md;
|
||||||
"opencode/command/cleanup.md".source = ./command/cleanup.md;
|
"opencode/command/cleanup.md".source = ./command/cleanup.md;
|
||||||
|
"opencode/tool/search-history.ts".source = ./tool/search-history.ts;
|
||||||
|
"opencode/skill/session-search/SKILL.md".source = ./skill/session-search/SKILL.md;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
name: session-search
|
||||||
|
description: Advanced strategies for searching OpenCode session history. Restricted to the archivist agent.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Session Search Skill
|
||||||
|
|
||||||
|
This skill provides advanced strategies for searching through OpenCode's session history storage.
|
||||||
|
|
||||||
|
## Storage Structure
|
||||||
|
|
||||||
|
OpenCode stores data in `~/.local/share/opencode/storage/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
storage/
|
||||||
|
├── session/ # Session metadata by project
|
||||||
|
│ └── {projectHash}/
|
||||||
|
│ └── ses_*.json # Session info (title, directory, timestamps)
|
||||||
|
├── message/ # Messages organized by session
|
||||||
|
│ └── ses_*/
|
||||||
|
│ └── msg_*.json # Message metadata (role, agent, model)
|
||||||
|
├── part/ # Actual message content
|
||||||
|
│ └── msg_*/
|
||||||
|
│ └── prt_*.json # Content parts (text, tool calls)
|
||||||
|
└── project/ # Project metadata
|
||||||
|
└── {hash}.json # Worktree path, timestamps
|
||||||
|
```
|
||||||
|
|
||||||
|
## Search Tool Usage
|
||||||
|
|
||||||
|
The `search-history` tool accepts:
|
||||||
|
- `query`: Text pattern to search for (searches message content)
|
||||||
|
- `directory`: Optional filter by project path (partial match)
|
||||||
|
- `limit`: Max results (default 30)
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Find all sessions mentioning "authentication"
|
||||||
|
search-history({ query: "authentication" })
|
||||||
|
|
||||||
|
// Find sessions in a specific project
|
||||||
|
search-history({ query: "database", directory: "myproject" })
|
||||||
|
|
||||||
|
// List recent sessions (empty query)
|
||||||
|
search-history({ query: "", limit: 20 })
|
||||||
|
```
|
||||||
|
|
||||||
|
## Search Strategies
|
||||||
|
|
||||||
|
### 1. Keyword Expansion
|
||||||
|
Don't just search for the exact term. Try synonyms and related concepts:
|
||||||
|
- "auth" → also try "login", "authentication", "jwt", "token"
|
||||||
|
- "database" → also try "postgres", "sqlite", "db", "migration"
|
||||||
|
- "api" → also try "endpoint", "route", "handler"
|
||||||
|
|
||||||
|
### 2. Code Pattern Search
|
||||||
|
Search for code-specific patterns:
|
||||||
|
- Function names: `handleAuth`, `validateToken`
|
||||||
|
- File paths: `src/auth`, `lib/database`
|
||||||
|
- Import statements: `import.*prisma`
|
||||||
|
- Error messages: specific error text
|
||||||
|
|
||||||
|
### 3. Tool Usage Search
|
||||||
|
Find when specific tools were used:
|
||||||
|
- Edit operations: search for file paths that were edited
|
||||||
|
- Bash commands: search for command names
|
||||||
|
- Specific operations: "git push", "npm install"
|
||||||
|
|
||||||
|
### 4. Directory Filtering
|
||||||
|
Use the `directory` parameter to scope searches:
|
||||||
|
- Filter by project name: `directory: "myapp"`
|
||||||
|
- Filter by path segment: `directory: "usr/projects"`
|
||||||
|
|
||||||
|
### 5. Iterative Refinement
|
||||||
|
1. Start with broad search
|
||||||
|
2. If too many results, add specificity
|
||||||
|
3. If no results, broaden or try alternative terms
|
||||||
|
4. Cross-reference multiple searches
|
||||||
|
|
||||||
|
## Understanding Results
|
||||||
|
|
||||||
|
### Session Info
|
||||||
|
- `id`: Unique session identifier (can be used to reference)
|
||||||
|
- `title`: Auto-generated session title
|
||||||
|
- `directory`: Project worktree path
|
||||||
|
- `updated`: Last activity timestamp
|
||||||
|
|
||||||
|
### Content Matches
|
||||||
|
- `sessionID`: Which session contains this match
|
||||||
|
- `snippet`: Context around the match (±100 chars)
|
||||||
|
- `role`: user/assistant/tool
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
1. **Recent vs Relevant**: The tool returns recent sessions first. Older but more relevant sessions may be further in results.
|
||||||
|
|
||||||
|
2. **Title Search**: Session titles are auto-generated from the conversation and can be good search targets.
|
||||||
|
|
||||||
|
3. **Multiple Searches**: Don't hesitate to run multiple searches with different terms to build a complete picture.
|
||||||
|
|
||||||
|
4. **Context Matters**: The snippet provides limited context. Session titles and directories help understand the broader context.
|
||||||
|
|
||||||
|
5. **No Results**: If no results found, the pattern may be too specific. Try shorter or more general terms.
|
||||||
@@ -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