@howaboua/pi-codex-conversion 1.5.4 → 1.5.5-dev.25.f80a775

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.5
4
+
5
+ - Avoid registering disabled native `web_search` and `image_generation` tools so other extensions can own those names.
6
+ - Preserve other extensions' `web_search` and `image_generation` tools when the matching Codex feature is off.
7
+ - Added a `/codex status` toggle and settings UI option for hiding the Codex footer/statusline.
8
+
3
9
  ## 1.5.4
4
10
 
5
11
  - Added `/codex` settings UI.
package/README.md CHANGED
@@ -42,6 +42,7 @@ Notably:
42
42
  Use `/codex` to change adapter settings.
43
43
 
44
44
  - `/codex all` — use the Codex tool and prompt adapter on every model
45
+ - `/codex status` — toggle the footer/statusline entry
45
46
  - `/codex fast` — toggle priority service tier for the OpenAI Codex provider
46
47
  - `/codex search` — toggle native Codex web search
47
48
  - `/codex image` — toggle native Codex image generation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@howaboua/pi-codex-conversion",
3
- "version": "1.5.4",
3
+ "version": "1.5.5-dev.25.f80a775",
4
4
  "description": "Codex-oriented tool and prompt adapter for pi coding agent",
5
5
  "type": "module",
6
6
  "repository": {
@@ -15,6 +15,7 @@ import { supportsNativeImageGeneration } from "../tools/image-generation-tool.ts
15
15
  import { supportsNativeWebSearch } from "../tools/web-search-tool.ts";
16
16
 
17
17
  const ADAPTER_TOOL_NAMES = [...CORE_ADAPTER_TOOL_NAMES, WEB_SEARCH_TOOL_NAME, IMAGE_GENERATION_TOOL_NAME, VIEW_IMAGE_TOOL_NAME];
18
+ const ALWAYS_OWNED_ADAPTER_TOOL_NAMES = [...CORE_ADAPTER_TOOL_NAMES, VIEW_IMAGE_TOOL_NAME];
18
19
 
19
20
  export function syncAdapter(pi: ExtensionAPI, ctx: ExtensionContext, state: AdapterState): void {
20
21
  if (shouldUseCodexAdapter(ctx, state.config)) {
@@ -29,32 +30,41 @@ export function shouldUseCodexAdapter(ctx: ExtensionContext, config: CodexConver
29
30
  }
30
31
 
31
32
  function enableAdapter(pi: ExtensionAPI, ctx: ExtensionContext, state: AdapterState): void {
32
- const toolNames = mergeAdapterTools(pi.getActiveTools(), getAdapterToolNames(ctx, state.config));
33
+ const currentAdapterOwnedTools = getAdapterOwnedToolNames(state.config);
34
+ const adapterOwnedTools = state.enabled ? mergeToolNames(state.adapterOwnedToolNames ?? currentAdapterOwnedTools, currentAdapterOwnedTools) : currentAdapterOwnedTools;
35
+ const toolNames = mergeAdapterTools(pi.getActiveTools(), getAdapterToolNames(ctx, state.config), adapterOwnedTools);
33
36
  if (!state.enabled) {
34
37
  // Preserve the previous active set once so switching away from Codex-like
35
38
  // models restores the user's existing Pi tool configuration. Strip adapter
36
39
  // tools in case a fresh session starts from persisted/mixed active tools.
37
- state.previousToolNames = stripAdapterTools(pi.getActiveTools());
40
+ state.previousToolNames = stripAdapterTools(pi.getActiveTools(), adapterOwnedTools);
38
41
  state.enabled = true;
39
42
  }
43
+ state.adapterOwnedToolNames = currentAdapterOwnedTools;
40
44
  pi.setActiveTools(toolNames);
41
45
  setStatus(ctx, true, state.config);
42
46
  }
43
47
 
44
48
  function disableAdapter(pi: ExtensionAPI, ctx: ExtensionContext, state: AdapterState): void {
45
49
  const previousToolNames = state.previousToolNames && state.previousToolNames.length > 0 ? state.previousToolNames : DEFAULT_TOOL_NAMES;
46
- const restoredTools = restoreTools(previousToolNames, pi.getActiveTools());
47
- if (state.enabled || hasAdapterTools(pi.getActiveTools())) {
50
+ const adapterOwnedTools = state.adapterOwnedToolNames ?? getAdapterOwnedToolNames(state.config);
51
+ const restoredTools = restoreTools(previousToolNames, pi.getActiveTools(), adapterOwnedTools);
52
+ if (state.enabled || hasAdapterTools(pi.getActiveTools(), adapterOwnedTools)) {
48
53
  pi.setActiveTools(restoredTools);
49
54
  }
50
55
  if (state.enabled) {
51
56
  state.enabled = false;
57
+ state.adapterOwnedToolNames = undefined;
52
58
  }
53
59
  setStatus(ctx, false, state.config);
54
60
  }
55
61
 
56
62
  function setStatus(ctx: ExtensionContext, enabled: boolean, config: CodexConversionConfig): void {
57
63
  if (!ctx.hasUI) return;
64
+ if (!config.statusLine) {
65
+ ctx.ui.setStatus(STATUS_KEY, undefined);
66
+ return;
67
+ }
58
68
  const statusConfig = getStatusConfig(ctx, config);
59
69
  ctx.ui.setStatus(STATUS_KEY, enabled ? buildStatusText(statusConfig) : undefined);
60
70
  }
@@ -67,6 +77,7 @@ function getStatusConfig(ctx: ExtensionContext, config: CodexConversionConfig):
67
77
  fast: showOpenAICodexFlags && config.fast,
68
78
  webSearch: showOpenAICodexFlags && config.webSearch && supportsNativeWebSearch(ctx.model),
69
79
  imageGeneration: showOpenAICodexFlags && config.imageGeneration && supportsNativeImageGeneration(ctx.model),
80
+ compaction: { enabled: Boolean(config.responsesCompaction), model: config.compactionModel, reasoning: config.compactionReasoning },
70
81
  ...(showResponsesVerbosity ? { verbosity: config.verbosity } : {}),
71
82
  };
72
83
  }
@@ -85,25 +96,38 @@ function getAdapterToolNames(ctx: ExtensionContext, config: CodexConversionConfi
85
96
  return toolNames;
86
97
  }
87
98
 
88
- export function mergeAdapterTools(activeTools: string[], adapterTools: string[]): string[] {
89
- const preservedTools = activeTools.filter((toolName) => !DEFAULT_TOOL_NAMES.includes(toolName) && !ADAPTER_TOOL_NAMES.includes(toolName));
99
+ function getAdapterOwnedToolNames(config: CodexConversionConfig): string[] {
100
+ return [
101
+ ...ALWAYS_OWNED_ADAPTER_TOOL_NAMES,
102
+ ...(config.webSearch ? [WEB_SEARCH_TOOL_NAME] : []),
103
+ ...(config.imageGeneration ? [IMAGE_GENERATION_TOOL_NAME] : []),
104
+ ];
105
+ }
106
+
107
+ function mergeToolNames(...toolNameGroups: string[][]): string[] {
108
+ return [...new Set(toolNameGroups.flat())];
109
+ }
110
+
111
+ export function mergeAdapterTools(activeTools: string[], adapterTools: string[], adapterOwnedTools: string[] = adapterTools): string[] {
112
+ const ownedTools = new Set([...ALWAYS_OWNED_ADAPTER_TOOL_NAMES, ...adapterTools, ...adapterOwnedTools]);
113
+ const preservedTools = activeTools.filter((toolName) => !DEFAULT_TOOL_NAMES.includes(toolName) && !ownedTools.has(toolName));
90
114
  return [...adapterTools, ...preservedTools];
91
115
  }
92
116
 
93
- export function restoreTools(previousTools: string[], activeTools: string[]): string[] {
94
- const restored = stripAdapterTools(previousTools);
117
+ export function restoreTools(previousTools: string[], activeTools: string[], adapterOwnedTools: string[] = ADAPTER_TOOL_NAMES): string[] {
118
+ const restored = stripAdapterTools(previousTools, adapterOwnedTools);
95
119
  for (const toolName of activeTools) {
96
- if (!ADAPTER_TOOL_NAMES.includes(toolName) && !restored.includes(toolName)) {
120
+ if (!adapterOwnedTools.includes(toolName) && !restored.includes(toolName)) {
97
121
  restored.push(toolName);
98
122
  }
99
123
  }
100
124
  return restored;
101
125
  }
102
126
 
103
- export function stripAdapterTools(toolNames: string[]): string[] {
104
- return toolNames.filter((toolName) => !ADAPTER_TOOL_NAMES.includes(toolName));
127
+ export function stripAdapterTools(toolNames: string[], adapterOwnedTools: string[] = ADAPTER_TOOL_NAMES): string[] {
128
+ return toolNames.filter((toolName) => !adapterOwnedTools.includes(toolName));
105
129
  }
106
130
 
107
- function hasAdapterTools(activeTools: string[]): boolean {
108
- return activeTools.some((toolName) => ADAPTER_TOOL_NAMES.includes(toolName));
131
+ function hasAdapterTools(activeTools: string[], adapterOwnedTools: string[]): boolean {
132
+ return activeTools.some((toolName) => adapterOwnedTools.includes(toolName));
109
133
  }
@@ -0,0 +1,257 @@
1
+ import type { NativeCompactionRuntime } from "./compaction-runtime.ts";
2
+ import type { NativeCompactionRequestBody } from "./serializer.ts";
3
+
4
+ const JSON_CONTENT_TYPE = "application/json";
5
+
6
+ type CompactResponseEnvelope = {
7
+ id?: string;
8
+ created_at?: number | string;
9
+ output: unknown[];
10
+ [key: string]: unknown;
11
+ };
12
+
13
+ export type NativeCompactionClientFailureReason =
14
+ | "aborted"
15
+ | "network-error"
16
+ | "non-2xx"
17
+ | "empty-body"
18
+ | "invalid-json"
19
+ | "malformed-response"
20
+ | "empty-output";
21
+
22
+ export type NativeCompactionClientSuccess = {
23
+ ok: true;
24
+ status: number;
25
+ compactedWindow: unknown[];
26
+ compactResponseId?: string;
27
+ createdAt?: string;
28
+ response: CompactResponseEnvelope;
29
+ };
30
+
31
+ export type NativeCompactionClientFailure = {
32
+ ok: false;
33
+ reason: NativeCompactionClientFailureReason;
34
+ status?: number;
35
+ errorMessage?: string;
36
+ responseText?: string;
37
+ responseJson?: unknown;
38
+ };
39
+
40
+ export type NativeCompactionClientResult = NativeCompactionClientSuccess | NativeCompactionClientFailure;
41
+
42
+ export type ExecuteNativeCompactionOptions = {
43
+ runtime: NativeCompactionRuntime;
44
+ request: NativeCompactionRequestBody;
45
+ signal?: AbortSignal;
46
+ };
47
+
48
+ function isRecord(value: unknown): value is Record<string, unknown> {
49
+ return !!value && typeof value === "object" && !Array.isArray(value);
50
+ }
51
+
52
+ function isAbortError(error: unknown): boolean {
53
+ return (
54
+ (error instanceof DOMException && error.name === "AbortError") ||
55
+ (error instanceof Error && (error.name === "AbortError" || error.name === "ABORT_ERR"))
56
+ );
57
+ }
58
+
59
+ function normalizeResponseTimestamp(value: unknown): string | undefined {
60
+ if (typeof value === "number" && Number.isFinite(value)) {
61
+ const milliseconds = value > 1_000_000_000_000 ? value : value * 1000;
62
+ return new Date(milliseconds).toISOString();
63
+ }
64
+
65
+ if (typeof value !== "string") {
66
+ return undefined;
67
+ }
68
+
69
+ const trimmed = value.trim();
70
+ if (!trimmed) {
71
+ return undefined;
72
+ }
73
+
74
+ const parsed = Date.parse(trimmed);
75
+ return Number.isNaN(parsed) ? trimmed : new Date(parsed).toISOString();
76
+ }
77
+
78
+ function isCompactOutputItem(value: unknown): value is Record<string, unknown> {
79
+ return isRecord(value);
80
+ }
81
+
82
+ function isCompactResponseEnvelope(value: unknown): value is CompactResponseEnvelope {
83
+ return isRecord(value) && Array.isArray(value.output) && value.output.every(isCompactOutputItem);
84
+ }
85
+
86
+ function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
87
+ const parts = token.split(".");
88
+ if (parts.length !== 3) {
89
+ return undefined;
90
+ }
91
+
92
+ try {
93
+ const payloadText = Buffer.from(parts[1]!, "base64url").toString("utf8");
94
+ const payload = JSON.parse(payloadText);
95
+ return isRecord(payload) ? payload : undefined;
96
+ } catch {
97
+ return undefined;
98
+ }
99
+ }
100
+
101
+ function extractCodexAccountId(token: string): string | undefined {
102
+ const payload = decodeJwtPayload(token);
103
+ const authClaims = payload?.["https://api.openai.com/auth"];
104
+ if (!isRecord(authClaims)) {
105
+ return undefined;
106
+ }
107
+
108
+ const accountId = authClaims.chatgpt_account_id;
109
+ return typeof accountId === "string" && accountId.trim().length > 0 ? accountId.trim() : undefined;
110
+ }
111
+
112
+ function buildCodexUserAgent(): string {
113
+ const platform = typeof process !== "undefined" ? process.platform : "browser";
114
+ const arch = typeof process !== "undefined" ? process.arch : "unknown";
115
+ return `pi (${platform}; ${arch})`;
116
+ }
117
+
118
+ function extractBearerToken(headers: Headers): string | undefined {
119
+ const authorization = headers.get("authorization")?.trim();
120
+ const match = authorization?.match(/^Bearer\s+(.+)$/i);
121
+ return match?.[1]?.trim() || undefined;
122
+ }
123
+
124
+ function toHeaders(runtime: NativeCompactionRuntime): Record<string, string> {
125
+ const headers = new Headers(runtime.currentModel.headers ?? {});
126
+ for (const [key, value] of Object.entries(runtime.headers ?? {})) {
127
+ headers.set(key, value);
128
+ }
129
+ headers.set("accept", JSON_CONTENT_TYPE);
130
+ headers.set("content-type", JSON_CONTENT_TYPE);
131
+ if (runtime.apiKey) {
132
+ headers.set("authorization", `Bearer ${runtime.apiKey}`);
133
+ }
134
+
135
+ if (runtime.provider === "openai-codex") {
136
+ const accountId = extractCodexAccountId(runtime.apiKey ?? extractBearerToken(headers) ?? "");
137
+ if (accountId) {
138
+ headers.set("chatgpt-account-id", accountId);
139
+ }
140
+ headers.set("originator", "pi");
141
+ headers.set("user-agent", buildCodexUserAgent());
142
+ headers.set("openai-beta", "responses=experimental");
143
+ }
144
+
145
+ return Object.fromEntries(headers.entries());
146
+ }
147
+
148
+ export async function executeNativeCompaction(
149
+ options: ExecuteNativeCompactionOptions,
150
+ ): Promise<NativeCompactionClientResult> {
151
+ const { runtime, request, signal } = options;
152
+ const headers = toHeaders(runtime);
153
+
154
+ if (signal?.aborted) {
155
+ const aborted: NativeCompactionClientFailure = {
156
+ ok: false,
157
+ reason: "aborted",
158
+ };
159
+ return aborted;
160
+ }
161
+
162
+ try {
163
+ const response = await fetch(runtime.compactUrl, {
164
+ method: "POST",
165
+ headers,
166
+ body: JSON.stringify(request),
167
+ signal,
168
+ });
169
+ const responseText = await response.text();
170
+
171
+ if (!response.ok) {
172
+ let responseJson: unknown;
173
+ if (responseText.trim().length > 0) {
174
+ try {
175
+ responseJson = JSON.parse(responseText);
176
+ } catch {
177
+ responseJson = undefined;
178
+ }
179
+ }
180
+
181
+ const failure: NativeCompactionClientFailure = {
182
+ ok: false,
183
+ reason: "non-2xx",
184
+ status: response.status,
185
+ responseText: responseText || undefined,
186
+ responseJson,
187
+ };
188
+ return failure;
189
+ }
190
+
191
+ if (!responseText.trim()) {
192
+ const failure: NativeCompactionClientFailure = {
193
+ ok: false,
194
+ reason: "empty-body",
195
+ status: response.status,
196
+ };
197
+ return failure;
198
+ }
199
+
200
+ let parsed: unknown;
201
+ try {
202
+ parsed = JSON.parse(responseText);
203
+ } catch (error) {
204
+ const failure: NativeCompactionClientFailure = {
205
+ ok: false,
206
+ reason: "invalid-json",
207
+ status: response.status,
208
+ errorMessage: error instanceof Error ? error.message : String(error),
209
+ responseText,
210
+ };
211
+ return failure;
212
+ }
213
+
214
+ if (!isCompactResponseEnvelope(parsed)) {
215
+ const failure: NativeCompactionClientFailure = {
216
+ ok: false,
217
+ reason: "malformed-response",
218
+ status: response.status,
219
+ responseJson: parsed,
220
+ };
221
+ return failure;
222
+ }
223
+
224
+ if (parsed.output.length === 0) {
225
+ const failure: NativeCompactionClientFailure = {
226
+ ok: false,
227
+ reason: "empty-output",
228
+ status: response.status,
229
+ responseJson: parsed,
230
+ };
231
+ return failure;
232
+ }
233
+
234
+ const success: NativeCompactionClientSuccess = {
235
+ ok: true,
236
+ status: response.status,
237
+ compactedWindow: [...parsed.output],
238
+ compactResponseId: typeof parsed.id === "string" && parsed.id.trim() ? parsed.id.trim() : undefined,
239
+ createdAt: normalizeResponseTimestamp(parsed.created_at),
240
+ response: parsed,
241
+ };
242
+ return success;
243
+ } catch (error) {
244
+ const failure: NativeCompactionClientFailure = isAbortError(error)
245
+ ? {
246
+ ok: false,
247
+ reason: "aborted",
248
+ }
249
+ : {
250
+ ok: false,
251
+ reason: "network-error",
252
+ errorMessage: error instanceof Error ? error.message : String(error),
253
+ };
254
+
255
+ return failure;
256
+ }
257
+ }
@@ -0,0 +1,80 @@
1
+ const COMPACTION_ITEM_TYPES = new Set(["compaction", "compaction_summary"]);
2
+
3
+ function isRecord(value: unknown): value is Record<string, unknown> {
4
+ return !!value && typeof value === "object" && !Array.isArray(value);
5
+ }
6
+
7
+ function cloneStructuredValue(value: unknown): unknown {
8
+ if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
9
+ return value;
10
+ }
11
+ if (Array.isArray(value)) {
12
+ return value.map(cloneStructuredValue);
13
+ }
14
+ if (isRecord(value)) {
15
+ const clone: Record<string, unknown> = {};
16
+ for (const [key, nested] of Object.entries(value)) {
17
+ clone[key] = cloneStructuredValue(nested);
18
+ }
19
+ return clone;
20
+ }
21
+ throw new Error(`Unsupported structured compact output value: ${typeof value}`);
22
+ }
23
+
24
+ function cloneCompactedOutputItem(item: Record<string, unknown>): Record<string, unknown> | undefined {
25
+ try {
26
+ return cloneStructuredValue(item) as Record<string, unknown>;
27
+ } catch {
28
+ return undefined;
29
+ }
30
+ }
31
+
32
+ export function shouldKeepCompactedOutputItem(item: unknown): item is Record<string, unknown> {
33
+ return isRecord(item) && typeof item.type === "string";
34
+ }
35
+
36
+ export function sanitizeCompactedWindow(output: readonly unknown[]): Record<string, unknown>[] {
37
+ const sanitized: Record<string, unknown>[] = [];
38
+ for (const item of output) {
39
+ if (!shouldKeepCompactedOutputItem(item)) continue;
40
+ const cloned = cloneCompactedOutputItem(item);
41
+ if (cloned) sanitized.push(cloned);
42
+ }
43
+ return sanitized;
44
+ }
45
+
46
+ export function extractCompactionSummaryText(compactedWindow: readonly unknown[]): string | undefined {
47
+ for (const item of compactedWindow) {
48
+ if (!isRecord(item) || typeof item.type !== "string" || !COMPACTION_ITEM_TYPES.has(item.type)) continue;
49
+ if (typeof item.encrypted_content === "string" && item.encrypted_content.trim().length > 0) return item.encrypted_content.trim();
50
+ }
51
+ return undefined;
52
+ }
53
+
54
+ export function hasCompactionOutputItem(compactedWindow: readonly unknown[]): boolean {
55
+ return compactedWindow.some((item) => isRecord(item) && typeof item.type === "string" && COMPACTION_ITEM_TYPES.has(item.type));
56
+ }
57
+
58
+ function describeOutputItem(item: unknown): string {
59
+ if (!isRecord(item)) return typeof item;
60
+ const type = typeof item.type === "string" ? item.type : "<missing-type>";
61
+ const role = typeof item.role === "string" ? `/${item.role}` : "";
62
+ const content = Array.isArray(item.content) ? ` content=${item.content.length}` : "";
63
+ const keys = Object.keys(item).sort().slice(0, 8).join(",");
64
+ return `${type}${role}${content} keys=[${keys}]`;
65
+ }
66
+
67
+ export function summarizeCompactionOutputForDiagnostics(rawOutput: readonly unknown[], sanitizedOutput: readonly unknown[]): string {
68
+ const rawTypes = rawOutput.map((item) => isRecord(item) && typeof item.type === "string" ? item.type : typeof item);
69
+ const sanitizedTypes = sanitizedOutput.map((item) => isRecord(item) && typeof item.type === "string" ? item.type : typeof item);
70
+ const rawCounts = countValues(rawTypes);
71
+ const sanitizedCounts = countValues(sanitizedTypes);
72
+ const sample = rawOutput.slice(0, 8).map((item, index) => `${index}: ${describeOutputItem(item)}`).join("; ");
73
+ return `raw=${rawOutput.length} {${rawCounts}}; sanitized=${sanitizedOutput.length} {${sanitizedCounts}}; sample=${sample || "<empty>"}`;
74
+ }
75
+
76
+ function countValues(values: readonly string[]): string {
77
+ const counts = new Map<string, number>();
78
+ for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
79
+ return Array.from(counts.entries()).map(([value, count]) => `${value}:${count}`).join(", ");
80
+ }