- Add :key to PromptBar to force remount on agent switch, fixing shared terminal session bug - Raise AgentTerminal z-index above PromptBar backdrop so floating terminal is visible/clickable - Send prompt text char-by-char (15ms delay) matching FloatingVoice pattern for Claude Code compat - Guard xterm dispose against unloaded addons to prevent errors on agent switch - Widen PromptBar panel from 360px to 420px to fit all ChatInput buttons
914 lines
27 KiB
TypeScript
914 lines
27 KiB
TypeScript
// Transcript Engine - Parses Claude Code JSONL transcripts
|
|
// Module-level state pattern (like terminal.ts, torch-handler.ts)
|
|
|
|
import { existsSync, readFileSync, statSync, readdirSync } from 'fs'
|
|
import { join } from 'path'
|
|
import { homedir } from 'os'
|
|
import { WORKING_DIR } from '../config'
|
|
|
|
// ── Types ──
|
|
|
|
export interface TranscriptAnalysis {
|
|
sessionId: string
|
|
model: string
|
|
version: string
|
|
gitBranch: string
|
|
cwd: string
|
|
startTime: string
|
|
endTime: string
|
|
duration: number
|
|
messages: TranscriptMessage[]
|
|
tokens: {
|
|
totalInput: number
|
|
totalOutput: number
|
|
totalCacheRead: number
|
|
totalCacheCreation: number
|
|
byTurn: TurnTokens[]
|
|
}
|
|
tools: {
|
|
summary: Record<string, number>
|
|
calls: ToolCall[]
|
|
}
|
|
filesModified: string[]
|
|
subagents: SubagentInfo[]
|
|
summaries: string[]
|
|
stats: {
|
|
messageCount: number
|
|
userMessageCount: number
|
|
assistantMessageCount: number
|
|
toolCallCount: number
|
|
thinkingBlocks: number
|
|
errors: number
|
|
}
|
|
lastStopReason: string
|
|
}
|
|
|
|
export interface TranscriptMessage {
|
|
uuid: string
|
|
role: 'user' | 'assistant'
|
|
content: string
|
|
timestamp: string
|
|
isMeta: boolean
|
|
tokens?: { input: number; output: number }
|
|
toolCalls?: string[]
|
|
hasThinking: boolean
|
|
}
|
|
|
|
export interface ToolCall {
|
|
name: string
|
|
input: unknown
|
|
output?: string
|
|
timestamp: string
|
|
isError: boolean
|
|
}
|
|
|
|
export interface TurnTokens {
|
|
turnIndex: number
|
|
input: number
|
|
output: number
|
|
cacheRead: number
|
|
cacheCreation: number
|
|
model: string
|
|
}
|
|
|
|
export interface SubagentInfo {
|
|
agentId: string
|
|
prompt: string
|
|
timestamp: string
|
|
}
|
|
|
|
export interface SessionInfo {
|
|
id: string
|
|
startTime: string
|
|
messageCount: number
|
|
model: string
|
|
}
|
|
|
|
export interface ClaudeStats {
|
|
today: {
|
|
date: string
|
|
messageCount: number
|
|
sessionCount: number
|
|
toolCallCount: number
|
|
tokensByModel: Record<string, number>
|
|
} | null
|
|
modelUsage: Record<string, {
|
|
inputTokens: number
|
|
outputTokens: number
|
|
cacheReadInputTokens: number
|
|
cacheCreationInputTokens: number
|
|
costUSD: number
|
|
}>
|
|
totalSessions: number
|
|
totalMessages: number
|
|
firstSessionDate: string
|
|
}
|
|
|
|
export interface ClaudeUsage {
|
|
subscription: { type: string; tier: string; label: string; multiplier: number }
|
|
today: { messages: number; outputTokens: number; sessions: number }
|
|
daily: { used: number; limit: number; percent: number }
|
|
weekly: { used: number; limit: number; percent: number }
|
|
status: 'normal' | 'elevated' | 'extended' | 'limit_approaching'
|
|
}
|
|
|
|
const TIER_LIMITS: Record<string, { windowMessages: number; dailyEstimate: number; weeklyEstimate: number }> = {
|
|
pro: { windowMessages: 45, dailyEstimate: 500, weeklyEstimate: 3500 },
|
|
max_5x: { windowMessages: 225, dailyEstimate: 2500, weeklyEstimate: 17500 },
|
|
max_20x: { windowMessages: 900, dailyEstimate: 10000, weeklyEstimate: 70000 },
|
|
}
|
|
|
|
function parseTier(rateLimitTier: string): { key: string; label: string; multiplier: number } {
|
|
const match = rateLimitTier.match(/max_(\d+)x/)
|
|
if (match) {
|
|
const n = parseInt(match[1])
|
|
return { key: `max_${n}x`, label: `Max ${n}x`, multiplier: n }
|
|
}
|
|
return { key: 'pro', label: 'Pro', multiplier: 1 }
|
|
}
|
|
|
|
// ── Module-level cache ──
|
|
|
|
const cache = new Map<string, {
|
|
analysis: TranscriptAnalysis
|
|
lastModified: number
|
|
}>()
|
|
|
|
// ── Active session tracking (for real-time chat) ──
|
|
|
|
const activeAgentSessions = new Map<string, {
|
|
sessionId: string
|
|
transcriptPath: string
|
|
}>()
|
|
|
|
// Byte offset per session (how far we've read)
|
|
const readOffsets = new Map<string, number>()
|
|
|
|
// ── Project hash ──
|
|
|
|
function getProjectHash(): string {
|
|
// C:\Users\jodar\agent-ui → C--Users-jodar-agent-ui
|
|
return WORKING_DIR.replace(/[\\/]/g, '-').replace(/:/g, '-')
|
|
}
|
|
|
|
function getProjectDir(): string {
|
|
return join(homedir(), '.claude', 'projects', getProjectHash())
|
|
}
|
|
|
|
// ── Path resolution ──
|
|
|
|
function resolveTranscriptPath(sessionId?: string): string | null {
|
|
const projectDir = getProjectDir()
|
|
if (!existsSync(projectDir)) return null
|
|
|
|
if (sessionId && sessionId !== 'latest') {
|
|
const filePath = join(projectDir, `${sessionId}.jsonl`)
|
|
return existsSync(filePath) ? filePath : null
|
|
}
|
|
|
|
// Find most recent by mtime
|
|
try {
|
|
const files = readdirSync(projectDir)
|
|
.filter(f => f.endsWith('.jsonl'))
|
|
.map(f => {
|
|
const fullPath = join(projectDir, f)
|
|
return { name: f, path: fullPath, mtime: statSync(fullPath).mtimeMs }
|
|
})
|
|
.sort((a, b) => b.mtime - a.mtime)
|
|
|
|
return files.length > 0 ? files[0].path : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function sessionIdFromPath(filePath: string): string {
|
|
const basename = filePath.split(/[\\/]/).pop() || ''
|
|
return basename.replace('.jsonl', '')
|
|
}
|
|
|
|
// ── Helpers ──
|
|
|
|
function truncate(str: string, maxLen: number): string {
|
|
if (!str || str.length <= maxLen) return str || ''
|
|
return str.slice(0, maxLen) + '...'
|
|
}
|
|
|
|
function extractText(content: any): string {
|
|
if (typeof content === 'string') {
|
|
return content.replace(/<[^>]+>/g, '').trim()
|
|
}
|
|
if (Array.isArray(content)) {
|
|
return content
|
|
.filter((c: any) => c.type === 'text')
|
|
.map((c: any) => c.text || '')
|
|
.join('\n')
|
|
.replace(/<[^>]+>/g, '')
|
|
.trim()
|
|
}
|
|
return ''
|
|
}
|
|
|
|
// ── JSONL parsing ──
|
|
|
|
interface ParsedLine {
|
|
type: string
|
|
data: any
|
|
}
|
|
|
|
function parseTranscriptFile(filePath: string): ParsedLine[] {
|
|
const content = readFileSync(filePath, 'utf8')
|
|
const rawLines = content.trim().split('\n')
|
|
const lines: ParsedLine[] = []
|
|
|
|
for (const line of rawLines) {
|
|
if (!line.trim()) continue
|
|
try {
|
|
const obj = JSON.parse(line)
|
|
lines.push({ type: obj.type, data: obj })
|
|
} catch {
|
|
// Skip unparseable lines
|
|
}
|
|
}
|
|
|
|
return lines
|
|
}
|
|
|
|
// ── Build analysis from parsed lines ──
|
|
|
|
function buildAnalysis(lines: ParsedLine[], fileSessionId: string): TranscriptAnalysis {
|
|
let sessionId = fileSessionId
|
|
let model = ''
|
|
let version = ''
|
|
let gitBranch = ''
|
|
let cwd = ''
|
|
let startTime = ''
|
|
let endTime = ''
|
|
|
|
const messages: TranscriptMessage[] = []
|
|
const toolCalls: ToolCall[] = []
|
|
const filesModified = new Set<string>()
|
|
const subagents: SubagentInfo[] = []
|
|
const summaries: string[] = []
|
|
const turnTokens: TurnTokens[] = []
|
|
|
|
let totalInput = 0
|
|
let totalOutput = 0
|
|
let totalCacheRead = 0
|
|
let totalCacheCreation = 0
|
|
let thinkingBlocks = 0
|
|
let errors = 0
|
|
|
|
// Track assistant message chunks by message.id (streaming chunks share the same id)
|
|
const assistantChunks = new Map<string, {
|
|
uuid: string
|
|
timestamp: string
|
|
model: string
|
|
textParts: string[]
|
|
toolNames: string[]
|
|
hasThinking: boolean
|
|
usage: any
|
|
stopReason: string
|
|
pendingToolCalls: { name: string; input: any; id: string; timestamp: string }[]
|
|
}>()
|
|
|
|
// Track tool results by tool_use_id
|
|
const toolResults = new Map<string, { content: string; isError: boolean }>()
|
|
|
|
// ── First pass: collect all data ──
|
|
for (const { type, data } of lines) {
|
|
// Extract metadata from first message that has it
|
|
if (data.sessionId && !sessionId) sessionId = data.sessionId
|
|
if (data.version && !version) version = data.version
|
|
if (data.gitBranch && !gitBranch) gitBranch = data.gitBranch
|
|
if (data.cwd && !cwd) cwd = data.cwd
|
|
|
|
// Track time bounds
|
|
if (data.timestamp) {
|
|
if (!startTime || data.timestamp < startTime) startTime = data.timestamp
|
|
if (!endTime || data.timestamp > endTime) endTime = data.timestamp
|
|
}
|
|
|
|
switch (type) {
|
|
case 'user': {
|
|
const msg = data.message
|
|
if (!msg) break
|
|
|
|
// Collect tool results (user messages contain tool_result blocks)
|
|
if (Array.isArray(msg.content)) {
|
|
for (const block of msg.content) {
|
|
if (block.type === 'tool_result') {
|
|
const resultText = typeof block.content === 'string'
|
|
? block.content
|
|
: Array.isArray(block.content)
|
|
? block.content.map((c: any) => c.text || '').join('\n')
|
|
: ''
|
|
toolResults.set(block.tool_use_id, {
|
|
content: truncate(resultText, 300),
|
|
isError: !!block.is_error
|
|
})
|
|
if (block.is_error) errors++
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add as user message (skip meta, skip tool-result-only messages)
|
|
const isMeta = !!data.isMeta
|
|
const text = extractText(msg.content)
|
|
const hasToolResult = Array.isArray(msg.content) &&
|
|
msg.content.some((c: any) => c.type === 'tool_result')
|
|
|
|
if (text && !hasToolResult) {
|
|
messages.push({
|
|
uuid: data.uuid || '',
|
|
role: 'user',
|
|
content: text,
|
|
timestamp: data.timestamp || '',
|
|
isMeta,
|
|
hasThinking: false
|
|
})
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'assistant': {
|
|
const msg = data.message
|
|
if (!msg || msg.role !== 'assistant') break
|
|
|
|
const msgId = msg.id || data.uuid
|
|
if (!model && msg.model) model = msg.model
|
|
|
|
let chunk = assistantChunks.get(msgId)
|
|
if (!chunk) {
|
|
chunk = {
|
|
uuid: data.uuid || '',
|
|
timestamp: data.timestamp || '',
|
|
model: msg.model || '',
|
|
textParts: [],
|
|
toolNames: [],
|
|
hasThinking: false,
|
|
usage: null,
|
|
stopReason: '',
|
|
pendingToolCalls: []
|
|
}
|
|
assistantChunks.set(msgId, chunk)
|
|
}
|
|
|
|
// Take latest usage (streaming chunks repeat usage, last is most accurate)
|
|
if (msg.usage) chunk.usage = msg.usage
|
|
if (msg.stop_reason) chunk.stopReason = msg.stop_reason
|
|
|
|
// Process content blocks (each JSONL line typically has one block)
|
|
if (Array.isArray(msg.content)) {
|
|
for (const block of msg.content) {
|
|
if (block.type === 'text' && block.text?.trim()) {
|
|
chunk.textParts.push(block.text)
|
|
} else if (block.type === 'thinking') {
|
|
chunk.hasThinking = true
|
|
thinkingBlocks++
|
|
} else if (block.type === 'tool_use') {
|
|
chunk.toolNames.push(block.name)
|
|
chunk.pendingToolCalls.push({
|
|
name: block.name,
|
|
input: block.input,
|
|
id: block.id,
|
|
timestamp: data.timestamp || ''
|
|
})
|
|
}
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'progress': {
|
|
if (data.data?.type === 'agent_progress' && data.data.agentId) {
|
|
const existing = subagents.find(s => s.agentId === data.data.agentId)
|
|
if (!existing) {
|
|
subagents.push({
|
|
agentId: data.data.agentId,
|
|
prompt: truncate(data.data.prompt || '', 200),
|
|
timestamp: data.timestamp || ''
|
|
})
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'file-history-snapshot': {
|
|
const backups = data.snapshot?.trackedFileBackups
|
|
if (backups && typeof backups === 'object') {
|
|
for (const filePath of Object.keys(backups)) {
|
|
filesModified.add(filePath)
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'summary': {
|
|
const summaryText = data.summary || data.message?.content
|
|
if (summaryText) {
|
|
summaries.push(truncate(
|
|
typeof summaryText === 'string' ? summaryText : JSON.stringify(summaryText),
|
|
1000
|
|
))
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Second pass: assemble assistant messages and finalize tool calls ──
|
|
let turnIndex = 0
|
|
let lastStopReason = ''
|
|
for (const [, chunk] of assistantChunks) {
|
|
if (chunk.stopReason) lastStopReason = chunk.stopReason
|
|
const text = chunk.textParts.join('\n').trim()
|
|
|
|
if (text || chunk.toolNames.length > 0) {
|
|
const msgTokens = chunk.usage
|
|
? { input: chunk.usage.input_tokens || 0, output: chunk.usage.output_tokens || 0 }
|
|
: undefined
|
|
|
|
messages.push({
|
|
uuid: chunk.uuid,
|
|
role: 'assistant',
|
|
content: text || `[Tool calls: ${chunk.toolNames.join(', ')}]`,
|
|
timestamp: chunk.timestamp,
|
|
isMeta: false,
|
|
tokens: msgTokens,
|
|
toolCalls: chunk.toolNames.length > 0 ? chunk.toolNames : undefined,
|
|
hasThinking: chunk.hasThinking
|
|
})
|
|
}
|
|
|
|
// Finalize tool calls with results
|
|
for (const tc of chunk.pendingToolCalls) {
|
|
const result = toolResults.get(tc.id)
|
|
const inputStr = typeof tc.input === 'string' ? tc.input : JSON.stringify(tc.input)
|
|
toolCalls.push({
|
|
name: tc.name,
|
|
input: truncate(inputStr, 500),
|
|
output: result?.content,
|
|
timestamp: tc.timestamp,
|
|
isError: result?.isError || false
|
|
})
|
|
}
|
|
|
|
// Token tracking per turn
|
|
if (chunk.usage) {
|
|
const u = chunk.usage
|
|
const input = u.input_tokens || 0
|
|
const output = u.output_tokens || 0
|
|
const cacheRead = u.cache_read_input_tokens || 0
|
|
const cacheCreation = u.cache_creation_input_tokens || 0
|
|
|
|
totalInput += input
|
|
totalOutput += output
|
|
totalCacheRead += cacheRead
|
|
totalCacheCreation += cacheCreation
|
|
|
|
turnTokens.push({
|
|
turnIndex: turnIndex++,
|
|
input,
|
|
output,
|
|
cacheRead,
|
|
cacheCreation,
|
|
model: chunk.model
|
|
})
|
|
}
|
|
}
|
|
|
|
// Sort messages chronologically
|
|
messages.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
|
|
|
|
// Build tool summary
|
|
const toolSummary: Record<string, number> = {}
|
|
for (const tc of toolCalls) {
|
|
toolSummary[tc.name] = (toolSummary[tc.name] || 0) + 1
|
|
}
|
|
|
|
// Extract files from Edit/Write tool calls
|
|
for (const tc of toolCalls) {
|
|
if (['Edit', 'Write', 'NotebookEdit'].includes(tc.name) && tc.input) {
|
|
try {
|
|
const input = typeof tc.input === 'string' ? JSON.parse(tc.input) : tc.input
|
|
if (input.file_path) filesModified.add(input.file_path)
|
|
if (input.notebook_path) filesModified.add(input.notebook_path)
|
|
} catch { /* skip */ }
|
|
}
|
|
}
|
|
|
|
const duration = startTime && endTime
|
|
? new Date(endTime).getTime() - new Date(startTime).getTime()
|
|
: 0
|
|
|
|
const userMsgCount = messages.filter(m => m.role === 'user').length
|
|
const assistantMsgCount = messages.filter(m => m.role === 'assistant').length
|
|
|
|
return {
|
|
sessionId,
|
|
model,
|
|
version,
|
|
gitBranch,
|
|
cwd,
|
|
startTime,
|
|
endTime,
|
|
duration,
|
|
messages,
|
|
tokens: {
|
|
totalInput,
|
|
totalOutput,
|
|
totalCacheRead,
|
|
totalCacheCreation,
|
|
byTurn: turnTokens
|
|
},
|
|
tools: {
|
|
summary: toolSummary,
|
|
calls: toolCalls
|
|
},
|
|
filesModified: [...filesModified],
|
|
subagents,
|
|
summaries,
|
|
stats: {
|
|
messageCount: messages.length,
|
|
userMessageCount: userMsgCount,
|
|
assistantMessageCount: assistantMsgCount,
|
|
toolCallCount: toolCalls.length,
|
|
thinkingBlocks,
|
|
errors
|
|
},
|
|
lastStopReason
|
|
}
|
|
}
|
|
|
|
// ── Exported API ──
|
|
|
|
export function getTranscriptAnalysis(sessionId?: string, transcriptPath?: string): TranscriptAnalysis | null {
|
|
const filePath = transcriptPath && existsSync(transcriptPath)
|
|
? transcriptPath
|
|
: resolveTranscriptPath(sessionId)
|
|
if (!filePath) return null
|
|
|
|
const sid = sessionIdFromPath(filePath)
|
|
|
|
try {
|
|
const stat = statSync(filePath)
|
|
const mtime = stat.mtimeMs
|
|
|
|
// Return cached if file hasn't changed
|
|
const cached = cache.get(sid)
|
|
if (cached && cached.lastModified === mtime) {
|
|
return cached.analysis
|
|
}
|
|
|
|
// Full parse
|
|
const lines = parseTranscriptFile(filePath)
|
|
const analysis = buildAnalysis(lines, sid)
|
|
|
|
cache.set(sid, { analysis, lastModified: mtime })
|
|
return analysis
|
|
} catch (e) {
|
|
console.error('[transcript-engine] Error parsing transcript:', e)
|
|
return null
|
|
}
|
|
}
|
|
|
|
// ── Path normalization (Windows compat) ──
|
|
|
|
function normalizeTranscriptPath(tp: string): string {
|
|
tp = tp.replace(/\\/g, '/')
|
|
if (/^\/[a-zA-Z]\//.test(tp)) {
|
|
tp = tp[1].toUpperCase() + ':' + tp.slice(2)
|
|
}
|
|
return tp
|
|
}
|
|
|
|
// ── Active session API (for real-time chat) ──
|
|
|
|
export function setActiveSession(agent: string, sessionId: string, transcriptPath: string): void {
|
|
const normalizedPath = normalizeTranscriptPath(transcriptPath)
|
|
activeAgentSessions.set(agent, { sessionId, transcriptPath: normalizedPath })
|
|
|
|
// Initialize offset to current file size (don't replay old messages as "new")
|
|
if (!readOffsets.has(sessionId)) {
|
|
try {
|
|
const stat = statSync(normalizedPath)
|
|
readOffsets.set(sessionId, stat.size)
|
|
} catch {
|
|
readOffsets.set(sessionId, 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
export function getActiveSession(agent: string): { sessionId: string; transcriptPath: string } | null {
|
|
return activeAgentSessions.get(agent) || null
|
|
}
|
|
|
|
export function resetSessionOffset(sessionId: string): void {
|
|
readOffsets.set(sessionId, 0)
|
|
}
|
|
|
|
export function getIncrementalMessages(sessionId: string, transcriptPath?: string): TranscriptMessage[] {
|
|
// Resolve path
|
|
let filePath = transcriptPath ? normalizeTranscriptPath(transcriptPath) : null
|
|
if (!filePath) {
|
|
const session = [...activeAgentSessions.values()].find(s => s.sessionId === sessionId)
|
|
filePath = session?.transcriptPath || null
|
|
}
|
|
if (!filePath) {
|
|
filePath = resolveTranscriptPath(sessionId)
|
|
}
|
|
if (!filePath || !existsSync(filePath)) return []
|
|
|
|
try {
|
|
const stat = statSync(filePath)
|
|
const fileSize = stat.size
|
|
const offset = readOffsets.get(sessionId) || 0
|
|
|
|
if (offset >= fileSize) return []
|
|
|
|
// Read only new bytes
|
|
const buffer = readFileSync(filePath)
|
|
const newContent = buffer.slice(offset, fileSize).toString('utf8')
|
|
|
|
// Update offset
|
|
readOffsets.set(sessionId, fileSize)
|
|
|
|
// Parse new lines
|
|
const rawLines = newContent.split('\n').filter(l => l.trim())
|
|
const messages: TranscriptMessage[] = []
|
|
|
|
// Track assistant chunks by message.id for consolidation
|
|
const assistantChunks = new Map<string, {
|
|
uuid: string
|
|
timestamp: string
|
|
textParts: string[]
|
|
toolNames: string[]
|
|
hasThinking: boolean
|
|
}>()
|
|
|
|
for (const line of rawLines) {
|
|
try {
|
|
const obj = JSON.parse(line)
|
|
|
|
if (obj.type === 'user') {
|
|
const msg = obj.message
|
|
if (!msg) continue
|
|
|
|
const isMeta = !!obj.isMeta
|
|
const text = extractText(msg.content)
|
|
const hasToolResult = Array.isArray(msg.content) &&
|
|
msg.content.some((c: any) => c.type === 'tool_result')
|
|
|
|
if (text && !hasToolResult && !isMeta) {
|
|
messages.push({
|
|
uuid: obj.uuid || crypto.randomUUID(),
|
|
role: 'user',
|
|
content: text,
|
|
timestamp: obj.timestamp || new Date().toISOString(),
|
|
isMeta: false,
|
|
hasThinking: false
|
|
})
|
|
}
|
|
} else if (obj.type === 'assistant') {
|
|
const msg = obj.message
|
|
if (!msg || msg.role !== 'assistant') continue
|
|
|
|
const msgId = msg.id || obj.uuid || 'unknown'
|
|
let chunk = assistantChunks.get(msgId)
|
|
if (!chunk) {
|
|
chunk = {
|
|
uuid: obj.uuid || crypto.randomUUID(),
|
|
timestamp: obj.timestamp || new Date().toISOString(),
|
|
textParts: [],
|
|
toolNames: [],
|
|
hasThinking: false
|
|
}
|
|
assistantChunks.set(msgId, chunk)
|
|
}
|
|
|
|
if (Array.isArray(msg.content)) {
|
|
for (const block of msg.content) {
|
|
if (block.type === 'text' && block.text?.trim()) {
|
|
chunk.textParts.push(block.text)
|
|
} else if (block.type === 'thinking') {
|
|
chunk.hasThinking = true
|
|
} else if (block.type === 'tool_use') {
|
|
chunk.toolNames.push(block.name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Ignore progress, file-history-snapshot, summary
|
|
} catch {
|
|
// Skip unparseable lines
|
|
}
|
|
}
|
|
|
|
// Assemble assistant messages from consolidated chunks
|
|
for (const [, chunk] of assistantChunks) {
|
|
const text = chunk.textParts.join('\n').trim()
|
|
if (text || chunk.toolNames.length > 0) {
|
|
messages.push({
|
|
uuid: chunk.uuid,
|
|
role: 'assistant',
|
|
content: text || `[Tool calls: ${chunk.toolNames.join(', ')}]`,
|
|
timestamp: chunk.timestamp,
|
|
isMeta: false,
|
|
toolCalls: chunk.toolNames.length > 0 ? chunk.toolNames : undefined,
|
|
hasThinking: chunk.hasThinking
|
|
})
|
|
}
|
|
}
|
|
|
|
// Sort chronologically
|
|
messages.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
|
|
|
|
return messages
|
|
} catch (e) {
|
|
console.error('[transcript-engine] Incremental read error:', e)
|
|
return []
|
|
}
|
|
}
|
|
|
|
export function getClaudeStats(): ClaudeStats | null {
|
|
const statsPath = join(homedir(), '.claude', 'stats-cache.json')
|
|
if (!existsSync(statsPath)) return null
|
|
|
|
try {
|
|
const raw = JSON.parse(readFileSync(statsPath, 'utf8'))
|
|
const todayStr = new Date().toISOString().slice(0, 10)
|
|
|
|
// Find today's daily activity
|
|
const todayActivity = raw.dailyActivity?.find((d: any) => d.date === todayStr) || null
|
|
const todayTokens = raw.dailyModelTokens?.find((d: any) => d.date === todayStr) || null
|
|
|
|
const today = todayActivity ? {
|
|
date: todayStr,
|
|
messageCount: todayActivity.messageCount || 0,
|
|
sessionCount: todayActivity.sessionCount || 0,
|
|
toolCallCount: todayActivity.toolCallCount || 0,
|
|
tokensByModel: todayTokens?.tokensByModel || {}
|
|
} : null
|
|
|
|
// Model usage
|
|
const modelUsage: ClaudeStats['modelUsage'] = {}
|
|
if (raw.modelUsage) {
|
|
for (const [model, usage] of Object.entries(raw.modelUsage as Record<string, any>)) {
|
|
modelUsage[model] = {
|
|
inputTokens: usage.inputTokens || 0,
|
|
outputTokens: usage.outputTokens || 0,
|
|
cacheReadInputTokens: usage.cacheReadInputTokens || 0,
|
|
cacheCreationInputTokens: usage.cacheCreationInputTokens || 0,
|
|
costUSD: usage.costUSD || 0
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
today,
|
|
modelUsage,
|
|
totalSessions: raw.totalSessions || 0,
|
|
totalMessages: raw.totalMessages || 0,
|
|
firstSessionDate: raw.firstSessionDate || ''
|
|
}
|
|
} catch (e) {
|
|
console.error('[transcript-engine] Error reading stats-cache.json:', e)
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function getClaudeUsage(): ClaudeUsage | null {
|
|
const credentialsPath = join(homedir(), '.claude', '.credentials.json')
|
|
const statsPath = join(homedir(), '.claude', 'stats-cache.json')
|
|
|
|
if (!existsSync(credentialsPath)) return null
|
|
|
|
try {
|
|
// 1. Read credentials (never expose tokens)
|
|
const creds = JSON.parse(readFileSync(credentialsPath, 'utf8'))
|
|
const oauth = creds.claudeAiOauth || {}
|
|
const subType = oauth.subscriptionType || 'pro'
|
|
const rawTier = oauth.rateLimitTier || ''
|
|
const tier = parseTier(rawTier)
|
|
const limits = TIER_LIMITS[tier.key] || TIER_LIMITS.pro
|
|
|
|
// 2. Read stats-cache for daily + weekly data
|
|
let todayMessages = 0
|
|
let todayOutputTokens = 0
|
|
let todaySessions = 0
|
|
let weeklyMessages = 0
|
|
|
|
if (existsSync(statsPath)) {
|
|
const raw = JSON.parse(readFileSync(statsPath, 'utf8'))
|
|
const todayStr = new Date().toISOString().slice(0, 10)
|
|
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
|
|
|
|
for (const day of raw.dailyActivity || []) {
|
|
if (day.date === todayStr) {
|
|
todayMessages = day.messageCount || 0
|
|
todaySessions = day.sessionCount || 0
|
|
}
|
|
if (day.date >= sevenDaysAgo) {
|
|
weeklyMessages += day.messageCount || 0
|
|
}
|
|
}
|
|
|
|
// Output tokens for today
|
|
const todayTokens = (raw.dailyModelTokens || []).find((d: any) => d.date === todayStr)
|
|
if (todayTokens?.tokensByModel) {
|
|
todayOutputTokens = Object.values(todayTokens.tokensByModel as Record<string, number>)
|
|
.reduce((sum: number, v: number) => sum + v, 0)
|
|
}
|
|
}
|
|
|
|
// 3. Calculate percentages
|
|
const dailyPercent = limits.dailyEstimate > 0
|
|
? Math.round(todayMessages / limits.dailyEstimate * 100)
|
|
: 0
|
|
const weeklyPercent = limits.weeklyEstimate > 0
|
|
? Math.round(weeklyMessages / limits.weeklyEstimate * 100)
|
|
: 0
|
|
|
|
// 4. Determine status based on highest usage (daily or weekly)
|
|
const maxPercent = Math.max(dailyPercent, weeklyPercent)
|
|
let status: ClaudeUsage['status'] = 'normal'
|
|
if (maxPercent >= 100) status = 'extended'
|
|
else if (maxPercent >= 80) status = 'limit_approaching'
|
|
else if (maxPercent >= 50) status = 'elevated'
|
|
|
|
return {
|
|
subscription: { type: subType, tier: tier.key, label: tier.label, multiplier: tier.multiplier },
|
|
today: { messages: todayMessages, outputTokens: todayOutputTokens, sessions: todaySessions },
|
|
daily: { used: todayMessages, limit: limits.dailyEstimate, percent: dailyPercent },
|
|
weekly: { used: weeklyMessages, limit: limits.weeklyEstimate, percent: weeklyPercent },
|
|
status
|
|
}
|
|
} catch (e) {
|
|
console.error('[transcript-engine] Error reading claude usage:', e)
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function listSessions(): SessionInfo[] {
|
|
const projectDir = getProjectDir()
|
|
if (!existsSync(projectDir)) return []
|
|
|
|
try {
|
|
const files = readdirSync(projectDir)
|
|
.filter(f => f.endsWith('.jsonl'))
|
|
.map(f => {
|
|
const fullPath = join(projectDir, f)
|
|
const stat = statSync(fullPath)
|
|
return { name: f, path: fullPath, mtime: stat.mtimeMs }
|
|
})
|
|
.sort((a, b) => b.mtime - a.mtime)
|
|
|
|
return files.map(f => {
|
|
const sid = f.name.replace('.jsonl', '')
|
|
|
|
// Try cache first for quick metadata
|
|
const cached = cache.get(sid)
|
|
if (cached && cached.lastModified === f.mtime) {
|
|
return {
|
|
id: sid,
|
|
startTime: cached.analysis.startTime,
|
|
messageCount: cached.analysis.stats.messageCount,
|
|
model: cached.analysis.model
|
|
}
|
|
}
|
|
|
|
// Quick scan: read first few lines for metadata without full parse
|
|
try {
|
|
const content = readFileSync(f.path, 'utf8')
|
|
const firstLines = content.split('\n').slice(0, 20)
|
|
let startTime = ''
|
|
let model = ''
|
|
let lineCount = content.split('\n').filter(l => l.trim()).length
|
|
|
|
for (const line of firstLines) {
|
|
if (!line.trim()) continue
|
|
try {
|
|
const obj = JSON.parse(line)
|
|
if (obj.timestamp && !startTime) startTime = obj.timestamp
|
|
if (obj.type === 'assistant' && obj.message?.model && !model) {
|
|
model = obj.message.model
|
|
}
|
|
} catch { /* skip */ }
|
|
}
|
|
|
|
return {
|
|
id: sid,
|
|
startTime,
|
|
messageCount: lineCount,
|
|
model
|
|
}
|
|
} catch {
|
|
return { id: sid, startTime: '', messageCount: 0, model: '' }
|
|
}
|
|
})
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|