pi-codex-search 0.1.1 → 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
@@ -3,106 +3,697 @@ import {
3
3
  defineTool,
4
4
  type ExtensionAPI,
5
5
  type ExtensionContext,
6
+ type Theme,
6
7
  } from "@earendil-works/pi-coding-agent";
8
+ import type { Static } from "@earendil-works/pi-ai";
9
+ import { Text } from "@earendil-works/pi-tui";
7
10
  import {
11
+ assertSupportedStandaloneCombination,
12
+ classifyError,
13
+ CodexError,
14
+ createRefStore,
15
+ createTransport,
8
16
  extractAccountIdFromToken,
9
17
  fetchCodexModels,
10
- fetchCodexWebSearch,
18
+ runResponsesSearch,
19
+ runStandaloneCommands,
11
20
  selectDefaultModel,
21
+ type CodexCitation,
22
+ type CodexErrorKind,
23
+ type CodexSearchCall,
12
24
  type SearchContextSize,
25
+ type StandaloneCommandsOptions,
13
26
  } from "./src/codex.ts";
27
+ import { registerSettingsCommand } from "./src/command.ts";
28
+ import {
29
+ STANDALONE_TOOL_NAME,
30
+ type Freshness,
31
+ isProjectTrustedContext,
32
+ loadConfig,
33
+ type ResolvedConfig,
34
+ } from "./src/config.ts";
14
35
 
15
36
  const OPENAI_CODEX_PROVIDER = "openai-codex";
16
- const DEFAULT_CONTEXT_SIZE = "medium";
17
-
18
- const webSearchTool = defineTool({
19
- name: "web_search",
20
- label: "Web Search",
21
- description:
22
- "Search the web using the user's configured ChatGPT Codex subscription and return an answer with sources.",
23
- promptSnippet: "web_search: search the web using the configured ChatGPT Codex subscription.",
24
- promptGuidelines: [
25
- "Use web_search when current or source-backed information is needed.",
26
- "Do not ask the user for an access token; the tool uses pi's configured OpenAI Codex subscription.",
27
- ],
28
- parameters: Type.Object({
29
- query: Type.String({ description: "The web search question to answer." }),
37
+
38
+ interface QuerySuccess {
39
+ query: string;
40
+ text: string;
41
+ citations: CodexCitation[];
42
+ searchCalls: CodexSearchCall[];
43
+ refIds?: Record<string, string>;
44
+ responseId?: string;
45
+ usage?: {
46
+ inputTokens?: number;
47
+ outputTokens?: number;
48
+ totalTokens?: number;
49
+ };
50
+ }
51
+
52
+ interface QueryFailure {
53
+ query: string;
54
+ kind: CodexErrorKind;
55
+ message: string;
56
+ }
57
+
58
+ interface StandaloneCallPlan {
59
+ query: string;
60
+ buildOptions: () => StandaloneCommandsOptions;
61
+ openedUrl?: string;
62
+ }
63
+
64
+ interface WebSearchFailureDetail {
65
+ kind: CodexErrorKind;
66
+ message: string;
67
+ }
68
+
69
+ interface WebSearchDetails {
70
+ model: string;
71
+ api: string;
72
+ freshness: Freshness;
73
+ searchContextSize: SearchContextSize;
74
+ queryCount: number;
75
+ queries: string[];
76
+ failedQueryCount: number;
77
+ successes: QuerySuccess[];
78
+ failures: QueryFailure[];
79
+ failure?: WebSearchFailureDetail;
80
+ partial?: boolean;
81
+ completed?: number;
82
+ total?: number;
83
+ }
84
+
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
+ }),
30
100
  search_context_size: Type.Optional(
31
101
  StringEnum(["low", "medium", "high"] as const, {
32
102
  description: "Amount of web context to retrieve. Defaults to medium.",
33
103
  }),
34
104
  ),
35
- live: Type.Optional(Type.Boolean({ description: "Use live web access. Defaults to true." })),
36
- }),
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
+ }
37
113
 
38
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
39
- const query = params.query.trim();
40
- if (!query) {
41
- throw new Error("query must not be empty");
42
- }
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.",
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
+ });
43
214
 
44
- const token = await ctx.modelRegistry.getApiKeyForProvider(OPENAI_CODEX_PROVIDER);
45
- if (!token) {
46
- throw new Error(
47
- "OpenAI Codex subscription is not configured. Run /login and choose ChatGPT Plus/Pro.",
48
- );
49
- }
215
+ type StandaloneParameters = Static<typeof StandaloneParametersSchema>;
50
216
 
