/** * Raw Dump Session Utilities * 与 session 相关的工具函数,从 worker.ts 和 batchWorker.ts 中剥离 */ import { promises as fs } from 'node:fs' import os from 'node:os' import path from 'node:path' import { createLogger } from './logger.js' import { countDiffLines, extractFilesFromDiff } from './git.js' const log = createLogger('raw-dump-session') /** * 获取 CoStrict 配置目录(~/.claude),支持环境变量 CLAUDE_CONFIG_HOME 覆盖 */ export function getClaudeConfigHomeDir(): string { return process.env.CLAUDE_CONFIG_HOME || path.join(os.homedir(), '.claude') } /** * 将目录路径规范化为安全的车间目录名 * 将路径中的 / 替换为 -,避免路径分隔符在文件系统操作中引发问题 * 如 /Users/linkai/code/csc → -Users-linkai-code-csc */ export function normalizeProjectPath(dir: string): string { return dir.replace(/:/, '-').replace(/[/\\]/g, '-') } /** * 解析 session 文件所在目录 * 按优先级尝试多个候选路径,返回第一个存在的目录: * 1. ~/.claude/projects/{normalized-path}/ * 2. ~/.claude/transcripts/ * 3. ~/.claude/sessions/ * 4. {directory}/.claude/sessions/ * 5. {directory}/.claude/ * 6. {directory} 本身 * 7. 环境变量 CSC_SESSION_DIR 指定路径 */ export function getSessionDirectory( directory: string, sessionID: string, ): string { const claudeHome = getClaudeConfigHomeDir() const projectPath = normalizeProjectPath(directory) const candidates = [ path.join(claudeHome, 'projects', projectPath), path.join(claudeHome, 'transcripts'), path.join(claudeHome, 'sessions'), path.join(directory, '.claude', 'sessions'), path.join(directory, '.claude'), directory, process.env.CSC_SESSION_DIR || '', ] return candidates.find(d => d) || directory } /** * 从 JSONL 文件加载会话消息列表 * - 扫描 sessionDir 目录下所有 .jsonl 文件 * - 优先读取文件名包含 sessionId 的文件(减少无意义解析) * - 通过 sessionId、messageId 匹配目标记录 * - csc 的会话文件名可能是 ses_{hash}.jsonl 或 {uuid}.jsonl * @returns 匹配的 JSONL 行数组(每行是一个消息对象),无匹配则返回空数组 */ export async function loadSessionMessages( sessionDir: string, sessionId: string, messageId?: string, ) { try { const entries = await fs.readdir(sessionDir) const jsonlFiles = entries.filter(f => f.endsWith('.jsonl')) log.debug('found jsonl files', { sessionDir, count: jsonlFiles.length, files: jsonlFiles.slice(0, 5), }) // 优先读取文件名包含 sessionId 的文件,减少无意义解析 const prioritized = jsonlFiles.sort((a, b) => { const aHas = a.includes(sessionId) const bHas = b.includes(sessionId) if (aHas && !bHas) return -1 if (!aHas && bHas) return 1 return 0 }) for (const file of prioritized) { const filePath = path.join(sessionDir, file) try { const text = await fs.readFile(filePath, 'utf-8') const lines = text .split('\n') .filter(Boolean) .map(line => { try { return JSON.parse(line) } catch { return null } }) .filter((m): m is Record => m !== null) // 检查是否包含目标 sessionId 或 messageId const hasSession = lines.some( m => m.sessionId === sessionId || m.session_id === sessionId ) const hasMessage = messageId ? lines.some( m => m.uuid === messageId, ) : false if (hasSession || hasMessage) { log.debug('loaded messages from file', { file, count: lines.length, hasSession, hasMessage, }) return lines } } catch { // ignore per-file errors } } } catch { // ignore dir read errors } return [] } /** * 在消息列表中查找指定 ID 的消息 * 支持通过 message.uuid 或 message.id 匹配 */ export function findMessage( messages: Record[], messageID: string, ): Record | undefined { return messages.find( m => m.uuid === messageID || (m.message as Record)?.id === messageID, ) } /** * 查找 assistant message 对应的父级 user message * 在 csc 中,user message 通常紧邻 assistant message 之前 * 从 assistant 位置向前遍历,找到第一个 type === 'user' 的消息 */ export function findParentUserMessage( messages: Record[], assistantMsg: Record, ): Record | undefined { const assistantIndex = messages.indexOf(assistantMsg) if (assistantIndex <= 0) return undefined for (let i = assistantIndex - 1; i >= 0; i--) { if (messages[i]?.type === 'user') return messages[i] } return undefined } /** * 判断本轮对话的发送者类型:agent 或 user * 通过 assistant 的 mode、agent 字段、isSidechain 标记、user 的 isMeta 标记综合判断 */ export function detectSender( assistant: Record, user: Record | undefined, ): string { const mode = String(assistant.mode ?? '') if (mode === 'agent' || mode === 'auto') return 'agent' if (assistant.agent) return 'agent' if (assistant.isSidechain === true) return 'agent' if (user?.isMeta === true) return 'agent' return 'user' } /** * 从消息对象中提取纯文本内容 * 兼容多种格式:直接 content 字符串,或 content 数组(block type === 'text') */ export function extractTextContent(msg: Record): string { const content = (msg.message as Record)?.content if (!Array.isArray(content)) return String(content ?? '') return content .filter((block): block is Record => block?.type === 'text') .map(block => String(block.text ?? '')) .join('\n') } /** * 将 structured patch 格式转换为 unified diff 格式 */ function structuredPatchToUnifiedDiff( filePath: string, patches: Array>, ): string { if (!patches.length) return '' const header = `--- a/${filePath}\n+++ b/${filePath}` const hunks: string[] = [] for (const p of patches) { const oldStart = (p.oldStart as number) ?? 1 const oldLines = (p.oldLines as number) ?? 0 const newStart = (p.newStart as number) ?? 1 const newLines = (p.newLines as number) ?? 0 const lines = (p.lines as string[]) ?? [] hunks.push( `@@ -${oldStart},${oldLines} +${newStart},${newLines} @@\n${lines.join('\n')}`, ) } return header + '\n' + hunks.join('\n') + '\n' } /** * 根据旧字符串和新字符串生成 unified diff */ function generateStringDiff( filePath: string, oldStr: string, newStr: string, ): string { const oldLines = oldStr.split('\n') const newLines = newStr.split('\n') const header = `--- a/${filePath}\n+++ b/${filePath}` const hunk = `@@ -1,${oldLines.length} +1,${newLines.length} @@` const body = oldLines .map(l => `-${l}`) .concat(newLines.map(l => `+${l}`)) .join('\n') return header + '\n' + hunk + '\n' + body + '\n' } /** * 从 assistant message 中提取 unified diff * 优先从子 user message 的 toolUseResult 获取 gitDiff.patch(已格式化的 unified diff) * fallback 到 structuredPatch 转换或从 tool_use input 参数生成 diff */ export function extractToolDiff( msg: Record, allMessages?: Record[], ): { diff: string; diff_lines: number; files: string[] } { const diffs: string[] = [] const files = new Set() const handledToolUseIds = new Set() const msgUuid = msg.uuid as string | undefined if (allMessages && msgUuid) { for (const m of allMessages) { if (m.parentUuid !== msgUuid || m.type !== 'user') continue const tur = (m.toolUseResult ?? m.tool_use_result) as | Record | undefined if (!tur) continue const filePath = (tur.filePath as string | undefined) ?? (tur.file_path as string | undefined) ?? (tur.notebook_path as string | undefined) ?? '' const gitDiff = tur.gitDiff as Record | undefined if ( gitDiff?.patch && typeof gitDiff.patch === 'string' && gitDiff.patch ) { diffs.push(gitDiff.patch) if (gitDiff.filename && typeof gitDiff.filename === 'string') files.add(gitDiff.filename) else if (filePath) files.add(filePath) const mContent = (m.message as Record)?.content if (Array.isArray(mContent)) { for (const b of mContent) { if ( b?.type === 'tool_result' && typeof b.tool_use_id === 'string' ) { handledToolUseIds.add(b.tool_use_id) } } } continue } const sp = tur.structuredPatch as | Array> | undefined if (Array.isArray(sp) && sp.length > 0 && filePath) { diffs.push(structuredPatchToUnifiedDiff(filePath, sp)) files.add(filePath) const mContent = (m.message as Record)?.content if (Array.isArray(mContent)) { for (const b of mContent) { if ( b?.type === 'tool_result' && typeof b.tool_use_id === 'string' ) { handledToolUseIds.add(b.tool_use_id) } } } continue } const oldStr = (tur.oldString as string | undefined) ?? (tur.old_string as string | undefined) ?? (tur.originalFile as string | undefined) ?? (tur.original_file as string | undefined) const newStr = (tur.newString as string | undefined) ?? (tur.new_string as string | undefined) ?? (tur.content as string | undefined) ?? (tur.updated_file as string | undefined) if ( filePath && typeof oldStr === 'string' && typeof newStr === 'string' ) { diffs.push(generateStringDiff(filePath, oldStr, newStr)) files.add(filePath) } else if (filePath && typeof newStr === 'string') { diffs.push(generateStringDiff(filePath, '', newStr)) files.add(filePath) } } } const content = (msg.message as Record)?.content if (Array.isArray(content)) { for (const block of content) { if (block?.type !== 'tool_use') continue if (typeof block.id === 'string' && handledToolUseIds.has(block.id)) continue const input = block.input as Record | undefined const toolName = block.name as string | undefined const filePath = (input?.file_path as string | undefined) ?? (input?.notebook_path as string | undefined) ?? '' if ( toolName === 'Edit' && typeof input?.old_string === 'string' && typeof input?.new_string === 'string' ) { diffs.push( generateStringDiff(filePath, input.old_string, input.new_string), ) if (filePath) files.add(filePath) } else if ( toolName === 'NotebookEdit' && typeof input?.new_source === 'string' && filePath ) { diffs.push(generateStringDiff(filePath, '', input.new_source)) files.add(filePath) } else if (typeof input?.diff === 'string' && input.diff) { diffs.push(input.diff) if (filePath) files.add(filePath) } else if (typeof input?.patch === 'string' && input.patch) { diffs.push(input.patch) if (filePath) files.add(filePath) } } } const diff = diffs.join('\n') for (const file of extractFilesFromDiff(diff)) files.add(file) return { diff, diff_lines: countDiffLines(diff), files: Array.from(files) } } /** * 从 assistant message 中提取 token 使用量 */ export function extractUsage(msg: Record) { const usage = (msg.message as Record)?.usage as | Record | undefined return { input_tokens: usage?.input_tokens ?? 0, output_tokens: usage?.output_tokens ?? 0, cache_read_input_tokens: usage?.cache_read_input_tokens ?? 0, cache_creation_input_tokens: usage?.cache_creation_input_tokens ?? 0, } } const SDK_ERROR_CODE_MAP: Record = { authentication_failed: 401, billing_error: 402, rate_limit: 429, invalid_request: 400, server_error: 500, max_output_tokens: 413, unknown: 500, } /** * 从 assistant message 中提取错误信息 */ export function extractError(msg: Record): { error_code?: number error_reason?: string } { const error = msg.error if (typeof error === 'string') { const errorCode = SDK_ERROR_CODE_MAP[error] ?? 500 const errorDetails = msg.errorDetails const reason = typeof errorDetails === 'string' && errorDetails ? errorDetails : error return { error_code: errorCode, error_reason: reason } } if (typeof error === 'object' && error !== null) { const err = error as Record const apiError = msg.apiError if (typeof apiError === 'string' && apiError === 'max_output_tokens') { const reason = typeof err.message === 'string' ? err.message : 'max_output_tokens' return { error_code: 413, error_reason: reason } } const name = String(err.name ?? 'UnknownError') const errMessage = typeof err.message === 'string' ? err.message : name const errorCode = name === 'ProviderAuthError' ? 401 : name === 'ContextOverflowError' || name === 'MessageOutputLengthError' ? 413 : name === 'MessageAbortedError' ? 499 : name === 'APIError' && typeof err.statusCode === 'number' ? err.statusCode : 500 return { error_code: errorCode, error_reason: errMessage } } if (msg.isApiErrorMessage) { return { error_code: 500, error_reason: 'api_error_unclassified' } } return {} } /** * 从 sessionDir 目录中获取最新的 sessionId 和 messageId * 扫描所有 .jsonl 文件,按 timestamp 降序排列,返回最新一条消息的 sessionId 和 messageId */ export async function getLatestSessionInfo( sessionDir: string, ): Promise<{ sessionId: string; messageId: string } | null> { try { const entries = await fs.readdir(sessionDir) const jsonlFiles = entries.filter(f => f.endsWith('.jsonl')) if (jsonlFiles.length === 0) return null let latestMsg: { sessionId: string; messageId: string; ts: number } | null = null for (const file of jsonlFiles) { const filePath = path.join(sessionDir, file) try { const text = await fs.readFile(filePath, 'utf-8') const lines = text.split('\n').filter(Boolean) for (const line of lines) { try { const msg = JSON.parse(line) as Record const ts = (msg.timestamp as number) || 0 if (!latestMsg || ts > latestMsg.ts) { const sessionId = (msg.sessionId as string) || (msg.session_id as string) || (msg.uuid as string) || '' const messageId = (msg.uuid as string) || ((msg.message as Record)?.id as string) || '' if (sessionId && messageId) { latestMsg = { sessionId, messageId, ts } } } } catch { // ignore parse errors } } } catch { // ignore file read errors } } if (latestMsg) { log.debug('found latest session info', { sessionId: latestMsg.sessionId, messageId: latestMsg.messageId, ts: latestMsg.ts, }) return { sessionId: latestMsg.sessionId, messageId: latestMsg.messageId } } } catch { // ignore dir read errors } return null } // Session messages 缓存(batchWorker 使用) // 缓存结构: [sessionJsonlFileName, file-timestamp, cache-timestamp, messages] type SessionMessagesCacheEntry = [ sessionJsonlFileName: string, fileTimestamp: number, cacheTimestamp: number, messages: Record[], ] const sessionMessagesCache = new Map() /** * 获取缓存的 session messages * 缓存 key: sessionDir:sessionId * 缓存值: [sessionJsonlFileName, file-timestamp, cache-timestamp, messages] * 当文件相比缓存时有更新(只要变化就认为有更新),则重新加载 */ export async function getCachedSessionMessages( sessionDir: string, sessionID: string, messageID?: string, ) { const cacheKey = `${sessionDir}:${sessionID}` const cached = sessionMessagesCache.get(cacheKey) // 尝试找到当前 session 文件 const findCurrentFile = async (): Promise<{ fileName: string; filePath: string } | null> => { try { const entries = await fs.readdir(sessionDir) const jsonlFiles = entries.filter(f => f.endsWith('.jsonl')) const prioritized = jsonlFiles.sort((a, b) => { const aHas = a.includes(sessionID) const bHas = b.includes(sessionID) if (aHas && !bHas) return -1 if (!aHas && bHas) return 1 return 0 }) for (const file of prioritized) { const filePath = path.join(sessionDir, file) const text = await fs.readFile(filePath, 'utf-8') const lines = text.split('\n').filter(Boolean) const hasSession = lines.some(m => { const msg = JSON.parse(m) as Record return msg.sessionId === sessionID || msg.session_id === sessionID || msg.uuid === sessionID }) if (hasSession) { return { fileName: file, filePath } } } } catch {} return null } if (cached) { const [cachedFileName, cachedFileTs] = cached const currentFile = await findCurrentFile() if (currentFile && currentFile.fileName === cachedFileName) { // 文件名匹配,检查文件是否更新 try { const stats = await fs.stat(path.join(sessionDir, cachedFileName)) const currentFileTs = stats.mtimeMs if (currentFileTs === cachedFileTs) { // 文件未变化,直接使用缓存 log.debug('using cached session messages', { sessionID, cachedFileName }) return cached[3] } } catch {} } // 文件有更新或文件名不匹配,需要重新加载 log.debug('file changed, reloading session messages', { sessionID }) } // 未找到缓存或文件已更新,搜索 session 文件 const currentFile = await findCurrentFile() if (currentFile) { const start = Date.now() const messages = await loadSessionMessages(sessionDir, sessionID, messageID) const elapsed = Date.now() - start if (elapsed > 100) { log.info('loadSessionMessages slow', { sessionID, elapsedMs: elapsed }) } try { const stats = await fs.stat(path.join(sessionDir, currentFile.fileName)) sessionMessagesCache.set(cacheKey, [ currentFile.fileName, stats.mtimeMs, Date.now(), messages, ]) } catch {} return messages } // 文件不存在,缓存空结果 sessionMessagesCache.set(cacheKey, ['', 0, Date.now(), []]) return [] }