this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add session reader for JSONL parsing

- Stream-based JSONL parsing for memory efficiency
- Extract messages, tool uses, and token stats
- Create condensed transcripts for LLM summarization
- Deduplication via message ID hashing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

alice 5fbc550a 4ef7ca29

+277
+277
src/core/session-reader.ts
··· 1 + import { createReadStream } from 'fs'; 2 + import * as readline from 'readline'; 3 + import type { 4 + RawSessionEntry, 5 + ParsedSession, 6 + ParsedMessage, 7 + ToolUse, 8 + SessionStats, 9 + MessageContent, 10 + } from '../types'; 11 + 12 + /** 13 + * Stream-parse a JSONL session file 14 + */ 15 + export async function* parseJSONLStream( 16 + filePath: string 17 + ): AsyncGenerator<RawSessionEntry> { 18 + const rl = readline.createInterface({ 19 + input: createReadStream(filePath), 20 + crlfDelay: Infinity, 21 + }); 22 + 23 + for await (const line of rl) { 24 + if (!line.trim()) continue; 25 + try { 26 + yield JSON.parse(line) as RawSessionEntry; 27 + } catch { 28 + // Skip invalid JSON lines 29 + } 30 + } 31 + } 32 + 33 + /** 34 + * Parse a session file into a structured format 35 + */ 36 + export async function parseSessionFile( 37 + filePath: string, 38 + projectPath: string, 39 + projectName: string 40 + ): Promise<ParsedSession> { 41 + const messages: ParsedMessage[] = []; 42 + const toolCalls: Record<string, number> = {}; 43 + let sessionId = ''; 44 + let gitBranch = ''; 45 + let startTime = ''; 46 + let endTime = ''; 47 + let totalInputTokens = 0; 48 + let totalOutputTokens = 0; 49 + let userMessages = 0; 50 + let assistantMessages = 0; 51 + 52 + const seen = new Set<string>(); 53 + 54 + for await (const entry of parseJSONLStream(filePath)) { 55 + // Deduplication 56 + const hash = `${entry.message?.id || entry.uuid}:${entry.requestId || ''}`; 57 + if (seen.has(hash)) continue; 58 + seen.add(hash); 59 + 60 + // Extract metadata from first entry 61 + if (!sessionId && entry.sessionId) { 62 + sessionId = entry.sessionId; 63 + } 64 + if (!gitBranch && entry.gitBranch) { 65 + gitBranch = entry.gitBranch; 66 + } 67 + 68 + // Track timestamps 69 + if (!startTime || entry.timestamp < startTime) { 70 + startTime = entry.timestamp; 71 + } 72 + if (!endTime || entry.timestamp > endTime) { 73 + endTime = entry.timestamp; 74 + } 75 + 76 + // Extract token usage from assistant messages 77 + if (entry.type === 'assistant' && entry.message?.usage) { 78 + const usage = entry.message.usage; 79 + totalInputTokens += usage.input_tokens || 0; 80 + totalOutputTokens += usage.output_tokens || 0; 81 + totalInputTokens += usage.cache_creation_input_tokens || 0; 82 + totalInputTokens += usage.cache_read_input_tokens || 0; 83 + } 84 + 85 + // Parse message content 86 + const text = extractText(entry.message?.content); 87 + const toolUses = extractToolUses(entry.message?.content); 88 + 89 + // Count tool calls 90 + for (const tool of toolUses) { 91 + toolCalls[tool.name] = (toolCalls[tool.name] || 0) + 1; 92 + } 93 + 94 + if (entry.type === 'user') userMessages++; 95 + if (entry.type === 'assistant') assistantMessages++; 96 + 97 + messages.push({ 98 + type: entry.type, 99 + timestamp: entry.timestamp, 100 + text, 101 + toolUses, 102 + }); 103 + } 104 + 105 + // Use filename as sessionId fallback 106 + if (!sessionId) { 107 + sessionId = filePath.split('/').pop()?.replace('.jsonl', '') || 'unknown'; 108 + } 109 + 110 + // Provide default timestamps if none found 111 + const now = new Date().toISOString(); 112 + if (!startTime) { 113 + startTime = now; 114 + } 115 + if (!endTime) { 116 + endTime = startTime; 117 + } 118 + 119 + // Derive date from startTime 120 + const date = startTime.split('T')[0]; 121 + 122 + const stats: SessionStats = { 123 + userMessages, 124 + assistantMessages, 125 + toolCalls, 126 + totalInputTokens, 127 + totalOutputTokens, 128 + }; 129 + 130 + return { 131 + sessionId, 132 + filePath, 133 + projectPath, 134 + projectName, 135 + gitBranch, 136 + startTime, 137 + endTime, 138 + date, 139 + messages, 140 + stats, 141 + }; 142 + } 143 + 144 + /** 145 + * Extract text from message content array 146 + */ 147 + function extractText(content: MessageContent[] | undefined): string { 148 + if (!content || !Array.isArray(content)) return ''; 149 + 150 + const texts: string[] = []; 151 + for (const item of content) { 152 + if (item.type === 'text') { 153 + // Handle both formats: { text: "..." } and { content: "..." } 154 + const text = 'text' in item ? item.text : 'content' in item ? item.content : ''; 155 + if (text) texts.push(text); 156 + } 157 + } 158 + return texts.join('\n'); 159 + } 160 + 161 + /** 162 + * Extract tool uses from message content 163 + */ 164 + function extractToolUses(content: MessageContent[] | undefined): ToolUse[] { 165 + if (!content || !Array.isArray(content)) return []; 166 + 167 + const tools: ToolUse[] = []; 168 + for (const item of content) { 169 + if (item.type === 'tool_use') { 170 + tools.push({ 171 + name: item.name, 172 + input: summarizeToolInput(item.name, item.input), 173 + }); 174 + } 175 + } 176 + return tools; 177 + } 178 + 179 + /** 180 + * Summarize tool input for display (truncate long content) 181 + */ 182 + function summarizeToolInput( 183 + toolName: string, 184 + input: Record<string, unknown> 185 + ): string { 186 + const MAX_LENGTH = 200; 187 + 188 + switch (toolName) { 189 + case 'Bash': 190 + return truncate(String(input.command || ''), MAX_LENGTH); 191 + case 'Read': 192 + return truncate(String(input.file_path || ''), MAX_LENGTH); 193 + case 'Write': 194 + case 'Edit': 195 + return truncate(String(input.file_path || ''), MAX_LENGTH); 196 + case 'Glob': 197 + return truncate(String(input.pattern || ''), MAX_LENGTH); 198 + case 'Grep': 199 + return truncate(String(input.pattern || ''), MAX_LENGTH); 200 + case 'Task': 201 + return truncate(String(input.description || ''), MAX_LENGTH); 202 + default: 203 + return truncate(JSON.stringify(input), MAX_LENGTH); 204 + } 205 + } 206 + 207 + function truncate(str: string, maxLength: number): string { 208 + if (str.length <= maxLength) return str; 209 + return str.slice(0, maxLength - 3) + '...'; 210 + } 211 + 212 + /** 213 + * Create a condensed transcript for LLM summarization 214 + * Focuses on user requests and key actions 215 + */ 216 + export function createCondensedTranscript(session: ParsedSession): string { 217 + const parts: string[] = []; 218 + 219 + parts.push(`Project: ${session.projectName}`); 220 + if (session.gitBranch) { 221 + parts.push(`Branch: ${session.gitBranch}`); 222 + } 223 + parts.push(`Duration: ${formatDuration(session.startTime, session.endTime)}`); 224 + parts.push(''); 225 + 226 + // Extract user requests, assistant responses, and tool actions 227 + for (const msg of session.messages) { 228 + if (msg.type === 'user' && msg.text) { 229 + // Include user prompts (truncated) 230 + const text = msg.text.slice(0, 500); 231 + parts.push(`User: ${text}`); 232 + } else if (msg.type === 'assistant') { 233 + // Include assistant text responses 234 + if (msg.text) { 235 + const text = msg.text.slice(0, 400); 236 + parts.push(`Assistant: ${text}`); 237 + } 238 + // Also include tool usage 239 + if (msg.toolUses.length > 0) { 240 + const toolSummary = msg.toolUses 241 + .map((t) => `${t.name}: ${t.input}`) 242 + .join(', '); 243 + parts.push(`Tools: ${toolSummary.slice(0, 300)}`); 244 + } 245 + } 246 + } 247 + 248 + // Add stats 249 + parts.push(''); 250 + parts.push(`Stats: ${session.stats.userMessages} user messages, ${session.stats.assistantMessages} assistant messages`); 251 + 252 + const toolSummary = Object.entries(session.stats.toolCalls) 253 + .sort((a, b) => b[1] - a[1]) 254 + .slice(0, 10) 255 + .map(([name, count]) => `${name}(${count})`) 256 + .join(', '); 257 + if (toolSummary) { 258 + parts.push(`Tool usage: ${toolSummary}`); 259 + } 260 + 261 + return parts.join('\n'); 262 + } 263 + 264 + function formatDuration(start: string, end: string): string { 265 + if (!start || !end) return 'unknown'; 266 + 267 + const startDate = new Date(start); 268 + const endDate = new Date(end); 269 + const diffMs = endDate.getTime() - startDate.getTime(); 270 + 271 + const minutes = Math.floor(diffMs / 60000); 272 + if (minutes < 60) return `${minutes} min`; 273 + 274 + const hours = Math.floor(minutes / 60); 275 + const remainingMinutes = minutes % 60; 276 + return `${hours}h ${remainingMinutes}m`; 277 + }