51
- const accountId = getConfiguredAccountId(ctx, token);
52
- if (!accountId) {
53
- throw new Error(
54
- "OpenAI Codex account id was not found in stored credentials or access token.",
55
- );
56
- }
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
+ }
231
+
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
+ );
293
+ }
294
+
295
+ const token = await ctx.modelRegistry.getApiKeyForProvider(OPENAI_CODEX_PROVIDER);
296
+ if (!token) {
297
+ const err = new CodexError(
298
+ "auth",
299
+ "OpenAI Codex subscription is not configured. Run `/login openai-codex` and choose ChatGPT Plus/Pro.",
300
+ );
301
+ throw err;
302
+ }
303
+
304
+ const accountId = getConfiguredAccountId(ctx, token);
305
+ if (!accountId) {
306
+ throw new CodexError(
307
+ "auth",
308
+ "OpenAI Codex account id was not found in stored credentials or access token. Re-run `/login openai-codex`.",
309
+ );
310
+ }
311
+
312
+ const model = await resolveSearchModel(ctx, token, accountId, config, signal);
313
+ const freshness = params.freshness ?? config.defaultFreshness;
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);
57
339
 
58
- const baseUrl = process.env.PI_CODEX_WEB_SEARCH_BASE_URL;
59
- const model = await resolveSearchModel(ctx, token, accountId, baseUrl, signal);
60
- let streamedText = "";
61
-
62
- const result = await fetchCodexWebSearch({
63
- query,
64
- token,
65
- accountId,
66
- model,
67
- baseUrl,
68
- externalWebAccess: resolveLive(params.live),
69
- searchContextSize: resolveSearchContextSize(params.search_context_size),
70
- signal,
71
- onTextDelta: (delta) => {
72
- streamedText += delta;
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
+ }
501
+
502
+ const total = queries.length;
503
+ let completed = 0;
504
+ let streamedText = "";
505
+
506
+ const emitPartial = (partialText: string) => {
73
507
  onUpdate?.({
74
- content: [{ type: "text", text: streamedText }],
75
- details: { model, partial: true },
508
+ content: [{ type: "text", text: partialText }],
509
+ details: buildDetails(config, model, freshness, searchContextSize, [], [], {
510
+ partial: true,
511
+ completed,
512
+ total,
513
+ }),
76
514
  });
77
- },
78
- });
79
-
80
- return {
81
- content: [{ type: "text", text: formatToolText(result.text, result.citations) }],
82
- details: {
83
- model: result.model,
84
- responseId: result.responseId,
85
- searchCalls: result.searchCalls,
86
- citations: result.citations,
87
- usage: result.usage,
88
- },
89
- };
90
- },
91
- });
515
+ };
516
+
517
+ if (total > 1) emitPartial(formatProgress(completed, total));
518
+
519
+ const settled = await Promise.allSettled(
520
+ queries.map(async (query: string) => {
521
+ const onTextDelta =
522
+ total === 1
523
+ ? (delta: string) => {
524
+ streamedText += delta;
525
+ emitPartial(streamedText);
526
+ }
527
+ : undefined;
528
+ try {
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
+ });
540
+ } finally {
541
+ completed += 1;
542
+ if (total > 1) emitPartial(formatProgress(completed, total));
543
+ }
544
+ }),
545
+ );
546
+
547
+ const successes: QuerySuccess[] = [];
548
+ const failures: QueryFailure[] = [];
549
+
550
+ settled.forEach((outcome, index) => {
551
+ const query = queries[index] ?? "";
552
+ if (outcome.status === "fulfilled") {
553
+ const success: QuerySuccess = {
554
+ query,
555
+ text: outcome.value.text,
556
+ citations: outcome.value.citations,
557
+ searchCalls: outcome.value.searchCalls,
558
+ };
559
+ if (outcome.value.responseId !== undefined) success.responseId = outcome.value.responseId;
560
+ if (outcome.value.usage !== undefined) success.usage = outcome.value.usage;
561
+ successes.push(success);
562
+ } else {
563
+ const reason = outcome.reason;
564
+ const kind = classifyError(reason);
565
+ const message = reason instanceof Error ? reason.message : String(reason);
566
+ failures.push({ query, kind, message });
567
+ }
568
+ });
569
+
570
+ if (successes.length === 0) {
571
+ const primary = failures[0];
572
+ const summary =
573
+ failures.length === 1
574
+ ? (primary?.message ?? "Codex web search failed")
575
+ : `All ${failures.length} ${config.toolName} queries failed: ${failures
576
+ .map((f, i) => `${i + 1}. [${f.kind}] ${f.message}`)
577
+ .join("; ")}`;
578
+ const err = new CodexError(primary?.kind ?? "unknown", summary) as CodexError & {
579
+ failures?: QueryFailure[];
580
+ };
581
+ err.failures = failures;
582
+ throw err;
583
+ }
584
+
585
+ return {
586
+ content: [{ type: "text", text: formatToolText(successes, failures) }],
587
+ details: buildDetails(config, model, freshness, searchContextSize, successes, failures),
588
+ };
589
+ },
590
+
591
+ renderCall(args, theme) {
592
+ const fresh = (args.freshness as string | undefined) ?? config.defaultFreshness;
593
+ const requestedCtxSize =
594
+ (args.search_context_size as string | undefined) ?? config.defaultSearchContextSize;
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))}`;
604
+ } else {
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)}`;
610
+ }
611
+ return new Text(text, 0, 0);
612
+ },
613
+
614
+ renderResult(result, { expanded, isPartial }, theme) {
615
+ const details = result.details as WebSearchDetails | undefined;
616
+
617
+ if (isPartial) {
618
+ return new Text(renderPartial(details, theme), 0, 0);
619
+ }
620
+
621
+ if (!details) {
622
+ const content = result.content.find((part) => part.type === "text");
623
+ const text = content?.type === "text" ? content.text : "";
624
+ return new Text(text || theme.fg("success", "✓ Web search finished"), 0, 0);
625
+ }
626
+
627
+ const total = details.queryCount;
628
+ const failed = details.failedQueryCount;
629
+ const ok = total - failed;
630
+ const sourceCount = details.successes.reduce((acc, s) => acc + countSuccessSources(s), 0);
631
+
632
+ let header: string;
633
+ if (ok === 0) {
634
+ header = theme.fg("warning", `⚠ Web search failed (${details.failure?.kind ?? "unknown"})`);
635
+ } else if (failed > 0) {
636
+ const sourceSuffix =
637
+ sourceCount > 0 ? ` · ${sourceCount} source${sourceCount === 1 ? "" : "s"}` : "";
638
+ header = theme.fg("warning", `⚠ ${ok}/${total} queries succeeded${sourceSuffix}`);
639
+ } else {
640
+ const querySuffix = total === 1 ? "" : ` across ${total} queries`;
641
+ const sourceText =
642
+ sourceCount > 0
643
+ ? `${sourceCount} source${sourceCount === 1 ? "" : "s"}`
644
+ : "Search completed";
645
+ header = theme.fg("success", `✓ ${sourceText}${querySuffix}`);
646
+ }
647
+ header += theme.fg(
648
+ "muted",
649
+ ` [${details.api}/${details.searchContextSize}/${details.freshness}]`,
650
+ );
651
+
652
+ if (!expanded) {
653
+ const preview = renderCollapsedPreview(details, theme);
654
+ return new Text(preview ? `${header}\n${preview}` : header, 0, 0);
655
+ }
656
+
657
+ const content = result.content.find((part) => part.type === "text");
658
+ const body = content?.type === "text" ? content.text : "";
659
+
660
+ let text = header;
661
+ text += `\n${theme.fg("muted", `Model: ${details.model}`)}`;
662
+ if (failed > 0) {
663
+ text += `\n${theme.fg("warning", `Failures (${failed}):`)}`;
664
+ for (const [i, f] of details.failures.entries()) {
665
+ text += `\n${theme.fg("dim", ` ${i + 1}. [${f.kind}] ${formatInline(f.query, 60)} — ${formatInline(f.message, 100)}`)}`;
666
+ }
667
+ }
668
+ if (body) {
669
+ text += `\n\n${body
670
+ .split("\n")
671
+ .map((line) => theme.fg("toolOutput", line))
672
+ .join("\n")}`;
673
+ }
674
+ return new Text(text, 0, 0);
675
+ },
676
+ });
677
+ }
92
678
 
