@wlv-zedd/dsh-chatgpt-web 1.0.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 (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/assets/demo.gif +0 -0
  4. package/assets/hero-demo.png +0 -0
  5. package/assets/promo-dshmarket-official.png +0 -0
  6. package/cordis.patch.yml +4 -0
  7. package/lib/cli.js +239642 -0
  8. package/lib/plugin.js +195 -0
  9. package/package.json +88 -0
  10. package/screenshots.json +5 -0
  11. package/src/adapters/base.ts +16 -0
  12. package/src/adapters/chatgpt-web/adapter-error.ts +59 -0
  13. package/src/adapters/chatgpt-web/browser-helper-main.ts +513 -0
  14. package/src/adapters/chatgpt-web/browser-helper-prompt-selection.ts +27 -0
  15. package/src/adapters/chatgpt-web/browser-worker.ts +4944 -0
  16. package/src/adapters/chatgpt-web/codex-rollout-environment.ts +628 -0
  17. package/src/adapters/chatgpt-web/compaction-handoff.ts +533 -0
  18. package/src/adapters/chatgpt-web/compaction-transaction.ts +142 -0
  19. package/src/adapters/chatgpt-web/concurrency.ts +6 -0
  20. package/src/adapters/chatgpt-web/conversation-key.ts +58 -0
  21. package/src/adapters/chatgpt-web/environment.ts +669 -0
  22. package/src/adapters/chatgpt-web/index.ts +1544 -0
  23. package/src/adapters/chatgpt-web/input-tokens.ts +74 -0
  24. package/src/adapters/chatgpt-web/launcher-helper-client.ts +695 -0
  25. package/src/adapters/chatgpt-web/markdown.ts +418 -0
  26. package/src/adapters/chatgpt-web/mcp-main.ts +25 -0
  27. package/src/adapters/chatgpt-web/mcp-server.ts +933 -0
  28. package/src/adapters/chatgpt-web/model.ts +70 -0
  29. package/src/adapters/chatgpt-web/native-compaction-control.ts +74 -0
  30. package/src/adapters/chatgpt-web/output-validation.ts +62 -0
  31. package/src/adapters/chatgpt-web/process-line-writer.ts +46 -0
  32. package/src/adapters/chatgpt-web/prompt.ts +702 -0
  33. package/src/adapters/chatgpt-web/retry-policy.ts +73 -0
  34. package/src/adapters/chatgpt-web/rolling-checkpoint.ts +384 -0
  35. package/src/adapters/chatgpt-web/thread-environment.ts +238 -0
  36. package/src/adapters/chatgpt-web/tool-stream-parser.ts +601 -0
  37. package/src/adapters/chatgpt-web/turn-broker.ts +1481 -0
  38. package/src/adapters/chatgpt-web/turn-execution.ts +816 -0
  39. package/src/adapters/chatgpt-web/turn-progress.ts +292 -0
  40. package/src/adapters/chatgpt-web/usage.ts +121 -0
  41. package/src/adapters/image.ts +9 -0
  42. package/src/bridge.ts +1083 -0
  43. package/src/browser-login.ts +521 -0
  44. package/src/chatgpt-session.ts +240 -0
  45. package/src/chatgpt-web-models.ts +400 -0
  46. package/src/cli.ts +568 -0
  47. package/src/codex-integration-document.ts +824 -0
  48. package/src/codex-integration-journal.ts +212 -0
  49. package/src/codex-integration-route.ts +515 -0
  50. package/src/codex-integration-shared.ts +332 -0
  51. package/src/codex-integration.ts +529 -0
  52. package/src/codex-interrupt-hook.ts +158 -0
  53. package/src/config.ts +616 -0
  54. package/src/dev-chat/cli.ts +432 -0
  55. package/src/dev-chat/constants.ts +3 -0
  56. package/src/dev-chat/driver.ts +655 -0
  57. package/src/dev-chat/profile.ts +223 -0
  58. package/src/dev-chat/session.ts +287 -0
  59. package/src/dev-chat/transport.ts +54 -0
  60. package/src/doctor.ts +237 -0
  61. package/src/event-queue.ts +45 -0
  62. package/src/http-body.ts +30 -0
  63. package/src/launcher-browser-host.ts +695 -0
  64. package/src/lib/errors.ts +281 -0
  65. package/src/lib/token-estimate.ts +42 -0
  66. package/src/login-helper.cjs +140 -0
  67. package/src/model-catalog.ts +197 -0
  68. package/src/native-passthrough.ts +261 -0
  69. package/src/plugin.ts +191 -0
  70. package/src/process.ts +45 -0
  71. package/src/responses/compaction.ts +199 -0
  72. package/src/responses/parser.ts +633 -0
  73. package/src/responses/reasoning-envelope.ts +49 -0
  74. package/src/responses/schema.ts +172 -0
  75. package/src/responses/state.ts +230 -0
  76. package/src/server.ts +1111 -0
  77. package/src/service.ts +315 -0
  78. package/src/setup.ts +671 -0
  79. package/src/stall-timeout.ts +23 -0
  80. package/src/tunnel-service.ts +160 -0
  81. package/src/tunnel.ts +417 -0
  82. package/src/turndown-plugin-gfm.d.ts +5 -0
  83. package/src/types.ts +307 -0
  84. package/src/usage/totals.ts +12 -0
  85. package/src/version.ts +1 -0
@@ -0,0 +1,292 @@
1
+ export interface ChatGptExternalTurnProgressSnapshot {
2
+ revision: number;
3
+ lastToolBatchRevision: number;
4
+ activeToolCalls: number;
5
+ lastProgressAt?: number;
6
+ }
7
+
8
+ interface ProgressWaiter {
9
+ afterRevision: number;
10
+ resolve: (snapshot: ChatGptExternalTurnProgressSnapshot) => void;
11
+ reject: (error: Error) => void;
12
+ signal?: AbortSignal;
13
+ onAbort?: () => void;
14
+ }
15
+
16
+ interface ToolBatchObservationWaiter {
17
+ revision: number;
18
+ resolve: () => void;
19
+ reject: (error: Error) => void;
20
+ signal?: AbortSignal;
21
+ onAbort?: () => void;
22
+ }
23
+
24
+ /**
25
+ * The read surface the browser worker depends on.
26
+ *
27
+ * The worker never records activity; it observes the daemon's progress and acknowledges only the
28
+ * pre-dispatch answer boundary it captured. Declaring the dependency as this interface lets the
29
+ * launcher helper process mirror the same causal contract without owning the recording side.
30
+ */
31
+ export interface ChatGptTurnProgressReader {
32
+ snapshot(): ChatGptExternalTurnProgressSnapshot;
33
+ waitForChange(afterRevision: number, signal?: AbortSignal): Promise<ChatGptExternalTurnProgressSnapshot>;
34
+ /** Confirm that the browser captured its answer projection before this batch was dispatched. */
35
+ acknowledgeToolBatch(revision: number): Promise<void>;
36
+ }
37
+
38
+ /**
39
+ * Carries only proven Codex MCP activity into the browser worker.
40
+ *
41
+ * It is deliberately not a completion channel: browser-visible text and terminal state remain
42
+ * owned by the ChatGPT DOM. A valid current-turn tool request only proves that submission was
43
+ * accepted and that the model is still making progress while its DOM is temporarily unavailable.
44
+ */
45
+ abstract class ChatGptTurnProgressBroadcaster implements ChatGptTurnProgressReader {
46
+ private readonly waiters = new Set<ProgressWaiter>();
47
+
48
+ abstract snapshot(): ChatGptExternalTurnProgressSnapshot;
49
+ abstract acknowledgeToolBatch(revision: number): Promise<void>;
50
+
51
+ waitForChange(afterRevision: number, signal?: AbortSignal): Promise<ChatGptExternalTurnProgressSnapshot> {
52
+ if (!Number.isSafeInteger(afterRevision) || afterRevision < 0) {
53
+ throw new Error("ChatGPT external progress revision must be a non-negative safe integer");
54
+ }
55
+ const current = this.snapshot();
56
+ if (current.revision > afterRevision) return Promise.resolve(current);
57
+ if (signal?.aborted) {
58
+ return Promise.reject(new DOMException("ChatGPT external progress wait aborted", "AbortError"));
59
+ }
60
+ return new Promise((resolve, reject) => {
61
+ const waiter: ProgressWaiter = { afterRevision, resolve, reject, ...(signal ? { signal } : {}) };
62
+ if (signal) {
63
+ waiter.onAbort = () => {
64
+ this.waiters.delete(waiter);
65
+ reject(new DOMException("ChatGPT external progress wait aborted", "AbortError"));
66
+ };
67
+ signal.addEventListener("abort", waiter.onAbort, { once: true });
68
+ }
69
+ this.waiters.add(waiter);
70
+ });
71
+ }
72
+
73
+ protected notify(snapshot: ChatGptExternalTurnProgressSnapshot): void {
74
+ for (const waiter of [...this.waiters]) {
75
+ if (snapshot.revision <= waiter.afterRevision) continue;
76
+ this.waiters.delete(waiter);
77
+ if (waiter.signal && waiter.onAbort) {
78
+ waiter.signal.removeEventListener("abort", waiter.onAbort);
79
+ }
80
+ waiter.resolve(snapshot);
81
+ }
82
+ }
83
+ }
84
+
85
+ export class ChatGptExternalTurnProgress extends ChatGptTurnProgressBroadcaster {
86
+ private revision = 0;
87
+ private lastToolBatchRevision = 0;
88
+ private observedToolBatchRevision = 0;
89
+ private activeToolCalls = 0;
90
+ private lastProgressAt?: number;
91
+ private retirementError?: Error;
92
+ private readonly toolBatchObservationWaiters = new Set<ToolBatchObservationWaiter>();
93
+
94
+ snapshot(): ChatGptExternalTurnProgressSnapshot {
95
+ return {
96
+ revision: this.revision,
97
+ lastToolBatchRevision: this.lastToolBatchRevision,
98
+ activeToolCalls: this.activeToolCalls,
99
+ ...(this.lastProgressAt !== undefined ? { lastProgressAt: this.lastProgressAt } : {}),
100
+ };
101
+ }
102
+
103
+ recordToolBatch(count: number, now = Date.now()): number {
104
+ this.assertNotRetired();
105
+ if (!Number.isSafeInteger(count) || count <= 0) {
106
+ throw new Error("ChatGPT external progress requires a non-empty tool batch");
107
+ }
108
+ this.activeToolCalls += count;
109
+ this.advance(now, "tool_batch");
110
+ return this.lastToolBatchRevision;
111
+ }
112
+
113
+ async acknowledgeToolBatch(revision: number): Promise<void> {
114
+ this.assertToolBatchRevision(revision);
115
+ this.assertNotRetired();
116
+ if (revision <= this.observedToolBatchRevision) return;
117
+ this.observedToolBatchRevision = revision;
118
+ for (const waiter of [...this.toolBatchObservationWaiters]) {
119
+ if (waiter.revision > revision) continue;
120
+ this.toolBatchObservationWaiters.delete(waiter);
121
+ if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener("abort", waiter.onAbort);
122
+ waiter.resolve();
123
+ }
124
+ }
125
+
126
+ waitForToolBatchObservation(revision: number, signal?: AbortSignal): Promise<void> {
127
+ this.assertToolBatchRevision(revision);
128
+ if (this.retirementError) return Promise.reject(this.retirementError);
129
+ if (this.observedToolBatchRevision >= revision) return Promise.resolve();
130
+ if (signal?.aborted) {
131
+ return Promise.reject(new DOMException("ChatGPT tool-boundary observation aborted", "AbortError"));
132
+ }
133
+ return new Promise((resolve, reject) => {
134
+ const waiter: ToolBatchObservationWaiter = { revision, resolve, reject, ...(signal ? { signal } : {}) };
135
+ if (signal) {
136
+ waiter.onAbort = () => {
137
+ this.toolBatchObservationWaiters.delete(waiter);
138
+ reject(new DOMException("ChatGPT tool-boundary observation aborted", "AbortError"));
139
+ };
140
+ signal.addEventListener("abort", waiter.onAbort, { once: true });
141
+ }
142
+ this.toolBatchObservationWaiters.add(waiter);
143
+ });
144
+ }
145
+
146
+ recordToolResult(now = Date.now()): void {
147
+ this.assertNotRetired();
148
+ if (this.activeToolCalls <= 0) {
149
+ throw new Error("ChatGPT external progress received a tool result without an active call");
150
+ }
151
+ this.activeToolCalls -= 1;
152
+ this.advance(now, "tool_result");
153
+ }
154
+
155
+ /** Retire every unresolved batch when the broker capability can no longer accept its result. */
156
+ retire(error: Error): boolean {
157
+ if (!(error instanceof Error)) throw new Error("ChatGPT external progress retirement requires an error");
158
+ if (this.retirementError) return false;
159
+ this.retirementError = error;
160
+ for (const waiter of this.toolBatchObservationWaiters) {
161
+ if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener("abort", waiter.onAbort);
162
+ waiter.reject(error);
163
+ }
164
+ this.toolBatchObservationWaiters.clear();
165
+ if (this.activeToolCalls === 0) return true;
166
+ this.activeToolCalls = 0;
167
+ // Retirement is not fresh model progress. Advance the transport revision so the browser mirror
168
+ // drops its completion veto, while preserving the timestamp of the last proven MCP activity.
169
+ this.revision += 1;
170
+ this.notify(this.snapshot());
171
+ return true;
172
+ }
173
+
174
+ assertToolBatchActive(revision: number): void {
175
+ this.assertToolBatchRevision(revision);
176
+ this.assertNotRetired();
177
+ }
178
+
179
+ private advance(now: number, event: "tool_batch" | "tool_result"): void {
180
+ if (!Number.isFinite(now)) throw new Error("ChatGPT external progress timestamp must be finite");
181
+ this.revision += 1;
182
+ if (event === "tool_batch") this.lastToolBatchRevision = this.revision;
183
+ this.lastProgressAt = now;
184
+ this.notify(this.snapshot());
185
+ }
186
+
187
+ private assertToolBatchRevision(revision: number): void {
188
+ if (!Number.isSafeInteger(revision)
189
+ || revision <= 0
190
+ || revision > this.lastToolBatchRevision) {
191
+ throw new Error("ChatGPT tool-boundary acknowledgement has an invalid batch revision");
192
+ }
193
+ }
194
+
195
+ private assertNotRetired(): void {
196
+ if (this.retirementError) throw this.retirementError;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Replays daemon-recorded progress inside the launcher browser helper process.
202
+ *
203
+ * The browser worker runs out of process from the Codex MCP broker, so the recording instance
204
+ * cannot be shared with it. Without a mirror the worker observes no progress at all and its
205
+ * liveness guards silently degrade to "never live", which lets a turn be cancelled while its tool
206
+ * calls are still completing.
207
+ */
208
+ export class ChatGptMirroredTurnProgress extends ChatGptTurnProgressBroadcaster {
209
+ private current: ChatGptExternalTurnProgressSnapshot = {
210
+ revision: 0,
211
+ lastToolBatchRevision: 0,
212
+ activeToolCalls: 0,
213
+ };
214
+ private observedToolBatchRevision = 0;
215
+
216
+ constructor(
217
+ private readonly onToolBatchObserved?: (revision: number) => Promise<void> | void,
218
+ ) {
219
+ super();
220
+ }
221
+
222
+ snapshot(): ChatGptExternalTurnProgressSnapshot {
223
+ return { ...this.current };
224
+ }
225
+
226
+ async acknowledgeToolBatch(revision: number): Promise<void> {
227
+ if (!Number.isSafeInteger(revision)
228
+ || revision <= 0
229
+ || revision > this.current.lastToolBatchRevision) {
230
+ throw new Error("ChatGPT mirrored tool-boundary acknowledgement has an invalid batch revision");
231
+ }
232
+ if (revision <= this.observedToolBatchRevision) return;
233
+ await this.onToolBatchObserved?.(revision);
234
+ this.observedToolBatchRevision = revision;
235
+ }
236
+
237
+ /** Ignores stale or replayed frames so out-of-order delivery cannot rewind observed liveness. */
238
+ apply(next: ChatGptExternalTurnProgressSnapshot): boolean {
239
+ assertChatGptTurnProgressSnapshot(next);
240
+ if (next.revision <= this.current.revision) return false;
241
+ // A frame that advances the revision must not contradict what it already reported: the
242
+ // recorder only ever moves these forward, so a regression means a corrupt or forged frame
243
+ // rather than an ordering artefact, and accepting it would desynchronise observed liveness.
244
+ if (next.lastToolBatchRevision < this.current.lastToolBatchRevision
245
+ || (next.lastProgressAt === undefined && this.current.lastProgressAt !== undefined)
246
+ || (next.lastProgressAt !== undefined
247
+ && this.current.lastProgressAt !== undefined
248
+ && next.lastProgressAt < this.current.lastProgressAt)) {
249
+ throw new Error("ChatGPT external progress snapshot regressed against the observed state");
250
+ }
251
+ this.current = { ...next };
252
+ this.notify(this.snapshot());
253
+ return true;
254
+ }
255
+ }
256
+
257
+ export function assertChatGptTurnProgressSnapshot(
258
+ value: ChatGptExternalTurnProgressSnapshot,
259
+ ): void {
260
+ const finiteIndex = (candidate: number): boolean => Number.isSafeInteger(candidate) && candidate >= 0;
261
+ if (!value
262
+ || !finiteIndex(value.revision)
263
+ || !finiteIndex(value.lastToolBatchRevision)
264
+ || !finiteIndex(value.activeToolCalls)
265
+ || value.lastToolBatchRevision > value.revision
266
+ || (value.lastProgressAt !== undefined && !Number.isFinite(value.lastProgressAt))
267
+ // Any recorded activity stamps a timestamp, so a frame claiming progress without one is
268
+ // malformed and would otherwise report liveness the daemon never observed.
269
+ || (value.revision > 0 && value.lastProgressAt === undefined)) {
270
+ throw new Error("ChatGPT external progress snapshot is invalid");
271
+ }
272
+ }
273
+
274
+ export function chatGptExternalProgressIsLive(
275
+ snapshot: ChatGptExternalTurnProgressSnapshot | undefined,
276
+ now: number,
277
+ graceMs: number,
278
+ ): boolean {
279
+ if (!snapshot) return false;
280
+ if (!Number.isFinite(now) || !Number.isFinite(graceMs) || graceMs < 0) {
281
+ throw new Error("ChatGPT external progress liveness inputs are invalid");
282
+ }
283
+ return snapshot.activeToolCalls > 0
284
+ || (snapshot.lastProgressAt !== undefined && now - snapshot.lastProgressAt < graceMs);
285
+ }
286
+
287
+ /** Only unresolved native tool calls veto browser-turn completion. */
288
+ export function chatGptExternalToolCallsAreInFlight(
289
+ snapshot: ChatGptExternalTurnProgressSnapshot | undefined,
290
+ ): boolean {
291
+ return (snapshot?.activeToolCalls ?? 0) > 0;
292
+ }
@@ -0,0 +1,121 @@
1
+ import { estimateTokens } from "../../lib/token-estimate";
2
+ import {
3
+ CHATGPT_WEB_BACKEND_MODEL,
4
+ isChatGptWebZeroRiskBackendModel,
5
+ resolveChatGptWebContextLimits,
6
+ } from "../../chatgpt-web-models";
7
+ import type { CodexParsedRequest, CodexUsage } from "../../types";
8
+ import { estimateCompiledChatGptWebInputTokens } from "./input-tokens";
9
+ import {
10
+ CHATGPT_BIGGER_CONTEXT_PARTS,
11
+ compileChatGptWebPrompt,
12
+ type ChatGptWebMultipartPartCount,
13
+ } from "./prompt";
14
+ import { extractChatGptTurnIdentity } from "./environment";
15
+ import { CHATGPT_WEB_LUNA_MODEL_ID, resolveChatGptWebModelMode, type ChatGptWebCapabilities } from "./model";
16
+ import type { BrokerToolRequest } from "./turn-broker";
17
+
18
+ // The real capability has the same length. Keeping it out of usage accounting would make
19
+ // estimates differ slightly between the prepared browser prompt and later Codex tool rounds.
20
+ const ESTIMATE_TURN_TOKEN = "turn_00000000000000000000000000000000";
21
+
22
+ export interface ChatGptWebRoundEvidence {
23
+ answer?: string;
24
+ reasoning?: string[];
25
+ toolRequests?: BrokerToolRequest[];
26
+ }
27
+
28
+ function conservativeTextTokens(text: string, modelId: string): number {
29
+ return estimateTokens(text, modelId);
30
+ }
31
+
32
+ export function estimateChatGptWebInputTokens(
33
+ parsed: CodexParsedRequest,
34
+ capabilities: ChatGptWebCapabilities,
35
+ ): number {
36
+ const manual = isChatGptWebZeroRiskBackendModel(parsed.modelId);
37
+ const mode = manual
38
+ ? { localTools: true }
39
+ : resolveChatGptWebModelMode(parsed.modelId, parsed.options.reasoning, capabilities);
40
+ const identity = extractChatGptTurnIdentity(parsed);
41
+ const compiled = compileChatGptWebPrompt(
42
+ parsed,
43
+ capabilities,
44
+ mode.localTools ? ESTIMATE_TURN_TOKEN : undefined,
45
+ {
46
+ ...(manual ? { manualControl: true as const } : {}),
47
+ captureLunaCheckpoint: parsed.modelId === CHATGPT_WEB_LUNA_MODEL_ID
48
+ && !parsed._compactionRequest
49
+ && Boolean(identity.threadId && identity.turnId),
50
+ },
51
+ );
52
+ return estimateCompiledChatGptWebInputTokens(compiled, parsed.modelId);
53
+ }
54
+
55
+ /**
56
+ * Use the existing model/account compaction threshold as the size of one context part. Normal
57
+ * turns stay on the original one-message transport until they actually need the experiment;
58
+ * compaction itself always receives all three parts so it can summarize the expanded window.
59
+ */
60
+ export function resolveBiggerContextMultipartParts(
61
+ parsed: CodexParsedRequest,
62
+ capabilities: ChatGptWebCapabilities,
63
+ ): ChatGptWebMultipartPartCount | undefined {
64
+ if (isChatGptWebZeroRiskBackendModel(parsed.modelId)) {
65
+ throw new Error("Bigger Context is unavailable for ChatGPT Zero Risk");
66
+ }
67
+ if (parsed.modelId === CHATGPT_WEB_LUNA_MODEL_ID) {
68
+ throw new Error("Bigger Context is unavailable for Luna because its accumulated browser transcript still shares one 28,000-token transport budget");
69
+ }
70
+ const mode = resolveChatGptWebModelMode(parsed.modelId, parsed.options.reasoning, capabilities);
71
+
72
+ const onePartLimit = resolveChatGptWebContextLimits(
73
+ CHATGPT_WEB_BACKEND_MODEL,
74
+ mode.effort,
75
+ capabilities,
76
+ ).autoCompactTokenLimit;
77
+ const inputTokens = estimateChatGptWebInputTokens(parsed, capabilities);
78
+ return biggerContextPartCount(inputTokens, onePartLimit, parsed._compactionRequest === true);
79
+ }
80
+
81
+ export function biggerContextPartCount(
82
+ inputTokens: number,
83
+ onePartLimit: number,
84
+ compaction: boolean,
85
+ ): ChatGptWebMultipartPartCount | undefined {
86
+ if (compaction) return CHATGPT_BIGGER_CONTEXT_PARTS;
87
+ if (inputTokens < onePartLimit) return undefined;
88
+ if (inputTokens < onePartLimit * 2) return 2;
89
+ return CHATGPT_BIGGER_CONTEXT_PARTS;
90
+ }
91
+
92
+ function roundEvidenceText(evidence: ChatGptWebRoundEvidence): string {
93
+ return JSON.stringify({
94
+ reasoning: evidence.reasoning ?? [],
95
+ ...(evidence.answer !== undefined ? { answer: evidence.answer } : {}),
96
+ ...(evidence.toolRequests ? {
97
+ tool_calls: evidence.toolRequests.map(request => ({
98
+ call_id: request.callId,
99
+ name: request.wireName,
100
+ ...(request.freeform
101
+ ? { input: request.input ?? "" }
102
+ : { arguments: request.arguments ?? {} }),
103
+ })),
104
+ } : {}),
105
+ });
106
+ }
107
+
108
+ export function estimateChatGptWebUsage(
109
+ parsed: CodexParsedRequest,
110
+ evidence: ChatGptWebRoundEvidence,
111
+ capabilities: ChatGptWebCapabilities,
112
+ ): CodexUsage {
113
+ const inputTokens = estimateChatGptWebInputTokens(parsed, capabilities);
114
+ const outputTokens = conservativeTextTokens(roundEvidenceText(evidence), parsed.modelId);
115
+ return {
116
+ inputTokens,
117
+ outputTokens,
118
+ totalTokens: inputTokens + outputTokens,
119
+ estimated: true,
120
+ };
121
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Parse a `data:<media-type>;base64,<data>` URL into the file payload Playwright attaches to the
3
+ * ChatGPT composer. Returns null for remote URLs; the browser bridge refuses those explicitly.
4
+ */
5
+ export function parseDataUrl(url: string): { mediaType: string; base64: string } | null {
6
+ const m = url.match(/^data:([^;,]+);base64,(.*)$/s);
7
+ if (!m) return null;
8
+ return { mediaType: m[1], base64: m[2] };
9
+ }