@oai404iao/pi-codex-minimal-tools 1.3.0

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.
Files changed (39) hide show
  1. package/LICENSE +28 -0
  2. package/LICENSES/Apache-2.0.txt +201 -0
  3. package/LICENSES/OpenAI-Codex-NOTICE.txt +6 -0
  4. package/README.md +410 -0
  5. package/THIRD_PARTY_NOTICES.md +97 -0
  6. package/config.schema.json +174 -0
  7. package/models.schema.json +217 -0
  8. package/package.json +87 -0
  9. package/provenance/openai-codex-eb9dceba-reserved-tools.json +140 -0
  10. package/src/activation.ts +56 -0
  11. package/src/background-image-generation.ts +574 -0
  12. package/src/capabilities.ts +146 -0
  13. package/src/codex-http.ts +133 -0
  14. package/src/codex-request-profile.ts +45 -0
  15. package/src/codex-reserved-tools.ts +323 -0
  16. package/src/codex-wire-identity.ts +182 -0
  17. package/src/fast-mode.ts +124 -0
  18. package/src/glyphs.ts +70 -0
  19. package/src/index.ts +332 -0
  20. package/src/model-catalog/catalog.ts +636 -0
  21. package/src/model-catalog/default-models.json +252 -0
  22. package/src/model-catalog/runtime.ts +113 -0
  23. package/src/model-catalog/types.ts +95 -0
  24. package/src/native-compaction.ts +393 -0
  25. package/src/patch/apply.ts +338 -0
  26. package/src/patch/parser.ts +224 -0
  27. package/src/patch/render.ts +201 -0
  28. package/src/provider-headers.ts +54 -0
  29. package/src/provider-native-tools.ts +71 -0
  30. package/src/provider-shim.ts +4338 -0
  31. package/src/providers/codex-apply-patch-tool.ts +23 -0
  32. package/src/providers/codex-apply-patch.lark +19 -0
  33. package/src/providers/openai-responses-shared.ts +1463 -0
  34. package/src/settings.ts +247 -0
  35. package/src/tools/apply-patch.ts +84 -0
  36. package/src/tools/image-generation.ts +274 -0
  37. package/src/tools/view-image.ts +98 -0
  38. package/src/tools/web-search.ts +524 -0
  39. package/src/utils/images.ts +73 -0
