log.ts
1 import { existsSync, mkdirSync } from 'fs' 2 import { dirname, join } from 'path' 3 import { writeFileSync, readFileSync } from 'fs' 4 import { captureException } from '../services/sentry.js' 5 import { randomUUID } from 'crypto' 6 import envPaths from 'env-paths' 7 import { promises as fsPromises } from 'fs' 8 import type { LogOption, SerializedMessage } from '../types/logs.js' 9 10 const IN_MEMORY_ERROR_LOG: Array<{ 11 error: string 12 timestamp: string 13 }> = [] 14 const MAX_IN_MEMORY_ERRORS = 100 // Limit to prevent memory issues 15 16 export const SESSION_ID = randomUUID() 17 18 const paths = envPaths('claude-cli') 19 20 function getProjectDir(cwd: string): string { 21 return cwd.replace(/[^a-zA-Z0-9]/g, '-') 22 } 23 24 export const CACHE_PATHS = { 25 errors: () => join(paths.cache, getProjectDir(process.cwd()), 'errors'), 26 messages: () => join(paths.cache, getProjectDir(process.cwd()), 'messages'), 27 mcpLogs: (serverName: string) => 28 join(paths.cache, getProjectDir(process.cwd()), `mcp-logs-${serverName}`), 29 } 30 31 export function dateToFilename(date: Date): string { 32 return date.toISOString().replace(/[:.]/g, '-') 33 } 34 35 const DATE = dateToFilename(new Date()) 36 37 function getErrorsPath(): string { 38 return join(CACHE_PATHS.errors(), DATE + '.txt') 39 } 40 41 export function getMessagesPath( 42 messageLogName: string, 43 forkNumber: number, 44 sidechainNumber: number, 45 ): string { 46 return join( 47 CACHE_PATHS.messages(), 48 `${messageLogName}${forkNumber > 0 ? `-${forkNumber}` : ''}${ 49 sidechainNumber > 0 ? `-sidechain-${sidechainNumber}` : '' 50 }.json`, 51 ) 52 } 53 54 export function logError(error: unknown): void { 55 try { 56 if (process.env.NODE_ENV === 'test') { 57 console.error(error) 58 } 59 60 const errorStr = 61 error instanceof Error ? error.stack || error.message : String(error) 62 63 const errorInfo = { 64 error: errorStr, 65 timestamp: new Date().toISOString(), 66 } 67 68 if (IN_MEMORY_ERROR_LOG.length >= MAX_IN_MEMORY_ERRORS) { 69 IN_MEMORY_ERROR_LOG.shift() // Remove oldest error 70 } 71 IN_MEMORY_ERROR_LOG.push(errorInfo) 72 73 appendToLog(getErrorsPath(), { 74 error: errorStr, 75 }) 76 } catch { 77 // pass 78 } 79 // Also send to Sentry with session ID, but don't await 80 captureException(error) 81 } 82 83 export function getErrorsLog(): object[] { 84 return readLog(getErrorsPath()) 85 } 86 87 export function getInMemoryErrors(): object[] { 88 return [...IN_MEMORY_ERROR_LOG] 89 } 90 91 function readLog(path: string): object[] { 92 if (!existsSync(path)) { 93 return [] 94 } 95 try { 96 return JSON.parse(readFileSync(path, 'utf8')) 97 } catch { 98 return [] 99 } 100 } 101 102 function appendToLog(path: string, message: object): void { 103 if (process.env.USER_TYPE === 'external') { 104 return 105 } 106 107 const dir = dirname(path) 108 if (!existsSync(dir)) { 109 mkdirSync(dir, { recursive: true }) 110 } 111 112 // Create messages file with empty array if it doesn't exist 113 if (!existsSync(path)) { 114 writeFileSync(path, '[]', 'utf8') 115 } 116 117 const messages = readLog(path) 118 const messageWithTimestamp = { 119 ...message, 120 cwd: process.cwd(), 121 userType: process.env.USER_TYPE, 122 sessionId: SESSION_ID, 123 timestamp: new Date().toISOString(), 124 version: MACRO.VERSION, 125 } 126 messages.push(messageWithTimestamp) 127 128 writeFileSync(path, JSON.stringify(messages, null, 2), 'utf8') 129 } 130 131 export function overwriteLog(path: string, messages: object[]): void { 132 if (process.env.USER_TYPE === 'external') { 133 return 134 } 135 136 if (!messages.length) { 137 return 138 } 139 140 const dir = dirname(path) 141 if (!existsSync(dir)) { 142 mkdirSync(dir, { recursive: true }) 143 } 144 145 const messagesWithMetadata = messages.map(message => ({ 146 ...message, 147 cwd: process.cwd(), 148 userType: process.env.USER_TYPE, 149 sessionId: SESSION_ID, 150 timestamp: new Date().toISOString(), 151 version: MACRO.VERSION, 152 })) 153 154 writeFileSync(path, JSON.stringify(messagesWithMetadata, null, 2), 'utf8') 155 } 156 157 export async function loadLogList( 158 path = CACHE_PATHS.messages(), 159 ): Promise<LogOption[]> { 160 if (!existsSync(path)) { 161 logError(`No logs found at ${path}`) 162 return [] 163 } 164 165 const files = await fsPromises.readdir(path) 166 const logData = await Promise.all( 167 files.map(async (file, i) => { 168 const fullPath = join(path, file) 169 const content = await fsPromises.readFile(fullPath, 'utf8') 170 const messages = JSON.parse(content) as SerializedMessage[] 171 const firstMessage = messages[0] 172 const lastMessage = messages[messages.length - 1] 173 const firstPrompt = 174 firstMessage?.type === 'user' && 175 typeof firstMessage?.message?.content === 'string' 176 ? firstMessage?.message?.content 177 : 'No prompt' 178 179 const { date, forkNumber, sidechainNumber } = parseLogFilename(file) 180 return { 181 date, 182 forkNumber, 183 fullPath, 184 messages, 185 value: i, // hack: overwritten after sorting, right below this 186 created: parseISOString(firstMessage?.timestamp || date), 187 modified: lastMessage?.timestamp 188 ? parseISOString(lastMessage.timestamp) 189 : parseISOString(date), 190 firstPrompt: 191 firstPrompt.split('\n')[0]?.slice(0, 50) + 192 (firstPrompt.length > 50 ? '…' : '') || 'No prompt', 193 messageCount: messages.length, 194 sidechainNumber, 195 } 196 }), 197 ) 198 199 return sortLogs(logData.filter(_ => _.messages.length)).map((_, i) => ({ 200 ..._, 201 value: i, 202 })) 203 } 204 205 export function parseLogFilename(filename: string): { 206 date: string 207 forkNumber: number | undefined 208 sidechainNumber: number | undefined 209 } { 210 const base = filename.split('.')[0]! 211 // Default timestamp format has 6 segments: 2025-01-27T01-31-35-104Z 212 const segments = base.split('-') 213 const hasSidechain = base.includes('-sidechain-') 214 215 let date = base 216 let forkNumber: number | undefined = undefined 217 let sidechainNumber: number | undefined = undefined 218 219 if (hasSidechain) { 220 const sidechainIndex = segments.indexOf('sidechain') 221 sidechainNumber = Number(segments[sidechainIndex + 1]) 222 // Fork number is before sidechain if exists 223 if (sidechainIndex > 6) { 224 forkNumber = Number(segments[sidechainIndex - 1]) 225 date = segments.slice(0, 6).join('-') 226 } else { 227 date = segments.slice(0, 6).join('-') 228 } 229 } else if (segments.length > 6) { 230 // Has fork number 231 const lastSegment = Number(segments[segments.length - 1]) 232 forkNumber = lastSegment >= 0 ? lastSegment : undefined 233 date = segments.slice(0, 6).join('-') 234 } else { 235 // Basic timestamp only 236 date = base 237 } 238 239 return { date, forkNumber, sidechainNumber } 240 } 241 242 export function getNextAvailableLogForkNumber( 243 date: string, 244 forkNumber: number, 245 // Main chain has sidechainNumber 0 246 sidechainNumber: number, 247 ): number { 248 while (existsSync(getMessagesPath(date, forkNumber, sidechainNumber))) { 249 forkNumber++ 250 } 251 return forkNumber 252 } 253 254 export function getNextAvailableLogSidechainNumber( 255 date: string, 256 forkNumber: number, 257 ): number { 258 let sidechainNumber = 1 259 while (existsSync(getMessagesPath(date, forkNumber, sidechainNumber))) { 260 sidechainNumber++ 261 } 262 return sidechainNumber 263 } 264 265 export function getForkNumberFromFilename( 266 filename: string, 267 ): number | undefined { 268 const base = filename.split('.')[0]! 269 const segments = base.split('-') 270 const hasSidechain = base.includes('-sidechain-') 271 272 if (hasSidechain) { 273 const sidechainIndex = segments.indexOf('sidechain') 274 if (sidechainIndex > 6) { 275 return Number(segments[sidechainIndex - 1]) 276 } 277 return undefined 278 } 279 280 if (segments.length > 6) { 281 const lastNumber = Number(segments[segments.length - 1]) 282 return lastNumber >= 0 ? lastNumber : undefined 283 } 284 return undefined 285 } 286 287 export function sortLogs(logs: LogOption[]): LogOption[] { 288 return logs.sort((a, b) => { 289 // Sort by modified date (newest first) 290 const modifiedDiff = b.modified.getTime() - a.modified.getTime() 291 if (modifiedDiff !== 0) { 292 return modifiedDiff 293 } 294 295 // If modified dates are equal, sort by created date 296 const createdDiff = b.created.getTime() - a.created.getTime() 297 if (createdDiff !== 0) { 298 return createdDiff 299 } 300 301 // If both dates are equal, sort by fork number 302 return (b.forkNumber ?? 0) - (a.forkNumber ?? 0) 303 }) 304 } 305 306 export function formatDate(date: Date): string { 307 const now = new Date() 308 const yesterday = new Date(now) 309 yesterday.setDate(yesterday.getDate() - 1) 310 311 const isToday = date.toDateString() === now.toDateString() 312 const isYesterday = date.toDateString() === yesterday.toDateString() 313 314 const timeStr = date 315 .toLocaleTimeString('en-US', { 316 hour: 'numeric', 317 minute: '2-digit', 318 hour12: true, 319 }) 320 .toLowerCase() 321 322 if (isToday) { 323 return `Today at ${timeStr}` 324 } else if (isYesterday) { 325 return `Yesterday at ${timeStr}` 326 } else { 327 return ( 328 date.toLocaleDateString('en-US', { 329 month: 'short', 330 day: 'numeric', 331 }) + ` at ${timeStr}` 332 ) 333 } 334 } 335 336 export function parseISOString(s: string): Date { 337 const b = s.split(/\D+/) 338 return new Date( 339 Date.UTC( 340 parseInt(b[0]!, 10), 341 parseInt(b[1]!, 10) - 1, 342 parseInt(b[2]!, 10), 343 parseInt(b[3]!, 10), 344 parseInt(b[4]!, 10), 345 parseInt(b[5]!, 10), 346 parseInt(b[6]!, 10), 347 ), 348 ) 349 } 350 351 export function logMCPError(serverName: string, error: unknown): void { 352 try { 353 const logDir = CACHE_PATHS.mcpLogs(serverName) 354 const errorStr = 355 error instanceof Error ? error.stack || error.message : String(error) 356 const timestamp = new Date().toISOString() 357 358 const logFile = join(logDir, DATE + '.txt') 359 360 if (!existsSync(logDir)) { 361 mkdirSync(logDir, { recursive: true }) 362 } 363 364 if (!existsSync(logFile)) { 365 writeFileSync(logFile, '[]', 'utf8') 366 } 367 368 const errorInfo = { 369 error: errorStr, 370 timestamp, 371 sessionId: SESSION_ID, 372 cwd: process.cwd(), 373 } 374 375 const messages = readLog(logFile) 376 messages.push(errorInfo) 377 writeFileSync(logFile, JSON.stringify(messages, null, 2), 'utf8') 378 } catch { 379 // Silently fail 380 } 381 }