| import type { ClientOptions } from '@anthropic-ai/sdk' |
| import { createHash } from 'crypto' |
| import { promises as fs } from 'fs' |
| import { dirname, join } from 'path' |
| import { getSessionId } from 'src/bootstrap/state.js' |
| import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' |
| import { jsonParse, jsonStringify } from '../../utils/slowOperations.js' |
|
|
| function hashString(str: string): string { |
| return createHash('sha256').update(str).digest('hex') |
| } |
|
|
| |
| const MAX_CACHED_REQUESTS = 5 |
| const cachedApiRequests: Array<{ timestamp: string; request: unknown }> = [] |
|
|
| type DumpState = { |
| initialized: boolean |
| messageCountSeen: number |
| lastInitDataHash: string |
| |
| |
| lastInitFingerprint: string |
| } |
|
|
| |
| const dumpState = new Map<string, DumpState>() |
|
|
| export function getLastApiRequests(): Array<{ |
| timestamp: string |
| request: unknown |
| }> { |
| return [...cachedApiRequests] |
| } |
|
|
| export function clearApiRequestCache(): void { |
| cachedApiRequests.length = 0 |
| } |
|
|
| export function clearDumpState(agentIdOrSessionId: string): void { |
| dumpState.delete(agentIdOrSessionId) |
| } |
|
|
| export function clearAllDumpState(): void { |
| dumpState.clear() |
| } |
|
|
| export function addApiRequestToCache(requestData: unknown): void { |
| if (process.env.USER_TYPE !== 'ant') return |
| cachedApiRequests.push({ |
| timestamp: new Date().toISOString(), |
| request: requestData, |
| }) |
| if (cachedApiRequests.length > MAX_CACHED_REQUESTS) { |
| cachedApiRequests.shift() |
| } |
| } |
|
|
| export function getDumpPromptsPath(agentIdOrSessionId?: string): string { |
| return join( |
| getClaudeConfigHomeDir(), |
| 'dump-prompts', |
| `${agentIdOrSessionId ?? getSessionId()}.jsonl`, |
| ) |
| } |
|
|
| function appendToFile(filePath: string, entries: string[]): void { |
| if (entries.length === 0) return |
| fs.mkdir(dirname(filePath), { recursive: true }) |
| .then(() => fs.appendFile(filePath, entries.join('\n') + '\n')) |
| .catch(() => {}) |
| } |
|
|
| function initFingerprint(req: Record<string, unknown>): string { |
| const tools = req.tools as Array<{ name?: string }> | undefined |
| const system = req.system as unknown[] | string | undefined |
| const sysLen = |
| typeof system === 'string' |
| ? system.length |
| : Array.isArray(system) |
| ? system.reduce( |
| (n: number, b) => n + ((b as { text?: string }).text?.length ?? 0), |
| 0, |
| ) |
| : 0 |
| const toolNames = tools?.map(t => t.name ?? '').join(',') ?? '' |
| return `${req.model}|${toolNames}|${sysLen}` |
| } |
|
|
| function dumpRequest( |
| body: string, |
| ts: string, |
| state: DumpState, |
| filePath: string, |
| ): void { |
| try { |
| const req = jsonParse(body) as Record<string, unknown> |
| addApiRequestToCache(req) |
|
|
| if (process.env.USER_TYPE !== 'ant') return |
| const entries: string[] = [] |
| const messages = (req.messages ?? []) as Array<{ role?: string }> |
|
|
| |
| |
| |
| |
| const fingerprint = initFingerprint(req) |
| if (!state.initialized || fingerprint !== state.lastInitFingerprint) { |
| const { messages: _, ...initData } = req |
| const initDataStr = jsonStringify(initData) |
| const initDataHash = hashString(initDataStr) |
| state.lastInitFingerprint = fingerprint |
| if (!state.initialized) { |
| state.initialized = true |
| state.lastInitDataHash = initDataHash |
| |
| |
| entries.push( |
| `{"type":"init","timestamp":"${ts}","data":${initDataStr}}`, |
| ) |
| } else if (initDataHash !== state.lastInitDataHash) { |
| state.lastInitDataHash = initDataHash |
| entries.push( |
| `{"type":"system_update","timestamp":"${ts}","data":${initDataStr}}`, |
| ) |
| } |
| } |
|
|
| |
| for (const msg of messages.slice(state.messageCountSeen)) { |
| if (msg.role === 'user') { |
| entries.push( |
| jsonStringify({ type: 'message', timestamp: ts, data: msg }), |
| ) |
| } |
| } |
| state.messageCountSeen = messages.length |
|
|
| appendToFile(filePath, entries) |
| } catch { |
| |
| } |
| } |
|
|
| export function createDumpPromptsFetch( |
| agentIdOrSessionId: string, |
| ): ClientOptions['fetch'] { |
| const filePath = getDumpPromptsPath(agentIdOrSessionId) |
|
|
| return async (input: RequestInfo | URL, init?: RequestInit) => { |
| const state = dumpState.get(agentIdOrSessionId) ?? { |
| initialized: false, |
| messageCountSeen: 0, |
| lastInitDataHash: '', |
| lastInitFingerprint: '', |
| } |
| dumpState.set(agentIdOrSessionId, state) |
|
|
| let timestamp: string | undefined |
|
|
| if (init?.method === 'POST' && init.body) { |
| timestamp = new Date().toISOString() |
| |
| |
| |
| setImmediate(dumpRequest, init.body as string, timestamp, state, filePath) |
| } |
|
|
| |
| const response = await globalThis.fetch(input, init) |
|
|
| |
| if (timestamp && response.ok && process.env.USER_TYPE === 'ant') { |
| const cloned = response.clone() |
| void (async () => { |
| try { |
| const isStreaming = cloned.headers |
| .get('content-type') |
| ?.includes('text/event-stream') |
|
|
| let data: unknown |
| if (isStreaming && cloned.body) { |
| |
| const reader = cloned.body.getReader() |
| const decoder = new TextDecoder() |
| let buffer = '' |
| try { |
| while (true) { |
| const { done, value } = await reader.read() |
| if (done) break |
| buffer += decoder.decode(value, { stream: true }) |
| } |
| } finally { |
| reader.releaseLock() |
| } |
| const chunks: unknown[] = [] |
| for (const event of buffer.split('\n\n')) { |
| for (const line of event.split('\n')) { |
| if (line.startsWith('data: ') && line !== 'data: [DONE]') { |
| try { |
| chunks.push(jsonParse(line.slice(6))) |
| } catch { |
| |
| } |
| } |
| } |
| } |
| data = { stream: true, chunks } |
| } else { |
| data = await cloned.json() |
| } |
|
|
| await fs.appendFile( |
| filePath, |
| jsonStringify({ type: 'response', timestamp, data }) + '\n', |
| ) |
| } catch { |
| |
| } |
| })() |
| } |
|
|
| return response |
| } |
| } |
|
|