this repo has no description
at experiment/session-classification 385 lines 12 kB view raw
1import { findUnprocessedSessions, type SessionFile } from '../core/session-detector'; 2import { parseSessionFile } from '../core/session-reader'; 3import { summarizeSession, generateDailyBragSummary } from '../core/summarizer'; 4import { 5 markFileProcessed, 6 saveSessionSummary, 7 saveDailySummary, 8 getDatesWithoutBragSummary, 9 getSessionsForDate, 10} from '../core/db'; 11 12interface ProcessOptions { 13 force: boolean; 14 verbose: boolean; 15 date?: string; 16 week?: string; 17} 18 19// Get start and end of week (Monday-Sunday) for a given date 20function getWeekBounds(date: Date): { start: string; end: string } { 21 const d = new Date(date); 22 const day = d.getDay(); 23 const diffToMonday = d.getDate() - day + (day === 0 ? -6 : 1); 24 const monday = new Date(d.setDate(diffToMonday)); 25 const sunday = new Date(monday); 26 sunday.setDate(monday.getDate() + 6); 27 28 return { 29 start: monday.toISOString().split('T')[0], 30 end: sunday.toISOString().split('T')[0], 31 }; 32} 33 34// Parse date string, handling shortcuts like "today", "yesterday" 35function parseDate(dateStr: string): string { 36 const today = new Date(); 37 38 switch (dateStr.toLowerCase()) { 39 case 'today': 40 return today.toISOString().split('T')[0]; 41 case 'yesterday': { 42 const yesterday = new Date(today); 43 yesterday.setDate(yesterday.getDate() - 1); 44 return yesterday.toISOString().split('T')[0]; 45 } 46 default: 47 // Assume YYYY-MM-DD format 48 return dateStr; 49 } 50} 51 52// Parse week string, handling shortcuts like "thisweek", "lastweek" 53function parseWeek(weekStr: string): { start: string; end: string } { 54 const today = new Date(); 55 56 switch (weekStr.toLowerCase()) { 57 case 'thisweek': 58 case 'this': 59 return getWeekBounds(today); 60 case 'lastweek': 61 case 'last': { 62 const lastWeek = new Date(today); 63 lastWeek.setDate(lastWeek.getDate() - 7); 64 return getWeekBounds(lastWeek); 65 } 66 default: 67 // Assume YYYY-MM-DD format, get the week containing that date 68 return getWeekBounds(new Date(weekStr + 'T12:00:00')); 69 } 70} 71 72// Check if a date falls within a range 73function isDateInRange(date: string, start: string, end: string): boolean { 74 return date >= start && date <= end; 75} 76 77export async function processCommand(options: ProcessOptions): Promise<{ 78 sessionsProcessed: number; 79 errors: number; 80}> { 81 const { force, verbose, date, week } = options; 82 83 // Build date filter 84 let dateFilter: { type: 'date'; value: string } | { type: 'range'; start: string; end: string } | null = null; 85 86 if (date) { 87 const targetDate = parseDate(date); 88 dateFilter = { type: 'date', value: targetDate }; 89 console.log(`\n📅 Filtering to date: ${targetDate}\n`); 90 } else if (week) { 91 const { start, end } = parseWeek(week); 92 dateFilter = { type: 'range', start, end }; 93 console.log(`\n📅 Filtering to week: ${start} to ${end}\n`); 94 } else { 95 console.log('\n🔍 Scanning for sessions...\n'); 96 } 97 98 let sessions = await findUnprocessedSessions(force); 99 100 // Pre-filter by file modification time if date filter is set 101 // This avoids parsing thousands of files just to check their dates 102 if (dateFilter && sessions.length > 0) { 103 const originalCount = sessions.length; 104 const bufferDays = 2; // Allow some buffer for timezone/edge cases 105 106 let startDate: Date, endDate: Date; 107 if (dateFilter.type === 'date') { 108 startDate = new Date(dateFilter.value + 'T00:00:00'); 109 endDate = new Date(dateFilter.value + 'T23:59:59'); 110 } else { 111 startDate = new Date(dateFilter.start + 'T00:00:00'); 112 endDate = new Date(dateFilter.end + 'T23:59:59'); 113 } 114 115 // Expand range by buffer 116 startDate.setDate(startDate.getDate() - bufferDays); 117 endDate.setDate(endDate.getDate() + bufferDays); 118 119 sessions = sessions.filter(s => 120 s.modifiedAt >= startDate && s.modifiedAt <= endDate 121 ); 122 123 console.log(`Pre-filtered ${originalCount}${sessions.length} sessions by modification time\n`); 124 } 125 126 if (sessions.length === 0) { 127 console.log('✅ No new sessions to process.\n'); 128 return { sessionsProcessed: 0, errors: 0 }; 129 } 130 131 console.log(`Found ${sessions.length} session(s) to check\n`); 132 133 // Process sessions in parallel with concurrency limit 134 const CONCURRENCY = 5; 135 const results: Array<{ 136 session: SessionFile; 137 result?: Awaited<ReturnType<typeof processSession>>; 138 error?: unknown; 139 }> = []; 140 141 // Process in batches 142 for (let i = 0; i < sessions.length; i += CONCURRENCY) { 143 const batch = sessions.slice(i, i + CONCURRENCY); 144 const batchResults = await Promise.all( 145 batch.map(async (session) => { 146 try { 147 const result = await processSession(session, verbose, dateFilter); 148 return { session, result }; 149 } catch (error) { 150 return { session, error }; 151 } 152 }) 153 ); 154 results.push(...batchResults); 155 } 156 157 // Group results by project for display 158 const byProject = groupByProject(sessions); 159 let processed = 0; 160 let errors = 0; 161 const datesProcessed = new Set<string>(); 162 163 for (const [projectName, projectSessions] of Object.entries(byProject)) { 164 console.log(`📁 ${projectName} (${projectSessions.length} sessions)`); 165 166 let skipped = 0; 167 let filtered = 0; 168 169 for (const session of projectSessions) { 170 const resultEntry = results.find((r) => r.session === session); 171 if (!resultEntry) continue; 172 173 if (resultEntry.error) { 174 errors++; 175 console.log(`${session.sessionId.slice(0, 8)}... - Error: ${resultEntry.error}`); 176 if (verbose) { 177 console.error(resultEntry.error); 178 } 179 continue; 180 } 181 182 const result = resultEntry.result!; 183 184 if (result.filtered) { 185 filtered++; 186 continue; 187 } 188 189 if (result.skipped) { 190 skipped++; 191 if (verbose) { 192 console.log(`${session.sessionId.slice(0, 8)}... (skipped - no work)`); 193 } 194 continue; 195 } 196 197 if (result.date) { 198 datesProcessed.add(result.date); 199 } 200 processed++; 201 202 const duration = formatDuration(result.startTime, result.endTime); 203 const summary = result.summary.slice(0, 60); 204 console.log(`${session.sessionId.slice(0, 8)}... (${duration}) → "${summary}..."`); 205 } 206 207 const notes = []; 208 if (skipped > 0) notes.push(`${skipped} empty`); 209 if (filtered > 0) notes.push(`${filtered} outside date range`); 210 if (notes.length > 0) { 211 console.log(` (${notes.join(', ')} skipped)`); 212 } 213 console.log(''); 214 } 215 216 // Generate brag summaries for new dates 217 console.log('📝 Generating daily summaries...\n'); 218 await generateMissingBragSummaries(verbose); 219 220 console.log(`\n✅ Done! Processed ${processed} sessions (${errors} errors)\n`); 221 console.log('Run `bun cli serve` to view your worklog.\n'); 222 223 return { sessionsProcessed: processed, errors }; 224} 225 226type DateFilter = { type: 'date'; value: string } | { type: 'range'; start: string; end: string } | null; 227 228async function processSession( 229 sessionFile: SessionFile, 230 verbose: boolean, 231 dateFilter: DateFilter 232): Promise<{ 233 date: string; 234 startTime: string; 235 endTime: string; 236 summary: string; 237 skipped: boolean; 238 filtered: boolean; 239}> { 240 // Parse the session file 241 const parsed = await parseSessionFile( 242 sessionFile.path, 243 sessionFile.projectPath, 244 sessionFile.projectName 245 ); 246 247 if (verbose) { 248 console.log(` Parsed: ${parsed.messages.length} messages, ${Object.keys(parsed.stats.toolCalls).length} tool types`); 249 } 250 251 // Check date filter BEFORE expensive LLM summarization 252 if (dateFilter) { 253 const matchesFilter = 254 dateFilter.type === 'date' 255 ? parsed.date === dateFilter.value 256 : isDateInRange(parsed.date, dateFilter.start, dateFilter.end); 257 258 if (!matchesFilter) { 259 // Don't mark as processed - we're just skipping for this run 260 return { 261 date: parsed.date, 262 startTime: parsed.startTime, 263 endTime: parsed.endTime, 264 summary: '', 265 skipped: false, 266 filtered: true, 267 }; 268 } 269 } 270 271 // Skip sessions with no actual code changes 272 // Exploration (reading, searching) doesn't count as work 273 const tools = parsed.stats.toolCalls; 274 275 // Only these tools indicate actual work happened 276 const codeChangeTools = ['Edit', 'Write', 'NotebookEdit', 'MultiEdit']; 277 const hasCodeChanges = codeChangeTools.some(tool => (tools[tool] || 0) > 0); 278 279 if (!hasCodeChanges) { 280 // Mark as processed but don't save to DB 281 markFileProcessed(sessionFile.path, sessionFile.fileHash); 282 return { 283 date: parsed.date, 284 startTime: parsed.startTime, 285 endTime: parsed.endTime, 286 summary: '', 287 skipped: true, 288 filtered: false, 289 }; 290 } 291 292 // Generate summary via LLM 293 const summary = await summarizeSession(parsed); 294 295 if (verbose) { 296 console.log(` Summary: ${summary.shortSummary}`); 297 console.log(` Accomplishments: ${summary.accomplishments.length}`); 298 } 299 300 // Filter out sessions that the LLM determined had no real work 301 const noWorkPhrases = [ 302 'no work', 'no coding', 'was interrupted', 'no substantive', 303 'minimal progress', 'minimal activity', 'no significant', 'nothing was accomplished' 304 ]; 305 const summaryLower = summary.shortSummary.toLowerCase(); 306 if (noWorkPhrases.some(phrase => summaryLower.includes(phrase))) { 307 markFileProcessed(sessionFile.path, sessionFile.fileHash); 308 return { 309 date: parsed.date, 310 startTime: parsed.startTime, 311 endTime: parsed.endTime, 312 summary: '', 313 skipped: true, 314 filtered: false, 315 }; 316 } 317 318 // Save to database 319 saveSessionSummary(parsed, summary); 320 markFileProcessed(sessionFile.path, sessionFile.fileHash); 321 322 return { 323 date: parsed.date, 324 startTime: parsed.startTime, 325 endTime: parsed.endTime, 326 summary: summary.shortSummary, 327 skipped: false, 328 filtered: false, 329 }; 330} 331 332async function generateMissingBragSummaries(verbose: boolean): Promise<void> { 333 const datesWithoutSummary = getDatesWithoutBragSummary(); 334 335 for (const date of datesWithoutSummary) { 336 try { 337 const sessions = getSessionsForDate(date); 338 if (sessions.length === 0) continue; 339 340 if (verbose) { 341 console.log(` Generating brag summary for ${date} (${sessions.length} sessions)`); 342 } 343 344 const bragSummary = await generateDailyBragSummary(date, sessions); 345 const projectNames = [...new Set(sessions.map((s) => s.project_name))]; 346 347 saveDailySummary(date, bragSummary, projectNames, sessions.length); 348 349 console.log(` 📣 ${date}: "${bragSummary.slice(0, 80)}..."`); 350 } catch (error) { 351 console.error(` Failed to generate brag for ${date}:`, error); 352 } 353 } 354} 355 356function groupByProject( 357 sessions: SessionFile[] 358): Record<string, SessionFile[]> { 359 const grouped: Record<string, SessionFile[]> = {}; 360 361 for (const session of sessions) { 362 const key = session.projectName; 363 if (!grouped[key]) { 364 grouped[key] = []; 365 } 366 grouped[key].push(session); 367 } 368 369 return grouped; 370} 371 372function formatDuration(start: string, end: string): string { 373 if (!start || !end) return '?'; 374 375 const startDate = new Date(start); 376 const endDate = new Date(end); 377 const diffMs = endDate.getTime() - startDate.getTime(); 378 379 const minutes = Math.floor(diffMs / 60000); 380 if (minutes < 60) return `${minutes}m`; 381 382 const hours = Math.floor(minutes / 60); 383 const remainingMinutes = minutes % 60; 384 return `${hours}h${remainingMinutes}m`; 385}