93
679
  export default function codexWebSearchExtension(pi: ExtensionAPI) {
94
- let registered = false;
680
+ registerSettingsCommand(pi);
95
681
 
96
682
  pi.on("session_start", async (_event, ctx) => {
97
- if (registered) return;
683
+ const config = await loadConfig(ctx.cwd, isProjectTrustedContext(ctx));
684
+ if (!config.enabled) return;
98
685
 
99
- const token = await ctx.modelRegistry.getApiKeyForProvider(OPENAI_CODEX_PROVIDER);
100
- if (!token || !getConfiguredAccountId(ctx, token)) {
101
- return;
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
+ );
102
696
  }
103
-
104
- pi.registerTool(webSearchTool);
105
- registered = true;
106
697
  });
107
698
  }
108
699
 
@@ -118,46 +709,284 @@ async function resolveSearchModel(
118
709
  ctx: ExtensionContext,
119
710
  token: string,
120
711
  accountId: string,
121
- baseUrl: string | undefined,
712
+ config: ResolvedConfig,
122
713
  signal: AbortSignal | undefined,
123
714
  ): Promise<string> {
124
- const override = process.env.PI_CODEX_WEB_SEARCH_MODEL?.trim();
125
- if (override) return override;
715
+ if (config.model) return config.model;
126
716
  if (ctx.model?.provider === OPENAI_CODEX_PROVIDER) return ctx.model.id;
127
717
 
128
718
  const models = await fetchCodexModels({
129
719
  token,
130
720
  accountId,
131
- baseUrl,
132
- clientVersion: process.env.PI_CODEX_WEB_SEARCH_CLIENT_VERSION,
721
+ baseUrl: config.baseUrl,
722
+ clientVersion: config.clientVersion,
133
723
  signal,
134
724
  });
135
725
  const model = selectDefaultModel(models);
136
726
  if (!model) {
137
- throw new Error("Codex model list is empty.");
727
+ throw new CodexError("unknown", "Codex model list is empty.");
138
728
  }
139
729
  return model;
140
730
  }
