@monotykamary/pi-better-openai 0.1.25 → 0.1.26
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/README.md +27 -0
- package/index.ts +31 -0
- package/package.json +1 -1
- package/src/config.ts +144 -1
- package/src/websearch.ts +446 -0
package/README.md
CHANGED
|
@@ -34,11 +34,13 @@ Usage display, image generation, and live voice require pi's `openai-codex` OAut
|
|
|
34
34
|
- Interactive settings picker via `/openai-settings`.
|
|
35
35
|
- Footer customization for model, thinking, fast mode, usage, and token/cost context.
|
|
36
36
|
- OpenAI image generation/editing through the `openai_image` tool and `/openai-image` command.
|
|
37
|
+
- Live web search through the `openai_websearch` tool and `/openai-websearch` command, backed by the ChatGPT Codex search backend.
|
|
37
38
|
- Codex-backed realtime voice through `/live`, with an animated microphone waveform and coding-task delegation into the active pi session.
|
|
38
39
|
- Animated Codex custom pets rendered in the Better OpenAI footer.
|
|
39
40
|
- Commands:
|
|
40
41
|
- `/fast` toggles fast mode.
|
|
41
42
|
- `/openai-image <prompt>` generates an image directly.
|
|
43
|
+
- `/openai-websearch <query>` searches the web and inserts the cited answer into the session.
|
|
42
44
|
- `/live` starts or stops realtime voice mode. `Ctrl+Shift+L` is the keyboard toggle.
|
|
43
45
|
- `/pets [help|list|wake [slug]|tuck|select <slug>]` renders or manages custom pets from `${CODEX_HOME:-~/.codex}/pets`.
|
|
44
46
|
- `/openai-usage` shows current OpenAI subscription usage.
|
|
@@ -151,6 +153,31 @@ Save modes:
|
|
|
151
153
|
|
|
152
154
|
The repository ignores `.pi/`, so generated images and local config should not be committed.
|
|
153
155
|
|
|
156
|
+
## Web search
|
|
157
|
+
|
|
158
|
+
Use the command for a quick search:
|
|
159
|
+
|
|
160
|
+
```text
|
|
161
|
+
/openai-websearch latest tanstack query release
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Agents can call the `openai_websearch` tool directly. Supported parameters:
|
|
165
|
+
|
|
166
|
+
- `query` (required): the web search query.
|
|
167
|
+
- `responseLength`: `short`, `medium`, or `long`. Defaults to the configured value.
|
|
168
|
+
|
|
169
|
+
The tool returns a synthesized answer plus cited source URLs. It calls the
|
|
170
|
+
undocumented `chatgpt.com/backend-api/codex/alpha/search` endpoint with your ChatGPT
|
|
171
|
+
OAuth credentials (`openai-codex` login), so it can change or break without notice;
|
|
172
|
+
OAuth/API-key-only setups without ChatGPT login are not supported.
|
|
173
|
+
|
|
174
|
+
Settings under `websearch` in the config file or the `/openai-settings` picker:
|
|
175
|
+
|
|
176
|
+
- `enabled` (default `true`), `model` (default `gpt-5.6-luna`),
|
|
177
|
+
`reasoningEffort` (default `max`), `responseLength` (default `short`),
|
|
178
|
+
`maxOutputTokens` (default `4096`, clamped to 256-100000), and
|
|
179
|
+
`timeoutMs` (default `25000`, clamped to 5000-120000).
|
|
180
|
+
|
|
154
181
|
## Codex pets
|
|
155
182
|
|
|
156
183
|
Codex pets are an OpenAI Codex app feature, so the floating overlay and pet picker are still controlled by Codex (`Settings → Appearance → Pets` or `/pet`). This extension can also render compatible custom pet spritesheets directly in pi's Better OpenAI footer.
|
package/index.ts
CHANGED
|
@@ -24,10 +24,12 @@ import {
|
|
|
24
24
|
DEFAULT_LIVE_CONFIG,
|
|
25
25
|
DEFAULT_PET_CONFIG,
|
|
26
26
|
DEFAULT_SUPPORTED_MODELS,
|
|
27
|
+
DEFAULT_WEBSEARCH_CONFIG,
|
|
27
28
|
PET_STATES,
|
|
28
29
|
FOOTER_SETTING_DESCRIPTORS,
|
|
29
30
|
USAGE_SETTING_DESCRIPTORS,
|
|
30
31
|
IMAGE_SETTING_DESCRIPTORS,
|
|
32
|
+
WEBSEARCH_SETTING_DESCRIPTORS,
|
|
31
33
|
LIVE_SETTING_DESCRIPTORS,
|
|
32
34
|
PET_SETTING_DESCRIPTORS,
|
|
33
35
|
FAST_SETTING_DESCRIPTORS,
|
|
@@ -51,6 +53,7 @@ import {
|
|
|
51
53
|
readCodexAuth,
|
|
52
54
|
} from "./src/usage.ts";
|
|
53
55
|
import { registerOpenAIImage, _imageTest } from "./src/image.ts";
|
|
56
|
+
import { registerOpenAIWebSearch, _websearchTest } from "./src/websearch.ts";
|
|
54
57
|
import { registerOpenAILive } from "./src/live/index.ts";
|
|
55
58
|
import {
|
|
56
59
|
type CodexPetPackage,
|
|
@@ -296,6 +299,8 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
|
|
|
296
299
|
"",
|
|
297
300
|
`Image enabled: ${cfg.image.enabled}`,
|
|
298
301
|
`Image default save: ${cfg.image.defaultSave}`,
|
|
302
|
+
`Websearch enabled: ${cfg.websearch.enabled}`,
|
|
303
|
+
`Websearch model: ${cfg.websearch.model} (${cfg.websearch.reasoningEffort}/${cfg.websearch.responseLength}, ${cfg.websearch.maxOutputTokens} tokens, ${cfg.websearch.timeoutMs}ms)`,
|
|
299
304
|
`Live enabled: ${cfg.live.enabled}`,
|
|
300
305
|
`Live voice: ${cfg.live.voice}`,
|
|
301
306
|
`Pet enabled: ${cfg.pets.enabled}`,
|
|
@@ -538,6 +543,12 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
|
|
|
538
543
|
: "disabled";
|
|
539
544
|
}
|
|
540
545
|
|
|
546
|
+
function websearchSettingsSummary(cfg: ResolvedConfig): string {
|
|
547
|
+
return cfg.websearch.enabled
|
|
548
|
+
? `enabled · ${cfg.websearch.model} · ${cfg.websearch.reasoningEffort}/${cfg.websearch.responseLength}`
|
|
549
|
+
: "disabled";
|
|
550
|
+
}
|
|
551
|
+
|
|
541
552
|
function liveSettingsSummary(cfg: ResolvedConfig): string {
|
|
542
553
|
return cfg.live.enabled ? `enabled · ${cfg.live.voice}` : "disabled";
|
|
543
554
|
}
|
|
@@ -596,6 +607,10 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
|
|
|
596
607
|
return settingsItemsFromDescriptors(IMAGE_SETTING_DESCRIPTORS, cfg);
|
|
597
608
|
}
|
|
598
609
|
|
|
610
|
+
function buildWebsearchSettingsItems(cfg: ResolvedConfig): SettingsPickerItem[] {
|
|
611
|
+
return settingsItemsFromDescriptors(WEBSEARCH_SETTING_DESCRIPTORS, cfg);
|
|
612
|
+
}
|
|
613
|
+
|
|
599
614
|
function buildLiveSettingsItems(cfg: ResolvedConfig): SettingsPickerItem[] {
|
|
600
615
|
return settingsItemsFromDescriptors(LIVE_SETTING_DESCRIPTORS, cfg);
|
|
601
616
|
}
|
|
@@ -690,6 +705,19 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
|
|
|
690
705
|
() => done(imageSettingsSummary(config(ctx))),
|
|
691
706
|
),
|
|
692
707
|
},
|
|
708
|
+
{
|
|
709
|
+
id: "section.websearch",
|
|
710
|
+
label: "Web search tool",
|
|
711
|
+
currentValue: websearchSettingsSummary(cfg),
|
|
712
|
+
description: "Configure ChatGPT Codex web search defaults.",
|
|
713
|
+
submenu: (_value, done) =>
|
|
714
|
+
settingsSubmenu(
|
|
715
|
+
"Web search tool settings",
|
|
716
|
+
() => buildWebsearchSettingsItems(config(ctx)),
|
|
717
|
+
ctx,
|
|
718
|
+
() => done(websearchSettingsSummary(config(ctx))),
|
|
719
|
+
),
|
|
720
|
+
},
|
|
693
721
|
{
|
|
694
722
|
id: "section.live",
|
|
695
723
|
label: "Live voice",
|
|
@@ -854,6 +882,7 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
|
|
|
854
882
|
});
|
|
855
883
|
|
|
856
884
|
registerOpenAIImage(pi, config);
|
|
885
|
+
registerOpenAIWebSearch(pi, config);
|
|
857
886
|
registerOpenAILive(pi, config);
|
|
858
887
|
registerOpenAIPets(pi, {
|
|
859
888
|
wake: async (ctx, slug) => {
|
|
@@ -1302,6 +1331,7 @@ export const _test = {
|
|
|
1302
1331
|
DEFAULT_IMAGE_CONFIG,
|
|
1303
1332
|
DEFAULT_LIVE_CONFIG,
|
|
1304
1333
|
DEFAULT_PET_CONFIG,
|
|
1334
|
+
DEFAULT_WEBSEARCH_CONFIG,
|
|
1305
1335
|
SERVICE_TIER,
|
|
1306
1336
|
configPaths,
|
|
1307
1337
|
abbreviateHomePath,
|
|
@@ -1324,5 +1354,6 @@ export const _test = {
|
|
|
1324
1354
|
readCodexAuth,
|
|
1325
1355
|
textPanel,
|
|
1326
1356
|
imageTest: _imageTest,
|
|
1357
|
+
websearchTest: _websearchTest,
|
|
1327
1358
|
petsTest: _petsTest,
|
|
1328
1359
|
};
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -13,6 +13,8 @@ import { piAgentDir } from "./paths.ts";
|
|
|
13
13
|
export const FOOTER_MODES = ["replace", "status", "off"] as const;
|
|
14
14
|
export const IMAGE_SAVE_MODES = ["none", "project", "global", "custom"] as const;
|
|
15
15
|
export const IMAGE_OUTPUT_FORMATS = ["png", "jpeg", "webp"] as const;
|
|
16
|
+
export const WEBSEARCH_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
|
|
17
|
+
export const WEBSEARCH_RESPONSE_LENGTHS = ["short", "medium", "long"] as const;
|
|
16
18
|
export const PET_PLACEMENTS = [
|
|
17
19
|
"stacked",
|
|
18
20
|
"inline-left",
|
|
@@ -45,6 +47,8 @@ export const DEFAULT_SUPPORTED_MODELS = [
|
|
|
45
47
|
export type FooterMode = (typeof FOOTER_MODES)[number];
|
|
46
48
|
export type ImageSaveMode = (typeof IMAGE_SAVE_MODES)[number];
|
|
47
49
|
export type ImageOutputFormat = (typeof IMAGE_OUTPUT_FORMATS)[number];
|
|
50
|
+
export type WebsearchReasoningEffort = (typeof WEBSEARCH_REASONING_EFFORTS)[number];
|
|
51
|
+
export type WebsearchResponseLength = (typeof WEBSEARCH_RESPONSE_LENGTHS)[number];
|
|
48
52
|
export type PetPlacement = (typeof PET_PLACEMENTS)[number];
|
|
49
53
|
export type PetState = (typeof PET_STATES)[number];
|
|
50
54
|
|
|
@@ -67,6 +71,15 @@ export type ImageConfig = {
|
|
|
67
71
|
timeoutMs?: number;
|
|
68
72
|
};
|
|
69
73
|
|
|
74
|
+
export type WebsearchConfig = {
|
|
75
|
+
enabled?: boolean;
|
|
76
|
+
model?: string;
|
|
77
|
+
reasoningEffort?: WebsearchReasoningEffort;
|
|
78
|
+
responseLength?: WebsearchResponseLength;
|
|
79
|
+
maxOutputTokens?: number;
|
|
80
|
+
timeoutMs?: number;
|
|
81
|
+
};
|
|
82
|
+
|
|
70
83
|
export type LiveConfig = {
|
|
71
84
|
enabled?: boolean;
|
|
72
85
|
voice?: LiveVoice;
|
|
@@ -93,6 +106,7 @@ export interface ConfigFile {
|
|
|
93
106
|
usage?: UsageConfig;
|
|
94
107
|
footer?: FooterConfig;
|
|
95
108
|
image?: ImageConfig;
|
|
109
|
+
websearch?: WebsearchConfig;
|
|
96
110
|
live?: LiveConfig;
|
|
97
111
|
pets?: PetConfig;
|
|
98
112
|
}
|
|
@@ -115,6 +129,7 @@ export interface ResolvedConfig {
|
|
|
115
129
|
usage: Required<UsageConfig>;
|
|
116
130
|
footer: Required<FooterConfig>;
|
|
117
131
|
image: Required<ImageConfig>;
|
|
132
|
+
websearch: Required<WebsearchConfig>;
|
|
118
133
|
live: Required<LiveConfig>;
|
|
119
134
|
pets: Required<PetConfig>;
|
|
120
135
|
}
|
|
@@ -140,6 +155,17 @@ export const DEFAULT_IMAGE_CONFIG: Required<ImageConfig> = {
|
|
|
140
155
|
timeoutMs: 180_000,
|
|
141
156
|
};
|
|
142
157
|
|
|
158
|
+
export const DEFAULT_WEBSEARCH_MODEL = "gpt-5.6-luna";
|
|
159
|
+
|
|
160
|
+
export const DEFAULT_WEBSEARCH_CONFIG: Required<WebsearchConfig> = {
|
|
161
|
+
enabled: true,
|
|
162
|
+
model: DEFAULT_WEBSEARCH_MODEL,
|
|
163
|
+
reasoningEffort: "max",
|
|
164
|
+
responseLength: "short",
|
|
165
|
+
maxOutputTokens: 4096,
|
|
166
|
+
timeoutMs: 25_000,
|
|
167
|
+
};
|
|
168
|
+
|
|
143
169
|
export const DEFAULT_LIVE_CONFIG: Required<LiveConfig> = {
|
|
144
170
|
enabled: true,
|
|
145
171
|
voice: DEFAULT_LIVE_VOICE,
|
|
@@ -166,11 +192,19 @@ export const DEFAULT_CONFIG: ConfigFile = {
|
|
|
166
192
|
usage: DEFAULT_USAGE_CONFIG,
|
|
167
193
|
footer: DEFAULT_FOOTER_CONFIG,
|
|
168
194
|
image: DEFAULT_IMAGE_CONFIG,
|
|
195
|
+
websearch: DEFAULT_WEBSEARCH_CONFIG,
|
|
169
196
|
live: DEFAULT_LIVE_CONFIG,
|
|
170
197
|
pets: DEFAULT_PET_CONFIG,
|
|
171
198
|
};
|
|
172
199
|
|
|
173
|
-
export type SettingsOptionSection =
|
|
200
|
+
export type SettingsOptionSection =
|
|
201
|
+
| "root"
|
|
202
|
+
| "usage"
|
|
203
|
+
| "footer"
|
|
204
|
+
| "image"
|
|
205
|
+
| "websearch"
|
|
206
|
+
| "live"
|
|
207
|
+
| "pets";
|
|
174
208
|
|
|
175
209
|
export type SettingsValueContext = {
|
|
176
210
|
petEmptyValue?: string;
|
|
@@ -314,6 +348,69 @@ export const IMAGE_SETTING_DESCRIPTORS: readonly SettingsOptionDescriptor[] = [
|
|
|
314
348
|
},
|
|
315
349
|
];
|
|
316
350
|
|
|
351
|
+
export const WEBSEARCH_SETTING_DESCRIPTORS: readonly SettingsOptionDescriptor[] = [
|
|
352
|
+
{
|
|
353
|
+
id: "websearch.enabled",
|
|
354
|
+
section: "websearch",
|
|
355
|
+
key: "enabled",
|
|
356
|
+
label: "Web search tool",
|
|
357
|
+
currentValue: (cfg) => String(cfg.websearch.enabled),
|
|
358
|
+
values: ["true", "false"],
|
|
359
|
+
description: "Allow the openai_websearch tool to make search requests.",
|
|
360
|
+
parse: booleanSetting,
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
id: "websearch.model",
|
|
364
|
+
section: "websearch",
|
|
365
|
+
key: "model",
|
|
366
|
+
label: "Search model",
|
|
367
|
+
currentValue: (cfg) => cfg.websearch.model,
|
|
368
|
+
values: ["gpt-5.6-luna"],
|
|
369
|
+
description: "Model used by the ChatGPT Codex search backend.",
|
|
370
|
+
parse: stringSetting,
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
id: "websearch.reasoningEffort",
|
|
374
|
+
section: "websearch",
|
|
375
|
+
key: "reasoningEffort",
|
|
376
|
+
label: "Search reasoning",
|
|
377
|
+
currentValue: (cfg) => cfg.websearch.reasoningEffort,
|
|
378
|
+
values: WEBSEARCH_REASONING_EFFORTS,
|
|
379
|
+
description: "Reasoning effort requested from the search backend.",
|
|
380
|
+
parse: stringSetting,
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
id: "websearch.responseLength",
|
|
384
|
+
section: "websearch",
|
|
385
|
+
key: "responseLength",
|
|
386
|
+
label: "Answer length",
|
|
387
|
+
currentValue: (cfg) => cfg.websearch.responseLength,
|
|
388
|
+
values: WEBSEARCH_RESPONSE_LENGTHS,
|
|
389
|
+
description: "Default answer length produced by the search backend.",
|
|
390
|
+
parse: stringSetting,
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
id: "websearch.maxOutputTokens",
|
|
394
|
+
section: "websearch",
|
|
395
|
+
key: "maxOutputTokens",
|
|
396
|
+
label: "Max output tokens",
|
|
397
|
+
currentValue: (cfg) => String(cfg.websearch.maxOutputTokens),
|
|
398
|
+
values: ["1024", "2048", "4096", "8192"],
|
|
399
|
+
description: "Maximum output tokens for a search request.",
|
|
400
|
+
parse: numberSetting,
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
id: "websearch.timeoutMs",
|
|
404
|
+
section: "websearch",
|
|
405
|
+
key: "timeoutMs",
|
|
406
|
+
label: "Search timeout",
|
|
407
|
+
currentValue: (cfg) => String(cfg.websearch.timeoutMs),
|
|
408
|
+
values: ["10000", "25000", "60000", "120000"],
|
|
409
|
+
description: "Web search request timeout in milliseconds.",
|
|
410
|
+
parse: numberSetting,
|
|
411
|
+
},
|
|
412
|
+
];
|
|
413
|
+
|
|
317
414
|
export const LIVE_SETTING_DESCRIPTORS: readonly SettingsOptionDescriptor[] = [
|
|
318
415
|
{
|
|
319
416
|
id: "live.enabled",
|
|
@@ -446,6 +543,7 @@ export const SETTINGS_OPTION_DESCRIPTORS: readonly SettingsOptionDescriptor[] =
|
|
|
446
543
|
...FOOTER_SETTING_DESCRIPTORS,
|
|
447
544
|
...USAGE_SETTING_DESCRIPTORS,
|
|
448
545
|
...IMAGE_SETTING_DESCRIPTORS,
|
|
546
|
+
...WEBSEARCH_SETTING_DESCRIPTORS,
|
|
449
547
|
...LIVE_SETTING_DESCRIPTORS,
|
|
450
548
|
...PET_SETTING_DESCRIPTORS,
|
|
451
549
|
];
|
|
@@ -553,6 +651,28 @@ export function readConfig(path: string): ConfigFile | undefined {
|
|
|
553
651
|
config.image.outputFormat = parsed.image.outputFormat as ImageOutputFormat;
|
|
554
652
|
if (typeof parsed.image.timeoutMs === "number") config.image.timeoutMs = parsed.image.timeoutMs;
|
|
555
653
|
}
|
|
654
|
+
if (isRecord(parsed.websearch)) {
|
|
655
|
+
config.websearch = {};
|
|
656
|
+
if (typeof parsed.websearch.enabled === "boolean")
|
|
657
|
+
config.websearch.enabled = parsed.websearch.enabled;
|
|
658
|
+
if (typeof parsed.websearch.model === "string" && parsed.websearch.model.trim())
|
|
659
|
+
config.websearch.model = parsed.websearch.model.trim();
|
|
660
|
+
if (
|
|
661
|
+
typeof parsed.websearch.reasoningEffort === "string" &&
|
|
662
|
+
(WEBSEARCH_REASONING_EFFORTS as readonly string[]).includes(parsed.websearch.reasoningEffort)
|
|
663
|
+
)
|
|
664
|
+
config.websearch.reasoningEffort = parsed.websearch
|
|
665
|
+
.reasoningEffort as WebsearchReasoningEffort;
|
|
666
|
+
if (
|
|
667
|
+
typeof parsed.websearch.responseLength === "string" &&
|
|
668
|
+
(WEBSEARCH_RESPONSE_LENGTHS as readonly string[]).includes(parsed.websearch.responseLength)
|
|
669
|
+
)
|
|
670
|
+
config.websearch.responseLength = parsed.websearch.responseLength as WebsearchResponseLength;
|
|
671
|
+
if (typeof parsed.websearch.maxOutputTokens === "number")
|
|
672
|
+
config.websearch.maxOutputTokens = parsed.websearch.maxOutputTokens;
|
|
673
|
+
if (typeof parsed.websearch.timeoutMs === "number")
|
|
674
|
+
config.websearch.timeoutMs = parsed.websearch.timeoutMs;
|
|
675
|
+
}
|
|
556
676
|
if (isRecord(parsed.live)) {
|
|
557
677
|
config.live = {};
|
|
558
678
|
if (typeof parsed.live.enabled === "boolean") config.live.enabled = parsed.live.enabled;
|
|
@@ -701,6 +821,29 @@ export function resolveConfig(cwd: string): ResolvedConfig {
|
|
|
701
821
|
),
|
|
702
822
|
),
|
|
703
823
|
},
|
|
824
|
+
websearch: {
|
|
825
|
+
...DEFAULT_WEBSEARCH_CONFIG,
|
|
826
|
+
...globalConfig.websearch,
|
|
827
|
+
...projectConfig.websearch,
|
|
828
|
+
maxOutputTokens: Math.max(
|
|
829
|
+
256,
|
|
830
|
+
Math.min(
|
|
831
|
+
100_000,
|
|
832
|
+
projectConfig.websearch?.maxOutputTokens ??
|
|
833
|
+
globalConfig.websearch?.maxOutputTokens ??
|
|
834
|
+
DEFAULT_WEBSEARCH_CONFIG.maxOutputTokens,
|
|
835
|
+
),
|
|
836
|
+
),
|
|
837
|
+
timeoutMs: Math.max(
|
|
838
|
+
5_000,
|
|
839
|
+
Math.min(
|
|
840
|
+
120_000,
|
|
841
|
+
projectConfig.websearch?.timeoutMs ??
|
|
842
|
+
globalConfig.websearch?.timeoutMs ??
|
|
843
|
+
DEFAULT_WEBSEARCH_CONFIG.timeoutMs,
|
|
844
|
+
),
|
|
845
|
+
),
|
|
846
|
+
},
|
|
704
847
|
live: {
|
|
705
848
|
...DEFAULT_LIVE_CONFIG,
|
|
706
849
|
...globalConfig.live,
|
package/src/websearch.ts
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Box, Container, Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
isRecord,
|
|
5
|
+
WEBSEARCH_RESPONSE_LENGTHS,
|
|
6
|
+
type ResolvedConfig,
|
|
7
|
+
type WebsearchResponseLength,
|
|
8
|
+
} from "./config.ts";
|
|
9
|
+
import { getCodexCredentials, type CodexCredentialsWithSource } from "./codex-auth.ts";
|
|
10
|
+
import { maskIdentifier, sanitizeDiagnosticError } from "./format.ts";
|
|
11
|
+
|
|
12
|
+
export const OPENAI_WEBSEARCH_TOOL = "openai_websearch";
|
|
13
|
+
export const OPENAI_WEBSEARCH_COMMAND = "openai-websearch";
|
|
14
|
+
export const CODEX_SEARCH_URL = "https://chatgpt.com/backend-api/codex/alpha/search";
|
|
15
|
+
export const SEARCH_ORIGINATOR = "codex_cli_rs";
|
|
16
|
+
export const MAX_QUERY_BYTES = 8 * 1024;
|
|
17
|
+
export const MAX_RESPONSE_BYTES = 256 * 1024;
|
|
18
|
+
export const MAX_TEXT_SOURCES = 10;
|
|
19
|
+
|
|
20
|
+
export type WebSearchErrorCode =
|
|
21
|
+
| "authentication_required"
|
|
22
|
+
| "authentication_failed"
|
|
23
|
+
| "invalid_query"
|
|
24
|
+
| "request_failed"
|
|
25
|
+
| "request_timeout"
|
|
26
|
+
| "request_aborted"
|
|
27
|
+
| "response_too_large"
|
|
28
|
+
| "invalid_response";
|
|
29
|
+
|
|
30
|
+
export class WebSearchError extends Error {
|
|
31
|
+
readonly code: WebSearchErrorCode;
|
|
32
|
+
|
|
33
|
+
constructor(code: WebSearchErrorCode, message: string) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "WebSearchError";
|
|
36
|
+
this.code = code;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type WebSearchResult = {
|
|
41
|
+
url: string;
|
|
42
|
+
title?: string;
|
|
43
|
+
content?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type WebSearchAnswer = {
|
|
47
|
+
answer: string;
|
|
48
|
+
results: WebSearchResult[];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type ToolParams = {
|
|
52
|
+
query: string;
|
|
53
|
+
responseLength?: WebsearchResponseLength;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const TOOL_PARAMS = {
|
|
57
|
+
type: "object",
|
|
58
|
+
properties: {
|
|
59
|
+
query: {
|
|
60
|
+
type: "string",
|
|
61
|
+
description: "The web search query. Pass the user's information need verbatim.",
|
|
62
|
+
},
|
|
63
|
+
responseLength: {
|
|
64
|
+
type: "string",
|
|
65
|
+
enum: WEBSEARCH_RESPONSE_LENGTHS,
|
|
66
|
+
description:
|
|
67
|
+
"Answer length produced by the search backend. Defaults to the configured value.",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
required: ["query"],
|
|
71
|
+
additionalProperties: false,
|
|
72
|
+
} as const;
|
|
73
|
+
|
|
74
|
+
export type WebSearchDebug = {
|
|
75
|
+
authFound: boolean;
|
|
76
|
+
authSource?: string;
|
|
77
|
+
accountId?: string;
|
|
78
|
+
endpoint: string;
|
|
79
|
+
model: string;
|
|
80
|
+
reasoningEffort: string;
|
|
81
|
+
enabled: boolean;
|
|
82
|
+
lastStatus?: string;
|
|
83
|
+
lastError?: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
async function getCredentials(
|
|
87
|
+
ctx: ExtensionContext,
|
|
88
|
+
signal?: AbortSignal,
|
|
89
|
+
): Promise<CodexCredentialsWithSource> {
|
|
90
|
+
const credentials = await getCodexCredentials(ctx, signal);
|
|
91
|
+
if (credentials) return credentials;
|
|
92
|
+
throw new WebSearchError(
|
|
93
|
+
"authentication_required",
|
|
94
|
+
"Missing openai-codex OAuth credentials. Run /login openai-codex.",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function validateSearchQuery(query: string): string {
|
|
99
|
+
const value = query.trim();
|
|
100
|
+
if (!value) throw new WebSearchError("invalid_query", "Web search requires a non-empty query.");
|
|
101
|
+
if (new TextEncoder().encode(value).byteLength > MAX_QUERY_BYTES) {
|
|
102
|
+
throw new WebSearchError(
|
|
103
|
+
"invalid_query",
|
|
104
|
+
`Web search query exceeded ${MAX_QUERY_BYTES} UTF-8 bytes.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function buildSearchRequestBody(
|
|
111
|
+
query: string,
|
|
112
|
+
cfg: ResolvedConfig["websearch"],
|
|
113
|
+
responseLength: WebsearchResponseLength,
|
|
114
|
+
id: string,
|
|
115
|
+
): Record<string, unknown> {
|
|
116
|
+
return {
|
|
117
|
+
id,
|
|
118
|
+
model: cfg.model,
|
|
119
|
+
reasoning: { effort: cfg.reasoningEffort },
|
|
120
|
+
input: query,
|
|
121
|
+
commands: {
|
|
122
|
+
search_query: [{ q: query }],
|
|
123
|
+
response_length: responseLength,
|
|
124
|
+
},
|
|
125
|
+
settings: {
|
|
126
|
+
allowed_callers: ["direct"],
|
|
127
|
+
external_web_access: true,
|
|
128
|
+
},
|
|
129
|
+
max_output_tokens: cfg.maxOutputTokens,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function buildSearchHeaders(
|
|
134
|
+
credentials: CodexCredentialsWithSource,
|
|
135
|
+
): Record<string, string> {
|
|
136
|
+
return {
|
|
137
|
+
authorization: `Bearer ${credentials.accessToken}`,
|
|
138
|
+
"chatgpt-account-id": credentials.accountId,
|
|
139
|
+
accept: "application/json",
|
|
140
|
+
"content-type": "application/json",
|
|
141
|
+
originator: SEARCH_ORIGINATOR,
|
|
142
|
+
"User-Agent": "codex_cli_rs/0.0.0 (pi-better-openai)",
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function trimmedText(value: unknown): string | undefined {
|
|
147
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isSafeUrl(value: string): boolean {
|
|
151
|
+
try {
|
|
152
|
+
const protocol = new URL(value).protocol;
|
|
153
|
+
return protocol === "http:" || protocol === "https:";
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function parseSearchResponse(body: string): WebSearchAnswer {
|
|
160
|
+
let value: unknown;
|
|
161
|
+
try {
|
|
162
|
+
value = JSON.parse(body);
|
|
163
|
+
} catch {
|
|
164
|
+
throw new WebSearchError("invalid_response", "ChatGPT web search returned invalid JSON.");
|
|
165
|
+
}
|
|
166
|
+
if (!isRecord(value) || typeof value.output !== "string") {
|
|
167
|
+
throw new WebSearchError(
|
|
168
|
+
"invalid_response",
|
|
169
|
+
"ChatGPT web search returned an invalid response.",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
if (value.results === undefined || value.results === null) {
|
|
173
|
+
throw new WebSearchError(
|
|
174
|
+
"invalid_response",
|
|
175
|
+
"ChatGPT web search did not return structured citations; the alpha protocol may have changed.",
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
if (!Array.isArray(value.results)) {
|
|
179
|
+
throw new WebSearchError("invalid_response", "ChatGPT web search returned invalid results.");
|
|
180
|
+
}
|
|
181
|
+
const results = value.results.flatMap((result): WebSearchResult[] => {
|
|
182
|
+
if (!isRecord(result) || result.type !== "text_result" || typeof result.url !== "string")
|
|
183
|
+
return [];
|
|
184
|
+
if (!isSafeUrl(result.url)) return [];
|
|
185
|
+
const title = trimmedText(result.title);
|
|
186
|
+
const content = trimmedText(result.snippet);
|
|
187
|
+
return [{ url: result.url, ...(title ? { title } : {}), ...(content ? { content } : {}) }];
|
|
188
|
+
});
|
|
189
|
+
return { answer: value.output.trim(), results };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function readBoundedResponse(response: Response): Promise<string> {
|
|
193
|
+
const declared = response.headers.get("content-length");
|
|
194
|
+
if (declared && /^\d+$/.test(declared) && Number(declared) > MAX_RESPONSE_BYTES) {
|
|
195
|
+
await response.body?.cancel().catch(() => undefined);
|
|
196
|
+
throw new WebSearchError(
|
|
197
|
+
"response_too_large",
|
|
198
|
+
`ChatGPT web search response exceeded ${MAX_RESPONSE_BYTES} bytes.`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
if (!response.body) return "";
|
|
202
|
+
|
|
203
|
+
const reader = response.body.getReader();
|
|
204
|
+
const chunks: Uint8Array[] = [];
|
|
205
|
+
let size = 0;
|
|
206
|
+
try {
|
|
207
|
+
for (;;) {
|
|
208
|
+
const item = await reader.read();
|
|
209
|
+
if (item.done) break;
|
|
210
|
+
size += item.value.byteLength;
|
|
211
|
+
if (size > MAX_RESPONSE_BYTES) {
|
|
212
|
+
await reader.cancel().catch(() => undefined);
|
|
213
|
+
throw new WebSearchError(
|
|
214
|
+
"response_too_large",
|
|
215
|
+
`ChatGPT web search response exceeded ${MAX_RESPONSE_BYTES} bytes.`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
chunks.push(item.value);
|
|
219
|
+
}
|
|
220
|
+
} finally {
|
|
221
|
+
reader.releaseLock();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const body = new Uint8Array(size);
|
|
225
|
+
let offset = 0;
|
|
226
|
+
for (const chunk of chunks) {
|
|
227
|
+
body.set(chunk, offset);
|
|
228
|
+
offset += chunk.byteLength;
|
|
229
|
+
}
|
|
230
|
+
return new TextDecoder().decode(body);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function formatSearchText(query: string, answer: WebSearchAnswer): string {
|
|
234
|
+
const lines: string[] = [];
|
|
235
|
+
if (answer.answer) lines.push(answer.answer, "");
|
|
236
|
+
const sources = answer.results.slice(0, MAX_TEXT_SOURCES);
|
|
237
|
+
if (sources.length > 0) {
|
|
238
|
+
lines.push("Sources:");
|
|
239
|
+
sources.forEach((result, index) => {
|
|
240
|
+
lines.push(`${index + 1}. [${result.title ?? result.url}](${result.url})`);
|
|
241
|
+
if (result.content) lines.push(` ${result.content}`);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
if (lines.length === 0) return `No web results found for "${query}".`;
|
|
245
|
+
return lines.join("\n").trim();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function requestWebSearch(
|
|
249
|
+
params: ToolParams,
|
|
250
|
+
ctx: ExtensionContext,
|
|
251
|
+
cfg: ResolvedConfig,
|
|
252
|
+
requestSignal?: AbortSignal,
|
|
253
|
+
): Promise<{ query: string; answer: WebSearchAnswer; model: string }> {
|
|
254
|
+
if (!cfg.websearch.enabled) throw new Error("OpenAI web search is disabled in config.");
|
|
255
|
+
const query = validateSearchQuery(String(params.query ?? ""));
|
|
256
|
+
const responseLength = params.responseLength ?? cfg.websearch.responseLength;
|
|
257
|
+
const timeoutSignal = AbortSignal.timeout(cfg.websearch.timeoutMs);
|
|
258
|
+
const baseSignal = requestSignal ?? ctx.signal;
|
|
259
|
+
const signal = baseSignal ? AbortSignal.any([baseSignal, timeoutSignal]) : timeoutSignal;
|
|
260
|
+
const credentials = await getCredentials(ctx, signal);
|
|
261
|
+
|
|
262
|
+
let response: Response;
|
|
263
|
+
try {
|
|
264
|
+
response = await fetch(CODEX_SEARCH_URL, {
|
|
265
|
+
method: "POST",
|
|
266
|
+
headers: buildSearchHeaders(credentials),
|
|
267
|
+
body: JSON.stringify(
|
|
268
|
+
buildSearchRequestBody(query, cfg.websearch, responseLength, crypto.randomUUID()),
|
|
269
|
+
),
|
|
270
|
+
signal,
|
|
271
|
+
redirect: "error",
|
|
272
|
+
credentials: "omit",
|
|
273
|
+
cache: "no-store",
|
|
274
|
+
referrerPolicy: "no-referrer",
|
|
275
|
+
});
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (error instanceof WebSearchError) throw error;
|
|
278
|
+
if (baseSignal?.aborted)
|
|
279
|
+
throw new WebSearchError("request_aborted", "ChatGPT web search was aborted.");
|
|
280
|
+
if (timeoutSignal.aborted)
|
|
281
|
+
throw new WebSearchError(
|
|
282
|
+
"request_timeout",
|
|
283
|
+
`ChatGPT web search timed out after ${cfg.websearch.timeoutMs}ms.`,
|
|
284
|
+
);
|
|
285
|
+
throw new WebSearchError(
|
|
286
|
+
"request_failed",
|
|
287
|
+
`ChatGPT web search request failed: ${sanitizeDiagnosticError(error instanceof Error ? error.message : String(error))}`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (response.url && new URL(response.url).origin !== new URL(CODEX_SEARCH_URL).origin) {
|
|
292
|
+
await response.body?.cancel().catch(() => undefined);
|
|
293
|
+
throw new WebSearchError(
|
|
294
|
+
"request_failed",
|
|
295
|
+
"ChatGPT web search returned from an unexpected origin.",
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
if (!response.ok) {
|
|
299
|
+
await response.body?.cancel().catch(() => undefined);
|
|
300
|
+
if (response.status === 401 || response.status === 403) {
|
|
301
|
+
throw new WebSearchError(
|
|
302
|
+
"authentication_failed",
|
|
303
|
+
`ChatGPT web search authentication failed (HTTP ${response.status}). Reconnect with /login openai-codex.`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
throw new WebSearchError(
|
|
307
|
+
"request_failed",
|
|
308
|
+
`ChatGPT web search failed (HTTP ${response.status}).`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const parsed = parseSearchResponse(await readBoundedResponse(response));
|
|
313
|
+
return { query, answer: parsed, model: cfg.websearch.model };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function registerOpenAIWebSearch(
|
|
317
|
+
pi: ExtensionAPI,
|
|
318
|
+
getConfig: (ctx: ExtensionContext) => ResolvedConfig,
|
|
319
|
+
): { getDebug: (ctx: ExtensionContext) => Promise<WebSearchDebug> } {
|
|
320
|
+
let lastStatus: string | undefined;
|
|
321
|
+
let lastError: string | undefined;
|
|
322
|
+
|
|
323
|
+
async function search(
|
|
324
|
+
params: ToolParams,
|
|
325
|
+
ctx: ExtensionContext,
|
|
326
|
+
requestSignal?: AbortSignal,
|
|
327
|
+
): Promise<{ text: string; details: Record<string, unknown> }> {
|
|
328
|
+
try {
|
|
329
|
+
lastStatus = "requesting";
|
|
330
|
+
lastError = undefined;
|
|
331
|
+
const result = await requestWebSearch(params, ctx, getConfig(ctx), requestSignal);
|
|
332
|
+
lastStatus = `completed (${result.answer.results.length} sources)`;
|
|
333
|
+
return {
|
|
334
|
+
text: formatSearchText(result.query, result.answer),
|
|
335
|
+
details: {
|
|
336
|
+
query: result.query,
|
|
337
|
+
answer: result.answer.answer,
|
|
338
|
+
results: result.answer.results,
|
|
339
|
+
model: result.model,
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
} catch (error) {
|
|
343
|
+
lastStatus = "error";
|
|
344
|
+
lastError = sanitizeDiagnosticError(error instanceof Error ? error.message : String(error));
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function getDebug(ctx: ExtensionContext): Promise<WebSearchDebug> {
|
|
350
|
+
const cfg = getConfig(ctx);
|
|
351
|
+
let credentials: CodexCredentialsWithSource | undefined;
|
|
352
|
+
try {
|
|
353
|
+
credentials = await getCredentials(ctx);
|
|
354
|
+
} catch {
|
|
355
|
+
credentials = undefined;
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
authFound: credentials !== undefined,
|
|
359
|
+
authSource: credentials?.source,
|
|
360
|
+
accountId: maskIdentifier(credentials?.accountId),
|
|
361
|
+
endpoint: CODEX_SEARCH_URL,
|
|
362
|
+
model: cfg.websearch.model,
|
|
363
|
+
reasoningEffort: cfg.websearch.reasoningEffort,
|
|
364
|
+
enabled: cfg.websearch.enabled,
|
|
365
|
+
lastStatus,
|
|
366
|
+
lastError,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
pi.registerMessageRenderer("openai-websearch", (message, _options, theme) => {
|
|
371
|
+
const text = Array.isArray(message.content)
|
|
372
|
+
? message.content
|
|
373
|
+
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
|
374
|
+
.map((part) => part.text)
|
|
375
|
+
.join("\n")
|
|
376
|
+
: String(message.content ?? "");
|
|
377
|
+
const container = new Container();
|
|
378
|
+
const box = new Box(1, 1, (line) => theme.bg("customMessageBg", line));
|
|
379
|
+
box.addChild(
|
|
380
|
+
new Text(`${theme.fg("accent", theme.bold("[openai-websearch]"))}\n\n${text}`, 0, 0),
|
|
381
|
+
);
|
|
382
|
+
container.addChild(box);
|
|
383
|
+
return container;
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
pi.registerCommand(OPENAI_WEBSEARCH_COMMAND, {
|
|
387
|
+
description: "Search the web with the ChatGPT Codex search backend",
|
|
388
|
+
handler: async (args, ctx) => {
|
|
389
|
+
const query = args.trim();
|
|
390
|
+
if (!query) {
|
|
391
|
+
ctx.ui.notify("Usage: /openai-websearch <query>", "error");
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
ctx.ui.notify("Searching the web via ChatGPT...", "info");
|
|
395
|
+
const result = await search({ query }, ctx);
|
|
396
|
+
pi.sendMessage({
|
|
397
|
+
customType: "openai-websearch",
|
|
398
|
+
content: [{ type: "text", text: result.text }],
|
|
399
|
+
display: true,
|
|
400
|
+
details: result.details,
|
|
401
|
+
});
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
pi.registerTool({
|
|
406
|
+
name: OPENAI_WEBSEARCH_TOOL,
|
|
407
|
+
label: "OpenAI web search",
|
|
408
|
+
description:
|
|
409
|
+
"Search the live web through the ChatGPT Codex search backend using OpenAI subscription auth. Returns an answer with cited sources.",
|
|
410
|
+
promptSnippet: "Search the live web via the ChatGPT Codex search backend.",
|
|
411
|
+
promptGuidelines: [
|
|
412
|
+
"Use openai_websearch when the user asks about current events, recent releases, prices, or anything requiring live web data.",
|
|
413
|
+
"Prefer openai_websearch over recalling facts that may have changed recently; cite the returned source URLs in your answer.",
|
|
414
|
+
],
|
|
415
|
+
parameters: TOOL_PARAMS,
|
|
416
|
+
async execute(_toolCallId, params: ToolParams, signal, onUpdate, ctx) {
|
|
417
|
+
onUpdate?.({
|
|
418
|
+
content: [{ type: "text", text: `Searching the web via ChatGPT for "${params.query}"...` }],
|
|
419
|
+
details: undefined,
|
|
420
|
+
});
|
|
421
|
+
const result = await search(params, ctx, signal);
|
|
422
|
+
return {
|
|
423
|
+
content: [{ type: "text", text: result.text }],
|
|
424
|
+
details: result.details,
|
|
425
|
+
};
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
return { getDebug };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export const _websearchTest = {
|
|
433
|
+
CODEX_SEARCH_URL,
|
|
434
|
+
MAX_QUERY_BYTES,
|
|
435
|
+
MAX_RESPONSE_BYTES,
|
|
436
|
+
MAX_TEXT_SOURCES,
|
|
437
|
+
OPENAI_WEBSEARCH_COMMAND,
|
|
438
|
+
OPENAI_WEBSEARCH_TOOL,
|
|
439
|
+
SEARCH_ORIGINATOR,
|
|
440
|
+
buildSearchHeaders,
|
|
441
|
+
buildSearchRequestBody,
|
|
442
|
+
formatSearchText,
|
|
443
|
+
parseSearchResponse,
|
|
444
|
+
readBoundedResponse,
|
|
445
|
+
validateSearchQuery,
|
|
446
|
+
};
|