source dump of claude code
at main 226 lines 7.3 kB view raw
1import type { ClientOptions } from '@anthropic-ai/sdk' 2import { createHash } from 'crypto' 3import { promises as fs } from 'fs' 4import { dirname, join } from 'path' 5import { getSessionId } from 'src/bootstrap/state.js' 6import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' 7import { jsonParse, jsonStringify } from '../../utils/slowOperations.js' 8 9function hashString(str: string): string { 10 return createHash('sha256').update(str).digest('hex') 11} 12 13// Cache last few API requests for ant users (e.g., for /issue command) 14const MAX_CACHED_REQUESTS = 5 15const cachedApiRequests: Array<{ timestamp: string; request: unknown }> = [] 16 17type DumpState = { 18 initialized: boolean 19 messageCountSeen: number 20 lastInitDataHash: string 21 // Cheap proxy for change detection — skips the expensive stringify+hash 22 // when model/tools/system are structurally identical to the last call. 23 lastInitFingerprint: string 24} 25 26// Track state per session to avoid duplicating data 27const dumpState = new Map<string, DumpState>() 28 29export function getLastApiRequests(): Array<{ 30 timestamp: string 31 request: unknown 32}> { 33 return [...cachedApiRequests] 34} 35 36export function clearApiRequestCache(): void { 37 cachedApiRequests.length = 0 38} 39 40export function clearDumpState(agentIdOrSessionId: string): void { 41 dumpState.delete(agentIdOrSessionId) 42} 43 44export function clearAllDumpState(): void { 45 dumpState.clear() 46} 47 48export function addApiRequestToCache(requestData: unknown): void { 49 if (process.env.USER_TYPE !== 'ant') return 50 cachedApiRequests.push({ 51 timestamp: new Date().toISOString(), 52 request: requestData, 53 }) 54 if (cachedApiRequests.length > MAX_CACHED_REQUESTS) { 55 cachedApiRequests.shift() 56 } 57} 58 59export function getDumpPromptsPath(agentIdOrSessionId?: string): string { 60 return join( 61 getClaudeConfigHomeDir(), 62 'dump-prompts', 63 `${agentIdOrSessionId ?? getSessionId()}.jsonl`, 64 ) 65} 66 67function appendToFile(filePath: string, entries: string[]): void { 68 if (entries.length === 0) return 69 fs.mkdir(dirname(filePath), { recursive: true }) 70 .then(() => fs.appendFile(filePath, entries.join('\n') + '\n')) 71 .catch(() => {}) 72} 73 74function initFingerprint(req: Record<string, unknown>): string { 75 const tools = req.tools as Array<{ name?: string }> | undefined 76 const system = req.system as unknown[] | string | undefined 77 const sysLen = 78 typeof system === 'string' 79 ? system.length 80 : Array.isArray(system) 81 ? system.reduce( 82 (n: number, b) => n + ((b as { text?: string }).text?.length ?? 0), 83 0, 84 ) 85 : 0 86 const toolNames = tools?.map(t => t.name ?? '').join(',') ?? '' 87 return `${req.model}|${toolNames}|${sysLen}` 88} 89 90function dumpRequest( 91 body: string, 92 ts: string, 93 state: DumpState, 94 filePath: string, 95): void { 96 try { 97 const req = jsonParse(body) as Record<string, unknown> 98 addApiRequestToCache(req) 99 100 if (process.env.USER_TYPE !== 'ant') return 101 const entries: string[] = [] 102 const messages = (req.messages ?? []) as Array<{ role?: string }> 103 104 // Write init data (system, tools, metadata) on first request, 105 // and a system_update entry whenever it changes. 106 // Cheap fingerprint first: system+tools don't change between turns, 107 // so skip the 300ms stringify when the shape is unchanged. 108 const fingerprint = initFingerprint(req) 109 if (!state.initialized || fingerprint !== state.lastInitFingerprint) { 110 const { messages: _, ...initData } = req 111 const initDataStr = jsonStringify(initData) 112 const initDataHash = hashString(initDataStr) 113 state.lastInitFingerprint = fingerprint 114 if (!state.initialized) { 115 state.initialized = true 116 state.lastInitDataHash = initDataHash 117 // Reuse initDataStr rather than re-serializing initData inside a wrapper. 118 // timestamp from toISOString() contains no chars needing JSON escaping. 119 entries.push( 120 `{"type":"init","timestamp":"${ts}","data":${initDataStr}}`, 121 ) 122 } else if (initDataHash !== state.lastInitDataHash) { 123 state.lastInitDataHash = initDataHash 124 entries.push( 125 `{"type":"system_update","timestamp":"${ts}","data":${initDataStr}}`, 126 ) 127 } 128 } 129 130 // Write only new user messages (assistant messages captured in response) 131 for (const msg of messages.slice(state.messageCountSeen)) { 132 if (msg.role === 'user') { 133 entries.push( 134 jsonStringify({ type: 'message', timestamp: ts, data: msg }), 135 ) 136 } 137 } 138 state.messageCountSeen = messages.length 139 140 appendToFile(filePath, entries) 141 } catch { 142 // Ignore parsing errors 143 } 144} 145 146export function createDumpPromptsFetch( 147 agentIdOrSessionId: string, 148): ClientOptions['fetch'] { 149 const filePath = getDumpPromptsPath(agentIdOrSessionId) 150 151 return async (input: RequestInfo | URL, init?: RequestInit) => { 152 const state = dumpState.get(agentIdOrSessionId) ?? { 153 initialized: false, 154 messageCountSeen: 0, 155 lastInitDataHash: '', 156 lastInitFingerprint: '', 157 } 158 dumpState.set(agentIdOrSessionId, state) 159 160 let timestamp: string | undefined 161 162 if (init?.method === 'POST' && init.body) { 163 timestamp = new Date().toISOString() 164 // Parsing + stringifying the request (system prompt + tool schemas = MBs) 165 // takes hundreds of ms. Defer so it doesn't block the actual API call — 166 // this is debug tooling for /issue, not on the critical path. 167 setImmediate(dumpRequest, init.body as string, timestamp, state, filePath) 168 } 169 170 // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins 171 const response = await globalThis.fetch(input, init) 172 173 // Save response async 174 if (timestamp && response.ok && process.env.USER_TYPE === 'ant') { 175 const cloned = response.clone() 176 void (async () => { 177 try { 178 const isStreaming = cloned.headers 179 .get('content-type') 180 ?.includes('text/event-stream') 181 182 let data: unknown 183 if (isStreaming && cloned.body) { 184 // Parse SSE stream into chunks 185 const reader = cloned.body.getReader() 186 const decoder = new TextDecoder() 187 let buffer = '' 188 try { 189 while (true) { 190 const { done, value } = await reader.read() 191 if (done) break 192 buffer += decoder.decode(value, { stream: true }) 193 } 194 } finally { 195 reader.releaseLock() 196 } 197 const chunks: unknown[] = [] 198 for (const event of buffer.split('\n\n')) { 199 for (const line of event.split('\n')) { 200 if (line.startsWith('data: ') && line !== 'data: [DONE]') { 201 try { 202 chunks.push(jsonParse(line.slice(6))) 203 } catch { 204 // Ignore parse errors 205 } 206 } 207 } 208 } 209 data = { stream: true, chunks } 210 } else { 211 data = await cloned.json() 212 } 213 214 await fs.appendFile( 215 filePath, 216 jsonStringify({ type: 'response', timestamp, data }) + '\n', 217 ) 218 } catch { 219 // Best effort 220 } 221 })() 222 } 223 224 return response 225 } 226}