pi-openai-toolkit 0.23.1 → 0.24.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 (53) hide show
  1. package/LICENSE +21 -21
  2. package/NOTICE +83 -83
  3. package/README.md +4 -11
  4. package/README.zh.md +4 -11
  5. package/config.schema.json +264 -278
  6. package/docs/configuration.md +304 -298
  7. package/docs/internals.md +98 -181
  8. package/package.json +123 -135
  9. package/src/compact-client.ts +412 -412
  10. package/src/compaction-projection.ts +35 -35
  11. package/src/config/legacy.ts +12 -62
  12. package/src/config/migration.ts +115 -103
  13. package/src/config/notifications.ts +10 -4
  14. package/src/config/policy.ts +9 -10
  15. package/src/config/v2.ts +12 -51
  16. package/src/config.ts +320 -310
  17. package/src/debug.ts +238 -238
  18. package/src/deferred-tool-carryover.ts +282 -282
  19. package/src/details-store.ts +151 -151
  20. package/src/extension-runtime.ts +1095 -1986
  21. package/src/image-generation/extension.ts +189 -189
  22. package/src/image-generation/protocol.ts +579 -579
  23. package/src/image-generation/types.ts +300 -300
  24. package/src/native-fallback.ts +14 -23
  25. package/src/payload-rewrite.ts +515 -515
  26. package/src/pi-context-hook.ts +117 -117
  27. package/src/remote-v2-client.ts +811 -811
  28. package/src/request-context-cache.ts +155 -155
  29. package/src/responses-headers.ts +180 -180
  30. package/src/retired-remote-windows.ts +35 -0
  31. package/src/runtime.ts +875 -865
  32. package/src/serializer.ts +882 -882
  33. package/src/supported-environment.ts +27 -27
  34. package/src/types.ts +541 -565
  35. package/src/web-search/alpha-client.ts +876 -876
  36. package/src/web-search/extension.ts +530 -530
  37. package/src/web-search/payload.ts +299 -299
  38. package/src/web-search/prompt.ts +60 -60
  39. package/src/web-search/types.ts +166 -166
  40. package/tsconfig.check.json +50 -50
  41. package/src/context-management/codex-provider.ts +0 -270
  42. package/src/context-management/history-notes.ts +0 -357
  43. package/src/context-management/manual-compact.ts +0 -599
  44. package/src/context-management/messages.ts +0 -238
  45. package/src/context-management/namespace-tools.ts +0 -426
  46. package/src/context-management/responses-tools.ts +0 -238
  47. package/src/context-management/tool-contract.ts +0 -62
  48. package/src/context-management/tools.ts +0 -371
  49. package/src/context-management/types.ts +0 -168
  50. package/src/context-management/window-budget.ts +0 -83
  51. package/src/context-management/window-bulk.ts +0 -155
  52. package/src/context-management/window-manager.ts +0 -905
  53. package/src/context-management/window-request.ts +0 -125
