/** * Metadata extraction utilities for parsing first messages and session context from JSONL files. */ import { isCommandOutputContent, sanitizeDisplayContent } from '@shared/utils/contentSanitizer'; import { createLogger } from '@shared/utils/logger'; import * as readline from 'readline'; import { LocalFileSystemProvider } from '../services/infrastructure/LocalFileSystemProvider'; import { type ChatHistoryEntry, isTextContent, type UserEntry } from '../types'; import type { FileSystemProvider } from '../services/infrastructure/FileSystemProvider'; const logger = createLogger('Util:metadataExtraction'); const defaultProvider = new LocalFileSystemProvider(); interface MessagePreview { text: string; timestamp: string; isCommand: boolean; } /** * Extract CWD (current working directory) from the first entry. * Used to get the actual project path from encoded directory names. */ export async function extractCwd( filePath: string, fsProvider: FileSystemProvider = defaultProvider ): Promise { if (!(await fsProvider.exists(filePath))) { return null; } const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity, }); try { for await (const line of rl) { if (!line.trim()) continue; const entry = JSON.parse(line) as ChatHistoryEntry; // Only conversational entries have cwd if ('cwd' in entry && entry.cwd) { rl.close(); fileStream.destroy(); return entry.cwd; } } } catch (error) { logger.error(`Error extracting cwd from ${filePath}:`, error); } finally { rl.close(); fileStream.destroy(); } return null; } /** * Extract a lightweight title preview from the first user message. * For command-style sessions, falls back to a slash-command label. */ export async function extractFirstUserMessagePreview( filePath: string, fsProvider: FileSystemProvider = defaultProvider, maxLines: number = 200 ): Promise<{ text: string; timestamp: string } | null> { const safeMaxLines = Math.max(1, maxLines); const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity, }); let commandFallback: { text: string; timestamp: string } | null = null; let linesRead = 0; try { for await (const line of rl) { if (linesRead++ >= safeMaxLines) { break; } const trimmed = line.trim(); if (!trimmed) continue; let entry: ChatHistoryEntry; try { entry = JSON.parse(trimmed) as ChatHistoryEntry; } catch { continue; } if (entry.type !== 'user') { continue; } const preview = extractPreviewFromUserEntry(entry); if (!preview) { continue; } if (!preview.isCommand) { return { text: preview.text, timestamp: preview.timestamp }; } if (!commandFallback) { commandFallback = { text: preview.text, timestamp: preview.timestamp }; } } } catch (error) { logger.debug(`Error extracting first user preview from ${filePath}:`, error); throw error; } finally { rl.close(); fileStream.destroy(); } return commandFallback; } function extractPreviewFromUserEntry(entry: UserEntry): MessagePreview | null { const timestamp = entry.timestamp ?? new Date().toISOString(); const message = entry.message; if (!message) { return null; } const content = message.content; if (typeof content === 'string') { if (isCommandOutputContent(content) || content.startsWith('[Request interrupted by user')) { return null; } if (content.startsWith('')) { return { text: extractCommandName(content), timestamp, isCommand: true, }; } const sanitized = sanitizeDisplayContent(content).trim(); if (!sanitized) { return null; } return { text: sanitized.substring(0, 500), timestamp, isCommand: false, }; } if (!Array.isArray(content)) { return null; } const textContent = content .filter(isTextContent) .map((block) => block.text) .join(' ') .trim(); if (!textContent || textContent.startsWith('[Request interrupted by user')) { return null; } if (textContent.startsWith('')) { return { text: extractCommandName(textContent), timestamp, isCommand: true, }; } const sanitized = sanitizeDisplayContent(textContent).trim(); if (!sanitized) { return null; } return { text: sanitized.substring(0, 500), timestamp, isCommand: false, }; } function extractCommandName(content: string): string { const commandMatch = /\/([^<]+)<\/command-name>/.exec(content); return commandMatch ? `/${commandMatch[1]}` : '/command'; }