pi-codex-search 0.1.2 → 0.1.3

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/index.ts CHANGED
@@ -5,29 +5,42 @@ import {
5
5
  type ExtensionContext,
6
6
  type Theme,
7
7
  } from "@earendil-works/pi-coding-agent";
8
+ import type { Static } from "@earendil-works/pi-ai";
8
9
  import { Text } from "@earendil-works/pi-tui";
9
10
  import {
11
+ assertSupportedStandaloneCombination,
10
12
  classifyError,
11
- type CodexCitation,
12
- type CodexErrorKind,
13
- type CodexSearchCall,
13
+ CodexError,
14
+ createRefStore,
15
+ createTransport,
14
16
  extractAccountIdFromToken,
15
17
  fetchCodexModels,
16
- fetchCodexWebSearch,
18
+ runResponsesSearch,
19
+ runStandaloneCommands,
17
20
  selectDefaultModel,
21
+ type CodexCitation,
22
+ type CodexErrorKind,
23
+ type CodexSearchCall,
18
24
  type SearchContextSize,
25
+ type StandaloneCommandsOptions,
19
26
  } from "./src/codex.ts";
20
27
  import { registerSettingsCommand } from "./src/command.ts";
21
- import { type Freshness, loadConfig, type ResolvedConfig } from "./src/config.ts";
28
+ import {
29
+ STANDALONE_TOOL_NAME,
30
+ type Freshness,
31
+ isProjectTrustedContext,
32
+ loadConfig,
33
+ type ResolvedConfig,
34
+ } from "./src/config.ts";
22
35
 
23
36
  const OPENAI_CODEX_PROVIDER = "openai-codex";
24
- const MAX_QUERIES = 5;
25
37
 
