mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +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:
@@ -182,6 +182,15 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [
|
|||||||
'-e',
|
'-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})'
|
'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;
|
] as const;
|
||||||
|
|
||||||
@@ -205,6 +214,7 @@ const TEMPLATE_ICONS: Record<string, typeof Bell> = {
|
|||||||
'git-auto-stage': GitBranch,
|
'git-auto-stage': GitBranch,
|
||||||
'post-edit-index': Database,
|
'post-edit-index': Database,
|
||||||
'session-end-summary': FileBarChart,
|
'session-end-summary': FileBarChart,
|
||||||
|
'project-state-inject': FileBarChart,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ========== Category Names ==========
|
// ========== Category Names ==========
|
||||||
|
|||||||
@@ -6,25 +6,16 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import {
|
import {
|
||||||
User,
|
|
||||||
Bot,
|
|
||||||
Brain,
|
|
||||||
Wrench,
|
|
||||||
Copy,
|
Copy,
|
||||||
Clock,
|
Clock,
|
||||||
Hash,
|
Hash,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
FileJson,
|
FileJson,
|
||||||
Coins,
|
|
||||||
ArrowDownUp,
|
|
||||||
Archive,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { Card } from '@/components/ui/Card';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -32,11 +23,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/Dialog';
|
} from '@/components/ui/Dialog';
|
||||||
import { useNativeSession } from '@/hooks/useNativeSession';
|
import { useNativeSession } from '@/hooks/useNativeSession';
|
||||||
import type {
|
import { SessionTimeline } from './SessionTimeline';
|
||||||
NativeSessionTurn,
|
|
||||||
NativeToolCall,
|
|
||||||
NativeTokenInfo,
|
|
||||||
} from '@/lib/api';
|
|
||||||
|
|
||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
|
|
||||||
@@ -46,21 +33,6 @@ export interface NativeSessionPanelProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TurnCardProps {
|
|
||||||
turn: NativeSessionTurn;
|
|
||||||
isLatest: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TokenDisplayProps {
|
|
||||||
tokens: NativeTokenInfo;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ToolCallItemProps {
|
|
||||||
toolCall: NativeToolCall;
|
|
||||||
index: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Helpers ==========
|
// ========== Helpers ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,16 +48,6 @@ function getToolVariant(tool: string): 'default' | 'secondary' | 'outline' | 'su
|
|||||||
return variants[tool?.toLowerCase()] || 'secondary';
|
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
|
* Truncate a string to a max length with ellipsis
|
||||||
*/
|
*/
|
||||||
@@ -106,173 +68,6 @@ async function copyToClipboard(text: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Sub-Components ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TokenDisplay - Compact token info line
|
|
||||||
*/
|
|
||||||
function TokenDisplay({ tokens, className }: TokenDisplayProps) {
|
|
||||||
const { formatMessage } = useIntl();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('flex items-center gap-3 text-xs text-muted-foreground', className)}>
|
|
||||||
<span className="flex items-center gap-1" title={formatMessage({ id: 'nativeSession.tokens.total', defaultMessage: 'Total tokens' })}>
|
|
||||||
<Coins className="h-3 w-3" />
|
|
||||||
{formatTokenCount(tokens.total)}
|
|
||||||
</span>
|
|
||||||
{tokens.input != null && (
|
|
||||||
<span className="flex items-center gap-1" title={formatMessage({ id: 'nativeSession.tokens.input', defaultMessage: 'Input tokens' })}>
|
|
||||||
<ArrowDownUp className="h-3 w-3" />
|
|
||||||
{formatTokenCount(tokens.input)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{tokens.output != null && (
|
|
||||||
<span title={formatMessage({ id: 'nativeSession.tokens.output', defaultMessage: 'Output tokens' })}>
|
|
||||||
out: {formatTokenCount(tokens.output)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{tokens.cached != null && tokens.cached > 0 && (
|
|
||||||
<span className="flex items-center gap-1" title={formatMessage({ id: 'nativeSession.tokens.cached', defaultMessage: 'Cached tokens' })}>
|
|
||||||
<Archive className="h-3 w-3" />
|
|
||||||
{formatTokenCount(tokens.cached)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ToolCallItem - Single tool call display with collapsible details
|
|
||||||
*/
|
|
||||||
function ToolCallItem({ toolCall, index }: ToolCallItemProps) {
|
|
||||||
const { formatMessage } = useIntl();
|
|
||||||
return (
|
|
||||||
<details className="group/tool border border-border/50 rounded-md overflow-hidden">
|
|
||||||
<summary className="flex items-center gap-2 px-3 py-2 text-xs cursor-pointer hover:bg-muted/50 select-none">
|
|
||||||
<Wrench className="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
|
||||||
<span className="font-mono font-medium">{toolCall.name}</span>
|
|
||||||
<span className="text-muted-foreground">#{index + 1}</span>
|
|
||||||
</summary>
|
|
||||||
<div className="border-t border-border/50 divide-y divide-border/50">
|
|
||||||
{toolCall.arguments && (
|
|
||||||
<div className="p-3">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-1">{formatMessage({ id: 'nativeSession.toolCall.input' })}</p>
|
|
||||||
<pre className="p-2 bg-muted/30 rounded text-xs whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-60 overflow-y-auto">
|
|
||||||
{toolCall.arguments}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{toolCall.output && (
|
|
||||||
<div className="p-3">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-1">{formatMessage({ id: 'nativeSession.toolCall.output' })}</p>
|
|
||||||
<pre className="p-2 bg-muted/30 rounded text-xs whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-60 overflow-y-auto">
|
|
||||||
{toolCall.output}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TurnCard - Single conversation turn
|
|
||||||
*/
|
|
||||||
function TurnCard({ turn, isLatest }: TurnCardProps) {
|
|
||||||
const { formatMessage } = useIntl();
|
|
||||||
const isUser = turn.role === 'user';
|
|
||||||
const RoleIcon = isUser ? User : Bot;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
className={cn(
|
|
||||||
'overflow-hidden transition-all',
|
|
||||||
isUser
|
|
||||||
? 'bg-muted/30'
|
|
||||||
: 'bg-blue-500/5 dark:bg-blue-500/10',
|
|
||||||
isLatest && 'ring-2 ring-primary/50 shadow-md'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Turn Header */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<RoleIcon
|
|
||||||
className={cn(
|
|
||||||
'h-4 w-4',
|
|
||||||
isUser ? 'text-primary' : 'text-blue-500'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="font-semibold text-sm capitalize">{turn.role}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
#{turn.turnNumber}
|
|
||||||
</span>
|
|
||||||
{isLatest && (
|
|
||||||
<Badge variant="default" className="text-xs h-5 px-1.5">
|
|
||||||
{formatMessage({ id: 'nativeSession.turn.latest', defaultMessage: 'Latest' })}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
|
||||||
{turn.timestamp && (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
{new Date(turn.timestamp).toLocaleTimeString()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{turn.tokens && (
|
|
||||||
<TokenDisplay tokens={turn.tokens} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Turn Content */}
|
|
||||||
<div className="p-4 space-y-3">
|
|
||||||
{turn.content && (
|
|
||||||
<pre className="p-3 bg-background/50 rounded-lg text-sm whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-96 overflow-y-auto">
|
|
||||||
{turn.content}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Thoughts Section */}
|
|
||||||
{turn.thoughts && turn.thoughts.length > 0 && (
|
|
||||||
<details className="group/thoughts">
|
|
||||||
<summary className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground text-muted-foreground select-none py-1">
|
|
||||||
<Brain className="h-4 w-4" />
|
|
||||||
<span className="font-medium">
|
|
||||||
{formatMessage({ id: 'nativeSession.turn.thoughts', defaultMessage: 'Thoughts' })}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs">({turn.thoughts.length})</span>
|
|
||||||
</summary>
|
|
||||||
<ul className="mt-2 space-y-1 pl-6 text-sm text-muted-foreground list-disc">
|
|
||||||
{turn.thoughts.map((thought, i) => (
|
|
||||||
<li key={i} className="leading-relaxed">{thought}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tool Calls Section */}
|
|
||||||
{turn.toolCalls && turn.toolCalls.length > 0 && (
|
|
||||||
<details className="group/calls">
|
|
||||||
<summary className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground text-muted-foreground select-none py-1">
|
|
||||||
<Wrench className="h-4 w-4" />
|
|
||||||
<span className="font-medium">
|
|
||||||
{formatMessage({ id: 'nativeSession.turn.toolCalls', defaultMessage: 'Tool Calls' })}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs">({turn.toolCalls.length})</span>
|
|
||||||
</summary>
|
|
||||||
<div className="mt-2 space-y-2">
|
|
||||||
{turn.toolCalls.map((tc, i) => (
|
|
||||||
<ToolCallItem key={i} toolCall={tc} index={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Main Component ==========
|
// ========== Main Component ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -350,17 +145,7 @@ export function NativeSessionPanel({
|
|||||||
)}
|
)}
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Token Summary Bar */}
|
{/* Content Area with SessionTimeline */}
|
||||||
{session?.totalTokens && (
|
|
||||||
<div className="flex items-center gap-4 px-6 py-2.5 border-b bg-muted/30 shrink-0">
|
|
||||||
<span className="text-xs font-medium text-foreground">
|
|
||||||
{formatMessage({ id: 'nativeSession.tokenSummary', defaultMessage: 'Total Tokens' })}
|
|
||||||
</span>
|
|
||||||
<TokenDisplay tokens={session.totalTokens} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Content Area */}
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex-1 flex items-center justify-center py-16">
|
<div className="flex-1 flex items-center justify-center py-16">
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
@@ -375,24 +160,9 @@ export function NativeSessionPanel({
|
|||||||
<span>{formatMessage({ id: 'nativeSession.error', defaultMessage: 'Failed to load session' })}</span>
|
<span>{formatMessage({ id: 'nativeSession.error', defaultMessage: 'Failed to load session' })}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : session && session.turns.length > 0 ? (
|
) : session ? (
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
<div className="space-y-4">
|
<SessionTimeline session={session} />
|
||||||
{session.turns.map((turn, idx) => (
|
|
||||||
<React.Fragment key={turn.turnNumber}>
|
|
||||||
<TurnCard
|
|
||||||
turn={turn}
|
|
||||||
isLatest={idx === session.turns.length - 1}
|
|
||||||
/>
|
|
||||||
{/* Connector line between turns */}
|
|
||||||
{idx < session.turns.length - 1 && (
|
|
||||||
<div className="flex justify-center" aria-hidden="true">
|
|
||||||
<div className="w-px h-4 bg-border" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center py-16 text-muted-foreground">
|
<div className="flex-1 flex items-center justify-center py-16 text-muted-foreground">
|
||||||
|
|||||||
416
ccw/frontend/src/components/shared/SessionTimeline.tsx
Normal file
416
ccw/frontend/src/components/shared/SessionTimeline.tsx
Normal file
@@ -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 <CheckCircle className="h-3.5 w-3.5 text-green-500" />;
|
||||||
|
case 'error':
|
||||||
|
case 'failed':
|
||||||
|
return <XCircle className="h-3.5 w-3.5 text-destructive" />;
|
||||||
|
case 'running':
|
||||||
|
case 'pending':
|
||||||
|
return <Loader2 className="h-3.5 w-3.5 text-blue-500 animate-spin" />;
|
||||||
|
default:
|
||||||
|
return <Wrench className="h-3.5 w-3.5 text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<div className={cn('space-y-1.5', className)}>
|
||||||
|
{/* Visual bar */}
|
||||||
|
<div className="flex h-2 w-full rounded-full overflow-hidden bg-muted">
|
||||||
|
{input > 0 && (
|
||||||
|
<div
|
||||||
|
className="bg-blue-500/80"
|
||||||
|
style={{ width: `${inputPercent}%` }}
|
||||||
|
title={formatMessage({ id: 'nativeSession.timeline.tokens.input', defaultMessage: 'Input: {count}' }, { count: formatTokenCount(input) })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{output > 0 && (
|
||||||
|
<div
|
||||||
|
className="bg-green-500/80"
|
||||||
|
style={{ width: `${outputPercent}%` }}
|
||||||
|
title={formatMessage({ id: 'nativeSession.timeline.tokens.output', defaultMessage: 'Output: {count}' }, { count: formatTokenCount(output) })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{cached > 0 && (
|
||||||
|
<div
|
||||||
|
className="bg-amber-500/80"
|
||||||
|
style={{ width: `${cachedPercent}%` }}
|
||||||
|
title={formatMessage({ id: 'nativeSession.timeline.tokens.cached', defaultMessage: 'Cached: {count}' }, { count: formatTokenCount(cached) })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Labels */}
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Coins className="h-3 w-3" />
|
||||||
|
{formatTokenCount(total)}
|
||||||
|
</span>
|
||||||
|
{input > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<ArrowDownUp className="h-3 w-3 text-blue-500" />
|
||||||
|
{formatTokenCount(input)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{output > 0 && (
|
||||||
|
<span className="text-green-600 dark:text-green-400">
|
||||||
|
out: {formatTokenCount(output)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{cached > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Archive className="h-3 w-3 text-amber-500" />
|
||||||
|
{formatTokenCount(cached)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ToolCallPanel - Collapsible panel for tool call details
|
||||||
|
*/
|
||||||
|
function ToolCallPanel({ toolCall, index }: ToolCallPanelProps) {
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-border/50 rounded-lg overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="w-full flex items-center justify-between px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{getToolStatusIcon(toolCall.output ? 'completed' : undefined)}
|
||||||
|
<span className="font-mono font-medium">{toolCall.name}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">#{index + 1}</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant={getStatusVariant(toolCall.output ? 'completed' : undefined)} className="text-xs">
|
||||||
|
{formatMessage({ id: 'nativeSession.timeline.toolCall.completed', defaultMessage: 'completed' })}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Collapsible content */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-border/50 divide-y divide-border/50">
|
||||||
|
{toolCall.arguments && (
|
||||||
|
<div className="p-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-1.5">
|
||||||
|
{formatMessage({ id: 'nativeSession.toolCall.input', defaultMessage: 'Input' })}
|
||||||
|
</p>
|
||||||
|
<pre className="p-2.5 bg-muted/30 rounded-md text-xs whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-48 overflow-y-auto">
|
||||||
|
{toolCall.arguments}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{toolCall.output && (
|
||||||
|
<div className="p-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-1.5">
|
||||||
|
{formatMessage({ id: 'nativeSession.toolCall.output', defaultMessage: 'Output' })}
|
||||||
|
</p>
|
||||||
|
<pre className="p-2.5 bg-muted/30 rounded-md text-xs whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-48 overflow-y-auto">
|
||||||
|
{toolCall.output}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!toolCall.arguments && !toolCall.output && (
|
||||||
|
<div className="p-3 text-xs text-muted-foreground italic">
|
||||||
|
{formatMessage({ id: 'nativeSession.timeline.toolCall.noData', defaultMessage: 'No data available' })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* Timeline column */}
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
{/* Node dot */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center w-8 h-8 rounded-full border-2 shrink-0',
|
||||||
|
isUser
|
||||||
|
? 'bg-primary/10 border-primary text-primary'
|
||||||
|
: 'bg-blue-500/10 border-blue-500 text-blue-500',
|
||||||
|
isLatest && 'ring-2 ring-offset-2 ring-offset-background',
|
||||||
|
isLatest && (isUser ? 'ring-primary/50' : 'ring-blue-500/50')
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RoleIcon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
{/* Vertical connector line */}
|
||||||
|
{!isLast && (
|
||||||
|
<div className="w-0.5 flex-1 bg-border min-h-4" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content column */}
|
||||||
|
<div className="flex-1 pb-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn(
|
||||||
|
'font-semibold text-sm capitalize',
|
||||||
|
isUser ? 'text-primary' : 'text-blue-500'
|
||||||
|
)}>
|
||||||
|
{turn.role}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatMessage(
|
||||||
|
{ id: 'nativeSession.timeline.turnNumber', defaultMessage: 'Turn #{number}' },
|
||||||
|
{ number: turn.turnNumber }
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{isLatest && (
|
||||||
|
<Badge variant="default" className="text-xs h-5 px-1.5">
|
||||||
|
{formatMessage({ id: 'nativeSession.turn.latest', defaultMessage: 'Latest' })}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{turn.timestamp && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{new Date(turn.timestamp).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content card */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border overflow-hidden',
|
||||||
|
isUser
|
||||||
|
? 'bg-muted/30 border-border'
|
||||||
|
: 'bg-blue-500/5 dark:bg-blue-500/10 border-blue-500/20'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Message content */}
|
||||||
|
{turn.content && (
|
||||||
|
<div className="p-4">
|
||||||
|
<pre className="text-sm whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-64 overflow-y-auto">
|
||||||
|
{turn.content}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Thoughts section */}
|
||||||
|
{turn.thoughts && turn.thoughts.length > 0 && (
|
||||||
|
<details className="group/thoughts border-t border-border/50">
|
||||||
|
<summary className="flex items-center gap-2 px-4 py-2.5 text-sm cursor-pointer hover:bg-muted/30 select-none">
|
||||||
|
<Brain className="h-4 w-4 text-purple-500" />
|
||||||
|
<span className="font-medium text-muted-foreground">
|
||||||
|
{formatMessage({ id: 'nativeSession.turn.thoughts', defaultMessage: 'Thoughts' })}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({turn.thoughts.length})
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<ul className="px-4 pb-3 space-y-1 text-sm text-muted-foreground list-disc list-inside">
|
||||||
|
{turn.thoughts.map((thought, i) => (
|
||||||
|
<li key={i} className="leading-relaxed pl-2">{thought}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tool calls section */}
|
||||||
|
{turn.toolCalls && turn.toolCalls.length > 0 && (
|
||||||
|
<div className="border-t border-border/50 p-4 space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
|
||||||
|
<Wrench className="h-4 w-4" />
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatMessage({ id: 'nativeSession.turn.toolCalls', defaultMessage: 'Tool Calls' })}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs">({turn.toolCalls.length})</span>
|
||||||
|
</div>
|
||||||
|
{turn.toolCalls.map((tc, i) => (
|
||||||
|
<ToolCallPanel key={i} toolCall={tc} index={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Token usage bar */}
|
||||||
|
{turn.tokens && (
|
||||||
|
<div className="border-t border-border/50 px-4 py-3">
|
||||||
|
<TokenBar tokens={turn.tokens} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 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 (
|
||||||
|
<div className={cn('space-y-0', className)}>
|
||||||
|
{/* Session token summary bar */}
|
||||||
|
{session.totalTokens && (
|
||||||
|
<div className="mb-6 p-4 rounded-lg bg-muted/30 border">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-2">
|
||||||
|
{formatMessage({ id: 'nativeSession.tokenSummary', defaultMessage: 'Total Tokens' })}
|
||||||
|
</p>
|
||||||
|
<TokenBar tokens={session.totalTokens} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timeline turns */}
|
||||||
|
{turns.length > 0 ? (
|
||||||
|
<div className="relative">
|
||||||
|
{turns.map((turn, idx) => (
|
||||||
|
<TurnNode
|
||||||
|
key={turn.turnNumber}
|
||||||
|
turn={turn}
|
||||||
|
isLatest={idx === turns.length - 1}
|
||||||
|
isLast={idx === turns.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||||
|
{formatMessage({ id: 'nativeSession.empty', defaultMessage: 'No session data available' })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SessionTimeline;
|
||||||
@@ -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(
|
export async function fetchNativeSession(
|
||||||
executionId: string,
|
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<NativeSession | string | Array<{ turn: number; userPrompt: string; assistantResponse: string; timestamp: string }>> {
|
||||||
|
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<NativeSession | Array<{ turn: number; userPrompt: string; assistantResponse: string; timestamp: string }>>(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<NativeSessionsListResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (tool) params.set('tool', tool);
|
||||||
|
if (project) params.set('project', project);
|
||||||
|
|
||||||
|
const query = params.toString();
|
||||||
|
return fetchApi<NativeSessionsListResponse>(
|
||||||
|
`/api/cli/native-sessions${query ? `?${query}` : ''}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ========== CLI Tools Config API ==========
|
// ========== CLI Tools Config API ==========
|
||||||
|
|
||||||
export interface CliToolsConfigResponse {
|
export interface CliToolsConfigResponse {
|
||||||
|
|||||||
@@ -112,6 +112,10 @@
|
|||||||
"session-end-summary": {
|
"session-end-summary": {
|
||||||
"name": "Session End Summary",
|
"name": "Session End Summary",
|
||||||
"description": "Send session summary to dashboard on session end"
|
"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": {
|
"actions": {
|
||||||
|
|||||||
@@ -6,6 +6,20 @@
|
|||||||
"output": "Output tokens",
|
"output": "Output tokens",
|
||||||
"cached": "Cached 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": {
|
"turn": {
|
||||||
"latest": "Latest",
|
"latest": "Latest",
|
||||||
"thoughts": "Thoughts",
|
"thoughts": "Thoughts",
|
||||||
|
|||||||
@@ -112,6 +112,10 @@
|
|||||||
"session-end-summary": {
|
"session-end-summary": {
|
||||||
"name": "会话结束摘要",
|
"name": "会话结束摘要",
|
||||||
"description": "会话结束时发送摘要到仪表盘"
|
"description": "会话结束时发送摘要到仪表盘"
|
||||||
|
},
|
||||||
|
"project-state-inject": {
|
||||||
|
"name": "项目状态注入",
|
||||||
|
"description": "会话启动时注入项目约束和最近开发历史"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
|
|||||||
@@ -6,6 +6,20 @@
|
|||||||
"output": "输出 Token",
|
"output": "输出 Token",
|
||||||
"cached": "缓存 Token"
|
"cached": "缓存 Token"
|
||||||
},
|
},
|
||||||
|
"timeline": {
|
||||||
|
"turnNumber": "第 {number} 轮",
|
||||||
|
"tokens": {
|
||||||
|
"input": "输入: {count}",
|
||||||
|
"output": "输出: {count}",
|
||||||
|
"cached": "缓存: {count}"
|
||||||
|
},
|
||||||
|
"toolCall": {
|
||||||
|
"completed": "已完成",
|
||||||
|
"running": "运行中",
|
||||||
|
"error": "错误",
|
||||||
|
"noData": "无数据"
|
||||||
|
}
|
||||||
|
},
|
||||||
"turn": {
|
"turn": {
|
||||||
"latest": "最新",
|
"latest": "最新",
|
||||||
"thoughts": "思考过程",
|
"thoughts": "思考过程",
|
||||||
|
|||||||
@@ -293,6 +293,8 @@ export function run(argv: string[]): void {
|
|||||||
.option('--session-id <id>', 'Session ID')
|
.option('--session-id <id>', 'Session ID')
|
||||||
.option('--prompt <text>', 'Prompt text')
|
.option('--prompt <text>', 'Prompt text')
|
||||||
.option('--type <type>', 'Context type: session-start, context')
|
.option('--type <type>', 'Context type: session-start, context')
|
||||||
|
.option('--path <path>', 'File or project path')
|
||||||
|
.option('--limit <n>', 'Max entries to return (for project-state)')
|
||||||
.action((subcommand, args, options) => hookCommand(subcommand, args, options));
|
.action((subcommand, args, options) => hookCommand(subcommand, args, options));
|
||||||
|
|
||||||
// Issue command - Issue lifecycle management with JSONL task tracking
|
// Issue command - Issue lifecycle management with JSONL task tracking
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import { existsSync, readFileSync } from 'fs';
|
import { existsSync, readFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
interface HookOptions {
|
interface HookOptions {
|
||||||
stdin?: boolean;
|
stdin?: boolean;
|
||||||
@@ -12,6 +13,7 @@ interface HookOptions {
|
|||||||
prompt?: string;
|
prompt?: string;
|
||||||
type?: 'session-start' | 'context' | 'session-end' | 'stop' | 'pre-compact';
|
type?: 'session-start' | 'context' | 'session-end' | 'stop' | 'pre-compact';
|
||||||
path?: string;
|
path?: string;
|
||||||
|
limit?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HookData {
|
interface HookData {
|
||||||
@@ -713,6 +715,142 @@ async function notifyAction(options: HookOptions): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project state action - reads project-tech.json and project-guidelines.json
|
||||||
|
* and outputs a concise summary for session context injection.
|
||||||
|
*
|
||||||
|
* Used as SessionStart hook: stdout → injected as system message.
|
||||||
|
*/
|
||||||
|
async function projectStateAction(options: HookOptions): Promise<void> {
|
||||||
|
let { stdin, path: projectPath } = options;
|
||||||
|
const limit = Math.min(parseInt(options.limit || '5', 10), 20);
|
||||||
|
|
||||||
|
if (stdin) {
|
||||||
|
try {
|
||||||
|
const stdinData = await readStdin();
|
||||||
|
if (stdinData) {
|
||||||
|
const hookData = JSON.parse(stdinData) as HookData;
|
||||||
|
projectPath = hookData.cwd || projectPath;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently continue if stdin parsing fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
projectPath = projectPath || process.cwd();
|
||||||
|
|
||||||
|
const result: {
|
||||||
|
tech: { recent: Array<{ title: string; category: string; date: string }> };
|
||||||
|
guidelines: { constraints: string[]; recent_learnings: Array<{ insight: string; date: string }> };
|
||||||
|
} = {
|
||||||
|
tech: { recent: [] },
|
||||||
|
guidelines: { constraints: [], recent_learnings: [] }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read project-tech.json
|
||||||
|
const techPath = join(projectPath, '.workflow', 'project-tech.json');
|
||||||
|
if (existsSync(techPath)) {
|
||||||
|
try {
|
||||||
|
const tech = JSON.parse(readFileSync(techPath, 'utf8'));
|
||||||
|
const allEntries: Array<{ title: string; category: string; date: string }> = [];
|
||||||
|
if (tech.development_index) {
|
||||||
|
for (const [cat, entries] of Object.entries(tech.development_index)) {
|
||||||
|
if (Array.isArray(entries)) {
|
||||||
|
for (const e of entries as Array<{ title?: string; date?: string }>) {
|
||||||
|
allEntries.push({ title: e.title || '', category: cat, date: e.date || '' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allEntries.sort((a, b) => b.date.localeCompare(a.date));
|
||||||
|
result.tech.recent = allEntries.slice(0, limit);
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read project-guidelines.json
|
||||||
|
const guidelinesPath = join(projectPath, '.workflow', 'project-guidelines.json');
|
||||||
|
if (existsSync(guidelinesPath)) {
|
||||||
|
try {
|
||||||
|
const gl = JSON.parse(readFileSync(guidelinesPath, 'utf8'));
|
||||||
|
// constraints is Record<string, array> - flatten all categories
|
||||||
|
const allConstraints: string[] = [];
|
||||||
|
if (gl.constraints && typeof gl.constraints === 'object') {
|
||||||
|
for (const entries of Object.values(gl.constraints)) {
|
||||||
|
if (Array.isArray(entries)) {
|
||||||
|
for (const c of entries) {
|
||||||
|
allConstraints.push(typeof c === 'string' ? c : (c as { rule?: string }).rule || JSON.stringify(c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.guidelines.constraints = allConstraints.slice(0, limit);
|
||||||
|
|
||||||
|
const learnings = Array.isArray(gl.learnings) ? gl.learnings : [];
|
||||||
|
learnings.sort((a: { date?: string }, b: { date?: string }) => (b.date || '').localeCompare(a.date || ''));
|
||||||
|
result.guidelines.recent_learnings = learnings.slice(0, limit).map(
|
||||||
|
(l: { insight?: string; date?: string }) => ({ insight: l.insight || '', date: l.date || '' })
|
||||||
|
);
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stdin) {
|
||||||
|
// Format as <project-state> tag for system message injection
|
||||||
|
const techStr = result.tech.recent.map(e => `${e.title} (${e.category})`).join(', ');
|
||||||
|
const constraintStr = result.guidelines.constraints.join('; ');
|
||||||
|
const learningStr = result.guidelines.recent_learnings.map(e => e.insight).join('; ');
|
||||||
|
|
||||||
|
const parts: string[] = ['<project-state>'];
|
||||||
|
if (techStr) parts.push(`Recent: ${techStr}`);
|
||||||
|
if (constraintStr) parts.push(`Constraints: ${constraintStr}`);
|
||||||
|
if (learningStr) parts.push(`Learnings: ${learningStr}`);
|
||||||
|
parts.push('</project-state>');
|
||||||
|
|
||||||
|
process.stdout.write(parts.join('\n'));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interactive mode: show detailed output
|
||||||
|
console.log(chalk.green('Project State Summary'));
|
||||||
|
console.log(chalk.gray('─'.repeat(40)));
|
||||||
|
console.log(chalk.cyan('Project:'), projectPath);
|
||||||
|
console.log(chalk.cyan('Limit:'), limit);
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
if (result.tech.recent.length > 0) {
|
||||||
|
console.log(chalk.yellow('Recent Development:'));
|
||||||
|
for (const e of result.tech.recent) {
|
||||||
|
console.log(` ${chalk.gray(e.date)} ${e.title} ${chalk.cyan(`(${e.category})`)}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(chalk.gray('(No development index entries)'));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
if (result.guidelines.constraints.length > 0) {
|
||||||
|
console.log(chalk.yellow('Constraints:'));
|
||||||
|
for (const c of result.guidelines.constraints) {
|
||||||
|
console.log(` - ${c}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(chalk.gray('(No constraints)'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.guidelines.recent_learnings.length > 0) {
|
||||||
|
console.log(chalk.yellow('Recent Learnings:'));
|
||||||
|
for (const l of result.guidelines.recent_learnings) {
|
||||||
|
console.log(` ${chalk.gray(l.date)} ${l.insight}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(chalk.gray('(No learnings)'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also output JSON for piping
|
||||||
|
console.log();
|
||||||
|
console.log(chalk.gray('JSON:'));
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show help for hook command
|
* Show help for hook command
|
||||||
*/
|
*/
|
||||||
@@ -731,10 +869,12 @@ ${chalk.bold('SUBCOMMANDS')}
|
|||||||
keyword Detect mode keywords in prompts and activate modes
|
keyword Detect mode keywords in prompts and activate modes
|
||||||
pre-compact Handle PreCompact hook events (checkpoint creation)
|
pre-compact Handle PreCompact hook events (checkpoint creation)
|
||||||
notify Send notification to ccw view dashboard
|
notify Send notification to ccw view dashboard
|
||||||
|
project-state Output project guidelines and recent dev history summary
|
||||||
|
|
||||||
${chalk.bold('OPTIONS')}
|
${chalk.bold('OPTIONS')}
|
||||||
--stdin Read input from stdin (for Claude Code hooks)
|
--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)
|
--session-id Session ID (alternative to stdin)
|
||||||
--prompt Current prompt text (alternative to stdin)
|
--prompt Current prompt text (alternative to stdin)
|
||||||
|
|
||||||
@@ -760,6 +900,12 @@ ${chalk.bold('EXAMPLES')}
|
|||||||
${chalk.gray('# Handle PreCompact events:')}
|
${chalk.gray('# Handle PreCompact events:')}
|
||||||
ccw hook pre-compact --stdin
|
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.bold('HOOK CONFIGURATION')}
|
||||||
${chalk.gray('Add to .claude/settings.json for Stop hook:')}
|
${chalk.gray('Add to .claude/settings.json for Stop hook:')}
|
||||||
{
|
{
|
||||||
@@ -820,6 +966,9 @@ export async function hookCommand(
|
|||||||
case 'notify':
|
case 'notify':
|
||||||
await notifyAction(options);
|
await notifyAction(options);
|
||||||
break;
|
break;
|
||||||
|
case 'project-state':
|
||||||
|
await projectStateAction(options);
|
||||||
|
break;
|
||||||
case 'help':
|
case 'help':
|
||||||
case undefined:
|
case undefined:
|
||||||
showHelp();
|
showHelp();
|
||||||
|
|||||||
@@ -559,30 +559,82 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// API: Get Native Session Content
|
// API: Get Native Session Content
|
||||||
|
// Supports: ?id=<executionId> (existing), ?path=<filepath>&tool=<tool> (new direct path query)
|
||||||
if (pathname === '/api/cli/native-session') {
|
if (pathname === '/api/cli/native-session') {
|
||||||
const projectPath = url.searchParams.get('path') || initialPath;
|
const projectPath = url.searchParams.get('path') || initialPath;
|
||||||
const executionId = url.searchParams.get('id');
|
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';
|
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.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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let result;
|
let result;
|
||||||
if (format === 'text') {
|
|
||||||
result = await getFormattedNativeConversation(projectPath, executionId, {
|
// Direct file path query (new)
|
||||||
includeThoughts: url.searchParams.get('thoughts') === 'true',
|
if (filePath) {
|
||||||
includeToolCalls: url.searchParams.get('tools') === 'true',
|
const { parseSessionFile } = await import('../../tools/session-content-parser.js');
|
||||||
includeTokens: url.searchParams.get('tokens') === 'true'
|
|
||||||
});
|
// Determine tool type
|
||||||
} else if (format === 'pairs') {
|
let tool = toolParam;
|
||||||
const enriched = await getEnrichedConversation(projectPath, executionId);
|
if (tool === 'auto') {
|
||||||
result = enriched?.merged || null;
|
// 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 {
|
} 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) {
|
if (!result) {
|
||||||
@@ -600,6 +652,83 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API: List Native Sessions (new endpoint)
|
||||||
|
// Supports: ?tool=<gemini|qwen|codex|claude|opencode> & ?project=<projectPath>
|
||||||
|
if (pathname === '/api/cli/native-sessions' && req.method === 'GET') {
|
||||||
|
const toolFilter = url.searchParams.get('tool');
|
||||||
|
const projectPath = url.searchParams.get('project') || initialPath;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
getDiscoverer,
|
||||||
|
getNativeSessions
|
||||||
|
} = await import('../../tools/native-session-discovery.js');
|
||||||
|
|
||||||
|
const sessions: Array<{
|
||||||
|
id: string;
|
||||||
|
tool: string;
|
||||||
|
path: string;
|
||||||
|
title?: string;
|
||||||
|
startTime: string;
|
||||||
|
updatedAt: string;
|
||||||
|
projectHash?: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// Define supported tools
|
||||||
|
const supportedTools = ['gemini', 'qwen', 'codex', 'claude', 'opencode'] as const;
|
||||||
|
const toolsToQuery = toolFilter && supportedTools.includes(toolFilter as typeof supportedTools[number])
|
||||||
|
? [toolFilter as typeof supportedTools[number]]
|
||||||
|
: [...supportedTools];
|
||||||
|
|
||||||
|
for (const tool of toolsToQuery) {
|
||||||
|
const discoverer = getDiscoverer(tool);
|
||||||
|
if (!discoverer) continue;
|
||||||
|
|
||||||
|
const nativeSessions = getNativeSessions(tool, {
|
||||||
|
workingDir: projectPath,
|
||||||
|
limit: 100
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const session of nativeSessions) {
|
||||||
|
// Try to extract title from session
|
||||||
|
let title: string | undefined;
|
||||||
|
try {
|
||||||
|
const firstUserMessage = (discoverer as any).extractFirstUserMessage?.(session.filePath);
|
||||||
|
if (firstUserMessage) {
|
||||||
|
// Truncate to first 100 chars as title
|
||||||
|
title = firstUserMessage.substring(0, 100).trim();
|
||||||
|
if (firstUserMessage.length > 100) {
|
||||||
|
title += '...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors extracting title
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions.push({
|
||||||
|
id: session.sessionId,
|
||||||
|
tool: session.tool,
|
||||||
|
path: session.filePath,
|
||||||
|
title,
|
||||||
|
startTime: session.createdAt.toISOString(),
|
||||||
|
updatedAt: session.updatedAt.toISOString(),
|
||||||
|
projectHash: session.projectHash
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by updatedAt descending
|
||||||
|
sessions.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ sessions, count: sessions.length }));
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: (err as Error).message }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// API: Get Enriched Conversation
|
// API: Get Enriched Conversation
|
||||||
if (pathname === '/api/cli/enriched') {
|
if (pathname === '/api/cli/enriched') {
|
||||||
const projectPath = url.searchParams.get('path') || initialPath;
|
const projectPath = url.searchParams.get('path') || initialPath;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
* - POST /api/hook - Main hook endpoint for Claude Code notifications
|
* - POST /api/hook - Main hook endpoint for Claude Code notifications
|
||||||
* - Handles: session-start, context, CLI events, A2UI surfaces
|
* - Handles: session-start, context, CLI events, A2UI surfaces
|
||||||
* - POST /api/hook/ccw-exec - Execute CCW CLI commands and parse output
|
* - 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
|
* - GET /api/hooks - Get hooks configuration from global and project settings
|
||||||
* - POST /api/hooks - Save a hook to settings
|
* - POST /api/hooks - Save a hook to settings
|
||||||
* - DELETE /api/hooks - Delete a hook from settings
|
* - DELETE /api/hooks - Delete a hook from settings
|
||||||
@@ -520,6 +521,62 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise<boolean
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API: Get project state summary for hook injection
|
||||||
|
if (pathname === '/api/hook/project-state' && req.method === 'GET') {
|
||||||
|
const projectPath = url.searchParams.get('path') || initialPath;
|
||||||
|
const limit = Math.min(parseInt(url.searchParams.get('limit') || '5', 10), 20);
|
||||||
|
|
||||||
|
const result: Record<string, unknown> = { tech: { recent: [] }, guidelines: { constraints: [], recent_learnings: [] } };
|
||||||
|
|
||||||
|
// Read project-tech.json
|
||||||
|
const techPath = join(projectPath, '.workflow', 'project-tech.json');
|
||||||
|
if (existsSync(techPath)) {
|
||||||
|
try {
|
||||||
|
const tech = JSON.parse(readFileSync(techPath, 'utf8'));
|
||||||
|
const allEntries: Array<{ title: string; category: string; date: string }> = [];
|
||||||
|
if (tech.development_index) {
|
||||||
|
for (const [cat, entries] of Object.entries(tech.development_index)) {
|
||||||
|
if (Array.isArray(entries)) {
|
||||||
|
for (const e of entries as Array<{ title?: string; date?: string }>) {
|
||||||
|
allEntries.push({ title: e.title || '', category: cat, date: e.date || '' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allEntries.sort((a, b) => b.date.localeCompare(a.date));
|
||||||
|
(result.tech as Record<string, unknown>).recent = allEntries.slice(0, limit);
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read project-guidelines.json
|
||||||
|
const guidelinesPath = join(projectPath, '.workflow', 'project-guidelines.json');
|
||||||
|
if (existsSync(guidelinesPath)) {
|
||||||
|
try {
|
||||||
|
const gl = JSON.parse(readFileSync(guidelinesPath, 'utf8'));
|
||||||
|
const g = result.guidelines as Record<string, unknown>;
|
||||||
|
// constraints is Record<string, array> - flatten all categories
|
||||||
|
const allConstraints: string[] = [];
|
||||||
|
if (gl.constraints && typeof gl.constraints === 'object') {
|
||||||
|
for (const entries of Object.values(gl.constraints)) {
|
||||||
|
if (Array.isArray(entries)) {
|
||||||
|
for (const c of entries) {
|
||||||
|
allConstraints.push(typeof c === 'string' ? c : (c as { rule?: string }).rule || JSON.stringify(c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.constraints = allConstraints.slice(0, limit);
|
||||||
|
const learnings = Array.isArray(gl.learnings) ? gl.learnings : [];
|
||||||
|
learnings.sort((a: { date?: string }, b: { date?: string }) => (b.date || '').localeCompare(a.date || ''));
|
||||||
|
g.recent_learnings = learnings.slice(0, limit).map((l: { insight?: string; date?: string }) => ({ insight: l.insight || '', date: l.date || '' }));
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(result));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// API: Get hooks configuration
|
// API: Get hooks configuration
|
||||||
if (pathname === '/api/hooks' && req.method === 'GET') {
|
if (pathname === '/api/hooks' && req.method === 'GET') {
|
||||||
const projectPathParam = url.searchParams.get('path');
|
const projectPathParam = url.searchParams.get('path');
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export interface ClaudeUserLine extends ClaudeJsonlLine {
|
|||||||
/**
|
/**
|
||||||
* Assistant message line in Claude JSONL
|
* Assistant message line in Claude JSONL
|
||||||
* Contains content blocks, tool calls, and usage info
|
* Contains content blocks, tool calls, and usage info
|
||||||
|
* Note: usage can be at top level or inside message object
|
||||||
*/
|
*/
|
||||||
export interface ClaudeAssistantLine extends ClaudeJsonlLine {
|
export interface ClaudeAssistantLine extends ClaudeJsonlLine {
|
||||||
type: 'assistant';
|
type: 'assistant';
|
||||||
@@ -50,6 +51,7 @@ export interface ClaudeAssistantLine extends ClaudeJsonlLine {
|
|||||||
id?: string;
|
id?: string;
|
||||||
stop_reason?: string | null;
|
stop_reason?: string | null;
|
||||||
stop_sequence?: string | null;
|
stop_sequence?: string | null;
|
||||||
|
usage?: ClaudeUsage;
|
||||||
};
|
};
|
||||||
usage?: ClaudeUsage;
|
usage?: ClaudeUsage;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
@@ -133,11 +135,10 @@ export function parseClaudeSession(filePath: string): ParsedSession | null {
|
|||||||
let model: string | undefined;
|
let model: string | undefined;
|
||||||
let totalTokens: TokenInfo = { input: 0, output: 0, total: 0 };
|
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 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) {
|
for (const line of lines) {
|
||||||
try {
|
try {
|
||||||
const entry: ClaudeJsonlLine = JSON.parse(line);
|
const entry: ClaudeJsonlLine = JSON.parse(line);
|
||||||
@@ -149,11 +150,6 @@ export function parseClaudeSession(filePath: string): ParsedSession | null {
|
|||||||
|
|
||||||
messageMap.set(entry.uuid, entry);
|
messageMap.set(entry.uuid, entry);
|
||||||
|
|
||||||
// Track root messages (no parent)
|
|
||||||
if (!entry.parentUuid) {
|
|
||||||
rootUuids.push(entry.uuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract metadata from first entry
|
// Extract metadata from first entry
|
||||||
if (!startTime && entry.timestamp) {
|
if (!startTime && entry.timestamp) {
|
||||||
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;
|
let turnNumber = 0;
|
||||||
const processedUuids = new Set<string>();
|
const processedUserUuids = new Set<string>();
|
||||||
|
|
||||||
for (const rootUuid of rootUuids) {
|
for (const [uuid, entry] of messageMap) {
|
||||||
const turn = processConversationBranch(
|
if (entry.type !== 'user') continue;
|
||||||
rootUuid,
|
|
||||||
messageMap,
|
|
||||||
processedUuids,
|
|
||||||
++turnNumber
|
|
||||||
);
|
|
||||||
|
|
||||||
if (turn) {
|
const userEntry = entry as ClaudeUserLine;
|
||||||
turns.push(turn);
|
|
||||||
|
|
||||||
// Accumulate tokens
|
// Skip meta messages (command messages, system messages)
|
||||||
if (turn.tokens) {
|
if (userEntry.isMeta) continue;
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track model
|
// Skip if already processed
|
||||||
if (!model && turn.tokens?.input) {
|
if (processedUserUuids.has(uuid)) continue;
|
||||||
// Model info is typically in assistant messages
|
|
||||||
|
// 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
|
// Create user turn
|
||||||
if (!model) {
|
turns.push({
|
||||||
for (const line of lines) {
|
turnNumber,
|
||||||
try {
|
timestamp: entry.timestamp,
|
||||||
const entry = JSON.parse(line);
|
role: 'user',
|
||||||
if (entry.type === 'assistant' && entry.message?.model) {
|
content: userContent
|
||||||
model = entry.message.model;
|
});
|
||||||
break;
|
|
||||||
}
|
// Create assistant turn if there's a response
|
||||||
} catch {
|
if (assistantContent || toolCalls.length > 0) {
|
||||||
// Skip
|
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
|
* Extract session ID from file path
|
||||||
* Claude session files are named <uuid>.jsonl
|
* Claude session files are named <uuid>.jsonl
|
||||||
@@ -249,114 +311,6 @@ function extractSessionId(filePath: string): string {
|
|||||||
return uuidMatch ? uuidMatch[1] : nameWithoutExt;
|
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
|
* Extract text content from user message
|
||||||
* Handles both string and array content formats
|
* Handles both string and array content formats
|
||||||
@@ -367,14 +321,6 @@ function extractUserContent(entry: ClaudeUserLine): string {
|
|||||||
|
|
||||||
// Simple string content
|
// Simple string content
|
||||||
if (typeof content === 'string') {
|
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;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,9 +404,8 @@ export function parseClaudeSessionContent(content: string, filePath?: string): P
|
|||||||
let model: string | undefined;
|
let model: string | undefined;
|
||||||
let totalTokens: TokenInfo = { input: 0, output: 0, total: 0 };
|
let totalTokens: TokenInfo = { input: 0, output: 0, total: 0 };
|
||||||
|
|
||||||
// Track conversation structure
|
// Build message map
|
||||||
const messageMap = new Map<string, ClaudeJsonlLine>();
|
const messageMap = new Map<string, ClaudeJsonlLine>();
|
||||||
const rootUuids: string[] = [];
|
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
try {
|
try {
|
||||||
@@ -472,10 +417,6 @@ export function parseClaudeSessionContent(content: string, filePath?: string): P
|
|||||||
|
|
||||||
messageMap.set(entry.uuid, entry);
|
messageMap.set(entry.uuid, entry);
|
||||||
|
|
||||||
if (!entry.parentUuid) {
|
|
||||||
rootUuids.push(entry.uuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!startTime && entry.timestamp) {
|
if (!startTime && entry.timestamp) {
|
||||||
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;
|
let turnNumber = 0;
|
||||||
const processedUuids = new Set<string>();
|
const processedUserUuids = new Set<string>();
|
||||||
|
|
||||||
for (const rootUuid of rootUuids) {
|
for (const [uuid, entry] of messageMap) {
|
||||||
const turn = processConversationBranch(
|
if (entry.type !== 'user') continue;
|
||||||
rootUuid,
|
|
||||||
messageMap,
|
|
||||||
processedUuids,
|
|
||||||
++turnNumber
|
|
||||||
);
|
|
||||||
|
|
||||||
if (turn) {
|
const userEntry = entry as ClaudeUserLine;
|
||||||
turns.push(turn);
|
|
||||||
|
|
||||||
if (turn.tokens) {
|
if (userEntry.isMeta) continue;
|
||||||
totalTokens.input = (totalTokens.input || 0) + (turn.tokens.input || 0);
|
if (processedUserUuids.has(uuid)) continue;
|
||||||
totalTokens.output = (totalTokens.output || 0) + (turn.tokens.output || 0);
|
|
||||||
|
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
|
turns.push({
|
||||||
for (const line of lines) {
|
turnNumber,
|
||||||
try {
|
timestamp: entry.timestamp,
|
||||||
const entry = JSON.parse(line);
|
role: 'user',
|
||||||
if (entry.type === 'assistant' && entry.message?.model) {
|
content: userContent
|
||||||
model = entry.message.model;
|
});
|
||||||
break;
|
|
||||||
}
|
if (assistantContent || toolCalls.length > 0) {
|
||||||
} catch {
|
turns.push({
|
||||||
// Skip
|
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 { readFileSync, existsSync } from 'fs';
|
||||||
import { parseClaudeSession } from './claude-session-parser.js';
|
import { parseClaudeSession } from './claude-session-parser.js';
|
||||||
|
import { parseOpenCodeSession } from './opencode-session-parser.js';
|
||||||
|
|
||||||
// Standardized conversation turn
|
// Standardized conversation turn
|
||||||
export interface ParsedTurn {
|
export interface ParsedTurn {
|
||||||
@@ -200,6 +201,8 @@ export function parseSessionFile(filePath: string, tool: string): ParsedSession
|
|||||||
return parseCodexSession(content);
|
return parseCodexSession(content);
|
||||||
case 'claude':
|
case 'claude':
|
||||||
return parseClaudeSession(filePath);
|
return parseClaudeSession(filePath);
|
||||||
|
case 'opencode':
|
||||||
|
return parseOpenCodeSession(filePath);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user