@@ -0,0 +1,98 @@
1
+ import { stat, readFile, realpath } from "node:fs/promises";
2
+ import { extname, isAbsolute, relative, resolve } from "node:path";
3
+
4
+ export type ImageDetail = "auto" | "low" | "high" | "original";
5
+
6
+ export interface ViewImageInput {
7
+ path: string;
8
+ detail?: ImageDetail;
9
+ }
10
+
11
+ export interface ViewImageOptions {
12
+ workspaceOnly?: boolean;
13
+ }
14
+
15
+ export interface ValidatedImage {
16
+ absolutePath: string;
17
+ displayPath: string;
18
+ mimeType: string;
19
+ sizeBytes: number;
20
+ detail: ImageDetail;
21
+ }
22
+
23
+ const IMAGE_MIME_BY_EXT: Record<string, string> = {
24
+ ".png": "image/png",
25
+ ".jpg": "image/jpeg",
26
+ ".jpeg": "image/jpeg",
27
+ ".gif": "image/gif",
28
+ ".webp": "image/webp",
29
+ ".bmp": "image/bmp",
30
+ ".tif": "image/tiff",
31
+ ".tiff": "image/tiff",
32
+ ".svg": "image/svg+xml",
33
+ };
34
+
35
+ function assertWithinCwd(absolutePath: string, cwd: string, displayPath: string): void {
36
+ const cwdAbsolute = resolve(cwd);
37
+ const rel = relative(cwdAbsolute, absolutePath);
38
+ if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) return;
39
+ throw new Error(`view_image path escapes the workspace: ${displayPath}`);
40
+ }
41
+
42
+ export function normalizeImagePath(pathValue: string, cwd: string, options?: ViewImageOptions): { absolutePath: string; displayPath: string } {
43
+ let cleaned = pathValue.trim();
44
+ if ((cleaned.startsWith('"') && cleaned.endsWith('"')) || (cleaned.startsWith("'") && cleaned.endsWith("'"))) cleaned = cleaned.slice(1, -1);
45
+ if (cleaned.startsWith("@")) cleaned = cleaned.slice(1);
46
+ const absolutePath = resolve(cwd, cleaned);
47
+ if (options?.workspaceOnly) assertWithinCwd(absolutePath, cwd, cleaned);
48
+ return { absolutePath, displayPath: cleaned };
49
+ }
50
+
51
+ export function mimeTypeForImagePath(path: string): string | undefined {
52
+ return IMAGE_MIME_BY_EXT[extname(path).toLowerCase()];
53
+ }
54
+
55
+ export async function validateImagePath(input: ViewImageInput, cwd: string, options?: ViewImageOptions): Promise<ValidatedImage> {
56
+ if (!input || typeof input.path !== "string" || input.path.trim().length === 0) throw new Error("view_image requires a non-empty path.");
57
+ const detail = input.detail ?? "auto";
58
+ if (!["auto", "low", "high", "original"].includes(detail)) throw new Error(`Unsupported image detail: ${String(input.detail)}`);
59
+ const normalized = normalizeImagePath(input.path, cwd, options);
60
+ let fileStat;
61
+ try {
62
+ fileStat = await stat(normalized.absolutePath);
63
+ } catch {
64
+ throw new Error(`Image not found: ${normalized.displayPath}`);
65
+ }
66
+ if (fileStat.isDirectory()) throw new Error(`view_image expected a file but got a directory: ${normalized.displayPath}`);
67
+ if (!fileStat.isFile()) throw new Error(`view_image expected a regular image file: ${normalized.displayPath}`);
68
+ if (options?.workspaceOnly) {
69
+ try {
70
+ assertWithinCwd(await realpath(normalized.absolutePath), await realpath(cwd), normalized.displayPath);
71
+ } catch (error) {
72
+ if (error instanceof Error && error.message.includes("escapes the workspace")) throw error;
73
+ throw new Error(`Unable to validate image path: ${normalized.displayPath}`);
74
+ }
75
+ }
76
+ const mimeType = mimeTypeForImagePath(normalized.absolutePath);
77
+ if (!mimeType) throw new Error(`Unsupported image file type for view_image: ${normalized.displayPath}`);
78
+ return { ...normalized, detail, mimeType, sizeBytes: fileStat.size };
79
+ }
80
+
81
+ export async function viewImage(input: ViewImageInput, cwd: string, options?: ViewImageOptions) {
82
+ const image = await validateImagePath(input, cwd, options);
83
+ const data = await readFile(image.absolutePath, "base64");
84
+ return {
85
+ content: [{ type: "image", data, mimeType: image.mimeType, detail: image.detail }],
86
+ details: image,
87
+ };
88
+ }
89
+
90
+ export const viewImageToolSchema = {
91
+ type: "object",
92
+ additionalProperties: false,
93
+ properties: {
94
+ path: { type: "string", description: "Path to the local image file. Relative paths resolve against ctx.cwd; a leading @ is accepted and stripped." },
95
+ detail: { type: "string", enum: ["auto", "low", "high", "original"], description: "Image detail hint. Defaults to auto." },
96
+ },
97
+ required: ["path"],
98
+ };
@@ -0,0 +1,524 @@
1
+ import { buildSessionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
2
+ import type { Api, Model, ProviderHeaders } from "@earendil-works/pi-ai";
3
+ import { Text } from "@earendil-works/pi-tui";
4
+ import {
5
+ buildCodexJsonHeaders,
6
+ hasCodexRequestAuth,
7
+ resolveCodexApiEndpoint,
8
+ } from "../codex-http.js";
9
+ import { glyphs, truncateText } from "../glyphs.js";
10
+ import { loadModelSettings } from "../model-catalog/runtime.js";
11
+
12
+ export interface SearchQuery {
13
+ q: string;
14
+ recency?: number;
15
+ domains?: string[];
16
+ }
17
+
18
+ export interface WebSearchInput {
19
+ search_query?: SearchQuery[];
20
+ image_query?: SearchQuery[];
21
+ open?: Array<{ ref_id: string; lineno?: number }>;
22
+ click?: Array<{ ref_id: string; id: number }>;
23
+ find?: Array<{ ref_id: string; pattern: string }>;
24
+ screenshot?: Array<{ ref_id: string; pageno: number }>;
25
+ finance?: Array<{
26
+ ticker: string;
27
+ type: "equity" | "fund" | "crypto" | "index";
28
+ market?: string;
29
+ }>;
30
+ weather?: Array<{ location: string; start?: string; duration?: number }>;
31
+ sports?: Array<{
32
+ tool?: "sports";
33
+ fn: "schedule" | "standings";
34
+ league: "nba" | "wnba" | "nfl" | "nhl" | "mlb" | "epl" | "ncaamb" | "ncaawb" | "ipl";
35
+ team?: string;
36
+ opponent?: string;
37
+ date_from?: string;
38
+ date_to?: string;
39
+ num_games?: number;
40
+ locale?: string;
41
+ }>;
42
+ time?: Array<{ utc_offset: string }>;
43
+ response_length?: "short" | "medium" | "long";
44
+ }
45
+
46
+ interface WebSearchToolContext {
47
+ cwd: string;
48
+ model?: Model<Api>;
49
+ modelRegistry?: {
50
+ getApiKeyAndHeaders(model: Model<Api>): Promise<
51
+ | { ok: true; apiKey?: string; headers?: ProviderHeaders }
52
+ | { ok: false; error: string }
53
+ >;
54
+ };
55
+ sessionManager?: {
56
+ getSessionId(): string;
57
+ getBranch?(): SessionEntry[];
58
+ };
59
+ }
60
+
61
+ interface StandaloneSearchResponse {
62
+ encrypted_output?: string | null;
63
+ output?: string;
64
+ results?: unknown[];
65
+ }
66
+
67
+ export interface StandaloneWebSearchResult {
68
+ type?: string;
69
+ domain?: string;
70
+ ref_id?: string;
71
+ snippet?: string;
72
+ title?: string;
73
+ url?: string;
74
+ }
75
+
76
+ export interface StandaloneWebSearchDetails {
77
+ mode: "standalone";
78
+ results: StandaloneWebSearchResult[];
79
+ }
80
+
81
+ export interface StandaloneWebSearchInvocation {
82
+ turnId?: string;
83
+ }
84
+
85
+ const CODEX_STANDALONE_SEARCH_OUTPUT_TOKEN_LIMIT = 10_000;
86
+ const SEARCH_OPERATION_KEYS = [
87
+ "search_query",
88
+ "image_query",
89
+ "open",
90
+ "click",
91
+ "find",
92
+ "screenshot",
93
+ "finance",
94
+ "weather",
95
+ "sports",
96
+ "time",
97
+ ] as const;
98
+
99
+ const searchQuerySchema = {
100
+ type: "object",
101
+ additionalProperties: false,
102
+ required: ["q"],
103
+ properties: {
104
+ q: { type: "string", minLength: 1, description: "Search query." },
105
+ recency: { type: "integer", minimum: 0, description: "Restrict results to this many recent days." },
106
+ domains: { type: "array", items: { type: "string", minLength: 1 }, description: "Restrict results to these domains." },
107
+ },
108
+ };
109
+
110
+ export const webSearchToolSchema = {
111
+ type: "object",
112
+ additionalProperties: false,
113
+ properties: {
114
+ search_query: {
115
+ type: "array",
116
+ maxItems: 4,
117
+ items: searchQuerySchema,
118
+ description: "Query the internet search engine.",
119
+ },
120
+ image_query: {
121
+ type: "array",
122
+ maxItems: 2,
123
+ items: searchQuerySchema,
124
+ description: "Query the image search engine.",
125
+ },
126
+ open: {
127
+ type: "array",
128
+ items: {
129
+ type: "object",
130
+ additionalProperties: false,
131
+ required: ["ref_id"],
132
+ properties: {
133
+ ref_id: { type: "string" },
134
+ lineno: { type: "integer", minimum: 0 },
135
+ },
136
+ },
137
+ },
138
+ click: {
139
+ type: "array",
140
+ items: {
141
+ type: "object",
142
+ additionalProperties: false,
143
+ required: ["ref_id", "id"],
144
+ properties: {
145
+ ref_id: { type: "string" },
146
+ id: { type: "integer", minimum: 0 },
147
+ },
148
+ },
149
+ },
150
+ find: {
151
+ type: "array",
152
+ items: {
153
+ type: "object",
154
+ additionalProperties: false,
155
+ required: ["ref_id", "pattern"],
156
+ properties: {
157
+ ref_id: { type: "string" },
158
+ pattern: { type: "string" },
159
+ },
160
+ },
161
+ },
162
+ screenshot: {
163
+ type: "array",
164
+ items: {
165
+ type: "object",
166
+ additionalProperties: false,
167
+ required: ["ref_id", "pageno"],
168
+ properties: {
169
+ ref_id: { type: "string" },
170
+ pageno: { type: "integer", minimum: 0 },
171
+ },
172
+ },
173
+ },
174
+ finance: {
175
+ type: "array",
176
+ items: {
177
+ type: "object",
178
+ additionalProperties: false,
179
+ required: ["ticker", "type"],
180
+ properties: {
181
+ ticker: { type: "string" },
182
+ type: { type: "string", enum: ["equity", "fund", "crypto", "index"] },
183
+ market: { type: "string" },
184
+ },
185
+ },
186
+ },
187
+ weather: {
188
+ type: "array",
189
+ items: {
190
+ type: "object",
191
+ additionalProperties: false,
192
+ required: ["location"],
193
+ properties: {
194
+ location: { type: "string" },
195
+ start: { type: "string" },
196
+ duration: { type: "integer", minimum: 1 },
197
+ },
198
+ },
199
+ },
200
+ sports: {
201
+ type: "array",
202
+ items: {
203
+ type: "object",
204
+ additionalProperties: false,
205
+ required: ["fn", "league"],
206
+ properties: {
207
+ tool: { type: "string", enum: ["sports"] },
208
+ fn: { type: "string", enum: ["schedule", "standings"] },
209
+ league: { type: "string", enum: ["nba", "wnba", "nfl", "nhl", "mlb", "epl", "ncaamb", "ncaawb", "ipl"] },
210
+ team: { type: "string" },
211
+ opponent: { type: "string" },
212
+ date_from: { type: "string" },
213
+ date_to: { type: "string" },
214
+ num_games: { type: "integer", minimum: 1 },
215
+ locale: { type: "string" },
216
+ },
217
+ },
218
+ },
219
+ time: {
220
+ type: "array",
221
+ items: {
222
+ type: "object",
223
+ additionalProperties: false,
224
+ required: ["utc_offset"],
225
+ properties: {
226
+ utc_offset: { type: "string" },
227
+ },
228
+ },
229
+ },
230
+ response_length: {
231
+ type: "string",
232
+ enum: ["short", "medium", "long"],
233
+ },
234
+ },
235
+ };
236
+
237
+ function visibleMessageText(content: unknown): string {
238
+ if (typeof content === "string") return content;
239
+ if (!Array.isArray(content)) return "";
240
+ return content
241
+ .filter((item): item is { type: "text"; text: string } =>
242
+ Boolean(item)
243
+ && typeof item === "object"
244
+ && (item as { type?: unknown }).type === "text"
245
+ && typeof (item as { text?: unknown }).text === "string")
246
+ .map((item) => item.text)
247
+ .join("\n")
248
+ .trim();
249
+ }
250
+
251
+ function recentSearchInput(ctx: WebSearchToolContext, turnId?: string): unknown[] | undefined {
252
+ if (!ctx.sessionManager?.getBranch) return undefined;
253
+ const visible: Array<{ role: "user" | "assistant"; text: string }> = [];
254
+ for (const message of buildSessionContext(ctx.sessionManager.getBranch()).messages) {
255
+ if (message.role !== "user" && message.role !== "assistant") continue;
256
+ const text = visibleMessageText(message.content);
257
+ if (!text || (message.role === "user" && /^<environment_context>[\s\S]*<\/environment_context>$/i.test(text))) {
258
+ continue;
259
+ }
260
+ visible.push({ role: message.role, text });
261
+ }
262
+ const userIndexes = visible
263
+ .map((message, index) => message.role === "user" ? index : -1)
264
+ .filter((index) => index >= 0);
265
+ const start = userIndexes.length > 1 ? userIndexes[userIndexes.length - 2]! : userIndexes[0] ?? 0;
266
+ const tail = visible.slice(start);
267
+ let currentUserIndex = -1;
268
+ for (let index = 0; index < tail.length; index++) {
269
+ if (tail[index]?.role === "user") currentUserIndex = index;
270
+ }
271
+ let assistantBudget = 4_000;
272
+ return tail.map((message, index) => {
273
+ let text = message.text;
274
+ if (message.role === "assistant") {
275
+ text = text.slice(0, Math.max(0, assistantBudget));
276
+ assistantBudget -= text.length;
277
+ }
278
+ return {
279
+ type: "message",
280
+ role: message.role,
281
+ content: [{
282
+ type: message.role === "assistant" ? "output_text" : "input_text",
283
+ text,
284
+ }],
285
+ ...(turnId && index === currentUserIndex
286
+ ? { internal_chat_message_metadata_passthrough: { turn_id: turnId } }
287
+ : {}),
288
+ };
289
+ }).filter((message) => message.content[0]!.text.length > 0);
290
+ }
291
+
292
+ function searchOperationLabel(input: WebSearchInput): string {
293
+ const operations = SEARCH_OPERATION_KEYS.filter((key) => (input[key]?.length ?? 0) > 0);
294
+ return operations.length > 0 ? operations.join(", ") : "commands";
295
+ }
296
+
297
+ function assertStandaloneSearchOutput(output: string, input: WebSearchInput): void {
298
+ const normalized = output.trim();
299
+ if (/^Found no tool response\b[\s\S]*arguments you provided were not valid\.?$/i.test(normalized)) {
300
+ throw new Error(
301
+ `Standalone web search backend returned no tool response for ${searchOperationLabel(input)}. `
302
+ + "The endpoint accepted the request but could not execute it; retry with search_query or another supported operation.",
303
+ );
304
+ }
305
+ if (/^Error parsing function call\b/i.test(normalized)) {
306
+ throw new Error(
307
+ `Standalone web search backend rejected ${searchOperationLabel(input)}: ${normalized}`,
308
+ );
309
+ }
310
+ }
311
+
312
+ function searchInputSummary(input: WebSearchInput): string {
313
+ const queries = [
314
+ ...(input.search_query ?? []).map((query) => query.q),
315
+ ...(input.image_query ?? []).map((query) => query.q),
316
+ ].map((query) => query.trim()).filter(Boolean);
317
+ if (queries.length > 0) {
318
+ return queries.length > 1 ? `${queries[0]} +${queries.length - 1}` : queries[0]!;
319
+ }
320
+ const open = input.open?.[0]?.ref_id?.trim();
321
+ if (open) return open;
322
+ const find = input.find?.[0];
323
+ if (find?.pattern?.trim()) return find.pattern.trim();
324
+ const weather = input.weather?.[0]?.location?.trim();
325
+ if (weather) return weather;
326
+ const finance = input.finance?.[0]?.ticker?.trim();
327
+ if (finance) return finance;
328
+ const sports = input.sports?.[0];
329
+ if (sports) return [sports.league, sports.team, sports.fn].filter(Boolean).join(" ");
330
+ const time = input.time?.[0]?.utc_offset?.trim();
331
+ if (time) return time;
332
+ return searchOperationLabel(input);
333
+ }
334
+
335
+ function resultHost(result: StandaloneWebSearchResult): string | undefined {
336
+ const domain = result.domain?.trim().replace(/^www\./i, "");
337
+ if (domain) return domain;
338
+ if (!result.url) return undefined;
339
+ try {
340
+ return new URL(result.url).hostname.replace(/^www\./i, "") || undefined;
341
+ } catch {
342
+ return undefined;
343
+ }
344
+ }
345
+
346
+ export function standaloneWebSearchHosts(results: readonly StandaloneWebSearchResult[]): string[] {
347
+ const seen = new Set<string>();
348
+ const hosts: string[] = [];
349
+ for (const result of results) {
350
+ const host = resultHost(result);
351
+ const key = host?.toLowerCase();
352
+ if (!host || !key || seen.has(key)) continue;
353
+ seen.add(key);
354
+ hosts.push(host);
355
+ }
356
+ return hosts;
357
+ }
358
+
359
+ function renderHostTags(
360
+ results: readonly StandaloneWebSearchResult[],
361
+ theme: any,
362
+ cwd?: string,
363
+ ): string {
364
+ const hosts = standaloneWebSearchHosts(results);
365
+ if (hosts.length === 0) return "";
366
+ const shown = hosts.slice(0, 8);
367
+ const separator = theme.fg("dim", glyphs(cwd).dot);
368
+ const tags = shown.map((host) => theme.fg("accent", host));
369
+ if (hosts.length > shown.length) tags.push(theme.fg("dim", `+${hosts.length - shown.length}`));
370
+ return tags.join(separator);
371
+ }
372
+
373
+ function renderStandaloneWebSearchCall(input: WebSearchInput, theme: any, cwd?: string): Text {
374
+ const summary = truncateText(searchInputSummary(input), 96, cwd);
375
+ const text = `${theme.fg("accent", glyphs(cwd).bullet)}`
376
+ + theme.fg("text", theme.bold("Web Search"))
377
+ + (summary ? theme.fg("dim", ` ${summary}`) : "");
378
+ return new Text(text, 0, 0);
379
+ }
380
+
381
+ function renderStandaloneWebSearchResult(
382
+ result: { content?: Array<{ type?: string; text?: string }>; details?: StandaloneWebSearchDetails },
383
+ options: { expanded?: boolean; isPartial?: boolean },
384
+ theme: any,
385
+ context: { cwd?: string; isError?: boolean },
386
+ ): Text {
387
+ if (options.isPartial) return new Text("", 0, 0);
388
+ const text = result.content
389
+ ?.filter((part) => part.type === "text" && typeof part.text === "string")
390
+ .map((part) => part.text)
391
+ .join("\n") ?? "";
392
+ if (context.isError) return new Text(theme.fg("error", text || "Web search failed"), 0, 0);
393
+
394
+ const results = result.details?.mode === "standalone" ? result.details.results : [];
395
+ const hosts = renderHostTags(results, theme, context.cwd);
396
+ const count = results.length;
397
+ let rendered = count > 0 ? `${hosts ? `${hosts} ` : ""}${theme.fg("dim", `(${count})`)}` : theme.fg("muted", "Search complete");
398
+ if (options.expanded && text) rendered += `\n\n${theme.fg("toolOutput", text)}`;
399
+ return new Text(rendered, 0, 0);
400
+ }
401
+
402
+ export async function standaloneWebSearch(
403
+ input: WebSearchInput,
404
+ ctx: WebSearchToolContext,
405
+ signal?: AbortSignal,
406
+ invocation: StandaloneWebSearchInvocation = {},
407
+ ) {
408
+ const model = ctx.model;
409
+ if (!model || !ctx.modelRegistry) throw new Error("No active model is available for standalone web search.");
410
+ const settings = loadModelSettings(model, ctx.cwd);
411
+ if (!settings.enabled) throw new Error("pi-codex-minimal-tools is disabled.");
412
+ if (settings.webSearchImplementation !== "standalone") {
413
+ throw new Error(`Standalone web search is not enabled for ${model.provider}/${model.id}.`);
414
+ }
415
+ const contentTypes = settings.modelProfile?.effective.tools.webSearch
416
+ ? settings.modelProfile.effective.tools.webSearch.contentTypes ?? ["text"]
417
+ : [];
418
+ if (input.search_query?.length && !contentTypes.includes("text")) {
419
+ throw new Error("Text search is disabled by the current model profile.");
420
+ }
421
+ if (input.image_query?.length && !contentTypes.includes("image")) {
422
+ throw new Error("Image search is disabled by the current model profile.");
423
+ }
424
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
425
+ if (!auth.ok) throw new Error(auth.error);
426
+ if (!hasCodexRequestAuth({
427
+ modelHeaders: model.headers,
428
+ auth: { apiKey: auth.apiKey, headers: auth.headers },
429
+ })) {
430
+ throw new Error(`No request authentication for provider: ${model.provider}`);
431
+ }
432
+
433
+ const url = resolveCodexApiEndpoint(model.baseUrl, settings.apiKeyMode, "alpha/search");
434
+ const sessionId = ctx.sessionManager?.getSessionId();
435
+ const searchInput = recentSearchInput(ctx, invocation.turnId);
436
+ const turnMetadata = invocation.turnId
437
+ ? JSON.stringify({
438
+ ...(sessionId ? { session_id: sessionId, thread_id: sessionId } : {}),
439
+ turn_id: invocation.turnId,
440
+ model: model.id,
441
+ })
442
+ : undefined;
443
+ const response = await fetch(url, {
444
+ method: "POST",
445
+ headers: buildCodexJsonHeaders({
446
+ modelHeaders: model.headers,
447
+ auth: { apiKey: auth.apiKey, headers: auth.headers },
448
+ apiKeyMode: settings.apiKeyMode,
449
+ ...(turnMetadata
450
+ ? { extraHeaders: { "x-codex-turn-metadata": turnMetadata } }
451
+ : {}),
452
+ }),
453
+ body: JSON.stringify({
454
+ id: sessionId ?? `pi-search-${Date.now()}`,
455
+ model: model.id,
456
+ ...(searchInput ? { input: searchInput } : {}),
457
+ commands: input,
458
+ settings: {
459
+ allowed_callers: ["direct"],
460
+ external_web_access: true,
461
+ },
462
+ max_output_tokens: CODEX_STANDALONE_SEARCH_OUTPUT_TOKEN_LIMIT,
463
+ }),
464
+ signal,
465
+ });
466
+ if (!response.ok) {
467
+ throw new Error(`Standalone web search failed: HTTP ${response.status}: ${await response.text()}`);
468
+ }
469
+ const result = await response.json() as StandaloneSearchResponse;
470
+ if (typeof result.output !== "string" || !result.output.trim()) {
471
+ throw new Error("Standalone web search returned no output.");
472
+ }
473
+ assertStandaloneSearchOutput(result.output, input);
474
+ return {
475
+ content: [{ type: "text", text: result.output }],
476
+ details: {
477
+ mode: "standalone",
478
+ results: (result.results ?? []) as StandaloneWebSearchResult[],
479
+ } satisfies StandaloneWebSearchDetails,
480
+ };
481
+ }
482
+
483
+ export function createWebSearchToolDefinition(options: {
484
+ getCurrentTurnId?: (sessionId: string | undefined) => string | undefined;
485
+ } = {}) {
486
+ return {
487
+ name: "web_search",
488
+ label: "Web Search",
489
+ description: "Search the web using the implementation selected by the current model profile. Hosted profiles are rewritten into the OpenAI Responses web_search tool; standalone profiles call the Codex alpha/search endpoint.",
490
+ promptSnippet: "Search the web when current information or citations are needed.",
491
+ promptGuidelines: ["Use web_search when current web information or cited sources are needed."],
492
+ parameters: webSearchToolSchema,
493
+ renderCall(input: WebSearchInput, theme: any, context: { cwd?: string }) {
494
+ return renderStandaloneWebSearchCall(input ?? {}, theme, context?.cwd);
495
+ },
496
+ renderResult(
497
+ result: { content?: Array<{ type?: string; text?: string }>; details?: StandaloneWebSearchDetails },
498
+ renderOptions: { expanded?: boolean; isPartial?: boolean },
499
+ theme: any,
500
+ context: { cwd?: string; isError?: boolean },
501
+ ) {
502
+ return renderStandaloneWebSearchResult(result, renderOptions, theme, context);
503
+ },
504
+ async execute(
505
+ _toolCallId: string,
506
+ input: WebSearchInput,
507
+ signal: AbortSignal | undefined,
508
+ _onUpdate: unknown,
509
+ ctx: WebSearchToolContext,
510
+ ) {
511
+ const settings = loadModelSettings(ctx.model, ctx.cwd);
512
+ if (settings.webSearchImplementation === "standalone") {
513
+ const sessionId = ctx.sessionManager?.getSessionId();
514
+ return standaloneWebSearch(input, ctx, signal, {
515
+ turnId: options.getCurrentTurnId?.(sessionId),
516
+ });
517
+ }
518
+ return {
519
+ content: [{ type: "text", text: "web_search is hosted-provider-first for this model profile and should be rewritten before execution." }],
520
+ details: { phase: "native-provider", nativeTool: "web_search" },
521
+ };
522
+ },
523
+ };
524
+ }
@@ -0,0 +1,73 @@
1
+ import { copyFile, mkdir, writeFile } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { randomUUID } from "node:crypto";
4
+ import { dirname, extname, isAbsolute, join, resolve } from "node:path";
5
+ import type { CodexMinimalToolsSettings } from "../settings.js";
6
+
7
+ export interface SavedImageInfo {
8
+ path: string;
9
+ latestPath?: string;
10
+ mimeType: string;
11
+ format: string;
12
+ bytes: number;
13
+ }
14
+
15
+ export function projectRoot(cwd: string): string {
16
+ let current = resolve(cwd);
17
+ while (true) {
18
+ if (existsSync(join(current, ".git")) || existsSync(join(current, ".pi"))) return current;
19
+ const parent = dirname(current);
20
+ if (parent === current) return resolve(cwd);
21
+ current = parent;
22
+ }
23
+ }
24
+
25
+ export function imageOutputDir(cwd: string, settings: Pick<CodexMinimalToolsSettings, "imageOutputDir">): string {
26
+ const configured = settings.imageOutputDir || ".pi/openai-codex-images";
27
+ return isAbsolute(configured) ? resolve(configured) : resolve(projectRoot(cwd), configured);
28
+ }
29
+
30
+ export function imageFormatToMime(format: string | undefined): string {
31
+ const normalized = (format || "png").toLowerCase().replace(/^\./, "");
32
+ if (normalized === "jpg" || normalized === "jpeg") return "image/jpeg";
33
+ if (normalized === "webp") return "image/webp";
34
+ return "image/png";
35
+ }
36
+
37
+ export function extensionForMime(mimeType: string): string {
38
+ if (mimeType === "image/jpeg") return "jpeg";
39
+ if (mimeType === "image/webp") return "webp";
40
+ return "png";
41
+ }
42
+
43
+ export function inferImageFormat(value: unknown, fallback = "png"): string {
44
+ if (typeof value !== "string") return fallback;
45
+ const lower = value.toLowerCase();
46
+ if (lower.includes("jpeg") || lower.includes("jpg")) return "jpeg";
47
+ if (lower.includes("webp")) return "webp";
48
+ if (lower.includes("png")) return "png";
49
+ const ext = extname(lower).replace(/^\./, "");
50
+ return ext || fallback;
51
+ }
52
+
53
+ export async function saveBase64Image(options: {
54
+ base64: string;
55
+ callId?: string;
56
+ cwd: string;
57
+ format?: string;
58
+ responseId?: string;
59
+ settings: Pick<CodexMinimalToolsSettings, "imageOutputDir">;
60
+ }): Promise<SavedImageInfo> {
61
+ const format = inferImageFormat(options.format, "png");
62
+ const mimeType = imageFormatToMime(format);
63
+ const ext = extensionForMime(mimeType);
64
+ const dir = imageOutputDir(options.cwd, options.settings);
65
+ await mkdir(dir, { recursive: true });
66
+ const unique = randomUUID().slice(0, 8);
67
+ const filePath = join(dir, `${new Date().toISOString().replace(/[:.]/g, "-")}-${unique}.${ext}`);
68
+ const data = Buffer.from(options.base64, "base64");
69
+ await writeFile(filePath, data, { mode: 0o600 });
70
+ const latestPath = join(dir, `latest.${ext}`);
71
+ await copyFile(filePath, latestPath);
72
+ return { bytes: data.byteLength, format: ext, latestPath, mimeType, path: filePath };
73
+ }