@howaboua/pi-codex-conversion 1.5.5 → 1.5.6-dev.28.300a94c

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.6
4
+
5
+ - Added Compaction and Overrides tabs to `/codex`.
6
+ - Added optional native Responses compaction for Codex sessions, with settings for compaction model and reasoning.
7
+ - Added an `apply_patch`-only override mode for GPT/Codex models. This mode bypasses most of this extension, but still gives you the `apply_patch` tool.
8
+ - Renamed the native Codex web search tool from `web_search` to responses-native `web.run`, allowing compatibility with other extensions.
9
+ - Synced the custom OpenAI Codex provider and Pi development dependencies with Pi `0.75.3`.
10
+
3
11
  ## 1.5.5
4
12
 
5
13
  - Avoid registering disabled native `web_search` and `image_generation` tools so other extensions can own those names.
package/README.md CHANGED
@@ -26,7 +26,7 @@ When the adapter is active, the LLM sees these tools:
26
26
  - `exec_command` — shell execution with Codex-style `cmd` parameters and resumable sessions
27
27
  - `write_stdin` — continue or poll a running exec session
28
28
  - `apply_patch` — patch tool
29
- - `web_search` — native OpenAI Codex Responses web search, enabled only on the `openai-codex` provider
29
+ - `web.run` — native OpenAI Codex Responses web search, enabled only on the `openai-codex` provider
30
30
  - `image_generation` — native OpenAI Codex Responses image generation, enabled only on image-capable `openai-codex` models
31
31
  - `view_image` — image-only wrapper around Pi's native image reading, enabled only for image-capable models
32
32
 
@@ -50,6 +50,10 @@ Use `/codex` to change adapter settings.
50
50
 
51
51
  Settings are saved globally in `~/.pi/agent/pi-codex-conversion.json`.
52
52
 
53
+ The settings UI also has an **Overrides** tab. These options intentionally do not have `/codex ...` command shortcuts:
54
+
55
+ - add only the Pi `apply_patch` tool for GPT/Codex models while keeping Pi's default toolkit, prompt, provider behavior, and compaction flow
56
+
53
57
  When `all` is on, non-Codex providers get the shell, patch, skill, and prompt-adapter behavior, but keep their normal Pi provider path. Native web search, native image generation, and priority service tier stay limited to the OpenAI Codex provider. Verbosity is applied to Responses API providers.
54
58
 
55
59
  The footer shows the active state, for example:
@@ -83,7 +87,7 @@ Raw command output is still available by expanding the tool result.
83
87
  - `exec_command` and `write_stdin` use a PTY-backed session manager for interactive commands and long-running processes.
84
88
  - `apply_patch` accepts absolute paths as-is and resolves relative paths against the current working directory.
85
89
  - Shell `apply_patch` is also available inside `exec_command`, but the dedicated `apply_patch` tool is preferred unless you are chaining edits with other shell steps.
86
- - Native `web_search` and `image_generation` are forwarded to OpenAI Codex Responses tools rather than executed as local function tools.
90
+ - Native `web.run` and `image_generation` are forwarded to OpenAI Codex Responses tools rather than executed as local function tools.
87
91
 
88
92
  ## Development checkout
89
93
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@howaboua/pi-codex-conversion",
3
- "version": "1.5.5",
3
+ "version": "1.5.6-dev.28.300a94c",
4
4
  "description": "Codex-oriented tool and prompt adapter for pi coding agent",
5
5
  "type": "module",
6
6
  "repository": {
@@ -60,9 +60,9 @@
60
60
  "typebox": "*"
61
61
  },
