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

318 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
compactMetadata?: {
preservedSegment?: unknown
[key: string]: 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)
// 只缓存找到的路径,不缓存 null文件可能还未写入
if (cached !== undefined && cached !== null) return cached
const result = await resolveTranscriptPathUncached(sessionId, cwd)
if (result !== null) {
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,
})
}
let sliced: SessionMessage[]
if (cursorIndex >= 0) {
sliced = allMessages.slice(cursorIndex + 1)
} else if (opts.limit) {
sliced = allMessages.slice(-opts.limit)
} else {
sliced = allMessages
}
const nextCursor =
opts.limit && allMessages.length > sliced.length
? sliced[0]?.uuid
: undefined
return { messages: sliced, 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()
}