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