141
731
 
142
- function resolveSearchContextSize(value: string | undefined): SearchContextSize {
143
- const configured = value ?? process.env.PI_CODEX_WEB_SEARCH_CONTEXT_SIZE ?? DEFAULT_CONTEXT_SIZE;
144
- if (configured === "low" || configured === "medium" || configured === "high") {
145
- return configured;
146
- }
147
- throw new Error(`Invalid search_context_size: ${configured}`);
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
+ };
148
754
  }
149
755
 
150
- function resolveLive(value: boolean | undefined): boolean {
151
- if (value !== undefined) return value;
152
- return process.env.PI_CODEX_WEB_SEARCH_LIVE !== "false";
756
+ function formatProgress(completed: number, total: number): string {
757
+ return `Searching ${completed}/${total} ${completed === total ? "complete" : "in progress"}`;
153
758
  }
154
759
 
155
- function formatToolText(text: string, citations: Array<{ title?: string; url: string }>): string {
156
- if (citations.length === 0) return text || "(no response text)";
760
+ function formatToolText(successes: QuerySuccess[], failures: QueryFailure[]): string {
761
+ const blocks: string[] = [];
762
+ const total = successes.length + failures.length;
763
+ const multiple = total > 1;
764
+
765
+ for (const success of successes) {
766
+ blocks.push(formatSuccessBlock(success, multiple));
767
+ }
768
+ for (const failure of failures) {
769
+ blocks.push(formatFailureBlock(failure, multiple));
770
+ }
771
+
772
+ return blocks.join("\n\n");
773
+ }
157
774
 
158
- const sourceLines = citations.map((citation, index) => {
775
+ function formatSuccessBlock(success: QuerySuccess, multiple: boolean): string {
776
+ const text = success.text || "(no response text)";
777
+ const sourceLines = success.citations.map((citation, index) => {
159
778
  const title = citation.title?.trim() || citation.url;
160
779
  return `${index + 1}. ${title}: ${citation.url}`;
161
780
  });
162
- return `${text || "(no response text)"}\n\nSources:\n${sourceLines.join("\n")}`;
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");
788
+ return multiple ? `## Query: ${success.query}\n\n${body}` : body;
789
+ }
790
+
791
+ function countSuccessSources(success: QuerySuccess): number {
792
+ return success.citations.length + Object.keys(success.refIds ?? {}).length;
793
+ }
794
+
795
+ function formatFailureBlock(failure: QueryFailure, multiple: boolean): string {
796
+ const body = `[${failure.kind}] ${failure.message}`;
797
+ return multiple ? `## Query: ${failure.query}\n\nFAILED: ${body}` : `FAILED: ${body}`;
798
+ }
799
+
800
+ function renderPartial(details: WebSearchDetails | undefined, theme: Theme): string {
801
+ if (!details) return theme.fg("warning", "Searching the web…");
802
+ const completed = details.completed ?? 0;
803
+ const total = details.total ?? details.queryCount;
804
+ const header = theme.fg("warning", `Searching ${completed}/${total}`);
805
+ const trailingDot = completed < total ? theme.fg("dim", " …") : theme.fg("dim", " (finalizing)");
806
+ return header + trailingDot;
807
+ }
808
+
809
+ function renderCollapsedPreview(details: WebSearchDetails, theme: Theme): string {
810
+ const lines: string[] = [];
811
+ const queriesPreview = renderQueriesPreview(details.queries, theme);
812
+ if (queriesPreview) lines.push(queriesPreview);
813
+
814
+ const firstFailure = details.failures[0];
815
+ if (firstFailure) {
816
+ lines.push(theme.fg("dim", formatInline(firstFailure.message, 110)));
817
+ }
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
+ );
986
+ }
987
+
988
+ function formatInline(value: unknown, maxLength = 90): string {
989
+ const text = typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
990
+ if (!text) return "";
991
+ return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
163
992
  }