62
62
  "devDependencies": {
63
- "@earendil-works/pi-ai": "^0.74.1",
64
- "@earendil-works/pi-coding-agent": "^0.74.1",
65
- "@earendil-works/pi-tui": "^0.74.1",
63
+ "@earendil-works/pi-ai": "^0.75.3",
64
+ "@earendil-works/pi-coding-agent": "^0.75.3",
65
+ "@earendil-works/pi-tui": "^0.75.3",
66
66
  "tsx": "^4.20.5",
67
67
  "typebox": "^1.1.24",
68
68
  "typescript": "^5.9.3"
@@ -3,21 +3,27 @@ import { isCodexLikeContext, isOpenAICodexContext, isResponsesContext } from "./
3
3
  import type { CodexConversionConfig } from "./config.ts";
4
4
  import type { AdapterState } from "./state.ts";
5
5
  import {
6
+ APPLY_PATCH_TOOL_NAME,
6
7
  CORE_ADAPTER_TOOL_NAMES,
7
8
  DEFAULT_TOOL_NAMES,
8
9
  IMAGE_GENERATION_TOOL_NAME,
10
+ APPLY_PATCH_ONLY_STATUS_TEXT,
9
11
  STATUS_KEY,
10
12
  VIEW_IMAGE_TOOL_NAME,
11
13
  WEB_SEARCH_TOOL_NAME,
14
+ SHELL_ADAPTER_TOOL_NAMES,
12
15
  buildStatusText,
13
16
  } from "./tool-set.ts";
14
17
  import { supportsNativeImageGeneration } from "../tools/image-generation-tool.ts";
15
18
  import { supportsNativeWebSearch } from "../tools/web-search-tool.ts";
16
19
 
17
20
  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];
19
21
 
