/ src / utils / log.ts
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  }