this repo has no description
at main 554 lines 16 kB view raw
1import { createReadStream } from 'fs'; 2import * as readline from 'readline'; 3 4import type { MessageContent, ParsedMessage, ParsedSession, RawSessionEntry, SessionStats, ToolUse } from '../types'; 5 6/** 7 * Get the "effective date" for a timestamp using a 3am boundary. 8 * Work done before 3am counts as the previous day (aligns with sleep cycle). 9 */ 10function getEffectiveDate(timestamp: string): string { 11 const d = new Date(timestamp); 12 d.setHours(d.getHours() - 3); // Shift 3am boundary to midnight 13 return d.toISOString().split('T')[0]; 14} 15 16/** 17 * Stream-parse a JSONL session file 18 */ 19export async function* parseJSONLStream(filePath: string): AsyncGenerator<RawSessionEntry> { 20 const rl = readline.createInterface({ 21 input: createReadStream(filePath), 22 crlfDelay: Infinity, 23 }); 24 25 for await (const line of rl) { 26 if (line.trim() === '') continue; 27 try { 28 yield JSON.parse(line) as RawSessionEntry; 29 } catch { 30 // Skip invalid JSON lines 31 } 32 } 33} 34 35/** 36 * Parse a session file into a structured format 37 */ 38export async function parseSessionFile( 39 filePath: string, 40 projectPath: string, 41 projectName: string, 42): Promise<ParsedSession> { 43 const messages: ParsedMessage[] = []; 44 const toolCalls: Record<string, number> = {}; 45 let sessionId = ''; 46 let gitBranch = ''; 47 let startTime = ''; 48 let endTime = ''; 49 let totalInputTokens = 0; 50 let totalOutputTokens = 0; 51 let userMessages = 0; 52 let assistantMessages = 0; 53 54 const seen = new Set<string>(); 55 56 for await (const entry of parseJSONLStream(filePath)) { 57 // Deduplication - use uuid (unique per chunk) not message.id (same across streaming chunks) 58 if (seen.has(entry.uuid)) continue; 59 seen.add(entry.uuid); 60 61 // Extract metadata from first entry 62 if (sessionId === '' && typeof entry.sessionId === 'string' && entry.sessionId !== '') { 63 sessionId = entry.sessionId; 64 } 65 if (gitBranch === '' && entry.gitBranch !== undefined) { 66 gitBranch = entry.gitBranch; 67 } 68 69 // Track timestamps (only if valid) 70 if (entry.timestamp && typeof entry.timestamp === 'string') { 71 if (startTime === '' || entry.timestamp < startTime) { 72 startTime = entry.timestamp; 73 } 74 if (endTime === '' || entry.timestamp > endTime) { 75 endTime = entry.timestamp; 76 } 77 } 78 79 // Skip entries without a valid message (malformed JSONL entries) 80 if (!entry.message) { 81 continue; 82 } 83 84 // Extract token usage from assistant messages 85 if (entry.type === 'assistant' && entry.message.usage !== undefined) { 86 const usage = entry.message.usage; 87 totalInputTokens += usage.input_tokens; 88 totalOutputTokens += usage.output_tokens; 89 totalInputTokens += usage.cache_creation_input_tokens ?? 0; 90 totalInputTokens += usage.cache_read_input_tokens ?? 0; 91 } 92 93 // Parse message content 94 const text = extractText(entry.message.content); 95 const toolUses = extractToolUses(entry.message.content); 96 97 // Count tool calls 98 for (const tool of toolUses) { 99 toolCalls[tool.name] = (toolCalls[tool.name] ?? 0) + 1; 100 } 101 102 if (entry.type === 'user') userMessages++; 103 if (entry.type === 'assistant') assistantMessages++; 104 105 messages.push({ 106 type: entry.type, 107 timestamp: entry.timestamp, 108 text, 109 toolUses, 110 }); 111 } 112 113 // Use filename as sessionId fallback 114 if (sessionId === '') { 115 sessionId = filePath.split('/').pop()?.replace('.jsonl', '') ?? 'unknown'; 116 } 117 118 // Provide default timestamps if none found 119 const now = new Date().toISOString(); 120 if (startTime === '') { 121 startTime = now; 122 } 123 if (endTime === '') { 124 endTime = startTime; 125 } 126 127 // Derive date from endTime with 3am boundary (aligns with sleep cycle) 128 // Work done before 3am counts as the previous day 129 const date = getEffectiveDate(endTime); 130 131 const stats: SessionStats = { 132 userMessages, 133 assistantMessages, 134 toolCalls, 135 totalInputTokens, 136 totalOutputTokens, 137 }; 138 139 return { 140 sessionId, 141 filePath, 142 projectPath, 143 projectName, 144 gitBranch, 145 startTime, 146 endTime, 147 date, 148 messages, 149 stats, 150 }; 151} 152 153/** 154 * Extract text from message content array 155 */ 156function extractText(content: MessageContent[]): string { 157 if (!Array.isArray(content)) return ''; 158 159 const texts: string[] = []; 160 for (const item of content) { 161 if (item.type === 'text') { 162 // Handle both formats: { text: "..." } and { content: "..." } 163 const text = 'text' in item ? item.text : 'content' in item ? item.content : ''; 164 if (text !== '') texts.push(text); 165 } 166 } 167 return texts.join('\n'); 168} 169 170/** 171 * Extract tool uses from message content 172 */ 173function extractToolUses(content: MessageContent[]): ToolUse[] { 174 if (!Array.isArray(content)) return []; 175 176 const tools: ToolUse[] = []; 177 for (const item of content) { 178 if (item.type === 'tool_use') { 179 tools.push({ 180 name: item.name, 181 input: summarizeToolInput(item.name, item.input), 182 rawInput: item.input, 183 }); 184 } 185 } 186 return tools; 187} 188 189/** 190 * Summarize tool input for display (truncate long content) 191 */ 192function summarizeToolInput(toolName: string, input: Record<string, unknown>): string { 193 const MAX_LENGTH = 200; 194 195 switch (toolName) { 196 case 'Bash': 197 return truncate(typeof input.command === 'string' ? input.command : '', MAX_LENGTH); 198 case 'Read': 199 return truncate(typeof input.file_path === 'string' ? input.file_path : '', MAX_LENGTH); 200 case 'Write': 201 case 'Edit': 202 return truncate(typeof input.file_path === 'string' ? input.file_path : '', MAX_LENGTH); 203 case 'Glob': 204 return truncate(typeof input.pattern === 'string' ? input.pattern : '', MAX_LENGTH); 205 case 'Grep': 206 return truncate(typeof input.pattern === 'string' ? input.pattern : '', MAX_LENGTH); 207 case 'Task': 208 return truncate(typeof input.description === 'string' ? input.description : '', MAX_LENGTH); 209 default: 210 return truncate(JSON.stringify(input), MAX_LENGTH); 211 } 212} 213 214function truncate(str: string, maxLength: number): string { 215 if (str.length <= maxLength) return str; 216 return str.slice(0, maxLength - 3) + '...'; 217} 218 219/** 220 * Work type classification based on files changed 221 */ 222export type WorkType = 'feature' | 'infrastructure' | 'tests' | 'docs' | 'mixed'; 223 224export interface WorkScope { 225 frontend: number; 226 backend: number; 227 tests: number; 228 types: number; 229 config: number; 230 docs: number; 231} 232 233export interface WorkClassification { 234 type: WorkType; 235 signals: string[]; // Human-readable explanation of why 236 scope: WorkScope; 237 scopeSummary: string; // e.g., "frontend, backend" or "tests" 238} 239 240/** 241 * Check if a file path looks like frontend code 242 */ 243function isFrontend(file: string): boolean { 244 const lower = file.toLowerCase(); 245 return ( 246 lower.includes('/components/') || 247 lower.includes('/pages/') || 248 lower.includes('/screens/') || 249 lower.includes('/views/') || 250 lower.includes('/ui/') || 251 lower.includes('/app/') || 252 lower.includes('/apps/web/') || 253 lower.includes('/web/') || 254 lower.includes('/frontend/') || 255 lower.includes('/client/') || 256 lower.endsWith('.tsx') || 257 lower.endsWith('.jsx') || 258 lower.endsWith('.css') || 259 lower.endsWith('.scss') 260 ); 261} 262 263/** 264 * Check if a file path looks like backend code 265 */ 266function isBackend(file: string): boolean { 267 const lower = file.toLowerCase(); 268 return ( 269 lower.includes('/api/') || 270 lower.includes('/server/') || 271 lower.includes('/services/') || 272 lower.includes('/lib/') || 273 lower.includes('/core/') || 274 lower.includes('/packages/') || 275 lower.includes('/backend/') || 276 lower.includes('/handlers/') || 277 lower.includes('/routes/') || 278 lower.includes('/controllers/') || 279 lower.includes('/models/') || 280 lower.includes('/utils/') || 281 (lower.endsWith('.ts') && 282 !lower.endsWith('.test.ts') && 283 !lower.endsWith('.spec.ts') && 284 !lower.endsWith('.d.ts') && 285 !isFrontend(file)) 286 ); 287} 288 289/** 290 * Classify the type of work based on file paths 291 */ 292export function classifyWork(files: string[]): WorkClassification { 293 const signals: string[] = []; 294 const scope: WorkScope = { 295 frontend: 0, 296 backend: 0, 297 tests: 0, 298 types: 0, 299 config: 0, 300 docs: 0, 301 }; 302 303 let featureFiles = 0; 304 305 for (const file of files) { 306 const lower = file.toLowerCase(); 307 const filename = file.split('/').pop() ?? ''; 308 309 // Tests 310 if ( 311 lower.includes('.test.') || 312 lower.includes('.spec.') || 313 lower.includes('__tests__') || 314 lower.includes('/test/') || 315 lower.includes('/tests/') 316 ) { 317 scope.tests++; 318 continue; 319 } 320 321 // Types/interfaces 322 if ( 323 filename === 'types.ts' || 324 filename === 'interfaces.ts' || 325 lower.endsWith('.d.ts') || 326 lower.includes('/types/') || 327 lower.includes('/interfaces/') 328 ) { 329 scope.types++; 330 continue; 331 } 332 333 // Config/devops 334 if ( 335 lower.includes('.config.') || 336 lower.includes('/config/') || 337 lower.includes('.github/') || 338 lower.includes('dockerfile') || 339 lower.includes('.yml') || 340 lower.includes('.yaml') || 341 filename.startsWith('.') || 342 filename === 'package.json' || 343 filename === 'tsconfig.json' 344 ) { 345 scope.config++; 346 continue; 347 } 348 349 // Docs 350 if (lower.endsWith('.md') || lower.includes('/docs/') || lower.includes('/documentation/')) { 351 scope.docs++; 352 continue; 353 } 354 355 // Feature work - classify as frontend or backend 356 featureFiles++; 357 if (isFrontend(file)) { 358 scope.frontend++; 359 } else if (isBackend(file)) { 360 scope.backend++; 361 } else { 362 // Default to backend for unclassified .ts files 363 scope.backend++; 364 } 365 } 366 367 const total = files.length; 368 if (total === 0) { 369 return { 370 type: 'mixed', 371 signals: ['no files changed'], 372 scope, 373 scopeSummary: '', 374 }; 375 } 376 377 // Build scope summary - simplified to frontend/backend/both 378 let scopeSummary = ''; 379 const hasFrontend = scope.frontend > 0; 380 const hasBackend = scope.backend > 0; 381 if (hasFrontend && hasBackend) { 382 scopeSummary = 'frontend, backend'; 383 } else if (hasFrontend) { 384 scopeSummary = 'frontend'; 385 } else if (hasBackend) { 386 scopeSummary = 'backend'; 387 } else if (scope.tests > 0) { 388 scopeSummary = 'tests'; 389 } else if (scope.docs > 0) { 390 scopeSummary = 'docs'; 391 } else if (scope.config > 0) { 392 scopeSummary = 'config'; 393 } 394 395 // Determine primary type (>50% of files) 396 const threshold = total * 0.5; 397 398 if (scope.tests > threshold) { 399 signals.push(`${scope.tests.toString()}/${total.toString()} files are tests`); 400 return { type: 'tests', signals, scope, scopeSummary }; 401 } 402 403 if (scope.docs > threshold) { 404 signals.push(`${scope.docs.toString()}/${total.toString()} files are documentation`); 405 return { type: 'docs', signals, scope, scopeSummary }; 406 } 407 408 if (scope.types + scope.config > threshold) { 409 if (scope.types > scope.config) { 410 signals.push(`${scope.types.toString()}/${total.toString()} files are types`); 411 } else { 412 signals.push(`${scope.config.toString()}/${total.toString()} files are config`); 413 } 414 return { type: 'infrastructure', signals, scope, scopeSummary }; 415 } 416 417 if (featureFiles > threshold) { 418 signals.push(`${featureFiles.toString()}/${total.toString()} files are feature code`); 419 return { type: 'feature', signals, scope, scopeSummary }; 420 } 421 422 // Mixed - build a description 423 if (featureFiles > 0) signals.push(`${featureFiles.toString()} feature`); 424 if (scope.tests > 0) signals.push(`${scope.tests.toString()} test`); 425 if (scope.types > 0) signals.push(`${scope.types.toString()} type`); 426 if (scope.config > 0) signals.push(`${scope.config.toString()} config`); 427 if (scope.docs > 0) signals.push(`${scope.docs.toString()} doc`); 428 429 return { type: 'mixed', signals, scope, scopeSummary }; 430} 431 432/** 433 * Create a condensed transcript for LLM summarization 434 * Leads with action summary (files changed) to ensure implementation work is captured 435 */ 436export function createCondensedTranscript(session: ParsedSession): string { 437 const parts: string[] = []; 438 439 parts.push(`Project: ${session.projectName}`); 440 if (session.gitBranch !== '') { 441 parts.push(`Branch: ${session.gitBranch}`); 442 } 443 parts.push(`Duration: ${formatDuration(session.startTime, session.endTime)}`); 444 parts.push(''); 445 446 // LEAD with files changed - this is the most important signal of actual work 447 const filesWritten: string[] = []; 448 const filesEdited: string[] = []; 449 const commandsRun: string[] = []; 450 451 for (const msg of session.messages) { 452 if (msg.type === 'assistant') { 453 for (const tool of msg.toolUses) { 454 if (tool.name === 'Write') { 455 const rawInput = tool.rawInput; 456 const filePath = rawInput?.file_path; 457 const path = typeof filePath === 'string' ? filePath : ''; 458 if (path !== '' && !filesWritten.includes(path)) { 459 filesWritten.push(path); 460 } 461 } else if (tool.name === 'Edit') { 462 const rawInput = tool.rawInput; 463 const filePath = rawInput?.file_path; 464 const path = typeof filePath === 'string' ? filePath : ''; 465 if (path !== '' && !filesEdited.includes(path)) { 466 filesEdited.push(path); 467 } 468 } else if (tool.name === 'Bash') { 469 const rawInput = tool.rawInput; 470 const command = rawInput?.command; 471 const cmd = typeof command === 'string' ? command.slice(0, 100) : ''; 472 if (cmd !== '' && commandsRun.length < 10) { 473 commandsRun.push(cmd); 474 } 475 } 476 } 477 } 478 } 479 480 // Classify the work based on file paths 481 const allFiles = [...filesWritten, ...filesEdited]; 482 const classification = classifyWork(allFiles); 483 parts.push(`WORK TYPE: ${classification.type}`); 484 if (classification.scopeSummary !== '') { 485 parts.push(`SCOPE: ${classification.scopeSummary}`); 486 } 487 parts.push(''); 488 489 // Show action summary at the TOP 490 if (filesWritten.length > 0) { 491 parts.push(`FILES CREATED (${filesWritten.length.toString()}):`); 492 filesWritten.slice(0, 15).forEach((f) => parts.push(` - ${f}`)); 493 if (filesWritten.length > 15) parts.push(` ... and ${(filesWritten.length - 15).toString()} more`); 494 parts.push(''); 495 } 496 497 if (filesEdited.length > 0) { 498 parts.push(`FILES EDITED (${filesEdited.length.toString()}):`); 499 filesEdited.slice(0, 15).forEach((f) => parts.push(` - ${f}`)); 500 if (filesEdited.length > 15) parts.push(` ... and ${(filesEdited.length - 15).toString()} more`); 501 parts.push(''); 502 } 503 504 if (commandsRun.length > 0) { 505 parts.push(`COMMANDS RUN (${commandsRun.length.toString()}):`); 506 commandsRun.slice(0, 5).forEach((c) => parts.push(` $ ${c}`)); 507 parts.push(''); 508 } 509 510 // Then show conversation context (but less of it) 511 parts.push('CONVERSATION:'); 512 let messageCount = 0; 513 for (const msg of session.messages) { 514 if (messageCount > 20) break; // Limit to avoid overwhelming 515 516 if (msg.type === 'user' && msg.text !== '') { 517 const text = msg.text.slice(0, 300); 518 parts.push(`User: ${text}`); 519 messageCount++; 520 } else if (msg.type === 'assistant' && msg.text !== '') { 521 const text = msg.text.slice(0, 200); 522 parts.push(`Assistant: ${text}`); 523 messageCount++; 524 } 525 } 526 527 // Add stats at end 528 parts.push(''); 529 const toolSummary = Object.entries(session.stats.toolCalls) 530 .sort((a, b) => b[1] - a[1]) 531 .slice(0, 10) 532 .map(([name, count]) => `${name}(${count.toString()})`) 533 .join(', '); 534 if (toolSummary !== '') { 535 parts.push(`Tool usage: ${toolSummary}`); 536 } 537 538 return parts.join('\n'); 539} 540 541function formatDuration(start: string, end: string): string { 542 if (start === '' || end === '') return 'unknown'; 543 544 const startDate = new Date(start); 545 const endDate = new Date(end); 546 const diffMs = endDate.getTime() - startDate.getTime(); 547 548 const minutes = Math.floor(diffMs / 60000); 549 if (minutes < 60) return `${minutes.toString()} min`; 550 551 const hours = Math.floor(minutes / 60); 552 const remainingMinutes = minutes % 60; 553 return `${hours.toString()}h ${remainingMinutes.toString()}m`; 554}