pi-openai-toolkit 0.11.1 → 0.13.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.
- package/NOTICE +28 -0
- package/README.md +57 -23
- package/README_zh.md +57 -23
- package/package.json +126 -114
- package/src/config.ts +39 -69
- package/src/context-management/codex-provider.ts +261 -0
- package/src/context-management/history-notes.ts +325 -0
- package/src/context-management/messages.ts +106 -0
- package/src/context-management/namespace-tools.ts +426 -0
- package/src/context-management/tool-contract.ts +23 -0
- package/src/context-management/tools.ts +313 -0
- package/src/context-management/types.ts +160 -0
- package/src/context-management/window-budget.ts +83 -0
- package/src/context-management/window-manager.ts +427 -0
- package/src/context-management/window-request.ts +125 -0
- package/src/debug.ts +3 -3
- package/src/extension-runtime.ts +305 -18
- package/src/payload-rewrite.ts +40 -10
- package/src/remote-v2-client.ts +340 -99
- package/src/request-context-cache.ts +36 -3
- package/src/serializer.ts +4 -12
- package/src/types.ts +16 -19
- package/tsconfig.check.json +0 -1
- package/src/inline-compaction.ts +0 -1264
|
@@ -0,0 +1,261 @@
|
|
|
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 { CODEX_CLIENT_VERSION } from "../responses-headers";
|
|
5
|
+
import { normalizeBaseUrl } from "../runtime";
|
|
6
|
+
import {
|
|
7
|
+
isNonEmptyString,
|
|
8
|
+
type CodexContextProvider,
|
|
9
|
+
type CodexContextProviderResolution,
|
|
10
|
+
} from "./types";
|
|
11
|
+
|
|
12
|
+
const CODEX_PROVIDER = "openai-codex";
|
|
13
|
+
const CODEX_API = "openai-codex-responses";
|
|
14
|
+
const GATEWAY_PROVIDER = "uwoacrimson";
|
|
15
|
+
const GATEWAY_API = "openai-responses";
|
|
16
|
+
const CODEX_TOOL_ORIGINATOR = "codex_cli_rs";
|
|
17
|
+
const CODEX_GATEWAY_ORIGINATOR = "codex_cli_rs";
|
|
18
|
+
const CODEX_TOOL_VERSION = "0.0.0";
|
|
19
|
+
const CODEX_SESSION_ID_MAX_LENGTH = 64;
|
|
20
|
+
|
|
21
|
+
export const CODEX_AFFINITY_SCOPE = "codex-session-v1";
|
|
22
|
+
|
|
23
|
+
export const CODEX_CONTEXT_PROVIDER_ERROR =
|
|
24
|
+
"Remote Context management requires a configured Codex-compatible backend";
|
|
25
|
+
|
|
26
|
+
export function isNativeCodexModel(
|
|
27
|
+
model: ExtensionContext["model"] | undefined,
|
|
28
|
+
): boolean {
|
|
29
|
+
return model?.provider === CODEX_PROVIDER && model.api === CODEX_API;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isCodexGatewayModel(
|
|
33
|
+
model: ExtensionContext["model"] | undefined,
|
|
34
|
+
allowlist: readonly string[] = [],
|
|
35
|
+
): boolean {
|
|
36
|
+
if (!model || model.provider !== GATEWAY_PROVIDER || model.api !== GATEWAY_API) return false;
|
|
37
|
+
const spec = `${model.provider}/${model.id}`;
|
|
38
|
+
return allowlist.some((entry) => entry.trim() === spec);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function normalizeCodexBackendBaseUrl(baseUrl: string | undefined | null): string | undefined {
|
|
42
|
+
const normalized = normalizeBaseUrl(baseUrl);
|
|
43
|
+
if (!normalized) return undefined;
|
|
44
|
+
try {
|
|
45
|
+
const url = new URL(normalized);
|
|
46
|
+
const pathname = url.pathname.replace(/\/+$/, "");
|
|
47
|
+
if (pathname.endsWith("/codex/responses")) {
|
|
48
|
+
url.pathname = pathname.slice(0, -"/responses".length);
|
|
49
|
+
return url.toString().replace(/\/$/, "");
|
|
50
|
+
}
|
|
51
|
+
if (pathname.endsWith("/codex")) return normalized;
|
|
52
|
+
if (pathname.endsWith("/backend-api") || pathname.endsWith("/api")) {
|
|
53
|
+
return `${normalized}/codex`;
|
|
54
|
+
}
|
|
55
|
+
if (pathname === "") return `${normalized}/api/codex`;
|
|
56
|
+
if (pathname === "/") return `${normalized}/api/codex`;
|
|
57
|
+
return undefined;
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function headerValue(headers: ProviderHeaders | undefined, name: string): string | undefined {
|
|
64
|
+
const wanted = name.toLowerCase();
|
|
65
|
+
for (const [key, value] of Object.entries(headers ?? {})) {
|
|
66
|
+
if (key.toLowerCase() === wanted && typeof value === "string" && value.trim()) {
|
|
67
|
+
return value.trim();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function bearerToken(value: string | undefined): string | undefined {
|
|
74
|
+
if (!value) return undefined;
|
|
75
|
+
const match = value.trim().match(/^Bearer\s+(.+)$/i);
|
|
76
|
+
return (match?.[1] ?? value).trim() || undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
|
|
80
|
+
const parts = token.split(".");
|
|
81
|
+
if (parts.length !== 3) return undefined;
|
|
82
|
+
try {
|
|
83
|
+
const value: unknown = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8"));
|
|
84
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
85
|
+
? value as Record<string, unknown>
|
|
86
|
+
: undefined;
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function accountIdFromToken(token: string): string | undefined {
|
|
93
|
+
const payload = decodeJwtPayload(token);
|
|
94
|
+
const direct = payload?.chatgpt_account_id;
|
|
95
|
+
if (isNonEmptyString(direct)) return direct.trim();
|
|
96
|
+
const auth = payload?.["https://api.openai.com/auth"];
|
|
97
|
+
if (!auth || typeof auth !== "object" || Array.isArray(auth)) return undefined;
|
|
98
|
+
const nested = (auth as Record<string, unknown>).chatgpt_account_id;
|
|
99
|
+
return isNonEmptyString(nested) ? nested.trim() : undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function userAgent(version: string): string {
|
|
103
|
+
const platform = typeof process !== "undefined" ? process.platform : "unknown";
|
|
104
|
+
const arch = typeof process !== "undefined" ? process.arch : "unknown";
|
|
105
|
+
return `codex_cli_rs/${version} (${platform}; ${arch})`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function clampSessionId(value: string | undefined): string | undefined {
|
|
109
|
+
if (!value) return undefined;
|
|
110
|
+
const chars = Array.from(value.trim());
|
|
111
|
+
if (chars.length === 0) return undefined;
|
|
112
|
+
return chars.length <= CODEX_SESSION_ID_MAX_LENGTH
|
|
113
|
+
? chars.join("")
|
|
114
|
+
: chars.slice(0, CODEX_SESSION_ID_MAX_LENGTH).join("");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function removeSensitiveGatewayHeaders(headers: ProviderHeaders): Record<string, string> {
|
|
118
|
+
const result: Record<string, string> = {};
|
|
119
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
120
|
+
if (typeof value !== "string") continue;
|
|
121
|
+
const lower = name.toLowerCase();
|
|
122
|
+
if (lower === "authorization" || lower === "cookie" || lower === "chatgpt-account-id" || lower === "x-api-key") {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
result[name] = value;
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
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
|
+
headers.set("version", CODEX_TOOL_VERSION);
|
|
150
|
+
} else {
|
|
151
|
+
headers.set("Authorization", `Bearer ${provider.apiKey}`);
|
|
152
|
+
headers.delete("ChatGPT-Account-ID");
|
|
153
|
+
headers.delete("Cookie");
|
|
154
|
+
headers.set("originator", CODEX_GATEWAY_ORIGINATOR);
|
|
155
|
+
headers.set("User-Agent", userAgent(CODEX_CLIENT_VERSION));
|
|
156
|
+
headers.set("version", CODEX_CLIENT_VERSION);
|
|
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", options.clientRequestId?.trim() || 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
|
+
gatewayAllowlist: readonly string[] = [],
|
|
174
|
+
): Promise<CodexContextProviderResolution> {
|
|
175
|
+
const model = modelOverride;
|
|
176
|
+
const descriptor = {
|
|
177
|
+
provider: model?.provider,
|
|
178
|
+
api: model?.api,
|
|
179
|
+
model: model?.id,
|
|
180
|
+
baseUrl: normalizeBaseUrl(model?.baseUrl),
|
|
181
|
+
};
|
|
182
|
+
if (!model) return { ok: false, reason: "unsupported-model" };
|
|
183
|
+
|
|
184
|
+
const isNative = isNativeCodexModel(model);
|
|
185
|
+
const isGateway = isCodexGatewayModel(model, gatewayAllowlist);
|
|
186
|
+
if (!isNative && !isGateway) {
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
reason: model.provider === GATEWAY_PROVIDER ? "unsupported-model" : "unsupported-model",
|
|
190
|
+
...descriptor,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (!isNative && model.api !== GATEWAY_API) {
|
|
194
|
+
return { ok: false, reason: "unsupported-api", ...descriptor };
|
|
195
|
+
}
|
|
196
|
+
if (isNative && model.api !== CODEX_API) {
|
|
197
|
+
return { ok: false, reason: "unsupported-api", ...descriptor };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let auth: Awaited<ReturnType<typeof ctx.modelRegistry.getApiKeyAndHeaders>>;
|
|
201
|
+
try {
|
|
202
|
+
auth = await ctx.modelRegistry.getApiKeyAndHeaders(model as Model<Api>);
|
|
203
|
+
} catch {
|
|
204
|
+
return { ok: false, reason: "auth-resolution-failed", ...descriptor };
|
|
205
|
+
}
|
|
206
|
+
if (!auth.ok) return { ok: false, reason: "auth-resolution-failed", ...descriptor };
|
|
207
|
+
|
|
208
|
+
const rawBaseUrl = normalizeBaseUrl(auth.baseUrl) ?? normalizeBaseUrl(model.baseUrl);
|
|
209
|
+
if (!rawBaseUrl) return { ok: false, reason: "missing-base-url", ...descriptor };
|
|
210
|
+
|
|
211
|
+
if (isGateway) {
|
|
212
|
+
const apiKey = bearerToken(auth.apiKey) ?? bearerToken(headerValue(auth.headers, "authorization"));
|
|
213
|
+
if (!apiKey) return { ok: false, reason: "missing-api-key", ...descriptor, baseUrl: rawBaseUrl };
|
|
214
|
+
return {
|
|
215
|
+
ok: true,
|
|
216
|
+
provider: {
|
|
217
|
+
kind: "codex-gateway",
|
|
218
|
+
route: "codex-gateway",
|
|
219
|
+
provider: GATEWAY_PROVIDER,
|
|
220
|
+
api: GATEWAY_API,
|
|
221
|
+
model: model.id,
|
|
222
|
+
baseUrl: rawBaseUrl,
|
|
223
|
+
apiKey,
|
|
224
|
+
headers: removeSensitiveGatewayHeaders(mergeProviderHeaders(model.headers, auth.headers)),
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const token = bearerToken(auth.apiKey) ?? bearerToken(headerValue(auth.headers, "authorization"));
|
|
230
|
+
if (!token) return { ok: false, reason: "missing-token", ...descriptor };
|
|
231
|
+
const accountId = headerValue(auth.headers, "chatgpt-account-id") ?? accountIdFromToken(token);
|
|
232
|
+
if (!accountId) return { ok: false, reason: "missing-account-id", ...descriptor };
|
|
233
|
+
const baseUrl = normalizeCodexBackendBaseUrl(rawBaseUrl);
|
|
234
|
+
if (!baseUrl) return { ok: false, reason: "unsupported-backend", ...descriptor };
|
|
235
|
+
|
|
236
|
+
const headers = mergeProviderHeaders(model.headers, auth.headers);
|
|
237
|
+
return {
|
|
238
|
+
ok: true,
|
|
239
|
+
provider: {
|
|
240
|
+
kind: "native-codex",
|
|
241
|
+
route: "openai-codex",
|
|
242
|
+
provider: CODEX_PROVIDER,
|
|
243
|
+
api: CODEX_API,
|
|
244
|
+
model: model.id,
|
|
245
|
+
baseUrl,
|
|
246
|
+
token,
|
|
247
|
+
accountId,
|
|
248
|
+
headers,
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export const _codexProviderTest = {
|
|
254
|
+
accountIdFromToken,
|
|
255
|
+
bearerToken,
|
|
256
|
+
clampSessionId,
|
|
257
|
+
decodeJwtPayload,
|
|
258
|
+
headerValue,
|
|
259
|
+
isCodexGatewayModel,
|
|
260
|
+
userAgent,
|
|
261
|
+
};
|
|
@@ -0,0 +1,325 @@
|
|
|
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
|
+
export const ENCRYPTED_ARGUMENT_ENDPOINTS = new Set<string>([
|
|
41
|
+
HISTORY_ENDPOINTS.search_contents,
|
|
42
|
+
NOTES_ENDPOINTS.search_contents,
|
|
43
|
+
NOTES_ENDPOINTS.append_to_file,
|
|
44
|
+
NOTES_ENDPOINTS.write_file,
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
export const HISTORY_ACTION_FIELDS: Record<HistoryAction, readonly string[]> = {
|
|
48
|
+
list_windows: ["agent_name", "limit", "recent_first"],
|
|
49
|
+
list_items: [
|
|
50
|
+
"agent_name", "limit", "max_chars_per_item", "recent_first", "role",
|
|
51
|
+
"tool_name", "tool_namespace", "window_id",
|
|
52
|
+
],
|
|
53
|
+
read_item: ["agent_name", "item_id", "limit_chars", "offset_chars", "window_id"],
|
|
54
|
+
search_contents: [
|
|
55
|
+
"agent_name", "limit", "query", "recent_first", "role", "tool_name",
|
|
56
|
+
"tool_namespace", "window_id",
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const NOTES_ACTION_FIELDS: Record<NotesAction, readonly string[]> = {
|
|
61
|
+
list_files_by_prefix: ["file_order", "file_order_by", "max_results", "prefix"],
|
|
62
|
+
read_file: ["path", "start_line", "stop_line"],
|
|
63
|
+
search_contents: ["max_files", "max_matches_per_file", "path_prefix", "query", "recent_file_first"],
|
|
64
|
+
append_to_file: ["path", "text"],
|
|
65
|
+
write_file: ["path", "text"],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export interface CodexHistoryNotesDetails {
|
|
69
|
+
codexHistoryNotes: Record<string, unknown>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function callHistoryNotesBackend(
|
|
73
|
+
endpoint: string,
|
|
74
|
+
arguments_: Record<string, unknown>,
|
|
75
|
+
ctx: ExtensionContext,
|
|
76
|
+
signal?: AbortSignal,
|
|
77
|
+
truncationPolicy: { mode: "bytes" | "tokens"; limit: number } = {
|
|
78
|
+
mode: "tokens",
|
|
79
|
+
limit: TOOL_OUTPUT_TOKEN_LIMIT,
|
|
80
|
+
},
|
|
81
|
+
gatewayAllowlist: readonly string[] = [],
|
|
82
|
+
): Promise<HistoryNotesResult> {
|
|
83
|
+
const timeoutSignal = AbortSignal.timeout(BACKEND_TIMEOUT_MS);
|
|
84
|
+
if (signal?.aborted) return { ok: false, reason: "aborted" };
|
|
85
|
+
const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
86
|
+
const providerResult = await resolveProviderWithDeadline(ctx, requestSignal, timeoutSignal, gatewayAllowlist);
|
|
87
|
+
if (providerResult === "aborted") return { ok: false, reason: "aborted" };
|
|
88
|
+
if (providerResult === "timeout") return { ok: false, reason: "backend-timeout" };
|
|
89
|
+
const provider = providerResult;
|
|
90
|
+
if (!provider.ok) {
|
|
91
|
+
const reason = provider.reason === "unsupported-model" || provider.reason === "unsupported-api" || provider.reason === "context-management-inactive"
|
|
92
|
+
? "unsupported-backend"
|
|
93
|
+
: provider.reason;
|
|
94
|
+
return { ok: false, reason };
|
|
95
|
+
}
|
|
96
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
97
|
+
const headers = codexContextProviderHeaders(provider.provider, {
|
|
98
|
+
sessionId,
|
|
99
|
+
clientRequestId: sessionId,
|
|
100
|
+
});
|
|
101
|
+
headers.set("x-openai-tool-output-truncation-policy", JSON.stringify(truncationPolicy));
|
|
102
|
+
if (ENCRYPTED_ARGUMENT_ENDPOINTS.has(endpoint)) {
|
|
103
|
+
headers.set("x-openai-encrypted-tool-arguments", "true");
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const response = await fetch(`${provider.provider.baseUrl}/${endpoint}`, {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers,
|
|
109
|
+
signal: requestSignal,
|
|
110
|
+
body: JSON.stringify({
|
|
111
|
+
...arguments_,
|
|
112
|
+
context: {
|
|
113
|
+
session_id: ctx.sessionManager.getSessionId(),
|
|
114
|
+
current_agent_name: "/root",
|
|
115
|
+
},
|
|
116
|
+
}),
|
|
117
|
+
});
|
|
118
|
+
if (!response.ok) return { ok: false, reason: "http-error", status: response.status };
|
|
119
|
+
let parsed: unknown;
|
|
120
|
+
try {
|
|
121
|
+
parsed = JSON.parse(await response.text()) as unknown;
|
|
122
|
+
} catch {
|
|
123
|
+
return { ok: false, reason: "invalid-json", status: response.status };
|
|
124
|
+
}
|
|
125
|
+
if (!isHistoryNotesResponse(parsed, endpoint)) return { ok: false, reason: "invalid-response", status: response.status };
|
|
126
|
+
return { ok: true, value: parsed };
|
|
127
|
+
} catch {
|
|
128
|
+
if (signal?.aborted) return { ok: false, reason: "aborted" };
|
|
129
|
+
if (timeoutSignal.aborted) return { ok: false, reason: "backend-timeout" };
|
|
130
|
+
return { ok: false, reason: "protocol-error" };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function resolveProviderWithDeadline(
|
|
135
|
+
ctx: ExtensionContext,
|
|
136
|
+
requestSignal: AbortSignal,
|
|
137
|
+
timeoutSignal: AbortSignal,
|
|
138
|
+
gatewayAllowlist: readonly string[],
|
|
139
|
+
): Promise<Awaited<ReturnType<typeof resolveCodexContextProvider>> | "aborted" | "timeout"> {
|
|
140
|
+
if (requestSignal.aborted) return timeoutSignal.aborted ? "timeout" : "aborted";
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
let settled = false;
|
|
143
|
+
const finish = (value: Awaited<ReturnType<typeof resolveCodexContextProvider>> | "aborted" | "timeout") => {
|
|
144
|
+
if (settled) return;
|
|
145
|
+
settled = true;
|
|
146
|
+
requestSignal.removeEventListener("abort", onAbort);
|
|
147
|
+
resolve(value);
|
|
148
|
+
};
|
|
149
|
+
const onAbort = () => finish(timeoutSignal.aborted ? "timeout" : "aborted");
|
|
150
|
+
requestSignal.addEventListener("abort", onAbort, { once: true });
|
|
151
|
+
resolveCodexContextProvider(ctx, ctx.model, gatewayAllowlist).then(
|
|
152
|
+
(value) => finish(value),
|
|
153
|
+
() => finish({ ok: false, reason: "auth-resolution-failed" }),
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function loadHistoryNotesThreadHint(
|
|
159
|
+
ctx: ExtensionContext,
|
|
160
|
+
signal?: AbortSignal,
|
|
161
|
+
gatewayAllowlist: readonly string[] = [],
|
|
162
|
+
): Promise<string | undefined> {
|
|
163
|
+
const result = await callHistoryNotesBackend(
|
|
164
|
+
THREAD_HINT_ENDPOINT,
|
|
165
|
+
{},
|
|
166
|
+
ctx,
|
|
167
|
+
signal,
|
|
168
|
+
{ mode: "bytes", limit: THREAD_HINT_MAX_BYTES },
|
|
169
|
+
gatewayAllowlist,
|
|
170
|
+
);
|
|
171
|
+
if (!result.ok) {
|
|
172
|
+
if (result.reason === "aborted") throw new Error("Remote context hint request was aborted");
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
const text = result.value.text;
|
|
176
|
+
return typeof text === "string" && Buffer.byteLength(text, "utf8") <= THREAD_HINT_MAX_BYTES ? text : undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function executeHistoryNotesTool(
|
|
180
|
+
namespace: "history" | "notes",
|
|
181
|
+
action: HistoryAction | NotesAction,
|
|
182
|
+
params: Record<string, unknown>,
|
|
183
|
+
ctx: ExtensionContext,
|
|
184
|
+
signal?: AbortSignal,
|
|
185
|
+
gatewayAllowlist: readonly string[] = [],
|
|
186
|
+
): Promise<AgentToolResult<CodexHistoryNotesDetails>> {
|
|
187
|
+
const endpoint = namespace === "history"
|
|
188
|
+
? HISTORY_ENDPOINTS[action as HistoryAction]
|
|
189
|
+
: NOTES_ENDPOINTS[action as NotesAction];
|
|
190
|
+
if (!endpoint) throw new Error(`Unsupported ${namespace} action`);
|
|
191
|
+
validateAction(namespace, action, params);
|
|
192
|
+
const result = await callHistoryNotesBackend(
|
|
193
|
+
endpoint,
|
|
194
|
+
stripAction(params),
|
|
195
|
+
ctx,
|
|
196
|
+
signal,
|
|
197
|
+
undefined,
|
|
198
|
+
gatewayAllowlist,
|
|
199
|
+
);
|
|
200
|
+
if (!result.ok) throw new Error(formatHistoryNotesFailure(result.reason, result.status));
|
|
201
|
+
const modelResult = { ...result.value };
|
|
202
|
+
delete modelResult.images;
|
|
203
|
+
const content: AgentToolResult<CodexHistoryNotesDetails>["content"] = [
|
|
204
|
+
{
|
|
205
|
+
type: "text",
|
|
206
|
+
text: typeof modelResult.encrypted_output === "string"
|
|
207
|
+
? `${namespace} operation completed`
|
|
208
|
+
: JSON.stringify(modelResult),
|
|
209
|
+
},
|
|
210
|
+
];
|
|
211
|
+
for (const image of parseBackendImages(result.value.images)) content.push(image);
|
|
212
|
+
return { content, details: { codexHistoryNotes: modelResult } };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function formatHistoryNotesFailure(
|
|
216
|
+
reason: Exclude<HistoryNotesResult, { ok: true }>["reason"],
|
|
217
|
+
status?: number,
|
|
218
|
+
): string {
|
|
219
|
+
switch (reason) {
|
|
220
|
+
case "aborted": return "Remote history/notes request was aborted";
|
|
221
|
+
case "backend-timeout": return "Remote history/notes backend timed out";
|
|
222
|
+
case "http-error": return `Remote history/notes backend returned HTTP ${status ?? "error"}`;
|
|
223
|
+
case "invalid-json": return "Remote history/notes backend returned invalid JSON";
|
|
224
|
+
case "invalid-response": return "Remote history/notes backend returned an invalid response";
|
|
225
|
+
case "auth-resolution-failed": return "Remote history/notes authentication could not be resolved";
|
|
226
|
+
case "missing-token": return "Remote history/notes authentication is missing a token";
|
|
227
|
+
case "missing-api-key": return "Remote history/notes gateway authentication is missing an API key";
|
|
228
|
+
case "missing-account-id": return "Remote history/notes authentication is missing an account id";
|
|
229
|
+
case "missing-base-url": return "Remote history/notes backend is missing a base URL";
|
|
230
|
+
case "unsupported-backend": return "Remote history/notes require the native Codex backend";
|
|
231
|
+
case "protocol-error": return "Remote history/notes request failed";
|
|
232
|
+
case "invalid-account-token": return "Remote history/notes authentication token is invalid";
|
|
233
|
+
default: return "Remote history/notes request failed";
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const RESPONSE_KEYS_BY_ENDPOINT: Record<string, readonly string[]> = {
|
|
238
|
+
[HISTORY_ENDPOINTS.list_windows]: ["windows", "items", "results", "data", "output", "encrypted_output"],
|
|
239
|
+
[HISTORY_ENDPOINTS.list_items]: ["items", "results", "data", "output", "encrypted_output"],
|
|
240
|
+
[HISTORY_ENDPOINTS.read_item]: ["item", "content", "text", "data", "output", "encrypted_output"],
|
|
241
|
+
[HISTORY_ENDPOINTS.search_contents]: ["matches", "items", "results", "data", "output", "encrypted_output"],
|
|
242
|
+
[NOTES_ENDPOINTS.list_files_by_prefix]: ["files", "items", "results", "data", "output", "encrypted_output"],
|
|
243
|
+
[NOTES_ENDPOINTS.read_file]: ["file", "content", "text", "data", "output", "encrypted_output"],
|
|
244
|
+
[NOTES_ENDPOINTS.search_contents]: ["matches", "items", "results", "data", "output", "encrypted_output"],
|
|
245
|
+
[NOTES_ENDPOINTS.append_to_file]: ["ok", "success", "file", "content", "data", "output", "encrypted_output"],
|
|
246
|
+
[NOTES_ENDPOINTS.write_file]: ["ok", "success", "file", "content", "data", "output", "encrypted_output"],
|
|
247
|
+
[THREAD_HINT_ENDPOINT]: ["text", "data", "output"],
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const ARRAY_RESPONSE_KEYS = new Set(["windows", "items", "results", "matches", "files"]);
|
|
251
|
+
|
|
252
|
+
function isHistoryNotesResponse(value: unknown, endpoint: string): value is HistoryNotesResponse {
|
|
253
|
+
if (!isRecord(value) || Object.keys(value).length === 0) return false;
|
|
254
|
+
if (value.encrypted_output !== undefined &&
|
|
255
|
+
(typeof value.encrypted_output !== "string" || value.encrypted_output.length === 0)) return false;
|
|
256
|
+
if (value.images !== undefined && !isValidImages(value.images)) return false;
|
|
257
|
+
if (value.ok !== undefined && typeof value.ok !== "boolean") return false;
|
|
258
|
+
if (value.success !== undefined && typeof value.success !== "boolean") return false;
|
|
259
|
+
if (value.text !== undefined && typeof value.text !== "string") return false;
|
|
260
|
+
for (const key of ARRAY_RESPONSE_KEYS) {
|
|
261
|
+
if (value[key] !== undefined && !Array.isArray(value[key])) return false;
|
|
262
|
+
}
|
|
263
|
+
const expectedKeys = RESPONSE_KEYS_BY_ENDPOINT[endpoint];
|
|
264
|
+
return expectedKeys ? expectedKeys.some((key) => key in value) : false;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
type BackendImage = {
|
|
268
|
+
data: string;
|
|
269
|
+
mime_type: string;
|
|
270
|
+
detail?: "auto" | "high" | "original" | null;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
function isValidImages(value: unknown): value is BackendImage[] {
|
|
274
|
+
return Array.isArray(value) && value.every((item): item is BackendImage => {
|
|
275
|
+
if (!isRecord(item) || typeof item.data !== "string" || typeof item.mime_type !== "string") return false;
|
|
276
|
+
return item.detail === undefined || item.detail === null || item.detail === "auto" || item.detail === "high" || item.detail === "original";
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function parseBackendImages(value: unknown): Array<{
|
|
281
|
+
type: "image";
|
|
282
|
+
data: string;
|
|
283
|
+
mimeType: string;
|
|
284
|
+
detail?: "auto" | "high" | "original";
|
|
285
|
+
}> {
|
|
286
|
+
if (value === undefined) return [];
|
|
287
|
+
if (!isValidImages(value)) throw new Error("Remote history backend returned invalid image content");
|
|
288
|
+
return value.map((item) => ({
|
|
289
|
+
type: "image" as const,
|
|
290
|
+
data: item.data,
|
|
291
|
+
mimeType: item.mime_type,
|
|
292
|
+
...(item.detail ? { detail: item.detail } : {}),
|
|
293
|
+
}));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function stripAction(params: Record<string, unknown>): Record<string, unknown> {
|
|
297
|
+
const result = { ...params };
|
|
298
|
+
delete result.action;
|
|
299
|
+
return result;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function validateAction(
|
|
303
|
+
namespace: "history" | "notes",
|
|
304
|
+
action: HistoryAction | NotesAction,
|
|
305
|
+
params: Record<string, unknown>,
|
|
306
|
+
): void {
|
|
307
|
+
const fields = namespace === "history"
|
|
308
|
+
? HISTORY_ACTION_FIELDS[action as HistoryAction]
|
|
309
|
+
: NOTES_ACTION_FIELDS[action as NotesAction];
|
|
310
|
+
if (!fields) throw new Error(`Unsupported ${namespace} action`);
|
|
311
|
+
const unexpected = Object.keys(params).find((field) => field !== "action" && !fields.includes(field));
|
|
312
|
+
if (unexpected) throw new Error(`${namespace} ${action} does not accept ${unexpected}`);
|
|
313
|
+
if (action === "read_item" && (typeof params.item_id !== "string" || !params.item_id || typeof params.window_id !== "string" || !params.window_id)) {
|
|
314
|
+
throw new Error("history read_item requires item_id and window_id");
|
|
315
|
+
}
|
|
316
|
+
if (action === "search_contents" && (typeof params.query !== "string" || !params.query)) {
|
|
317
|
+
throw new Error(`${namespace} search_contents requires query`);
|
|
318
|
+
}
|
|
319
|
+
if ((action === "read_file" || action === "append_to_file" || action === "write_file") && (typeof params.path !== "string" || !params.path)) {
|
|
320
|
+
throw new Error(`notes ${action} requires path`);
|
|
321
|
+
}
|
|
322
|
+
if ((action === "append_to_file" || action === "write_file") && typeof params.text !== "string") {
|
|
323
|
+
throw new Error(`notes ${action} requires text`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import {
|
|
5
|
+
CODEX_CONTEXT_WINDOW_MESSAGE_TYPE,
|
|
6
|
+
CONTEXT_MANAGEMENT_PROTOCOL,
|
|
7
|
+
CONTEXT_WINDOW_COMPACTION_STRATEGY,
|
|
8
|
+
CONTEXT_WINDOW_FALLBACK_BUFFER,
|
|
9
|
+
CONTEXT_WINDOW_REMINDER_THRESHOLD,
|
|
10
|
+
type CodexContextManagementMessageDetails,
|
|
11
|
+
type ContextManagementMessageKind,
|
|
12
|
+
type ContextWindowCompactionDetails,
|
|
13
|
+
type ContextWindowIdentity,
|
|
14
|
+
isCodexContextManagementMessageDetails,
|
|
15
|
+
isContextWindowCompactionDetails,
|
|
16
|
+
} from "./types";
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
CODEX_CONTEXT_WINDOW_MESSAGE_TYPE,
|
|
20
|
+
CONTEXT_WINDOW_COMPACTION_STRATEGY,
|
|
21
|
+
CONTEXT_WINDOW_FALLBACK_BUFFER,
|
|
22
|
+
CONTEXT_WINDOW_REMINDER_THRESHOLD,
|
|
23
|
+
isCodexContextManagementMessageDetails,
|
|
24
|
+
isContextWindowCompactionDetails,
|
|
25
|
+
};
|
|
26
|
+
export type {
|
|
27
|
+
CodexContextManagementMessageDetails,
|
|
28
|
+
ContextManagementMessageKind,
|
|
29
|
+
ContextWindowCompactionDetails,
|
|
30
|
+
ContextWindowIdentity,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const CONTEXT_WINDOW_COMPACTION_SUMMARY =
|
|
34
|
+
"[Pi Codex context-window boundary; no conversation summary was generated.]";
|
|
35
|
+
|
|
36
|
+
const CONTEXT_WINDOW_GUIDANCE = `<context_window_guidance>
|
|
37
|
+
Checkpoint the active request, known history IDs, decisions, progress, learnings and next steps in notes before new_context; no summary carries over. After rollover, read hinted notes. Use history only for a missing detail.
|
|
38
|
+
</context_window_guidance>`;
|
|
39
|
+
|
|
40
|
+
export function renderContextWindowMessage(
|
|
41
|
+
identity: ContextWindowIdentity,
|
|
42
|
+
threadHint?: string,
|
|
43
|
+
): string {
|
|
44
|
+
const lines = [
|
|
45
|
+
"<context_window>",
|
|
46
|
+
"Agent name: /root",
|
|
47
|
+
`First context window id: ${identity.firstWindowId}`,
|
|
48
|
+
`Current context window id: ${identity.currentWindowId}`,
|
|
49
|
+
];
|
|
50
|
+
if (identity.previousWindowId) lines.push(`Previous context window id: ${identity.previousWindowId}`);
|
|
51
|
+
if (threadHint) lines.push(threadHint);
|
|
52
|
+
lines.push("</context_window>");
|
|
53
|
+
return `${CONTEXT_WINDOW_GUIDANCE}\n\n${lines.join("\n")}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function renderContextWindowReminder(remainingTokens: number): string {
|
|
57
|
+
return `<context_window_reminder>
|
|
58
|
+
Only ${Math.max(0, Math.floor(remainingTokens))} context tokens remain. Checkpoint the active request, state and known history IDs in notes, then call new_context; no conversation summary carries over.
|
|
59
|
+
</context_window_reminder>`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const CONTEXT_WINDOW_FALLBACK_MESSAGE = `<context_window_reminder>
|
|
63
|
+
Context exhausted. Do not continue or answer. Make exactly one notes write or append call that checkpoints the active request, state and known history IDs, then call new_context. Use no other tools before rollover.
|
|
64
|
+
</context_window_reminder>`;
|
|
65
|
+
|
|
66
|
+
export function isContextWindowBoundary(
|
|
67
|
+
message: AgentMessage,
|
|
68
|
+
): message is Extract<AgentMessage, { role: "custom" }> & {
|
|
69
|
+
details: CodexContextManagementMessageDetails;
|
|
70
|
+
} {
|
|
71
|
+
return (
|
|
72
|
+
message.role === "custom" &&
|
|
73
|
+
message.customType === CODEX_CONTEXT_WINDOW_MESSAGE_TYPE &&
|
|
74
|
+
isCodexContextManagementMessageDetails(message.details) &&
|
|
75
|
+
message.details.contextManagement.kind === "window"
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function sendContextWindowMessage(
|
|
80
|
+
pi: ExtensionAPI,
|
|
81
|
+
content: string,
|
|
82
|
+
kind: ContextManagementMessageKind,
|
|
83
|
+
identity: ContextWindowIdentity,
|
|
84
|
+
options: { triggerTurn: boolean; sessionId?: string },
|
|
85
|
+
trimPreviousWindow = false,
|
|
86
|
+
): void {
|
|
87
|
+
const details: CodexContextManagementMessageDetails = {
|
|
88
|
+
protocol: CONTEXT_MANAGEMENT_PROTOCOL,
|
|
89
|
+
id: randomUUID(),
|
|
90
|
+
...(options.sessionId ? { sessionId: options.sessionId } : {}),
|
|
91
|
+
contextManagement: {
|
|
92
|
+
protocol: CONTEXT_MANAGEMENT_PROTOCOL,
|
|
93
|
+
kind,
|
|
94
|
+
...identity,
|
|
95
|
+
...(trimPreviousWindow ? { trimPreviousWindow: true as const } : {}),
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
pi.sendMessage({
|
|
99
|
+
customType: CODEX_CONTEXT_WINDOW_MESSAGE_TYPE,
|
|
100
|
+
content,
|
|
101
|
+
display: true,
|
|
102
|
+
details,
|
|
103
|
+
}, options.triggerTurn
|
|
104
|
+
? { deliverAs: "steer", triggerTurn: true }
|
|
105
|
+
: { triggerTurn: false });
|
|
106
|
+
}
|