this repo has no description
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}