diff --git a/src/genie-commands/__tests__/session.test.ts b/src/genie-commands/__tests__/session.test.ts index b299cfdad..009d7cd55 100644 --- a/src/genie-commands/__tests__/session.test.ts +++ b/src/genie-commands/__tests__/session.test.ts @@ -8,7 +8,7 @@ */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; -import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { basename, join } from 'node:path'; import { buildClaudeCommand, getAgentsFilePath, sanitizeWindowName } from '../session.js'; @@ -94,7 +94,7 @@ describe('buildClaudeCommand', () => { // ============================================================================ describe('getAgentsFilePath', () => { - const TEST_DIR = '/tmp/session-test-agents-md'; + const TEST_DIR = `${realpathSync('/tmp')}/session-test-agents-md`; let originalCwd: string; beforeEach(() => { diff --git a/src/genie.ts b/src/genie.ts index 8ce6a17b9..893d02d95 100644 --- a/src/genie.ts +++ b/src/genie.ts @@ -194,7 +194,11 @@ program .description('Show compressed session history for an agent') .option('--full', 'Show full conversation without compression') .option('--since ', 'Show last N user/assistant exchanges', Number.parseInt) + .option('--last ', 'Show last N transcript entries', Number.parseInt) + .option('--type ', 'Filter by role (user, assistant, tool_call)') + .option('--after ', 'Only entries after ISO timestamp') .option('--json', 'Output as JSON') + .option('--ndjson', 'Output as newline-delimited JSON (pipeable to jq)') .option('--raw', 'Output raw JSONL entries') .option('--log-file ', 'Direct path to log file (for testing)') .action(async (name: string, options: historyCmd.HistoryOptions) => { diff --git a/src/lib/claude-logs.ts b/src/lib/claude-logs.ts index 522a15238..ce7669d11 100644 --- a/src/lib/claude-logs.ts +++ b/src/lib/claude-logs.ts @@ -25,6 +25,8 @@ import { access, readFile, readdir, stat } from 'node:fs/promises'; import { join } from 'node:path'; +import type { Agent } from './agent-registry.js'; +import type { TranscriptEntry, TranscriptProvider } from './transcript.js'; // ============================================================================ // Types @@ -268,9 +270,9 @@ function populateAssistantFields( } if (raw.message.model) { entry.model = raw.message.model; - if (raw.message.usage) { - entry.usage = raw.message.usage as { input_tokens: number; output_tokens: number }; - } + } + if (raw.message.usage) { + entry.usage = raw.message.usage as { input_tokens: number; output_tokens: number }; } } @@ -399,3 +401,91 @@ export async function getLogsForPane( projectDir: result.projectDir, }; } + +// ============================================================================ +// Transcript Provider Adapter +// ============================================================================ + +/** + * Extract text content from a message content field. + */ +function extractText(content: unknown): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + const parts: string[] = []; + for (const item of content) { + if (typeof item === 'string') parts.push(item); + else if (item && typeof item === 'object' && 'text' in item) parts.push(String(item.text)); + } + return parts.join(' '); +} + +type ClaudeBase = { provider: 'claude'; model?: string; raw: Record }; + +function convertAssistant(entry: ClaudeLogEntry, base: ClaudeBase): TranscriptEntry[] { + const results: TranscriptEntry[] = []; + const text = entry.message ? extractText(entry.message.content) : ''; + + if (text) { + results.push({ + ...base, + role: 'assistant', + timestamp: entry.timestamp, + text, + usage: entry.usage ? { input: entry.usage.input_tokens, output: entry.usage.output_tokens } : undefined, + }); + } + + if (entry.toolCalls) { + for (const tc of entry.toolCalls) { + results.push({ + ...base, + role: 'tool_call', + timestamp: entry.timestamp, + text: `${tc.name}: ${JSON.stringify(tc.input).slice(0, 200)}`, + toolCall: { id: tc.id, name: tc.name, input: tc.input }, + }); + } + } + + return results; +} + +/** + * Convert a Claude log entry into normalized TranscriptEntry items. + * An assistant message with tool calls produces multiple entries. + */ +export function claudeEntryToTranscript(entry: ClaudeLogEntry): TranscriptEntry[] { + const base: ClaudeBase = { provider: 'claude', model: entry.model, raw: entry.raw }; + + if (entry.type === 'user' && entry.message) { + const text = extractText(entry.message.content); + return text ? [{ ...base, role: 'user', timestamp: entry.timestamp, text }] : []; + } + + if (entry.type === 'assistant') return convertAssistant(entry, base); + + if (entry.type === 'system' || entry.type === 'progress') { + const text = entry.message ? extractText(entry.message.content) : ''; + return text ? [{ ...base, role: 'system', timestamp: entry.timestamp, text }] : []; + } + + return []; +} + +/** + * Claude Code transcript provider. + * Discovers logs via workspace path hash, reads JSONL session files. + */ +export const claudeTranscriptProvider: TranscriptProvider = { + async discoverLogPath(worker: Agent): Promise { + const workspacePath = worker.worktree || worker.repoPath; + const result = await getLogsForPane(workspacePath); + return result?.logPath ?? null; + }, + + async readEntries(logPath: string): Promise { + const entries = await readLogFile(logPath); + return entries.flatMap(claudeEntryToTranscript); + }, +}; diff --git a/src/lib/claude-transcript.test.ts b/src/lib/claude-transcript.test.ts new file mode 100644 index 000000000..7931589dd --- /dev/null +++ b/src/lib/claude-transcript.test.ts @@ -0,0 +1,114 @@ +/** + * Tests for Claude Code transcript adapter + * Run with: bun test src/lib/claude-transcript.test.ts + */ + +import { describe, expect, test } from 'bun:test'; +import type { ClaudeLogEntry } from './claude-logs.js'; +import { claudeEntryToTranscript } from './claude-logs.js'; + +function makeEntry(overrides: Partial): ClaudeLogEntry { + return { + type: 'user', + sessionId: 'test-session', + uuid: 'msg-1', + parentUuid: null, + timestamp: '2026-03-19T10:00:00.000Z', + cwd: '/tmp/test', + raw: {}, + ...overrides, + }; +} + +describe('claudeEntryToTranscript', () => { + test('converts user message', () => { + const entry = makeEntry({ + type: 'user', + message: { role: 'user', content: 'hello' }, + }); + const result = claudeEntryToTranscript(entry); + expect(result).toHaveLength(1); + expect(result[0].role).toBe('user'); + expect(result[0].text).toBe('hello'); + expect(result[0].provider).toBe('claude'); + }); + + test('converts assistant text message', () => { + const entry = makeEntry({ + type: 'assistant', + message: { role: 'assistant', content: 'I can help with that' }, + }); + const result = claudeEntryToTranscript(entry); + expect(result).toHaveLength(1); + expect(result[0].role).toBe('assistant'); + expect(result[0].text).toBe('I can help with that'); + }); + + test('converts assistant message with tool calls', () => { + const entry = makeEntry({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'Let me read that' }] }, + toolCalls: [ + { id: 'tc1', name: 'Read', input: { file_path: 'foo.ts' } }, + { id: 'tc2', name: 'Bash', input: { command: 'ls' } }, + ], + }); + const result = claudeEntryToTranscript(entry); + // 1 assistant text + 2 tool calls + expect(result).toHaveLength(3); + expect(result[0].role).toBe('assistant'); + expect(result[1].role).toBe('tool_call'); + expect(result[1].toolCall?.name).toBe('Read'); + expect(result[2].role).toBe('tool_call'); + expect(result[2].toolCall?.name).toBe('Bash'); + }); + + test('converts assistant with content array', () => { + const entry = makeEntry({ + type: 'assistant', + message: { + role: 'assistant', + content: [ + { type: 'text', text: 'first part' }, + { type: 'text', text: 'second part' }, + ], + }, + }); + const result = claudeEntryToTranscript(entry); + expect(result).toHaveLength(1); + expect(result[0].text).toBe('first part second part'); + }); + + test('skips file-history-snapshot', () => { + const entry = makeEntry({ type: 'file-history-snapshot' }); + expect(claudeEntryToTranscript(entry)).toEqual([]); + }); + + test('skips user with empty content', () => { + const entry = makeEntry({ + type: 'user', + message: { role: 'user', content: '' }, + }); + expect(claudeEntryToTranscript(entry)).toEqual([]); + }); + + test('converts system/progress entries', () => { + const entry = makeEntry({ + type: 'system', + message: { role: 'system', content: 'session started' }, + }); + const result = claudeEntryToTranscript(entry); + expect(result).toHaveLength(1); + expect(result[0].role).toBe('system'); + }); + + test('includes usage when present', () => { + const entry = makeEntry({ + type: 'assistant', + message: { role: 'assistant', content: 'response' }, + usage: { input_tokens: 100, output_tokens: 50 }, + }); + const result = claudeEntryToTranscript(entry); + expect(result[0].usage).toEqual({ input: 100, output: 50 }); + }); +}); diff --git a/src/lib/codex-logs.test.ts b/src/lib/codex-logs.test.ts new file mode 100644 index 000000000..215727a1d --- /dev/null +++ b/src/lib/codex-logs.test.ts @@ -0,0 +1,210 @@ +/** + * Tests for Codex log parsing and normalization + * Run with: bun test src/lib/codex-logs.test.ts + */ + +import { describe, expect, test } from 'bun:test'; +import { extractCodexContent, parseCodexLine } from './codex-logs.js'; + +// ============================================================================ +// Test Fixtures — real Codex JSONL lines +// ============================================================================ + +const sessionMeta = JSON.stringify({ + timestamp: '2026-03-19T16:06:10.906Z', + type: 'session_meta', + payload: { + id: '019d06d8-dee4-7970-8e18-133489a1221f', + cwd: '/Users/luis/project', + cli_version: '0.114.0', + source: 'vscode', + model_provider: 'openai', + }, +}); + +const userMessage = JSON.stringify({ + timestamp: '2026-03-19T16:06:10.909Z', + type: 'event_msg', + payload: { type: 'user_message', message: 'hello codex' }, +}); + +const agentMessage = JSON.stringify({ + timestamp: '2026-03-19T16:06:20.468Z', + type: 'event_msg', + payload: { type: 'agent_message', message: 'Hi! How can I help?', phase: 'final_answer' }, +}); + +const functionCall = JSON.stringify({ + timestamp: '2025-11-03T20:50:16.216Z', + type: 'response_item', + payload: { + type: 'function_call', + name: 'shell', + arguments: '{"command":["bash","-lc","cat foo.ts"],"workdir":"/Users/luis/project"}', + call_id: 'call_abc123', + }, +}); + +const functionCallOutput = JSON.stringify({ + timestamp: '2025-11-03T20:50:16.216Z', + type: 'response_item', + payload: { + type: 'function_call_output', + call_id: 'call_abc123', + output: '{"output":"file contents here"}', + }, +}); + +const webSearchCall = JSON.stringify({ + timestamp: '2026-03-19T16:44:59.862Z', + type: 'response_item', + payload: { + type: 'web_search_call', + status: 'completed', + action: { type: 'search', query: 'how to use bun:sqlite' }, + }, +}); + +const reasoning = JSON.stringify({ + timestamp: '2026-03-19T16:06:19.774Z', + type: 'response_item', + payload: { type: 'reasoning', summary: [], encrypted_content: 'gAAAA...' }, +}); + +const responseItemUser = JSON.stringify({ + timestamp: '2026-03-19T16:06:10.908Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'inline user message' }], + }, +}); + +const responseItemDeveloper = JSON.stringify({ + timestamp: '2026-03-19T16:06:10.908Z', + type: 'response_item', + payload: { + type: 'message', + role: 'developer', + content: [{ type: 'input_text', text: 'system instructions' }], + }, +}); + +const tokenCount = JSON.stringify({ + timestamp: '2026-03-19T16:06:20.491Z', + type: 'event_msg', + payload: { type: 'token_count', info: { total_token_usage: { input_tokens: 100, output_tokens: 50 } } }, +}); + +const taskComplete = JSON.stringify({ + timestamp: '2026-03-19T16:06:20.495Z', + type: 'event_msg', + payload: { type: 'task_complete', turn_id: 'turn-1' }, +}); + +// ============================================================================ +// Tests +// ============================================================================ + +describe('parseCodexLine', () => { + test('skips session_meta', () => { + expect(parseCodexLine(sessionMeta)).toEqual([]); + }); + + test('parses user_message event_msg', () => { + const entries = parseCodexLine(userMessage); + expect(entries).toHaveLength(1); + expect(entries[0].role).toBe('user'); + expect(entries[0].text).toBe('hello codex'); + expect(entries[0].provider).toBe('codex'); + }); + + test('parses agent_message event_msg', () => { + const entries = parseCodexLine(agentMessage); + expect(entries).toHaveLength(1); + expect(entries[0].role).toBe('assistant'); + expect(entries[0].text).toBe('Hi! How can I help?'); + }); + + test('parses function_call as tool_call', () => { + const entries = parseCodexLine(functionCall); + expect(entries).toHaveLength(1); + expect(entries[0].role).toBe('tool_call'); + expect(entries[0].toolCall?.name).toBe('shell'); + expect(entries[0].toolCall?.id).toBe('call_abc123'); + expect(entries[0].toolCall?.input).toHaveProperty('command'); + }); + + test('parses function_call_output as tool_result', () => { + const entries = parseCodexLine(functionCallOutput); + expect(entries).toHaveLength(1); + expect(entries[0].role).toBe('tool_result'); + expect(entries[0].text).toContain('file contents here'); + }); + + test('parses web_search_call as tool_call', () => { + const entries = parseCodexLine(webSearchCall); + expect(entries).toHaveLength(1); + expect(entries[0].role).toBe('tool_call'); + expect(entries[0].toolCall?.name).toBe('web_search'); + expect(entries[0].text).toContain('how to use bun:sqlite'); + }); + + test('skips reasoning entries', () => { + expect(parseCodexLine(reasoning)).toEqual([]); + }); + + test('parses response_item message with role user', () => { + const entries = parseCodexLine(responseItemUser); + expect(entries).toHaveLength(1); + expect(entries[0].role).toBe('user'); + expect(entries[0].text).toBe('inline user message'); + }); + + test('parses response_item message with role developer as system', () => { + const entries = parseCodexLine(responseItemDeveloper); + expect(entries).toHaveLength(1); + expect(entries[0].role).toBe('system'); + expect(entries[0].text).toBe('system instructions'); + }); + + test('skips token_count events', () => { + expect(parseCodexLine(tokenCount)).toEqual([]); + }); + + test('skips task_complete events', () => { + expect(parseCodexLine(taskComplete)).toEqual([]); + }); + + test('handles empty/invalid lines', () => { + expect(parseCodexLine('')).toEqual([]); + expect(parseCodexLine('not json')).toEqual([]); + expect(parseCodexLine('{}')).toEqual([]); + }); +}); + +describe('extractCodexContent', () => { + test('extracts from string', () => { + expect(extractCodexContent('hello')).toBe('hello'); + }); + + test('extracts from content array with text', () => { + const content = [ + { type: 'text', text: 'hello' }, + { type: 'text', text: 'world' }, + ]; + expect(extractCodexContent(content)).toBe('hello world'); + }); + + test('extracts from content array with input_text', () => { + const content = [{ type: 'input_text', input_text: 'user input' }]; + expect(extractCodexContent(content)).toBe('user input'); + }); + + test('handles empty/null', () => { + expect(extractCodexContent(null)).toBe(''); + expect(extractCodexContent([])).toBe(''); + expect(extractCodexContent(undefined)).toBe(''); + }); +}); diff --git a/src/lib/codex-logs.ts b/src/lib/codex-logs.ts new file mode 100644 index 000000000..5ed3178f7 --- /dev/null +++ b/src/lib/codex-logs.ts @@ -0,0 +1,308 @@ +/** + * Codex Log Discovery and Parsing — TranscriptProvider adapter. + * + * Codex stores session logs as JSONL at: + * ~/.codex/sessions///
/rollout--.jsonl + * + * Thread metadata lives in SQLite: + * ~/.codex/state_5.sqlite (table: threads, column: rollout_path) + * + * Event types in JSONL: + * - session_meta: Session initialization + * - response_item: Model messages (user, assistant, tool calls, reasoning) + * - event_msg: Turn lifecycle (user_message, agent_message, task_complete, etc.) + * - turn_context: Per-turn workspace metadata + */ + +import { access, readFile, readdir } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import type { Agent } from './agent-registry.js'; +import type { TranscriptEntry, TranscriptProvider } from './transcript.js'; + +// ============================================================================ +// Paths +// ============================================================================ + +function getCodexDir(): string { + return join(homedir(), '.codex'); +} + +function getSessionsDir(): string { + return join(getCodexDir(), 'sessions'); +} + +function getStateDbPath(): string { + return join(getCodexDir(), 'state_5.sqlite'); +} + +// ============================================================================ +// Log Discovery +// ============================================================================ + +/** + * Discover the Codex log file for a worker via SQLite thread lookup. + * Falls back to scanning session directories by date. + */ +async function discoverLogPath(worker: Agent): Promise { + const cwd = worker.worktree || worker.repoPath; + const sqlitePath = await discoverViaSqlite(cwd); + if (sqlitePath) { + try { + await access(sqlitePath); + return sqlitePath; + } catch { + // Stale path — fall through to scan + } + } + return discoverViaScan(cwd); +} + +/** + * Query ~/.codex/state_5.sqlite for the most recent thread matching the CWD. + */ +async function discoverViaSqlite(cwd: string): Promise { + try { + const { Database } = await import('bun:sqlite'); + const dbPath = getStateDbPath(); + const db = new Database(dbPath, { readonly: true }); + try { + const row = db + .query('SELECT rollout_path FROM threads WHERE cwd = ? ORDER BY updated_at DESC LIMIT 1') + .get(cwd) as { rollout_path: string } | null; + return row?.rollout_path ?? null; + } finally { + db.close(); + } + } catch { + return null; + } +} + +/** List subdirectories matching a regex pattern, sorted descending. */ +async function listDirsDesc(parent: string, pattern: RegExp): Promise { + const entries = await readdir(parent); + return entries + .filter((d) => pattern.test(d)) + .sort() + .reverse(); +} + +/** + * Scan ~/.codex/sessions/ directories for the most recent JSONL matching a CWD. + */ +async function discoverViaScan(cwd: string): Promise { + const sessionsDir = getSessionsDir(); + try { + const years = await listDirsDesc(sessionsDir, /^\d{4}$/); + for (const year of years.slice(0, 2)) { + const result = await scanYear(join(sessionsDir, year), cwd); + if (result) return result; + } + } catch { + // Sessions directory doesn't exist + } + return null; +} + +async function scanYear(yearDir: string, cwd: string): Promise { + const months = await listDirsDesc(yearDir, /^\d{2}$/); + for (const month of months.slice(0, 2)) { + const result = await scanMonth(join(yearDir, month), cwd); + if (result) return result; + } + return null; +} + +async function scanMonth(monthDir: string, cwd: string): Promise { + const days = await listDirsDesc(monthDir, /^\d{2}$/); + for (const day of days.slice(0, 3)) { + const result = await scanDay(join(monthDir, day), cwd); + if (result) return result; + } + return null; +} + +async function scanDay(dayDir: string, cwd: string): Promise { + const files = (await readdir(dayDir)) + .filter((f) => f.endsWith('.jsonl')) + .sort() + .reverse(); + for (const file of files.slice(0, 5)) { + const filePath = join(dayDir, file); + const meta = await readSessionMeta(filePath); + if (meta?.cwd === cwd) return filePath; + } + return null; +} + +/** + * Read the first line of a JSONL file to extract session_meta CWD. + */ +async function readSessionMeta(filePath: string): Promise<{ cwd: string } | null> { + try { + const content = await readFile(filePath, 'utf-8'); + const nlIdx = content.indexOf('\n'); + const firstLine = nlIdx === -1 ? content : content.slice(0, nlIdx); + const entry = JSON.parse(firstLine); + if (entry.type === 'session_meta' && entry.payload?.cwd) { + return { cwd: entry.payload.cwd }; + } + } catch { + // Invalid file + } + return null; +} + +// ============================================================================ +// Entry Parsing +// ============================================================================ + +interface CodexRawEntry { + timestamp: string; + type: string; + payload: Record; +} + +type EntryBase = { provider: 'codex'; raw: Record }; + +function parseEventMsg(payload: Record, ts: string, base: EntryBase): TranscriptEntry[] { + if (payload.type === 'user_message') { + const text = String(payload.message ?? ''); + return text ? [{ ...base, role: 'user', timestamp: ts, text }] : []; + } + if (payload.type === 'agent_message') { + const text = String(payload.message ?? ''); + return text ? [{ ...base, role: 'assistant', timestamp: ts, text }] : []; + } + return []; +} + +function parseResponseMessage(payload: Record, ts: string, base: EntryBase): TranscriptEntry[] { + const role = payload.role as string; + const text = extractCodexContent(payload.content); + if (!text) return []; + + if (role === 'user') return [{ ...base, role: 'user', timestamp: ts, text }]; + if (role === 'developer') return [{ ...base, role: 'system', timestamp: ts, text }]; + if (role === 'assistant') return [{ ...base, role: 'assistant', timestamp: ts, text }]; + return []; +} + +function parseFunctionCall(payload: Record, ts: string, base: EntryBase): TranscriptEntry[] { + const name = String(payload.name ?? payload.type); + const callId = String(payload.call_id ?? ''); + let input: Record = {}; + try { + input = typeof payload.arguments === 'string' ? JSON.parse(payload.arguments) : {}; + } catch { + input = { raw: payload.arguments }; + } + const cmdText = input.command ? String(Array.isArray(input.command) ? input.command.join(' ') : input.command) : name; + return [ + { + ...base, + role: 'tool_call', + timestamp: ts, + text: `${name}: ${cmdText.slice(0, 200)}`, + toolCall: { id: callId, name, input }, + }, + ]; +} + +function parseWebSearch(payload: Record, ts: string, base: EntryBase): TranscriptEntry[] { + const action = payload.action as Record | undefined; + const query = String(action?.query ?? 'web search'); + return [ + { + ...base, + role: 'tool_call', + timestamp: ts, + text: `web_search: ${query.slice(0, 200)}`, + toolCall: { id: '', name: 'web_search', input: { query } }, + }, + ]; +} + +function parseResponseItem(payload: Record, ts: string, base: EntryBase): TranscriptEntry[] { + if (payload.type === 'message') return parseResponseMessage(payload, ts, base); + if (payload.type === 'function_call' || payload.type === 'shell') return parseFunctionCall(payload, ts, base); + if (payload.type === 'function_call_output') { + const output = String(payload.output ?? '').slice(0, 500); + return [{ ...base, role: 'tool_result' as const, timestamp: ts, text: output }]; + } + if (payload.type === 'web_search_call') return parseWebSearch(payload, ts, base); + return []; +} + +/** + * Parse a single Codex JSONL line into TranscriptEntry items. + */ +function parseCodexLine(line: string): TranscriptEntry[] { + if (!line.trim()) return []; + + let raw: CodexRawEntry; + try { + raw = JSON.parse(line); + } catch { + return []; + } + + if (!raw.type || !raw.timestamp) return []; + + const base: EntryBase = { provider: 'codex', raw: raw as unknown as Record }; + + if (!raw.payload || typeof raw.payload !== 'object') return []; + + if (raw.type === 'event_msg') return parseEventMsg(raw.payload, raw.timestamp, base); + if (raw.type === 'response_item') return parseResponseItem(raw.payload, raw.timestamp, base); + return []; +} + +/** + * Extract text from Codex content array. + */ +function extractCodexContent(content: unknown): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + const parts: string[] = []; + for (const item of content) { + if (typeof item === 'string') { + parts.push(item); + } else if (item && typeof item === 'object') { + if ('text' in item) parts.push(String(item.text)); + else if ('input_text' in item) parts.push(String((item as { input_text: string }).input_text)); + } + } + return parts.join(' '); +} + +// ============================================================================ +// Provider +// ============================================================================ + +/** + * Read all transcript entries from a Codex JSONL log file. + */ +async function readEntries(logPath: string): Promise { + let content: string; + try { + content = await readFile(logPath, 'utf-8'); + } catch { + return []; + } + + return content.split('\n').flatMap(parseCodexLine); +} + +/** + * Codex transcript provider. + * Discovers logs via SQLite thread lookup or directory scan. + */ +export const codexTranscriptProvider: TranscriptProvider = { + discoverLogPath, + readEntries, +}; + +// Exported for testing +export { parseCodexLine, discoverViaSqlite, discoverViaScan, extractCodexContent }; diff --git a/src/lib/transcript.test.ts b/src/lib/transcript.test.ts new file mode 100644 index 000000000..78c2a2bef --- /dev/null +++ b/src/lib/transcript.test.ts @@ -0,0 +1,98 @@ +/** + * Tests for transcript provider abstraction + * Run with: bun test src/lib/transcript.test.ts + */ + +import { describe, expect, test } from 'bun:test'; +import type { TranscriptEntry } from './transcript.js'; +import { applyFilter } from './transcript.js'; + +// ============================================================================ +// Test Fixtures +// ============================================================================ + +function makeEntry(overrides: Partial): TranscriptEntry { + return { + role: 'assistant', + timestamp: '2026-03-19T10:00:00.000Z', + text: 'test', + provider: 'claude', + raw: {}, + ...overrides, + }; +} + +const entries: TranscriptEntry[] = [ + makeEntry({ role: 'user', timestamp: '2026-03-19T09:00:00.000Z', text: 'hello' }), + makeEntry({ role: 'assistant', timestamp: '2026-03-19T09:01:00.000Z', text: 'hi there' }), + makeEntry({ + role: 'tool_call', + timestamp: '2026-03-19T09:02:00.000Z', + text: 'Read: foo.ts', + toolCall: { id: 'tc1', name: 'Read', input: { file_path: 'foo.ts' } }, + }), + makeEntry({ role: 'assistant', timestamp: '2026-03-19T09:03:00.000Z', text: 'I read the file' }), + makeEntry({ role: 'user', timestamp: '2026-03-19T10:00:00.000Z', text: 'now edit it' }), + makeEntry({ role: 'tool_call', timestamp: '2026-03-19T10:01:00.000Z', text: 'Edit: foo.ts' }), + makeEntry({ role: 'assistant', timestamp: '2026-03-19T10:02:00.000Z', text: 'done editing' }), + makeEntry({ role: 'system', timestamp: '2026-03-19T10:03:00.000Z', text: 'session end' }), +]; + +// ============================================================================ +// Tests +// ============================================================================ + +describe('applyFilter', () => { + test('returns all entries when no filter', () => { + expect(applyFilter(entries)).toEqual(entries); + expect(applyFilter(entries, {})).toEqual(entries); + }); + + test('filters by role', () => { + const result = applyFilter(entries, { roles: ['user'] }); + expect(result).toHaveLength(2); + expect(result.every((e) => e.role === 'user')).toBe(true); + }); + + test('filters by multiple roles', () => { + const result = applyFilter(entries, { roles: ['user', 'assistant'] }); + expect(result).toHaveLength(5); + expect(result.every((e) => e.role === 'user' || e.role === 'assistant')).toBe(true); + }); + + test('filters by since timestamp', () => { + const result = applyFilter(entries, { since: '2026-03-19T10:00:00.000Z' }); + expect(result).toHaveLength(4); + expect(result[0].text).toBe('now edit it'); + }); + + test('applies last N', () => { + const result = applyFilter(entries, { last: 3 }); + expect(result).toHaveLength(3); + // Last 3: tool_call(Edit), assistant(done editing), system(session end) + expect(result[0].role).toBe('tool_call'); + expect(result[1].text).toBe('done editing'); + expect(result[2].text).toBe('session end'); + }); + + test('applies filters in order: since → roles → last', () => { + const result = applyFilter(entries, { + since: '2026-03-19T09:01:00.000Z', + roles: ['assistant'], + last: 2, + }); + // since filters to 7 entries (09:01+), roles to 3 assistants, last 2 + expect(result).toHaveLength(2); + expect(result[0].text).toBe('I read the file'); + expect(result[1].text).toBe('done editing'); + }); + + test('handles empty entries', () => { + expect(applyFilter([], { roles: ['user'] })).toEqual([]); + }); + + test('last larger than array returns all', () => { + const result = applyFilter(entries, { last: 100 }); + expect(result).toHaveLength(entries.length); + }); +}); diff --git a/src/lib/transcript.ts b/src/lib/transcript.ts new file mode 100644 index 000000000..f64f29ab0 --- /dev/null +++ b/src/lib/transcript.ts @@ -0,0 +1,128 @@ +/** + * Transcript — Provider-agnostic transcript reading. + * + * Normalizes conversation logs from Claude Code and Codex into a + * unified `TranscriptEntry` format with filtering and NDJSON output. + * + * Usage: + * const entries = await readTranscript(worker, { last: 10, roles: ['assistant'] }); + */ + +import type { Agent } from './agent-registry.js'; +import type { ProviderName } from './provider-adapters.js'; + +// ============================================================================ +// Types +// ============================================================================ + +export type TranscriptRole = 'user' | 'assistant' | 'system' | 'tool_call' | 'tool_result'; + +export interface TranscriptEntry { + /** Normalized role */ + role: TranscriptRole; + /** ISO timestamp */ + timestamp: string; + /** Extracted text content */ + text: string; + /** Tool call details (when role === 'tool_call') */ + toolCall?: { id: string; name: string; input: Record }; + /** Provider that produced this entry */ + provider: ProviderName; + /** Model name if available */ + model?: string; + /** Token usage if available */ + usage?: { input: number; output: number }; + /** Raw entry for --raw mode */ + raw: Record; +} + +export interface TranscriptFilter { + /** Return only last N entries (applied after role/since filtering) */ + last?: number; + /** Only entries after this ISO timestamp */ + since?: string; + /** Only entries matching these roles */ + roles?: TranscriptRole[]; +} + +export interface TranscriptProvider { + /** Find the log file path for a worker */ + discoverLogPath(worker: Agent): Promise; + /** Read entries from a log file */ + readEntries(logPath: string): Promise; +} + +// ============================================================================ +// Filter +// ============================================================================ + +/** + * Apply filters to transcript entries. Order: since → roles → last. + */ +export function applyFilter(entries: TranscriptEntry[], filter?: TranscriptFilter): TranscriptEntry[] { + if (!filter) return entries; + + let result = entries; + + if (filter.since) { + const sinceMs = new Date(filter.since).getTime(); + result = result.filter((e) => new Date(e.timestamp).getTime() >= sinceMs); + } + + if (filter.roles && filter.roles.length > 0) { + const roles = new Set(filter.roles); + result = result.filter((e) => roles.has(e.role)); + } + + if (filter.last && filter.last > 0) { + result = result.slice(-filter.last); + } + + return result; +} + +// ============================================================================ +// Dispatch +// ============================================================================ + +let _claudeProvider: TranscriptProvider | undefined; +let _codexProvider: TranscriptProvider | undefined; + +async function getClaudeProvider(): Promise { + if (!_claudeProvider) { + const mod = await import('./claude-logs.js'); + _claudeProvider = mod.claudeTranscriptProvider; + } + return _claudeProvider; +} + +async function getCodexProvider(): Promise { + if (!_codexProvider) { + const mod = await import('./codex-logs.js'); + _codexProvider = mod.codexTranscriptProvider; + } + return _codexProvider; +} + +/** + * Get the transcript provider for a worker based on its provider field. + * Defaults to Claude for legacy workers without a provider field. + */ +export async function getProvider(worker: Agent): Promise { + const provider = worker.provider ?? 'claude'; + if (provider === 'codex') return getCodexProvider(); + return getClaudeProvider(); +} + +/** + * Read transcript entries for a worker with optional filtering. + * Discovers the log file, reads entries, and applies filters. + */ +export async function readTranscript(worker: Agent, filter?: TranscriptFilter): Promise { + const provider = await getProvider(worker); + const logPath = await provider.discoverLogPath(worker); + if (!logPath) return []; + + const entries = await provider.readEntries(logPath); + return applyFilter(entries, filter); +} diff --git a/src/term-commands/history.ts b/src/term-commands/history.ts index 52d506e3a..2819f4a6f 100644 --- a/src/term-commands/history.ts +++ b/src/term-commands/history.ts @@ -2,17 +2,21 @@ * Worker History Command - Session catch-up with compression * * Produces a compressed summary of a worker's session by parsing - * Claude's JSONL logs and extracting key events. + * provider logs (Claude Code or Codex) and extracting key events. * * Usage: - * genie history # Compressed summary - * genie history --full # Full conversation - * genie history --since 5 # Last 5 exchanges - * genie history --json # JSON output + * genie history # Compressed summary + * genie history --full # Full conversation + * genie history --since 5 # Last 5 user/assistant exchanges + * genie history --last 20 # Last 20 transcript entries + * genie history --type assistant # Only assistant messages + * genie history --json # JSON output + * genie history --ndjson # Newline-delimited JSON (pipeable) + * genie history --ndjson | jq '.text' # Pipe to jq */ import * as workerRegistry from '../lib/agent-registry.js'; -import * as claudeLogs from '../lib/claude-logs.js'; +import type { TranscriptEntry, TranscriptFilter, TranscriptRole } from '../lib/transcript.js'; // ============================================================================ // Types @@ -21,14 +25,22 @@ import * as claudeLogs from '../lib/claude-logs.js'; export interface HistoryOptions { /** Show full conversation without compression */ full?: boolean; - /** Show last N user/assistant exchanges */ + /** Show last N user/assistant exchanges (legacy) */ since?: number; + /** Show last N transcript entries */ + last?: number; + /** Filter by role (user, assistant, tool_call, system) */ + type?: string; /** Output as JSON */ json?: boolean; + /** Output as newline-delimited JSON */ + ndjson?: boolean; /** Show raw JSONL entries */ raw?: boolean; /** Direct path to log file (for testing/debugging) */ logFile?: string; + /** ISO timestamp — only entries after this time */ + after?: string; } /** Compressed event for display */ @@ -45,8 +57,9 @@ interface SessionStats { workerId: string; taskId?: string; branch?: string; + provider: string; duration: string; - totalLines: number; + totalEntries: number; compressedLines: number; compressionRatio: number; exchanges: number; @@ -55,33 +68,9 @@ interface SessionStats { } // ============================================================================ -// Event Extraction +// Event Extraction (from TranscriptEntry) // ============================================================================ -/** - * Extract text content from a message content array or string - */ -function extractTextContent(content: unknown): string { - if (typeof content === 'string') { - return content; - } - if (Array.isArray(content)) { - const textParts: string[] = []; - for (const item of content) { - if (typeof item === 'string') { - textParts.push(item); - } else if (item && typeof item === 'object' && 'text' in item) { - textParts.push(String(item.text)); - } - } - return textParts.join(' '); - } - return ''; -} - -/** - * Format a timestamp for display (HH:MM) - */ function formatTime(timestamp: string): string { try { const date = new Date(timestamp); @@ -91,19 +80,12 @@ function formatTime(timestamp: string): string { } } -/** - * Truncate a string with ellipsis - */ function truncate(str: string, maxLen: number): string { if (str.length <= maxLen) return str; return `${str.slice(0, maxLen - 3)}...`; } -/** - * Format file path for display (shorten if needed) - */ function formatPath(path: string): string { - // Remove common prefixes const shortened = path.replace(/^\/home\/\w+\/workspace\//, '~/').replace(/^\/home\/\w+\//, '~/'); return truncate(shortened, 50); } @@ -150,79 +132,86 @@ function flushAll(ctx: EventExtractionContext): void { flushPendingEdits(ctx); } -function processToolCall(tool: { name: string; input: unknown }, timestamp: string, ctx: EventExtractionContext): void { - const input = tool.input as Record; - switch (tool.name) { +function processToolCallEntry(entry: TranscriptEntry, ctx: EventExtractionContext): void { + if (!entry.toolCall) return; + const { name, input } = entry.toolCall; + const inputRecord = input as Record; + + // Normalize Codex shell/exec_command to match Claude tool names + const normalizedName = name === 'shell' || name === 'exec_command' ? 'Bash' : name; + + switch (normalizedName) { case 'Read': - ctx.lastReadTime = timestamp; - if (input.file_path) ctx.pendingReads.push(String(input.file_path)); + ctx.lastReadTime = entry.timestamp; + if (inputRecord.file_path) ctx.pendingReads.push(String(inputRecord.file_path)); break; case 'Edit': flushPendingReads(ctx); - ctx.lastEditTime = timestamp; - if (input.file_path) ctx.pendingEdits.push(String(input.file_path)); + ctx.lastEditTime = entry.timestamp; + if (inputRecord.file_path) ctx.pendingEdits.push(String(inputRecord.file_path)); break; case 'Write': flushAll(ctx); ctx.events.push({ - timestamp, + timestamp: entry.timestamp, type: 'write', - summary: `Write: ${formatPath(String(input.file_path || 'unknown'))}`, + summary: `Write: ${formatPath(String(inputRecord.file_path || 'unknown'))}`, }); break; case 'Bash': { flushAll(ctx); - const cmd = String(input.command || '').replace(/\n/g, ' '); - ctx.events.push({ timestamp, type: 'bash', summary: `Bash: ${truncate(cmd, 60)}` }); + const cmd = String(inputRecord.command || '').replace(/\n/g, ' '); + ctx.events.push({ timestamp: entry.timestamp, type: 'bash', summary: `Bash: ${truncate(cmd, 60)}` }); break; } case 'AskUserQuestion': { flushAll(ctx); - const questions = input.questions as Array<{ question?: string }> | undefined; + const questions = inputRecord.questions as Array<{ question?: string }> | undefined; ctx.events.push({ - timestamp, + timestamp: entry.timestamp, type: 'question', summary: `Question: ${truncate(questions?.[0]?.question || 'question', 60)}`, }); break; } + default: { + flushAll(ctx); + ctx.events.push({ + timestamp: entry.timestamp, + type: 'bash', + summary: `${normalizedName}: ${truncate(entry.text.replace(/\n/g, ' '), 60)}`, + }); + } } } -function processLogEntry(entry: claudeLogs.ClaudeLogEntry, ctx: EventExtractionContext): void { - if (entry.type === 'user' && entry.message) { +function processTranscriptEntry(entry: TranscriptEntry, ctx: EventExtractionContext): void { + if (entry.role === 'user') { flushAll(ctx); - const text = extractTextContent(entry.message.content); - if (text) { - ctx.events.push({ timestamp: entry.timestamp, type: 'prompt', summary: truncate(text.replace(/\n/g, ' '), 80) }); - } + ctx.events.push({ + timestamp: entry.timestamp, + type: 'prompt', + summary: truncate(entry.text.replace(/\n/g, ' '), 80), + }); return; } - if (entry.type === 'assistant' && entry.toolCalls) { - for (const tool of entry.toolCalls) { - processToolCall(tool, entry.timestamp, ctx); - } + if (entry.role === 'tool_call') { + processToolCallEntry(entry, ctx); return; } - if (entry.type === 'assistant' && entry.message && !entry.toolCalls) { + if (entry.role === 'assistant' && entry.text.length > 100) { flushAll(ctx); - const text = extractTextContent(entry.message.content); - if (text && text.length > 100) { - ctx.events.push({ - timestamp: entry.timestamp, - type: 'response', - summary: truncate(text.replace(/\n/g, ' '), 80), - }); - } + ctx.events.push({ + timestamp: entry.timestamp, + type: 'response', + summary: truncate(entry.text.replace(/\n/g, ' '), 80), + }); } } -/** - * Extract compressed events from log entries - */ -function extractEvents(entries: claudeLogs.ClaudeLogEntry[]): CompressedEvent[] { +function extractEvents(entries: TranscriptEntry[]): CompressedEvent[] { const ctx: EventExtractionContext = { events: [], pendingReads: [], @@ -232,40 +221,24 @@ function extractEvents(entries: claudeLogs.ClaudeLogEntry[]): CompressedEvent[] }; for (const entry of entries) { - processLogEntry(entry, ctx); + processTranscriptEntry(entry, ctx); } flushAll(ctx); return ctx.events; } -/** - * Detect current worker status from last entries - */ -function detectStatusFromEntry(entry: claudeLogs.ClaudeLogEntry): string | null { - if (entry.type === 'assistant' && entry.toolCalls) { - const lastTool = entry.toolCalls[entry.toolCalls.length - 1]; - if (lastTool.name === 'AskUserQuestion') return 'question'; - } - if (entry.type === 'progress' && entry.data) { - const data = entry.data as Record; - if (data.type === 'permission_request') return 'permission'; - } - return null; -} - -function detectStatus(entries: claudeLogs.ClaudeLogEntry[]): string { +function detectStatus(entries: TranscriptEntry[]): string { if (entries.length === 0) return 'unknown'; const lastEntries = entries.slice(-10); for (const entry of lastEntries.reverse()) { - const status = detectStatusFromEntry(entry); - if (status) return status; + if (entry.role === 'tool_call' && entry.toolCall?.name === 'AskUserQuestion') return 'question'; } - const lastEntry = entries[entries.length - 1]; - if (lastEntry.type === 'user') return 'working'; - if (lastEntry.type === 'assistant') return 'idle'; + const last = entries[entries.length - 1]; + if (last.role === 'user') return 'working'; + if (last.role === 'assistant') return 'idle'; return 'unknown'; } @@ -273,26 +246,20 @@ function detectStatus(entries: claudeLogs.ClaudeLogEntry[]): string { // Display Formatting // ============================================================================ -/** - * Format events for terminal display - */ function formatEventsForDisplay(events: CompressedEvent[], stats: SessionStats): string { const lines: string[] = []; - // Header lines.push(''); lines.push( - `Session: ${stats.workerId}${stats.branch ? ` (${stats.branch})` : ''} | ${stats.duration} | ${stats.totalLines} lines → ${stats.compressedLines} lines`, + `Session: ${stats.workerId} [${stats.provider}]${stats.branch ? ` (${stats.branch})` : ''} | ${stats.duration} | ${stats.totalEntries} entries → ${stats.compressedLines} lines`, ); lines.push(''); - // Events for (const event of events) { const time = formatTime(event.timestamp); const icon = getEventIcon(event.type); lines.push(`[${time}] ${icon} ${event.summary}`); - // Show details if present if (event.details && event.details.length > 0) { for (const detail of event.details.slice(0, 5)) { lines.push(` ${detail}`); @@ -302,13 +269,11 @@ function formatEventsForDisplay(events: CompressedEvent[], stats: SessionStats): } } - // Show result if present if (event.result) { lines.push(` → ${event.result}`); } } - // Footer lines.push(''); lines.push( `Status: ${stats.status.toUpperCase()} | ${stats.exchanges} exchanges | ${stats.toolCalls} tool calls | ${stats.compressionRatio.toFixed(0)}x compression`, @@ -318,9 +283,6 @@ function formatEventsForDisplay(events: CompressedEvent[], stats: SessionStats): return lines.join('\n'); } -/** - * Get icon for event type - */ function getEventIcon(type: CompressedEvent['type']): string { switch (type) { case 'prompt': @@ -348,113 +310,144 @@ function getEventIcon(type: CompressedEvent['type']): string { } } -/** - * Format full conversation for display - */ -function formatToolCallLine(tool: { name: string; input: unknown }): string { - const input = tool.input as Record; - if (tool.name === 'Bash') return ` ${tool.name}: ${input.command}`; - if (tool.name === 'Read' || tool.name === 'Edit' || tool.name === 'Write') - return ` ${tool.name}: ${input.file_path}`; - return ` ${tool.name}`; -} - -function formatEntryForDisplay(entry: claudeLogs.ClaudeLogEntry): string[] { +function formatTranscriptEntryForDisplay(entry: TranscriptEntry): string[] { const time = formatTime(entry.timestamp); - if (entry.type === 'user' && entry.message) { - return [`\n[${time}] USER:`, extractTextContent(entry.message.content)]; + if (entry.role === 'user') { + return [`\n[${time}] USER:`, entry.text]; + } + + if (entry.role === 'tool_call' && entry.toolCall) { + const input = entry.toolCall.input as Record; + let detail: string; + if (entry.toolCall.name === 'Bash' || entry.toolCall.name === 'shell') { + detail = ` ${entry.toolCall.name}: ${input.command ?? ''}`; + } else if (['Read', 'Edit', 'Write'].includes(entry.toolCall.name)) { + detail = ` ${entry.toolCall.name}: ${input.file_path ?? ''}`; + } else { + detail = ` ${entry.toolCall.name}`; + } + return [`\n[${time}] TOOL:`, detail]; } - if (entry.type !== 'assistant') return []; + if (entry.role === 'assistant') { + const text = entry.text.slice(0, 500) + (entry.text.length > 500 ? '...' : ''); + return [`\n[${time}] ASSISTANT:`, text]; + } - if (entry.toolCalls && entry.toolCalls.length > 0) { - return [`\n[${time}] ASSISTANT (tools):`, ...entry.toolCalls.map(formatToolCallLine)]; + if (entry.role === 'tool_result') { + const text = entry.text.slice(0, 500) + (entry.text.length > 500 ? '...' : ''); + return [`\n[${time}] RESULT:`, ` ${text}`]; } - if (entry.message) { - const text = extractTextContent(entry.message.content); - if (text) { - return [`\n[${time}] ASSISTANT:`, text.slice(0, 500) + (text.length > 500 ? '...' : '')]; - } + if (entry.role === 'system') { + return [`\n[${time}] SYSTEM:`, entry.text]; } return []; } -function formatFullConversation(entries: claudeLogs.ClaudeLogEntry[]): string { - return entries.flatMap(formatEntryForDisplay).join('\n'); +function formatFullConversation(entries: TranscriptEntry[]): string { + return entries.flatMap(formatTranscriptEntryForDisplay).join('\n'); } // ============================================================================ // Main Command // ============================================================================ -/** - * Find worker by ID or task ID - */ async function findWorker(identifier: string): Promise { - // Try direct ID lookup first let worker = await workerRegistry.get(identifier); if (worker) return worker; - // Try task ID lookup worker = await workerRegistry.findByTask(identifier); if (worker) return worker; - // Try partial match on worker ID const allWorkers = await workerRegistry.list(); - const match = allWorkers.find( - (w) => - w.id.includes(identifier) || - w.taskId?.includes(identifier) || - w.taskTitle?.toLowerCase().includes(identifier.toLowerCase()), + return ( + allWorkers.find( + (w) => + w.id.includes(identifier) || + w.taskId?.includes(identifier) || + w.taskTitle?.toLowerCase().includes(identifier.toLowerCase()), + ) ?? null ); - - return match || null; } -/** Resolved log context for history display */ -interface LogContext { - logPath: string; +/** Resolved context for transcript reading */ +interface TranscriptContext { + worker: workerRegistry.Agent; workerId: string; + provider: string; branch?: string; duration: string; } -async function resolveLogContext(workerIdOrName: string, options: HistoryOptions): Promise { +async function resolveContext(workerIdOrName: string, options: HistoryOptions): Promise { if (options.logFile) { - return { logPath: options.logFile, workerId: 'direct', duration: 'N/A' }; + // Direct log file mode — create a stub worker for Claude provider + const stub: workerRegistry.Agent = { + id: 'direct', + paneId: '', + session: '', + worktree: null, + startedAt: new Date().toISOString(), + state: 'idle', + lastStateChange: new Date().toISOString(), + repoPath: process.cwd(), + provider: 'claude', + }; + return { worker: stub, workerId: 'direct', provider: 'claude', duration: 'N/A' }; } const worker = await findWorker(workerIdOrName); if (!worker) { - console.error(`❌ Agent "${workerIdOrName}" not found.`); - console.error(' Run `genie ls` to see agents.'); - process.exit(1); - } - - const workspacePath = worker.worktree || worker.repoPath; - const logInfo = await claudeLogs.getLogsForPane(workspacePath); - if (!logInfo) { - console.error(`❌ No Claude logs found for agent "${worker.id}"`); - console.error(` Workspace: ${workspacePath}`); + console.error(`Agent "${workerIdOrName}" not found. Run \`genie ls\` to see agents.`); process.exit(1); } const elapsed = workerRegistry.getElapsedTime(worker); return { - logPath: logInfo.logPath, + worker, workerId: worker.id, + provider: worker.provider ?? 'claude', branch: worker.worktree ? `work/${worker.taskId}` : undefined, duration: elapsed.formatted, }; } -function filterSinceExchanges(entries: claudeLogs.ClaudeLogEntry[], since: number): claudeLogs.ClaudeLogEntry[] { +/** + * Build a TranscriptFilter from CLI options. + */ +function buildFilter(options: HistoryOptions): TranscriptFilter | undefined { + const filter: TranscriptFilter = {}; + let hasFilter = false; + + if (options.last && options.last > 0) { + filter.last = options.last; + hasFilter = true; + } + + if (options.after) { + filter.since = options.after; + hasFilter = true; + } + + if (options.type) { + filter.roles = [options.type as TranscriptRole]; + hasFilter = true; + } + + return hasFilter ? filter : undefined; +} + +/** + * Filter entries by exchange count (last N user turns + everything after). + * Legacy --since behavior. + */ +function filterSinceExchanges(entries: TranscriptEntry[], since: number): TranscriptEntry[] { let userCount = 0; for (let i = entries.length - 1; i >= 0; i--) { - if (entries[i].type === 'user') { + if (entries[i].role === 'user') { userCount++; if (userCount >= since) return entries.slice(i); } @@ -462,44 +455,69 @@ function filterSinceExchanges(entries: claudeLogs.ClaudeLogEntry[], since: numbe return entries; } +async function loadEntries(ctx: TranscriptContext, options: HistoryOptions): Promise { + const { readTranscript, getProvider } = await import('../lib/transcript.js'); + if (options.logFile) { + const provider = await getProvider(ctx.worker); + return provider.readEntries(options.logFile); + } + return readTranscript(ctx.worker); +} + +function filterEntries(entries: TranscriptEntry[], options: HistoryOptions): TranscriptEntry[] { + const { applyFilter } = require('../lib/transcript.js') as typeof import('../lib/transcript.js'); + let filtered = options.since && options.since > 0 ? filterSinceExchanges(entries, options.since) : entries; + const transcriptFilter = buildFilter(options); + if (transcriptFilter) filtered = applyFilter(filtered, transcriptFilter); + return filtered; +} + +function outputEntries(filtered: TranscriptEntry[], options: HistoryOptions): boolean { + if (options.ndjson) { + for (const entry of filtered) { + const { raw: _raw, ...rest } = entry; + console.log(JSON.stringify(options.raw ? entry : rest)); + } + return true; + } + if (options.raw) { + for (const entry of filtered) console.log(JSON.stringify(entry.raw)); + return true; + } + if (options.full) { + console.log(formatFullConversation(filtered)); + return true; + } + return false; +} + /** * Main history command handler */ export async function historyCommand(workerIdOrName: string, options: HistoryOptions): Promise { - const { logPath, workerId, branch, duration } = await resolveLogContext(workerIdOrName, options); + const ctx = await resolveContext(workerIdOrName, options); + const entries = await loadEntries(ctx, options); - const entries = await claudeLogs.readLogFile(logPath); if (entries.length === 0) { - console.error(`❌ Log file is empty: ${logPath}`); + console.error(`No transcript found for agent "${ctx.workerId}".`); process.exit(1); } - const conversationEntries = entries.filter((e) => e.type === 'user' || e.type === 'assistant'); - const filteredEntries = - options.since && options.since > 0 ? filterSinceExchanges(conversationEntries, options.since) : conversationEntries; - - if (options.raw) { - for (const entry of filteredEntries) { - console.log(JSON.stringify(entry.raw)); - } - return; - } - - if (options.full) { - console.log(formatFullConversation(filteredEntries)); - return; - } + const filtered = filterEntries(entries, options); + if (outputEntries(filtered, options)) return; - const events = extractEvents(filteredEntries); - const toolCallCount = entries.reduce((count, e) => count + (e.toolCalls?.length || 0), 0); - const userMessageCount = entries.filter((e) => e.type === 'user').length; + // Compressed summary (default) + const events = extractEvents(filtered); + const toolCallCount = entries.filter((e) => e.role === 'tool_call').length; + const userMessageCount = entries.filter((e) => e.role === 'user').length; const stats: SessionStats = { - workerId, - taskId: workerId, - branch, - duration, - totalLines: entries.length, + workerId: ctx.workerId, + taskId: ctx.workerId, + branch: ctx.branch, + provider: ctx.provider, + duration: ctx.duration, + totalEntries: entries.length, compressedLines: events.length, compressionRatio: entries.length / Math.max(events.length, 1), exchanges: userMessageCount,