feat(tools): add session management tools for OpenCode sessions (#227)
* feat(tools): add session management tools for OpenCode sessions - Add session_list tool for listing sessions with filtering - Add session_read tool for reading session messages and history - Add session_search tool for full-text search across sessions - Add session_info tool for session metadata inspection - Add comprehensive tests for storage, utils, and tools - Update documentation in AGENTS.md Closes #132 * fix(session-manager): add Windows compatibility for storage paths - Create shared/data-path.ts utility for cross-platform data directory resolution - On Windows: uses %LOCALAPPDATA% (e.g., C:\Users\Username\AppData\Local) - On Unix: uses $XDG_DATA_HOME or ~/.local/share (XDG Base Directory spec) - Update session-manager/constants.ts to use getOpenCodeStorageDir() - Update hook-message-injector/constants.ts to use same utility - Remove dependency on xdg-basedir package in session-manager - Follows existing pattern from auto-update-checker for consistency --------- Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
import type { SessionInfo, SessionMessage, SearchResult } from "./types"
|
||||
import { getSessionInfo, readSessionMessages } from "./storage"
|
||||
|
||||
export function formatSessionList(sessionIDs: string[]): string {
|
||||
if (sessionIDs.length === 0) {
|
||||
return "No sessions found."
|
||||
}
|
||||
|
||||
const infos = sessionIDs.map((id) => getSessionInfo(id)).filter((info): info is SessionInfo => info !== null)
|
||||
|
||||
if (infos.length === 0) {
|
||||
return "No valid sessions found."
|
||||
}
|
||||
|
||||
const headers = ["Session ID", "Messages", "First", "Last", "Agents"]
|
||||
const rows = infos.map((info) => [
|
||||
info.id,
|
||||
info.message_count.toString(),
|
||||
info.first_message?.toISOString().split("T")[0] ?? "N/A",
|
||||
info.last_message?.toISOString().split("T")[0] ?? "N/A",
|
||||
info.agents_used.join(", ") || "none",
|
||||
])
|
||||
|
||||
const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)))
|
||||
|
||||
const formatRow = (cells: string[]): string => {
|
||||
return (
|
||||
"| " +
|
||||
cells
|
||||
.map((cell, i) => cell.padEnd(colWidths[i]))
|
||||
.join(" | ")
|
||||
.trim() +
|
||||
" |"
|
||||
)
|
||||
}
|
||||
|
||||
const separator = "|" + colWidths.map((w) => "-".repeat(w + 2)).join("|") + "|"
|
||||
|
||||
return [formatRow(headers), separator, ...rows.map(formatRow)].join("\n")
|
||||
}
|
||||
|
||||
export function formatSessionMessages(messages: SessionMessage[], includeTodos?: boolean, todos?: Array<{id: string; content: string; status: string}>): string {
|
||||
if (messages.length === 0) {
|
||||
return "No messages found in this session."
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
const timestamp = msg.time?.created ? new Date(msg.time.created).toISOString() : "Unknown time"
|
||||
const agent = msg.agent ? ` (${msg.agent})` : ""
|
||||
lines.push(`\n[${msg.role}${agent}] ${timestamp}`)
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "text" && part.text) {
|
||||
lines.push(part.text.trim())
|
||||
} else if (part.type === "thinking" && part.thinking) {
|
||||
lines.push(`[thinking] ${part.thinking.substring(0, 200)}...`)
|
||||
} else if ((part.type === "tool_use" || part.type === "tool") && part.tool) {
|
||||
const input = part.input ? JSON.stringify(part.input).substring(0, 100) : ""
|
||||
lines.push(`[tool: ${part.tool}] ${input}`)
|
||||
} else if (part.type === "tool_result") {
|
||||
const output = part.output ? part.output.substring(0, 200) : ""
|
||||
lines.push(`[tool result] ${output}...`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (includeTodos && todos && todos.length > 0) {
|
||||
lines.push("\n\n=== Todos ===")
|
||||
for (const todo of todos) {
|
||||
const status = todo.status === "completed" ? "✓" : todo.status === "in_progress" ? "→" : "○"
|
||||
lines.push(`${status} [${todo.status}] ${todo.content}`)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export function formatSessionInfo(info: SessionInfo): string {
|
||||
const lines = [
|
||||
`Session ID: ${info.id}`,
|
||||
`Messages: ${info.message_count}`,
|
||||
`Date Range: ${info.first_message?.toISOString() ?? "N/A"} to ${info.last_message?.toISOString() ?? "N/A"}`,
|
||||
`Agents Used: ${info.agents_used.join(", ") || "none"}`,
|
||||
`Has Todos: ${info.has_todos ? `Yes (${info.todos?.length ?? 0} items)` : "No"}`,
|
||||
`Has Transcript: ${info.has_transcript ? `Yes (${info.transcript_entries} entries)` : "No"}`,
|
||||
]
|
||||
|
||||
if (info.first_message && info.last_message) {
|
||||
const duration = info.last_message.getTime() - info.first_message.getTime()
|
||||
const days = Math.floor(duration / (1000 * 60 * 60 * 24))
|
||||
const hours = Math.floor((duration % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
|
||||
if (days > 0 || hours > 0) {
|
||||
lines.push(`Duration: ${days} days, ${hours} hours`)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export function formatSearchResults(results: SearchResult[]): string {
|
||||
if (results.length === 0) {
|
||||
return "No matches found."
|
||||
}
|
||||
|
||||
const lines: string[] = [`Found ${results.length} matches:\n`]
|
||||
|
||||
for (const result of results) {
|
||||
const timestamp = result.timestamp ? new Date(result.timestamp).toISOString() : ""
|
||||
lines.push(`[${result.session_id}] ${result.message_id} (${result.role}) ${timestamp}`)
|
||||
lines.push(` ${result.excerpt}`)
|
||||
lines.push(` Matches: ${result.match_count}\n`)
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export function filterSessionsByDate(sessionIDs: string[], fromDate?: string, toDate?: string): string[] {
|
||||
if (!fromDate && !toDate) return sessionIDs
|
||||
|
||||
const from = fromDate ? new Date(fromDate) : null
|
||||
const to = toDate ? new Date(toDate) : null
|
||||
|
||||
return sessionIDs.filter((id) => {
|
||||
const info = getSessionInfo(id)
|
||||
if (!info || !info.last_message) return false
|
||||
|
||||
if (from && info.last_message < from) return false
|
||||
if (to && info.last_message > to) return false
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function searchInSession(sessionID: string, query: string, caseSensitive = false): SearchResult[] {
|
||||
const messages = readSessionMessages(sessionID)
|
||||
const results: SearchResult[] = []
|
||||
|
||||
const searchQuery = caseSensitive ? query : query.toLowerCase()
|
||||
|
||||
for (const msg of messages) {
|
||||
let matchCount = 0
|
||||
let excerpts: string[] = []
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "text" && part.text) {
|
||||
const text = caseSensitive ? part.text : part.text.toLowerCase()
|
||||
const matches = text.split(searchQuery).length - 1
|
||||
if (matches > 0) {
|
||||
matchCount += matches
|
||||
|
||||
const index = text.indexOf(searchQuery)
|
||||
if (index !== -1) {
|
||||
const start = Math.max(0, index - 50)
|
||||
const end = Math.min(text.length, index + searchQuery.length + 50)
|
||||
let excerpt = part.text.substring(start, end)
|
||||
if (start > 0) excerpt = "..." + excerpt
|
||||
if (end < text.length) excerpt = excerpt + "..."
|
||||
excerpts.push(excerpt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchCount > 0) {
|
||||
results.push({
|
||||
session_id: sessionID,
|
||||
message_id: msg.id,
|
||||
role: msg.role,
|
||||
excerpt: excerpts[0] || "",
|
||||
match_count: matchCount,
|
||||
timestamp: msg.time?.created,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
Reference in New Issue
Block a user