mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-01 15:03:57 +08:00
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:
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
442
ccw/src/tools/opencode-session-parser.ts
Normal file
442
ccw/src/tools/opencode-session-parser.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user