import type { ChatMessage, ChatCompletionResponse, ChatCompletionChunk, ChatToolCall, ChatToolDefinition, } from '@api-gateway/shared/types.js'; import { BaseProvider, providerHttpError, RequestAbortError, type CompletionOptions } from './base.js'; import { contentToString } from '../lib/content.js'; const NPM_VERSION_URL = 'https://registry.npmjs.org/command-code/latest'; const API_BASE = 'https://api.commandcode.ai'; /** Fallback max_tokens when neither the caller nor the catalog specifies one. * Matches the Go reference proxy (proxy.go:92). */ const FALLBACK_MAX_TOKENS = 64000; /** Default temperature. The Go reference proxy defaults to 0.3, but * the CommandCode API is tuned for 0.7 (matching the Livebench/default * consensus observed across providers). */ const DEFAULT_TEMPERATURE = 0.7; const STREAM_TIMEOUT_MS = 300000; // 5 min — same as BaseProvider.readSseStream const VERSION_FALLBACK = '0.18.10'; // upstream's minVersion — safe floor if npm is unreachable const MIN_SUPPORTED_VERSION = '0.18.10'; /** Hard upper bound the CommandCode /alpha/generate API enforces on * params.max_tokens. Verified live 2026-06-19: values above 200_000 return * 400 BAD_REQUEST ("Too big: expected number to be <=200000 at * \"params.max_tokens\""). The catalog's max_output_tokens can be higher * (e.g. 262144 for deepseek-v4-pro, MiniMax-M3) — we clamp to this ceiling * so those models don't permanently 400. This is the maximum possible * output length the upstream allows, not an arbitrary default. */ const API_MAX_TOKENS = 200000; let cachedVersion: string | undefined; let versionPromise: Promise | undefined; /** Fetch the latest command-code npm version, cached for the process lifetime. * Mirrors the Go proxy's behavior of sending an up-to-date x-command-code-version. */ async function getCommandCodeVersion(): Promise { if (cachedVersion) return cachedVersion; if (versionPromise) return versionPromise; versionPromise = (async () => { try { const res = await fetch(NPM_VERSION_URL, { signal: AbortSignal.timeout(5000) }); if (!res.ok) return VERSION_FALLBACK; const pkg = (await res.json()) as { version?: string }; const v = typeof pkg.version === 'string' ? pkg.version : VERSION_FALLBACK; // Always send at least the known-minimum so the upstream doesn't 426 us cachedVersion = compareVersions(v, MIN_SUPPORTED_VERSION) < 0 ? MIN_SUPPORTED_VERSION : v; return cachedVersion; } catch { cachedVersion = VERSION_FALLBACK; return cachedVersion; } finally { versionPromise = undefined; } })(); return versionPromise; } /** Loose semver-ish compare: returns <0 if a0 if a>b. */ function compareVersions(a: string, b: string): number { const pa = a.split('.').map(p => parseInt(p, 10) || 0); const pb = b.split('.').map(p => parseInt(p, 10) || 0); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const da = pa[i] ?? 0; const db = pb[i] ?? 0; if (da !== db) return da - db; } return 0; } // ── CommandCode wire types ─────────────────────────────────────────────── interface CCContentBlock { type: string; text?: string; id?: string; name?: string; input?: unknown; toolCallId?: string; toolName?: string; tool_use_id?: string; content?: unknown; output?: { type: string; value: string }; } interface CCStreamEvent { type: string; text?: string; id?: string; delta?: string; input?: Record; toolCallId?: string; toolName?: string; finishReason?: string; totalUsage?: { inputTokens: number; outputTokens: number }; // Optional reasoning fields. CommandCode's wrapper surfaces model-side // reasoning traces under varying keys depending on the underlying model; // we capture any of these so OpenAI-format clients can preserve the trace // for multi-turn replay. (#290) reasoning?: string; reasoningContent?: string; reasoning_content?: string; thinking?: string | { text?: string; thinking?: string }; redacted_thinking?: string; error?: { message: string; statusCode?: number }; } /** Normalize the variable-shape reasoning field on CCStreamEvent to a string. * CommandCode's wrapper surfaces reasoning traces under varying keys * depending on the underlying model (`reasoning`, `text`, `thinking` * as object/string, …). This helper flattens any of them. (#290) */ function extractReasoningFromEvent(event: CCStreamEvent): string { if (typeof event.reasoning_content === 'string') return event.reasoning_content; if (typeof event.reasoningContent === 'string') return event.reasoningContent; if (typeof event.reasoning === 'string') return event.reasoning; if (typeof event.redacted_thinking === 'string') return event.redacted_thinking; if (typeof event.thinking === 'string') return event.thinking; if (event.thinking && typeof event.thinking === 'object') { const inner = event.thinking.text ?? event.thinking.thinking; if (typeof inner === 'string') return inner; } if (typeof event.text === 'string' && event.type?.toLowerCase().includes('reason')) { return event.text; } return ''; } interface StreamingToolDelta { index: number; id?: string; type?: 'function'; function?: { name?: string; arguments?: string }; } interface StreamingDelta { role?: 'assistant'; content?: string; tool_calls?: StreamingToolDelta[]; reasoning_content?: string; } // ── Provider ───────────────────────────────────────────────────────────── export class CommandCodeProvider extends BaseProvider { readonly platform = 'commandcode' as const; readonly name = 'CommandCode'; // baseUrl left undefined — CommandCode has no /v1/models endpoint, so // auto-discovery skips this provider. async chatCompletion( apiKey: string, messages: ChatMessage[], modelId: string, options?: CompletionOptions, ): Promise { const body = this.buildRequestBody(messages, modelId, options); const headers = await this.requestHeaders(apiKey); const res = await this.fetchWithTimeout(`${API_BASE}/alpha/generate`, { method: 'POST', headers, body: JSON.stringify(body), }, options?.timeoutMs ?? 120000, options?.abortSignal); if (!res.ok) { const err = await res.text().catch(() => ''); throw providerHttpError(res, `CommandCode API error ${res.status}: ${err}`); } return this.collectNonStreamResponse(res, modelId); } async *streamChatCompletion( apiKey: string, messages: ChatMessage[], modelId: string, options?: CompletionOptions, ): AsyncGenerator { const body = this.buildRequestBody(messages, modelId, options); const headers = await this.requestHeaders(apiKey); const res = await this.fetchWithTimeout(`${API_BASE}/alpha/generate`, { method: 'POST', headers, body: JSON.stringify(body), }, options?.timeoutMs ?? 120000, options?.abortSignal); if (!res.ok) { const err = await res.text().catch(() => ''); throw providerHttpError(res, `CommandCode API error ${res.status}: ${err}`); } yield* this.streamNdjsonResponse(res, modelId, options?.abortSignal); } async validateKey(apiKey: string): Promise { try { const headers = await this.requestHeaders(apiKey); const res = await this.fetchWithTimeout(`${API_BASE}/alpha/generate`, { method: 'POST', headers, body: JSON.stringify({ config: this.defaultConfig(), memory: '', taste: '', skills: '', params: { model: 'deepseek/deepseek-v4-flash', messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], system: '', max_tokens: 1, temperature: 0.7, stream: true, tools: [] }, threadId: crypto.randomUUID(), }), }, 15000); return res.ok || res.status >= 500; } catch { return true; } } // ── Request building ─────────────────────────────────────────────────── private buildRequestBody( messages: ChatMessage[], modelId: string, options?: CompletionOptions, ): Record { const system = this.buildSystemText(messages); const ccMessages = this.convertMessages(messages); const tools = this.convertTools(options?.tools); const temperature = options?.temperature ?? DEFAULT_TEMPERATURE; // The CommandCode API rejects max_tokens > 200_000 with 400. The catalog // stores per-model max_output_tokens which can exceed this (e.g. 262144 for // deepseek-v4-pro), and callers may also ask for more than the upstream // accepts. Clamp to the API ceiling so every model can serve at least up const maxTokens = Math.min(options?.max_tokens ?? FALLBACK_MAX_TOKENS, API_MAX_TOKENS); return { config: this.defaultConfig(), memory: '', taste: '', skills: '', params: { model: modelId, messages: ccMessages, tools, system, max_tokens: maxTokens, temperature, stream: true, // Pass thinking signals through to the underlying provider. The // CommandCode wrapper reaches model-specific APIs that recognize // `reasoning_effort`; the richer `thinking` object is forwarded // verbatim too so the wrapper can pick what it understands. (#290) ...(options?.reasoning_effort ? { reasoning_effort: options.reasoning_effort } : {}), ...(options?.thinking ? { thinking: options.thinking } : {}), }, threadId: crypto.randomUUID(), }; } private defaultConfig(): Record { return { workingDir: '.', date: new Date().toISOString().slice(0, 10), environment: 'cli', structure: [], isGitRepo: false, currentBranch: '', mainBranch: 'main', gitStatus: '', recentCommits: [], }; } private async requestHeaders(apiKey: string): Promise> { const version = await getCommandCodeVersion(); return { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'x-command-code-version': version, 'x-cli-environment': 'production', 'Accept': 'text/event-stream', }; } // ── Message translation ──────────────────────────────────────────────── private buildSystemText(messages: ChatMessage[]): string { let system = ''; for (const m of messages) { if (m.role === 'system') { if (system.length > 0) system += '\n'; system += this.blockText(m.content); } } return system; } private convertMessages(messages: ChatMessage[]): Array<{ role: string; content: CCContentBlock[] }> { const toolNames = new Map(); for (const m of messages) { if (m.role === 'assistant' && 'tool_calls' in m) { const calls = (m as { tool_calls?: ChatToolCall[] }).tool_calls; if (calls) { for (const tc of calls) { if (tc.id && tc.function?.name) toolNames.set(tc.id, tc.function.name); } } } } const out: Array<{ role: string; content: CCContentBlock[] }> = []; for (const m of messages) { if (m.role === 'system') continue; if (m.role === 'tool') { const toolMsg = m as ChatMessage & { tool_call_id: string }; const name = (m as { name?: string }).name || toolNames.get(toolMsg.tool_call_id) || 'unknown'; const val = this.blockText(m.content); out.push({ role: 'tool', content: [{ type: 'tool-result', toolCallId: toolMsg.tool_call_id, toolName: name, output: { type: val.startsWith('Error:') ? 'error-text' : 'text', value: val } }], }); continue; } if (m.role === 'assistant' && 'tool_calls' in m) { const a = m as ChatMessage & { tool_calls?: ChatToolCall[] }; const blocks: CCContentBlock[] = this.contentToBlocks(m.content); const added = new Set(blocks.filter(b => b.type === 'tool-call' && b.toolCallId).map(b => b.toolCallId!)); for (const tc of a.tool_calls ?? []) { if (added.has(tc.id)) continue; blocks.push({ type: 'tool-call', toolCallId: tc.id, toolName: tc.function?.name, input: this.safeParseJson(tc.function?.arguments) }); added.add(tc.id); } out.push({ role: 'assistant', content: blocks }); continue; } out.push({ role: m.role, content: this.contentToBlocks(m.content) }); } return out; } /** Extract the text value from an individual content block object, mirroring * the Go proxy's `contentPartToString()` in convert.go. Key difference from * lib/content.ts's `contentToString`: that function operates on a whole * message (string | null | array), NOT on single block objects — a bare * `{type:'text',text:'Hello'}` returns '' from it, silently dropping user * content. This helper handles single blocks directly. */ private blockText(block: unknown): string { if (block === null || block === undefined) return ''; if (typeof block === 'string') return block; if (Array.isArray(block)) { return block.map(b => this.blockText(b)).join(''); } if (typeof block === 'object') { const b = block as Record; // Common text-carrying keys — same order as Go reference for (const key of ['text', 'content', 'output_text', 'input_text', 'refusal', 'thinking', 'redacted_thinking']) { if (typeof b[key] === 'string') return b[key] as string; } // Image blocks → descriptive string const iu = b['image_url']; if (iu && typeof iu === 'object' && typeof (iu as Record)['url'] === 'string') { return `[Image URL: ${(iu as Record)['url']}]`; } if (typeof iu === 'string') return `[Image URL: ${iu}]`; // Fallback: stringify the block try { return JSON.stringify(b); } catch { return String(b); } } return String(block); } /** Convert an OpenAI content value to CommandCode content blocks. * Matches the Go reference proxy's `parseContent()` in convert.go — every * block type the Go proxy preserves is preserved here so conversation * history is never silently truncated. */ private contentToBlocks(content: unknown, _toolNames?: Map): CCContentBlock[] { if (content === null || content === undefined) return []; if (typeof content === 'string') { return content.length > 0 ? [{ type: 'text', text: content }] : []; } if (Array.isArray(content)) { return content .map((c): CCContentBlock | null => { if (typeof c === 'string') return { type: 'text', text: c }; const b = c as Record; const typ = typeof b.type === 'string' ? b.type : ''; // ── text-like blocks (Go: text, input_text, output_text, refusal, // thinking, redacted_thinking, reasoning, document, search_result) ── if (typ === 'text' || typ === 'input_text' || typ === 'output_text' || typ === 'refusal' || typ === 'thinking' || typ === 'redacted_thinking' || typ === 'reasoning' || typ === 'document' || typ === 'search_result') { return { type: 'text', text: this.blockText(b) }; } // ── image-like blocks (Go: image_url, input_image, image → stringified text) ── if (typ === 'image_url' || typ === 'input_image' || typ === 'image') { return { type: 'text', text: this.blockText(b) }; } // ── tool-call blocks (Go: tool_use, tool-call) ── if (typ === 'tool_use' || typ === 'tool-call' || b.tool_use_id) { const id = (typeof b.id === 'string' ? b.id : '') || (typeof b.toolCallId === 'string' ? b.toolCallId : '') || (typeof b.tool_use_id === 'string' ? b.tool_use_id : ''); const name = (typeof b.name === 'string' ? b.name : '') || (typeof b.toolName === 'string' ? b.toolName : ''); const input = b.input ?? b.arguments; const block: CCContentBlock = { type: 'tool-call', input }; if (id) block.toolCallId = id; if (name) block.toolName = name; return block; } // ── tool-result blocks (Go: tool_result, tool-result) ── if (typ === 'tool_result' || typ === 'tool-result') { const toolUseId = (typeof b.tool_use_id === 'string' ? b.tool_use_id : '') || (typeof b.toolCallId === 'string' ? b.toolCallId : ''); const toolName = typeof b.toolName === 'string' ? b.toolName : ''; const contentVal = this.blockText(b.content ?? b.output); const outputType = contentVal.startsWith('Error:') ? 'error-text' : 'text'; const block: CCContentBlock = { type: 'tool-result', output: { type: outputType, value: contentVal }, }; if (toolUseId) block.toolCallId = toolUseId; if (toolName) block.toolName = toolName; return block; } // ── fallthrough: unknown types become text ── return { type: 'text', text: this.blockText(b) }; }) .filter((b): b is CCContentBlock => b !== null); } return []; } private convertTools(tools?: ChatToolDefinition[]): Record[] { if (!tools || tools.length === 0) return []; return tools .filter(t => t.type === 'function' && t.function?.name) .map(t => { const out: Record = { name: t.function.name, input_schema: t.function.parameters ?? { type: 'object', properties: {} }, }; if (t.function.description) out.description = t.function.description; return out; }); } private async collectNonStreamResponse(res: Response, modelId: string): Promise { const events = await this.readNdjsonEvents(res); let content = ''; let reasoning = ''; const toolCalls: ChatToolCall[] = []; const toolCallBySlot: Record = {}; let inputTokens = 0; let outputTokens = 0; for (const event of events) { switch (event.type) { case 'text-delta': content += event.text ?? ''; break; case 'reasoning-delta': case 'reasoning-deltas': case 'reasoning': { const r = extractReasoningFromEvent(event); if (r) reasoning = reasoning ? `${reasoning}\n${r}` : r; break; } case 'tool-use': toolCallBySlot[event.toolCallId!] = toolCalls.length; toolCalls.push({ id: event.toolCallId!, type: 'function', function: { name: event.toolName ?? '', arguments: '' } }); break; case 'tool-delta': if (toolCalls.length > 0) toolCalls[toolCalls.length - 1].function.arguments += event.text ?? ''; break; case 'tool-input-start': toolCallBySlot[event.id!] = toolCalls.length; toolCalls.push({ id: event.id!, type: 'function', function: { name: event.toolName ?? '', arguments: '' } }); break; case 'tool-input-delta': if (event.id && toolCallBySlot[event.id] !== undefined) { toolCalls[toolCallBySlot[event.id]].function.arguments += event.delta ?? ''; } break; case 'tool-call': { const args = event.input ? JSON.stringify(event.input) : ''; const idx = event.toolCallId ? toolCallBySlot[event.toolCallId] : undefined; if (idx !== undefined) { if (event.toolName) toolCalls[idx].function.name = event.toolName; if (args) toolCalls[idx].function.arguments = args; } else { toolCalls.push({ id: event.toolCallId!, type: 'function', function: { name: event.toolName ?? '', arguments: args } }); } break; } case 'finish': if (event.totalUsage) { inputTokens = event.totalUsage.inputTokens; outputTokens = event.totalUsage.outputTokens; } break; } } const hasToolCalls = toolCalls.length > 0; const finishReason = hasToolCalls ? 'tool_calls' : 'stop'; const msg: { role: string; content: string | null; tool_calls?: ChatToolCall[]; reasoning_content?: string } = { role: 'assistant', content: hasToolCalls ? null : content, }; if (hasToolCalls) msg.tool_calls = toolCalls; if (reasoning.length > 0) msg.reasoning_content = reasoning; return { id: this.makeId(), object: 'chat.completion', created: Math.floor(Date.now() / 1000), model: modelId, choices: [{ index: 0, message: msg as ChatCompletionResponse['choices'][0]['message'], finish_reason: finishReason }], usage: { prompt_tokens: inputTokens, completion_tokens: outputTokens, total_tokens: inputTokens + outputTokens, }, }; } // ── Streaming ────────────────────────────────────────────────────────── private async *streamNdjsonResponse(res: Response, modelId: string, abortSignal?: AbortSignal): AsyncGenerator { const id = this.makeId(); const created = Math.floor(Date.now() / 1000); let sentRole = false; let toolCallIndex = 0; const toolCallSlot: Record = {}; const reader = res.body?.getReader(); if (!reader) throw new Error('No response body'); const decoder = new TextDecoder(); let buf = ''; // Client disconnect must interrupt a stalled/slow CommandCode stream // immediately rather than waiting for the inactivity timer. (#292) if (abortSignal?.aborted) throw new RequestAbortError(); let aborted = false; const onAbort = () => { aborted = true; }; if (abortSignal) abortSignal.addEventListener('abort', onAbort, { once: true }); try { while (true) { if (aborted) throw new RequestAbortError(); let timer: ReturnType | undefined; const result = await Promise.race([ reader.read(), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('CommandCode stream stalled')), STREAM_TIMEOUT_MS); }), // A client abort rejects the pending read first. (#292) ...(abortSignal ? [new Promise((_, reject) => { abortSignal.addEventListener('abort', () => reject(new RequestAbortError()), { once: true }); })] : []), ]).finally(() => clearTimeout(timer)); const { done, value } = result; if (done) { buf += decoder.decode(); yield* this.flushStreamBuffer(buf, id, created, modelId, () => sentRole, (v) => { sentRole = v; }, toolCallSlot, () => toolCallIndex, (v) => { toolCallIndex = v; }); break; } buf += decoder.decode(value, { stream: true }); const lines = buf.split('\n'); buf = lines.pop() ?? ''; for (const line of lines) { const chunk = this.processStreamLine(line, id, created, modelId, () => sentRole, (v) => { sentRole = v; }, toolCallSlot, () => toolCallIndex, (v) => { toolCallIndex = v; }); if (chunk) yield chunk; } } } finally { if (abortSignal) abortSignal.removeEventListener('abort', onAbort); reader.cancel().catch(() => {}); } } private *flushStreamBuffer( buf: string, id: string, created: number, modelId: string, getRole: () => boolean, setRole: (v: boolean) => void, toolCallSlot: Record, getIdx: () => number, setIdx: (v: number) => void, ): Generator { for (const line of buf.split('\n')) { const chunk = this.processStreamLine(line, id, created, modelId, getRole, setRole, toolCallSlot, getIdx, setIdx); if (chunk) yield chunk; } } private processStreamLine( line: string, id: string, created: number, modelId: string, getRole: () => boolean, setRole: (v: boolean) => void, toolCallSlot: Record, getIdx: () => number, setIdx: (v: number) => void, ): ChatCompletionChunk | null { const trimmed = line.trim(); if (!trimmed) return null; const event = this.safeParseJson(trimmed) as CCStreamEvent | null; if (!event || !event.type) return null; switch (event.type) { case 'text-delta': { const delta: StreamingDelta = { content: event.text }; if (!getRole()) { delta.role = 'assistant'; setRole(true); } return this.buildChunk(id, created, modelId, delta, null); } case 'reasoning-delta': case 'reasoning-deltas': case 'reasoning': case 'thinking-delta': { const text = extractReasoningFromEvent(event); if (text.length === 0) return null; const delta: StreamingDelta = { reasoning_content: text }; if (!getRole()) { delta.role = 'assistant'; setRole(true); } return this.buildChunk(id, created, modelId, delta, null); } case 'tool-use': { const idx = getIdx(); toolCallSlot[event.toolCallId!] = idx; const tc: StreamingToolDelta = { index: idx, id: event.toolCallId!, type: 'function', function: { name: event.toolName } }; const delta: StreamingDelta = { tool_calls: [tc] }; if (!getRole()) { delta.role = 'assistant'; setRole(true); } return this.buildChunk(id, created, modelId, delta, null); } case 'tool-delta': { const prevIdx = getIdx() - 1; const delta: StreamingDelta = { tool_calls: [{ index: prevIdx, function: { arguments: event.text } }] }; return this.buildChunk(id, created, modelId, delta, null); } case 'tool-input-start': { if (toolCallSlot[event.id!] === undefined) { const idx = getIdx(); toolCallSlot[event.id!] = idx; setIdx(idx + 1); } const idx = toolCallSlot[event.id!]; const tc: StreamingToolDelta = { index: idx, id: event.id!, type: 'function', function: { name: event.toolName } }; const delta: StreamingDelta = { tool_calls: [tc] }; if (!getRole()) { delta.role = 'assistant'; setRole(true); } return this.buildChunk(id, created, modelId, delta, null); } case 'tool-input-delta': { const idx = toolCallSlot[event.id!] ?? getIdx(); const delta: StreamingDelta = { tool_calls: [{ index: idx, function: { arguments: event.delta } }] }; return this.buildChunk(id, created, modelId, delta, null); } case 'tool-call': { if (!event.toolCallId) return null; if (toolCallSlot[event.toolCallId] !== undefined) return null; const idx = getIdx(); toolCallSlot[event.toolCallId] = idx; setIdx(idx + 1); const args = event.input ? JSON.stringify(event.input) : ''; const tc: StreamingToolDelta = { index: idx, id: event.toolCallId, type: 'function', function: { name: event.toolName, arguments: args } }; const delta: StreamingDelta = { tool_calls: [tc] }; if (!getRole()) { delta.role = 'assistant'; setRole(true); } return this.buildChunk(id, created, modelId, delta, null); } case 'finish': { const reason = this.mapFinishReason(event.finishReason); return { id, object: 'chat.completion.chunk', created, model: modelId, choices: [{ index: 0, delta: {}, finish_reason: reason }], usage: event.totalUsage ? { prompt_tokens: event.totalUsage.inputTokens, completion_tokens: event.totalUsage.outputTokens, total_tokens: event.totalUsage.inputTokens + event.totalUsage.outputTokens, } : undefined, }; } default: return null; } } private buildChunk( id: string, created: number, modelId: string, delta: StreamingDelta, finishReason: string | null, ): ChatCompletionChunk { return { id, object: 'chat.completion.chunk', created, model: modelId, choices: [{ index: 0, delta: delta as unknown as ChatCompletionChunk['choices'][0]['delta'], finish_reason: finishReason }], } satisfies ChatCompletionChunk; } private mapFinishReason(reason?: string): string { if (reason === 'tool_calls' || reason === 'tool-calls') return 'tool_calls'; if (reason === 'length' || reason === 'max_tokens') return 'length'; if (reason === 'content_filter' || reason === 'content-filter') return 'content_filter'; return 'stop'; } private async readNdjsonEvents(res: Response): Promise { const text = await res.text(); const events: CCStreamEvent[] = []; for (const line of text.split('\n')) { const trimmed = line.trim(); if (!trimmed) continue; const parsed = this.safeParseJson(trimmed); if (parsed && typeof parsed === 'object' && 'type' in parsed) { events.push(parsed as CCStreamEvent); } } return events; } private safeParseJson(raw: string): unknown { try { return JSON.parse(raw); } catch { return null; } } }