@@ -1,270 +0,0 @@
1
- import type { Api, Model, ProviderHeaders } from "@earendil-works/pi-ai";
2
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { mergeProviderHeaders } from "../provider-headers";
4
- import {
5
- CODEX_AFFINITY_SCOPE,
6
- CODEX_CLIENT_VERSION,
7
- CODEX_GATEWAY_ORIGINATOR,
8
- buildCodexCliUserAgent,
9
- filterCodexGatewayHeaders,
10
- } from "../responses-headers";
11
- export { CODEX_AFFINITY_SCOPE } from "../responses-headers";
12
- import { normalizeBaseUrl, resolveEffectiveModel, VIRTUAL_MODEL_API } from "../runtime";
13
- import { isExactModelAllowed } from "../model-scope";
14
- import {
15
- isNonEmptyString,
16
- type CodexContextProvider,
17
- type CodexContextProviderResolution,
18
- } from "./types";
19
-
20
- const CODEX_PROVIDER = "openai-codex";
21
- const CODEX_API = "openai-codex-responses";
22
- const GATEWAY_API = "openai-responses";
23
- const CODEX_TOOL_ORIGINATOR = CODEX_GATEWAY_ORIGINATOR;
24
- const CODEX_TOOL_VERSION = "0.0.0";
25
- const CODEX_SESSION_ID_MAX_LENGTH = 64;
26
-
27
- export const CODEX_CONTEXT_PROVIDER_ERROR =
28
- "Remote Context management requires a configured Codex-compatible backend";
29
-
30
- export function isNativeCodexModel(
31
- model: ExtensionContext["model"] | undefined,
32
- ): boolean {
33
- return model?.provider === CODEX_PROVIDER && model.api === CODEX_API;
34
- }
35
-
36
- export function isCodexGatewayModel(
37
- model: ExtensionContext["model"] | undefined,
38
- gatewayModels: readonly string[] = [],
39
- ): boolean {
40
- // Gateway coverage is an operator allowlist of exact "provider/model" keys
41
- // (compaction.gatewayContextModels). The backend relay must actually pass
42
- // hosted window markers through; models outside the list stay on remote
43
- // compaction v2.
44
- if (!model || model.api !== GATEWAY_API) return false;
45
- return isExactModelAllowed(model, gatewayModels);
46
- }
47
-
48
- export function normalizeCodexBackendBaseUrl(baseUrl: string | undefined | null): string | undefined {
49
- const normalized = normalizeBaseUrl(baseUrl);
50
- if (!normalized) return undefined;
51
- try {
52
- const url = new URL(normalized);
53
- const pathname = url.pathname.replace(/\/+$/, "");
54
- if (pathname.endsWith("/codex/responses")) {
55
- url.pathname = pathname.slice(0, -"/responses".length);
56
- return url.toString().replace(/\/$/, "");
57
- }
58
- if (pathname.endsWith("/codex")) return normalized;
59
- if (pathname.endsWith("/backend-api") || pathname.endsWith("/api")) {
60
- return `${normalized}/codex`;
61
- }
62
- if (pathname === "") return `${normalized}/api/codex`;
63
- if (pathname === "/") return `${normalized}/api/codex`;
64
- return undefined;
65
- } catch {
66
- return undefined;
67
- }
68
- }
69
-
70
- function headerValue(headers: ProviderHeaders | undefined, name: string): string | undefined {
71
- const wanted = name.toLowerCase();
72
- for (const [key, value] of Object.entries(headers ?? {})) {
73
- if (key.toLowerCase() === wanted && typeof value === "string" && value.trim()) {
74
- return value.trim();
75
- }
76
- }
77
- return undefined;
78
- }
79
-
80
- function bearerToken(value: string | undefined): string | undefined {
81
- if (!value) return undefined;
82
- const match = value.trim().match(/^Bearer\s+(.+)$/i);
83
- return (match?.[1] ?? value).trim() || undefined;
84
- }
85
-
86
- function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
87
- const parts = token.split(".");
88
- if (parts.length !== 3) return undefined;
89
- try {
90
- const value: unknown = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8"));
91
- return value && typeof value === "object" && !Array.isArray(value)
92
- ? value as Record<string, unknown>
93
- : undefined;
94
- } catch {
95
- return undefined;
96
- }
97
- }
98
-
99
- function accountIdFromToken(token: string): string | undefined {
100
- const payload = decodeJwtPayload(token);
101
- const direct = payload?.chatgpt_account_id;
102
- if (isNonEmptyString(direct)) return direct.trim();
103
- const auth = payload?.["https://api.openai.com/auth"];
104
- if (!auth || typeof auth !== "object" || Array.isArray(auth)) return undefined;
105
- const nested = (auth as Record<string, unknown>).chatgpt_account_id;
106
- return isNonEmptyString(nested) ? nested.trim() : undefined;
107
- }
108
-
109
- function userAgent(version: string): string {
110
- return buildCodexCliUserAgent(version);
111
- }
112
-
113
- function clampSessionId(value: string | undefined): string | undefined {
114
- if (!value) return undefined;
115
- const chars = Array.from(value.trim());
116
- if (chars.length === 0) return undefined;
117
- return chars.length <= CODEX_SESSION_ID_MAX_LENGTH
118
- ? chars.join("")
119
- : chars.slice(0, CODEX_SESSION_ID_MAX_LENGTH).join("");
120
- }
121
-
122
- function resolveContextSessionId(ctx: ExtensionContext): string | undefined {
123
- try {
124
- return clampSessionId(ctx.sessionManager.getSessionId());
125
- } catch {
126
- return undefined;
127
- }
128
- }
129
-
130
- export type CodexContextProviderHeaderOptions = {
131
- sessionId?: string;
132
- clientRequestId?: string;
133
- };
134
-
135
- export function codexContextProviderHeaders(
136
- provider: CodexContextProvider,
137
- options: CodexContextProviderHeaderOptions = {},
138
- ): Headers {
139
- const headers = new Headers();
140
- for (const [name, value] of Object.entries(provider.headers)) {
141
- if (typeof value === "string") headers.set(name, value);
142
- }
143
-
144
- if (provider.kind === "native-codex") {
145
- headers.set("Authorization", `Bearer ${provider.token}`);
146
- headers.set("ChatGPT-Account-ID", provider.accountId);
147
- headers.set("originator", CODEX_TOOL_ORIGINATOR);
148
- headers.set("User-Agent", userAgent(CODEX_TOOL_VERSION));
149
- } else {
150
- headers.set("Authorization", `Bearer ${provider.apiKey}`);
151
- headers.delete("ChatGPT-Account-ID");
152
- headers.delete("Cookie");
153
- headers.set("originator", CODEX_GATEWAY_ORIGINATOR);
154
- headers.set("User-Agent", userAgent(CODEX_CLIENT_VERSION));
155
- // Let the gateway own its version policy; preserve an explicitly configured
156
- // version already copied from provider.headers above.
157
- headers.set("X-Codex-Affinity-Scope", CODEX_AFFINITY_SCOPE);
158
- headers.set("X-Codex-Model", provider.model);
159
- }
160
-
161
- const sessionId = clampSessionId(options.sessionId);
162
- if (sessionId) {
163
- headers.set("Session-Id", sessionId);
164
- headers.set("X-Client-Request-Id", clampSessionId(options.clientRequestId) ?? sessionId);
165
- }
166
- headers.set("content-type", "application/json");
167
- return headers;
168
- }
169
-
170
- export async function resolveCodexContextProvider(
171
- ctx: ExtensionContext,
172
- modelOverride: ExtensionContext["model"] = ctx.model,
173
- gatewayModels: readonly string[] = [],
174
- ): Promise<CodexContextProviderResolution> {
175
- const selectedModel = modelOverride;
176
- const selectedDescriptor = {
177
- provider: selectedModel?.provider,
178
- api: selectedModel?.api,
179
- model: selectedModel?.id,
180
- baseUrl: normalizeBaseUrl(selectedModel?.baseUrl),
181
- };
182
- const effective = await resolveEffectiveModel(ctx, selectedModel);
183
- if (!effective.ok) return { ok: false, reason: "unsupported-model", ...selectedDescriptor };
184
- const model = effective.model;
185
- const descriptor = {
186
- provider: model.provider,
187
- api: model.api,
188
- model: model.id,
189
- baseUrl: normalizeBaseUrl(model.baseUrl),
190
- };
191
-
192
- const isNative = isNativeCodexModel(model);
193
- const isLogicalVirtualGateway = selectedModel?.api === VIRTUAL_MODEL_API && isExactModelAllowed(selectedModel, gatewayModels);
194
- const isGateway = isCodexGatewayModel(model, gatewayModels) ||
195
- (isLogicalVirtualGateway && model.api === GATEWAY_API);
196
- if (!isNative && !isGateway) {
197
- return { ok: false, reason: "unsupported-model", ...descriptor };
198
- }
199
- if (!isNative && model.api !== GATEWAY_API) {
200
- return { ok: false, reason: "unsupported-api", ...descriptor };
201
- }
202
- if (isNative && model.api !== CODEX_API) {
203
- return { ok: false, reason: "unsupported-api", ...descriptor };
204
- }
205
-
206
- let auth: Awaited<ReturnType<typeof ctx.modelRegistry.getApiKeyAndHeaders>>;
207
- try {
208
- auth = await ctx.modelRegistry.getApiKeyAndHeaders(model as Model<Api>);
209
- } catch {
210
- return { ok: false, reason: "auth-resolution-failed", ...descriptor };
211
- }
212
- if (!auth.ok) return { ok: false, reason: "auth-resolution-failed", ...descriptor };
213
-
214
- const rawBaseUrl = normalizeBaseUrl(auth.baseUrl) ?? normalizeBaseUrl(model.baseUrl);
215
- if (!rawBaseUrl) return { ok: false, reason: "missing-base-url", ...descriptor };
216
-
217
- if (isGateway) {
218
- if (!resolveContextSessionId(ctx)) {
219
- return { ok: false, reason: "missing-session-id", ...descriptor, baseUrl: rawBaseUrl };
220
- }
221
- const apiKey = bearerToken(auth.apiKey) ?? bearerToken(headerValue(auth.headers, "authorization"));
222
- if (!apiKey) return { ok: false, reason: "missing-api-key", ...descriptor, baseUrl: rawBaseUrl };
223
- return {
224
- ok: true,
225
- provider: {
226
- kind: "codex-gateway",
227
- route: "codex-gateway",
228
- provider: model.provider,
229
- api: GATEWAY_API,
230
- model: model.id,
231
- baseUrl: rawBaseUrl,
232
- apiKey,
233
- headers: filterCodexGatewayHeaders(mergeProviderHeaders(model.headers, auth.headers)),
234
- },
235
- };
236
- }
237
-
238
- const token = bearerToken(auth.apiKey) ?? bearerToken(headerValue(auth.headers, "authorization"));
239
- if (!token) return { ok: false, reason: "missing-token", ...descriptor };
240
- const accountId = headerValue(auth.headers, "chatgpt-account-id") ?? accountIdFromToken(token);
241
- if (!accountId) return { ok: false, reason: "missing-account-id", ...descriptor };
242
- const baseUrl = normalizeCodexBackendBaseUrl(rawBaseUrl);
243
- if (!baseUrl) return { ok: false, reason: "unsupported-backend", ...descriptor };
244
-
245
- const headers = mergeProviderHeaders(model.headers, auth.headers);
246
- return {
247
- ok: true,
248
- provider: {
249
- kind: "native-codex",
250
- route: "openai-codex",
251
- provider: CODEX_PROVIDER,
252
- api: CODEX_API,
253
- model: model.id,
254
- baseUrl,
255
- token,
256
- accountId,
257
- headers,
258
- },
259
- };
260
- }
261
-
262
- export const _codexProviderTest = {
263
- accountIdFromToken,
264
- bearerToken,
265
- clampSessionId,
266
- decodeJwtPayload,
267
- headerValue,
268
- isCodexGatewayModel,
269
- userAgent,
270
- };
@@ -1,357 +0,0 @@
1
- import type { AgentToolResult, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import {
3
- codexContextProviderHeaders,
4
- resolveCodexContextProvider,
5
- } from "./codex-provider";
6
- import {
7
- isRecord,
8
- type HistoryAction,
9
- type HistoryNotesResponse,
10
- type HistoryNotesResult,
11
- type NotesAction,
12
- } from "./types";
13
-
14
- export const BACKEND_TIMEOUT_MS = 35_000;
15
- export const THREAD_HINT_MAX_BYTES = 4_000;
16
- export const TOOL_OUTPUT_TOKEN_LIMIT = 10_000;
17
- export const CODEX_ENCRYPTED_OUTPUT_CONTEXT_MARKER = "\u0000pi-openai-toolkit:encrypted-output:";
18
-
19
- export function encodeEncryptedOutputForContext(value: string): string {
20
- return `${CODEX_ENCRYPTED_OUTPUT_CONTEXT_MARKER}${JSON.stringify(value)}`;
21
- }
22
-
23
- export const HISTORY_ENDPOINTS = {
24
- list_windows: "alpha/history/v2/list_windows",
25
- list_items: "alpha/history/v2/list_items",
26
- read_item: "alpha/history/v2/read_item",
27
- search_contents: "alpha/history/v2/search_contents",
28
- } as const satisfies Record<HistoryAction, string>;
29
-
30
- export const THREAD_HINT_ENDPOINT = "alpha/notes/v2/thread_hint";
31
-
32
- export const NOTES_ENDPOINTS = {
33
- list_files_by_prefix: "alpha/notes/v2/list_files_by_prefix",
34
- read_file: "alpha/notes/v2/read_file",
35
- search_contents: "alpha/notes/v2/search_contents",
36
- append_to_file: "alpha/notes/v2/append_to_file",
37
- write_file: "alpha/notes/v2/write_file",
38
- } as const satisfies Record<NotesAction, string>;
39
-
40
- const NOTES_WRITE_ENDPOINTS = new Set<string>([
41
- NOTES_ENDPOINTS.append_to_file,
42
- NOTES_ENDPOINTS.write_file,
43
- ]);
44
-
45
- export const ENCRYPTED_ARGUMENT_ENDPOINTS = new Set<string>([
46
- HISTORY_ENDPOINTS.search_contents,
47
- NOTES_ENDPOINTS.search_contents,
48
- NOTES_ENDPOINTS.append_to_file,
49
- NOTES_ENDPOINTS.write_file,
50
- ]);
51
-
52
- export const HISTORY_ACTION_FIELDS: Record<HistoryAction, readonly string[]> = {
53
- list_windows: ["agent_name", "limit", "recent_first"],
54
- list_items: [
55
- "agent_name", "limit", "max_chars_per_item", "recent_first", "role",
56
- "tool_name", "tool_namespace", "window_id",
57
- ],
58
- read_item: ["agent_name", "item_id", "limit_chars", "offset_chars", "window_id"],
59
- search_contents: [
60
- "agent_name", "limit", "query", "recent_first", "role", "tool_name",
61
- "tool_namespace", "window_id",
62
- ],
63
- };
64
-
65
- export const NOTES_ACTION_FIELDS: Record<NotesAction, readonly string[]> = {
66
- list_files_by_prefix: ["file_order", "file_order_by", "max_results", "prefix"],
67
- read_file: ["path", "start_line", "stop_line"],
68
- search_contents: ["max_files", "max_matches_per_file", "path_prefix", "query", "recent_file_first"],
69
- append_to_file: ["path", "text"],
70
- write_file: ["path", "text"],
71
- };
72
-
73
- export interface CodexHistoryNotesDetails {
74
- codexHistoryNotes: Record<string, unknown>;
75
- }
76
-
77
- /**
78
- * Optional backend status flags are accepted only when explicitly affirmative;
79
- * an omitted flag is compatible with older response shapes.
80
- */
81
- export function isSuccessfulHistoryNotesResponse(value: unknown): value is HistoryNotesResponse {
82
- return isRecord(value) && isAffirmativeOptionalBoolean(value.ok) && isAffirmativeOptionalBoolean(value.success);
83
- }
84
-
85
- /** Validate persisted tool-result details before using them as a checkpoint. */
86
- export function isSuccessfulHistoryNotesToolResult(details: unknown): details is CodexHistoryNotesDetails {
87
- if (!isRecord(details) || !isRecord(details.codexHistoryNotes)) return false;
88
- return isSuccessfulHistoryNotesResponse(details.codexHistoryNotes);
89
- }
90
-
91
- function isAffirmativeOptionalBoolean(value: unknown): boolean {
92
- return value === undefined || value === true;
93
- }
94
-
95
- function isRejectedNotesWriteResponse(value: HistoryNotesResponse): boolean {
96
- return value.ok === false || value.success === false;
97
- }
98
-
99
- export async function callHistoryNotesBackend(
100
- endpoint: string,
101
- arguments_: Record<string, unknown>,
102
- ctx: ExtensionContext,
103
- signal?: AbortSignal,
104
- truncationPolicy: { mode: "bytes" | "tokens"; limit: number } = {
105
- mode: "tokens",
106
- limit: TOOL_OUTPUT_TOKEN_LIMIT,
107
- },
108
- gatewayModels: readonly string[] = [],
109
- ): Promise<HistoryNotesResult> {
110
- const timeoutSignal = AbortSignal.timeout(BACKEND_TIMEOUT_MS);
111
- if (signal?.aborted) return { ok: false, reason: "aborted" };
112
- const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
113
- const providerResult = await resolveProviderWithDeadline(ctx, requestSignal, timeoutSignal, gatewayModels);
114
- if (providerResult === "aborted") return { ok: false, reason: "aborted" };
115
- if (providerResult === "timeout") return { ok: false, reason: "backend-timeout" };
116
- const provider = providerResult;
117
- if (!provider.ok) {
118
- const reason = provider.reason === "unsupported-model" || provider.reason === "unsupported-api" || provider.reason === "context-management-inactive"
119
- ? "unsupported-backend"
120
- : provider.reason;
121
- return { ok: false, reason };
122
- }
123
- const sessionId = ctx.sessionManager.getSessionId();
124
- const headers = codexContextProviderHeaders(provider.provider, {
125
- sessionId,
126
- clientRequestId: sessionId,
127
- });
128
- headers.set("x-openai-tool-output-truncation-policy", JSON.stringify(truncationPolicy));
129
- if (ENCRYPTED_ARGUMENT_ENDPOINTS.has(endpoint)) {
130
- headers.set("x-openai-encrypted-tool-arguments", "true");
131
- }
132
- try {
133
- const response = await fetch(`${provider.provider.baseUrl}/${endpoint}`, {
134
- method: "POST",
135
- headers,
136
- signal: requestSignal,
137
- body: JSON.stringify({
138
- ...arguments_,
139
- context: {
140
- session_id: ctx.sessionManager.getSessionId(),
141
- current_agent_name: "/root",
142
- },
143
- }),
144
- });
145
- if (!response.ok) return { ok: false, reason: "http-error", status: response.status };
146
- let parsed: unknown;
147
- try {
148
- parsed = JSON.parse(await response.text()) as unknown;
149
- } catch {
150
- return { ok: false, reason: "invalid-json", status: response.status };
151
- }
152
- if (!isHistoryNotesResponse(parsed, endpoint)) return { ok: false, reason: "invalid-response", status: response.status };
153
- return { ok: true, value: parsed };
154
- } catch {
155
- if (signal?.aborted) return { ok: false, reason: "aborted" };
156
- if (timeoutSignal.aborted) return { ok: false, reason: "backend-timeout" };
157
- return { ok: false, reason: "protocol-error" };
158
- }
159
- }
160
-
161
- async function resolveProviderWithDeadline(
162
- ctx: ExtensionContext,
163
- requestSignal: AbortSignal,
164
- timeoutSignal: AbortSignal,
165
- gatewayModels: readonly string[] = [],
166
- ): Promise<Awaited<ReturnType<typeof resolveCodexContextProvider>> | "aborted" | "timeout"> {
167
- if (requestSignal.aborted) return timeoutSignal.aborted ? "timeout" : "aborted";
168
- return new Promise((resolve) => {
169
- let settled = false;
170
- const finish = (value: Awaited<ReturnType<typeof resolveCodexContextProvider>> | "aborted" | "timeout") => {
171
- if (settled) return;
172
- settled = true;
173
- requestSignal.removeEventListener("abort", onAbort);
174
- resolve(value);
175
- };
176
- const onAbort = () => finish(timeoutSignal.aborted ? "timeout" : "aborted");
177
- requestSignal.addEventListener("abort", onAbort, { once: true });
178
- resolveCodexContextProvider(ctx, ctx.model, gatewayModels).then(
179
- (value) => finish(value),
180
- () => finish({ ok: false, reason: "auth-resolution-failed" }),
181
- );
182
- });
183
- }
184
-
185
- export async function loadHistoryNotesThreadHint(
186
- ctx: ExtensionContext,
187
- signal?: AbortSignal,
188
- gatewayModels: readonly string[] = [],
189
- ): Promise<string | undefined> {
190
- const result = await callHistoryNotesBackend(
191
- THREAD_HINT_ENDPOINT,
192
- {},
193
- ctx,
194
- signal,
195
- { mode: "bytes", limit: THREAD_HINT_MAX_BYTES },
196
- gatewayModels,
197
- );
198
- if (!result.ok) {
199
- if (result.reason === "aborted") throw new Error("Remote context hint request was aborted");
200
- return undefined;
201
- }
202
- if (!isSuccessfulHistoryNotesResponse(result.value)) return undefined;
203
- const text = result.value.text;
204
- return typeof text === "string" && Buffer.byteLength(text, "utf8") <= THREAD_HINT_MAX_BYTES ? text : undefined;
205
- }
206
-
207
- export async function executeHistoryNotesTool(
208
- namespace: "history" | "notes",
209
- action: HistoryAction | NotesAction,
210
- params: Record<string, unknown>,
211
- ctx: ExtensionContext,
212
- signal?: AbortSignal,
213
- gatewayModels: readonly string[] = [],
214
- ): Promise<AgentToolResult<CodexHistoryNotesDetails>> {
215
- const endpoint = namespace === "history"
216
- ? HISTORY_ENDPOINTS[action as HistoryAction]
217
- : NOTES_ENDPOINTS[action as NotesAction];
218
- if (!endpoint) throw new Error(`Unsupported ${namespace} action`);
219
- validateAction(namespace, action, params);
220
- const result = await callHistoryNotesBackend(
221
- endpoint,
222
- stripAction(params),
223
- ctx,
224
- signal,
225
- undefined,
226
- gatewayModels,
227
- );
228
- if (!result.ok) throw new Error(formatHistoryNotesFailure(result.reason, result.status));
229
- if (NOTES_WRITE_ENDPOINTS.has(endpoint) && isRejectedNotesWriteResponse(result.value)) {
230
- throw new Error("Remote notes write was rejected");
231
- }
232
- const modelResult = { ...result.value };
233
- delete modelResult.images;
234
- const content: AgentToolResult<CodexHistoryNotesDetails>["content"] = [
235
- {
236
- type: "text",
237
- text: typeof modelResult.encrypted_output === "string"
238
- ? `${namespace} operation completed`
239
- : JSON.stringify(modelResult),
240
- },
241
- ];
242
- for (const image of parseBackendImages(result.value.images)) content.push(image);
243
- return { content, details: { codexHistoryNotes: modelResult } };
244
- }
245
-
246
- export function formatHistoryNotesFailure(
247
- reason: Exclude<HistoryNotesResult, { ok: true }>["reason"],
248
- status?: number,
249
- ): string {
250
- switch (reason) {
251
- case "aborted": return "Remote history/notes request was aborted";
252
- case "backend-timeout": return "Remote history/notes backend timed out";
253
- case "http-error": return `Remote history/notes backend returned HTTP ${status ?? "error"}`;
254
- case "invalid-json": return "Remote history/notes backend returned invalid JSON";
255
- case "invalid-response": return "Remote history/notes backend returned an invalid response";
256
- case "auth-resolution-failed": return "Remote history/notes authentication could not be resolved";
257
- case "missing-token": return "Remote history/notes authentication is missing a token";
258
- case "missing-api-key": return "Remote history/notes gateway authentication is missing an API key";
259
- case "missing-account-id": return "Remote history/notes authentication is missing an account id";
260
- case "missing-base-url": return "Remote history/notes backend is missing a base URL";
261
- case "missing-session-id": return "Remote history/notes request is missing a session id";
262
- case "unsupported-backend": return "Remote history/notes require the native Codex backend";
263
- case "protocol-error": return "Remote history/notes request failed";
264
- case "invalid-account-token": return "Remote history/notes authentication token is invalid";
265
- default: return "Remote history/notes request failed";
266
- }
267
- }
268
-
269
- const RESPONSE_KEYS_BY_ENDPOINT: Record<string, readonly string[]> = {
270
- [HISTORY_ENDPOINTS.list_windows]: ["windows", "items", "results", "data", "output", "encrypted_output"],
271
- [HISTORY_ENDPOINTS.list_items]: ["items", "results", "data", "output", "encrypted_output"],
272
- [HISTORY_ENDPOINTS.read_item]: ["item", "content", "text", "data", "output", "encrypted_output"],
273
- [HISTORY_ENDPOINTS.search_contents]: ["matches", "items", "results", "data", "output", "encrypted_output"],
274
- [NOTES_ENDPOINTS.list_files_by_prefix]: ["files", "items", "results", "data", "output", "encrypted_output"],
275
- [NOTES_ENDPOINTS.read_file]: ["file", "content", "text", "data", "output", "encrypted_output"],
276
- [NOTES_ENDPOINTS.search_contents]: ["matches", "items", "results", "data", "output", "encrypted_output"],
277
- [NOTES_ENDPOINTS.append_to_file]: ["ok", "success", "file", "content", "data", "output", "encrypted_output"],
278
- [NOTES_ENDPOINTS.write_file]: ["ok", "success", "file", "content", "data", "output", "encrypted_output"],
279
- [THREAD_HINT_ENDPOINT]: ["text", "data", "output"],
280
- };
281
-
282
- const ARRAY_RESPONSE_KEYS = new Set(["windows", "items", "results", "matches", "files"]);
283
-
284
- function isHistoryNotesResponse(value: unknown, endpoint: string): value is HistoryNotesResponse {
285
- if (!isRecord(value) || Object.keys(value).length === 0) return false;
286
- if (value.encrypted_output !== undefined &&
287
- (typeof value.encrypted_output !== "string" || value.encrypted_output.length === 0)) return false;
288
- if (value.images !== undefined && !isValidImages(value.images)) return false;
289
- if (value.ok !== undefined && typeof value.ok !== "boolean") return false;
290
- if (value.success !== undefined && typeof value.success !== "boolean") return false;
291
- if (value.text !== undefined && typeof value.text !== "string") return false;
292
- for (const key of ARRAY_RESPONSE_KEYS) {
293
- if (value[key] !== undefined && !Array.isArray(value[key])) return false;
294
- }
295
- const expectedKeys = RESPONSE_KEYS_BY_ENDPOINT[endpoint];
296
- return expectedKeys ? expectedKeys.some((key) => key in value) : false;
297
- }
298
-
299
- type BackendImage = {
300
- data: string;
301
- mime_type: string;
302
- detail?: "auto" | "high" | "original" | null;
303
- };
304
-
305
- function isValidImages(value: unknown): value is BackendImage[] {
306
- return Array.isArray(value) && value.every((item): item is BackendImage => {
307
- if (!isRecord(item) || typeof item.data !== "string" || typeof item.mime_type !== "string") return false;
308
- return item.detail === undefined || item.detail === null || item.detail === "auto" || item.detail === "high" || item.detail === "original";
309
- });
310
- }
311
-
312
- function parseBackendImages(value: unknown): Array<{
313
- type: "image";
314
- data: string;
315
- mimeType: string;
316
- detail?: "auto" | "high" | "original";
317
- }> {
318
- if (value === undefined) return [];
319
- if (!isValidImages(value)) throw new Error("Remote history backend returned invalid image content");
320
- return value.map((item) => ({
321
- type: "image" as const,
322
- data: item.data,
323
- mimeType: item.mime_type,
324
- ...(item.detail ? { detail: item.detail } : {}),
325
- }));
326
- }
327
-
328
- function stripAction(params: Record<string, unknown>): Record<string, unknown> {
329
- const result = { ...params };
330
- delete result.action;
331
- return result;
332
- }
333
-
334
- function validateAction(
335
- namespace: "history" | "notes",
336
- action: HistoryAction | NotesAction,
337
- params: Record<string, unknown>,
338
- ): void {
339
- const fields = namespace === "history"
340
- ? HISTORY_ACTION_FIELDS[action as HistoryAction]
341
- : NOTES_ACTION_FIELDS[action as NotesAction];
342
- if (!fields) throw new Error(`Unsupported ${namespace} action`);
343
- const unexpected = Object.keys(params).find((field) => field !== "action" && !fields.includes(field));
344
- if (unexpected) throw new Error(`${namespace} ${action} does not accept ${unexpected}`);
345
- if (action === "read_item" && (typeof params.item_id !== "string" || !params.item_id || typeof params.window_id !== "string" || !params.window_id)) {
346
- throw new Error("history read_item requires item_id and window_id");
347
- }
348
- if (action === "search_contents" && (typeof params.query !== "string" || !params.query)) {
349
- throw new Error(`${namespace} search_contents requires query`);
350
- }
351
- if ((action === "read_file" || action === "append_to_file" || action === "write_file") && (typeof params.path !== "string" || !params.path)) {
352
- throw new Error(`notes ${action} requires path`);
353
- }
354
- if ((action === "append_to_file" || action === "write_file") && typeof params.text !== "string") {
355
- throw new Error(`notes ${action} requires text`);
356
- }
357
- }