@wlv-zedd/dsh-chatgpt-web 1.0.0 → 1.0.2

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 (81) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +140 -140
  3. package/cordis.patch.yml +4 -4
  4. package/lib/cli.js +13269 -11860
  5. package/lib/plugin.js +3 -50
  6. package/package.json +89 -88
  7. package/src/adapters/base.ts +16 -16
  8. package/src/adapters/chatgpt-web/adapter-error.ts +59 -59
  9. package/src/adapters/chatgpt-web/browser-helper-main.ts +513 -513
  10. package/src/adapters/chatgpt-web/browser-helper-prompt-selection.ts +27 -27
  11. package/src/adapters/chatgpt-web/browser-worker.ts +4944 -4944
  12. package/src/adapters/chatgpt-web/codex-rollout-environment.ts +628 -628
  13. package/src/adapters/chatgpt-web/compaction-handoff.ts +533 -533
  14. package/src/adapters/chatgpt-web/compaction-transaction.ts +142 -142
  15. package/src/adapters/chatgpt-web/concurrency.ts +6 -6
  16. package/src/adapters/chatgpt-web/conversation-key.ts +58 -58
  17. package/src/adapters/chatgpt-web/environment.ts +669 -669
  18. package/src/adapters/chatgpt-web/index.ts +1544 -1544
  19. package/src/adapters/chatgpt-web/input-tokens.ts +74 -74
  20. package/src/adapters/chatgpt-web/launcher-helper-client.ts +695 -695
  21. package/src/adapters/chatgpt-web/markdown.ts +418 -418
  22. package/src/adapters/chatgpt-web/mcp-main.ts +25 -25
  23. package/src/adapters/chatgpt-web/mcp-server.ts +933 -933
  24. package/src/adapters/chatgpt-web/model.ts +70 -70
  25. package/src/adapters/chatgpt-web/native-compaction-control.ts +74 -74
  26. package/src/adapters/chatgpt-web/output-validation.ts +62 -62
  27. package/src/adapters/chatgpt-web/process-line-writer.ts +46 -46
  28. package/src/adapters/chatgpt-web/prompt.ts +702 -702
  29. package/src/adapters/chatgpt-web/retry-policy.ts +73 -73
  30. package/src/adapters/chatgpt-web/rolling-checkpoint.ts +384 -384
  31. package/src/adapters/chatgpt-web/thread-environment.ts +238 -238
  32. package/src/adapters/chatgpt-web/tool-stream-parser.ts +1 -1
  33. package/src/adapters/chatgpt-web/turn-broker.ts +1481 -1481
  34. package/src/adapters/chatgpt-web/turn-execution.ts +816 -816
  35. package/src/adapters/chatgpt-web/turn-progress.ts +292 -292
  36. package/src/adapters/chatgpt-web/usage.ts +121 -121
  37. package/src/adapters/image.ts +9 -9
  38. package/src/bridge.ts +1083 -1083
  39. package/src/browser-login.ts +521 -521
  40. package/src/chatgpt-session.ts +240 -240
  41. package/src/chatgpt-web-models.ts +400 -400
  42. package/src/cli.ts +568 -568
  43. package/src/codex-integration-document.ts +824 -824
  44. package/src/codex-integration-journal.ts +212 -212
  45. package/src/codex-integration-route.ts +515 -515
  46. package/src/codex-integration-shared.ts +332 -332
  47. package/src/codex-integration.ts +529 -529
  48. package/src/codex-interrupt-hook.ts +158 -158
  49. package/src/config.ts +616 -616
  50. package/src/dev-chat/cli.ts +432 -432
  51. package/src/dev-chat/constants.ts +3 -3
  52. package/src/dev-chat/driver.ts +655 -655
  53. package/src/dev-chat/profile.ts +223 -223
  54. package/src/dev-chat/session.ts +287 -287
  55. package/src/dev-chat/transport.ts +54 -54
  56. package/src/doctor.ts +237 -237
  57. package/src/event-queue.ts +45 -45
  58. package/src/http-body.ts +30 -30
  59. package/src/launcher-browser-host.ts +695 -695
  60. package/src/lib/errors.ts +281 -281
  61. package/src/lib/token-estimate.ts +42 -42
  62. package/src/login-helper.cjs +1 -1
  63. package/src/model-catalog.ts +197 -197
  64. package/src/native-passthrough.ts +261 -261
  65. package/src/plugin.ts +191 -191
  66. package/src/process.ts +45 -45
  67. package/src/responses/compaction.ts +199 -199
  68. package/src/responses/parser.ts +633 -633
  69. package/src/responses/reasoning-envelope.ts +49 -49
  70. package/src/responses/schema.ts +172 -172
  71. package/src/responses/state.ts +230 -230
  72. package/src/server.ts +1111 -1111
  73. package/src/service.ts +315 -315
  74. package/src/setup.ts +671 -671
  75. package/src/stall-timeout.ts +23 -23
  76. package/src/tunnel-service.ts +160 -160
  77. package/src/tunnel.ts +417 -417
  78. package/src/turndown-plugin-gfm.d.ts +5 -5
  79. package/src/types.ts +307 -307
  80. package/src/usage/totals.ts +12 -12
  81. package/src/version.ts +1 -1
