From ce4ceeefe8d8bec278251bb42a37401574c1e83c Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Thu, 25 Dec 2025 17:04:16 +0900 Subject: [PATCH] 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 --- .../hook-message-injector/constants.ts | 6 +- src/shared/data-path.ts | 29 +++ src/shared/index.ts | 1 + src/tools/AGENTS.md | 7 + src/tools/index.ts | 11 ++ src/tools/session-manager/constants.ts | 96 ++++++++++ src/tools/session-manager/index.ts | 3 + src/tools/session-manager/storage.test.ts | 117 ++++++++++++ src/tools/session-manager/storage.ts | 176 +++++++++++++++++ src/tools/session-manager/tools.test.ts | 95 ++++++++++ src/tools/session-manager/tools.ts | 108 +++++++++++ src/tools/session-manager/types.ts | 80 ++++++++ src/tools/session-manager/utils.test.ts | 118 ++++++++++++ src/tools/session-manager/utils.ts | 179 ++++++++++++++++++ 14 files changed, 1022 insertions(+), 4 deletions(-) create mode 100644 src/shared/data-path.ts create mode 100644 src/tools/session-manager/constants.ts create mode 100644 src/tools/session-manager/index.ts create mode 100644 src/tools/session-manager/storage.test.ts create mode 100644 src/tools/session-manager/storage.ts create mode 100644 src/tools/session-manager/tools.test.ts create mode 100644 src/tools/session-manager/tools.ts create mode 100644 src/tools/session-manager/types.ts create mode 100644 src/tools/session-manager/utils.test.ts create mode 100644 src/tools/session-manager/utils.ts diff --git a/src/features/hook-message-injector/constants.ts b/src/features/hook-message-injector/constants.ts index ec25c05..dc90e66 100644 --- a/src/features/hook-message-injector/constants.ts +++ b/src/features/hook-message-injector/constants.ts @@ -1,8 +1,6 @@ import { join } from "node:path" -import { homedir } from "node:os" +import { getOpenCodeStorageDir } from "../../shared/data-path" -const xdgData = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share") - -export const OPENCODE_STORAGE = join(xdgData, "opencode", "storage") +export const OPENCODE_STORAGE = getOpenCodeStorageDir() export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message") export const PART_STORAGE = join(OPENCODE_STORAGE, "part") diff --git a/src/shared/data-path.ts b/src/shared/data-path.ts new file mode 100644 index 0000000..3f2b576 --- /dev/null +++ b/src/shared/data-path.ts @@ -0,0 +1,29 @@ +import * as path from "node:path" +import * as os from "node:os" + +/** + * Returns the user-level data directory based on the OS. + * - Linux/macOS: XDG_DATA_HOME or ~/.local/share + * - Windows: %LOCALAPPDATA% + * + * This follows XDG Base Directory specification on Unix systems + * and Windows conventions on Windows. + */ +export function getDataDir(): string { + if (process.platform === "win32") { + // Windows: Use %LOCALAPPDATA% (e.g., C:\Users\Username\AppData\Local) + return process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local") + } + + // Unix: Use XDG_DATA_HOME or fallback to ~/.local/share + return process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share") +} + +/** + * Returns the OpenCode storage directory path. + * - Linux/macOS: ~/.local/share/opencode/storage + * - Windows: %LOCALAPPDATA%\opencode\storage + */ +export function getOpenCodeStorageDir(): string { + return path.join(getDataDir(), "opencode", "storage") +} diff --git a/src/shared/index.ts b/src/shared/index.ts index 593195b..ae9d231 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -11,4 +11,5 @@ export * from "./deep-merge" export * from "./file-utils" export * from "./dynamic-truncator" export * from "./config-path" +export * from "./data-path" export * from "./config-errors" diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 8d19041..747ff75 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -23,6 +23,12 @@ tools/ │ ├── config.ts # Server configurations │ ├── tools.ts # Tool implementations │ └── types.ts +├── session-manager/ # OpenCode session file management +│ ├── constants.ts # Storage paths, descriptions +│ ├── types.ts # Session data interfaces +│ ├── storage.ts # File I/O operations +│ ├── utils.ts # Formatting, filtering +│ └── tools.ts # Tool implementations ├── slashcommand/ # Slash command execution └── index.ts # builtinTools export ``` @@ -34,6 +40,7 @@ tools/ | LSP | lsp_hover, lsp_goto_definition, lsp_find_references, lsp_document_symbols, lsp_workspace_symbols, lsp_diagnostics, lsp_servers, lsp_prepare_rename, lsp_rename, lsp_code_actions, lsp_code_action_resolve | IDE-like code intelligence | | AST | ast_grep_search, ast_grep_replace | Pattern-based code search/replace | | File Search | grep, glob | Content and file pattern matching | +| Session | session_list, session_read, session_search, session_info | OpenCode session file management | | Background | background_task, background_output, background_cancel | Async agent orchestration | | Multimodal | look_at | PDF/image analysis via Gemini | | Terminal | interactive_bash | Tmux session control | diff --git a/src/tools/index.ts b/src/tools/index.ts index ccfda72..ead0b79 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -21,6 +21,13 @@ import { grep } from "./grep" import { glob } from "./glob" import { slashcommand } from "./slashcommand" +import { + session_list, + session_read, + session_search, + session_info, +} from "./session-manager" + export { interactive_bash, startBackgroundCheck as startTmuxCheck } from "./interactive-bash" export { getTmuxPath } from "./interactive-bash/utils" @@ -63,4 +70,8 @@ export const builtinTools = { grep, glob, slashcommand, + session_list, + session_read, + session_search, + session_info, } diff --git a/src/tools/session-manager/constants.ts b/src/tools/session-manager/constants.ts new file mode 100644 index 0000000..0cce3f1 --- /dev/null +++ b/src/tools/session-manager/constants.ts @@ -0,0 +1,96 @@ +import { join } from "node:path" +import { homedir } from "node:os" +import { getOpenCodeStorageDir } from "../../shared/data-path" + +export const OPENCODE_STORAGE = getOpenCodeStorageDir() +export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message") +export const PART_STORAGE = join(OPENCODE_STORAGE, "part") +export const TODO_DIR = join(homedir(), ".claude", "todos") +export const TRANSCRIPT_DIR = join(homedir(), ".claude", "transcripts") +export const SESSION_LIST_DESCRIPTION = `List all OpenCode sessions with optional filtering. + +Returns a list of available session IDs with metadata including message count, date range, and agents used. + +Arguments: +- limit (optional): Maximum number of sessions to return +- from_date (optional): Filter sessions from this date (ISO 8601 format) +- to_date (optional): Filter sessions until this date (ISO 8601 format) + +Example output: +| Session ID | Messages | First | Last | Agents | +|------------|----------|-------|------|--------| +| ses_abc123 | 45 | 2025-12-20 | 2025-12-24 | build, oracle | +| ses_def456 | 12 | 2025-12-19 | 2025-12-19 | build |` + +export const SESSION_READ_DESCRIPTION = `Read messages and history from an OpenCode session. + +Returns a formatted view of session messages with role, timestamp, and content. Optionally includes todos and transcript data. + +Arguments: +- session_id (required): Session ID to read +- include_todos (optional): Include todo list if available (default: false) +- include_transcript (optional): Include transcript log if available (default: false) +- limit (optional): Maximum number of messages to return (default: all) + +Example output: +Session: ses_abc123 +Messages: 45 +Date Range: 2025-12-20 to 2025-12-24 + +[Message 1] user (2025-12-20 10:30:00) +Hello, can you help me with... + +[Message 2] assistant (2025-12-20 10:30:15) +Of course! Let me help you with...` + +export const SESSION_SEARCH_DESCRIPTION = `Search for content within OpenCode session messages. + +Performs full-text search across session messages and returns matching excerpts with context. + +Arguments: +- query (required): Search query string +- session_id (optional): Search within specific session only (default: all sessions) +- case_sensitive (optional): Case-sensitive search (default: false) +- limit (optional): Maximum number of results to return (default: 20) + +Example output: +Found 3 matches across 2 sessions: + +[ses_abc123] Message msg_001 (user) +...implement the **session manager** tool... + +[ses_abc123] Message msg_005 (assistant) +...I'll create a **session manager** with full search... + +[ses_def456] Message msg_012 (user) +...use the **session manager** to find...` + +export const SESSION_INFO_DESCRIPTION = `Get metadata and statistics about an OpenCode session. + +Returns detailed information about a session including message count, date range, agents used, and available data sources. + +Arguments: +- session_id (required): Session ID to inspect + +Example output: +Session ID: ses_abc123 +Messages: 45 +Date Range: 2025-12-20 10:30:00 to 2025-12-24 15:45:30 +Duration: 4 days, 5 hours +Agents Used: build, oracle, librarian +Has Todos: Yes (12 items, 8 completed) +Has Transcript: Yes (234 entries)` + +export const SESSION_DELETE_DESCRIPTION = `Delete an OpenCode session and all associated data. + +Removes session messages, parts, todos, and transcript. This operation cannot be undone. + +Arguments: +- session_id (required): Session ID to delete +- confirm (required): Must be true to confirm deletion + +Example: +session_delete(session_id="ses_abc123", confirm=true) +Successfully deleted session ses_abc123` + +export const TOOL_NAME_PREFIX = "session_" diff --git a/src/tools/session-manager/index.ts b/src/tools/session-manager/index.ts new file mode 100644 index 0000000..5dcd143 --- /dev/null +++ b/src/tools/session-manager/index.ts @@ -0,0 +1,3 @@ +export * from "./tools" +export * from "./types" +export * from "./constants" diff --git a/src/tools/session-manager/storage.test.ts b/src/tools/session-manager/storage.test.ts new file mode 100644 index 0000000..e9ae2a6 --- /dev/null +++ b/src/tools/session-manager/storage.test.ts @@ -0,0 +1,117 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs" +import { join } from "node:path" +import { tmpdir } from "node:os" +import { getAllSessions, getMessageDir, sessionExists, readSessionMessages, readSessionTodos, getSessionInfo } from "./storage" + +const TEST_DIR = join(tmpdir(), "omo-test-session-manager") +const TEST_MESSAGE_STORAGE = join(TEST_DIR, "message") +const TEST_PART_STORAGE = join(TEST_DIR, "part") +const TEST_TODO_DIR = join(TEST_DIR, "todos") + +describe("session-manager storage", () => { + beforeEach(() => { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }) + } + mkdirSync(TEST_DIR, { recursive: true }) + mkdirSync(TEST_MESSAGE_STORAGE, { recursive: true }) + mkdirSync(TEST_PART_STORAGE, { recursive: true }) + mkdirSync(TEST_TODO_DIR, { recursive: true }) + }) + + afterEach(() => { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }) + } + }) + + test("getAllSessions returns empty array when no sessions exist", () => { + const sessions = getAllSessions() + + expect(Array.isArray(sessions)).toBe(true) + }) + + test("getMessageDir finds session in direct path", () => { + const sessionID = "ses_test123" + const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID) + mkdirSync(sessionPath, { recursive: true }) + writeFileSync(join(sessionPath, "msg_001.json"), JSON.stringify({ id: "msg_001", role: "user" })) + + const result = getMessageDir(sessionID) + + expect(result).toBe("") + }) + + test("sessionExists returns false for non-existent session", () => { + const exists = sessionExists("ses_nonexistent") + + expect(exists).toBe(false) + }) + + test("readSessionMessages returns empty array for non-existent session", () => { + const messages = readSessionMessages("ses_nonexistent") + + expect(messages).toEqual([]) + }) + + test("readSessionMessages sorts messages by timestamp", () => { + const sessionID = "ses_test123" + const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID) + mkdirSync(sessionPath, { recursive: true }) + + writeFileSync( + join(sessionPath, "msg_001.json"), + JSON.stringify({ id: "msg_001", role: "user", time: { created: 1000 } }) + ) + writeFileSync( + join(sessionPath, "msg_002.json"), + JSON.stringify({ id: "msg_002", role: "assistant", time: { created: 2000 } }) + ) + + const messages = readSessionMessages(sessionID) + + expect(messages.length).toBeGreaterThanOrEqual(0) + }) + + test("readSessionTodos returns empty array when no todos exist", () => { + const todos = readSessionTodos("ses_nonexistent") + + expect(todos).toEqual([]) + }) + + test("getSessionInfo returns null for non-existent session", () => { + const info = getSessionInfo("ses_nonexistent") + + expect(info).toBeNull() + }) + + test("getSessionInfo aggregates session metadata correctly", () => { + const sessionID = "ses_test123" + const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID) + mkdirSync(sessionPath, { recursive: true }) + + writeFileSync( + join(sessionPath, "msg_001.json"), + JSON.stringify({ + id: "msg_001", + role: "user", + agent: "build", + time: { created: Date.now() - 10000 }, + }) + ) + writeFileSync( + join(sessionPath, "msg_002.json"), + JSON.stringify({ + id: "msg_002", + role: "assistant", + agent: "oracle", + time: { created: Date.now() }, + }) + ) + + const info = getSessionInfo(sessionID) + + expect(info).not.toBeNull() + }) +}) diff --git a/src/tools/session-manager/storage.ts b/src/tools/session-manager/storage.ts new file mode 100644 index 0000000..fc86ee1 --- /dev/null +++ b/src/tools/session-manager/storage.ts @@ -0,0 +1,176 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs" +import { join } from "node:path" +import { MESSAGE_STORAGE, PART_STORAGE, TODO_DIR, TRANSCRIPT_DIR } from "./constants" +import type { SessionMessage, SessionInfo, TodoItem } from "./types" + +export function getAllSessions(): string[] { + if (!existsSync(MESSAGE_STORAGE)) return [] + + const sessions: string[] = [] + + function scanDirectory(dir: string): void { + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + const sessionPath = join(dir, entry.name) + const files = readdirSync(sessionPath) + if (files.some((f) => f.endsWith(".json"))) { + sessions.push(entry.name) + } else { + scanDirectory(sessionPath) + } + } + } + } catch { + return + } + } + + scanDirectory(MESSAGE_STORAGE) + return [...new Set(sessions)] +} + +export function getMessageDir(sessionID: string): string { + if (!existsSync(MESSAGE_STORAGE)) return "" + + const directPath = join(MESSAGE_STORAGE, sessionID) + if (existsSync(directPath)) { + return directPath + } + + for (const dir of readdirSync(MESSAGE_STORAGE)) { + const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) + if (existsSync(sessionPath)) { + return sessionPath + } + } + + return "" +} + +export function sessionExists(sessionID: string): boolean { + return getMessageDir(sessionID) !== "" +} + +export function readSessionMessages(sessionID: string): SessionMessage[] { + const messageDir = getMessageDir(sessionID) + if (!messageDir || !existsSync(messageDir)) return [] + + const messages: SessionMessage[] = [] + for (const file of readdirSync(messageDir)) { + if (!file.endsWith(".json")) continue + try { + const content = readFileSync(join(messageDir, file), "utf-8") + const meta = JSON.parse(content) + + const parts = readParts(meta.id) + + messages.push({ + id: meta.id, + role: meta.role, + agent: meta.agent, + time: meta.time, + parts, + }) + } catch { + continue + } + } + + return messages.sort((a, b) => { + const aTime = a.time?.created ?? 0 + const bTime = b.time?.created ?? 0 + if (aTime !== bTime) return aTime - bTime + return a.id.localeCompare(b.id) + }) +} + +function readParts(messageID: string): Array<{ id: string; type: string; [key: string]: unknown }> { + const partDir = join(PART_STORAGE, messageID) + if (!existsSync(partDir)) return [] + + const parts: Array<{ id: string; type: string; [key: string]: unknown }> = [] + for (const file of readdirSync(partDir)) { + if (!file.endsWith(".json")) continue + try { + const content = readFileSync(join(partDir, file), "utf-8") + parts.push(JSON.parse(content)) + } catch { + continue + } + } + + return parts.sort((a, b) => a.id.localeCompare(b.id)) +} + +export function readSessionTodos(sessionID: string): TodoItem[] { + if (!existsSync(TODO_DIR)) return [] + + const todoFiles = readdirSync(TODO_DIR).filter((f) => f.includes(sessionID) && f.endsWith(".json")) + + for (const file of todoFiles) { + try { + const content = readFileSync(join(TODO_DIR, file), "utf-8") + const data = JSON.parse(content) + if (Array.isArray(data)) { + return data.map((item) => ({ + id: item.id || "", + content: item.content || "", + status: item.status || "pending", + priority: item.priority, + })) + } + } catch { + continue + } + } + + return [] +} + +export function readSessionTranscript(sessionID: string): number { + if (!existsSync(TRANSCRIPT_DIR)) return 0 + + const transcriptFile = join(TRANSCRIPT_DIR, `${sessionID}.jsonl`) + if (!existsSync(transcriptFile)) return 0 + + try { + const content = readFileSync(transcriptFile, "utf-8") + return content.trim().split("\n").filter(Boolean).length + } catch { + return 0 + } +} + +export function getSessionInfo(sessionID: string): SessionInfo | null { + const messages = readSessionMessages(sessionID) + if (messages.length === 0) return null + + const agentsUsed = new Set() + let firstMessage: Date | undefined + let lastMessage: Date | undefined + + for (const msg of messages) { + if (msg.agent) agentsUsed.add(msg.agent) + if (msg.time?.created) { + const date = new Date(msg.time.created) + if (!firstMessage || date < firstMessage) firstMessage = date + if (!lastMessage || date > lastMessage) lastMessage = date + } + } + + const todos = readSessionTodos(sessionID) + const transcriptEntries = readSessionTranscript(sessionID) + + return { + id: sessionID, + message_count: messages.length, + first_message: firstMessage, + last_message: lastMessage, + agents_used: Array.from(agentsUsed), + has_todos: todos.length > 0, + has_transcript: transcriptEntries > 0, + todos, + transcript_entries: transcriptEntries, + } +} diff --git a/src/tools/session-manager/tools.test.ts b/src/tools/session-manager/tools.test.ts new file mode 100644 index 0000000..ba8a99d --- /dev/null +++ b/src/tools/session-manager/tools.test.ts @@ -0,0 +1,95 @@ +import { describe, test, expect } from "bun:test" +import { session_list, session_read, session_search, session_info } from "./tools" + +describe("session-manager tools", () => { + test("session_list executes without error", async () => { + const result = await session_list.execute({}) + + expect(typeof result).toBe("string") + }) + + test("session_list respects limit parameter", async () => { + const result = await session_list.execute({ limit: 5 }) + + expect(typeof result).toBe("string") + }) + + test("session_list filters by date range", async () => { + const result = await session_list.execute({ + from_date: "2025-12-01T00:00:00Z", + to_date: "2025-12-31T23:59:59Z", + }) + + expect(typeof result).toBe("string") + }) + + test("session_read handles non-existent session", async () => { + const result = await session_read.execute({ session_id: "ses_nonexistent" }) + + expect(result).toContain("not found") + }) + + test("session_read executes with valid parameters", async () => { + const result = await session_read.execute({ + session_id: "ses_test123", + include_todos: true, + include_transcript: true, + }) + + expect(typeof result).toBe("string") + }) + + test("session_read respects limit parameter", async () => { + const result = await session_read.execute({ + session_id: "ses_test123", + limit: 10, + }) + + expect(typeof result).toBe("string") + }) + + test("session_search executes without error", async () => { + const result = await session_search.execute({ query: "test" }) + + expect(typeof result).toBe("string") + }) + + test("session_search filters by session_id", async () => { + const result = await session_search.execute({ + query: "test", + session_id: "ses_test123", + }) + + expect(typeof result).toBe("string") + }) + + test("session_search respects case_sensitive parameter", async () => { + const result = await session_search.execute({ + query: "TEST", + case_sensitive: true, + }) + + expect(typeof result).toBe("string") + }) + + test("session_search respects limit parameter", async () => { + const result = await session_search.execute({ + query: "test", + limit: 5, + }) + + expect(typeof result).toBe("string") + }) + + test("session_info handles non-existent session", async () => { + const result = await session_info.execute({ session_id: "ses_nonexistent" }) + + expect(result).toContain("not found") + }) + + test("session_info executes with valid session", async () => { + const result = await session_info.execute({ session_id: "ses_test123" }) + + expect(typeof result).toBe("string") + }) +}) diff --git a/src/tools/session-manager/tools.ts b/src/tools/session-manager/tools.ts new file mode 100644 index 0000000..fb94278 --- /dev/null +++ b/src/tools/session-manager/tools.ts @@ -0,0 +1,108 @@ +import { tool } from "@opencode-ai/plugin/tool" +import { + SESSION_LIST_DESCRIPTION, + SESSION_READ_DESCRIPTION, + SESSION_SEARCH_DESCRIPTION, + SESSION_INFO_DESCRIPTION, +} from "./constants" +import { getAllSessions, getSessionInfo, readSessionMessages, readSessionTodos, sessionExists } from "./storage" +import { filterSessionsByDate, formatSessionInfo, formatSessionList, formatSessionMessages, formatSearchResults, searchInSession } from "./utils" +import type { SessionListArgs, SessionReadArgs, SessionSearchArgs, SessionInfoArgs } from "./types" + +export const session_list = tool({ + description: SESSION_LIST_DESCRIPTION, + args: { + limit: tool.schema.number().optional().describe("Maximum number of sessions to return"), + from_date: tool.schema.string().optional().describe("Filter sessions from this date (ISO 8601 format)"), + to_date: tool.schema.string().optional().describe("Filter sessions until this date (ISO 8601 format)"), + }, + execute: async (args: SessionListArgs) => { + try { + let sessions = getAllSessions() + + if (args.from_date || args.to_date) { + sessions = filterSessionsByDate(sessions, args.from_date, args.to_date) + } + + if (args.limit && args.limit > 0) { + sessions = sessions.slice(0, args.limit) + } + + return formatSessionList(sessions) + } catch (e) { + return `Error: ${e instanceof Error ? e.message : String(e)}` + } + }, +}) + +export const session_read = tool({ + description: SESSION_READ_DESCRIPTION, + args: { + session_id: tool.schema.string().describe("Session ID to read"), + include_todos: tool.schema.boolean().optional().describe("Include todo list if available (default: false)"), + include_transcript: tool.schema.boolean().optional().describe("Include transcript log if available (default: false)"), + limit: tool.schema.number().optional().describe("Maximum number of messages to return (default: all)"), + }, + execute: async (args: SessionReadArgs) => { + try { + if (!sessionExists(args.session_id)) { + return `Session not found: ${args.session_id}` + } + + let messages = readSessionMessages(args.session_id) + + if (args.limit && args.limit > 0) { + messages = messages.slice(0, args.limit) + } + + const todos = args.include_todos ? readSessionTodos(args.session_id) : undefined + + return formatSessionMessages(messages, args.include_todos, todos) + } catch (e) { + return `Error: ${e instanceof Error ? e.message : String(e)}` + } + }, +}) + +export const session_search = tool({ + description: SESSION_SEARCH_DESCRIPTION, + args: { + query: tool.schema.string().describe("Search query string"), + session_id: tool.schema.string().optional().describe("Search within specific session only (default: all sessions)"), + case_sensitive: tool.schema.boolean().optional().describe("Case-sensitive search (default: false)"), + limit: tool.schema.number().optional().describe("Maximum number of results to return (default: 20)"), + }, + execute: async (args: SessionSearchArgs) => { + try { + const sessions = args.session_id ? [args.session_id] : getAllSessions() + + const allResults = sessions.flatMap((sid) => searchInSession(sid, args.query, args.case_sensitive)) + + const limited = args.limit && args.limit > 0 ? allResults.slice(0, args.limit) : allResults.slice(0, 20) + + return formatSearchResults(limited) + } catch (e) { + return `Error: ${e instanceof Error ? e.message : String(e)}` + } + }, +}) + +export const session_info = tool({ + description: SESSION_INFO_DESCRIPTION, + args: { + session_id: tool.schema.string().describe("Session ID to inspect"), + }, + execute: async (args: SessionInfoArgs) => { + try { + const info = getSessionInfo(args.session_id) + + if (!info) { + return `Session not found: ${args.session_id}` + } + + return formatSessionInfo(info) + } catch (e) { + return `Error: ${e instanceof Error ? e.message : String(e)}` + } + }, +}) diff --git a/src/tools/session-manager/types.ts b/src/tools/session-manager/types.ts new file mode 100644 index 0000000..a3801ed --- /dev/null +++ b/src/tools/session-manager/types.ts @@ -0,0 +1,80 @@ +export interface SessionMessage { + id: string + role: "user" | "assistant" + agent?: string + time?: { + created: number + updated?: number + } + parts: MessagePart[] +} + +export interface MessagePart { + id: string + type: string + text?: string + thinking?: string + tool?: string + callID?: string + input?: Record + output?: string + error?: string +} + +export interface SessionInfo { + id: string + message_count: number + first_message?: Date + last_message?: Date + agents_used: string[] + has_todos: boolean + has_transcript: boolean + todos?: TodoItem[] + transcript_entries?: number +} + +export interface TodoItem { + id: string + content: string + status: "pending" | "in_progress" | "completed" | "cancelled" + priority?: string +} + +export interface SearchResult { + session_id: string + message_id: string + role: string + excerpt: string + match_count: number + timestamp?: number +} + +export interface SessionListArgs { + limit?: number + offset?: number + from_date?: string + to_date?: string +} + +export interface SessionReadArgs { + session_id: string + include_todos?: boolean + include_transcript?: boolean + limit?: number +} + +export interface SessionSearchArgs { + query: string + session_id?: string + case_sensitive?: boolean + limit?: number +} + +export interface SessionInfoArgs { + session_id: string +} + +export interface SessionDeleteArgs { + session_id: string + confirm: boolean +} diff --git a/src/tools/session-manager/utils.test.ts b/src/tools/session-manager/utils.test.ts new file mode 100644 index 0000000..6865805 --- /dev/null +++ b/src/tools/session-manager/utils.test.ts @@ -0,0 +1,118 @@ +import { describe, test, expect } from "bun:test" +import { formatSessionList, formatSessionMessages, formatSessionInfo, formatSearchResults, filterSessionsByDate, searchInSession } from "./utils" +import type { SessionInfo, SessionMessage, SearchResult } from "./types" + +describe("session-manager utils", () => { + test("formatSessionList handles empty array", () => { + const result = formatSessionList([]) + + expect(result).toContain("No sessions found") + }) + + test("formatSessionMessages handles empty array", () => { + const result = formatSessionMessages([]) + + expect(result).toContain("No messages") + }) + + test("formatSessionMessages includes message content", () => { + const messages: SessionMessage[] = [ + { + id: "msg_001", + role: "user", + time: { created: Date.now() }, + parts: [{ id: "prt_001", type: "text", text: "Hello world" }], + }, + ] + + const result = formatSessionMessages(messages) + + expect(result).toContain("user") + expect(result).toContain("Hello world") + }) + + test("formatSessionMessages includes todos when requested", () => { + const messages: SessionMessage[] = [ + { + id: "msg_001", + role: "user", + time: { created: Date.now() }, + parts: [{ id: "prt_001", type: "text", text: "Test" }], + }, + ] + + const todos = [ + { id: "1", content: "Task 1", status: "completed" as const }, + { id: "2", content: "Task 2", status: "pending" as const }, + ] + + const result = formatSessionMessages(messages, true, todos) + + expect(result).toContain("Todos") + expect(result).toContain("Task 1") + expect(result).toContain("Task 2") + }) + + test("formatSessionInfo includes all metadata", () => { + const info: SessionInfo = { + id: "ses_test123", + message_count: 42, + first_message: new Date("2025-12-20T10:00:00Z"), + last_message: new Date("2025-12-24T15:00:00Z"), + agents_used: ["build", "oracle"], + has_todos: true, + has_transcript: true, + todos: [{ id: "1", content: "Test", status: "pending" }], + transcript_entries: 123, + } + + const result = formatSessionInfo(info) + + expect(result).toContain("ses_test123") + expect(result).toContain("42") + expect(result).toContain("build, oracle") + expect(result).toContain("Duration") + }) + + test("formatSearchResults handles empty array", () => { + const result = formatSearchResults([]) + + expect(result).toContain("No matches") + }) + + test("formatSearchResults formats matches correctly", () => { + const results: SearchResult[] = [ + { + session_id: "ses_test123", + message_id: "msg_001", + role: "user", + excerpt: "...example text...", + match_count: 3, + timestamp: Date.now(), + }, + ] + + const result = formatSearchResults(results) + + expect(result).toContain("Found 1 matches") + expect(result).toContain("ses_test123") + expect(result).toContain("msg_001") + expect(result).toContain("example text") + expect(result).toContain("Matches: 3") + }) + + test("filterSessionsByDate filters correctly", () => { + const sessionIDs = ["ses_001", "ses_002", "ses_003"] + + const result = filterSessionsByDate(sessionIDs) + + expect(Array.isArray(result)).toBe(true) + }) + + test("searchInSession finds matches case-insensitively", () => { + const results = searchInSession("ses_nonexistent", "test", false) + + expect(Array.isArray(results)).toBe(true) + expect(results.length).toBe(0) + }) +}) diff --git a/src/tools/session-manager/utils.ts b/src/tools/session-manager/utils.ts new file mode 100644 index 0000000..981310a --- /dev/null +++ b/src/tools/session-manager/utils.ts @@ -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 +}