pi-codex-search 0.1.1 → 0.1.2
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 +176 -50
- package/index.ts +353 -104
- package/package.json +3 -2
- package/src/codex.ts +50 -6
- package/src/command.ts +343 -0
- package/src/config.ts +214 -0
package/index.ts
CHANGED
|
@@ -3,106 +3,315 @@ 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 { Text } from "@earendil-works/pi-tui";
|
|
7
9
|
import {
|
|
10
|
+
classifyError,
|
|
11
|
+
type CodexCitation,
|
|
12
|
+
type CodexErrorKind,
|
|
13
|
+
type CodexSearchCall,
|
|
8
14
|
extractAccountIdFromToken,
|
|
9
15
|
fetchCodexModels,
|
|
10
16
|
fetchCodexWebSearch,
|
|
11
17
|
selectDefaultModel,
|
|
12
18
|
type SearchContextSize,
|
|
13
19
|
} from "./src/codex.ts";
|
|
20
|
+
import { registerSettingsCommand } from "./src/command.ts";
|
|
21
|
+
import { type Freshness, loadConfig, type ResolvedConfig } from "./src/config.ts";
|
|
14
22
|
|
|
15
23
|
const OPENAI_CODEX_PROVIDER = "openai-codex";
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
24
|
+
const MAX_QUERIES = 5;
|
|
25
|
+
|
|
26
|
+
interface QuerySuccess {
|
|
27
|
+
query: string;
|
|
28
|
+
text: string;
|
|
29
|
+
citations: CodexCitation[];
|
|
30
|
+
searchCalls: CodexSearchCall[];
|
|
31
|
+
responseId?: string;
|
|
32
|
+
usage?: {
|
|
33
|
+
inputTokens?: number;
|
|
34
|
+
outputTokens?: number;
|
|
35
|
+
totalTokens?: number;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface QueryFailure {
|
|
40
|
+
query: string;
|
|
41
|
+
kind: CodexErrorKind;
|
|
42
|
+
message: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface WebSearchFailureDetail {
|
|
46
|
+
kind: CodexErrorKind;
|
|
47
|
+
message: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface WebSearchDetails {
|
|
51
|
+
model: string;
|
|
52
|
+
freshness: Freshness;
|
|
53
|
+
searchContextSize: SearchContextSize;
|
|
54
|
+
queryCount: number;
|
|
55
|
+
failedQueryCount: number;
|
|
56
|
+
successes: QuerySuccess[];
|
|
57
|
+
failures: QueryFailure[];
|
|
58
|
+
failure?: WebSearchFailureDetail;
|
|
59
|
+
partial?: boolean;
|
|
60
|
+
completed?: number;
|
|
61
|
+
total?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildTool(config: ResolvedConfig) {
|
|
65
|
+
return defineTool({
|
|
66
|
+
name: config.toolName,
|
|
67
|
+
label: "Codex Search",
|
|
68
|
+
description:
|
|
69
|
+
"Search the web using the user's configured ChatGPT Codex subscription. Accepts one or more queries in a single call; results are returned grouped by query with sources.",
|
|
70
|
+
promptSnippet: `${config.toolName}: search the web using the configured ChatGPT Codex subscription.`,
|
|
71
|
+
promptGuidelines: [
|
|
72
|
+
`Use ${config.toolName} when current or source-backed information is needed.`,
|
|
73
|
+
`Batch up to ${MAX_QUERIES} related queries in one call when grouped comparison matters; use separate calls when independent results unblock the next step.`,
|
|
74
|
+
"Do not ask the user for an access token; the tool uses pi's configured OpenAI Codex subscription.",
|
|
75
|
+
],
|
|
76
|
+
parameters: Type.Object({
|
|
77
|
+
queries: Type.Array(Type.String({ minLength: 1 }), {
|
|
78
|
+
minItems: 1,
|
|
79
|
+
maxItems: MAX_QUERIES,
|
|
80
|
+
description: `One or more search queries to run in parallel (max ${MAX_QUERIES}).`,
|
|
33
81
|
}),
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (!token) {
|
|
46
|
-
throw new Error(
|
|
47
|
-
"OpenAI Codex subscription is not configured. Run /login and choose ChatGPT Plus/Pro.",
|
|
48
|
-
);
|
|
49
|
-
}
|
|
82
|
+
search_context_size: Type.Optional(
|
|
83
|
+
StringEnum(["low", "medium", "high"] as const, {
|
|
84
|
+
description: `Amount of web context to retrieve. Defaults to ${config.defaultSearchContextSize}.`,
|
|
85
|
+
}),
|
|
86
|
+
),
|
|
87
|
+
freshness: Type.Optional(
|
|
88
|
+
StringEnum(["cached", "live"] as const, {
|
|
89
|
+
description: `Use 'live' for time-sensitive queries; 'cached' for stable topics. Defaults to ${config.defaultFreshness}.`,
|
|
90
|
+
}),
|
|
91
|
+
),
|
|
92
|
+
}),
|
|
50
93
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
94
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
95
|
+
const queries = params.queries.map((q) => q.trim()).filter((q) => q.length > 0);
|
|
96
|
+
if (queries.length === 0) {
|
|
97
|
+
throw new Error("queries must contain at least one non-empty entry");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const token = await ctx.modelRegistry.getApiKeyForProvider(OPENAI_CODEX_PROVIDER);
|
|
101
|
+
if (!token) {
|
|
102
|
+
const err = new Error(
|
|
103
|
+
"OpenAI Codex subscription is not configured. Run `/login openai-codex` and choose ChatGPT Plus/Pro.",
|
|
104
|
+
);
|
|
105
|
+
(err as Error & { kind?: CodexErrorKind }).kind = "auth";
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const accountId = getConfiguredAccountId(ctx, token);
|
|
110
|
+
if (!accountId) {
|
|
111
|
+
const err = new Error(
|
|
112
|
+
"OpenAI Codex account id was not found in stored credentials or access token. Re-run `/login openai-codex`.",
|
|
113
|
+
);
|
|
114
|
+
(err as Error & { kind?: CodexErrorKind }).kind = "auth";
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const model = await resolveSearchModel(ctx, token, accountId, config, signal);
|
|
119
|
+
const freshness = params.freshness ?? config.defaultFreshness;
|
|
120
|
+
const searchContextSize = params.search_context_size ?? config.defaultSearchContextSize;
|
|
121
|
+
|
|
122
|
+
const total = queries.length;
|
|
123
|
+
let completed = 0;
|
|
124
|
+
let streamedText = "";
|
|
125
|
+
|
|
126
|
+
const emitPartial = (partialText: string) => {
|
|
73
127
|
onUpdate?.({
|
|
74
|
-
content: [{ type: "text", text:
|
|
75
|
-
details: {
|
|
128
|
+
content: [{ type: "text", text: partialText }],
|
|
129
|
+
details: {
|
|
130
|
+
model,
|
|
131
|
+
freshness,
|
|
132
|
+
searchContextSize,
|
|
133
|
+
queryCount: total,
|
|
134
|
+
failedQueryCount: 0,
|
|
135
|
+
successes: [],
|
|
136
|
+
failures: [],
|
|
137
|
+
partial: true,
|
|
138
|
+
completed,
|
|
139
|
+
total,
|
|
140
|
+
} satisfies WebSearchDetails,
|
|
76
141
|
});
|
|
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
|
-
});
|
|
142
|
+
};
|
|
92
143
|
|
|
93
|
-
|
|
94
|
-
let registered = false;
|
|
144
|
+
if (total > 1) emitPartial(formatProgress(completed, total));
|
|
95
145
|
|
|
96
|
-
|
|
97
|
-
|
|
146
|
+
const settled = await Promise.allSettled(
|
|
147
|
+
queries.map(async (query) => {
|
|
148
|
+
const onTextDelta =
|
|
149
|
+
total === 1
|
|
150
|
+
? (delta: string) => {
|
|
151
|
+
streamedText += delta;
|
|
152
|
+
emitPartial(streamedText);
|
|
153
|
+
}
|
|
154
|
+
: undefined;
|
|
155
|
+
const fetchOpts: Parameters<typeof fetchCodexWebSearch>[0] = {
|
|
156
|
+
query,
|
|
157
|
+
token,
|
|
158
|
+
accountId,
|
|
159
|
+
model,
|
|
160
|
+
externalWebAccess: freshness === "live",
|
|
161
|
+
searchContextSize,
|
|
162
|
+
};
|
|
163
|
+
if (config.baseUrl !== undefined) fetchOpts.baseUrl = config.baseUrl;
|
|
164
|
+
if (signal) fetchOpts.signal = signal;
|
|
165
|
+
if (onTextDelta) fetchOpts.onTextDelta = onTextDelta;
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
return await fetchCodexWebSearch(fetchOpts);
|
|
169
|
+
} finally {
|
|
170
|
+
completed += 1;
|
|
171
|
+
if (total > 1) emitPartial(formatProgress(completed, total));
|
|
172
|
+
}
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const successes: QuerySuccess[] = [];
|
|
177
|
+
const failures: QueryFailure[] = [];
|
|
178
|
+
|
|
179
|
+
settled.forEach((outcome, index) => {
|
|
180
|
+
const query = queries[index] ?? "";
|
|
181
|
+
if (outcome.status === "fulfilled") {
|
|
182
|
+
const success: QuerySuccess = {
|
|
183
|
+
query,
|
|
184
|
+
text: outcome.value.text,
|
|
185
|
+
citations: outcome.value.citations,
|
|
186
|
+
searchCalls: outcome.value.searchCalls,
|
|
187
|
+
};
|
|
188
|
+
if (outcome.value.responseId !== undefined) success.responseId = outcome.value.responseId;
|
|
189
|
+
if (outcome.value.usage !== undefined) success.usage = outcome.value.usage;
|
|
190
|
+
successes.push(success);
|
|
191
|
+
} else {
|
|
192
|
+
const kind = classifyError(outcome.reason);
|
|
193
|
+
const message =
|
|
194
|
+
outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
|
|
195
|
+
failures.push({ query, kind, message });
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (successes.length === 0) {
|
|
200
|
+
const primary = failures[0];
|
|
201
|
+
const summary =
|
|
202
|
+
failures.length === 1
|
|
203
|
+
? (primary?.message ?? "Codex web search failed")
|
|
204
|
+
: `All ${failures.length} ${config.toolName} queries failed: ${failures
|
|
205
|
+
.map((f, i) => `${i + 1}. [${f.kind}] ${f.message}`)
|
|
206
|
+
.join("; ")}`;
|
|
207
|
+
const err = new Error(summary) as Error & {
|
|
208
|
+
kind?: CodexErrorKind;
|
|
209
|
+
failures?: QueryFailure[];
|
|
210
|
+
};
|
|
211
|
+
err.kind = primary?.kind ?? "unknown";
|
|
212
|
+
err.failures = failures;
|
|
213
|
+
throw err;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
content: [{ type: "text", text: formatToolText(successes, failures) }],
|
|
218
|
+
details: {
|
|
219
|
+
model,
|
|
220
|
+
freshness,
|
|
221
|
+
searchContextSize,
|
|
222
|
+
queryCount: total,
|
|
223
|
+
failedQueryCount: failures.length,
|
|
224
|
+
successes,
|
|
225
|
+
failures,
|
|
226
|
+
} satisfies WebSearchDetails,
|
|
227
|
+
};
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
renderCall(args, theme) {
|
|
231
|
+
const queries = Array.isArray(args.queries) ? args.queries : [];
|
|
232
|
+
const fresh = (args.freshness as string | undefined) ?? config.defaultFreshness;
|
|
233
|
+
const ctxSize =
|
|
234
|
+
(args.search_context_size as string | undefined) ?? config.defaultSearchContextSize;
|
|
235
|
+
|
|
236
|
+
let text = theme.fg("toolTitle", theme.bold(`${config.toolName} `));
|
|
237
|
+
if (queries.length === 1) {
|
|
238
|
+
text += theme.fg("accent", formatInline(queries[0] ?? "", 90));
|
|
239
|
+
} else {
|
|
240
|
+
text += theme.fg("accent", `${queries.length} queries`);
|
|
241
|
+
}
|
|
242
|
+
text += theme.fg("dim", ` [${ctxSize}/${fresh}]`);
|
|
243
|
+
return new Text(text, 0, 0);
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
247
|
+
const details = result.details as WebSearchDetails | undefined;
|
|
248
|
+
|
|
249
|
+
if (isPartial) {
|
|
250
|
+
return new Text(renderPartial(details, theme), 0, 0);
|
|
251
|
+
}
|
|
98
252
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
253
|
+
if (!details) {
|
|
254
|
+
const content = result.content.find((part) => part.type === "text");
|
|
255
|
+
const text = content?.type === "text" ? content.text : "";
|
|
256
|
+
return new Text(text || theme.fg("success", "✓ Web search finished"), 0, 0);
|
|
257
|
+
}
|
|
103
258
|
|
|
104
|
-
|
|
105
|
-
|
|
259
|
+
const total = details.queryCount;
|
|
260
|
+
const failed = details.failedQueryCount;
|
|
261
|
+
const ok = total - failed;
|
|
262
|
+
const sourceCount = details.successes.reduce((acc, s) => acc + s.citations.length, 0);
|
|
263
|
+
|
|
264
|
+
let header: string;
|
|
265
|
+
if (ok === 0) {
|
|
266
|
+
header = theme.fg("warning", `⚠ Web search failed (${details.failure?.kind ?? "unknown"})`);
|
|
267
|
+
} else if (failed > 0) {
|
|
268
|
+
header = theme.fg(
|
|
269
|
+
"warning",
|
|
270
|
+
`⚠ ${ok}/${total} queries succeeded · ${sourceCount} source${sourceCount === 1 ? "" : "s"}`,
|
|
271
|
+
);
|
|
272
|
+
} else {
|
|
273
|
+
const querySuffix = total === 1 ? "" : ` across ${total} queries`;
|
|
274
|
+
header = theme.fg(
|
|
275
|
+
"success",
|
|
276
|
+
`✓ ${sourceCount} source${sourceCount === 1 ? "" : "s"}${querySuffix}`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
header += theme.fg("muted", ` [${details.searchContextSize}/${details.freshness}]`);
|
|
280
|
+
|
|
281
|
+
if (!expanded) {
|
|
282
|
+
const preview = renderCollapsedPreview(details, theme);
|
|
283
|
+
return new Text(preview ? `${header}\n${preview}` : header, 0, 0);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const content = result.content.find((part) => part.type === "text");
|
|
287
|
+
const body = content?.type === "text" ? content.text : "";
|
|
288
|
+
|
|
289
|
+
let text = header;
|
|
290
|
+
text += `\n${theme.fg("muted", `Model: ${details.model}`)}`;
|
|
291
|
+
if (failed > 0) {
|
|
292
|
+
text += `\n${theme.fg("warning", `Failures (${failed}):`)}`;
|
|
293
|
+
for (const [i, f] of details.failures.entries()) {
|
|
294
|
+
text += `\n${theme.fg("dim", ` ${i + 1}. [${f.kind}] ${formatInline(f.query, 60)} — ${formatInline(f.message, 100)}`)}`;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (body) {
|
|
298
|
+
text += `\n\n${body
|
|
299
|
+
.split("\n")
|
|
300
|
+
.map((line) => theme.fg("toolOutput", line))
|
|
301
|
+
.join("\n")}`;
|
|
302
|
+
}
|
|
303
|
+
return new Text(text, 0, 0);
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export default function codexWebSearchExtension(pi: ExtensionAPI) {
|
|
309
|
+
registerSettingsCommand(pi);
|
|
310
|
+
|
|
311
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
312
|
+
const config = await loadConfig(ctx.cwd);
|
|
313
|
+
if (!config.enabled) return;
|
|
314
|
+
pi.registerTool(buildTool(config));
|
|
106
315
|
});
|
|
107
316
|
}
|
|
108
317
|
|
|
@@ -118,20 +327,21 @@ async function resolveSearchModel(
|
|
|
118
327
|
ctx: ExtensionContext,
|
|
119
328
|
token: string,
|
|
120
329
|
accountId: string,
|
|
121
|
-
|
|
330
|
+
config: ResolvedConfig,
|
|
122
331
|
signal: AbortSignal | undefined,
|
|
123
332
|
): Promise<string> {
|
|
124
|
-
|
|
125
|
-
if (override) return override;
|
|
333
|
+
if (config.model) return config.model;
|
|
126
334
|
if (ctx.model?.provider === OPENAI_CODEX_PROVIDER) return ctx.model.id;
|
|
127
335
|
|
|
128
|
-
const
|
|
336
|
+
const fetchOpts: Parameters<typeof fetchCodexModels>[0] = {
|
|
129
337
|
token,
|
|
130
338
|
accountId,
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
339
|
+
};
|
|
340
|
+
if (config.baseUrl !== undefined) fetchOpts.baseUrl = config.baseUrl;
|
|
341
|
+
if (config.clientVersion !== undefined) fetchOpts.clientVersion = config.clientVersion;
|
|
342
|
+
if (signal) fetchOpts.signal = signal;
|
|
343
|
+
|
|
344
|
+
const models = await fetchCodexModels(fetchOpts);
|
|
135
345
|
const model = selectDefaultModel(models);
|
|
136
346
|
if (!model) {
|
|
137
347
|
throw new Error("Codex model list is empty.");
|
|
@@ -139,25 +349,64 @@ async function resolveSearchModel(
|
|
|
139
349
|
return model;
|
|
140
350
|
}
|
|
141
351
|
|
|
142
|
-
function
|
|
143
|
-
|
|
144
|
-
if (configured === "low" || configured === "medium" || configured === "high") {
|
|
145
|
-
return configured;
|
|
146
|
-
}
|
|
147
|
-
throw new Error(`Invalid search_context_size: ${configured}`);
|
|
352
|
+
function formatProgress(completed: number, total: number): string {
|
|
353
|
+
return `Searching ${completed}/${total} ${completed === total ? "complete" : "in progress"}`;
|
|
148
354
|
}
|
|
149
355
|
|
|
150
|
-
function
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
356
|
+
function formatToolText(successes: QuerySuccess[], failures: QueryFailure[]): string {
|
|
357
|
+
const blocks: string[] = [];
|
|
358
|
+
const total = successes.length + failures.length;
|
|
359
|
+
const multiple = total > 1;
|
|
154
360
|
|
|
155
|
-
|
|
156
|
-
|
|
361
|
+
for (const success of successes) {
|
|
362
|
+
blocks.push(formatSuccessBlock(success, multiple));
|
|
363
|
+
}
|
|
364
|
+
for (const failure of failures) {
|
|
365
|
+
blocks.push(formatFailureBlock(failure, multiple));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return blocks.join("\n\n");
|
|
369
|
+
}
|
|
157
370
|
|
|
158
|
-
|
|
371
|
+
function formatSuccessBlock(success: QuerySuccess, multiple: boolean): string {
|
|
372
|
+
const text = success.text || "(no response text)";
|
|
373
|
+
const sourceLines = success.citations.map((citation, index) => {
|
|
159
374
|
const title = citation.title?.trim() || citation.url;
|
|
160
375
|
return `${index + 1}. ${title}: ${citation.url}`;
|
|
161
376
|
});
|
|
162
|
-
|
|
377
|
+
const body = sourceLines.length > 0 ? `${text}\n\nSources:\n${sourceLines.join("\n")}` : text;
|
|
378
|
+
return multiple ? `## Query: ${success.query}\n\n${body}` : body;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function formatFailureBlock(failure: QueryFailure, multiple: boolean): string {
|
|
382
|
+
const body = `[${failure.kind}] ${failure.message}`;
|
|
383
|
+
return multiple ? `## Query: ${failure.query}\n\nFAILED: ${body}` : `FAILED: ${body}`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function renderPartial(details: WebSearchDetails | undefined, theme: Theme): string {
|
|
387
|
+
if (!details) return theme.fg("warning", "Searching the web…");
|
|
388
|
+
const completed = details.completed ?? 0;
|
|
389
|
+
const total = details.total ?? details.queryCount;
|
|
390
|
+
const header = theme.fg("warning", `Searching ${completed}/${total}`);
|
|
391
|
+
const trailingDot = completed < total ? theme.fg("dim", " …") : theme.fg("dim", " (finalizing)");
|
|
392
|
+
return header + trailingDot;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function renderCollapsedPreview(details: WebSearchDetails, theme: Theme): string {
|
|
396
|
+
const firstSuccess = details.successes[0];
|
|
397
|
+
if (firstSuccess) {
|
|
398
|
+
const snippet = formatInline(firstSuccess.text, 110);
|
|
399
|
+
if (snippet) return theme.fg("dim", snippet);
|
|
400
|
+
}
|
|
401
|
+
const firstFailure = details.failures[0];
|
|
402
|
+
if (firstFailure) {
|
|
403
|
+
return theme.fg("dim", formatInline(firstFailure.message, 110));
|
|
404
|
+
}
|
|
405
|
+
return "";
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function formatInline(value: unknown, maxLength = 90): string {
|
|
409
|
+
const text = typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
|
|
410
|
+
if (!text) return "";
|
|
411
|
+
return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
|
|
163
412
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-codex-search",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Pi extension for Codex Web Search — reuse your ChatGPT Plus/Pro Codex subscription in pi-coding-agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"chatgpt",
|
|
@@ -58,7 +58,8 @@
|
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
60
|
"@earendil-works/pi-ai": "*",
|
|
61
|
-
"@earendil-works/pi-coding-agent": "*"
|
|
61
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
62
|
+
"@earendil-works/pi-tui": "*"
|
|
62
63
|
},
|
|
63
64
|
"pi": {
|
|
64
65
|
"extensions": [
|
package/src/codex.ts
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
export type SearchContextSize = "low" | "medium" | "high";
|
|
2
2
|
|
|
3
|
+
export type CodexErrorKind = "auth" | "rate_limit" | "transport" | "timeout" | "schema" | "unknown";
|
|
4
|
+
|
|
5
|
+
export class CodexError extends Error {
|
|
6
|
+
readonly kind: CodexErrorKind;
|
|
7
|
+
readonly status?: number;
|
|
8
|
+
|
|
9
|
+
constructor(kind: CodexErrorKind, message: string, status?: number) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "CodexError";
|
|
12
|
+
this.kind = kind;
|
|
13
|
+
if (status !== undefined) this.status = status;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function classifyError(error: unknown): CodexErrorKind {
|
|
18
|
+
if (error instanceof CodexError) return error.kind;
|
|
19
|
+
if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) {
|
|
20
|
+
return "timeout";
|
|
21
|
+
}
|
|
22
|
+
return "unknown";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function classifyHttpStatus(status: number): CodexErrorKind {
|
|
26
|
+
if (status === 401 || status === 403) return "auth";
|
|
27
|
+
if (status === 429) return "rate_limit";
|
|
28
|
+
return "transport";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function classifyEventErrorMessage(message: string): CodexErrorKind {
|
|
32
|
+
const lower = message.toLowerCase();
|
|
33
|
+
if (/rate[- ]?limit|too many requests|quota|429/.test(lower)) return "rate_limit";
|
|
34
|
+
if (/auth|unauthori[sz]ed|forbidden|401|403/.test(lower)) return "auth";
|
|
35
|
+
if (/timeout|timed out/.test(lower)) return "timeout";
|
|
36
|
+
if (/network|connection|disconnect|transport|fetch failed/.test(lower)) return "transport";
|
|
37
|
+
return "unknown";
|
|
38
|
+
}
|
|
39
|
+
|
|
3
40
|
export interface CodexModel {
|
|
4
41
|
id: string;
|
|
5
42
|
name?: string;
|
|
@@ -157,8 +194,11 @@ export async function fetchCodexModels(options: {
|
|
|
157
194
|
signal: options.signal,
|
|
158
195
|
});
|
|
159
196
|
if (!response.ok) {
|
|
160
|
-
|
|
161
|
-
|
|
197
|
+
const status = response.status;
|
|
198
|
+
throw new CodexError(
|
|
199
|
+
classifyHttpStatus(status),
|
|
200
|
+
`Codex models request failed: HTTP ${status} ${await response.text()}`,
|
|
201
|
+
status,
|
|
162
202
|
);
|
|
163
203
|
}
|
|
164
204
|
|
|
@@ -196,12 +236,15 @@ export async function fetchCodexWebSearch(
|
|
|
196
236
|
});
|
|
197
237
|
|
|
198
238
|
if (!response.ok) {
|
|
199
|
-
|
|
200
|
-
|
|
239
|
+
const status = response.status;
|
|
240
|
+
throw new CodexError(
|
|
241
|
+
classifyHttpStatus(status),
|
|
242
|
+
`Codex web search request failed: HTTP ${status} ${await response.text()}`,
|
|
243
|
+
status,
|
|
201
244
|
);
|
|
202
245
|
}
|
|
203
246
|
if (!response.body) {
|
|
204
|
-
throw new
|
|
247
|
+
throw new CodexError("transport", "Codex web search response did not include a body");
|
|
205
248
|
}
|
|
206
249
|
|
|
207
250
|
let responseId: string | undefined;
|
|
@@ -247,7 +290,8 @@ export async function fetchCodexWebSearch(
|
|
|
247
290
|
}
|
|
248
291
|
|
|
249
292
|
if (event.type === "response.failed") {
|
|
250
|
-
|
|
293
|
+
const message = data.error?.message ?? data.error?.code ?? "Codex web search failed";
|
|
294
|
+
throw new CodexError(classifyEventErrorMessage(message), message);
|
|
251
295
|
}
|
|
252
296
|
}
|
|
253
297
|
|