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