@@ -1,230 +1,230 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- import { atomicWriteFile, getConfigDir } from "../config";
4
-
5
- const MAX_STORED_RESPONSES = 1_000;
6
- const RESPONSE_TTL_MS = 60 * 60 * 1_000;
7
- const SNAPSHOT_DEBOUNCE_MS = 2_000;
8
- /** In-memory high-water byte cap across all entries. Forced store:false continuation chains
9
- * store the full expanded input each turn — ~quadratic bytes per chain —
10
- * so a count cap alone cannot bound memory. Oldest-first eviction applies past this mark. */
11
- const MAX_STORED_RESPONSE_BYTES = 64 * 1024 * 1024;
12
- /** Entries whose serialized size exceeds this are kept in memory but skipped on disk: inputs can
13
- * carry base64 `input_image` data URLs, and one screenshot-heavy thread must not balloon the file. */
14
- const SNAPSHOT_ENTRY_MAX_BYTES = 2 * 1024 * 1024;
15
- const SNAPSHOT_TOTAL_MAX_BYTES = 24 * 1024 * 1024;
16
-
17
- interface StoredResponseState {
18
- createdAt: number;
19
- items: unknown[];
20
- /** Approximate in-memory size, computed locally at insert time (never trusted from disk). */
21
- sizeBytes?: number;
22
- }
23
-
24
- const states = new Map<string, StoredResponseState>();
25
- let storedResponseBytes = 0;
26
-
27
- /** The ONLY size computation: approximate entry weight from its items payload. */
28
- function measuredEntry(entry: Omit<StoredResponseState, "sizeBytes">): StoredResponseState {
29
- let sizeBytes = 0;
30
- try {
31
- sizeBytes = JSON.stringify(entry.items).length;
32
- } catch {
33
- /* unserializable items: weightless rather than fatal */
34
- }
35
- return { ...entry, sizeBytes };
36
- }
37
-
38
- /** The ONLY insertion point: keeps the byte counter consistent on replacement. */
39
- function setEntry(id: string, entry: Omit<StoredResponseState, "sizeBytes">): void {
40
- deleteEntry(id);
41
- const measured = measuredEntry(entry);
42
- storedResponseBytes += measured.sizeBytes ?? 0;
43
- states.set(id, measured);
44
- }
45
-
46
- /** The ONLY deletion point: TTL, count, byte, and explicit deletes all route here. */
47
- function deleteEntry(id: string): void {
48
- const existing = states.get(id);
49
- if (!existing) return;
50
- storedResponseBytes -= existing.sizeBytes ?? 0;
51
- if (storedResponseBytes < 0) storedResponseBytes = 0;
52
- states.delete(id);
53
- }
54
- // Expansion provenance must stay proxy-private: a WeakMap distinguishes replayed history from the
55
- // newly appended input suffix without adding an unknown field that native passthrough could send
56
- // upstream. Consumers use the prefix length to bind trusted history and rolling checkpoints to the
57
- // exact replayed portion of this request.
58
- const replayedInputPrefixLengths = new WeakMap<object, number>();
59
- let loaded = false;
60
- let persistTimer: ReturnType<typeof setTimeout> | null = null;
61
- let pendingPersistPath: string | null = null;
62
-
63
- function now(): number {
64
- return Date.now();
65
- }
66
-
67
- function snapshotPath(): string {
68
- return join(getConfigDir(), "responses-state.json");
69
- }
70
-
71
- /**
72
- * Best-effort disk snapshot so previous_response_id chains survive a proxy restart (the
73
- * dominant expansion-miss cause: an in-memory-only store dies with the process, and the next
74
- * chained turn then reaches the upstream as a naked delta). Load is lazy on first store access;
75
- * persistence is debounced + unref'd so the hot path never blocks and the process can exit.
76
- * Every disk failure is swallowed — the snapshot is a cache, not a source of truth.
77
- */
78
- function ensureLoaded(): void {
79
- if (loaded) return;
80
- loaded = true;
81
- try {
82
- const path = snapshotPath();
83
- if (!existsSync(path)) return;
84
- const raw = JSON.parse(readFileSync(path, "utf-8")) as { version?: unknown; states?: unknown };
85
- if (raw.version !== 1 || !Array.isArray(raw.states)) return;
86
- for (const entry of raw.states) {
87
- if (!Array.isArray(entry) || entry.length !== 2) continue;
88
- const [id, state] = entry as [unknown, unknown];
89
- if (typeof id !== "string" || !state || typeof state !== "object") continue;
90
- const rec = state as StoredResponseState;
91
- if (typeof rec.createdAt !== "number" || !Array.isArray(rec.items)) continue;
92
- // Recompute sizes locally while loading; persisted sizeBytes is never trusted.
93
- setEntry(id, {
94
- createdAt: rec.createdAt,
95
- items: rec.items,
96
- });
97
- }
98
- pruneResponses();
99
- } catch {
100
- /* missing/corrupt snapshot: start empty */
101
- }
102
- }
103
-
104
- function persistNow(path: string): void {
105
- if (persistTimer) {
106
- clearTimeout(persistTimer);
107
- persistTimer = null;
108
- }
109
- pendingPersistPath = null;
110
- try {
111
- const entries: [string, StoredResponseState][] = [];
112
- let total = 0;
113
- // Newest-first so the most recent chains survive both caps.
114
- for (const entry of [...states].reverse()) {
115
- // sizeBytes is in-memory accounting only; keep it out of the disk snapshot.
116
- const [id, state] = entry;
117
- const { sizeBytes: _sizeBytes, ...persistable } = state;
118
- const persistEntry: [string, StoredResponseState] = [id, persistable];
119
- const size = JSON.stringify(persistEntry).length;
120
- if (size > SNAPSHOT_ENTRY_MAX_BYTES) continue;
121
- if (total + size > SNAPSHOT_TOTAL_MAX_BYTES) break;
122
- total += size;
123
- entries.push(persistEntry);
124
- }
125
- entries.reverse();
126
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
127
- // mkdirSync's mode only applies on creation — re-harden an existing config dir so the
128
- // conversation-content snapshot never lands in a group/world-readable directory.
129
- try { chmodSync(dirname(path), 0o700); } catch { /* best-effort (e.g. Windows) */ }
130
- atomicWriteFile(path, JSON.stringify({ version: 1, states: entries }));
131
- } catch {
132
- /* best-effort: disk trouble must never affect request handling */
133
- }
134
- }
135
-
136
- function schedulePersist(): void {
137
- if (persistTimer) return;
138
- // Resolve the target path now: tests may swap DSH_CHATGPT_FREE_HOME before the
139
- // debounce fires, and a late write must land in the home that owned the recorded state.
140
- pendingPersistPath = snapshotPath();
141
- const path = pendingPersistPath;
142
- persistTimer = setTimeout(() => persistNow(path), SNAPSHOT_DEBOUNCE_MS);
143
- (persistTimer as { unref?: () => void }).unref?.();
144
- }
145
-
146
- /** Flush any pending debounced snapshot write (graceful shutdown / deterministic tests). */
147
- export function flushResponseState(): void {
148
- if (!persistTimer) return;
149
- // Use the path captured when the write was scheduled; DSH_CHATGPT_FREE_HOME may have moved.
150
- persistNow(pendingPersistPath ?? snapshotPath());
151
- }
152
-
153
- function inputItems(input: unknown): unknown[] {
154
- if (input === undefined) return [];
155
- if (Array.isArray(input)) return input;
156
- if (typeof input === "string") return [{ role: "user", content: input }];
157
- return [input];
158
- }
159
-
160
- function pruneResponses(at = now()): void {
161
- for (const [id, state] of states) {
162
- if (at - state.createdAt > RESPONSE_TTL_MS) deleteEntry(id);
163
- }
164
- while (states.size > MAX_STORED_RESPONSES) {
165
- const oldest = states.keys().next().value;
166
- if (!oldest) break;
167
- deleteEntry(oldest);
168
- }
169
- // Byte high-water eviction, oldest-first (Map preserves insertion order).
170
- while (storedResponseBytes > MAX_STORED_RESPONSE_BYTES && states.size > 1) {
171
- const oldest = states.keys().next().value;
172
- if (!oldest) break;
173
- deleteEntry(oldest);
174
- }
175
- }
176
-
177
- export function expandPreviousResponseInput(body: unknown): unknown {
178
- if (!body || typeof body !== "object" || Array.isArray(body)) return body;
179
- const request = body as Record<string, unknown>;
180
- const previousId = typeof request.previous_response_id === "string" ? request.previous_response_id : undefined;
181
- if (!previousId) return body;
182
- ensureLoaded();
183
- pruneResponses();
184
- const previous = states.get(previousId);
185
- if (!previous) return body;
186
- const expanded = {
187
- ...request,
188
- input: [...previous.items, ...inputItems(request.input)],
189
- };
190
- replayedInputPrefixLengths.set(expanded, previous.items.length);
191
- return expanded;
192
- }
193
-
194
- /** Number of leading input items restored from previous_response_id state for this exact body. */
195
- export function previousResponseReplayPrefixLength(body: unknown): number {
196
- if (!body || typeof body !== "object" || Array.isArray(body)) return 0;
197
- return replayedInputPrefixLengths.get(body) ?? 0;
198
- }
199
-
200
- /**
201
- * Cache completed output and max_output_tokens partial output for previous_response_id replay.
202
- * Content-filtered incomplete and failed output are not authoritative replay history.
203
- */
204
- export function rememberResponseState(
205
- requestBody: unknown,
206
- response: { id?: unknown; output?: unknown; status?: unknown; incomplete_details?: unknown },
207
- opts?: { force?: boolean },
208
- ): void {
209
- if (!requestBody || typeof requestBody !== "object" || Array.isArray(requestBody)) return;
210
- const request = requestBody as Record<string, unknown>;
211
- // `force` bypasses only the store:false skip: Codex sends `store:false` on every non-Azure
212
- // HTTP request (and WS inherits it), yet its WS turns still chain with previous_response_id.
213
- // The passthrough branch records with force so those chains can be expanded locally; the
214
- // store stays in-memory with a 1h TTL, so this is a proxy-internal continuation cache, not
215
- // real server-side response storage.
216
- if (request.store === false && !opts?.force) return;
217
- if (typeof response.id !== "string" || !Array.isArray(response.output)) return;
218
- if (response.status === "incomplete") {
219
- const details = response.incomplete_details;
220
- if (!details || typeof details !== "object" || Array.isArray(details)
221
- || (details as { reason?: unknown }).reason !== "max_output_tokens") return;
222
- } else if (response.status !== undefined && response.status !== "completed") return;
223
- ensureLoaded();
224
- setEntry(response.id, {
225
- createdAt: now(),
226
- items: [...inputItems(request.input), ...response.output],
227
- });
228
- pruneResponses();
229
- schedulePersist();
230
- }
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { atomicWriteFile, getConfigDir } from "../config";
4
+
5
+ const MAX_STORED_RESPONSES = 1_000;
6
+ const RESPONSE_TTL_MS = 60 * 60 * 1_000;
7
+ const SNAPSHOT_DEBOUNCE_MS = 2_000;
8
+ /** In-memory high-water byte cap across all entries. Forced store:false continuation chains
9
+ * store the full expanded input each turn — ~quadratic bytes per chain —
10
+ * so a count cap alone cannot bound memory. Oldest-first eviction applies past this mark. */
11
+ const MAX_STORED_RESPONSE_BYTES = 64 * 1024 * 1024;
12
+ /** Entries whose serialized size exceeds this are kept in memory but skipped on disk: inputs can
13
+ * carry base64 `input_image` data URLs, and one screenshot-heavy thread must not balloon the file. */
14
+ const SNAPSHOT_ENTRY_MAX_BYTES = 2 * 1024 * 1024;
15
+ const SNAPSHOT_TOTAL_MAX_BYTES = 24 * 1024 * 1024;
16
+
17
+ interface StoredResponseState {
18
+ createdAt: number;
19
+ items: unknown[];
20
+ /** Approximate in-memory size, computed locally at insert time (never trusted from disk). */
21
+ sizeBytes?: number;
22
+ }
23
+
24
+ const states = new Map<string, StoredResponseState>();
25
+ let storedResponseBytes = 0;
26
+
27
+ /** The ONLY size computation: approximate entry weight from its items payload. */
28
+ function measuredEntry(entry: Omit<StoredResponseState, "sizeBytes">): StoredResponseState {
29
+ let sizeBytes = 0;
30
+ try {
31
+ sizeBytes = JSON.stringify(entry.items).length;
32
+ } catch {
33
+ /* unserializable items: weightless rather than fatal */
34
+ }
35
+ return { ...entry, sizeBytes };
36
+ }
37
+
38
+ /** The ONLY insertion point: keeps the byte counter consistent on replacement. */
39
+ function setEntry(id: string, entry: Omit<StoredResponseState, "sizeBytes">): void {
40
+ deleteEntry(id);
41
+ const measured = measuredEntry(entry);
42
+ storedResponseBytes += measured.sizeBytes ?? 0;
43
+ states.set(id, measured);
44
+ }
45
+
46
+ /** The ONLY deletion point: TTL, count, byte, and explicit deletes all route here. */
47
+ function deleteEntry(id: string): void {
48
+ const existing = states.get(id);
49
+ if (!existing) return;
50
+ storedResponseBytes -= existing.sizeBytes ?? 0;
51
+ if (storedResponseBytes < 0) storedResponseBytes = 0;
52
+ states.delete(id);
53
+ }
54
+ // Expansion provenance must stay proxy-private: a WeakMap distinguishes replayed history from the
55
+ // newly appended input suffix without adding an unknown field that native passthrough could send
56
+ // upstream. Consumers use the prefix length to bind trusted history and rolling checkpoints to the
57
+ // exact replayed portion of this request.
58
+ const replayedInputPrefixLengths = new WeakMap<object, number>();
59
+ let loaded = false;
60
+ let persistTimer: ReturnType<typeof setTimeout> | null = null;
61
+ let pendingPersistPath: string | null = null;
62
+
63
+ function now(): number {
64
+ return Date.now();
65
+ }
66
+
67
+ function snapshotPath(): string {
68
+ return join(getConfigDir(), "responses-state.json");
69
+ }
70
+
71
+ /**
72
+ * Best-effort disk snapshot so previous_response_id chains survive a proxy restart (the
73
+ * dominant expansion-miss cause: an in-memory-only store dies with the process, and the next
74
+ * chained turn then reaches the upstream as a naked delta). Load is lazy on first store access;
75
+ * persistence is debounced + unref'd so the hot path never blocks and the process can exit.
76
+ * Every disk failure is swallowed — the snapshot is a cache, not a source of truth.
77
+ */
78
+ function ensureLoaded(): void {
79
+ if (loaded) return;
80
+ loaded = true;
81
+ try {
82
+ const path = snapshotPath();
83
+ if (!existsSync(path)) return;
84
+ const raw = JSON.parse(readFileSync(path, "utf-8")) as { version?: unknown; states?: unknown };
85
+ if (raw.version !== 1 || !Array.isArray(raw.states)) return;
86
+ for (const entry of raw.states) {
87
+ if (!Array.isArray(entry) || entry.length !== 2) continue;
88
+ const [id, state] = entry as [unknown, unknown];
89
+ if (typeof id !== "string" || !state || typeof state !== "object") continue;
90
+ const rec = state as StoredResponseState;
91
+ if (typeof rec.createdAt !== "number" || !Array.isArray(rec.items)) continue;
92
+ // Recompute sizes locally while loading; persisted sizeBytes is never trusted.
93
+ setEntry(id, {
94
+ createdAt: rec.createdAt,
95
+ items: rec.items,
96
+ });
97
+ }
98
+ pruneResponses();
99
+ } catch {
100
+ /* missing/corrupt snapshot: start empty */
101
+ }
102
+ }
103
+
104
+ function persistNow(path: string): void {
105
+ if (persistTimer) {
106
+ clearTimeout(persistTimer);
107
+ persistTimer = null;
108
+ }
109
+ pendingPersistPath = null;
110
+ try {
111
+ const entries: [string, StoredResponseState][] = [];
112
+ let total = 0;
113
+ // Newest-first so the most recent chains survive both caps.
114
+ for (const entry of [...states].reverse()) {
115
+ // sizeBytes is in-memory accounting only; keep it out of the disk snapshot.
116
+ const [id, state] = entry;
117
+ const { sizeBytes: _sizeBytes, ...persistable } = state;
118
+ const persistEntry: [string, StoredResponseState] = [id, persistable];
119
+ const size = JSON.stringify(persistEntry).length;
120
+ if (size > SNAPSHOT_ENTRY_MAX_BYTES) continue;
121
+ if (total + size > SNAPSHOT_TOTAL_MAX_BYTES) break;
122
+ total += size;
123
+ entries.push(persistEntry);
124
+ }
125
+ entries.reverse();
126
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
127
+ // mkdirSync's mode only applies on creation — re-harden an existing config dir so the
128
+ // conversation-content snapshot never lands in a group/world-readable directory.
129
+ try { chmodSync(dirname(path), 0o700); } catch { /* best-effort (e.g. Windows) */ }
130
+ atomicWriteFile(path, JSON.stringify({ version: 1, states: entries }));
131
+ } catch {
132
+ /* best-effort: disk trouble must never affect request handling */
133
+ }
134
+ }
135
+
136
+ function schedulePersist(): void {
137
+ if (persistTimer) return;
138
+ // Resolve the target path now: tests may swap DSH_CHATGPT_FREE_HOME before the
139
+ // debounce fires, and a late write must land in the home that owned the recorded state.
140
+ pendingPersistPath = snapshotPath();
141
+ const path = pendingPersistPath;
142
+ persistTimer = setTimeout(() => persistNow(path), SNAPSHOT_DEBOUNCE_MS);
143
+ (persistTimer as { unref?: () => void }).unref?.();
144
+ }
145
+
146
+ /** Flush any pending debounced snapshot write (graceful shutdown / deterministic tests). */
147
+ export function flushResponseState(): void {
148
+ if (!persistTimer) return;
149
+ // Use the path captured when the write was scheduled; DSH_CHATGPT_FREE_HOME may have moved.
150
+ persistNow(pendingPersistPath ?? snapshotPath());
151
+ }
152
+
153
+ function inputItems(input: unknown): unknown[] {
154
+ if (input === undefined) return [];
155
+ if (Array.isArray(input)) return input;
156
+ if (typeof input === "string") return [{ role: "user", content: input }];
157
+ return [input];
158
+ }
159
+
160
+ function pruneResponses(at = now()): void {
161
+ for (const [id, state] of states) {
162
+ if (at - state.createdAt > RESPONSE_TTL_MS) deleteEntry(id);
163
+ }
164
+ while (states.size > MAX_STORED_RESPONSES) {
165
+ const oldest = states.keys().next().value;
166
+ if (!oldest) break;
167
+ deleteEntry(oldest);
168
+ }
169
+ // Byte high-water eviction, oldest-first (Map preserves insertion order).
170
+ while (storedResponseBytes > MAX_STORED_RESPONSE_BYTES && states.size > 1) {
171
+ const oldest = states.keys().next().value;
172
+ if (!oldest) break;
173
+ deleteEntry(oldest);
174
+ }
175
+ }
176
+
177
+ export function expandPreviousResponseInput(body: unknown): unknown {
178
+ if (!body || typeof body !== "object" || Array.isArray(body)) return body;
179
+ const request = body as Record<string, unknown>;
180
+ const previousId = typeof request.previous_response_id === "string" ? request.previous_response_id : undefined;
181
+ if (!previousId) return body;
182
+ ensureLoaded();
183
+ pruneResponses();
184
+ const previous = states.get(previousId);
185
+ if (!previous) return body;
186
+ const expanded = {
187
+ ...request,
188
+ input: [...previous.items, ...inputItems(request.input)],
189
+ };
190
+ replayedInputPrefixLengths.set(expanded, previous.items.length);
191
+ return expanded;
192
+ }
193
+
194
+ /** Number of leading input items restored from previous_response_id state for this exact body. */
195
+ export function previousResponseReplayPrefixLength(body: unknown): number {
196
+ if (!body || typeof body !== "object" || Array.isArray(body)) return 0;
197
+ return replayedInputPrefixLengths.get(body) ?? 0;
198
+ }
199
+
200
+ /**
201
+ * Cache completed output and max_output_tokens partial output for previous_response_id replay.
202
+ * Content-filtered incomplete and failed output are not authoritative replay history.
203
+ */
204
+ export function rememberResponseState(
205
+ requestBody: unknown,
206
+ response: { id?: unknown; output?: unknown; status?: unknown; incomplete_details?: unknown },
207
+ opts?: { force?: boolean },
208
+ ): void {
209
+ if (!requestBody || typeof requestBody !== "object" || Array.isArray(requestBody)) return;
210
+ const request = requestBody as Record<string, unknown>;
211
+ // `force` bypasses only the store:false skip: Codex sends `store:false` on every non-Azure
212
+ // HTTP request (and WS inherits it), yet its WS turns still chain with previous_response_id.
213
+ // The passthrough branch records with force so those chains can be expanded locally; the
214
+ // store stays in-memory with a 1h TTL, so this is a proxy-internal continuation cache, not
215
+ // real server-side response storage.
216
+ if (request.store === false && !opts?.force) return;
217
+ if (typeof response.id !== "string" || !Array.isArray(response.output)) return;
218
+ if (response.status === "incomplete") {
219
+ const details = response.incomplete_details;
220
+ if (!details || typeof details !== "object" || Array.isArray(details)
221
+ || (details as { reason?: unknown }).reason !== "max_output_tokens") return;
222
+ } else if (response.status !== undefined && response.status !== "completed") return;
223
+ ensureLoaded();
224
+ setEntry(response.id, {
225
+ createdAt: now(),
226
+ items: [...inputItems(request.input), ...response.output],
227
+ });
228
+ pruneResponses();
229
+ schedulePersist();
230
+ }