pi-codex-search 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.ts CHANGED
@@ -3,7 +3,9 @@ import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import type { SearchContextSize } from "./codex.ts";
5
5
 
6
- export type Freshness = "live" | "cached";
6
+ export type SearchApi = "standalone" | "responses";
7
+
8
+ export type Freshness = "live" | "cached" | "indexed";
7
9
 
8
10
  export type ConfigScope = "project" | "home";
9
11
 
@@ -15,6 +17,9 @@ export interface PiCodexSearchConfig {
15
17
  clientVersion?: string;
16
18
  searchContextSize?: SearchContextSize;
17
19
  freshness?: Freshness;
20
+ searchApi?: SearchApi;
21
+ standaloneEnabled?: boolean;
22
+ batchSize?: number;
18
23
  }
19
24
 
20
25
  export interface ResolvedConfig {
@@ -25,6 +30,9 @@ export interface ResolvedConfig {
25
30
  clientVersion?: string;
26
31
  defaultSearchContextSize: SearchContextSize;
27
32
  defaultFreshness: Freshness;
33
+ searchApi: SearchApi;
34
+ standaloneEnabled: boolean;
35
+ batchSize: number;
28
36
  sources: {
29
37
  project?: PiCodexSearchConfig;
30
38
  home?: PiCodexSearchConfig;
@@ -36,19 +44,40 @@ export const DEFAULT_ENABLED = true;
36
44
  export const DEFAULT_TOOL_NAME = "codex_search";
37
45
  export const DEFAULT_SEARCH_CONTEXT_SIZE: SearchContextSize = "medium";
38
46
  export const DEFAULT_FRESHNESS: Freshness = "live";
47
+ export const DEFAULT_SEARCH_API: SearchApi = "responses";
48
+ export const DEFAULT_STANDALONE_ENABLED = false;
49
+ export const STANDALONE_TOOL_NAME = "codex_standalone_web";
50
+ export const DEFAULT_BATCH_SIZE = 5;
39
51
  export const CONFIG_FILE_NAME = "pi-codex-search.json";
40
52
  const TOOL_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]{0,63}$/;
41
53
  const CONTEXT_SIZES: readonly SearchContextSize[] = ["low", "medium", "high"] as const;
42
- const FRESHNESS_VALUES: readonly Freshness[] = ["live", "cached"] as const;
54
+ const FRESHNESS_VALUES: readonly Freshness[] = ["live", "cached", "indexed"] as const;
55
+ const SEARCH_API_VALUES: readonly SearchApi[] = ["standalone", "responses"] as const;
56
+ export const MIN_BATCH_SIZE = 1;
57
+ export const MAX_BATCH_SIZE = 32;
43
58
 
44
59
  export function getConfigPath(scope: ConfigScope, cwd: string): string {
45
60
  if (scope === "project") return join(cwd, ".pi", CONFIG_FILE_NAME);
46
61
  return join(homedir(), ".pi", CONFIG_FILE_NAME);
47
62
  }
48
63
 
49
- export async function loadConfig(cwd: string): Promise<ResolvedConfig> {
64
+ export function isProjectTrustedContext(ctx: unknown): boolean {
65
+ if (ctx === null || ctx === undefined || typeof ctx !== "object") return true;
66
+ const maybe = ctx as { isProjectTrusted?: unknown };
67
+ if (typeof maybe.isProjectTrusted === "boolean") return maybe.isProjectTrusted;
68
+ if (typeof maybe.isProjectTrusted !== "function") return true;
69
+ try {
70
+ return Boolean(maybe.isProjectTrusted());
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ export async function loadConfig(cwd: string, isProjectTrusted = true): Promise<ResolvedConfig> {
50
77
  const homeConfig = await readConfigFile(getConfigPath("home", cwd));
51
- const projectConfig = await readConfigFile(getConfigPath("project", cwd));
78
+ const projectConfig = isProjectTrusted
79
+ ? await readConfigFile(getConfigPath("project", cwd))
80
+ : undefined;
52
81
  const envConfig = readEnvConfig();
53
82
 
54
83
  const merged: PiCodexSearchConfig = {
@@ -62,6 +91,11 @@ export async function loadConfig(cwd: string): Promise<ResolvedConfig> {
62
91
  toolName: merged.toolName ?? DEFAULT_TOOL_NAME,
63
92
  defaultSearchContextSize: merged.searchContextSize ?? DEFAULT_SEARCH_CONTEXT_SIZE,
64
93
  defaultFreshness: merged.freshness ?? DEFAULT_FRESHNESS,
94
+ searchApi: DEFAULT_SEARCH_API,
95
+ standaloneEnabled:
96
+ merged.standaloneEnabled ??
97
+ (merged.searchApi === "standalone" ? true : DEFAULT_STANDALONE_ENABLED),
98
+ batchSize: merged.batchSize ?? DEFAULT_BATCH_SIZE,
65
99
  sources: {},
66
100
  };
67
101
  if (merged.model !== undefined) resolved.model = merged.model;
@@ -138,6 +172,12 @@ function readEnvConfig(): PiCodexSearchConfig | undefined {
138
172
  }
139
173
  const freshness = trimmedEnv("PI_CODEX_WEB_SEARCH_FRESHNESS");
140
174
  if (freshness !== undefined) env.freshness = freshness as Freshness;
175
+ const searchApi = trimmedEnv("PI_CODEX_WEB_SEARCH_API");
176
+ if (searchApi !== undefined) env.searchApi = searchApi as SearchApi;
177
+ const standaloneEnabled = booleanEnv("PI_CODEX_WEB_STANDALONE_ENABLED");
178
+ if (standaloneEnabled !== undefined) env.standaloneEnabled = standaloneEnabled;
179
+ const batchSize = integerEnv("PI_CODEX_WEB_SEARCH_BATCH_SIZE");
180
+ if (batchSize !== undefined) env.batchSize = batchSize;
141
181
 
142
182
  if (Object.keys(env).length === 0) return undefined;
143
183
  validateConfig(env, "<env>");
@@ -177,6 +217,29 @@ function validateConfig(config: PiCodexSearchConfig, sourceLabel: string): void
177
217
  `Expected one of ${FRESHNESS_VALUES.join(", ")}.`,
178
218
  );
179
219
  }
220
+ if (config.standaloneEnabled !== undefined && typeof config.standaloneEnabled !== "boolean") {
221
+ throw new Error(
222
+ `Invalid standaloneEnabled in ${sourceLabel}: ${JSON.stringify(config.standaloneEnabled)}. Must be a boolean.`,
223
+ );
224
+ }
225
+ if (config.searchApi !== undefined && !SEARCH_API_VALUES.includes(config.searchApi)) {
226
+ throw new Error(
227
+ `Invalid searchApi in ${sourceLabel}: ${JSON.stringify(config.searchApi)}. ` +
228
+ `Expected one of ${SEARCH_API_VALUES.join(", ")}.`,
229
+ );
230
+ }
231
+ if (config.batchSize !== undefined) {
232
+ if (
233
+ !Number.isInteger(config.batchSize) ||
234
+ config.batchSize < MIN_BATCH_SIZE ||
235
+ config.batchSize > MAX_BATCH_SIZE
236
+ ) {
237
+ throw new Error(
238
+ `Invalid batchSize in ${sourceLabel}: ${JSON.stringify(config.batchSize)}. ` +
239
+ `Expected an integer between ${MIN_BATCH_SIZE} and ${MAX_BATCH_SIZE}.`,
240
+ );
241
+ }
242
+ }
180
243
  }
181
244
 
182
245
  function trimmedEnv(name: string): string | undefined {
@@ -195,6 +258,16 @@ function booleanEnv(name: string): boolean | undefined {
195
258
  throw new Error(`Invalid ${name}: ${JSON.stringify(raw)}. Expected 'true' or 'false'.`);
196
259
  }
197
260
 
261
+ function integerEnv(name: string): number | undefined {
262
+ const raw = trimmedEnv(name);
263
+ if (raw === undefined) return undefined;
264
+ const parsed = Number(raw);
265
+ if (!Number.isInteger(parsed)) {
266
+ throw new Error(`Invalid ${name}: ${JSON.stringify(raw)}. Expected an integer.`);
267
+ }
268
+ return parsed;
269
+ }
270
+
198
271
  function isPlainObject(value: unknown): value is Record<string, unknown> {
199
272
  return typeof value === "object" && value !== null && !Array.isArray(value);
200
273
  }
package/src/cookies.ts ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Process-local Cloudflare cookie store for ChatGPT endpoints.
3
+ *
4
+ * Mirrors the constraints in codex chatgpt_cloudflare_cookies.rs:
5
+ * - HTTPS only
6
+ * - ChatGPT host allowlist
7
+ * - Cloudflare infrastructure cookie name allowlist only
8
+ * - Never store account/session/auth cookies
9
+ */
10
+
11
+ export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
12
+
13
+ interface Cookie {
14
+ name: string;
15
+ value: string;
16
+ domain: string;
17
+ path: string;
18
+ secure: boolean;
19
+ }
20
+
21
+ const ALLOWED_HOSTS = ["chatgpt.com", "chat.openai.com", "chatgpt-staging.com"];
22
+ const HOST_SUFFIXES = [".chatgpt.com", ".chatgpt-staging.com"];
23
+
24
+ const ALLOWED_COOKIE_NAMES = new Set([
25
+ "__cf_bm",
26
+ "__cflb",
27
+ "__cfruid",
28
+ "__cfseq",
29
+ "__cfwaitingroom",
30
+ "_cfuvid",
31
+ "cf_clearance",
32
+ "cf_ob_info",
33
+ "cf_use_ob",
34
+ ]);
35
+
36
+ function isAllowedHost(host: string): boolean {
37
+ if (ALLOWED_HOSTS.includes(host)) return true;
38
+ return HOST_SUFFIXES.some((suffix) => host.endsWith(suffix));
39
+ }
40
+
41
+ function isAllowedCookieName(name: string): boolean {
42
+ if (ALLOWED_COOKIE_NAMES.has(name)) return true;
43
+ return name.startsWith("cf_chl_");
44
+ }
45
+
46
+ function parseSetCookieHeader(header: string, url: URL): Cookie | undefined {
47
+ const [firstPart] = header.split(";");
48
+ if (!firstPart) return undefined;
49
+ const eqIndex = firstPart.indexOf("=");
50
+ if (eqIndex <= 0) return undefined;
51
+ const name = firstPart.slice(0, eqIndex).trim();
52
+ const value = firstPart.slice(eqIndex + 1).trim();
53
+ if (!isAllowedCookieName(name)) return undefined;
54
+ return {
55
+ name,
56
+ value,
57
+ domain: url.hostname,
58
+ path: "/",
59
+ secure: true,
60
+ };
61
+ }
62
+
63
+ function cookieKey(cookie: Cookie): string {
64
+ return `${cookie.domain}:${cookie.path}:${cookie.name}`;
65
+ }
66
+
67
+ export class ChatGptCloudflareCookieStore {
68
+ private cookies = new Map<string, Cookie>();
69
+
70
+ setCookies(setCookieHeaders: string[], url: URL): void {
71
+ if (url.protocol !== "https:") return;
72
+ if (!isAllowedHost(url.hostname)) return;
73
+ for (const header of setCookieHeaders) {
74
+ const cookie = parseSetCookieHeader(header, url);
75
+ if (cookie) {
76
+ this.cookies.set(cookieKey(cookie), cookie);
77
+ }
78
+ }
79
+ }
80
+
81
+ cookiesForUrl(url: URL): string | undefined {
82
+ if (url.protocol !== "https:") return undefined;
83
+ if (!isAllowedHost(url.hostname)) return undefined;
84
+ const parts: string[] = [];
85
+ for (const cookie of this.cookies.values()) {
86
+ if (cookie.domain === url.hostname) {
87
+ parts.push(`${cookie.name}=${cookie.value}`);
88
+ }
89
+ }
90
+ return parts.length > 0 ? parts.join("; ") : undefined;
91
+ }
92
+ }
93
+
94
+ const SHARED_STORE = new ChatGptCloudflareCookieStore();
95
+
96
+ export function getSharedCookieStore(): ChatGptCloudflareCookieStore {
97
+ return SHARED_STORE;
98
+ }
99
+
100
+ export function wrapFetchWithCookies(fetchImpl: FetchLike): FetchLike {
101
+ return async (input, init) => {
102
+ const url = new URL(
103
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url,
104
+ );
105
+ const cookieHeader = SHARED_STORE.cookiesForUrl(url);
106
+ const headers = new Headers(input instanceof Request ? input.headers : undefined);
107
+ new Headers(init?.headers).forEach((value, key) => headers.set(key, value));
108
+ if (cookieHeader) {
109
+ const existing = headers.get("cookie");
110
+ headers.set("cookie", existing ? `${existing}; ${cookieHeader}` : cookieHeader);
111
+ }
112
+
113
+ const response = await fetchImpl(input, { ...init, headers });
114
+
115
+ const setCookie =
116
+ response.headers.getSetCookie?.() ?? parseSetCookieLegacy(response.headers.get("set-cookie"));
117
+ if (setCookie.length > 0) {
118
+ SHARED_STORE.setCookies(setCookie, url);
119
+ }
120
+ return response;
121
+ };
122
+ }
123
+
124
+ function parseSetCookieLegacy(value: string | null): string[] {
125
+ if (!value) return [];
126
+ // Split on comma, but Set-Cookie values rarely contain bare commas in CF cookies.
127
+ return value
128
+ .split(",")
129
+ .map((s) => s.trim())
130
+ .filter(Boolean);
131
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,56 @@
1
+ export type CodexErrorKind = "auth" | "rate_limit" | "transport" | "timeout" | "schema" | "unknown";
2
+
3
+ export class CodexError extends Error {
4
+ readonly kind: CodexErrorKind;
5
+ readonly status?: number;
6
+
7
+ constructor(kind: CodexErrorKind, message: string, status?: number) {
8
+ super(message);
9
+ this.name = "CodexError";
10
+ this.kind = kind;
11
+ if (status !== undefined) this.status = status;
12
+ }
13
+ }
14
+
15
+ export function classifyError(error: unknown): CodexErrorKind {
16
+ if (error instanceof CodexError) return error.kind;
17
+ if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) {
18
+ return "timeout";
19
+ }
20
+ return "unknown";
21
+ }
22
+
23
+ export function classifyHttpStatus(status: number): CodexErrorKind {
24
+ if (status === 401 || status === 403) return "auth";
25
+ if (status === 429) return "rate_limit";
26
+ return "transport";
27
+ }
28
+
29
+ export function formatHttpErrorBody(text: string, mode: "responses" | "standalone"): string {
30
+ if (isCloudflareChallenge(text)) {
31
+ const advice =
32
+ mode === "standalone"
33
+ ? "Use codex_search for search, or retry after Codex/ChatGPT has refreshed its Cloudflare clearance."
34
+ : "Retry after Codex/ChatGPT has refreshed its Cloudflare clearance.";
35
+ return `Cloudflare challenge blocked the Codex request. ${advice}`;
36
+ }
37
+ return text;
38
+ }
39
+
40
+ export function isCloudflareChallenge(text: string): boolean {
41
+ const lower = text.toLowerCase();
42
+ return (
43
+ lower.includes("/cdn-cgi/challenge-platform/") ||
44
+ lower.includes("cf_chl_") ||
45
+ lower.includes("enable javascript and cookies to continue")
46
+ );
47
+ }
48
+
49
+ export function classifyEventErrorMessage(message: string): CodexErrorKind {
50
+ const lower = message.toLowerCase();
51
+ if (/rate[- ]?limit|too many requests|quota|429/.test(lower)) return "rate_limit";
52
+ if (/auth|unauthori[sz]ed|forbidden|401|403/.test(lower)) return "auth";
53
+ if (/timeout|timed out/.test(lower)) return "timeout";
54
+ if (/network|connection|disconnect|transport|fetch failed/.test(lower)) return "transport";
55
+ return "unknown";
56
+ }
@@ -0,0 +1,310 @@
1
+ import {
2
+ CodexError,
3
+ classifyEventErrorMessage,
4
+ classifyHttpStatus,
5
+ formatHttpErrorBody,
6
+ } from "../errors.ts";
7
+ import type { CodexTransport } from "../transport.ts";
8
+ import type {
9
+ CodexWebSearchResult,
10
+ CodexCitation,
11
+ CodexSearchCall,
12
+ SearchContextSize,
13
+ } from "./types.ts";
14
+
15
+ export interface ResponsesSearchOptions {
16
+ query: string;
17
+ model: string;
18
+ transport: CodexTransport;
19
+ externalWebAccess: boolean;
20
+ indexGatedWebAccess?: true;
21
+ searchContextSize?: SearchContextSize;
22
+ sessionId?: string;
23
+ threadId?: string;
24
+ signal?: AbortSignal;
25
+ onTextDelta?: (delta: string) => void;
26
+ }
27
+
28
+ interface SseEvent {
29
+ type: string;
30
+ data?: unknown;
31
+ raw?: string;
32
+ }
33
+
34
+ interface ResponseOutputText {
35
+ type?: string;
36
+ text?: string;
37
+ annotations?: Array<{
38
+ type?: string;
39
+ title?: string;
40
+ url?: string;
41
+ start_index?: number;
42
+ end_index?: number;
43
+ }>;
44
+ }
45
+
46
+ interface ResponseOutputItem {
47
+ id?: string;
48
+ type?: string;
49
+ status?: string;
50
+ role?: string;
51
+ action?: {
52
+ type?: string;
53
+ query?: string;
54
+ queries?: string[];
55
+ url?: string;
56
+ };
57
+ content?: ResponseOutputText[];
58
+ }
59
+
60
+ interface ResponseUsage {
61
+ input_tokens?: number;
62
+ output_tokens?: number;
63
+ total_tokens?: number;
64
+ }
65
+
66
+ interface ResponseEnvelope {
67
+ id?: string;
68
+ usage?: ResponseUsage;
69
+ }
70
+
71
+ interface ResponseEventData {
72
+ response?: ResponseEnvelope;
73
+ item?: ResponseOutputItem;
74
+ delta?: string;
75
+ error?: {
76
+ message?: string;
77
+ code?: string;
78
+ };
79
+ }
80
+
81
+ export async function runResponsesSearch(
82
+ options: ResponsesSearchOptions,
83
+ ): Promise<CodexWebSearchResult> {
84
+ const {
85
+ transport,
86
+ query,
87
+ model,
88
+ externalWebAccess,
89
+ searchContextSize,
90
+ sessionId,
91
+ threadId,
92
+ signal,
93
+ onTextDelta,
94
+ } = options;
95
+ const headers = transport.buildHeaders("text/event-stream");
96
+ if (sessionId) headers.set("session-id", sessionId);
97
+ if (threadId) {
98
+ headers.set("thread-id", threadId);
99
+ headers.set("x-client-request-id", threadId);
100
+ }
101
+
102
+ const webSearchTool: Record<string, unknown> = {
103
+ type: "web_search",
104
+ external_web_access: externalWebAccess,
105
+ search_context_size: searchContextSize ?? "medium",
106
+ };
107
+
108
+ const response = await transport.fetch(transport.resolveEndpoint("responses"), {
109
+ method: "POST",
110
+ headers,
111
+ body: JSON.stringify({
112
+ model,
113
+ instructions:
114
+ "You are a concise web search assistant. Use web search, answer the query, and preserve source citations from annotations.",
115
+ input: [
116
+ {
117
+ type: "message",
118
+ role: "user",
119
+ content: [{ type: "input_text", text: query }],
120
+ },
121
+ ],
122
+ tools: [webSearchTool],
123
+ tool_choice: "required",
124
+ parallel_tool_calls: true,
125
+ store: false,
126
+ stream: true,
127
+ include: [],
128
+ }),
129
+ signal,
130
+ });
131
+
132
+ if (!response.ok) {
133
+ const status = response.status;
134
+ const text = formatHttpErrorBody(await response.text(), "responses");
135
+ throw new CodexError(
136
+ classifyHttpStatus(status),
137
+ `Codex responses request failed: HTTP ${status}: ${text}`,
138
+ status,
139
+ );
140
+ }
141
+ if (!response.body) {
142
+ throw new Error("Codex responses response did not include a body");
143
+ }
144
+
145
+ let responseId: string | undefined;
146
+ let usage: ResponseUsage | undefined;
147
+ let streamedText = "";
148
+ const messageTextParts: string[] = [];
149
+ const searchCalls = new Map<string, CodexSearchCall>();
150
+ const citations = new Map<string, CodexCitation>();
151
+
152
+ for await (const event of parseSse(response.body)) {
153
+ const data = event.data as ResponseEventData | undefined;
154
+ if (!data) continue;
155
+
156
+ if (event.type === "response.created") {
157
+ responseId = data.response?.id;
158
+ continue;
159
+ }
160
+
161
+ if (event.type === "response.output_text.delta") {
162
+ const delta = data.delta ?? "";
163
+ streamedText += delta;
164
+ onTextDelta?.(delta);
165
+ continue;
166
+ }
167
+
168
+ if (event.type === "response.output_item.added" && data.item?.type === "web_search_call") {
169
+ const item = data.item;
170
+ if (item.id) {
171
+ searchCalls.set(item.id, {
172
+ id: item.id,
173
+ status: item.status,
174
+ });
175
+ }
176
+ continue;
177
+ }
178
+
179
+ if (event.type === "response.output_item.done") {
180
+ collectOutputItem(data.item, searchCalls, messageTextParts, citations);
181
+ continue;
182
+ }
183
+
184
+ if (event.type === "response.completed") {
185
+ usage = data.response?.usage;
186
+ continue;
187
+ }
188
+
189
+ if (event.type === "response.failed") {
190
+ const message = data.error?.message ?? data.error?.code ?? "Codex web search failed";
191
+ throw new CodexError(classifyEventErrorMessage(message), message);
192
+ }
193
+ }
194
+
195
+ return {
196
+ responseId,
197
+ model,
198
+ text: messageTextParts.join("") || streamedText,
199
+ searchCalls: [...searchCalls.values()],
200
+ citations: [...citations.values()],
201
+ usage: usage
202
+ ? {
203
+ inputTokens: usage.input_tokens,
204
+ outputTokens: usage.output_tokens,
205
+ totalTokens: usage.total_tokens,
206
+ }
207
+ : undefined,
208
+ };
209
+ }
210
+
211
+ async function* parseSse(body: ReadableStream<Uint8Array>): AsyncGenerator<SseEvent> {
212
+ const reader = body.getReader();
213
+ const decoder = new TextDecoder();
214
+ let buffer = "";
215
+
216
+ let doneReading = false;
217
+ try {
218
+ while (true) {
219
+ const { done, value } = await reader.read();
220
+ if (done) {
221
+ doneReading = true;
222
+ break;
223
+ }
224
+ buffer += decoder.decode(value, { stream: true });
225
+
226
+ let separator = findSseSeparator(buffer);
227
+ while (separator) {
228
+ const frame = buffer.slice(0, separator.index);
229
+ buffer = buffer.slice(separator.index + separator.length);
230
+ const event = parseSseFrame(frame);
231
+ if (event) yield event;
232
+ separator = findSseSeparator(buffer);
233
+ }
234
+ }
235
+ } finally {
236
+ if (!doneReading) await reader.cancel().catch(() => undefined);
237
+ reader.releaseLock();
238
+ }
239
+
240
+ buffer += decoder.decode();
241
+ const event = parseSseFrame(buffer);
242
+ if (event) yield event;
243
+ }
244
+
245
+ function findSseSeparator(buffer: string): { index: number; length: number } | undefined {
246
+ const match = /\r?\n\r?\n/.exec(buffer);
247
+ return match?.index === undefined ? undefined : { index: match.index, length: match[0].length };
248
+ }
249
+
250
+ function parseSseFrame(frame: string): SseEvent | undefined {
251
+ const lines = frame.split(/\r?\n/);
252
+ let type = "";
253
+ const dataLines: string[] = [];
254
+
255
+ for (const line of lines) {
256
+ if (line.startsWith("event:")) {
257
+ type = line.slice("event:".length).trim();
258
+ } else if (line.startsWith("data:")) {
259
+ dataLines.push(line.slice("data:".length).trimStart());
260
+ }
261
+ }
262
+
263
+ if (dataLines.length === 0) return undefined;
264
+ const raw = dataLines.join("\n");
265
+ if (raw === "[DONE]") return undefined;
266
+
267
+ try {
268
+ return { type, data: JSON.parse(raw) };
269
+ } catch {
270
+ return { type, raw };
271
+ }
272
+ }
273
+
274
+ function collectOutputItem(
275
+ item: ResponseOutputItem | undefined,
276
+ searchCalls: Map<string, CodexSearchCall>,
277
+ messageTextParts: string[],
278
+ citations: Map<string, CodexCitation>,
279
+ ): void {
280
+ if (!item) return;
281
+
282
+ if (item.type === "web_search_call") {
283
+ const key = item.id ?? `search-${searchCalls.size + 1}`;
284
+ const query = item.action?.query ?? item.action?.queries?.join(", ");
285
+ searchCalls.set(key, {
286
+ id: item.id,
287
+ status: item.status,
288
+ query,
289
+ url: item.action?.url,
290
+ actionType: item.action?.type,
291
+ });
292
+ return;
293
+ }
294
+
295
+ if (item.type !== "message" || item.role !== "assistant") return;
296
+
297
+ for (const part of item.content ?? []) {
298
+ if (part.type !== "output_text") continue;
299
+ messageTextParts.push(part.text ?? "");
300
+ for (const annotation of part.annotations ?? []) {
301
+ if (annotation.type !== "url_citation" || !annotation.url) continue;
302
+ citations.set(annotation.url, {
303
+ title: annotation.title,
304
+ url: annotation.url,
305
+ startIndex: annotation.start_index,
306
+ endIndex: annotation.end_index,
307
+ });
308
+ }
309
+ }
310
+ }