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