claude-code-best/src/server/transcriptReader.ts

306 lines
7.7 KiB
TypeScript

import { readFile, readdir, stat } from 'fs/promises'
import { existsSync } from 'fs'
import { join } from 'path'
import {
getProjectsDir,
getProjectDir,
findProjectDir,
} from '../utils/sessionStoragePortable.js'
import { safeParseJSON } from '../utils/json.js'
type TranscriptEntry = {
type: string
uuid?: string
parentUuid?: string
sessionId?: string
timestamp?: string
role?: string
subtype?: string
message?: {
role?: string
content?: unknown
}
content?: unknown
[key: string]: unknown
}
export type SessionMessage = {
uuid: string
type: string
role: string
content: unknown
timestamp: number
parent_uuid: string | null
usage?: { input_tokens: number; output_tokens: number }
}
export type TodoItem = {
id: string
content: string
status: string
priority: string
}
const MESSAGE_TYPES = new Set(['user', 'assistant'])
const SKIP_TYPES = new Set([
'summary',
'tag',
'mode',
'agent-setting',
'pr-link',
'customTitle',
'file-history-snapshot',
'attribution-snapshot',
'context-collapse-commit',
'context-collapse-snapshot',
])
const pathCache = new Map<string, string | null>()
async function resolveTranscriptPath(
sessionId: string,
cwd?: string,
): Promise<string | null> {
const cacheKey = `${sessionId}:${cwd ?? ''}`
const cached = pathCache.get(cacheKey)
if (cached !== undefined) return cached
const result = await resolveTranscriptPathUncached(sessionId, cwd)
pathCache.set(cacheKey, result)
return result
}
async function resolveTranscriptPathUncached(
sessionId: string,
cwd?: string,
): Promise<string | null> {
if (cwd) {
const projectDir = getProjectDir(cwd)
const direct = join(projectDir, `${sessionId}.jsonl`)
if (existsSync(direct)) return direct
}
const found = await findProjectDir(cwd ?? process.cwd())
if (found) {
const direct = join(found, `${sessionId}.jsonl`)
if (existsSync(direct)) return direct
}
const projectsDir = getProjectsDir()
const target = `${sessionId}.jsonl`
try {
const dirs = await readdir(projectsDir)
for (const dir of dirs) {
const candidate = join(projectsDir, dir, target)
if (existsSync(candidate)) return candidate
}
} catch {}
return null
}
function parseEntry(line: string): TranscriptEntry | null {
const parsed = safeParseJSON(line, false)
if (!parsed || typeof parsed !== 'object') return null
return parsed as TranscriptEntry
}
export async function readSessionMessages(opts: {
sessionId: string
cwd?: string
limit?: number
before?: string
includeSystem?: boolean
}): Promise<{
messages: SessionMessage[]
nextCursor?: string
}> {
const path = await resolveTranscriptPath(opts.sessionId, opts.cwd)
if (!path || !existsSync(path)) {
return { messages: [] }
}
const raw = await readFile(path, 'utf-8')
const lines = raw.split('\n').filter(Boolean)
let lastCompactBoundaryIndex = -1
for (let i = lines.length - 1; i >= 0; i--) {
const entry = parseEntry(lines[i])
if (
entry?.type === 'system' &&
entry?.subtype === 'compact_boundary' &&
!entry?.compactMetadata?.preservedSegment
) {
lastCompactBoundaryIndex = i
break
}
}
const startLine = lastCompactBoundaryIndex >= 0 ? lastCompactBoundaryIndex + 1 : 0
const allMessages: SessionMessage[] = []
let cursorIndex = -1
for (let i = startLine; i < lines.length; i++) {
const entry = parseEntry(lines[i])
if (!entry) continue
if (!entry.type || !entry.uuid) continue
if (SKIP_TYPES.has(entry.type)) continue
if (entry.type === 'attribution-snapshot') continue
const includeThis =
MESSAGE_TYPES.has(entry.type) ||
(opts.includeSystem && entry.type === 'system')
if (!includeThis) continue
if (entry.uuid === opts.before) {
cursorIndex = allMessages.length
}
const role = entry.role ?? entry.message?.role ?? entry.type
const content = entry.message?.content ?? entry.content ?? ''
const timestamp = entry.timestamp
? new Date(entry.timestamp).getTime()
: 0
allMessages.push({
uuid: entry.uuid,
type: entry.type,
role: role ?? entry.type,
content,
timestamp,
parent_uuid: entry.parentUuid ?? null,
usage: entry.usage as
| { input_tokens: number; output_tokens: number }
| undefined,
})
}
const startIndex = cursorIndex >= 0 ? cursorIndex + 1 : 0
const sliced = allMessages.slice(startIndex)
const limited = opts.limit ? sliced.slice(0, opts.limit) : sliced
const nextCursor =
opts.limit && sliced.length > opts.limit
? sliced[opts.limit]?.uuid
: undefined
return { messages: limited, nextCursor }
}
export async function readSessionTodos(opts: {
sessionId: string
cwd?: string
}): Promise<TodoItem[]> {
const path = await resolveTranscriptPath(opts.sessionId, opts.cwd)
if (!path || !existsSync(path)) return []
const raw = await readFile(path, 'utf-8')
const lines = raw.split('\n').filter(Boolean)
let latestTodos: TodoItem[] = []
for (const line of lines) {
const entry = parseEntry(line)
if (!entry) continue
if (entry.type === 'assistant' && entry.message?.content) {
const content = entry.message.content
if (!Array.isArray(content)) continue
for (const block of content) {
if (!block || typeof block !== 'object') continue
const b = block as Record<string, unknown>
if (b.type === 'tool_use' && (b.name === 'TodoWrite' || b.name === 'TaskCreateTool')) {
const input = b.input as
| { todos?: Array<{ id: string; content: string; status: string; priority: string }> }
| undefined
if (input?.todos) {
latestTodos = input.todos
}
}
}
}
}
return latestTodos
}
export async function readSessionDiff(opts: {
sessionId: string
cwd?: string
messageID?: string
}): Promise<{
diffs: Array<{
file: string
status: string
additions: number
deletions: number
patch: string
}>
}> {
const { execSync } = await import('child_process')
const cwd = opts.cwd ?? process.cwd()
try {
const diffOutput = execSync('git diff HEAD', {
encoding: 'utf-8',
timeout: 10000,
cwd,
})
if (!diffOutput.trim()) return { diffs: [] }
const files = new Map<
string,
{ additions: number; deletions: number; patch: string }
>()
let currentFile = ''
let currentPatch: string[] = []
for (const line of diffOutput.split('\n')) {
const match = /^diff --git a\/(.+?) b\/(.+?)$/.exec(line)
if (match) {
if (currentFile && currentPatch.length > 0) {
files.set(currentFile, {
additions: currentPatch.filter(l => l.startsWith('+') && !l.startsWith('+++')).length,
deletions: currentPatch.filter(l => l.startsWith('-') && !l.startsWith('---')).length,
patch: currentPatch.join('\n'),
})
}
currentFile = match[2]
currentPatch = [line]
} else {
currentPatch.push(line)
}
}
if (currentFile && currentPatch.length > 0) {
files.set(currentFile, {
additions: currentPatch.filter(l => l.startsWith('+') && !l.startsWith('+++')).length,
deletions: currentPatch.filter(l => l.startsWith('-') && !l.startsWith('---')).length,
patch: currentPatch.join('\n'),
})
}
return {
diffs: [...files.entries()].map(([file, info]) => ({
file,
status:
info.additions > 0 && info.deletions > 0
? 'modified'
: info.additions > 0
? 'added'
: 'removed',
...info,
})),
}
} catch {
return { diffs: [] }
}
}
export function clearPathCache(): void {
pathCache.clear()
}