pi-openai-toolkit 0.20.7 → 0.21.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 (55) hide show
  1. package/LICENSE +21 -21
  2. package/NOTICE +83 -83
  3. package/README.md +8 -26
  4. package/README.zh.md +8 -26
  5. package/config.schema.json +278 -366
  6. package/docs/configuration.md +294 -310
  7. package/docs/internals.md +166 -175
  8. package/package.json +3 -13
  9. package/src/compact-client.ts +412 -389
  10. package/src/compaction-errors.ts +96 -0
  11. package/src/compaction-projection.ts +35 -35
  12. package/src/config/legacy.ts +519 -717
  13. package/src/config/migration.ts +103 -100
  14. package/src/config/notifications.ts +36 -28
  15. package/src/config/policy.ts +10 -18
  16. package/src/config/v2.ts +1167 -1380
  17. package/src/config.ts +310 -319
  18. package/src/context-management/codex-provider.ts +270 -270
  19. package/src/context-management/history-notes.ts +357 -357
  20. package/src/context-management/manual-compact.ts +0 -11
  21. package/src/context-management/tools.ts +371 -371
  22. package/src/context-management/types.ts +168 -168
  23. package/src/context-management/window-manager.ts +801 -801
  24. package/src/debug.ts +238 -237
  25. package/src/deferred-tool-carryover.ts +282 -282
  26. package/src/details-store.ts +151 -151
  27. package/src/extension-runtime.ts +1662 -1623
  28. package/src/image-generation/extension.ts +189 -189
  29. package/src/image-generation/protocol.ts +579 -579
  30. package/src/image-generation/types.ts +300 -300
  31. package/src/native-fallback.ts +4 -132
  32. package/src/payload-rewrite.ts +515 -518
  33. package/src/pi-context-hook.ts +117 -117
  34. package/src/remote-v2-client.ts +811 -814
  35. package/src/request-context-cache.ts +155 -155
  36. package/src/responses-headers.ts +180 -180
  37. package/src/runtime.ts +865 -832
  38. package/src/serializer.ts +882 -882
  39. package/src/supported-environment.ts +27 -27
  40. package/src/types.ts +565 -648
  41. package/src/web-search/alpha-client.ts +876 -876
  42. package/src/web-search/extension.ts +530 -530
  43. package/src/web-search/payload.ts +299 -299
  44. package/src/web-search/prompt.ts +60 -60
  45. package/src/web-search/types.ts +166 -166
  46. package/tsconfig.check.json +50 -54
  47. package/src/auto-mode/classifier.ts +0 -161
  48. package/src/auto-mode/extension.ts +0 -691
  49. package/src/auto-mode/model-selection-guard.ts +0 -52
  50. package/src/auto-mode/policy.ts +0 -146
  51. package/src/auto-mode/prompt.ts +0 -279
  52. package/src/auto-mode/reviewer.ts +0 -325
  53. package/src/auto-mode/tool-review-tui.ts +0 -211
  54. package/src/auto-mode/transcript.ts +0 -135
  55. package/src/auto-mode/types.ts +0 -137
@@ -1,357 +1,357 @@
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
- }
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
+ }