feat(hooks): add hook management and session timeline features

- Add hook quick templates component with configurable templates
- Refactor NativeSessionPanel to use new SessionTimeline component
- Add OpenCode session parser for parsing OpenCode CLI sessions
- Enhance API with session-related endpoints
- Add locale translations for hooks and native session features
- Update hook commands and routes for better hook management
This commit is contained in:
catlog22
2026-02-25 23:21:35 +08:00
parent 25f442b329
commit 519efe9783
15 changed files with 1543 additions and 435 deletions

View File

@@ -293,6 +293,8 @@ export function run(argv: string[]): void {
.option('--session-id <id>', 'Session ID')
.option('--prompt <text>', 'Prompt text')
.option('--type <type>', 'Context type: session-start, context')
.option('--path <path>', 'File or project path')
.option('--limit <n>', 'Max entries to return (for project-state)')
.action((subcommand, args, options) => hookCommand(subcommand, args, options));
// Issue command - Issue lifecycle management with JSONL task tracking

View File

@@ -5,6 +5,7 @@
import chalk from 'chalk';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
interface HookOptions {
stdin?: boolean;
@@ -12,6 +13,7 @@ interface HookOptions {
prompt?: string;
type?: 'session-start' | 'context' | 'session-end' | 'stop' | 'pre-compact';
path?: string;
limit?: string;
}
interface HookData {
@@ -713,6 +715,142 @@ async function notifyAction(options: HookOptions): Promise<void> {
}
}
/**
* Project state action - reads project-tech.json and project-guidelines.json
* and outputs a concise summary for session context injection.
*
* Used as SessionStart hook: stdout → injected as system message.
*/
async function projectStateAction(options: HookOptions): Promise<void> {
let { stdin, path: projectPath } = options;
const limit = Math.min(parseInt(options.limit || '5', 10), 20);
if (stdin) {
try {
const stdinData = await readStdin();
if (stdinData) {
const hookData = JSON.parse(stdinData) as HookData;
projectPath = hookData.cwd || projectPath;
}
} catch {
// Silently continue if stdin parsing fails
}
}
projectPath = projectPath || process.cwd();
const result: {
tech: { recent: Array<{ title: string; category: string; date: string }> };
guidelines: { constraints: string[]; recent_learnings: Array<{ insight: string; date: string }> };
} = {
tech: { recent: [] },
guidelines: { constraints: [], recent_learnings: [] }
};
// Read project-tech.json
const techPath = join(projectPath, '.workflow', 'project-tech.json');
if (existsSync(techPath)) {
try {
const tech = JSON.parse(readFileSync(techPath, 'utf8'));
const allEntries: Array<{ title: string; category: string; date: string }> = [];
if (tech.development_index) {
for (const [cat, entries] of Object.entries(tech.development_index)) {
if (Array.isArray(entries)) {
for (const e of entries as Array<{ title?: string; date?: string }>) {
allEntries.push({ title: e.title || '', category: cat, date: e.date || '' });
}
}
}
}
allEntries.sort((a, b) => b.date.localeCompare(a.date));
result.tech.recent = allEntries.slice(0, limit);
} catch { /* ignore parse errors */ }
}
// Read project-guidelines.json
const guidelinesPath = join(projectPath, '.workflow', 'project-guidelines.json');
if (existsSync(guidelinesPath)) {
try {
const gl = JSON.parse(readFileSync(guidelinesPath, 'utf8'));
// constraints is Record<string, array> - flatten all categories
const allConstraints: string[] = [];
if (gl.constraints && typeof gl.constraints === 'object') {
for (const entries of Object.values(gl.constraints)) {
if (Array.isArray(entries)) {
for (const c of entries) {
allConstraints.push(typeof c === 'string' ? c : (c as { rule?: string }).rule || JSON.stringify(c));
}
}
}
}
result.guidelines.constraints = allConstraints.slice(0, limit);
const learnings = Array.isArray(gl.learnings) ? gl.learnings : [];
learnings.sort((a: { date?: string }, b: { date?: string }) => (b.date || '').localeCompare(a.date || ''));
result.guidelines.recent_learnings = learnings.slice(0, limit).map(
(l: { insight?: string; date?: string }) => ({ insight: l.insight || '', date: l.date || '' })
);
} catch { /* ignore parse errors */ }
}
if (stdin) {
// Format as <project-state> tag for system message injection
const techStr = result.tech.recent.map(e => `${e.title} (${e.category})`).join(', ');
const constraintStr = result.guidelines.constraints.join('; ');
const learningStr = result.guidelines.recent_learnings.map(e => e.insight).join('; ');
const parts: string[] = ['<project-state>'];
if (techStr) parts.push(`Recent: ${techStr}`);
if (constraintStr) parts.push(`Constraints: ${constraintStr}`);
if (learningStr) parts.push(`Learnings: ${learningStr}`);
parts.push('</project-state>');
process.stdout.write(parts.join('\n'));
process.exit(0);
}
// Interactive mode: show detailed output
console.log(chalk.green('Project State Summary'));
console.log(chalk.gray('─'.repeat(40)));
console.log(chalk.cyan('Project:'), projectPath);
console.log(chalk.cyan('Limit:'), limit);
console.log();
if (result.tech.recent.length > 0) {
console.log(chalk.yellow('Recent Development:'));
for (const e of result.tech.recent) {
console.log(` ${chalk.gray(e.date)} ${e.title} ${chalk.cyan(`(${e.category})`)}`);
}
} else {
console.log(chalk.gray('(No development index entries)'));
}
console.log();
if (result.guidelines.constraints.length > 0) {
console.log(chalk.yellow('Constraints:'));
for (const c of result.guidelines.constraints) {
console.log(` - ${c}`);
}
} else {
console.log(chalk.gray('(No constraints)'));
}
if (result.guidelines.recent_learnings.length > 0) {
console.log(chalk.yellow('Recent Learnings:'));
for (const l of result.guidelines.recent_learnings) {
console.log(` ${chalk.gray(l.date)} ${l.insight}`);
}
} else {
console.log(chalk.gray('(No learnings)'));
}
// Also output JSON for piping
console.log();
console.log(chalk.gray('JSON:'));
console.log(JSON.stringify(result, null, 2));
}
/**
* Show help for hook command
*/
@@ -731,10 +869,12 @@ ${chalk.bold('SUBCOMMANDS')}
keyword Detect mode keywords in prompts and activate modes
pre-compact Handle PreCompact hook events (checkpoint creation)
notify Send notification to ccw view dashboard
project-state Output project guidelines and recent dev history summary
${chalk.bold('OPTIONS')}
--stdin Read input from stdin (for Claude Code hooks)
--path Path to status.json file (for parse-status)
--path File or project path (for parse-status, project-state)
--limit Max entries to return (for project-state, default: 5)
--session-id Session ID (alternative to stdin)
--prompt Current prompt text (alternative to stdin)
@@ -760,6 +900,12 @@ ${chalk.bold('EXAMPLES')}
${chalk.gray('# Handle PreCompact events:')}
ccw hook pre-compact --stdin
${chalk.gray('# Project state summary (interactive):')}
ccw hook project-state --path /my/project
${chalk.gray('# Project state summary (hook, reads cwd from stdin):')}
ccw hook project-state --stdin
${chalk.bold('HOOK CONFIGURATION')}
${chalk.gray('Add to .claude/settings.json for Stop hook:')}
{
@@ -820,6 +966,9 @@ export async function hookCommand(
case 'notify':
await notifyAction(options);
break;
case 'project-state':
await projectStateAction(options);
break;
case 'help':
case undefined:
showHelp();

View File

@@ -559,30 +559,82 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
}
// API: Get Native Session Content
// Supports: ?id=<executionId> (existing), ?path=<filepath>&tool=<tool> (new direct path query)
if (pathname === '/api/cli/native-session') {
const projectPath = url.searchParams.get('path') || initialPath;
const executionId = url.searchParams.get('id');
const filePath = url.searchParams.get('filePath'); // New: direct file path
const toolParam = url.searchParams.get('tool') || 'auto'; // New: tool type for path query
const format = url.searchParams.get('format') || 'json';
if (!executionId) {
// Priority: filePath > id (backward compatible)
if (!executionId && !filePath) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Execution ID is required' }));
res.end(JSON.stringify({ error: 'Either execution ID (id) or file path (filePath) is required' }));
return true;
}
try {
let result;
if (format === 'text') {
result = await getFormattedNativeConversation(projectPath, executionId, {
includeThoughts: url.searchParams.get('thoughts') === 'true',
includeToolCalls: url.searchParams.get('tools') === 'true',
includeTokens: url.searchParams.get('tokens') === 'true'
});
} else if (format === 'pairs') {
const enriched = await getEnrichedConversation(projectPath, executionId);
result = enriched?.merged || null;
// Direct file path query (new)
if (filePath) {
const { parseSessionFile } = await import('../../tools/session-content-parser.js');
// Determine tool type
let tool = toolParam;
if (tool === 'auto') {
// Auto-detect tool from file path
if (filePath.includes('.claude') as boolean || filePath.includes('claude-session')) {
tool = 'claude';
} else if (filePath.includes('.opencode') as boolean || filePath.includes('opencode')) {
tool = 'opencode';
} else if (filePath.includes('.codex') as boolean || filePath.includes('rollout-')) {
tool = 'codex';
} else if (filePath.includes('.qwen') as boolean) {
tool = 'qwen';
} else if (filePath.includes('.gemini') as boolean) {
tool = 'gemini';
} else {
// Default to claude for unknown paths
tool = 'claude';
}
}
const session = parseSessionFile(filePath, tool);
if (!session) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Native session not found at path: ' + filePath }));
return true;
}
if (format === 'text') {
const { formatConversation } = await import('../../tools/session-content-parser.js');
result = formatConversation(session, {
includeThoughts: url.searchParams.get('thoughts') === 'true',
includeToolCalls: url.searchParams.get('tools') === 'true',
includeTokens: url.searchParams.get('tokens') === 'true'
});
} else if (format === 'pairs') {
const { extractConversationPairs } = await import('../../tools/session-content-parser.js');
result = extractConversationPairs(session);
} else {
result = session;
}
} else {
result = await getNativeSessionContent(projectPath, executionId);
// Existing: query by execution ID
if (format === 'text') {
result = await getFormattedNativeConversation(projectPath, executionId!, {
includeThoughts: url.searchParams.get('thoughts') === 'true',
includeToolCalls: url.searchParams.get('tools') === 'true',
includeTokens: url.searchParams.get('tokens') === 'true'
});
} else if (format === 'pairs') {
const enriched = await getEnrichedConversation(projectPath, executionId!);
result = enriched?.merged || null;
} else {
result = await getNativeSessionContent(projectPath, executionId!);
}
}
if (!result) {
@@ -600,6 +652,83 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
return true;
}
// API: List Native Sessions (new endpoint)
// Supports: ?tool=<gemini|qwen|codex|claude|opencode> & ?project=<projectPath>
if (pathname === '/api/cli/native-sessions' && req.method === 'GET') {
const toolFilter = url.searchParams.get('tool');
const projectPath = url.searchParams.get('project') || initialPath;
try {
const {
getDiscoverer,
getNativeSessions
} = await import('../../tools/native-session-discovery.js');
const sessions: Array<{
id: string;
tool: string;
path: string;
title?: string;
startTime: string;
updatedAt: string;
projectHash?: string;
}> = [];
// Define supported tools
const supportedTools = ['gemini', 'qwen', 'codex', 'claude', 'opencode'] as const;
const toolsToQuery = toolFilter && supportedTools.includes(toolFilter as typeof supportedTools[number])
? [toolFilter as typeof supportedTools[number]]
: [...supportedTools];
for (const tool of toolsToQuery) {
const discoverer = getDiscoverer(tool);
if (!discoverer) continue;
const nativeSessions = getNativeSessions(tool, {
workingDir: projectPath,
limit: 100
});
for (const session of nativeSessions) {
// Try to extract title from session
let title: string | undefined;
try {
const firstUserMessage = (discoverer as any).extractFirstUserMessage?.(session.filePath);
if (firstUserMessage) {
// Truncate to first 100 chars as title
title = firstUserMessage.substring(0, 100).trim();
if (firstUserMessage.length > 100) {
title += '...';
}
}
} catch {
// Ignore errors extracting title
}
sessions.push({
id: session.sessionId,
tool: session.tool,
path: session.filePath,
title,
startTime: session.createdAt.toISOString(),
updatedAt: session.updatedAt.toISOString(),
projectHash: session.projectHash
});
}
}
// Sort by updatedAt descending
sessions.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ sessions, count: sessions.length }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (err as Error).message }));
}
return true;
}
// API: Get Enriched Conversation
if (pathname === '/api/cli/enriched') {
const projectPath = url.searchParams.get('path') || initialPath;

View File

@@ -8,6 +8,7 @@
* - POST /api/hook - Main hook endpoint for Claude Code notifications
* - Handles: session-start, context, CLI events, A2UI surfaces
* - POST /api/hook/ccw-exec - Execute CCW CLI commands and parse output
* - GET /api/hook/project-state - Get project guidelines and recent dev history summary
* - GET /api/hooks - Get hooks configuration from global and project settings
* - POST /api/hooks - Save a hook to settings
* - DELETE /api/hooks - Delete a hook from settings
@@ -520,6 +521,62 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise<boolean
return true;
}
// API: Get project state summary for hook injection
if (pathname === '/api/hook/project-state' && req.method === 'GET') {
const projectPath = url.searchParams.get('path') || initialPath;
const limit = Math.min(parseInt(url.searchParams.get('limit') || '5', 10), 20);
const result: Record<string, unknown> = { tech: { recent: [] }, guidelines: { constraints: [], recent_learnings: [] } };
// Read project-tech.json
const techPath = join(projectPath, '.workflow', 'project-tech.json');
if (existsSync(techPath)) {
try {
const tech = JSON.parse(readFileSync(techPath, 'utf8'));
const allEntries: Array<{ title: string; category: string; date: string }> = [];
if (tech.development_index) {
for (const [cat, entries] of Object.entries(tech.development_index)) {
if (Array.isArray(entries)) {
for (const e of entries as Array<{ title?: string; date?: string }>) {
allEntries.push({ title: e.title || '', category: cat, date: e.date || '' });
}
}
}
}
allEntries.sort((a, b) => b.date.localeCompare(a.date));
(result.tech as Record<string, unknown>).recent = allEntries.slice(0, limit);
} catch { /* ignore parse errors */ }
}
// Read project-guidelines.json
const guidelinesPath = join(projectPath, '.workflow', 'project-guidelines.json');
if (existsSync(guidelinesPath)) {
try {
const gl = JSON.parse(readFileSync(guidelinesPath, 'utf8'));
const g = result.guidelines as Record<string, unknown>;
// constraints is Record<string, array> - flatten all categories
const allConstraints: string[] = [];
if (gl.constraints && typeof gl.constraints === 'object') {
for (const entries of Object.values(gl.constraints)) {
if (Array.isArray(entries)) {
for (const c of entries) {
allConstraints.push(typeof c === 'string' ? c : (c as { rule?: string }).rule || JSON.stringify(c));
}
}
}
}
g.constraints = allConstraints.slice(0, limit);
const learnings = Array.isArray(gl.learnings) ? gl.learnings : [];
learnings.sort((a: { date?: string }, b: { date?: string }) => (b.date || '').localeCompare(a.date || ''));
g.recent_learnings = learnings.slice(0, limit).map((l: { insight?: string; date?: string }) => ({ insight: l.insight || '', date: l.date || '' }));
} catch { /* ignore parse errors */ }
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
return true;
}
// API: Get hooks configuration
if (pathname === '/api/hooks' && req.method === 'GET') {
const projectPathParam = url.searchParams.get('path');

View File

@@ -40,6 +40,7 @@ export interface ClaudeUserLine extends ClaudeJsonlLine {
/**
* Assistant message line in Claude JSONL
* Contains content blocks, tool calls, and usage info
* Note: usage can be at top level or inside message object
*/
export interface ClaudeAssistantLine extends ClaudeJsonlLine {
type: 'assistant';
@@ -50,6 +51,7 @@ export interface ClaudeAssistantLine extends ClaudeJsonlLine {
id?: string;
stop_reason?: string | null;
stop_sequence?: string | null;
usage?: ClaudeUsage;
};
usage?: ClaudeUsage;
requestId?: string;
@@ -133,11 +135,10 @@ export function parseClaudeSession(filePath: string): ParsedSession | null {
let model: string | undefined;
let totalTokens: TokenInfo = { input: 0, output: 0, total: 0 };
// Track conversation structure using uuid/parentUuid
// Build message map for parent-child relationships
const messageMap = new Map<string, ClaudeJsonlLine>();
const rootUuids: string[] = [];
// First pass: collect all messages and find roots
// First pass: collect all messages
for (const line of lines) {
try {
const entry: ClaudeJsonlLine = JSON.parse(line);
@@ -149,11 +150,6 @@ export function parseClaudeSession(filePath: string): ParsedSession | null {
messageMap.set(entry.uuid, entry);
// Track root messages (no parent)
if (!entry.parentUuid) {
rootUuids.push(entry.uuid);
}
// Extract metadata from first entry
if (!startTime && entry.timestamp) {
startTime = entry.timestamp;
@@ -171,47 +167,100 @@ export function parseClaudeSession(filePath: string): ParsedSession | null {
}
}
// Second pass: build conversation turns
// Second pass: process user/assistant message pairs
// Find all user messages that are not meta/command messages
let turnNumber = 0;
const processedUuids = new Set<string>();
const processedUserUuids = new Set<string>();
for (const rootUuid of rootUuids) {
const turn = processConversationBranch(
rootUuid,
messageMap,
processedUuids,
++turnNumber
);
for (const [uuid, entry] of messageMap) {
if (entry.type !== 'user') continue;
if (turn) {
turns.push(turn);
const userEntry = entry as ClaudeUserLine;
// Accumulate tokens
if (turn.tokens) {
totalTokens.input = (totalTokens.input || 0) + (turn.tokens.input || 0);
totalTokens.output = (totalTokens.output || 0) + (turn.tokens.output || 0);
totalTokens.total = (totalTokens.total || 0) + (turn.tokens.total || 0);
}
// Skip meta messages (command messages, system messages)
if (userEntry.isMeta) continue;
// Track model
if (!model && turn.tokens?.input) {
// Model info is typically in assistant messages
// Skip if already processed
if (processedUserUuids.has(uuid)) continue;
// Extract user content
const userContent = extractUserContent(userEntry);
// Skip if no meaningful content (commands, tool results, etc.)
if (!userContent || userContent.trim().length === 0) continue;
// Skip command-like messages
if (isCommandMessage(userContent)) continue;
processedUserUuids.add(uuid);
turnNumber++;
// Find the corresponding assistant response(s)
// Look for assistant messages that have this user message as parent
let assistantContent = '';
let assistantTimestamp = '';
let toolCalls: ToolCallInfo[] = [];
let thoughts: string[] = [];
let turnTokens: TokenInfo | undefined;
for (const [childUuid, childEntry] of messageMap) {
if (childEntry.parentUuid === uuid && childEntry.type === 'assistant') {
const assistantEntry = childEntry as ClaudeAssistantLine;
const extracted = extractAssistantContent(assistantEntry);
if (extracted.content) {
assistantContent = extracted.content;
assistantTimestamp = childEntry.timestamp;
}
if (extracted.toolCalls.length > 0) {
toolCalls = toolCalls.concat(extracted.toolCalls);
}
if (extracted.thoughts.length > 0) {
thoughts = thoughts.concat(extracted.thoughts);
}
// Usage can be at top level or inside message object
const usage = assistantEntry.usage || assistantEntry.message?.usage;
if (usage) {
turnTokens = {
input: usage.input_tokens,
output: usage.output_tokens,
total: usage.input_tokens + usage.output_tokens,
cached: (usage.cache_read_input_tokens || 0) +
(usage.cache_creation_input_tokens || 0)
};
// Accumulate total tokens
totalTokens.input = (totalTokens.input || 0) + (turnTokens.input || 0);
totalTokens.output = (totalTokens.output || 0) + (turnTokens.output || 0);
// Extract model from assistant message
if (!model && assistantEntry.message?.model) {
model = assistantEntry.message.model;
}
}
}
}
}
// Extract model from assistant messages if not found
if (!model) {
for (const line of lines) {
try {
const entry = JSON.parse(line);
if (entry.type === 'assistant' && entry.message?.model) {
model = entry.message.model;
break;
}
} catch {
// Skip
}
// Create user turn
turns.push({
turnNumber,
timestamp: entry.timestamp,
role: 'user',
content: userContent
});
// Create assistant turn if there's a response
if (assistantContent || toolCalls.length > 0) {
turns.push({
turnNumber,
timestamp: assistantTimestamp || entry.timestamp,
role: 'assistant',
content: assistantContent || '[Tool execution]',
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
thoughts: thoughts.length > 0 ? thoughts : undefined,
tokens: turnTokens
});
}
}
@@ -234,6 +283,19 @@ export function parseClaudeSession(filePath: string): ParsedSession | null {
}
}
/**
* Check if content is a command message (should be skipped)
*/
function isCommandMessage(content: string): boolean {
const trimmed = content.trim();
return (
trimmed.startsWith('<command-name>') ||
trimmed.startsWith('<local-command') ||
trimmed.startsWith('<command-') ||
trimmed.includes('<local-command-caveat>')
);
}
/**
* Extract session ID from file path
* Claude session files are named <uuid>.jsonl
@@ -249,114 +311,6 @@ function extractSessionId(filePath: string): string {
return uuidMatch ? uuidMatch[1] : nameWithoutExt;
}
/**
* Process a conversation branch starting from a root UUID
* Returns a combined turn with user and assistant messages
*/
function processConversationBranch(
rootUuid: string,
messageMap: Map<string, ClaudeJsonlLine>,
processedUuids: Set<string>,
turnNumber: number
): ParsedTurn | null {
const rootEntry = messageMap.get(rootUuid);
if (!rootEntry || processedUuids.has(rootUuid)) {
return null;
}
// Find the user message at this root
let userContent = '';
let userTimestamp = '';
let assistantContent = '';
let assistantTimestamp = '';
let toolCalls: ToolCallInfo[] = [];
let tokens: TokenInfo | undefined;
let thoughts: string[] = [];
// Process this entry if it's a user message
if (rootEntry.type === 'user') {
const userEntry = rootEntry as ClaudeUserLine;
processedUuids.add(rootEntry.uuid);
// Skip meta messages (command messages, etc.)
if (userEntry.isMeta) {
return null;
}
userContent = extractUserContent(userEntry);
userTimestamp = rootEntry.timestamp;
// Find child assistant message
for (const [uuid, entry] of messageMap) {
if (entry.parentUuid === rootEntry.uuid && entry.type === 'assistant') {
const assistantEntry = entry as ClaudeAssistantLine;
processedUuids.add(uuid);
const extracted = extractAssistantContent(assistantEntry);
assistantContent = extracted.content;
assistantTimestamp = entry.timestamp;
toolCalls = extracted.toolCalls;
thoughts = extracted.thoughts;
if (assistantEntry.usage) {
tokens = {
input: assistantEntry.usage.input_tokens,
output: assistantEntry.usage.output_tokens,
total: assistantEntry.usage.input_tokens + assistantEntry.usage.output_tokens,
cached: (assistantEntry.usage.cache_read_input_tokens || 0) +
(assistantEntry.usage.cache_creation_input_tokens || 0)
};
}
break;
}
}
// Handle tool result messages (follow-up user messages)
for (const [uuid, entry] of messageMap) {
if (entry.parentUuid === rootEntry.uuid && entry.type === 'user') {
const followUpUser = entry as ClaudeUserLine;
if (!followUpUser.isMeta && processedUuids.has(uuid)) {
continue;
}
// Check if this is a tool result message
if (followUpUser.message?.content && Array.isArray(followUpUser.message.content)) {
const hasToolResult = followUpUser.message.content.some(
block => block.type === 'tool_result'
);
if (hasToolResult) {
processedUuids.add(uuid);
// Tool results are typically not displayed as separate turns
}
}
}
}
if (userContent) {
return {
turnNumber,
timestamp: userTimestamp,
role: 'user',
content: userContent
};
}
}
// If no user content but we have assistant content (edge case)
if (assistantContent) {
return {
turnNumber,
timestamp: assistantTimestamp,
role: 'assistant',
content: assistantContent,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
thoughts: thoughts.length > 0 ? thoughts : undefined,
tokens
};
}
return null;
}
/**
* Extract text content from user message
* Handles both string and array content formats
@@ -367,14 +321,6 @@ function extractUserContent(entry: ClaudeUserLine): string {
// Simple string content
if (typeof content === 'string') {
// Skip command messages
if (content.startsWith('<command-') || content.includes('<local-command')) {
return '';
}
// Skip meta messages
if (content.includes('<local-command-caveat>')) {
return '';
}
return content;
}
@@ -458,9 +404,8 @@ export function parseClaudeSessionContent(content: string, filePath?: string): P
let model: string | undefined;
let totalTokens: TokenInfo = { input: 0, output: 0, total: 0 };
// Track conversation structure
// Build message map
const messageMap = new Map<string, ClaudeJsonlLine>();
const rootUuids: string[] = [];
for (const line of lines) {
try {
@@ -472,10 +417,6 @@ export function parseClaudeSessionContent(content: string, filePath?: string): P
messageMap.set(entry.uuid, entry);
if (!entry.parentUuid) {
rootUuids.push(entry.uuid);
}
if (!startTime && entry.timestamp) {
startTime = entry.timestamp;
}
@@ -490,37 +431,85 @@ export function parseClaudeSessionContent(content: string, filePath?: string): P
}
}
// Process user/assistant pairs
let turnNumber = 0;
const processedUuids = new Set<string>();
const processedUserUuids = new Set<string>();
for (const rootUuid of rootUuids) {
const turn = processConversationBranch(
rootUuid,
messageMap,
processedUuids,
++turnNumber
);
for (const [uuid, entry] of messageMap) {
if (entry.type !== 'user') continue;
if (turn) {
turns.push(turn);
const userEntry = entry as ClaudeUserLine;
if (turn.tokens) {
totalTokens.input = (totalTokens.input || 0) + (turn.tokens.input || 0);
totalTokens.output = (totalTokens.output || 0) + (turn.tokens.output || 0);
if (userEntry.isMeta) continue;
if (processedUserUuids.has(uuid)) continue;
const userContent = extractUserContent(userEntry);
if (!userContent || userContent.trim().length === 0) continue;
if (isCommandMessage(userContent)) continue;
processedUserUuids.add(uuid);
turnNumber++;
let assistantContent = '';
let assistantTimestamp = '';
let toolCalls: ToolCallInfo[] = [];
let thoughts: string[] = [];
let turnTokens: TokenInfo | undefined;
for (const [childUuid, childEntry] of messageMap) {
if (childEntry.parentUuid === uuid && childEntry.type === 'assistant') {
const assistantEntry = childEntry as ClaudeAssistantLine;
const extracted = extractAssistantContent(assistantEntry);
if (extracted.content) {
assistantContent = extracted.content;
assistantTimestamp = childEntry.timestamp;
}
if (extracted.toolCalls.length > 0) {
toolCalls = toolCalls.concat(extracted.toolCalls);
}
if (extracted.thoughts.length > 0) {
thoughts = thoughts.concat(extracted.thoughts);
}
// Usage can be at top level or inside message object
const usage = assistantEntry.usage || assistantEntry.message?.usage;
if (usage) {
turnTokens = {
input: usage.input_tokens,
output: usage.output_tokens,
total: usage.input_tokens + usage.output_tokens,
cached: (usage.cache_read_input_tokens || 0) +
(usage.cache_creation_input_tokens || 0)
};
totalTokens.input = (totalTokens.input || 0) + (turnTokens.input || 0);
totalTokens.output = (totalTokens.output || 0) + (turnTokens.output || 0);
if (!model && assistantEntry.message?.model) {
model = assistantEntry.message.model;
}
}
}
}
}
// Extract model
for (const line of lines) {
try {
const entry = JSON.parse(line);
if (entry.type === 'assistant' && entry.message?.model) {
model = entry.message.model;
break;
}
} catch {
// Skip
turns.push({
turnNumber,
timestamp: entry.timestamp,
role: 'user',
content: userContent
});
if (assistantContent || toolCalls.length > 0) {
turns.push({
turnNumber,
timestamp: assistantTimestamp || entry.timestamp,
role: 'assistant',
content: assistantContent || '[Tool execution]',
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
thoughts: thoughts.length > 0 ? thoughts : undefined,
tokens: turnTokens
});
}
}

View File

@@ -0,0 +1,442 @@
/**
* OpenCode Session Parser - Parses OpenCode multi-file session structure
*
* Storage Structure:
* session/<projectHash>/<sessionId>.json - Session metadata
* message/<sessionId>/<messageId>.json - Message content
* part/<messageId>/<partId>.json - Message parts (text, tool, reasoning, step-start)
*/
import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
import { join, dirname } from 'path';
import type { ParsedSession, ParsedTurn, ToolCallInfo, TokenInfo } from './session-content-parser.js';
// ============================================================
// OpenCode Raw Interfaces (mirrors JSON file structure)
// ============================================================
export interface OpenCodeSession {
id: string;
version: string;
projectID: string;
directory: string;
title: string;
time: {
created: number;
updated: number;
};
summary?: {
additions?: number;
deletions?: number;
files?: number;
};
}
export interface OpenCodeMessage {
id: string;
sessionID: string;
role: 'user' | 'assistant';
time: {
created: number;
completed?: number;
};
parentID?: string;
modelID?: string;
providerID?: string;
mode?: string;
agent?: string;
path?: {
cwd?: string;
root?: string;
};
tokens?: {
input: number;
output: number;
reasoning?: number;
cache?: {
read: number;
write: number;
};
};
finish?: string;
summary?: {
title?: string;
diffs?: unknown[];
};
model?: {
providerID?: string;
modelID?: string;
};
}
export interface OpenCodePart {
id: string;
sessionID: string;
messageID: string;
type: 'text' | 'tool' | 'reasoning' | 'step-start' | 'step-end';
// For text/reasoning parts
text?: string;
// For tool parts
callID?: string;
tool?: string;
state?: {
status: string;
input?: Record<string, unknown>;
output?: string;
time?: {
start: number;
end?: number;
};
};
// For step-start/step-end
snapshot?: string;
// Timing for reasoning
time?: {
start: number;
end?: number;
};
}
// ============================================================
// Helper Functions
// ============================================================
/**
* Get OpenCode storage base path
*/
export function getOpenCodeStoragePath(): string {
// OpenCode uses ~/.local/share/opencode/storage on all platforms
const homePath = process.env.USERPROFILE || process.env.HOME || '';
return join(homePath, '.local', 'share', 'opencode', 'storage');
}
/**
* Read JSON file safely
*/
function readJsonFile<T>(filePath: string): T | null {
try {
if (!existsSync(filePath)) {
return null;
}
const content = readFileSync(filePath, 'utf8');
return JSON.parse(content) as T;
} catch {
return null;
}
}
/**
* Get all JSON files in a directory sorted by name (which includes timestamp)
*/
function getJsonFilesInDir(dirPath: string): string[] {
if (!existsSync(dirPath)) {
return [];
}
try {
return readdirSync(dirPath)
.filter(f => f.endsWith('.json'))
.sort();
} catch {
return [];
}
}
/**
* Format timestamp (milliseconds) to ISO string
*/
function formatTimestamp(ms: number): string {
return new Date(ms).toISOString();
}
// ============================================================
// Main Parser Function
// ============================================================
/**
* Parse OpenCode session from session file path
*
* @param sessionPath - Path to session JSON file
* @param storageBasePath - Optional base path to storage (auto-detected if not provided)
* @returns ParsedSession with aggregated turns from messages and parts
*/
export function parseOpenCodeSession(
sessionPath: string,
storageBasePath?: string
): ParsedSession | null {
// Read session file
const session = readJsonFile<OpenCodeSession>(sessionPath);
if (!session) {
return null;
}
// Determine storage base path
const basePath = storageBasePath || getOpenCodeStoragePath();
const sessionId = session.id;
// Read all messages for this session
const messageDir = join(basePath, 'message', sessionId);
const messageFiles = getJsonFilesInDir(messageDir);
if (messageFiles.length === 0) {
// Return session with no turns
return {
sessionId: session.id,
tool: 'opencode',
projectHash: session.projectID,
workingDir: session.directory,
startTime: formatTimestamp(session.time.created),
lastUpdated: formatTimestamp(session.time.updated),
turns: [],
model: undefined,
totalTokens: { input: 0, output: 0, total: 0 }
};
}
// Eager loading: Read all messages and their parts
const messages: Array<{
message: OpenCodeMessage;
parts: OpenCodePart[];
}> = [];
for (const msgFile of messageFiles) {
const message = readJsonFile<OpenCodeMessage>(join(messageDir, msgFile));
if (!message) continue;
// Read all parts for this message
const partDir = join(basePath, 'part', message.id);
const partFiles = getJsonFilesInDir(partDir);
const parts: OpenCodePart[] = [];
for (const partFile of partFiles) {
const part = readJsonFile<OpenCodePart>(join(partDir, partFile));
if (part) {
parts.push(part);
}
}
messages.push({ message, parts });
}
// Sort messages by creation time
messages.sort((a, b) => a.message.time.created - b.message.time.created);
// Build turns
const turns: ParsedTurn[] = buildTurns(messages);
// Calculate total tokens
const totalTokens: TokenInfo = { input: 0, output: 0, total: 0 };
let model: string | undefined;
for (const { message } of messages) {
if (message.role === 'assistant' && message.tokens) {
totalTokens.input = (totalTokens.input || 0) + message.tokens.input;
totalTokens.output = (totalTokens.output || 0) + message.tokens.output;
totalTokens.total = (totalTokens.total || 0) + message.tokens.input + message.tokens.output;
}
if (message.modelID && !model) {
model = message.modelID;
}
}
return {
sessionId: session.id,
tool: 'opencode',
projectHash: session.projectID,
workingDir: session.directory,
startTime: formatTimestamp(session.time.created),
lastUpdated: formatTimestamp(session.time.updated),
turns,
totalTokens,
model
};
}
/**
* Build turns from messages and parts
*
* OpenCode structure:
* - User messages have role='user' and text parts
* - Assistant messages have role='assistant' and may have:
* - step-start parts (snapshot info)
* - reasoning parts (thoughts)
* - tool parts (tool calls with input/output)
* - text parts (final response content)
*/
function buildTurns(messages: Array<{ message: OpenCodeMessage; parts: OpenCodePart[] }>): ParsedTurn[] {
const turns: ParsedTurn[] = [];
let currentTurn = 0;
let pendingUserTurn: ParsedTurn | null = null;
for (const { message, parts } of messages) {
if (message.role === 'user') {
// Start new turn
currentTurn++;
// Extract content from text parts
const textParts = parts.filter(p => p.type === 'text' && p.text);
const content = textParts.map(p => p.text || '').join('\n');
pendingUserTurn = {
turnNumber: currentTurn,
timestamp: formatTimestamp(message.time.created),
role: 'user',
content
};
turns.push(pendingUserTurn);
} else if (message.role === 'assistant') {
// Extract thoughts from reasoning parts
const reasoningParts = parts.filter(p => p.type === 'reasoning' && p.text);
const thoughts = reasoningParts.map(p => p.text || '').filter(t => t);
// Extract tool calls from tool parts
const toolParts = parts.filter(p => p.type === 'tool');
const toolCalls: ToolCallInfo[] = toolParts.map(p => ({
name: p.tool || 'unknown',
arguments: p.state?.input ? JSON.stringify(p.state.input) : undefined,
output: p.state?.output
}));
// Extract content from text parts (final response)
const textParts = parts.filter(p => p.type === 'text' && p.text);
const content = textParts.map(p => p.text || '').join('\n');
// Build token info
const tokens: TokenInfo | undefined = message.tokens ? {
input: message.tokens.input,
output: message.tokens.output,
cached: message.tokens.cache?.read,
total: message.tokens.input + message.tokens.output
} : undefined;
const assistantTurn: ParsedTurn = {
turnNumber: currentTurn,
timestamp: formatTimestamp(message.time.created),
role: 'assistant',
content: content || (toolCalls.length > 0 ? '[Tool execution completed]' : ''),
thoughts: thoughts.length > 0 ? thoughts : undefined,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
tokens
};
turns.push(assistantTurn);
pendingUserTurn = null;
}
}
return turns;
}
/**
* Parse OpenCode session from session ID
*
* @param sessionId - OpenCode session ID (e.g., 'ses_xxx')
* @param projectHash - Optional project hash (will search all projects if not provided)
* @returns ParsedSession or null if not found
*/
export function parseOpenCodeSessionById(
sessionId: string,
projectHash?: string
): ParsedSession | null {
const basePath = getOpenCodeStoragePath();
const sessionDir = join(basePath, 'session');
if (!existsSync(sessionDir)) {
return null;
}
// If project hash provided, look in that directory
if (projectHash) {
const sessionPath = join(sessionDir, projectHash, `${sessionId}.json`);
return parseOpenCodeSession(sessionPath, basePath);
}
// Search all project directories
try {
const projectDirs = readdirSync(sessionDir).filter(d => {
const fullPath = join(sessionDir, d);
return statSync(fullPath).isDirectory();
});
for (const projHash of projectDirs) {
const sessionPath = join(sessionDir, projHash, `${sessionId}.json`);
if (existsSync(sessionPath)) {
return parseOpenCodeSession(sessionPath, basePath);
}
}
} catch {
// Ignore errors
}
return null;
}
/**
* Get all OpenCode sessions for a project
*
* @param projectHash - Project hash to filter by
* @returns Array of session info (not full parsed sessions)
*/
export function getOpenCodeSessions(projectHash?: string): Array<{
sessionId: string;
projectHash: string;
filePath: string;
title?: string;
createdAt: Date;
updatedAt: Date;
}> {
const basePath = getOpenCodeStoragePath();
const sessionDir = join(basePath, 'session');
const sessions: Array<{
sessionId: string;
projectHash: string;
filePath: string;
title?: string;
createdAt: Date;
updatedAt: Date;
}> = [];
if (!existsSync(sessionDir)) {
return sessions;
}
try {
const projectDirs = projectHash
? [projectHash]
: readdirSync(sessionDir).filter(d => {
const fullPath = join(sessionDir, d);
return statSync(fullPath).isDirectory();
});
for (const projHash of projectDirs) {
const projDir = join(sessionDir, projHash);
if (!existsSync(projDir)) continue;
const sessionFiles = getJsonFilesInDir(projDir);
for (const sessionFile of sessionFiles) {
const filePath = join(projDir, sessionFile);
const session = readJsonFile<OpenCodeSession>(filePath);
if (session) {
sessions.push({
sessionId: session.id,
projectHash: session.projectID,
filePath,
title: session.title,
createdAt: new Date(session.time.created),
updatedAt: new Date(session.time.updated)
});
}
}
}
} catch {
// Ignore errors
}
// Sort by updated time descending
sessions.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
return sessions;
}
export default parseOpenCodeSession;

View File

@@ -5,6 +5,7 @@
import { readFileSync, existsSync } from 'fs';
import { parseClaudeSession } from './claude-session-parser.js';
import { parseOpenCodeSession } from './opencode-session-parser.js';
// Standardized conversation turn
export interface ParsedTurn {
@@ -200,6 +201,8 @@ export function parseSessionFile(filePath: string, tool: string): ParsedSession
return parseCodexSession(content);
case 'claude':
return parseClaudeSession(filePath);
case 'opencode':
return parseOpenCodeSession(filePath);
default:
return null;
}