/ services / api / dumpPrompts.ts
dumpPrompts.ts
  1  import type { ClientOptions } from '@anthropic-ai/sdk'
  2  import { createHash } from 'crypto'
  3  import { promises as fs } from 'fs'
  4  import { dirname, join } from 'path'
  5  import { getSessionId } from 'src/bootstrap/state.js'
  6  import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
  7  import { jsonParse, jsonStringify } from '../../utils/slowOperations.js'
  8  
  9  function 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)
 14  const MAX_CACHED_REQUESTS = 5
 15  const cachedApiRequests: Array<{ timestamp: string; request: unknown }> = []
 16  
 17  type 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
 27  const dumpState = new Map<string, DumpState>()
 28  
 29  export function getLastApiRequests(): Array<{
 30    timestamp: string
 31    request: unknown
 32  }> {
 33    return [...cachedApiRequests]
 34  }
 35  
 36  export function clearApiRequestCache(): void {
 37    cachedApiRequests.length = 0
 38  }
 39  
 40  export function clearDumpState(agentIdOrSessionId: string): void {
 41    dumpState.delete(agentIdOrSessionId)
 42  }
 43  
 44  export function clearAllDumpState(): void {
 45    dumpState.clear()
 46  }
 47  
 48  export 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  
 59  export function getDumpPromptsPath(agentIdOrSessionId?: string): string {
 60    return join(
 61      getClaudeConfigHomeDir(),
 62      'dump-prompts',
 63      `${agentIdOrSessionId ?? getSessionId()}.jsonl`,
 64    )
 65  }
 66  
 67  function 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  
 74  function 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  
 90  function 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  
146  export 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  }