20
22
  export function syncAdapter(pi: ExtensionAPI, ctx: ExtensionContext, state: AdapterState): void {
23
+ if (shouldUseApplyPatchOnly(ctx, state.config)) {
24
+ enableApplyPatchOnly(pi, ctx, state);
25
+ return;
26
+ }
21
27
  if (shouldUseCodexAdapter(ctx, state.config)) {
22
28
  enableAdapter(pi, ctx, state);
23
29
  } else {
@@ -26,9 +32,28 @@ export function syncAdapter(pi: ExtensionAPI, ctx: ExtensionContext, state: Adap
26
32
  }
27
33
 
28
34
  export function shouldUseCodexAdapter(ctx: ExtensionContext, config: CodexConversionConfig): boolean {
35
+ if (config.applyPatchOnly) return false;
29
36
  return config.useOnAllModels || isCodexLikeContext(ctx);
30
37
  }
31
38
 
39
+ export function shouldUseApplyPatchOnly(ctx: ExtensionContext, config: CodexConversionConfig): boolean {
40
+ return config.applyPatchOnly && isCodexLikeContext(ctx);
41
+ }
42
+
43
+ function enableApplyPatchOnly(pi: ExtensionAPI, ctx: ExtensionContext, state: AdapterState): void {
44
+ const adapterOwnedTools = [APPLY_PATCH_TOOL_NAME];
45
+ if (!state.enabled || state.adapterOwnedToolNames?.some((toolName) => toolName !== APPLY_PATCH_TOOL_NAME)) {
46
+ const restoredBase = state.enabled
47
+ ? restoreTools(state.previousToolNames && state.previousToolNames.length > 0 ? state.previousToolNames : DEFAULT_TOOL_NAMES, pi.getActiveTools(), state.adapterOwnedToolNames ?? ADAPTER_TOOL_NAMES)
48
+ : stripAdapterTools(pi.getActiveTools(), ADAPTER_TOOL_NAMES);
49
+ state.previousToolNames = restoredBase;
50
+ state.enabled = true;
51
+ }
52
+ state.adapterOwnedToolNames = adapterOwnedTools;
53
+ pi.setActiveTools(mergeToolNames(state.previousToolNames ?? DEFAULT_TOOL_NAMES, adapterOwnedTools));
54
+ setApplyPatchOnlyStatus(ctx, state.config);
55
+ }
56
+
32
57
  function enableAdapter(pi: ExtensionAPI, ctx: ExtensionContext, state: AdapterState): void {
33
58
  const currentAdapterOwnedTools = getAdapterOwnedToolNames(state.config);
34
59
  const adapterOwnedTools = state.enabled ? mergeToolNames(state.adapterOwnedToolNames ?? currentAdapterOwnedTools, currentAdapterOwnedTools) : currentAdapterOwnedTools;
@@ -69,14 +94,20 @@ function setStatus(ctx: ExtensionContext, enabled: boolean, config: CodexConvers
69
94
  ctx.ui.setStatus(STATUS_KEY, enabled ? buildStatusText(statusConfig) : undefined);
70
95
  }
71
96
 
97
+ function setApplyPatchOnlyStatus(ctx: ExtensionContext, config: CodexConversionConfig): void {
98
+ if (!ctx.hasUI) return;
99
+ ctx.ui.setStatus(STATUS_KEY, config.statusLine ? APPLY_PATCH_ONLY_STATUS_TEXT : undefined);
100
+ }
101
+
72
102
  function getStatusConfig(ctx: ExtensionContext, config: CodexConversionConfig): Parameters<typeof buildStatusText>[0] {
73
103
  const showOpenAICodexFlags = isOpenAICodexContext(ctx);
74
104
  const showResponsesVerbosity = isResponsesContext(ctx);
75
105
  return {
76
106
  useOnAllModels: config.useOnAllModels,
77
107
  fast: showOpenAICodexFlags && config.fast,
78
- webSearch: showOpenAICodexFlags && config.webSearch && supportsNativeWebSearch(ctx.model),
79
- imageGeneration: showOpenAICodexFlags && config.imageGeneration && supportsNativeImageGeneration(ctx.model),
108
+ webSearch: showOpenAICodexFlags && !config.applyPatchOnly && config.webSearch && supportsNativeWebSearch(ctx.model),
109
+ imageGeneration: showOpenAICodexFlags && !config.applyPatchOnly && config.imageGeneration && supportsNativeImageGeneration(ctx.model),
110
+ compaction: { enabled: Boolean(config.responsesCompaction), model: config.compactionModel, reasoning: config.compactionReasoning },
80
111
  ...(showResponsesVerbosity ? { verbosity: config.verbosity } : {}),
81
112
  };
82
113
  }
@@ -97,7 +128,9 @@ function getAdapterToolNames(ctx: ExtensionContext, config: CodexConversionConfi
97
128
 
98
129
  function getAdapterOwnedToolNames(config: CodexConversionConfig): string[] {
99
130
  return [
100
- ...ALWAYS_OWNED_ADAPTER_TOOL_NAMES,
131
+ ...SHELL_ADAPTER_TOOL_NAMES,
132
+ APPLY_PATCH_TOOL_NAME,
133
+ VIEW_IMAGE_TOOL_NAME,
101
134
  ...(config.webSearch ? [WEB_SEARCH_TOOL_NAME] : []),
102
135
  ...(config.imageGeneration ? [IMAGE_GENERATION_TOOL_NAME] : []),
103
136
  ];
@@ -108,7 +141,7 @@ function mergeToolNames(...toolNameGroups: string[][]): string[] {
108
141
  }
109
142
 
110
143
  export function mergeAdapterTools(activeTools: string[], adapterTools: string[], adapterOwnedTools: string[] = adapterTools): string[] {
111
- const ownedTools = new Set([...ALWAYS_OWNED_ADAPTER_TOOL_NAMES, ...adapterTools, ...adapterOwnedTools]);
144
+ const ownedTools = new Set([...CORE_ADAPTER_TOOL_NAMES, ...adapterTools, ...adapterOwnedTools]);
112
145
  const preservedTools = activeTools.filter((toolName) => !DEFAULT_TOOL_NAMES.includes(toolName) && !ownedTools.has(toolName));
113
146
  return [...adapterTools, ...preservedTools];
114
147
  }
@@ -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
+ }