this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fix ESLint issues in core modules

- Add explicit null checks and type guards
- Use nullish coalescing (??) instead of logical OR (||)
- Fix floating promises with proper await/void handling
- Remove invalid generateObject options (mode, maxTokens)
- Add type annotations for parsed JSON values
- Use consistent type imports

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

alice f704e570 fc970575

+377 -241
+98 -27
src/core/codex-detector.ts
··· 5 5 import type { SessionFile } from '../types'; 6 6 7 7 /** 8 + * New format (post-October 2025): session_meta type with nested payload 9 + */ 10 + interface NewFormatEntry { 11 + type: string; 12 + payload?: { 13 + cwd?: string; 14 + id?: string; 15 + git?: { 16 + branch?: string; 17 + }; 18 + }; 19 + } 20 + 21 + /** 22 + * Old format (pre-October 2025): flat structure with id at top level 23 + */ 24 + interface OldFormatEntry { 25 + id?: string; 26 + type?: string; 27 + git?: { 28 + branch?: string; 29 + }; 30 + } 31 + 32 + /** 33 + * Message entry in old format sessions 34 + */ 35 + interface OldFormatMessage { 36 + type?: string; 37 + content?: { 38 + type?: string; 39 + text?: string; 40 + }[]; 41 + } 42 + 43 + /** 8 44 * Get Codex config directory if it exists 9 45 */ 10 46 export function getCodexPaths(): string[] { ··· 25 61 try { 26 62 const content = readFileSync(filePath, 'utf-8'); 27 63 const lines = content.split('\n'); 28 - const firstLine = lines[0]; 29 - if (!firstLine) return null; 64 + const firstLine = lines[0] ?? ''; 65 + if (firstLine.length === 0) return null; 66 + 67 + const entry = JSON.parse(firstLine) as unknown; 68 + 69 + // Type guard for new format 70 + const isNewFormat = (e: unknown): e is NewFormatEntry => { 71 + return ( 72 + typeof e === 'object' && 73 + e !== null && 74 + 'type' in e && 75 + typeof e.type === 'string' && 76 + e.type === 'session_meta' 77 + ); 78 + }; 30 79 31 - const entry = JSON.parse(firstLine); 80 + // Type guard for old format 81 + const isOldFormat = (e: unknown): e is OldFormatEntry => { 82 + return ( 83 + typeof e === 'object' && 84 + e !== null && 85 + 'id' in e && 86 + typeof e.id === 'string' && 87 + e.id.length > 0 && 88 + (!('type' in e) || typeof e.type !== 'string' || e.type.length === 0) 89 + ); 90 + }; 32 91 33 92 // New format: type === 'session_meta' with payload.cwd 34 - if (entry.type === 'session_meta' && entry.payload?.cwd) { 35 - return { 36 - cwd: entry.payload.cwd, 37 - sessionId: entry.payload.id || '', 38 - gitBranch: entry.payload.git?.branch || '', 39 - }; 93 + if (isNewFormat(entry)) { 94 + const cwd = entry.payload?.cwd; 95 + if (cwd !== undefined && cwd.length > 0) { 96 + return { 97 + cwd, 98 + sessionId: entry.payload?.id ?? '', 99 + gitBranch: entry.payload?.git?.branch ?? '', 100 + }; 101 + } 40 102 } 41 103 42 104 // Old format: id at top level, cwd in environment_context message 43 - if (entry.id && !entry.type) { 44 - const sessionId = entry.id; 45 - const gitBranch = entry.git?.branch || ''; 105 + if (isOldFormat(entry)) { 106 + const sessionId = entry.id ?? ''; 107 + const gitBranch = entry.git?.branch ?? ''; 46 108 47 109 // Search first few lines for environment_context with cwd 48 110 for (let i = 0; i < Math.min(lines.length, 10); i++) { 49 - const line = lines[i]; 50 - if (!line) continue; 111 + const line = lines[i] ?? ''; 112 + if (line.length === 0) continue; 51 113 try { 52 - const msg = JSON.parse(line); 53 - if (msg.type === 'message' && msg.content) { 114 + const msg = JSON.parse(line) as OldFormatMessage; 115 + if ( 116 + msg.type === 'message' && 117 + msg.content !== undefined && 118 + Array.isArray(msg.content) 119 + ) { 54 120 for (const block of msg.content) { 55 - if (block.type === 'input_text' && block.text?.includes('Current working directory:')) { 56 - const match = block.text.match(/Current working directory: ([^\n\\]+)/); 57 - if (match) { 58 - return { cwd: match[1], sessionId, gitBranch }; 121 + if ( 122 + block.type === 'input_text' && 123 + block.text?.includes('Current working directory:') === true 124 + ) { 125 + const cwdRegex = /Current working directory: ([^\n\\]+)/; 126 + const match = cwdRegex.exec(block.text); 127 + const extractedCwd = match?.[1]; 128 + if (extractedCwd !== undefined) { 129 + return { cwd: extractedCwd, sessionId, gitBranch }; 59 130 } 60 131 } 61 132 } 62 133 } 63 134 } catch { 64 - // Skip malformed lines 135 + // ignore parse errors 65 136 } 66 137 } 67 138 } 68 139 } catch { 69 - // Invalid JSON or missing fields 140 + // ignore parse errors 70 141 } 71 142 return null; 72 143 } ··· 77 148 function getProjectInfo(cwd: string): { projectPath: string; projectName: string } { 78 149 // Try to find git root for canonical project identity 79 150 const gitRoot = findGitRoot(cwd); 80 - if (gitRoot) { 151 + if (gitRoot !== null && gitRoot.length > 0) { 81 152 return { 82 153 projectPath: gitRoot, 83 - projectName: gitRoot.split('/').pop() || 'unknown', 154 + projectName: gitRoot.split('/').pop() ?? 'unknown', 84 155 }; 85 156 } 86 157 87 158 // Fallback to cwd itself 88 159 return { 89 160 projectPath: cwd, 90 - projectName: cwd.split('/').pop() || 'unknown', 161 + projectName: cwd.split('/').pop() ?? 'unknown', 91 162 }; 92 163 } 93 164 ··· 129 200 130 201 // Extract project info from session_meta 131 202 const meta = extractCodexSessionMeta(filePath); 132 - if (!meta?.cwd) continue; 203 + if (meta === null || meta.cwd.length === 0) continue; 133 204 134 205 const { projectPath, projectName } = getProjectInfo(meta.cwd); 135 206 ··· 137 208 path: filePath, 138 209 projectPath, 139 210 projectName, 140 - sessionId: meta.sessionId || file.replace('.jsonl', ''), 211 + sessionId: meta.sessionId.length > 0 ? meta.sessionId : file.replace('.jsonl', ''), 141 212 modifiedAt: fileStat.mtime, 142 213 fileHash: '', // Computed lazily 143 214 source: 'codex',
+89 -72
src/core/codex-reader.ts
··· 4 4 5 5 // Codex JSONL entry types 6 6 interface CodexEntry { 7 - timestamp: string; 7 + timestamp?: string; 8 8 type: 'session_meta' | 'event_msg' | 'response_item' | 'turn_context' | 'message' | 'function_call'; 9 9 payload: unknown; 10 10 } 11 11 12 12 interface CodexSessionMeta { 13 - id: string; 14 - cwd: string; 13 + id?: string; 14 + cwd?: string; 15 15 cli_version?: string; 16 16 model_provider?: string; 17 17 git?: { ··· 32 32 reasoning_output_tokens?: number; 33 33 }; 34 34 }; 35 + } 36 + 37 + interface CodexContentItem { 38 + type: string; 39 + text?: string; 35 40 } 36 41 37 42 interface CodexResponseItem { 38 43 type: 'message' | 'function_call' | 'function_call_output' | 'custom_tool_call' | 'reasoning'; 39 44 role?: string; 40 - content?: Array<{ type: string; text?: string }>; 45 + content?: CodexContentItem[]; 41 46 name?: string; 42 47 input?: string; // For function_call/custom_tool_call (apply_patch content) 43 48 arguments?: string; // For function_call (shell args as JSON) ··· 84 89 function extractFilesFromPatch(patchContent: string): string[] { 85 90 const files: string[] = []; 86 91 const regex = /\*\*\* (?:Add|Update|Delete) File:\s*(.+)/g; 87 - let match; 88 - while ((match = regex.exec(patchContent)) !== null) { 92 + let match = regex.exec(patchContent); 93 + while (match !== null) { 89 94 const filePath = match[1].trim(); 90 - if (filePath && !files.includes(filePath)) { 95 + if (filePath !== '' && !files.includes(filePath)) { 91 96 files.push(filePath); 92 97 } 98 + match = regex.exec(patchContent); 93 99 } 94 100 return files; 95 101 } ··· 104 110 apply_patch: 'Edit', 105 111 update_plan: 'TodoWrite', 106 112 }; 107 - return mapping[name] || name; 113 + return mapping[name] ?? name; 114 + } 115 + 116 + interface ShellArgs { 117 + command?: unknown; 108 118 } 109 119 110 120 /** ··· 113 123 function summarizeCodexToolInput(name: string, payload: CodexResponseItem): string { 114 124 const MAX_LENGTH = 200; 115 125 116 - if ((name === 'shell' || name === 'shell_command') && payload.arguments) { 126 + if ((name === 'shell' || name === 'shell_command') && payload.arguments !== undefined) { 117 127 try { 118 - const args = JSON.parse(payload.arguments); 119 - const cmd = Array.isArray(args.command) ? args.command.join(' ') : String(args.command || ''); 128 + const args = JSON.parse(payload.arguments) as ShellArgs; 129 + let cmd = ''; 130 + if (Array.isArray(args.command)) { 131 + cmd = args.command.join(' '); 132 + } else if (typeof args.command === 'string' || typeof args.command === 'number' || typeof args.command === 'boolean') { 133 + cmd = String(args.command); 134 + } 120 135 return truncate(cmd, MAX_LENGTH); 121 136 } catch { 122 137 return truncate(payload.arguments, MAX_LENGTH); 123 138 } 124 139 } 125 140 126 - if (name === 'apply_patch' && payload.input) { 141 + if (name === 'apply_patch' && payload.input !== undefined) { 127 142 // Extract first file path from patch 128 143 const files = extractFilesFromPatch(payload.input); 129 144 if (files.length > 0) { 130 - return files.length === 1 ? files[0] : `${files[0]} (+${files.length - 1} more)`; 145 + const additionalFiles = files.length - 1; 146 + return files.length === 1 ? files[0] : `${files[0]} (+${additionalFiles.toString()} more)`; 131 147 } 132 148 return truncate(payload.input, MAX_LENGTH); 133 149 } ··· 143 159 /** 144 160 * Extract text content from Codex message content array 145 161 */ 146 - function extractTextFromContent(content: Array<{ type: string; text?: string }> | undefined): string { 147 - if (!content || !Array.isArray(content)) return ''; 162 + function extractTextFromContent(content: CodexContentItem[] | undefined): string { 163 + if (content === undefined || !Array.isArray(content)) return ''; 148 164 const texts: string[] = []; 149 165 for (const item of content) { 150 166 // Handle both new format ('text') and old format ('input_text', 'output_text') 151 - if ((item.type === 'text' || item.type === 'input_text' || item.type === 'output_text') && item.text) { 167 + if ((item.type === 'text' || item.type === 'input_text' || item.type === 'output_text') && item.text !== undefined) { 152 168 texts.push(item.text); 153 169 } 154 170 } ··· 178 194 179 195 for await (const entry of parseCodexJSONLStream(filePath)) { 180 196 // Track timestamps (skip entries without timestamps - common in old format) 181 - if (entry.timestamp) { 182 - if (!startTime || entry.timestamp < startTime) startTime = entry.timestamp; 183 - if (!endTime || entry.timestamp > endTime) endTime = entry.timestamp; 197 + if (entry.timestamp !== undefined && entry.timestamp !== '') { 198 + if (startTime === '' || entry.timestamp < startTime) startTime = entry.timestamp; 199 + if (endTime === '' || entry.timestamp > endTime) endTime = entry.timestamp; 184 200 } 185 201 186 202 // Handle session_meta (first line) - new format 187 203 if (entry.type === 'session_meta') { 188 204 const meta = entry.payload as CodexSessionMeta; 189 - sessionId = meta.id || ''; 190 - gitBranch = meta.git?.branch || ''; 205 + sessionId = meta.id ?? ''; 206 + gitBranch = meta.git !== undefined ? meta.git.branch : ''; 191 207 continue; 192 208 } 193 209 194 210 // Handle old format first line (pre-October 2025): {id, timestamp, git, ...} without type 195 - const rawEntry = entry as Record<string, unknown>; 196 - if (rawEntry.id && !rawEntry.type && rawEntry.git) { 211 + const rawEntry = entry as unknown as Record<string, unknown>; 212 + if (rawEntry.id !== undefined && rawEntry.type === undefined && rawEntry.git !== undefined) { 197 213 sessionId = rawEntry.id as string; 198 - gitBranch = (rawEntry.git as Record<string, unknown>)?.branch as string || ''; 214 + const git = rawEntry.git as Record<string, unknown>; 215 + gitBranch = (git.branch !== undefined ? git.branch as string : ''); 199 216 continue; 200 217 } 201 218 ··· 207 224 userMessages++; 208 225 messages.push({ 209 226 type: 'user', 210 - timestamp: entry.timestamp, 211 - text: payload.message || '', 227 + timestamp: entry.timestamp ?? '', 228 + text: payload.message ?? '', 212 229 toolUses: [], 213 230 }); 214 231 } else if (payload.type === 'agent_message') { 215 232 assistantMessages++; 216 233 messages.push({ 217 234 type: 'assistant', 218 - timestamp: entry.timestamp, 219 - text: payload.message || '', 235 + timestamp: entry.timestamp ?? '', 236 + text: payload.message ?? '', 220 237 toolUses: [], 221 238 }); 222 - } else if (payload.type === 'token_count' && payload.info?.total_token_usage) { 239 + } else if (payload.type === 'token_count' && payload.info?.total_token_usage !== undefined) { 223 240 // Track final token counts (total_token_usage accumulates) 224 241 const usage = payload.info.total_token_usage; 225 - totalInputTokens = usage.input_tokens + (usage.cached_input_tokens || 0); 226 - totalOutputTokens = usage.output_tokens + (usage.reasoning_output_tokens || 0); 242 + totalInputTokens = usage.input_tokens + (usage.cached_input_tokens ?? 0); 243 + totalOutputTokens = usage.output_tokens + (usage.reasoning_output_tokens ?? 0); 227 244 } 228 245 continue; 229 246 } ··· 233 250 const payload = entry.payload as CodexResponseItem; 234 251 235 252 // Function calls and custom tool calls (equivalent to Claude tool_use) 236 - if ((payload.type === 'function_call' || payload.type === 'custom_tool_call') && payload.name) { 253 + if ((payload.type === 'function_call' || payload.type === 'custom_tool_call') && payload.name !== undefined) { 237 254 const mappedName = mapCodexToolName(payload.name); 238 - toolCalls[mappedName] = (toolCalls[mappedName] || 0) + 1; 255 + toolCalls[mappedName] = (toolCalls[mappedName] ?? 0) + 1; 239 256 240 257 // Extract files from apply_patch 241 - if (payload.name === 'apply_patch' && payload.input) { 258 + if (payload.name === 'apply_patch' && payload.input !== undefined) { 242 259 const files = extractFilesFromPatch(payload.input); 243 260 files.forEach((f) => filesChanged.add(f)); 244 261 } ··· 253 270 assistantMessages++; 254 271 messages.push({ 255 272 type: 'assistant', 256 - timestamp: entry.timestamp, 273 + timestamp: entry.timestamp ?? '', 257 274 text: '', 258 275 toolUses: [toolUse], 259 276 }); 260 277 } 261 278 262 279 // Agent text messages from response_item 263 - if (payload.type === 'message' && payload.role === 'assistant' && payload.content) { 280 + if (payload.type === 'message' && payload.role === 'assistant' && payload.content !== undefined) { 264 281 const text = extractTextFromContent(payload.content); 265 - if (text) { 282 + if (text !== '') { 266 283 assistantMessages++; 267 284 messages.push({ 268 285 type: 'assistant', 269 - timestamp: entry.timestamp, 286 + timestamp: entry.timestamp ?? '', 270 287 text, 271 288 toolUses: [], 272 289 }); ··· 276 293 277 294 // Handle old format: top-level function_call (pre-October 2025) 278 295 // Old format: {"type":"function_call","name":"shell","arguments":"{\"command\":[\"bash\",\"-lc\",\"apply_patch...\"]}"} 279 - if (entry.type === 'function_call' && (entry as Record<string, unknown>).name) { 280 - const oldEntry = entry as Record<string, unknown>; 296 + if (entry.type === 'function_call' && (entry as unknown as Record<string, unknown>).name !== undefined) { 297 + const oldEntry = entry as unknown as Record<string, unknown>; 281 298 const name = oldEntry.name as string; 282 - const argsStr = oldEntry.arguments as string; 299 + const argsStr = oldEntry.arguments as string | undefined; 283 300 284 301 // Check if this is a shell command containing apply_patch 285 - if (name === 'shell' && argsStr) { 302 + if (name === 'shell' && argsStr !== undefined) { 286 303 try { 287 - const args = JSON.parse(argsStr); 304 + const args = JSON.parse(argsStr) as ShellArgs; 288 305 const command = args.command; 289 306 if (Array.isArray(command) && command.length >= 3) { 290 307 const shellCmd = command[2] as string; 291 - if (shellCmd?.includes('apply_patch')) { 308 + const patchRegex = /apply_patch\s*<<\s*['"]?PATCH['"]?\n([\s\S]*?)\n\s*PATCH/; 309 + const patchMatch = patchRegex.exec(shellCmd); 310 + if (shellCmd.includes('apply_patch') && patchMatch !== null) { 292 311 // Extract the patch content from the heredoc 293 - const patchMatch = shellCmd.match(/apply_patch\s*<<\s*['"]?PATCH['"]?\n([\s\S]*?)\n\s*PATCH/); 294 - if (patchMatch) { 295 - toolCalls['Edit'] = (toolCalls['Edit'] || 0) + 1; 296 - const files = extractFilesFromPatch(patchMatch[1]); 297 - files.forEach((f) => filesChanged.add(f)); 312 + toolCalls.Edit = ('Edit' in toolCalls ? toolCalls.Edit : 0) + 1; 313 + const files = extractFilesFromPatch(patchMatch[1]); 314 + files.forEach((f) => filesChanged.add(f)); 298 315 299 - assistantMessages++; 300 - messages.push({ 301 - type: 'assistant', 302 - timestamp: (oldEntry.timestamp as string) || '', 303 - text: '', 304 - toolUses: [{ 305 - name: 'Edit', 306 - input: `apply_patch: ${files.join(', ') || 'file changes'}`, 307 - rawInput: oldEntry, 308 - }], 309 - }); 310 - } 316 + assistantMessages++; 317 + messages.push({ 318 + type: 'assistant', 319 + timestamp: (oldEntry.timestamp as string | undefined) ?? '', 320 + text: '', 321 + toolUses: [{ 322 + name: 'Edit', 323 + input: `apply_patch: ${files.join(', ') !== '' ? files.join(', ') : 'file changes'}`, 324 + rawInput: oldEntry, 325 + }], 326 + }); 311 327 } else { 312 328 // Regular shell command 313 - toolCalls['Bash'] = (toolCalls['Bash'] || 0) + 1; 329 + toolCalls.Bash = ('Bash' in toolCalls ? toolCalls.Bash : 0) + 1; 314 330 assistantMessages++; 315 331 messages.push({ 316 332 type: 'assistant', 317 - timestamp: (oldEntry.timestamp as string) || '', 333 + timestamp: (oldEntry.timestamp as string | undefined) ?? '', 318 334 text: '', 319 335 toolUses: [{ 320 336 name: 'Bash', 321 - input: shellCmd?.substring(0, 100) || 'shell command', 337 + input: shellCmd.substring(0, 100), 322 338 rawInput: oldEntry, 323 339 }], 324 340 }); ··· 333 349 // Handle old format: top-level message (pre-October 2025) 334 350 // Old format: {"type":"message","role":"user/assistant","content":[{"type":"input_text/output_text","text":"..."}]} 335 351 if (entry.type === 'message') { 336 - const msgEntry = entry as unknown as { type: string; role: string; content?: Array<{ type: string; text?: string }> }; 352 + const msgEntry = entry as unknown as { type: string; role: string; content?: CodexContentItem[]; timestamp?: string }; 337 353 const text = extractTextFromContent(msgEntry.content); 338 354 339 355 // Skip environment_context messages (just contain cwd/approval policy info) 340 - if (text && !text.includes('<environment_context>')) { 356 + if (text !== '' && !text.includes('<environment_context>')) { 341 357 if (msgEntry.role === 'user') { 342 358 userMessages++; 343 359 messages.push({ 344 360 type: 'user', 345 - timestamp: (entry as Record<string, unknown>).timestamp as string || '', 361 + timestamp: msgEntry.timestamp ?? '', 346 362 text, 347 363 toolUses: [], 348 364 }); ··· 350 366 assistantMessages++; 351 367 messages.push({ 352 368 type: 'assistant', 353 - timestamp: (entry as Record<string, unknown>).timestamp as string || '', 369 + timestamp: msgEntry.timestamp ?? '', 354 370 text, 355 371 toolUses: [], 356 372 }); ··· 360 376 } 361 377 362 378 // Fallback to filename for sessionId 363 - if (!sessionId) { 364 - sessionId = filePath.split('/').pop()?.replace('.jsonl', '') || 'unknown'; 379 + if (sessionId === '') { 380 + const filename = filePath.split('/').pop(); 381 + sessionId = filename?.replace('.jsonl', '') ?? 'unknown'; 365 382 } 366 383 367 384 // Provide default timestamps if none found 368 385 const now = new Date().toISOString(); 369 - if (!startTime) startTime = now; 370 - if (!endTime) endTime = startTime; 386 + if (startTime === '') startTime = now; 387 + if (endTime === '') endTime = startTime; 371 388 372 389 // Derive date from endTime with 3am boundary 373 390 const date = getEffectiveDate(endTime);
+34 -26
src/core/db.ts
··· 1 1 import { Database } from 'bun:sqlite'; 2 - import { join, dirname } from 'path'; 2 + import { join } from 'path'; 3 3 import { mkdirSync, existsSync } from 'fs'; 4 4 import type { 5 5 DBSessionSummary, 6 6 DBDailySummary, 7 7 DBProcessedFile, 8 - DBProject, 9 8 SessionSummary, 10 9 ParsedSession, 11 10 SessionStats, ··· 35 34 } 36 35 37 36 function initSchema() { 38 - const database = db!; 37 + const database = db; 38 + if (!database) { 39 + throw new Error('Database not initialized'); 40 + } 39 41 40 - database.exec(` 42 + database.run(` 41 43 CREATE TABLE IF NOT EXISTS session_summaries ( 42 44 id INTEGER PRIMARY KEY, 43 45 session_id TEXT UNIQUE NOT NULL, ··· 98 100 * Run database migrations 99 101 */ 100 102 function runMigrations(): void { 101 - const database = db!; 103 + const database = db; 104 + if (!database) { 105 + throw new Error('Database not initialized'); 106 + } 102 107 103 108 // Check if source column exists 104 109 const columns = database ··· 109 114 110 115 if (!hasSourceColumn) { 111 116 console.log('Migration: Adding source column to session_summaries...'); 112 - database.exec(` 117 + database.run(` 113 118 ALTER TABLE session_summaries ADD COLUMN source TEXT DEFAULT 'claude'; 114 119 `); 115 120 console.log('Migration complete.'); ··· 238 243 let totalTokens = 0; 239 244 240 245 for (const session of sessions) { 241 - const stats: SessionStats = JSON.parse(session.stats || '{}'); 242 - totalTokens += (stats.totalInputTokens || 0) + (stats.totalOutputTokens || 0); 246 + const stats = JSON.parse(session.stats ?? '{}') as SessionStats; 247 + totalTokens += (stats.totalInputTokens ?? 0) + (stats.totalOutputTokens ?? 0); 243 248 244 249 const sessionDetail: SessionDetail = { 245 250 sessionId: session.session_id, 246 251 startTime: session.start_time, 247 252 endTime: session.end_time, 248 - shortSummary: session.short_summary, 249 - accomplishments: JSON.parse(session.accomplishments || '[]'), 250 - filesChanged: JSON.parse(session.files_changed || '[]'), 251 - toolsUsed: JSON.parse(session.tools_used || '[]'), 253 + shortSummary: session.short_summary ?? '', 254 + accomplishments: JSON.parse(session.accomplishments ?? '[]') as string[], 255 + filesChanged: JSON.parse(session.files_changed ?? '[]') as string[], 256 + toolsUsed: JSON.parse(session.tools_used ?? '[]') as string[], 252 257 stats, 253 258 }; 254 259 255 - const existing = projectMap.get(session.project_path) || []; 260 + const existing = projectMap.get(session.project_path) ?? []; 256 261 existing.push(sessionDetail); 257 262 projectMap.set(session.project_path, existing); 258 263 } 259 264 260 265 const projects: ProjectDetail[] = Array.from(projectMap.entries()).map( 261 266 ([path, sessions]) => ({ 262 - name: sessions[0]?.sessionId ? path.split('/').pop() || path : path, 267 + name: sessions[0]?.sessionId ? path.split('/').pop() ?? path : path, 263 268 path, 264 269 sessions, 265 270 }) ··· 268 273 // Get project name from first session 269 274 for (const project of projects) { 270 275 const firstSession = sessions.find((s) => s.project_path === project.path); 271 - if (firstSession?.project_name) { 276 + if (firstSession !== undefined && firstSession.project_name !== null && firstSession.project_name !== '') { 272 277 project.name = firstSession.project_name; 273 278 } 274 279 } ··· 302 307 `).get(); 303 308 304 309 return { 305 - totalSessions: stats?.total_sessions || 0, 306 - totalDays: stats?.total_days || 0, 307 - totalProjects: stats?.total_projects || 0, 310 + totalSessions: stats?.total_sessions ?? 0, 311 + totalDays: stats?.total_days ?? 0, 312 + totalProjects: stats?.total_projects ?? 0, 308 313 }; 309 314 } 310 315 ··· 337 342 'SELECT COUNT(*) as count FROM session_summaries WHERE project_path = ? AND date < ?' 338 343 ).get(projectPath, beforeDate); 339 344 340 - return (row?.count || 0) === 0; 345 + return (row?.count ?? 0) === 0; 341 346 } 342 347 343 348 /** ··· 363 368 * Backfill projects table from existing session data (one-time migration) 364 369 */ 365 370 function backfillProjectsIfNeeded(): void { 366 - const database = db!; 371 + const database = db; 372 + if (!database) { 373 + throw new Error('Database not initialized'); 374 + } 367 375 368 376 // Check if projects table is empty but sessions exist 369 377 const projectCount = 370 378 database 371 379 .query<{ count: number }, []>('SELECT COUNT(*) as count FROM projects') 372 - .get()?.count || 0; 380 + .get()?.count ?? 0; 373 381 374 382 const sessionCount = 375 383 database 376 384 .query<{ count: number }, []>( 377 385 'SELECT COUNT(*) as count FROM session_summaries' 378 386 ) 379 - .get()?.count || 0; 387 + .get()?.count ?? 0; 380 388 381 389 if (projectCount === 0 && sessionCount > 0) { 382 390 console.log('Backfilling projects table from session data...'); ··· 407 415 const filled = 408 416 database 409 417 .query<{ count: number }, []>('SELECT COUNT(*) as count FROM projects') 410 - .get()?.count || 0; 411 - console.log(`Created ${filled} project records.`); 418 + .get()?.count ?? 0; 419 + console.log(`Created ${String(filled)} project records.`); 412 420 } 413 421 } 414 422 ··· 480 488 first_session_date: string; 481 489 last_session_date: string; 482 490 total_sessions: number; 483 - days_since_last: number; 491 + days_since_last: number | null; 484 492 }, 485 493 string[] 486 494 >(query) ··· 493 501 firstSessionDate: row.first_session_date, 494 502 lastSessionDate: row.last_session_date, 495 503 totalSessions: row.total_sessions, 496 - daysSinceLastSession: row.days_since_last || 0, 504 + daysSinceLastSession: row.days_since_last ?? 0, 497 505 })); 498 506 } 499 507
+25 -15
src/core/session-detector.ts
··· 72 72 } 73 73 74 74 // Filesystem probing failed - try reading cwd from session file 75 - if (sessionFilePath && existsSync(sessionFilePath)) { 75 + if (sessionFilePath !== undefined && existsSync(sessionFilePath)) { 76 76 const cwd = extractCwdFromSessionFile(sessionFilePath); 77 - if (cwd) { 77 + if (cwd !== null) { 78 78 return cwd; 79 79 } 80 80 } ··· 93 93 for (const line of lines) { 94 94 if (!line.trim()) continue; 95 95 try { 96 - const entry = JSON.parse(line); 97 - if (entry.cwd && typeof entry.cwd === 'string') { 96 + const entry: unknown = JSON.parse(line); 97 + if ( 98 + typeof entry === 'object' && 99 + entry !== null && 100 + 'cwd' in entry && 101 + typeof entry.cwd === 'string' 102 + ) { 98 103 return entry.cwd; 99 104 } 100 - } catch {} 105 + } catch { 106 + // Ignore malformed JSON lines 107 + } 101 108 } 102 - } catch {} 109 + } catch { 110 + // Ignore file read errors 111 + } 103 112 return null; 104 113 } 105 114 ··· 183 192 const withoutLeading = folderName.slice(1); 184 193 185 194 // Find the src/a/ or src/tries/ marker 186 - const srcAMatch = withoutLeading.match(/^(Users-[^-]+-src-a)-(.+)$/); 187 - const srcTriesMatch = withoutLeading.match(/^(Users-[^-]+-src-tries)-(.+)$/); 195 + const srcAMatch = /^(Users-[^-]+-src-a)-(.+)$/.exec(withoutLeading); 196 + const srcTriesMatch = /^(Users-[^-]+-src-tries)-(.+)$/.exec(withoutLeading); 188 197 189 198 let decodedPath: string; 190 199 let isTriesProject = false; ··· 217 226 // Try to find git root to normalize monorepo subdirectories 218 227 if (existsSync(decodedPath)) { 219 228 const gitRoot = findGitRoot(decodedPath); 220 - if (gitRoot) { 221 - let projectName = gitRoot.split('/').pop() || 'unknown'; 229 + if (gitRoot !== null) { 230 + let projectName = gitRoot.split('/').pop() ?? 'unknown'; 222 231 // Strip date prefix from tries projects 223 232 if (isTriesProject) { 224 233 projectName = stripDatePrefix(projectName); ··· 228 237 } 229 238 230 239 // No git root found - use the decoded path as-is 231 - let projectName = decodedPath.split('/').pop() || 'unknown'; 240 + let projectName = decodedPath.split('/').pop() ?? 'unknown'; 232 241 // Strip date prefix from tries projects 233 242 if (isTriesProject) { 234 243 projectName = stripDatePrefix(projectName); ··· 247 256 * @deprecated Use decodeProjectFolder instead 248 257 */ 249 258 export function getProjectName(projectPath: string): string { 250 - return projectPath.split('/').filter(Boolean).pop() || 'unknown'; 259 + return projectPath.split('/').filter(Boolean).pop() ?? 'unknown'; 251 260 } 252 261 253 262 /** 254 263 * Calculate MD5 hash of a file 255 264 */ 256 - export function getFileHash(filePath: string): string { 257 - const content = Bun.file(filePath).toString(); 265 + export async function getFileHash(filePath: string): Promise<string> { 266 + const file = Bun.file(filePath); 267 + const content = await file.text(); 258 268 return createHash('md5').update(content).digest('hex'); 259 269 } 260 270 ··· 352 362 */ 353 363 export function filterSessionsByDate( 354 364 sessions: SessionFile[], 355 - targetDate: string 365 + _targetDate: string 356 366 ): SessionFile[] { 357 367 // We need to peek into files to check dates, but that's expensive 358 368 // For now, return all and let the processor filter
+65 -59
src/core/session-reader.ts
··· 31 31 }); 32 32 33 33 for await (const line of rl) { 34 - if (!line.trim()) continue; 34 + if (line.trim() === '') continue; 35 35 try { 36 36 yield JSON.parse(line) as RawSessionEntry; 37 37 } catch { ··· 67 67 seen.add(entry.uuid); 68 68 69 69 // Extract metadata from first entry 70 - if (!sessionId && entry.sessionId) { 70 + if (sessionId === '') { 71 71 sessionId = entry.sessionId; 72 72 } 73 - if (!gitBranch && entry.gitBranch) { 73 + if (gitBranch === '' && entry.gitBranch !== undefined) { 74 74 gitBranch = entry.gitBranch; 75 75 } 76 76 77 77 // Track timestamps 78 - if (!startTime || entry.timestamp < startTime) { 78 + if (startTime === '' || entry.timestamp < startTime) { 79 79 startTime = entry.timestamp; 80 80 } 81 - if (!endTime || entry.timestamp > endTime) { 81 + if (endTime === '' || entry.timestamp > endTime) { 82 82 endTime = entry.timestamp; 83 83 } 84 84 85 85 // Extract token usage from assistant messages 86 - if (entry.type === 'assistant' && entry.message?.usage) { 86 + if (entry.type === 'assistant' && entry.message.usage !== undefined) { 87 87 const usage = entry.message.usage; 88 - totalInputTokens += usage.input_tokens || 0; 89 - totalOutputTokens += usage.output_tokens || 0; 90 - totalInputTokens += usage.cache_creation_input_tokens || 0; 91 - totalInputTokens += usage.cache_read_input_tokens || 0; 88 + totalInputTokens += usage.input_tokens; 89 + totalOutputTokens += usage.output_tokens; 90 + totalInputTokens += usage.cache_creation_input_tokens ?? 0; 91 + totalInputTokens += usage.cache_read_input_tokens ?? 0; 92 92 } 93 93 94 94 // Parse message content 95 - const text = extractText(entry.message?.content); 96 - const toolUses = extractToolUses(entry.message?.content); 95 + const text = extractText(entry.message.content); 96 + const toolUses = extractToolUses(entry.message.content); 97 97 98 98 // Count tool calls 99 99 for (const tool of toolUses) { 100 - toolCalls[tool.name] = (toolCalls[tool.name] || 0) + 1; 100 + toolCalls[tool.name] = (toolCalls[tool.name] ?? 0) + 1; 101 101 } 102 102 103 103 if (entry.type === 'user') userMessages++; ··· 112 112 } 113 113 114 114 // Use filename as sessionId fallback 115 - if (!sessionId) { 116 - sessionId = filePath.split('/').pop()?.replace('.jsonl', '') || 'unknown'; 115 + if (sessionId === '') { 116 + sessionId = filePath.split('/').pop()?.replace('.jsonl', '') ?? 'unknown'; 117 117 } 118 118 119 119 // Provide default timestamps if none found 120 120 const now = new Date().toISOString(); 121 - if (!startTime) { 121 + if (startTime === '') { 122 122 startTime = now; 123 123 } 124 - if (!endTime) { 124 + if (endTime === '') { 125 125 endTime = startTime; 126 126 } 127 127 ··· 154 154 /** 155 155 * Extract text from message content array 156 156 */ 157 - function extractText(content: MessageContent[] | undefined): string { 158 - if (!content || !Array.isArray(content)) return ''; 157 + function extractText(content: MessageContent[]): string { 158 + if (!Array.isArray(content)) return ''; 159 159 160 160 const texts: string[] = []; 161 161 for (const item of content) { 162 162 if (item.type === 'text') { 163 163 // Handle both formats: { text: "..." } and { content: "..." } 164 164 const text = 'text' in item ? item.text : 'content' in item ? item.content : ''; 165 - if (text) texts.push(text); 165 + if (text !== '') texts.push(text); 166 166 } 167 167 } 168 168 return texts.join('\n'); ··· 171 171 /** 172 172 * Extract tool uses from message content 173 173 */ 174 - function extractToolUses(content: MessageContent[] | undefined): ToolUse[] { 175 - if (!content || !Array.isArray(content)) return []; 174 + function extractToolUses(content: MessageContent[]): ToolUse[] { 175 + if (!Array.isArray(content)) return []; 176 176 177 177 const tools: ToolUse[] = []; 178 178 for (const item of content) { ··· 198 198 199 199 switch (toolName) { 200 200 case 'Bash': 201 - return truncate(String(input.command || ''), MAX_LENGTH); 201 + return truncate(typeof input.command === 'string' ? input.command : '', MAX_LENGTH); 202 202 case 'Read': 203 - return truncate(String(input.file_path || ''), MAX_LENGTH); 203 + return truncate(typeof input.file_path === 'string' ? input.file_path : '', MAX_LENGTH); 204 204 case 'Write': 205 205 case 'Edit': 206 - return truncate(String(input.file_path || ''), MAX_LENGTH); 206 + return truncate(typeof input.file_path === 'string' ? input.file_path : '', MAX_LENGTH); 207 207 case 'Glob': 208 - return truncate(String(input.pattern || ''), MAX_LENGTH); 208 + return truncate(typeof input.pattern === 'string' ? input.pattern : '', MAX_LENGTH); 209 209 case 'Grep': 210 - return truncate(String(input.pattern || ''), MAX_LENGTH); 210 + return truncate(typeof input.pattern === 'string' ? input.pattern : '', MAX_LENGTH); 211 211 case 'Task': 212 - return truncate(String(input.description || ''), MAX_LENGTH); 212 + return truncate(typeof input.description === 'string' ? input.description : '', MAX_LENGTH); 213 213 default: 214 214 return truncate(JSON.stringify(input), MAX_LENGTH); 215 215 } ··· 304 304 305 305 for (const file of files) { 306 306 const lower = file.toLowerCase(); 307 - const filename = file.split('/').pop() || ''; 307 + const filename = file.split('/').pop() ?? ''; 308 308 309 309 // Tests 310 310 if ( ··· 400 400 const threshold = total * 0.5; 401 401 402 402 if (scope.tests > threshold) { 403 - signals.push(`${scope.tests}/${total} files are tests`); 403 + signals.push(`${scope.tests.toString()}/${total.toString()} files are tests`); 404 404 return { type: 'tests', signals, scope, scopeSummary }; 405 405 } 406 406 407 407 if (scope.docs > threshold) { 408 - signals.push(`${scope.docs}/${total} files are documentation`); 408 + signals.push(`${scope.docs.toString()}/${total.toString()} files are documentation`); 409 409 return { type: 'docs', signals, scope, scopeSummary }; 410 410 } 411 411 412 412 if (scope.types + scope.config > threshold) { 413 413 if (scope.types > scope.config) { 414 - signals.push(`${scope.types}/${total} files are types`); 414 + signals.push(`${scope.types.toString()}/${total.toString()} files are types`); 415 415 } else { 416 - signals.push(`${scope.config}/${total} files are config`); 416 + signals.push(`${scope.config.toString()}/${total.toString()} files are config`); 417 417 } 418 418 return { type: 'infrastructure', signals, scope, scopeSummary }; 419 419 } 420 420 421 421 if (featureFiles > threshold) { 422 - signals.push(`${featureFiles}/${total} files are feature code`); 422 + signals.push(`${featureFiles.toString()}/${total.toString()} files are feature code`); 423 423 return { type: 'feature', signals, scope, scopeSummary }; 424 424 } 425 425 426 426 // Mixed - build a description 427 - if (featureFiles > 0) signals.push(`${featureFiles} feature`); 428 - if (scope.tests > 0) signals.push(`${scope.tests} test`); 429 - if (scope.types > 0) signals.push(`${scope.types} type`); 430 - if (scope.config > 0) signals.push(`${scope.config} config`); 431 - if (scope.docs > 0) signals.push(`${scope.docs} doc`); 427 + if (featureFiles > 0) signals.push(`${featureFiles.toString()} feature`); 428 + if (scope.tests > 0) signals.push(`${scope.tests.toString()} test`); 429 + if (scope.types > 0) signals.push(`${scope.types.toString()} type`); 430 + if (scope.config > 0) signals.push(`${scope.config.toString()} config`); 431 + if (scope.docs > 0) signals.push(`${scope.docs.toString()} doc`); 432 432 433 433 return { type: 'mixed', signals, scope, scopeSummary }; 434 434 } ··· 441 441 const parts: string[] = []; 442 442 443 443 parts.push(`Project: ${session.projectName}`); 444 - if (session.gitBranch) { 444 + if (session.gitBranch !== '') { 445 445 parts.push(`Branch: ${session.gitBranch}`); 446 446 } 447 447 parts.push(`Duration: ${formatDuration(session.startTime, session.endTime)}`); ··· 456 456 if (msg.type === 'assistant') { 457 457 for (const tool of msg.toolUses) { 458 458 if (tool.name === 'Write') { 459 - const path = String((tool.rawInput as any)?.file_path || ''); 460 - if (path && !filesWritten.includes(path)) { 459 + const rawInput = tool.rawInput; 460 + const filePath = rawInput?.file_path; 461 + const path = typeof filePath === 'string' ? filePath : ''; 462 + if (path !== '' && !filesWritten.includes(path)) { 461 463 filesWritten.push(path); 462 464 } 463 465 } else if (tool.name === 'Edit') { 464 - const path = String((tool.rawInput as any)?.file_path || ''); 465 - if (path && !filesEdited.includes(path)) { 466 + const rawInput = tool.rawInput; 467 + const filePath = rawInput?.file_path; 468 + const path = typeof filePath === 'string' ? filePath : ''; 469 + if (path !== '' && !filesEdited.includes(path)) { 466 470 filesEdited.push(path); 467 471 } 468 472 } else if (tool.name === 'Bash') { 469 - const cmd = String((tool.rawInput as any)?.command || '').slice(0, 100); 470 - if (cmd && commandsRun.length < 10) { 473 + const rawInput = tool.rawInput; 474 + const command = rawInput?.command; 475 + const cmd = typeof command === 'string' ? command.slice(0, 100) : ''; 476 + if (cmd !== '' && commandsRun.length < 10) { 471 477 commandsRun.push(cmd); 472 478 } 473 479 } ··· 479 485 const allFiles = [...filesWritten, ...filesEdited]; 480 486 const classification = classifyWork(allFiles); 481 487 parts.push(`WORK TYPE: ${classification.type}`); 482 - if (classification.scopeSummary) { 488 + if (classification.scopeSummary !== '') { 483 489 parts.push(`SCOPE: ${classification.scopeSummary}`); 484 490 } 485 491 parts.push(''); 486 492 487 493 // Show action summary at the TOP 488 494 if (filesWritten.length > 0) { 489 - parts.push(`FILES CREATED (${filesWritten.length}):`); 495 + parts.push(`FILES CREATED (${filesWritten.length.toString()}):`); 490 496 filesWritten.slice(0, 15).forEach(f => parts.push(` - ${f}`)); 491 - if (filesWritten.length > 15) parts.push(` ... and ${filesWritten.length - 15} more`); 497 + if (filesWritten.length > 15) parts.push(` ... and ${(filesWritten.length - 15).toString()} more`); 492 498 parts.push(''); 493 499 } 494 500 495 501 if (filesEdited.length > 0) { 496 - parts.push(`FILES EDITED (${filesEdited.length}):`); 502 + parts.push(`FILES EDITED (${filesEdited.length.toString()}):`); 497 503 filesEdited.slice(0, 15).forEach(f => parts.push(` - ${f}`)); 498 - if (filesEdited.length > 15) parts.push(` ... and ${filesEdited.length - 15} more`); 504 + if (filesEdited.length > 15) parts.push(` ... and ${(filesEdited.length - 15).toString()} more`); 499 505 parts.push(''); 500 506 } 501 507 502 508 if (commandsRun.length > 0) { 503 - parts.push(`COMMANDS RUN (${commandsRun.length}):`); 509 + parts.push(`COMMANDS RUN (${commandsRun.length.toString()}):`); 504 510 commandsRun.slice(0, 5).forEach(c => parts.push(` $ ${c}`)); 505 511 parts.push(''); 506 512 } ··· 511 517 for (const msg of session.messages) { 512 518 if (messageCount > 20) break; // Limit to avoid overwhelming 513 519 514 - if (msg.type === 'user' && msg.text) { 520 + if (msg.type === 'user' && msg.text !== '') { 515 521 const text = msg.text.slice(0, 300); 516 522 parts.push(`User: ${text}`); 517 523 messageCount++; 518 - } else if (msg.type === 'assistant' && msg.text) { 524 + } else if (msg.type === 'assistant' && msg.text !== '') { 519 525 const text = msg.text.slice(0, 200); 520 526 parts.push(`Assistant: ${text}`); 521 527 messageCount++; ··· 527 533 const toolSummary = Object.entries(session.stats.toolCalls) 528 534 .sort((a, b) => b[1] - a[1]) 529 535 .slice(0, 10) 530 - .map(([name, count]) => `${name}(${count})`) 536 + .map(([name, count]) => `${name}(${count.toString()})`) 531 537 .join(', '); 532 - if (toolSummary) { 538 + if (toolSummary !== '') { 533 539 parts.push(`Tool usage: ${toolSummary}`); 534 540 } 535 541 ··· 537 543 } 538 544 539 545 function formatDuration(start: string, end: string): string { 540 - if (!start || !end) return 'unknown'; 546 + if (start === '' || end === '') return 'unknown'; 541 547 542 548 const startDate = new Date(start); 543 549 const endDate = new Date(end); 544 550 const diffMs = endDate.getTime() - startDate.getTime(); 545 551 546 552 const minutes = Math.floor(diffMs / 60000); 547 - if (minutes < 60) return `${minutes} min`; 553 + if (minutes < 60) return `${minutes.toString()} min`; 548 554 549 555 const hours = Math.floor(minutes / 60); 550 556 const remainingMinutes = minutes % 60; 551 - return `${hours}h ${remainingMinutes}m`; 557 + return `${hours.toString()}h ${remainingMinutes.toString()}m`; 552 558 }
+66 -42
src/core/summarizer.ts
··· 1 1 import { createAnthropic } from '@ai-sdk/anthropic'; 2 - import { generateObject, generateText } from 'ai'; 2 + import { generateObject } from 'ai'; 3 3 import { z } from 'zod'; 4 4 import type { ParsedSession, SessionSummary, DBSessionSummary } from '../types'; 5 5 import { createCondensedTranscript } from './session-reader'; 6 6 7 7 const anthropic = createAnthropic({ 8 8 apiKey: process.env.WORKLOG_API_KEY, 9 - ...(process.env.WORKLOG_BASE_URL && { baseURL: process.env.WORKLOG_BASE_URL }), 9 + ...(process.env.WORKLOG_BASE_URL !== undefined && process.env.WORKLOG_BASE_URL.length > 0 && { baseURL: process.env.WORKLOG_BASE_URL }), 10 10 headers: { 11 11 'Accept-Encoding': 'identity', 12 12 }, 13 13 }); 14 14 15 - const MODEL = process.env.SUMMARIZER_MODEL || 'claude-haiku-4-5-20251001'; 15 + const MODEL = process.env.SUMMARIZER_MODEL ?? 'claude-haiku-4-5-20251001'; 16 16 17 17 /** 18 18 * Try to recover valid JSON from malformed Haiku responses. ··· 36 36 let rawText = errorObj.text; 37 37 38 38 // Also try cause.value which contains the parsed (but invalid) object 39 - if (!rawText && errorObj.cause?.value) { 39 + if (rawText === undefined && errorObj.cause?.value !== undefined) { 40 40 const value = errorObj.cause.value as { shortSummary?: string }; 41 41 // If shortSummary contains escaped JSON structure, extract it 42 - if (value.shortSummary && value.shortSummary.includes('"accomplishments"')) { 42 + if (value.shortSummary?.includes('"accomplishments"') === true) { 43 43 rawText = value.shortSummary; 44 44 } 45 45 } 46 46 47 - if (!rawText || typeof rawText !== 'string') return null; 47 + if (rawText === undefined || typeof rawText !== 'string') return null; 48 48 49 49 // Unescape the content 50 50 const unescaped = rawText ··· 54 54 55 55 // Pattern: the model returned JSON with shortSummary containing the rest 56 56 // Extract: shortSummary ends at ",\n"accomplishments" or similar 57 - const summaryMatch = unescaped.match(/"shortSummary"\s*:\s*"([^"]+)"/); 58 - const accomplishmentsMatch = unescaped.match(/"accomplishments"\s*:\s*\[([\s\S]*?)\]/); 59 - const filesMatch = unescaped.match(/"filesChanged"\s*:\s*\[([\s\S]*?)\]/); 60 - const toolsMatch = unescaped.match(/"toolsUsed"\s*:\s*\[([\s\S]*?)\]/); 57 + const summaryMatch = /"shortSummary"\s*:\s*"([^"]+)"/.exec(unescaped); 58 + const accomplishmentsMatch = /"accomplishments"\s*:\s*\[([\s\S]*?)\]/.exec(unescaped); 59 + const filesMatch = /"filesChanged"\s*:\s*\[([\s\S]*?)\]/.exec(unescaped); 60 + const toolsMatch = /"toolsUsed"\s*:\s*\[([\s\S]*?)\]/.exec(unescaped); 61 61 62 - if (summaryMatch) { 62 + if (summaryMatch !== null) { 63 63 const parseArray = (match: RegExpMatchArray | null): string[] => { 64 - if (!match) return []; 64 + if (match === null) return []; 65 65 try { 66 - return JSON.parse(`[${match[1]}]`); 66 + const parsed: unknown = JSON.parse(`[${match[1]}]`); 67 + if (Array.isArray(parsed)) { 68 + return parsed.filter((item): item is string => typeof item === 'string'); 69 + } 70 + return []; 67 71 } catch { 68 72 // Extract strings manually 69 73 const items: string[] = []; ··· 86 90 87 91 // Last resort: try to parse as complete JSON object 88 92 try { 89 - const jsonMatch = unescaped.match(/\{[\s\S]*"shortSummary"[\s\S]*\}/); 90 - if (jsonMatch) { 91 - const parsed = JSON.parse(jsonMatch[0]); 92 - if (parsed.shortSummary && Array.isArray(parsed.accomplishments)) { 93 - return parsed; 93 + const jsonMatch = /\{[\s\S]*"shortSummary"[\s\S]*\}/.exec(unescaped); 94 + if (jsonMatch !== null) { 95 + const parsed: unknown = JSON.parse(jsonMatch[0]); 96 + if ( 97 + typeof parsed === 'object' && 98 + parsed !== null && 99 + 'shortSummary' in parsed && 100 + 'accomplishments' in parsed && 101 + typeof parsed.shortSummary === 'string' && 102 + Array.isArray(parsed.accomplishments) 103 + ) { 104 + return parsed as { 105 + shortSummary: string; 106 + accomplishments: string[]; 107 + filesChanged?: string[]; 108 + toolsUsed?: string[]; 109 + }; 94 110 } 95 111 } 96 - } catch {} 112 + } catch { 113 + // Failed to parse as complete JSON, continue to outer catch 114 + } 97 115 } catch { 98 116 // Recovery failed, will fall back to placeholder 99 117 } ··· 151 169 schema: sessionSummarySchema, 152 170 system: systemPrompt, 153 171 prompt: userPrompt, 154 - maxTokens: 1024, 155 - mode: 'tool', // Force tool use mode for reliable structured output 156 172 }); 157 173 158 174 return { 159 - shortSummary: object.shortSummary || 'Session completed', 160 - accomplishments: object.accomplishments || [], 161 - filesChanged: object.filesChanged || [], 175 + shortSummary: object.shortSummary, 176 + accomplishments: object.accomplishments, 177 + filesChanged: object.filesChanged, 162 178 toolsUsed: object.toolsUsed.length > 0 163 179 ? object.toolsUsed 164 180 : Object.keys(session.stats.toolCalls), ··· 167 183 // Haiku sometimes returns double-encoded JSON (valid JSON wrapped in a string) 168 184 // Try to recover by parsing the text field from the error 169 185 const recovered = tryRecoverMalformedResponse(error); 170 - if (recovered) { 186 + if (recovered !== null) { 171 187 return { 172 - shortSummary: recovered.shortSummary || 'Session completed', 173 - accomplishments: recovered.accomplishments || [], 174 - filesChanged: recovered.filesChanged || [], 175 - toolsUsed: recovered.toolsUsed?.length > 0 176 - ? recovered.toolsUsed 188 + shortSummary: recovered.shortSummary ?? 'Session completed', 189 + accomplishments: recovered.accomplishments ?? [], 190 + filesChanged: recovered.filesChanged ?? [], 191 + toolsUsed: (recovered.toolsUsed?.length ?? 0) > 0 192 + ? recovered.toolsUsed ?? [] 177 193 : Object.keys(session.stats.toolCalls), 178 194 }; 179 195 } ··· 201 217 202 218 // Extended schema with isNew flag (added post-LLM) 203 219 interface DailySummaryWithNew { 204 - projects: Array<{ 220 + projects: { 205 221 name: string; 206 222 summary: string; 207 223 isNew?: boolean; 208 - }>; 224 + }[]; 209 225 } 210 226 211 227 export type DailySummary = z.infer<typeof dailySummarySchema>; ··· 217 233 export async function generateDailyBragSummary( 218 234 date: string, 219 235 sessions: DBSessionSummary[], 220 - newProjectNames: Set<string> = new Set() 236 + newProjectNames = new Set<string>() 221 237 ): Promise<string> { 222 238 if (sessions.length === 0) { 223 239 return JSON.stringify({ projects: [] }); ··· 226 242 // Group accomplishments by project 227 243 const accomplishmentsByProject = new Map<string, string[]>(); 228 244 for (const session of sessions) { 229 - const project = session.project_name; 245 + const project = session.project_name ?? 'Unknown'; 230 246 if (!accomplishmentsByProject.has(project)) { 231 247 accomplishmentsByProject.set(project, []); 232 248 } 233 - try { 234 - const acc = JSON.parse(session.accomplishments || '[]'); 235 - accomplishmentsByProject.get(project)!.push(...acc); 236 - } catch {} 249 + if (session.accomplishments !== null) { 250 + try { 251 + const parsed: unknown = JSON.parse(session.accomplishments); 252 + if (Array.isArray(parsed)) { 253 + const stringItems = parsed.filter((item): item is string => typeof item === 'string'); 254 + const existing = accomplishmentsByProject.get(project); 255 + if (existing !== undefined) { 256 + existing.push(...stringItems); 257 + } 258 + } 259 + } catch { 260 + // Failed to parse accomplishments, skip this session 261 + } 262 + } 237 263 } 238 264 239 265 const projectSummaries = Array.from(accomplishmentsByProject.entries()) ··· 261 287 schema: dailySummarySchema, 262 288 system: systemPrompt, 263 289 prompt: userPrompt, 264 - maxTokens: 512, 265 - mode: 'tool', // Force tool use mode for reliable structured output 266 290 }); 267 291 268 292 // Add isNew flag to projects that are first-time appearances ··· 271 295 const result: DailySummaryWithNew = { 272 296 projects: object.projects.map((p) => ({ 273 297 ...p, 274 - isNew: isNewProject(p.name) || undefined, 298 + isNew: isNewProject(p.name) ? true : undefined, 275 299 })), 276 300 }; 277 301 ··· 283 307 const projects = Array.from(accomplishmentsByProject.keys()).map(name => ({ 284 308 name, 285 309 summary: 'Session details unavailable', 286 - isNew: newProjectNames.has(name) || undefined, 310 + isNew: newProjectNames.has(name) ? true : undefined, 287 311 })); 288 312 return JSON.stringify({ projects }); 289 313 }