@xamukavila/pxpipe 0.8.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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +312 -0
  3. package/bin/cli.js +7 -0
  4. package/dist/core/applicability.d.ts +31 -0
  5. package/dist/core/applicability.js +96 -0
  6. package/dist/core/atlas-gray.d.ts +26 -0
  7. package/dist/core/atlas-gray.js +64 -0
  8. package/dist/core/atlas.d.ts +34 -0
  9. package/dist/core/atlas.js +71 -0
  10. package/dist/core/baseline.d.ts +80 -0
  11. package/dist/core/baseline.js +101 -0
  12. package/dist/core/caveman.d.ts +38 -0
  13. package/dist/core/caveman.js +183 -0
  14. package/dist/core/export.d.ts +128 -0
  15. package/dist/core/export.js +390 -0
  16. package/dist/core/factsheet.d.ts +78 -0
  17. package/dist/core/factsheet.js +216 -0
  18. package/dist/core/gpt-model-profiles.d.ts +60 -0
  19. package/dist/core/gpt-model-profiles.js +161 -0
  20. package/dist/core/history.d.ts +141 -0
  21. package/dist/core/history.js +553 -0
  22. package/dist/core/index.d.ts +8 -0
  23. package/dist/core/index.js +8 -0
  24. package/dist/core/library.d.ts +74 -0
  25. package/dist/core/library.js +133 -0
  26. package/dist/core/measurement.d.ts +22 -0
  27. package/dist/core/measurement.js +213 -0
  28. package/dist/core/openai-history.d.ts +124 -0
  29. package/dist/core/openai-history.js +494 -0
  30. package/dist/core/openai-savings.d.ts +44 -0
  31. package/dist/core/openai-savings.js +75 -0
  32. package/dist/core/openai.d.ts +24 -0
  33. package/dist/core/openai.js +839 -0
  34. package/dist/core/png.d.ts +11 -0
  35. package/dist/core/png.js +132 -0
  36. package/dist/core/proxy.d.ts +81 -0
  37. package/dist/core/proxy.js +730 -0
  38. package/dist/core/render.d.ts +188 -0
  39. package/dist/core/render.js +785 -0
  40. package/dist/core/schema-strip.d.ts +29 -0
  41. package/dist/core/schema-strip.js +160 -0
  42. package/dist/core/tracker.d.ts +154 -0
  43. package/dist/core/tracker.js +216 -0
  44. package/dist/core/transform.d.ts +362 -0
  45. package/dist/core/transform.js +1828 -0
  46. package/dist/core/types.d.ts +77 -0
  47. package/dist/core/types.js +8 -0
  48. package/dist/dashboard/fragments.d.ts +36 -0
  49. package/dist/dashboard/fragments.js +938 -0
  50. package/dist/dashboard/types.d.ts +154 -0
  51. package/dist/dashboard/types.js +3 -0
  52. package/dist/dashboard/vendor.d.ts +3 -0
  53. package/dist/dashboard/vendor.js +6 -0
  54. package/dist/dashboard.d.ts +245 -0
  55. package/dist/dashboard.js +1140 -0
  56. package/dist/export-collect.d.ts +36 -0
  57. package/dist/export-collect.js +59 -0
  58. package/dist/node.d.ts +9 -0
  59. package/dist/node.js +9038 -0
  60. package/dist/sessions.d.ts +172 -0
  61. package/dist/sessions.js +510 -0
  62. package/dist/stats.d.ts +74 -0
  63. package/dist/stats.js +248 -0
  64. package/dist/worker.d.ts +53 -0
  65. package/dist/worker.js +102 -0
  66. package/package.json +96 -0