26
38
  interface QuerySuccess {
27
39
  query: string;
28
40
  text: string;
29
41
  citations: CodexCitation[];
30
42
  searchCalls: CodexSearchCall[];
43
+ refIds?: Record<string, string>;
31
44
  responseId?: string;
32
45
  usage?: {
33
46
  inputTokens?: number;
@@ -42,6 +55,12 @@ interface QueryFailure {
42
55
  message: string;
43
56
  }
44
57
 
58
+ interface StandaloneCallPlan {
59
+ query: string;
60
+ buildOptions: () => StandaloneCommandsOptions;
61
+ openedUrl?: string;
62
+ }
63
+
45
64
  interface WebSearchFailureDetail {
46
65
  kind: CodexErrorKind;
47
66
  message: string;
@@ -49,9 +68,11 @@ interface WebSearchFailureDetail {
49
68
 
50
69
  interface WebSearchDetails {
51
70
  model: string;
71
+ api: string;
52
72
  freshness: Freshness;
53
73
  searchContextSize: SearchContextSize;
54
74
  queryCount: number;
75
+ queries: string[];
55
76
  failedQueryCount: number;
56
77
  successes: QuerySuccess[];
57
78
  failures: QueryFailure[];
@@ -61,63 +82,422 @@ interface WebSearchDetails {
61
82
  total?: number;
62
83
  }
63
84
 
64
- function buildTool(config: ResolvedConfig) {
65
- return defineTool({
66
- name: config.toolName,
67
- label: "Codex Search",
68
- description:
69
- "Search the web using the user's configured ChatGPT Codex subscription. Accepts one or more queries in a single call; results are returned grouped by query with sources.",
70
- promptSnippet: `${config.toolName}: search the web using the configured ChatGPT Codex subscription.`,
71
- promptGuidelines: [
72
- `Use ${config.toolName} when current or source-backed information is needed.`,
73
- `Batch up to ${MAX_QUERIES} related queries in one call when grouped comparison matters; use separate calls when independent results unblock the next step.`,
74
- "Do not ask the user for an access token; the tool uses pi's configured OpenAI Codex subscription.",
75
- ],
76
- parameters: Type.Object({
77
- queries: Type.Array(Type.String({ minLength: 1 }), {
78
- minItems: 1,
79
- maxItems: MAX_QUERIES,
80
- description: `One or more search queries to run in parallel (max ${MAX_QUERIES}).`,
85
+ function buildToolDescription(config: ResolvedConfig): string {
86
+ const toolName = config.toolName;
87
+ if (config.searchApi === "standalone") {
88
+ return `${toolName}: standalone webpage actions for explicit page inspection: open one URL, find text, click link ids, screenshot pages, or run finance/weather/sports/time lookups. Not for web search.`;
89
+ }
90
+ return `${toolName}: search the web using the configured ChatGPT Codex subscription.`;
91
+ }
92
+
93
+ function buildSearchParametersSchema(config: ResolvedConfig) {
94
+ return Type.Object({
95
+ queries: Type.Array(Type.String({ minLength: 1 }), {
96
+ minItems: 1,
97
+ maxItems: config.batchSize,
98
+ description: `One or more search queries to run in parallel (max ${config.batchSize}).`,
99
+ }),
100
+ search_context_size: Type.Optional(
101
+ StringEnum(["low", "medium", "high"] as const, {
102
+ description: "Amount of web context to retrieve. Defaults to medium.",
81
103
  }),
82
- search_context_size: Type.Optional(
83
- StringEnum(["low", "medium", "high"] as const, {
84
- description: `Amount of web context to retrieve. Defaults to ${config.defaultSearchContextSize}.`,
85
- }),
86
- ),
87
- freshness: Type.Optional(
88
- StringEnum(["cached", "live"] as const, {
89
- description: `Use 'live' for time-sensitive queries; 'cached' for stable topics. Defaults to ${config.defaultFreshness}.`,
90
- }),
91
- ),
104
+ ),
105
+ freshness: Type.Optional(
106
+ StringEnum(["cached", "indexed", "live"] as const, {
107
+ description:
108
+ "Use 'live' for time-sensitive queries; 'indexed' for OpenAI-indexed web access; 'cached' for stable topics. Defaults to live.",
109
+ }),
110
+ ),
111
+ });
112
+ }
113
+
114
+ const StandaloneParametersSchema = Type.Object({
115
+ search_context_size: Type.Optional(
116
+ StringEnum(["medium", "high"] as const, {
117
+ description:
118
+ 'Amount of web context to retrieve. Defaults to medium. Standalone mode disables "low".',
119
+ }),
120
+ ),
121
+ freshness: Type.Optional(
122
+ StringEnum(["cached", "indexed", "live"] as const, {
123
+ description:
124
+ "Use 'live' for time-sensitive queries; 'indexed' for OpenAI-indexed web access; 'cached' for stable topics. Defaults to live.",
92
125
  }),
126
+ ),
127
+ urls: Type.Optional(
128
+ Type.Array(Type.String({ minLength: 1 }), {
129
+ maxItems: 1,
130
+ description: "One URL to open/fetch directly.",
131
+ }),
132
+ ),
133
+ find: Type.Optional(
134
+ Type.Array(
135
+ Type.Object({
136
+ url: Type.String({ minLength: 1 }),
137
+ pattern: Type.String({ minLength: 1 }),
138
+ }),
139
+ { maxItems: 1, description: "Find a pattern within a previously opened webpage." },
140
+ ),
141
+ ),
142
+ click: Type.Optional(
143
+ Type.Array(
144
+ Type.Object({
145
+ url: Type.String({ minLength: 1 }),
146
+ id: Type.Integer({ minimum: 0 }),
147
+ }),
148
+ { maxItems: 1, description: "Follow one link id from a previously opened page." },
149
+ ),
150
+ ),
151
+ screenshot: Type.Optional(
152
+ Type.Array(
153
+ Type.Object({
154
+ url: Type.String({ minLength: 1 }),
155
+ pageno: Type.Integer({ minimum: 0 }),
156
+ }),
157
+ { maxItems: 1, description: "Capture one screenshot of a previously opened page." },
158
+ ),
159
+ ),
160
+ finance: Type.Optional(
161
+ Type.Array(
162
+ Type.Object({
163
+ ticker: Type.String({ minLength: 1 }),
164
+ type: StringEnum(["equity", "fund", "crypto", "index"] as const),
165
+ market: Type.Optional(Type.String()),
166
+ }),
167
+ { maxItems: 1, description: "Look up one stock/ETF/crypto/index price." },
168
+ ),
169
+ ),
170
+ weather: Type.Optional(
171
+ Type.Array(
172
+ Type.Object({
173
+ location: Type.String({ minLength: 1 }),
174
+ start: Type.Optional(Type.String()),
175
+ duration: Type.Optional(Type.Integer({ minimum: 0 })),
176
+ }),
177
+ { maxItems: 1, description: "Look up one weather forecast." },
178
+ ),
179
+ ),
180
+ sports: Type.Optional(
181
+ Type.Array(
182
+ Type.Object({
183
+ fn: StringEnum(["schedule", "standings"] as const),
184
+ league: StringEnum([
185
+ "nba",
186
+ "wnba",
187
+ "nfl",
188
+ "nhl",
189
+ "mlb",
190
+ "epl",
191
+ "ncaamb",
192
+ "ncaawb",
193
+ "ipl",
194
+ ] as const),
195
+ team: Type.Optional(Type.String()),
196
+ opponent: Type.Optional(Type.String()),
197
+ date_from: Type.Optional(Type.String()),
198
+ date_to: Type.Optional(Type.String()),
199
+ num_games: Type.Optional(Type.Integer({ minimum: 0 })),
200
+ locale: Type.Optional(Type.String()),
201
+ }),
202
+ { maxItems: 1, description: "Look up one sports schedule or standings request." },
203
+ ),
204
+ ),
205
+ time: Type.Optional(
206
+ Type.Array(
207
+ Type.Object({
208
+ utc_offset: Type.String({ minLength: 1 }),
209
+ }),
210
+ { maxItems: 1, description: "Get time for one UTC offset." },
211
+ ),
212
+ ),
213
+ });
214
+
215
+ type StandaloneParameters = Static<typeof StandaloneParametersSchema>;
216
+
217
+ type ToolParameters = Partial<
218
+ Omit<StandaloneParameters, "queries" | "search_context_size" | "freshness">
219
+ > & {
220
+ queries?: string[];
221
+ image_queries?: string[];
222
+ search_context_size?: SearchContextSize;
223
+ freshness?: Freshness;
224
+ };
225
+
226
+ function buildToolParameters(config: ResolvedConfig) {
227
+ return config.searchApi === "standalone"
228
+ ? StandaloneParametersSchema
229
+ : buildSearchParametersSchema(config);
230
+ }
93
231
 
94
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
95
- const queries = params.queries.map((q) => q.trim()).filter((q) => q.length > 0);
96
- if (queries.length === 0) {
97
- throw new Error("queries must contain at least one non-empty entry");
232
+ function buildTool(config: ResolvedConfig) {
233
+ return defineTool({
234
+ name: config.toolName,
235
+ label: config.searchApi === "standalone" ? "Codex Standalone Web" : "Codex Search",
236
+ description: buildToolDescription(config),
237
+ promptSnippet:
238
+ config.searchApi === "standalone"
239
+ ? `${config.toolName}: use only for explicit standalone webpage actions: open one URL, find text in that opened page, follow page link ids, take screenshots, or run finance/weather/sports/time lookups. Do not use for web search.`
240
+ : `${config.toolName}: search the web using the configured ChatGPT Codex subscription.`,
241
+ promptGuidelines:
242
+ config.searchApi === "standalone"
243
+ ? [
244
+ `Use ${config.toolName} only when the user explicitly asks to open, read, inspect, find within, click inside, screenshot, or run finance/weather/sports/time lookup actions.`,
245
+ "Do not use codex_standalone_web for ordinary web search, source gathering, or batches; use codex_search for search queries.",
246
+ "Send exactly one standalone action per tool call. Do not combine urls/find/click/screenshot/lookup actions in one call.",
247
+ "For webpage workflows, first open one exact URL with urls. After a successful open, do not open the same page again unless the user asks to reload it.",
248
+ "For follow-up find/click/screenshot, use the same URL string the user opened. Do not switch between www and non-www hosts, add/remove trailing paths, or upgrade search_context_size just to retry.",
249
+ "Do not use search_context_size low in standalone; use medium unless the user explicitly asks for high.",
250
+ "If a follow-up page action fails because the page was not opened in this session, open the exact requested URL once, then retry the follow-up once.",
251
+ "Do not ask the user for an access token; the tool uses pi's configured OpenAI Codex subscription.",
252
+ ]
253
+ : [
254
+ `Use ${config.toolName} when current or source-backed information is needed.`,
255
+ `Batch up to ${config.batchSize} related queries in one call when grouped comparison matters; use separate calls when independent results unblock the next step.`,
256
+ config.standaloneEnabled
257
+ ? "Use codex_standalone_web only when the user explicitly asks to open/read/inspect a webpage, find text inside an opened page, click a page link id, take a screenshot, or run finance/weather/sports/time lookups."
258
+ : "codex_standalone_web is not enabled in this session; if the user asks for webpage actions, say that the Standalone web tool must be enabled in /codex-search-settings.",
259
+ "Do not call codex_standalone_web merely to improve or duplicate a codex_search result.",
260
+ "Choose freshness per request: use 'live' for news, prices, releases, availability, laws, schedules, or other time-sensitive facts; use 'cached' for stable facts and docs; use 'indexed' when OpenAI-indexed web access is enough but live browsing is not needed.",
261
+ "Do not ask the user for an access token; the tool uses pi's configured OpenAI Codex subscription.",
262
+ ],
263
+ parameters: buildToolParameters(config),
264
+
265
+ async execute(_toolCallId, params: ToolParameters, signal, onUpdate, ctx) {
266
+ const queries = params.queries?.map((q) => q.trim()).filter((q) => q.length > 0) ?? [];
267
+ const imageQueries =
268
+ params.image_queries?.map((q) => q.trim()).filter((q) => q.length > 0) ?? [];
269
+ const urls = params.urls?.map((u) => u.trim()).filter((u) => u.length > 0) ?? [];
270
+ const findCommands = params.find?.filter((c) => c.url.trim() && c.pattern.trim()) ?? [];
271
+ const clickCommands = params.click?.filter((c) => c.url.trim()) ?? [];
272
+ const screenshotCommands = params.screenshot?.filter((c) => c.url.trim()) ?? [];
273
+ const financeCommands = params.finance ?? [];
274
+ const weatherCommands = params.weather ?? [];
275
+ const sportsCommands = params.sports ?? [];
276
+ const timeCommands = params.time?.map((c) => ({ utc_offset: c.utc_offset })) ?? [];
277
+ if (
278
+ queries.length === 0 &&
279
+ imageQueries.length === 0 &&
280
+ urls.length === 0 &&
281
+ findCommands.length === 0 &&
282
+ clickCommands.length === 0 &&
283
+ screenshotCommands.length === 0 &&
284
+ financeCommands.length === 0 &&
285
+ weatherCommands.length === 0 &&
286
+ sportsCommands.length === 0 &&
287
+ timeCommands.length === 0
288
+ ) {
289
+ throw new CodexError(
290
+ "schema",
291
+ "At least one query, url, page action, or lookup command is required",
292
+ );
98
293
  }
99
294
 
100
295
  const token = await ctx.modelRegistry.getApiKeyForProvider(OPENAI_CODEX_PROVIDER);
101
296
  if (!token) {
102
- const err = new Error(
297
+ const err = new CodexError(
298
+ "auth",
103
299
  "OpenAI Codex subscription is not configured. Run `/login openai-codex` and choose ChatGPT Plus/Pro.",
104
300
  );
105
- (err as Error & { kind?: CodexErrorKind }).kind = "auth";
106
301
  throw err;
107
302
  }
108
303
 
109
304
  const accountId = getConfiguredAccountId(ctx, token);
110
305
  if (!accountId) {
111
- const err = new Error(
306
+ throw new CodexError(
307
+ "auth",
112
308
  "OpenAI Codex account id was not found in stored credentials or access token. Re-run `/login openai-codex`.",
113
309
  );
114
- (err as Error & { kind?: CodexErrorKind }).kind = "auth";
115
- throw err;
116
310
  }
117
311
 
118
312
  const model = await resolveSearchModel(ctx, token, accountId, config, signal);
119
313
  const freshness = params.freshness ?? config.defaultFreshness;
120
- const searchContextSize = params.search_context_size ?? config.defaultSearchContextSize;
314
+ let searchContextSize = params.search_context_size ?? config.defaultSearchContextSize;
315
+ if (config.searchApi === "standalone") {
316
+ if (params.search_context_size === "low") {
317
+ assertSupportedStandaloneCombination("low", freshness);
318
+ }
319
+ if (searchContextSize === "low") searchContextSize = "medium";
320
+ assertSupportedStandaloneCombination(searchContextSize, freshness);
321
+ }
322
+
323
+ const transport = createTransport({
324
+ token,
325
+ accountId,
326
+ baseUrl: config.baseUrl,
327
+ });
328
+
329
+ if (config.searchApi === "standalone") {
330
+ if (queries.length > 0 || imageQueries.length > 0) {
331
+ throw new CodexError(
332
+ "schema",
333
+ "codex_standalone_web does not support search or image search queries. Use codex_search for web search.",
334
+ );
335
+ }
336
+ const refStore = createRefStore();
337
+ const sessionDir = ctx.sessionManager.getSessionDir();
338
+ await refStore.load(sessionDir);
339
+
340
+ const baseStandaloneOptions = {
341
+ model,
342
+ transport,
343
+ sessionId: ctx.sessionManager.getSessionId(),
344
+ freshness,
345
+ searchContextSize,
346
+ maxOutputTokens: 8000,
347
+ signal,
348
+ };
349
+ const standaloneCalls: StandaloneCallPlan[] = [
350
+ ...queries.map((q) => ({
351
+ query: q,
352
+ buildOptions: () => ({ ...baseStandaloneOptions, searchQuery: [{ q }] }),
353
+ })),
354
+ ...imageQueries.map((q) => ({
355
+ query: `image: ${q}`,
356
+ buildOptions: () => ({ ...baseStandaloneOptions, imageQuery: [{ q }] }),
357
+ })),
358
+ ...urls.map((url) => ({
359
+ query: `open: ${url}`,
360
+ openedUrl: url,
361
+ buildOptions: () => ({
362
+ ...baseStandaloneOptions,
363
+ open: [{ refId: refStore.resolveRefId(url) ?? url }],
364
+ }),
365
+ })),
366
+ ...findCommands.map((c: { url: string; pattern: string }) => ({
367
+ query: `find "${c.pattern}" in ${c.url}`,
368
+ buildOptions: () => ({
369
+ ...baseStandaloneOptions,
370
+ find: [
371
+ {
372
+ refId: resolveStandalonePageRef(refStore, c.url, "find"),
373
+ pattern: c.pattern,
374
+ },
375
+ ],
376
+ }),
377
+ })),
378
+ ...clickCommands.map((c: { url: string; id: number }) => ({
379
+ query: `click ${c.id} in ${c.url}`,
380
+ buildOptions: () => ({
381
+ ...baseStandaloneOptions,
382
+ click: [{ refId: resolveStandalonePageRef(refStore, c.url, "click"), id: c.id }],
383
+ }),
384
+ })),
385
+ ...screenshotCommands.map((c: { url: string; pageno: number }) => ({
386
+ query: `screenshot ${c.pageno} of ${c.url}`,
387
+ buildOptions: () => ({
388
+ ...baseStandaloneOptions,
389
+ screenshot: [
390
+ {
391
+ refId: resolveStandalonePageRef(refStore, c.url, "screenshot"),
392
+ pageno: c.pageno,
393
+ },
394
+ ],
395
+ }),
396
+ })),
397
+ ...financeCommands.map((c) => ({
398
+ query: `finance: ${c.ticker}`,
399
+ buildOptions: () => ({ ...baseStandaloneOptions, finance: [c] }),
400
+ })),
401
+ ...weatherCommands.map((c) => ({
402
+ query: `weather: ${c.location}`,
403
+ buildOptions: () => ({ ...baseStandaloneOptions, weather: [c] }),
404
+ })),
405
+ ...sportsCommands.map((c) => ({
406
+ query: `sports: ${c.fn} ${c.league}${c.team ? ` ${c.team}` : ""}`,
407
+ buildOptions: () => ({ ...baseStandaloneOptions, sports: [c] }),
408
+ })),
409
+ ...timeCommands.map((c) => ({
410
+ query: `time: ${c.utc_offset}`,
411
+ buildOptions: () => ({ ...baseStandaloneOptions, time: [c] }),
412
+ })),
413
+ ];
414
+
415
+ const total = standaloneCalls.length;
416
+ if (total > 1) {
417
+ throw new CodexError(
418
+ "schema",
419
+ `${config.toolName} accepts exactly one standalone action per tool call. Split the request or use codex_search for batched search.`,
420
+ );
421
+ }
422
+ let completed = 0;
423
+ const emitPartial = (partialText: string) => {
424
+ onUpdate?.({
425
+ content: [{ type: "text", text: partialText }],
426
+ details: buildDetails(config, model, freshness, searchContextSize, [], [], {
427
+ partial: true,
428
+ completed,
429
+ total,
430
+ }),
431
+ });
432
+ };
433
+ if (total > 1) emitPartial(formatProgress(completed, total));
434
+
435
+ const successes: QuerySuccess[] = [];
436
+ const failures: QueryFailure[] = [];
437
+ for (const call of standaloneCalls) {
438
+ try {
439
+ const result = await runStandaloneCommands(call.buildOptions());
440
+ if (call.openedUrl) {
441
+ const refId = selectStandalonePageRefId(result.refIds);
442
+ if (refId) await refStore.remember(call.openedUrl, refId);
443
+ }
444
+ const success: QuerySuccess = {
445
+ query: call.query,
446
+ text: result.text,
447
+ citations: result.citations,
448
+ searchCalls: result.searchCalls,
449
+ };
450
+ if (result.refIds) success.refIds = result.refIds;
451
+ if (result.usage) success.usage = result.usage;
452
+ successes.push(success);
453
+ } catch (error) {
454
+ const kind = classifyError(error);
455
+ const message = error instanceof Error ? error.message : String(error);
456
+ failures.push({ query: call.query, kind, message });
457
+ } finally {
458
+ completed += 1;
459
+ if (total > 1) emitPartial(formatProgress(completed, total));
460
+ }
461
+ }
462
+
463
+ if (successes.length === 0) {
464
+ const primary = failures[0];
465
+ const summary =
466
+ failures.length === 1
467
+ ? (primary?.message ?? "Codex standalone request failed")
468
+ : `All ${failures.length} ${config.toolName} standalone actions failed: ${failures
469
+ .map((f, i) => `${i + 1}. [${f.kind}] ${f.message}`)
470
+ .join("; ")}`;
471
+ const err = new CodexError(primary?.kind ?? "unknown", summary) as CodexError & {
472
+ failures?: QueryFailure[];
473
+ };
474
+ err.failures = failures;
475
+ throw err;
476
+ }
477
+
478
+ return {
479
+ content: [{ type: "text", text: formatToolText(successes, failures) }],
480
+ details: buildDetails(config, model, freshness, searchContextSize, successes, failures),
481
+ };
482
+ }
483
+
484
+ // Responses API path: only search queries are supported.
485
+ if (
486
+ urls.length > 0 ||
487
+ findCommands.length > 0 ||
488
+ clickCommands.length > 0 ||
489
+ screenshotCommands.length > 0 ||
490
+ imageQueries.length > 0 ||
491
+ financeCommands.length > 0 ||
492
+ weatherCommands.length > 0 ||
493
+ sportsCommands.length > 0 ||
494
+ timeCommands.length > 0
495
+ ) {
496
+ throw new CodexError(
497
+ "schema",
498
+ `Open webpage and domain lookups require codex_standalone_web. Current tool is codex_search. Search requests should stay on codex_search.`,
499
+ );
500
+ }
121
501
 
122
502
  const total = queries.length;
123
503
  let completed = 0;
@@ -126,25 +506,18 @@ function buildTool(config: ResolvedConfig) {
126
506
  const emitPartial = (partialText: string) => {
127
507
  onUpdate?.({
128
508
  content: [{ type: "text", text: partialText }],
129
- details: {
130
- model,
131
- freshness,
132
- searchContextSize,
133
- queryCount: total,
134
- failedQueryCount: 0,
135
- successes: [],
136
- failures: [],
509
+ details: buildDetails(config, model, freshness, searchContextSize, [], [], {
137
510
  partial: true,
138
511
  completed,
139
512
  total,
140
- } satisfies WebSearchDetails,
513
+ }),
141
514
  });
142
515
  };
143
516
 
144
517
  if (total > 1) emitPartial(formatProgress(completed, total));
145
518
 
146
519
  const settled = await Promise.allSettled(
147
- queries.map(async (query) => {
520
+ queries.map(async (query: string) => {
148
521
  const onTextDelta =
149
522
  total === 1
150
523
  ? (delta: string) => {
@@ -152,20 +525,18 @@ function buildTool(config: ResolvedConfig) {
152
525
  emitPartial(streamedText);
153
526
  }
154
527
  : undefined;
155
- const fetchOpts: Parameters<typeof fetchCodexWebSearch>[0] = {
156
- query,
157
- token,
158
- accountId,
159
- model,
160
- externalWebAccess: freshness === "live",
161
- searchContextSize,
162
- };
163
- if (config.baseUrl !== undefined) fetchOpts.baseUrl = config.baseUrl;
164
- if (signal) fetchOpts.signal = signal;
165
- if (onTextDelta) fetchOpts.onTextDelta = onTextDelta;
166
-
167
528
  try {
168
- return await fetchCodexWebSearch(fetchOpts);
529
+ return await runResponsesSearch({
530
+ query,
531
+ model,
532
+ transport,
533
+ externalWebAccess: freshness !== "cached",
534
+ searchContextSize,
535
+ sessionId: ctx.sessionManager.getSessionId(),
536
+ threadId: ctx.sessionManager.getSessionId(),
537
+ signal,
538
+ onTextDelta,
539
+ });
169
540
  } finally {
170
541
  completed += 1;
171
542
  if (total > 1) emitPartial(formatProgress(completed, total));
@@ -189,9 +560,9 @@ function buildTool(config: ResolvedConfig) {
189
560
  if (outcome.value.usage !== undefined) success.usage = outcome.value.usage;
190
561
  successes.push(success);
191
562
  } else {
192
- const kind = classifyError(outcome.reason);
193
- const message =
194
- outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
563
+ const reason = outcome.reason;
564
+ const kind = classifyError(reason);
565
+ const message = reason instanceof Error ? reason.message : String(reason);
195
566
  failures.push({ query, kind, message });
196
567
  }
197
568
  });
@@ -204,42 +575,39 @@ function buildTool(config: ResolvedConfig) {
204
575
  : `All ${failures.length} ${config.toolName} queries failed: ${failures
205
576
  .map((f, i) => `${i + 1}. [${f.kind}] ${f.message}`)
206
577
  .join("; ")}`;
207
- const err = new Error(summary) as Error & {
208
- kind?: CodexErrorKind;
578
+ const err = new CodexError(primary?.kind ?? "unknown", summary) as CodexError & {
209
579
  failures?: QueryFailure[];
210
580
  };
211
- err.kind = primary?.kind ?? "unknown";
212
581
  err.failures = failures;
213
582
  throw err;
214
583
  }
215
584
 
216
585
  return {
217
586
  content: [{ type: "text", text: formatToolText(successes, failures) }],
218
- details: {
219
- model,
220
- freshness,
221
- searchContextSize,
222
- queryCount: total,
223
- failedQueryCount: failures.length,
224
- successes,
225
- failures,
226
- } satisfies WebSearchDetails,
587
+ details: buildDetails(config, model, freshness, searchContextSize, successes, failures),
227
588
  };
228
589
  },
229
590
 
230
591
  renderCall(args, theme) {
231
- const queries = Array.isArray(args.queries) ? args.queries : [];
232
592
  const fresh = (args.freshness as string | undefined) ?? config.defaultFreshness;
233
- const ctxSize =
593
+ const requestedCtxSize =
234
594
  (args.search_context_size as string | undefined) ?? config.defaultSearchContextSize;
235
-
236
- let text = theme.fg("toolTitle", theme.bold(`${config.toolName} `));
237
- if (queries.length === 1) {
238
- text += theme.fg("accent", formatInline(queries[0] ?? "", 90));
595
+ const ctxSize =
596
+ config.searchApi === "standalone" && requestedCtxSize === "low"
597
+ ? "medium"
598
+ : requestedCtxSize;
599
+ const labels = buildCallLabels(args);
600
+
601
+ let text = theme.fg("toolTitle", theme.bold(config.toolName));
602
+ if (labels.length === 1) {
603
+ text += ` ${theme.fg("accent", formatInline(labels[0] ?? "", 90))}`;
239
604
  } else {
240
- text += theme.fg("accent", `${queries.length} queries`);
605
+ text += ` ${theme.fg("accent", `${labels.length} actions`)}`;
606
+ }
607
+ text += theme.fg("dim", ` [${config.searchApi}/${ctxSize}/${fresh}]`);
608
+ if (labels.length > 1) {
609
+ text += `\n${renderCallQueries(labels, theme)}`;
241
610
  }
242
- text += theme.fg("dim", ` [${ctxSize}/${fresh}]`);
243
611
  return new Text(text, 0, 0);
244
612
  },
245
613
 
@@ -259,24 +627,27 @@ function buildTool(config: ResolvedConfig) {
259
627
  const total = details.queryCount;
260
628
  const failed = details.failedQueryCount;
261
629
  const ok = total - failed;
262
- const sourceCount = details.successes.reduce((acc, s) => acc + s.citations.length, 0);
630
+ const sourceCount = details.successes.reduce((acc, s) => acc + countSuccessSources(s), 0);
263
631
 
264
632
  let header: string;
265
633
  if (ok === 0) {
266
634
  header = theme.fg("warning", `⚠ Web search failed (${details.failure?.kind ?? "unknown"})`);
267
635
  } else if (failed > 0) {
268
- header = theme.fg(
269
- "warning",
270
- `⚠ ${ok}/${total} queries succeeded · ${sourceCount} source${sourceCount === 1 ? "" : "s"}`,
271
- );
636
+ const sourceSuffix =
637
+ sourceCount > 0 ? ` · ${sourceCount} source${sourceCount === 1 ? "" : "s"}` : "";
638
+ header = theme.fg("warning", `⚠ ${ok}/${total} queries succeeded${sourceSuffix}`);
272
639
  } else {
273
640
  const querySuffix = total === 1 ? "" : ` across ${total} queries`;
274
- header = theme.fg(
275
- "success",
276
- `✓ ${sourceCount} source${sourceCount === 1 ? "" : "s"}${querySuffix}`,
277
- );
641
+ const sourceText =
642
+ sourceCount > 0
643
+ ? `${sourceCount} source${sourceCount === 1 ? "" : "s"}`
644
+ : "Search completed";
645
+ header = theme.fg("success", `✓ ${sourceText}${querySuffix}`);
278
646
  }
279
- header += theme.fg("muted", ` [${details.searchContextSize}/${details.freshness}]`);
647
+ header += theme.fg(
648
+ "muted",
649
+ ` [${details.api}/${details.searchContextSize}/${details.freshness}]`,
650
+ );
280
651
 
281
652
  if (!expanded) {
282
653
  const preview = renderCollapsedPreview(details, theme);
@@ -309,9 +680,20 @@ export default function codexWebSearchExtension(pi: ExtensionAPI) {
309
680
  registerSettingsCommand(pi);
310
681
 
311
682
  pi.on("session_start", async (_event, ctx) => {
312
- const config = await loadConfig(ctx.cwd);
683
+ const config = await loadConfig(ctx.cwd, isProjectTrustedContext(ctx));
313
684
  if (!config.enabled) return;
314
- pi.registerTool(buildTool(config));
685
+
686
+ pi.registerTool(buildTool({ ...config, searchApi: "responses", toolName: "codex_search" }));
687
+ if (config.standaloneEnabled) {
688
+ pi.registerTool(
689
+ buildTool({
690
+ ...config,
691
+ searchApi: "standalone",
692
+ toolName: STANDALONE_TOOL_NAME,
693
+ batchSize: 1,
694
+ }),
695
+ );
696
+ }
315
697
  });
316
698
  }
317
699
 
@@ -333,22 +715,44 @@ async function resolveSearchModel(
333
715
  if (config.model) return config.model;
334
716
  if (ctx.model?.provider === OPENAI_CODEX_PROVIDER) return ctx.model.id;
335
717
 
336
- const fetchOpts: Parameters<typeof fetchCodexModels>[0] = {
718
+ const models = await fetchCodexModels({
337
719
  token,
338
720
  accountId,
339
- };
340
- if (config.baseUrl !== undefined) fetchOpts.baseUrl = config.baseUrl;
341
- if (config.clientVersion !== undefined) fetchOpts.clientVersion = config.clientVersion;
342
- if (signal) fetchOpts.signal = signal;
343
-
344
- const models = await fetchCodexModels(fetchOpts);
721
+ baseUrl: config.baseUrl,
722
+ clientVersion: config.clientVersion,
723
+ signal,
724
+ });
345
725
  const model = selectDefaultModel(models);
346
726
  if (!model) {
347
- throw new Error("Codex model list is empty.");
727
+ throw new CodexError("unknown", "Codex model list is empty.");
348
728
  }
349
729
  return model;
350
730
  }
351
731
 
732
+ function buildDetails(
733
+ config: ResolvedConfig,
734
+ model: string,
735
+ freshness: Freshness,
736
+ searchContextSize: SearchContextSize,
737
+ successes: QuerySuccess[],
738
+ failures: QueryFailure[],
739
+ extra?: { partial?: boolean; completed?: number; total?: number },
740
+ ): WebSearchDetails {
741
+ const queries = successes.map((s) => s.query).concat(failures.map((f) => f.query));
742
+ return {
743
+ model,
744
+ api: config.searchApi,
745
+ freshness,
746
+ searchContextSize,
747
+ queryCount: queries.length,
748
+ queries,
749
+ failedQueryCount: failures.length,
750
+ successes,
751
+ failures,
752
+ ...extra,
753
+ };
754
+ }
755
+
352
756
  function formatProgress(completed: number, total: number): string {
353
757
  return `Searching ${completed}/${total} ${completed === total ? "complete" : "in progress"}`;
354
758
  }
@@ -374,10 +778,20 @@ function formatSuccessBlock(success: QuerySuccess, multiple: boolean): string {
374
778
  const title = citation.title?.trim() || citation.url;
375
779
  return `${index + 1}. ${title}: ${citation.url}`;
376
780
  });
377
- const body = sourceLines.length > 0 ? `${text}\n\nSources:\n${sourceLines.join("\n")}` : text;
781
+ const refLines = Object.keys(success.refIds ?? {}).map(
782
+ (refId, index) => `${index + 1}. ${refId}`,
783
+ );
784
+ const sourceBlock = sourceLines.length > 0 ? `Sources:\n${sourceLines.join("\n")}` : "";
785
+ const refBlock = refLines.length > 0 ? `Source refs:\n${refLines.join("\n")}` : "";
786
+ const blocks = [text, sourceBlock, refBlock].filter((block) => block.length > 0);
787
+ const body = blocks.join("\n\n");
378
788
  return multiple ? `## Query: ${success.query}\n\n${body}` : body;
379
789
  }
380
790
 
791
+ function countSuccessSources(success: QuerySuccess): number {
792
+ return success.citations.length + Object.keys(success.refIds ?? {}).length;
793
+ }
794
+
381
795
  function formatFailureBlock(failure: QueryFailure, multiple: boolean): string {
382
796
  const body = `[${failure.kind}] ${failure.message}`;
383
797
  return multiple ? `## Query: ${failure.query}\n\nFAILED: ${body}` : `FAILED: ${body}`;
@@ -393,16 +807,182 @@ function renderPartial(details: WebSearchDetails | undefined, theme: Theme): str
393
807
  }
394
808
 
395
809
  function renderCollapsedPreview(details: WebSearchDetails, theme: Theme): string {
396
- const firstSuccess = details.successes[0];
397
- if (firstSuccess) {
398
- const snippet = formatInline(firstSuccess.text, 110);
399
- if (snippet) return theme.fg("dim", snippet);
400
- }
810
+ const lines: string[] = [];
811
+ const queriesPreview = renderQueriesPreview(details.queries, theme);
812
+ if (queriesPreview) lines.push(queriesPreview);
813
+
401
814
  const firstFailure = details.failures[0];
402
815
  if (firstFailure) {
403
- return theme.fg("dim", formatInline(firstFailure.message, 110));
816
+ lines.push(theme.fg("dim", formatInline(firstFailure.message, 110)));
404
817
  }
405
- return "";
818
+ return lines.join("\n");
819
+ }
820
+
821
+ function renderQueriesPreview(queries: string[], theme: Theme): string {
822
+ if (queries.length === 0) return "";
823
+ if (queries.length === 1) {
824
+ return theme.fg("accent", `Query: ${formatInline(queries[0], 120)}`);
825
+ }
826
+ return [theme.fg("accent", "Queries:"), renderCallQueries(queries, theme)].join("\n");
827
+ }
828
+
829
+ function renderCallQueries(queries: unknown[], theme: Theme): string {
830
+ const iconPrefix = " ⌕";
831
+ return formatQueryPreviewLines(queries)
832
+ .map(
833
+ (line) =>
834
+ `${theme.fg("accent", iconPrefix)}${theme.fg("dim", line.slice(iconPrefix.length))}`,
835
+ )
836
+ .join("\n");
837
+ }
838
+
839
+ function resolveStandalonePageRef(
840
+ refStore: ReturnType<typeof createRefStore>,
841
+ urlOrRef: string,
842
+ action: string,
843
+ ): string {
844
+ const refId = refStore.resolveRefId(urlOrRef);
845
+ if (refId) return refId;
846
+ if (/^turn\d+(?:view|fetch)\d+$/.test(urlOrRef)) return urlOrRef;
847
+ throw new CodexError(
848
+ "schema",
849
+ `${action} requires opening ${urlOrRef} with codex_standalone_web urls first in this session.`,
850
+ );
851
+ }
852
+
853
+ export function selectStandalonePageRefId(
854
+ refIds: Record<string, string> | undefined,
855
+ ): string | undefined {
856
+ const refs = Object.keys(refIds ?? {});
857
+ return (
858
+ refs.find((candidate) => /^turn\d+view\d+$/.test(candidate)) ??
859
+ refs.find((candidate) => /^turn\d+fetch\d+$/.test(candidate))
860
+ );
861
+ }
862
+
863
+ export function formatQueryPreviewLines(queries: unknown[], maxLength = 110): string[] {
864
+ return queries.map((query, index) => ` ⌕ ${index + 1}. ${formatInline(query, maxLength)}`);
865
+ }
866
+
867
+ function buildRequestLabels(input: {
868
+ queries: string[];
869
+ imageQueries: string[];
870
+ urls: string[];
871
+ findCommands: Array<{ url: string; pattern: string }>;
872
+ clickCommands: Array<{ url: string; id: number }>;
873
+ screenshotCommands: Array<{ url: string; pageno: number }>;
874
+ financeCommands: Array<{ ticker: string }>;
875
+ weatherCommands: Array<{ location: string }>;
876
+ sportsCommands: Array<{ fn: string; league: string; team?: string }>;
877
+ timeCommands: Array<{ utc_offset: string }>;
878
+ }): string[] {
879
+ return [
880
+ ...input.queries,
881
+ ...input.imageQueries.map((q) => `image: ${q}`),
882
+ ...input.urls.map((url) => `open: ${url}`),
883
+ ...input.findCommands.map((c) => `find "${c.pattern}" in ${c.url}`),
884
+ ...input.clickCommands.map((c) => `click ${c.id} in ${c.url}`),
885
+ ...input.screenshotCommands.map((c) => `screenshot ${c.pageno} of ${c.url}`),
886
+ ...input.financeCommands.map((c) => `finance: ${c.ticker}`),
887
+ ...input.weatherCommands.map((c) => `weather: ${c.location}`),
888
+ ...input.sportsCommands.map((c) => `sports: ${c.fn} ${c.league}${c.team ? ` ${c.team}` : ""}`),
889
+ ...input.timeCommands.map((c) => `time: ${c.utc_offset}`),
890
+ ];
891
+ }
892
+
893
+ function buildCallLabels(args: Record<string, unknown>): string[] {
894
+ return buildRequestLabels({
895
+ queries: Array.isArray(args.queries) ? args.queries.filter(isString) : [],
896
+ imageQueries: Array.isArray(args.image_queries) ? args.image_queries.filter(isString) : [],
897
+ urls: Array.isArray(args.urls) ? args.urls.filter(isString) : [],
898
+ findCommands: Array.isArray(args.find)
899
+ ? args.find.filter(isFindArg).map((c) => ({ url: c.url, pattern: c.pattern }))
900
+ : [],
901
+ clickCommands: Array.isArray(args.click)
902
+ ? args.click.filter(isClickArg).map((c) => ({ url: c.url, id: c.id }))
903
+ : [],
904
+ screenshotCommands: Array.isArray(args.screenshot)
905
+ ? args.screenshot.filter(isScreenshotArg).map((c) => ({ url: c.url, pageno: c.pageno }))
906
+ : [],
907
+ financeCommands: Array.isArray(args.finance)
908
+ ? args.finance.filter(isFinanceArg).map((c) => ({ ticker: c.ticker }))
909
+ : [],
910
+ weatherCommands: Array.isArray(args.weather)
911
+ ? args.weather.filter(isWeatherArg).map((c) => ({ location: c.location }))
912
+ : [],
913
+ sportsCommands: Array.isArray(args.sports)
914
+ ? args.sports.filter(isSportsArg).map((c) => ({ fn: c.fn, league: c.league, team: c.team }))
915
+ : [],
916
+ timeCommands: Array.isArray(args.time)
917
+ ? args.time.filter(isTimeArg).map((c) => ({ utc_offset: c.utc_offset }))
918
+ : [],
919
+ });
920
+ }
921
+
922
+ function isString(value: unknown): value is string {
923
+ return typeof value === "string";
924
+ }
925
+
926
+ function isFindArg(value: unknown): value is { url: string; pattern: string } {
927
+ return (
928
+ typeof value === "object" &&
929
+ value !== null &&
930
+ typeof (value as { url?: unknown }).url === "string" &&
931
+ typeof (value as { pattern?: unknown }).pattern === "string"
932
+ );
933
+ }
934
+
935
+ function isClickArg(value: unknown): value is { url: string; id: number } {
936
+ return (
937
+ typeof value === "object" &&
938
+ value !== null &&
939
+ typeof (value as { url?: unknown }).url === "string" &&
940
+ typeof (value as { id?: unknown }).id === "number"
941
+ );
942
+ }
943
+
944
+ function isScreenshotArg(value: unknown): value is { url: string; pageno: number } {
945
+ return (
946
+ typeof value === "object" &&
947
+ value !== null &&
948
+ typeof (value as { url?: unknown }).url === "string" &&
949
+ typeof (value as { pageno?: unknown }).pageno === "number"
950
+ );
951
+ }
952
+
953
+ function isFinanceArg(value: unknown): value is { ticker: string } {
954
+ return (
955
+ typeof value === "object" &&
956
+ value !== null &&
957
+ typeof (value as { ticker?: unknown }).ticker === "string"
958
+ );
959
+ }
960
+
961
+ function isWeatherArg(value: unknown): value is { location: string } {
962
+ return (
963
+ typeof value === "object" &&
964
+ value !== null &&
965
+ typeof (value as { location?: unknown }).location === "string"
966
+ );
967
+ }
968
+
969
+ function isSportsArg(value: unknown): value is { fn: string; league: string; team?: string } {
970
+ return (
971
+ typeof value === "object" &&
972
+ value !== null &&
973
+ typeof (value as { fn?: unknown }).fn === "string" &&
974
+ typeof (value as { league?: unknown }).league === "string" &&
975
+ ((value as { team?: unknown }).team === undefined ||
976
+ typeof (value as { team?: unknown }).team === "string")
977
+ );
978
+ }
979
+
980
+ function isTimeArg(value: unknown): value is { utc_offset: string } {
981
+ return (
982
+ typeof value === "object" &&
983
+ value !== null &&
984
+ typeof (value as { utc_offset?: unknown }).utc_offset === "string"
985
+ );
406
986
  }
407
987
 
408
988
  function formatInline(value: unknown, maxLength = 90): string {