From 519efe9783ffb42804d4972f76fa5eee968d9eba Mon Sep 17 00:00:00 2001 From: catlog22 Date: Wed, 25 Feb 2026 23:21:35 +0800 Subject: [PATCH] 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 --- .../components/hook/HookQuickTemplates.tsx | 10 + .../components/shared/NativeSessionPanel.tsx | 238 +--------- .../src/components/shared/SessionTimeline.tsx | 416 +++++++++++++++++ ccw/frontend/src/lib/api.ts | 107 ++++- ccw/frontend/src/locales/en/cli-hooks.json | 4 + .../src/locales/en/native-session.json | 14 + ccw/frontend/src/locales/zh/cli-hooks.json | 4 + .../src/locales/zh/native-session.json | 14 + ccw/src/cli.ts | 2 + ccw/src/commands/hook.ts | 151 +++++- ccw/src/core/routes/cli-routes.ts | 153 +++++- ccw/src/core/routes/hooks-routes.ts | 57 +++ ccw/src/tools/claude-session-parser.ts | 363 +++++++------- ccw/src/tools/opencode-session-parser.ts | 442 ++++++++++++++++++ ccw/src/tools/session-content-parser.ts | 3 + 15 files changed, 1543 insertions(+), 435 deletions(-) create mode 100644 ccw/frontend/src/components/shared/SessionTimeline.tsx create mode 100644 ccw/src/tools/opencode-session-parser.ts diff --git a/ccw/frontend/src/components/hook/HookQuickTemplates.tsx b/ccw/frontend/src/components/hook/HookQuickTemplates.tsx index 017f4b8b..f44465b8 100644 --- a/ccw/frontend/src/components/hook/HookQuickTemplates.tsx +++ b/ccw/frontend/src/components/hook/HookQuickTemplates.tsx @@ -182,6 +182,15 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [ '-e', 'const p=JSON.parse(process.env.HOOK_INPUT||"{}");const cp=require("child_process");const payload=JSON.stringify({type:"SESSION_SUMMARY",transcript:p.transcript_path||"",project:process.env.CLAUDE_PROJECT_DIR||process.cwd(),timestamp:Date.now()});cp.spawnSync("curl",["-s","-X","POST","-H","Content-Type: application/json","-d",payload,"http://localhost:3456/api/hook"],{stdio:"inherit",shell:true})' ] + }, + { + id: 'project-state-inject', + name: 'Project State Inject', + description: 'Inject project guidelines and recent dev history at session start', + category: 'indexing', + trigger: 'SessionStart', + command: 'ccw', + args: ['hook', 'project-state', '--stdin'] } ] as const; @@ -205,6 +214,7 @@ const TEMPLATE_ICONS: Record = { 'git-auto-stage': GitBranch, 'post-edit-index': Database, 'session-end-summary': FileBarChart, + 'project-state-inject': FileBarChart, }; // ========== Category Names ========== diff --git a/ccw/frontend/src/components/shared/NativeSessionPanel.tsx b/ccw/frontend/src/components/shared/NativeSessionPanel.tsx index 26856917..329b613a 100644 --- a/ccw/frontend/src/components/shared/NativeSessionPanel.tsx +++ b/ccw/frontend/src/components/shared/NativeSessionPanel.tsx @@ -6,25 +6,16 @@ import * as React from 'react'; import { useIntl } from 'react-intl'; import { - User, - Bot, - Brain, - Wrench, Copy, Clock, Hash, FolderOpen, FileJson, - Coins, - ArrowDownUp, - Archive, Loader2, AlertCircle, } from 'lucide-react'; -import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/Button'; import { Badge } from '@/components/ui/Badge'; -import { Card } from '@/components/ui/Card'; import { Dialog, DialogContent, @@ -32,11 +23,7 @@ import { DialogTitle, } from '@/components/ui/Dialog'; import { useNativeSession } from '@/hooks/useNativeSession'; -import type { - NativeSessionTurn, - NativeToolCall, - NativeTokenInfo, -} from '@/lib/api'; +import { SessionTimeline } from './SessionTimeline'; // ========== Types ========== @@ -46,21 +33,6 @@ export interface NativeSessionPanelProps { onOpenChange: (open: boolean) => void; } -interface TurnCardProps { - turn: NativeSessionTurn; - isLatest: boolean; -} - -interface TokenDisplayProps { - tokens: NativeTokenInfo; - className?: string; -} - -interface ToolCallItemProps { - toolCall: NativeToolCall; - index: number; -} - // ========== Helpers ========== /** @@ -76,16 +48,6 @@ function getToolVariant(tool: string): 'default' | 'secondary' | 'outline' | 'su return variants[tool?.toLowerCase()] || 'secondary'; } -/** - * Format token number with compact notation - */ -function formatTokenCount(count: number | undefined): string { - if (count == null || count === 0) return '0'; - if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; - if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`; - return count.toLocaleString(); -} - /** * Truncate a string to a max length with ellipsis */ @@ -106,173 +68,6 @@ async function copyToClipboard(text: string): Promise { } } -// ========== Sub-Components ========== - -/** - * TokenDisplay - Compact token info line - */ -function TokenDisplay({ tokens, className }: TokenDisplayProps) { - const { formatMessage } = useIntl(); - - return ( -
- - - {formatTokenCount(tokens.total)} - - {tokens.input != null && ( - - - {formatTokenCount(tokens.input)} - - )} - {tokens.output != null && ( - - out: {formatTokenCount(tokens.output)} - - )} - {tokens.cached != null && tokens.cached > 0 && ( - - - {formatTokenCount(tokens.cached)} - - )} -
- ); -} - -/** - * ToolCallItem - Single tool call display with collapsible details - */ -function ToolCallItem({ toolCall, index }: ToolCallItemProps) { - const { formatMessage } = useIntl(); - return ( -
- - - {toolCall.name} - #{index + 1} - -
- {toolCall.arguments && ( -
-

{formatMessage({ id: 'nativeSession.toolCall.input' })}

-
-              {toolCall.arguments}
-            
-
- )} - {toolCall.output && ( -
-

{formatMessage({ id: 'nativeSession.toolCall.output' })}

-
-              {toolCall.output}
-            
-
- )} -
-
- ); -} - -/** - * TurnCard - Single conversation turn - */ -function TurnCard({ turn, isLatest }: TurnCardProps) { - const { formatMessage } = useIntl(); - const isUser = turn.role === 'user'; - const RoleIcon = isUser ? User : Bot; - - return ( - - {/* Turn Header */} -
-
- - {turn.role} - - #{turn.turnNumber} - - {isLatest && ( - - {formatMessage({ id: 'nativeSession.turn.latest', defaultMessage: 'Latest' })} - - )} -
-
- {turn.timestamp && ( - - - {new Date(turn.timestamp).toLocaleTimeString()} - - )} - {turn.tokens && ( - - )} -
-
- - {/* Turn Content */} -
- {turn.content && ( -
-            {turn.content}
-          
- )} - - {/* Thoughts Section */} - {turn.thoughts && turn.thoughts.length > 0 && ( -
- - - - {formatMessage({ id: 'nativeSession.turn.thoughts', defaultMessage: 'Thoughts' })} - - ({turn.thoughts.length}) - -
    - {turn.thoughts.map((thought, i) => ( -
  • {thought}
  • - ))} -
-
- )} - - {/* Tool Calls Section */} - {turn.toolCalls && turn.toolCalls.length > 0 && ( -
- - - - {formatMessage({ id: 'nativeSession.turn.toolCalls', defaultMessage: 'Tool Calls' })} - - ({turn.toolCalls.length}) - -
- {turn.toolCalls.map((tc, i) => ( - - ))} -
-
- )} -
-
- ); -} - // ========== Main Component ========== /** @@ -350,17 +145,7 @@ export function NativeSessionPanel({ )} - {/* Token Summary Bar */} - {session?.totalTokens && ( -
- - {formatMessage({ id: 'nativeSession.tokenSummary', defaultMessage: 'Total Tokens' })} - - -
- )} - - {/* Content Area */} + {/* Content Area with SessionTimeline */} {isLoading ? (
@@ -375,24 +160,9 @@ export function NativeSessionPanel({ {formatMessage({ id: 'nativeSession.error', defaultMessage: 'Failed to load session' })}
- ) : session && session.turns.length > 0 ? ( + ) : session ? (
-
- {session.turns.map((turn, idx) => ( - - - {/* Connector line between turns */} - {idx < session.turns.length - 1 && ( - +
) : (
diff --git a/ccw/frontend/src/components/shared/SessionTimeline.tsx b/ccw/frontend/src/components/shared/SessionTimeline.tsx new file mode 100644 index 00000000..9678bdb9 --- /dev/null +++ b/ccw/frontend/src/components/shared/SessionTimeline.tsx @@ -0,0 +1,416 @@ +// ======================================== +// SessionTimeline Component +// ======================================== +// Timeline visualization for native CLI session turns, tokens, and tool calls + +import * as React from 'react'; +import { useIntl } from 'react-intl'; +import { + User, + Bot, + Brain, + Wrench, + Coins, + Clock, + ChevronDown, + ChevronRight, + Archive, + ArrowDownUp, + CheckCircle, + XCircle, + Loader2, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Badge } from '@/components/ui/Badge'; +import type { + NativeSession, + NativeSessionTurn, + NativeTokenInfo, + NativeToolCall, +} from '@/lib/api'; + +// ========== Types ========== + +export interface SessionTimelineProps { + session: NativeSession; + className?: string; +} + +interface TurnNodeProps { + turn: NativeSessionTurn; + isLatest: boolean; + isLast: boolean; +} + +interface TokenBarProps { + tokens: NativeTokenInfo; + className?: string; +} + +interface ToolCallPanelProps { + toolCall: NativeToolCall; + index: number; +} + +// ========== Helpers ========== + +/** + * Format token number with compact notation + */ +function formatTokenCount(count: number | undefined): string { + if (count == null || count === 0) return '0'; + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; + if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`; + return count.toLocaleString(); +} + +/** + * Get status icon for tool call + */ +function getToolStatusIcon(status?: string): React.ReactNode { + switch (status) { + case 'completed': + case 'success': + return ; + case 'error': + case 'failed': + return ; + case 'running': + case 'pending': + return ; + default: + return ; + } +} + +/** + * Get badge variant for tool call status + */ +function getStatusVariant(status?: string): 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'info' { + switch (status) { + case 'completed': + case 'success': + return 'success'; + case 'error': + case 'failed': + return 'warning'; + case 'running': + case 'pending': + return 'info'; + default: + return 'secondary'; + } +} + +// ========== Sub-Components ========== + +/** + * TokenBar - Horizontal stacked bar for token usage + */ +function TokenBar({ tokens, className }: TokenBarProps) { + const { formatMessage } = useIntl(); + const total = tokens.total || 0; + const input = tokens.input || 0; + const output = tokens.output || 0; + const cached = tokens.cached || 0; + + // Calculate percentages + const inputPercent = total > 0 ? (input / total) * 100 : 0; + const outputPercent = total > 0 ? (output / total) * 100 : 0; + const cachedPercent = total > 0 ? (cached / total) * 100 : 0; + + return ( +
+ {/* Visual bar */} +
+ {input > 0 && ( +
+ )} + {output > 0 && ( +
+ )} + {cached > 0 && ( +
+ )} +
+ {/* Labels */} +
+ + + {formatTokenCount(total)} + + {input > 0 && ( + + + {formatTokenCount(input)} + + )} + {output > 0 && ( + + out: {formatTokenCount(output)} + + )} + {cached > 0 && ( + + + {formatTokenCount(cached)} + + )} +
+
+ ); +} + +/** + * ToolCallPanel - Collapsible panel for tool call details + */ +function ToolCallPanel({ toolCall, index }: ToolCallPanelProps) { + const { formatMessage } = useIntl(); + const [isExpanded, setIsExpanded] = React.useState(false); + + return ( +
+ {/* Header */} + + + {/* Collapsible content */} + {isExpanded && ( +
+ {toolCall.arguments && ( +
+

+ {formatMessage({ id: 'nativeSession.toolCall.input', defaultMessage: 'Input' })} +

+
+                {toolCall.arguments}
+              
+
+ )} + {toolCall.output && ( +
+

+ {formatMessage({ id: 'nativeSession.toolCall.output', defaultMessage: 'Output' })} +

+
+                {toolCall.output}
+              
+
+ )} + {!toolCall.arguments && !toolCall.output && ( +
+ {formatMessage({ id: 'nativeSession.timeline.toolCall.noData', defaultMessage: 'No data available' })} +
+ )} +
+ )} +
+ ); +} + +/** + * TurnNode - Single conversation turn on the timeline + */ +function TurnNode({ turn, isLatest, isLast }: TurnNodeProps) { + const { formatMessage } = useIntl(); + const isUser = turn.role === 'user'; + const RoleIcon = isUser ? User : Bot; + + return ( +
+ {/* Timeline column */} +
+ {/* Node dot */} +
+ +
+ {/* Vertical connector line */} + {!isLast && ( + + + {/* Content column */} +
+ {/* Header */} +
+
+ + {turn.role} + + + {formatMessage( + { id: 'nativeSession.timeline.turnNumber', defaultMessage: 'Turn #{number}' }, + { number: turn.turnNumber } + )} + + {isLatest && ( + + {formatMessage({ id: 'nativeSession.turn.latest', defaultMessage: 'Latest' })} + + )} +
+ {turn.timestamp && ( + + + {new Date(turn.timestamp).toLocaleTimeString()} + + )} +
+ + {/* Content card */} +
+ {/* Message content */} + {turn.content && ( +
+
+                {turn.content}
+              
+
+ )} + + {/* Thoughts section */} + {turn.thoughts && turn.thoughts.length > 0 && ( +
+ + + + {formatMessage({ id: 'nativeSession.turn.thoughts', defaultMessage: 'Thoughts' })} + + + ({turn.thoughts.length}) + + +
    + {turn.thoughts.map((thought, i) => ( +
  • {thought}
  • + ))} +
+
+ )} + + {/* Tool calls section */} + {turn.toolCalls && turn.toolCalls.length > 0 && ( +
+
+ + + {formatMessage({ id: 'nativeSession.turn.toolCalls', defaultMessage: 'Tool Calls' })} + + ({turn.toolCalls.length}) +
+ {turn.toolCalls.map((tc, i) => ( + + ))} +
+ )} + + {/* Token usage bar */} + {turn.tokens && ( +
+ +
+ )} +
+
+
+ ); +} + +// ========== Main Component ========== + +/** + * SessionTimeline - Timeline visualization for native CLI sessions + * + * Displays conversation turns in a vertical timeline layout with: + * - Left side: Timeline nodes with role icons + * - Right side: Content cards with messages, thoughts, and tool calls + * - Token usage bars with stacked input/output/cached visualization + * - Collapsible tool call panels + */ +export function SessionTimeline({ session, className }: SessionTimelineProps) { + const { formatMessage } = useIntl(); + const turns = session.turns || []; + + return ( +
+ {/* Session token summary bar */} + {session.totalTokens && ( +
+

+ {formatMessage({ id: 'nativeSession.tokenSummary', defaultMessage: 'Total Tokens' })} +

+ +
+ )} + + {/* Timeline turns */} + {turns.length > 0 ? ( +
+ {turns.map((turn, idx) => ( + + ))} +
+ ) : ( +
+ {formatMessage({ id: 'nativeSession.empty', defaultMessage: 'No session data available' })} +
+ )} +
+ ); +} + +export default SessionTimeline; diff --git a/ccw/frontend/src/lib/api.ts b/ccw/frontend/src/lib/api.ts index 82c32065..52990103 100644 --- a/ccw/frontend/src/lib/api.ts +++ b/ccw/frontend/src/lib/api.ts @@ -2064,7 +2064,30 @@ export interface NativeSession { } /** - * Fetch native CLI session content by execution ID + * Options for fetching native session + */ +export interface FetchNativeSessionOptions { + executionId?: string; + projectPath?: string; + /** Direct file path to session file (bypasses ccw execution ID lookup) */ + filePath?: string; + /** Tool type for file path query: claude | opencode | codex | qwen | gemini | auto */ + tool?: 'claude' | 'opencode' | 'codex' | 'qwen' | 'gemini' | 'auto'; + /** Output format: json (default) | text | pairs */ + format?: 'json' | 'text' | 'pairs'; + /** Include thoughts in text format */ + thoughts?: boolean; + /** Include tool calls in text format */ + tools?: boolean; + /** Include token counts in text format */ + tokens?: boolean; +} + +/** + * Fetch native CLI session content by execution ID or file path + * @param executionId - CCW execution ID (backward compatible) + * @param projectPath - Optional project path + * @deprecated Use fetchNativeSessionWithOptions for new features */ export async function fetchNativeSession( executionId: string, @@ -2077,6 +2100,88 @@ export async function fetchNativeSession( ); } +/** + * Fetch native CLI session content with full options + * Supports both execution ID lookup and direct file path query + */ +export async function fetchNativeSessionWithOptions( + options: FetchNativeSessionOptions +): Promise> { + const params = new URLSearchParams(); + + // Priority: filePath > executionId + if (options.filePath) { + params.set('filePath', options.filePath); + if (options.tool) params.set('tool', options.tool); + } else if (options.executionId) { + params.set('id', options.executionId); + } else { + throw new Error('Either executionId or filePath is required'); + } + + if (options.projectPath) params.set('path', options.projectPath); + if (options.format) params.set('format', options.format); + if (options.thoughts) params.set('thoughts', 'true'); + if (options.tools) params.set('tools', 'true'); + if (options.tokens) params.set('tokens', 'true'); + + const url = `/api/cli/native-session?${params.toString()}`; + + // Text format returns string, others return JSON + if (options.format === 'text') { + const response = await fetch(url, { credentials: 'same-origin' }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Request failed' })); + throw new Error(error.error || response.statusText); + } + return response.text(); + } + + return fetchApi>(url); +} + +// ========== Native Sessions List API ========== + +/** + * Native session metadata for list endpoint + */ +export interface NativeSessionListItem { + id: string; + tool: string; + path: string; + title?: string; + startTime: string; + updatedAt: string; + projectHash?: string; +} + +/** + * Native sessions list response + */ +export interface NativeSessionsListResponse { + sessions: NativeSessionListItem[]; + count: number; +} + +/** + * Fetch list of native CLI sessions + * @param tool - Filter by tool type (optional) + * @param project - Filter by project path (optional) + */ +export async function fetchNativeSessions( + tool?: 'gemini' | 'qwen' | 'codex' | 'claude' | 'opencode', + project?: string +): Promise { + const params = new URLSearchParams(); + if (tool) params.set('tool', tool); + if (project) params.set('project', project); + + const query = params.toString(); + return fetchApi( + `/api/cli/native-sessions${query ? `?${query}` : ''}` + ); +} + // ========== CLI Tools Config API ========== export interface CliToolsConfigResponse { diff --git a/ccw/frontend/src/locales/en/cli-hooks.json b/ccw/frontend/src/locales/en/cli-hooks.json index 96175157..2c0c2048 100644 --- a/ccw/frontend/src/locales/en/cli-hooks.json +++ b/ccw/frontend/src/locales/en/cli-hooks.json @@ -112,6 +112,10 @@ "session-end-summary": { "name": "Session End Summary", "description": "Send session summary to dashboard on session end" + }, + "project-state-inject": { + "name": "Project State Inject", + "description": "Inject project guidelines and recent dev history at session start" } }, "actions": { diff --git a/ccw/frontend/src/locales/en/native-session.json b/ccw/frontend/src/locales/en/native-session.json index 4530fbba..6b73720b 100644 --- a/ccw/frontend/src/locales/en/native-session.json +++ b/ccw/frontend/src/locales/en/native-session.json @@ -6,6 +6,20 @@ "output": "Output tokens", "cached": "Cached tokens" }, + "timeline": { + "turnNumber": "Turn #{number}", + "tokens": { + "input": "Input: {count}", + "output": "Output: {count}", + "cached": "Cached: {count}" + }, + "toolCall": { + "completed": "completed", + "running": "running", + "error": "error", + "noData": "No data available" + } + }, "turn": { "latest": "Latest", "thoughts": "Thoughts", diff --git a/ccw/frontend/src/locales/zh/cli-hooks.json b/ccw/frontend/src/locales/zh/cli-hooks.json index 5b9a562b..62b68f1f 100644 --- a/ccw/frontend/src/locales/zh/cli-hooks.json +++ b/ccw/frontend/src/locales/zh/cli-hooks.json @@ -112,6 +112,10 @@ "session-end-summary": { "name": "会话结束摘要", "description": "会话结束时发送摘要到仪表盘" + }, + "project-state-inject": { + "name": "项目状态注入", + "description": "会话启动时注入项目约束和最近开发历史" } }, "actions": { diff --git a/ccw/frontend/src/locales/zh/native-session.json b/ccw/frontend/src/locales/zh/native-session.json index a4e2af85..d816504f 100644 --- a/ccw/frontend/src/locales/zh/native-session.json +++ b/ccw/frontend/src/locales/zh/native-session.json @@ -6,6 +6,20 @@ "output": "输出 Token", "cached": "缓存 Token" }, + "timeline": { + "turnNumber": "第 {number} 轮", + "tokens": { + "input": "输入: {count}", + "output": "输出: {count}", + "cached": "缓存: {count}" + }, + "toolCall": { + "completed": "已完成", + "running": "运行中", + "error": "错误", + "noData": "无数据" + } + }, "turn": { "latest": "最新", "thoughts": "思考过程", diff --git a/ccw/src/cli.ts b/ccw/src/cli.ts index 484012f6..cc7662fe 100644 --- a/ccw/src/cli.ts +++ b/ccw/src/cli.ts @@ -293,6 +293,8 @@ export function run(argv: string[]): void { .option('--session-id ', 'Session ID') .option('--prompt ', 'Prompt text') .option('--type ', 'Context type: session-start, context') + .option('--path ', 'File or project path') + .option('--limit ', 'Max entries to return (for project-state)') .action((subcommand, args, options) => hookCommand(subcommand, args, options)); // Issue command - Issue lifecycle management with JSONL task tracking diff --git a/ccw/src/commands/hook.ts b/ccw/src/commands/hook.ts index 895d8a56..681a4864 100644 --- a/ccw/src/commands/hook.ts +++ b/ccw/src/commands/hook.ts @@ -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 { } } +/** + * 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 { + 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 - 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 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[] = ['']; + if (techStr) parts.push(`Recent: ${techStr}`); + if (constraintStr) parts.push(`Constraints: ${constraintStr}`); + if (learningStr) parts.push(`Learnings: ${learningStr}`); + parts.push(''); + + 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(); diff --git a/ccw/src/core/routes/cli-routes.ts b/ccw/src/core/routes/cli-routes.ts index af6c9ed2..83ea1471 100644 --- a/ccw/src/core/routes/cli-routes.ts +++ b/ccw/src/core/routes/cli-routes.ts @@ -559,30 +559,82 @@ export async function handleCliRoutes(ctx: RouteContext): Promise { } // API: Get Native Session Content + // Supports: ?id= (existing), ?path=&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 { return true; } + // API: List Native Sessions (new endpoint) + // Supports: ?tool= & ?project= + 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; diff --git a/ccw/src/core/routes/hooks-routes.ts b/ccw/src/core/routes/hooks-routes.ts index 04b6b1cb..d3397844 100644 --- a/ccw/src/core/routes/hooks-routes.ts +++ b/ccw/src/core/routes/hooks-routes.ts @@ -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 = { 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).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; + // constraints is Record - 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'); diff --git a/ccw/src/tools/claude-session-parser.ts b/ccw/src/tools/claude-session-parser.ts index 6bd67ecd..3ee32de3 100644 --- a/ccw/src/tools/claude-session-parser.ts +++ b/ccw/src/tools/claude-session-parser.ts @@ -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(); - 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(); + const processedUserUuids = new Set(); - 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('') || + trimmed.startsWith('') + ); +} + /** * Extract session ID from file path * Claude session files are named .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, - processedUuids: Set, - 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('')) { - 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(); - 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(); + const processedUserUuids = new Set(); - 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 + }); } } diff --git a/ccw/src/tools/opencode-session-parser.ts b/ccw/src/tools/opencode-session-parser.ts new file mode 100644 index 00000000..04c71a56 --- /dev/null +++ b/ccw/src/tools/opencode-session-parser.ts @@ -0,0 +1,442 @@ +/** + * OpenCode Session Parser - Parses OpenCode multi-file session structure + * + * Storage Structure: + * session//.json - Session metadata + * message//.json - Message content + * part//.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; + 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(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(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(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(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(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; diff --git a/ccw/src/tools/session-content-parser.ts b/ccw/src/tools/session-content-parser.ts index ea2270a5..bac69552 100644 --- a/ccw/src/tools/session-content-parser.ts +++ b/ccw/src/tools/session-content-parser.ts @@ -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; }