@@ -0,0 +1,839 @@
1
+ /**
2
+ * OpenAI Chat Completions + Responses API transformer for the GPT-5 family.
3
+ * Separate from the Anthropic path: no cache-control breakpoints,
4
+ * images as image_url/input_image parts, system/developer messages in messages[]/input[].
5
+ * OpenAI tools keep native names/descriptions/schema shape; verbose schema prose
6
+ * is also rendered into images for token savings so calls do not depend only on OCR.
7
+ */
8
+ import { renderTextToPngs, reflow, shrinkColsToContent, PAD_X, CELL_W, } from './render.js';
9
+ import { resolveGptProfile, DEFAULT_GPT_STRIP_COLS, } from './gpt-model-profiles.js';
10
+ import { bytesToBase64 } from './png.js';
11
+ import { compactSlabWhitespace, estimateImageCount, sha8, } from './transform.js';
12
+ import { stripSchemaDescriptions } from './schema-strip.js';
13
+ import { planGptCollapse, responsesItemsToTurns, chatMessagesToTurns, } from './openai-history.js';
14
+ import { HISTORY_SYNTHETIC_INTRO, HISTORY_SYNTHETIC_OUTRO } from './history.js';
15
+ import { factSheetText } from './factsheet.js';
16
+ import { countTokens as o200kCountTokens } from 'gpt-tokenizer/encoding/o200k_base';
17
+ export function resolveVisionCost(model) {
18
+ return resolveGptProfile(model).vision;
19
+ }
20
+ // Sharp framing around the collapsed-history image. The transcript flattens BOTH
21
+ // roles into one role:'user' message (the API forbids images inside role:'assistant'),
22
+ // so this text must (a) tell the model to attribute strictly by the <user>/<assistant>
23
+ // tags rendered inside the image and (b) make explicit this is PAST context, not the
24
+ // live request — otherwise the model summarizes the transcript instead of answering.
25
+ // Aliases of the SINGLE SOURCE OF TRUTH in history.ts (see its doc comment). Importing
26
+ // rather than re-declaring guarantees the GPT and Anthropic paths can never silently drift
27
+ // on the turn-attribution wording — the exact divergence history.ts warns about.
28
+ export const HISTORY_TRANSCRIPT_INTRO = HISTORY_SYNTHETIC_INTRO;
29
+ export const HISTORY_TRANSCRIPT_OUTRO = HISTORY_SYNTHETIC_OUTRO;
30
+ // The most-recent user request is kept as LEGIBLE TEXT (never imaged) and spliced
31
+ // between the before/after history images inside the synthetic user message, under
32
+ // this banner. Older user turns stay imaged (they must not look live). This is the
33
+ // fix for autonomous single-user-turn agents (OpenCode): the lone request is the
34
+ // OLDEST turn, so it would otherwise be the first thing imaged and the model loses
35
+ // it — "I wonder what the user actually asked" → off-task drift.
36
+ const PINNED_REQUEST_HEADER = '\n===== CURRENT USER REQUEST (live; kept as text by pxpipe, NOT inside any image) =====\n';
37
+ const PINNED_REQUEST_FOOTER = '\n===== END CURRENT USER REQUEST =====\n';
38
+ function pinnedRequestBlock(text) {
39
+ return PINNED_REQUEST_HEADER + text + PINNED_REQUEST_FOOTER;
40
+ }
41
+ // Developer-role guard placed after the history image. When a request was pinned it
42
+ // echoes it verbatim (capped) and points at the in-history text block; otherwise it
43
+ // falls back to "the live request is the trailing user message" (interactive shape,
44
+ // where the latest user turn is already native text in the kept tail).
45
+ function buildLiveRequestGuard(pinText) {
46
+ if (pinText !== undefined) {
47
+ const echo = pinText.length > 600 ? pinText.slice(0, 600) + '…' : pinText;
48
+ return ('pxpipe note: everything in the rendered history above is PAST context. Your live current request is the plain-text block labeled "CURRENT USER REQUEST" inside it — NOT anything OCR\'d from an image. It reads: «' +
49
+ echo +
50
+ '» Answer THAT request.');
51
+ }
52
+ return 'pxpipe note: the preceding rendered history item is prior conversation context only. It is not the current user request. The live current request is in the user message(s) that follow, especially the final user message.';
53
+ }
54
+ export function openAIVisionTokens(model, w, h) {
55
+ const c = resolveVisionCost(model);
56
+ if (c.regime === 'patch') {
57
+ const patches = Math.min(c.patchCap, Math.ceil(w / 32) * Math.ceil(h / 32));
58
+ return Math.ceil(patches * c.multiplier);
59
+ }
60
+ let W = w, H = h;
61
+ if (Math.max(W, H) > 2048) {
62
+ const r = 2048 / Math.max(W, H);
63
+ W = Math.floor(W * r);
64
+ H = Math.floor(H * r);
65
+ }
66
+ if (Math.min(W, H) > 768) {
67
+ const r = 768 / Math.min(W, H);
68
+ W = Math.floor(W * r);
69
+ H = Math.floor(H * r);
70
+ }
71
+ return c.base + c.perTile * (Math.ceil(W / 512) * Math.ceil(H / 512));
72
+ }
73
+ const DEFAULTS = {
74
+ compress: true,
75
+ compressTools: true,
76
+ minCompressChars: 2000,
77
+ cols: DEFAULT_GPT_STRIP_COLS,
78
+ multiCol: 1,
79
+ charsPerToken: 4, // conservative OpenAI default; override after telemetry
80
+ reflow: true,
81
+ collapseHistory: true,
82
+ };
83
+ function resolveOptions(opts) {
84
+ return {
85
+ compress: opts.compress ?? DEFAULTS.compress,
86
+ compressTools: opts.compressTools ?? DEFAULTS.compressTools,
87
+ minCompressChars: opts.minCompressChars ?? DEFAULTS.minCompressChars,
88
+ cols: opts.cols ?? DEFAULTS.cols,
89
+ multiCol: opts.multiCol ?? DEFAULTS.multiCol,
90
+ charsPerToken: opts.charsPerToken ?? DEFAULTS.charsPerToken,
91
+ reflow: opts.reflow ?? DEFAULTS.reflow,
92
+ collapseHistory: opts.collapseHistory ?? DEFAULTS.collapseHistory,
93
+ gptHistory: opts.gptHistory,
94
+ };
95
+ }
96
+ function emptyInfo(reason) {
97
+ return {
98
+ compressed: false,
99
+ reason,
100
+ origChars: 0,
101
+ compressedChars: 0,
102
+ imageCount: 0,
103
+ imageBytes: 0,
104
+ staticChars: 0,
105
+ dynamicChars: 0,
106
+ dynamicBlockCount: 0,
107
+ droppedChars: 0,
108
+ };
109
+ }
110
+ function maybeReflow(text, enabled) {
111
+ if (!enabled)
112
+ return text;
113
+ return reflow(text) ?? text;
114
+ }
115
+ function isTextPart(part) {
116
+ return (typeof part === 'object'
117
+ && part !== null
118
+ && part.type === 'text'
119
+ && typeof part.text === 'string');
120
+ }
121
+ function contentText(content) {
122
+ if (typeof content === 'string')
123
+ return content;
124
+ if (!Array.isArray(content))
125
+ return '';
126
+ return content
127
+ .filter(isTextPart)
128
+ .map((p) => p.text)
129
+ .join('\n\n');
130
+ }
131
+ function setTextContent(msg, text) {
132
+ if (Array.isArray(msg.content)) {
133
+ const kept = msg.content.filter((p) => !isTextPart(p));
134
+ msg.content = [{ type: 'text', text }, ...kept];
135
+ }
136
+ else {
137
+ msg.content = text;
138
+ }
139
+ }
140
+ function firstUserText(req) {
141
+ for (const msg of req.messages) {
142
+ if (msg.role === 'user')
143
+ return contentText(msg.content).slice(0, 4096);
144
+ }
145
+ return '';
146
+ }
147
+ function responsesContentText(content) {
148
+ if (typeof content === 'string')
149
+ return content;
150
+ if (!Array.isArray(content))
151
+ return '';
152
+ return content
153
+ .filter((p) => typeof p === 'object'
154
+ && p !== null
155
+ && p.type === 'input_text'
156
+ && typeof p.text === 'string')
157
+ .map((p) => p.text)
158
+ .join('\n\n');
159
+ }
160
+ function firstResponsesUserText(inputWasString, originalInput, inputItems) {
161
+ if (inputWasString)
162
+ return (originalInput ?? '').slice(0, 4096);
163
+ for (const item of inputItems) {
164
+ if (item.role !== 'user')
165
+ continue;
166
+ return responsesContentText(item.content).slice(0, 4096);
167
+ }
168
+ return '';
169
+ }
170
+ function isFunctionTool(tool) {
171
+ return (typeof tool === 'object'
172
+ && tool !== null
173
+ && tool.type === 'function'
174
+ && typeof tool.function === 'object'
175
+ && tool.function !== null);
176
+ }
177
+ function isFlatFunctionTool(tool) {
178
+ return (typeof tool === 'object'
179
+ && tool !== null
180
+ && tool.type === 'function'
181
+ && typeof tool.name === 'string');
182
+ }
183
+ /** Full doc (prose + compact schema JSON) for one tool. On this path the docs are
184
+ * IMAGED, so carrying the schema here is compression, not duplication: the imaged
185
+ * copy keeps param docs readable while tools[] ships the stripped skeleton.
186
+ * (Contrast transform.ts renderToolDoc: text reference → prose only.) */
187
+ function renderToolDoc(tool) {
188
+ const f = tool.function;
189
+ const parts = [`## Tool: ${f.name ?? '?'}`];
190
+ if (typeof f.description === 'string' && f.description.length > 0)
191
+ parts.push(f.description);
192
+ if (f.parameters !== undefined) {
193
+ parts.push('```json\n' + JSON.stringify(f.parameters) + '\n```');
194
+ }
195
+ return parts.join('\n');
196
+ }
197
+ function renderFlatToolDoc(tool) {
198
+ const parts = [`## Tool: ${tool.name ?? '?'}`];
199
+ if (typeof tool.description === 'string' && tool.description.length > 0)
200
+ parts.push(tool.description);
201
+ if (tool.parameters !== undefined) {
202
+ parts.push('```json\n' + JSON.stringify(tool.parameters) + '\n```');
203
+ }
204
+ return parts.join('\n');
205
+ }
206
+ function rewriteToolsForGpt(tools) {
207
+ if (!Array.isArray(tools) || tools.length === 0)
208
+ return { tools, docs: '' };
209
+ const docs = [];
210
+ let changed = false;
211
+ const rewritten = tools.map((tool) => {
212
+ if (!isFunctionTool(tool))
213
+ return tool;
214
+ docs.push(renderToolDoc(tool));
215
+ if (tool.function.parameters === undefined)
216
+ return tool;
217
+ changed = true;
218
+ return {
219
+ ...tool,
220
+ function: {
221
+ ...tool.function,
222
+ parameters: stripSchemaDescriptions(tool.function.parameters),
223
+ },
224
+ };
225
+ });
226
+ return { tools: changed ? rewritten : tools, docs: docs.join('\n\n') };
227
+ }
228
+ function rewriteFlatToolsForGpt(tools) {
229
+ if (!Array.isArray(tools) || tools.length === 0)
230
+ return { tools, docs: '' };
231
+ const docs = [];
232
+ let changed = false;
233
+ const rewritten = tools.map((tool) => {
234
+ if (!isFlatFunctionTool(tool))
235
+ return tool;
236
+ docs.push(renderFlatToolDoc(tool));
237
+ if (tool.parameters === undefined)
238
+ return tool;
239
+ changed = true;
240
+ return {
241
+ ...tool,
242
+ parameters: stripSchemaDescriptions(tool.parameters),
243
+ };
244
+ });
245
+ return { tools: changed ? rewritten : tools, docs: docs.join('\n\n') };
246
+ }
247
+ function openAIImagePart(img) {
248
+ return {
249
+ type: 'image_url',
250
+ image_url: {
251
+ url: `data:image/png;base64,${bytesToBase64(img.png)}`,
252
+ detail: 'original', // gpt-5.x: 'original' = 10k-patch/6000px budget; 'high' (2.5k/2048px) downscales dense text
253
+ },
254
+ };
255
+ }
256
+ /** Build a Responses API input_image part. */
257
+ function responsesImagePart(img) {
258
+ return {
259
+ type: 'input_image',
260
+ image_url: `data:image/png;base64,${bytesToBase64(img.png)}`,
261
+ detail: 'original', // see openAIImagePart: avoid 'high' downscale of dense text
262
+ };
263
+ }
264
+ function countOutgoingTextChars(req) {
265
+ let n = 0;
266
+ for (const msg of req.messages)
267
+ n += contentText(msg.content).length;
268
+ if (Array.isArray(req.tools)) {
269
+ for (const tool of req.tools) {
270
+ if (!isFunctionTool(tool))
271
+ continue;
272
+ const f = tool.function;
273
+ if (typeof f.name === 'string')
274
+ n += f.name.length;
275
+ if (typeof f.description === 'string')
276
+ n += f.description.length;
277
+ if (f.parameters !== undefined)
278
+ n += safeStringifyLen(f.parameters);
279
+ }
280
+ }
281
+ return n;
282
+ }
283
+ /** Outgoing text-char denominator for the GPT Responses regression, mirroring
284
+ * countOutgoingTextChars for Chat: instructions + message-item text (string or
285
+ * input_text parts) + flat tool name/description/parameters. input_image base64
286
+ * is excluded on purpose — it is image cost, not text (responsesContentText
287
+ * already drops non-text parts). */
288
+ function countResponsesOutgoingTextChars(req) {
289
+ let n = 0;
290
+ if (typeof req.instructions === 'string')
291
+ n += req.instructions.length;
292
+ if (typeof req.input === 'string') {
293
+ n += req.input.length;
294
+ }
295
+ else if (Array.isArray(req.input)) {
296
+ for (const item of req.input) {
297
+ n += responsesContentText(item.content).length;
298
+ }
299
+ }
300
+ if (Array.isArray(req.tools)) {
301
+ for (const tool of req.tools) {
302
+ if (!isFlatFunctionTool(tool))
303
+ continue;
304
+ if (typeof tool.name === 'string')
305
+ n += tool.name.length;
306
+ if (typeof tool.description === 'string')
307
+ n += tool.description.length;
308
+ if (tool.parameters !== undefined)
309
+ n += safeStringifyLen(tool.parameters);
310
+ }
311
+ }
312
+ return n;
313
+ }
314
+ function safeStringifyLen(v) {
315
+ try {
316
+ return JSON.stringify(v)?.length ?? 0;
317
+ }
318
+ catch {
319
+ return 0;
320
+ }
321
+ }
322
+ function droppedCodepointsTop(droppedCodepoints) {
323
+ if (droppedCodepoints.size === 0)
324
+ return undefined;
325
+ const out = {};
326
+ for (const [cp, count] of [...droppedCodepoints.entries()]
327
+ .sort((a, b) => b[1] - a[1])
328
+ .slice(0, 20)) {
329
+ out[`U+${cp.toString(16).toUpperCase().padStart(4, '0')}`] = count;
330
+ }
331
+ return out;
332
+ }
333
+ /** Shared gate: compute image vs text token cost and decide profitability. */
334
+ function evalOpenAIGate(model, renderedText, cols, charsPerToken) {
335
+ const stripW = 2 * PAD_X + cols * CELL_W;
336
+ const estImages = estimateImageCount(renderedText, cols, 1);
337
+ const perStrip = openAIVisionTokens(model, stripW, resolveGptProfile(model).maxHeightPx);
338
+ const imageTokens = estImages * perStrip;
339
+ const textTokens = renderedText.length / charsPerToken;
340
+ return { imageTokens, textTokens, profitable: imageTokens < textTokens };
341
+ }
342
+ /** Shared image-part accumulation from rendered PNGs. */
343
+ function accumulateRenderedImages(images, info) {
344
+ const droppedCodepoints = new Map();
345
+ for (const img of images) {
346
+ info.imageBytes += img.png.length;
347
+ info.imagePixels = (info.imagePixels ?? 0) + img.width * img.height;
348
+ info.droppedChars = (info.droppedChars ?? 0) + img.droppedChars;
349
+ for (const [cp, count] of img.droppedCodepoints) {
350
+ droppedCodepoints.set(cp, (droppedCodepoints.get(cp) ?? 0) + count);
351
+ }
352
+ }
353
+ return { droppedCodepoints };
354
+ }
355
+ /** o200k_base token count — gpt-5 / gpt-4o / o-series share this encoding. The
356
+ * honest "as plain text" baseline for the content pxpipe imaged. Pure JS, no
357
+ * native build, runs in both Node and Workers. */
358
+ function gptTextTokens(text) {
359
+ if (!text)
360
+ return 0;
361
+ try {
362
+ return o200kCountTokens(text);
363
+ }
364
+ catch {
365
+ return 0;
366
+ }
367
+ }
368
+ /** Vision-token cost of the rendered images, summed over their real dims —
369
+ * what GPT actually bills as input for the slab pxpipe imaged. */
370
+ function gptImageTokens(model, images) {
371
+ let n = 0;
372
+ for (const img of images)
373
+ n += openAIVisionTokens(model, img.width, img.height);
374
+ return n;
375
+ }
376
+ /** Text-token value of what pxpipe replaced with images this request: the
377
+ * original system/developer text (now a pointer + image) plus the tool
378
+ * *description* tokens stripped from the native JSON (the verbose docs moved
379
+ * into the image). Tool *structure* stays in the JSON on both paths, so only
380
+ * the stripped delta counts. Compared against gptImageTokens for the saving. */
381
+ function gptBaselineImagedTokens(systemTexts, originalTools, strippedTools) {
382
+ let n = 0;
383
+ for (const t of systemTexts)
384
+ n += gptTextTokens(t);
385
+ const orig = Array.isArray(originalTools) && originalTools.length > 0
386
+ ? gptTextTokens(JSON.stringify(originalTools))
387
+ : 0;
388
+ const stripped = Array.isArray(strippedTools) && strippedTools.length > 0
389
+ ? gptTextTokens(JSON.stringify(strippedTools))
390
+ : 0;
391
+ return n + Math.max(0, orig - stripped);
392
+ }
393
+ /** Fold a history-collapse plan into TransformInfo: the history images cost
394
+ * vision tokens (added to imageTokens) and stand in for the o200k text tokens
395
+ * the collapsed transcript would have cost unproxied (added to baselineImagedTokens).
396
+ * openai-savings.ts then credits (baseline − image) × cache-weight with no
397
+ * further change. Also merges image bytes/pixels/dropped + collapse telemetry. */
398
+ function foldGptHistory(info, model, plan) {
399
+ // A pin can split the collapse into before/after image groups — account for both.
400
+ const allImages = [...plan.images, ...plan.imagesAfter];
401
+ if (allImages.length === 0) {
402
+ if (plan.reason)
403
+ info.historyReason = plan.reason;
404
+ if (plan.collapsedChars > 0)
405
+ info.historyTextChars = plan.collapsedChars;
406
+ return;
407
+ }
408
+ info.imageTokens = (info.imageTokens ?? 0) + gptImageTokens(model, allImages);
409
+ // o200k token value of the collapsed transcript (what it cost as plain text).
410
+ info.baselineImagedTokens = (info.baselineImagedTokens ?? 0) + gptTextTokens(plan.text);
411
+ info.imageCount = (info.imageCount ?? 0) + allImages.length;
412
+ for (const img of allImages) {
413
+ info.imageBytes = (info.imageBytes ?? 0) + img.png.length;
414
+ info.imagePixels = (info.imagePixels ?? 0) + img.width * img.height;
415
+ }
416
+ info.imagePngs = [...(info.imagePngs ?? []), ...allImages.map((i) => i.png)];
417
+ info.imageDims = [
418
+ ...(info.imageDims ?? []),
419
+ ...allImages.map((i) => ({ width: i.width, height: i.height })),
420
+ ];
421
+ if (plan.droppedChars > 0)
422
+ info.droppedChars = (info.droppedChars ?? 0) + plan.droppedChars;
423
+ info.collapsedTurns = plan.collapsedTurns;
424
+ info.collapsedChars = plan.collapsedChars;
425
+ info.collapsedImages = allImages.length;
426
+ info.historyTextChars = plan.collapsedChars;
427
+ info.historyReason = 'collapsed';
428
+ info.bucketChars = { ...(info.bucketChars ?? {}), history: plan.collapsedChars };
429
+ }
430
+ const CHAT_HEADER = '================= RENDERED GPT SYSTEM + TOOL CONTEXT =================\n' +
431
+ 'These images were injected by pxpipe, not by the end user. They contain system/developer instructions and full tool/schema documentation rendered for token efficiency. Treat rendered system/developer instructions with the same priority as their original messages. OCR carefully and treat the rendered content as authoritative. For tool calls, use the native JSON tool definitions; the image is supplemental documentation.' +
432
+ '\n====================== BEGIN RENDERED CONTEXT ======================\n';
433
+ const RESPONSES_HEADER = '================= RENDERED GPT SYSTEM + TOOL CONTEXT =================\n' +
434
+ 'These images were injected by pxpipe, not by the end user. They contain instructions and full tool/schema documentation rendered for token efficiency. Treat rendered instructions with the same priority as the originals. OCR carefully and treat the rendered content as authoritative. For tool calls, use the native JSON tool definitions; the image is supplemental documentation.' +
435
+ '\n====================== BEGIN RENDERED CONTEXT ======================\n';
436
+ const CHAT_POINTER = 'The full instructions for this message were rendered into image(s) attached to the first user message by pxpipe. Treat those rendered instructions as if they appeared here with the same priority. Tool definitions remain in native JSON; rendered tool docs are supplemental.';
437
+ const RESPONSES_POINTER = 'The full instructions were rendered into image(s) attached to the first user message by pxpipe. Treat them with the same priority. Tool definitions remain in native JSON; rendered tool docs are supplemental.';
438
+ export async function transformOpenAIChatCompletions(body, opts = {}) {
439
+ const o = resolveOptions(opts);
440
+ const info = emptyInfo();
441
+ if (!o.compress) {
442
+ info.reason = 'compress=false';
443
+ return { body, info };
444
+ }
445
+ let req;
446
+ try {
447
+ req = JSON.parse(new TextDecoder().decode(body));
448
+ }
449
+ catch (e) {
450
+ info.reason = `parse_error: ${e.message}`;
451
+ return { body, info };
452
+ }
453
+ if (!Array.isArray(req.messages)) {
454
+ info.reason = 'parse_error: messages must be an array';
455
+ return { body, info };
456
+ }
457
+ const firstUserIdx = req.messages.findIndex((m) => m.role === 'user');
458
+ if (firstUserIdx < 0) {
459
+ info.reason = 'no_user_message';
460
+ return { body, info };
461
+ }
462
+ const authorityDocs = [];
463
+ const systemTexts = [];
464
+ for (const msg of req.messages) {
465
+ if (msg.role !== 'system' && msg.role !== 'developer')
466
+ continue;
467
+ const text = contentText(msg.content);
468
+ if (!text)
469
+ continue;
470
+ authorityDocs.push(`## ${String(msg.role).toUpperCase()} MESSAGE\n${text}`);
471
+ systemTexts.push(text);
472
+ info.staticChars += text.length;
473
+ }
474
+ const { tools: rewrittenTools, docs: toolDocs } = o.compressTools
475
+ ? rewriteToolsForGpt(req.tools)
476
+ : { tools: req.tools, docs: '' };
477
+ const combinedRaw = [...authorityDocs, toolDocs].filter((s) => s.length > 0).join('\n\n');
478
+ info.origChars = combinedRaw.length;
479
+ if (!combinedRaw) {
480
+ info.reason = 'no_static_context';
481
+ return { body, info };
482
+ }
483
+ const firstUser = firstUserText(req);
484
+ if (firstUser)
485
+ info.firstUserSha8 = await sha8(firstUser);
486
+ const combined = maybeReflow(compactSlabWhitespace(combinedRaw), o.reflow);
487
+ if (combined.length < o.minCompressChars) {
488
+ info.reason = `below_min_chars (${combined.length} < ${o.minCompressChars})`;
489
+ return { body, info };
490
+ }
491
+ // Portrait strip only — multi-col would exceed 768px → downscale.
492
+ const numCols = 1;
493
+ const reflowNote = o.reflow
494
+ ? ' The glyph ↵ (U+21B5) marks an original hard line break in content; treat it as a real newline.'
495
+ : '';
496
+ const header = CHAT_HEADER.replace('\n====', reflowNote + '\n====');
497
+ const renderedText = header + combined;
498
+ const cols = Math.min(shrinkColsToContent(renderedText, o.cols), resolveGptProfile(req.model).stripCols);
499
+ const gate = evalOpenAIGate(req.model, renderedText, cols, o.charsPerToken);
500
+ info.gateEval = {
501
+ site: 'slab',
502
+ imageTokens: gate.imageTokens,
503
+ textTokens: gate.textTokens,
504
+ burnImageSide: 0,
505
+ burnTextSide: 0,
506
+ profitable: gate.profitable,
507
+ };
508
+ if (!gate.profitable) {
509
+ info.reason = `not_profitable (slab=${combined.length} chars)`;
510
+ info.passthroughReasons = { not_profitable: 1 };
511
+ return { body, info };
512
+ }
513
+ const images = await renderTextToPngs(renderedText, cols, {}, resolveGptProfile(req.model).maxHeightPx);
514
+ if (images.length === 0) {
515
+ info.reason = 'render_empty';
516
+ return { body, info };
517
+ }
518
+ const { droppedCodepoints } = accumulateRenderedImages(images, info);
519
+ const topDropped = droppedCodepointsTop(droppedCodepoints);
520
+ if (topDropped)
521
+ info.droppedCodepointsTop = topDropped;
522
+ const imageParts = images.map(openAIImagePart);
523
+ info.imageCount = images.length;
524
+ // GPT savings basis: vision tokens the images actually cost vs the text tokens
525
+ // the same content would have cost unproxied. req.tools is still the original
526
+ // (reassigned to the stripped set below). See src/core/openai-savings.ts.
527
+ info.imageTokens = gptImageTokens(req.model, images);
528
+ info.baselineImagedTokens = gptBaselineImagedTokens(systemTexts, req.tools, rewrittenTools);
529
+ info.compressedChars = combinedRaw.length;
530
+ info.bucketChars = { static_slab: combinedRaw.length };
531
+ info.systemSha8 = await sha8(combined);
532
+ info.firstImagePng = images[0].png;
533
+ info.firstImageWidth = images[0].width;
534
+ info.firstImageHeight = images[0].height;
535
+ info.imagePngs = images.map((img) => img.png);
536
+ info.imageDims = images.map((img) => ({ width: img.width, height: img.height }));
537
+ // Verbatim fact-sheet: precision-critical tokens (paths, ids, versions, flags)
538
+ // pulled from the pre-image text so exact strings survive OCR loss. Deterministic
539
+ // → stays inside the cached prefix. See src/core/factsheet.ts.
540
+ const slabFactSheet = factSheetText(combinedRaw);
541
+ const slabUserMsg = {
542
+ role: 'user',
543
+ content: [
544
+ ...imageParts,
545
+ ...(slabFactSheet ? [{ type: 'text', text: slabFactSheet }] : []),
546
+ { type: 'text', text: '[End of rendered GPT system/tool context.]' },
547
+ ],
548
+ };
549
+ req.messages = [
550
+ ...req.messages.slice(0, firstUserIdx),
551
+ slabUserMsg,
552
+ ...req.messages.slice(firstUserIdx),
553
+ ];
554
+ for (const msg of req.messages) {
555
+ if (msg.role !== 'system' && msg.role !== 'developer')
556
+ continue;
557
+ if (!contentText(msg.content))
558
+ continue;
559
+ setTextContent(msg, CHAT_POINTER);
560
+ }
561
+ // Collapse the OLD conversation prefix into history image(s). The inserted slab
562
+ // item carries static images and is protected; the original opening user prompt
563
+ // remains collapsible history instead of looking like the live request.
564
+ if (o.collapseHistory) {
565
+ const turns = chatMessagesToTurns(req.messages);
566
+ const profitable = (text, cols) => evalOpenAIGate(req.model, text, cols, o.charsPerToken).profitable;
567
+ const plan = await planGptCollapse(turns, firstUserIdx + 1, profitable, {
568
+ ...o.gptHistory,
569
+ reflow: o.reflow,
570
+ cols: o.gptHistory?.cols ?? resolveGptProfile(req.model).stripCols,
571
+ maxHeightPx: o.gptHistory?.maxHeightPx ?? resolveGptProfile(req.model).maxHeightPx,
572
+ });
573
+ foldGptHistory(info, req.model, plan);
574
+ const allImages = [...plan.images, ...plan.imagesAfter];
575
+ if (allImages.length > 0) {
576
+ // [intro][before-images][pinned request as TEXT][after-images][outro] —
577
+ // chronological, with the live ask legible (not OCR-only) in its real slot.
578
+ const content = [{ type: 'text', text: HISTORY_TRANSCRIPT_INTRO }];
579
+ for (const img of plan.images)
580
+ content.push(openAIImagePart(img));
581
+ if (plan.pinText !== undefined) {
582
+ content.push({ type: 'text', text: pinnedRequestBlock(plan.pinText) });
583
+ for (const img of plan.imagesAfter)
584
+ content.push(openAIImagePart(img));
585
+ }
586
+ // Verbatim fact-sheet for the imaged transcript (exact ids survive OCR loss).
587
+ const histFactSheet = factSheetText(plan.text);
588
+ if (histFactSheet)
589
+ content.push({ type: 'text', text: histFactSheet });
590
+ content.push({ type: 'text', text: HISTORY_TRANSCRIPT_OUTRO });
591
+ const synthetic = { role: 'user', content };
592
+ const guard = {
593
+ role: 'developer',
594
+ content: buildLiveRequestGuard(plan.pinText),
595
+ };
596
+ req.messages = [
597
+ ...req.messages.slice(0, plan.start),
598
+ synthetic,
599
+ guard,
600
+ ...req.messages.slice(plan.endExclusive),
601
+ ];
602
+ info.historyImageSha = await sha8(allImages.map((i) => bytesToBase64(i.png)).join(''));
603
+ }
604
+ }
605
+ if (rewrittenTools !== undefined)
606
+ req.tools = rewrittenTools;
607
+ info.outgoingTextChars = countOutgoingTextChars(req);
608
+ info.compressed = true;
609
+ return { body: new TextEncoder().encode(JSON.stringify(req)), info };
610
+ }
611
+ export async function transformOpenAIResponses(body, opts = {}) {
612
+ const o = resolveOptions(opts);
613
+ const info = emptyInfo();
614
+ if (!o.compress) {
615
+ info.reason = 'compress=false';
616
+ return { body, info };
617
+ }
618
+ let req;
619
+ try {
620
+ req = JSON.parse(new TextDecoder().decode(body));
621
+ }
622
+ catch (e) {
623
+ info.reason = `parse_error: ${e.message}`;
624
+ return { body, info };
625
+ }
626
+ // Normalize input to an array; preserve original string for wrap-back if needed.
627
+ const inputWasString = typeof req.input === 'string';
628
+ const originalInputString = inputWasString ? req.input : undefined;
629
+ let inputItems;
630
+ if (inputWasString) {
631
+ inputItems = [];
632
+ }
633
+ else if (Array.isArray(req.input)) {
634
+ inputItems = req.input;
635
+ }
636
+ else {
637
+ info.reason = 'parse_error: input must be a string or array';
638
+ return { body, info };
639
+ }
640
+ // Find first user item index (skip non-message items like function_call_output, reasoning).
641
+ const firstUserIdx = inputItems.findIndex((item) => typeof item.role === 'string' &&
642
+ item.role === 'user');
643
+ if (!inputWasString && firstUserIdx < 0) {
644
+ info.reason = 'no_user_message';
645
+ return { body, info };
646
+ }
647
+ // Collect static context: instructions + system/developer items + flat tools.
648
+ const authorityDocs = [];
649
+ const systemTexts = [];
650
+ if (typeof req.instructions === 'string' && req.instructions.length > 0) {
651
+ authorityDocs.push(`## INSTRUCTIONS\n${req.instructions}`);
652
+ systemTexts.push(req.instructions);
653
+ info.staticChars += req.instructions.length;
654
+ }
655
+ for (const item of inputItems) {
656
+ const r = item.role;
657
+ if (r !== 'system' && r !== 'developer')
658
+ continue;
659
+ const content = item.content;
660
+ // content may be a string OR an array of input_text parts (both are valid
661
+ // Responses shapes for system/developer items) — read either form.
662
+ const text = responsesContentText(content);
663
+ if (!text)
664
+ continue;
665
+ authorityDocs.push(`## ${String(r).toUpperCase()} MESSAGE\n${text}`);
666
+ systemTexts.push(text);
667
+ info.staticChars += text.length;
668
+ }
669
+ const { tools: rewrittenTools, docs: toolDocs } = o.compressTools
670
+ ? rewriteFlatToolsForGpt(req.tools)
671
+ : { tools: req.tools, docs: '' };
672
+ const combinedRaw = [...authorityDocs, toolDocs].filter((s) => s.length > 0).join('\n\n');
673
+ info.origChars = combinedRaw.length;
674
+ if (!combinedRaw) {
675
+ info.reason = 'no_static_context';
676
+ return { body, info };
677
+ }
678
+ const firstUser = firstResponsesUserText(inputWasString, originalInputString, inputItems);
679
+ if (firstUser)
680
+ info.firstUserSha8 = await sha8(firstUser);
681
+ const combined = maybeReflow(compactSlabWhitespace(combinedRaw), o.reflow);
682
+ if (combined.length < o.minCompressChars) {
683
+ info.reason = `below_min_chars (${combined.length} < ${o.minCompressChars})`;
684
+ return { body, info };
685
+ }
686
+ const reflowNote = o.reflow
687
+ ? ' The glyph ↵ (U+21B5) marks an original hard line break in content; treat it as a real newline.'
688
+ : '';
689
+ const header = RESPONSES_HEADER.replace('\n====', reflowNote + '\n====');
690
+ const renderedText = header + combined;
691
+ const cols = Math.min(shrinkColsToContent(renderedText, o.cols), resolveGptProfile(req.model).stripCols);
692
+ const gate = evalOpenAIGate(req.model, renderedText, cols, o.charsPerToken);
693
+ info.gateEval = {
694
+ site: 'slab',
695
+ imageTokens: gate.imageTokens,
696
+ textTokens: gate.textTokens,
697
+ burnImageSide: 0,
698
+ burnTextSide: 0,
699
+ profitable: gate.profitable,
700
+ };
701
+ if (!gate.profitable) {
702
+ info.reason = `not_profitable (slab=${combined.length} chars)`;
703
+ info.passthroughReasons = { not_profitable: 1 };
704
+ return { body, info };
705
+ }
706
+ const images = await renderTextToPngs(renderedText, cols, {}, resolveGptProfile(req.model).maxHeightPx);
707
+ if (images.length === 0) {
708
+ info.reason = 'render_empty';
709
+ return { body, info };
710
+ }
711
+ const { droppedCodepoints } = accumulateRenderedImages(images, info);
712
+ const topDropped = droppedCodepointsTop(droppedCodepoints);
713
+ if (topDropped)
714
+ info.droppedCodepointsTop = topDropped;
715
+ info.imageCount = images.length;
716
+ // GPT savings basis (see src/core/openai-savings.ts). req.tools is still the
717
+ // original here — reassigned to the stripped set below.
718
+ info.imageTokens = gptImageTokens(req.model, images);
719
+ info.baselineImagedTokens = gptBaselineImagedTokens(systemTexts, req.tools, rewrittenTools);
720
+ info.compressedChars = combinedRaw.length;
721
+ info.bucketChars = { static_slab: combinedRaw.length };
722
+ info.systemSha8 = await sha8(combined);
723
+ info.firstImagePng = images[0].png;
724
+ info.firstImageWidth = images[0].width;
725
+ info.firstImageHeight = images[0].height;
726
+ info.imagePngs = images.map((img) => img.png);
727
+ info.imageDims = images.map((img) => ({ width: img.width, height: img.height }));
728
+ const imagePartsResp = images.map(responsesImagePart);
729
+ const endMarker = { type: 'input_text', text: '[End of rendered GPT system/tool context.]' };
730
+ // Verbatim fact-sheet (see src/core/factsheet.ts): exact tokens that survive OCR loss.
731
+ const slabFactSheet = factSheetText(combinedRaw);
732
+ const slabFactSheetPart = slabFactSheet
733
+ ? [{ type: 'input_text', text: slabFactSheet }]
734
+ : [];
735
+ if (inputWasString) {
736
+ // Wrap bare string input into a user item with images prepended.
737
+ req.input = [{
738
+ role: 'user',
739
+ content: [
740
+ ...imagePartsResp,
741
+ ...slabFactSheetPart,
742
+ endMarker,
743
+ { type: 'input_text', text: originalInputString },
744
+ ],
745
+ }];
746
+ }
747
+ else {
748
+ // Insert a dedicated static-slab item. Do not attach it to the opening real
749
+ // user prompt: that prompt is old history on long stateless Responses calls,
750
+ // and protecting it made stale first-turn requests look live.
751
+ const slabUserItem = {
752
+ role: 'user',
753
+ content: [...imagePartsResp, ...slabFactSheetPart, endMarker],
754
+ };
755
+ inputItems = [
756
+ ...inputItems.slice(0, firstUserIdx),
757
+ slabUserItem,
758
+ ...inputItems.slice(firstUserIdx),
759
+ ];
760
+ req.input = inputItems;
761
+ }
762
+ // Replace instructions with pointer.
763
+ if (typeof req.instructions === 'string' && req.instructions.length > 0) {
764
+ req.instructions = RESPONSES_POINTER;
765
+ }
766
+ // Replace system/developer input items with a pointer. Mirror the collection
767
+ // gate above for BOTH content shapes: a string becomes the pointer string; an
768
+ // input_text part array keeps its array shape with a single pointer part, so a
769
+ // request the caller sent as parts is not silently reshaped into a string.
770
+ if (!inputWasString) {
771
+ for (const item of inputItems) {
772
+ const it = item;
773
+ if (it.role !== 'system' && it.role !== 'developer')
774
+ continue;
775
+ const content = it.content;
776
+ if (typeof content === 'string') {
777
+ if (content.length > 0)
778
+ it.content = RESPONSES_POINTER;
779
+ }
780
+ else if (Array.isArray(content) && responsesContentText(content).length > 0) {
781
+ it.content = [{ type: 'input_text', text: RESPONSES_POINTER }];
782
+ }
783
+ }
784
+ }
785
+ // Collapse the OLD conversation prefix into history image(s). The inserted slab
786
+ // item is protected; the transcript OpenCode resends every turn is the real cost.
787
+ // Skip for bare-string input (single message, nothing to collapse).
788
+ if (o.collapseHistory && !inputWasString) {
789
+ const turns = responsesItemsToTurns(inputItems);
790
+ const profitable = (text, cols) => evalOpenAIGate(req.model, text, cols, o.charsPerToken).profitable;
791
+ const plan = await planGptCollapse(turns, firstUserIdx + 1, profitable, {
792
+ ...o.gptHistory,
793
+ reflow: o.reflow,
794
+ cols: o.gptHistory?.cols ?? resolveGptProfile(req.model).stripCols,
795
+ maxHeightPx: o.gptHistory?.maxHeightPx ?? resolveGptProfile(req.model).maxHeightPx,
796
+ });
797
+ foldGptHistory(info, req.model, plan);
798
+ const allImages = [...plan.images, ...plan.imagesAfter];
799
+ if (allImages.length > 0) {
800
+ // [intro][before-images][pinned request as TEXT][after-images][outro] —
801
+ // chronological, with the live ask legible (not OCR-only) in its real slot.
802
+ const content = [
803
+ { type: 'input_text', text: HISTORY_TRANSCRIPT_INTRO },
804
+ ];
805
+ for (const img of plan.images)
806
+ content.push(responsesImagePart(img));
807
+ if (plan.pinText !== undefined) {
808
+ content.push({ type: 'input_text', text: pinnedRequestBlock(plan.pinText) });
809
+ for (const img of plan.imagesAfter)
810
+ content.push(responsesImagePart(img));
811
+ }
812
+ // Verbatim fact-sheet for the imaged transcript (exact ids survive OCR loss).
813
+ const histFactSheet = factSheetText(plan.text);
814
+ if (histFactSheet)
815
+ content.push({ type: 'input_text', text: histFactSheet });
816
+ content.push({ type: 'input_text', text: HISTORY_TRANSCRIPT_OUTRO });
817
+ const synthetic = { role: 'user', content };
818
+ const guard = {
819
+ role: 'developer',
820
+ content: buildLiveRequestGuard(plan.pinText),
821
+ };
822
+ req.input = [
823
+ ...inputItems.slice(0, plan.start),
824
+ synthetic,
825
+ guard,
826
+ ...inputItems.slice(plan.endExclusive),
827
+ ];
828
+ info.historyImageSha = await sha8(allImages.map((i) => bytesToBase64(i.png)).join(''));
829
+ }
830
+ }
831
+ if (rewrittenTools !== undefined)
832
+ req.tools = rewrittenTools;
833
+ // Regression denominator, same as the Chat path — Responses was the only
834
+ // transform that never recorded it.
835
+ info.outgoingTextChars = countResponsesOutgoingTextChars(req);
836
+ info.compressed = true;
837
+ return { body: new TextEncoder().encode(JSON.stringify(req)), info };
838
+ }
839
+ //# sourceMappingURL=openai.js.map