@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.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/assets/demo.gif +0 -0
- package/assets/hero-demo.png +0 -0
- package/assets/promo-dshmarket-official.png +0 -0
- package/cordis.patch.yml +4 -0
- package/lib/cli.js +239642 -0
- package/lib/plugin.js +195 -0
- package/package.json +88 -0
- package/screenshots.json +5 -0
- package/src/adapters/base.ts +16 -0
- package/src/adapters/chatgpt-web/adapter-error.ts +59 -0
- package/src/adapters/chatgpt-web/browser-helper-main.ts +513 -0
- package/src/adapters/chatgpt-web/browser-helper-prompt-selection.ts +27 -0
- package/src/adapters/chatgpt-web/browser-worker.ts +4944 -0
- package/src/adapters/chatgpt-web/codex-rollout-environment.ts +628 -0
- package/src/adapters/chatgpt-web/compaction-handoff.ts +533 -0
- package/src/adapters/chatgpt-web/compaction-transaction.ts +142 -0
- package/src/adapters/chatgpt-web/concurrency.ts +6 -0
- package/src/adapters/chatgpt-web/conversation-key.ts +58 -0
- package/src/adapters/chatgpt-web/environment.ts +669 -0
- package/src/adapters/chatgpt-web/index.ts +1544 -0
- package/src/adapters/chatgpt-web/input-tokens.ts +74 -0
- package/src/adapters/chatgpt-web/launcher-helper-client.ts +695 -0
- package/src/adapters/chatgpt-web/markdown.ts +418 -0
- package/src/adapters/chatgpt-web/mcp-main.ts +25 -0
- package/src/adapters/chatgpt-web/mcp-server.ts +933 -0
- package/src/adapters/chatgpt-web/model.ts +70 -0
- package/src/adapters/chatgpt-web/native-compaction-control.ts +74 -0
- package/src/adapters/chatgpt-web/output-validation.ts +62 -0
- package/src/adapters/chatgpt-web/process-line-writer.ts +46 -0
- package/src/adapters/chatgpt-web/prompt.ts +702 -0
- package/src/adapters/chatgpt-web/retry-policy.ts +73 -0
- package/src/adapters/chatgpt-web/rolling-checkpoint.ts +384 -0
- package/src/adapters/chatgpt-web/thread-environment.ts +238 -0
- package/src/adapters/chatgpt-web/tool-stream-parser.ts +601 -0
- package/src/adapters/chatgpt-web/turn-broker.ts +1481 -0
- package/src/adapters/chatgpt-web/turn-execution.ts +816 -0
- package/src/adapters/chatgpt-web/turn-progress.ts +292 -0
- package/src/adapters/chatgpt-web/usage.ts +121 -0
- package/src/adapters/image.ts +9 -0
- package/src/bridge.ts +1083 -0
- package/src/browser-login.ts +521 -0
- package/src/chatgpt-session.ts +240 -0
- package/src/chatgpt-web-models.ts +400 -0
- package/src/cli.ts +568 -0
- package/src/codex-integration-document.ts +824 -0
- package/src/codex-integration-journal.ts +212 -0
- package/src/codex-integration-route.ts +515 -0
- package/src/codex-integration-shared.ts +332 -0
- package/src/codex-integration.ts +529 -0
- package/src/codex-interrupt-hook.ts +158 -0
- package/src/config.ts +616 -0
- package/src/dev-chat/cli.ts +432 -0
- package/src/dev-chat/constants.ts +3 -0
- package/src/dev-chat/driver.ts +655 -0
- package/src/dev-chat/profile.ts +223 -0
- package/src/dev-chat/session.ts +287 -0
- package/src/dev-chat/transport.ts +54 -0
- package/src/doctor.ts +237 -0
- package/src/event-queue.ts +45 -0
- package/src/http-body.ts +30 -0
- package/src/launcher-browser-host.ts +695 -0
- package/src/lib/errors.ts +281 -0
- package/src/lib/token-estimate.ts +42 -0
- package/src/login-helper.cjs +140 -0
- package/src/model-catalog.ts +197 -0
- package/src/native-passthrough.ts +261 -0
- package/src/plugin.ts +191 -0
- package/src/process.ts +45 -0
- package/src/responses/compaction.ts +199 -0
- package/src/responses/parser.ts +633 -0
- package/src/responses/reasoning-envelope.ts +49 -0
- package/src/responses/schema.ts +172 -0
- package/src/responses/state.ts +230 -0
- package/src/server.ts +1111 -0
- package/src/service.ts +315 -0
- package/src/setup.ts +671 -0
- package/src/stall-timeout.ts +23 -0
- package/src/tunnel-service.ts +160 -0
- package/src/tunnel.ts +417 -0
- package/src/turndown-plugin-gfm.d.ts +5 -0
- package/src/types.ts +307 -0
- package/src/usage/totals.ts +12 -0
- package/src/version.ts +1 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { readJsonRequestBody } from "./http-body";
|
|
2
|
+
import {
|
|
3
|
+
BRIDGE_COMPACTION_PREFIX,
|
|
4
|
+
SUMMARY_PREFIX,
|
|
5
|
+
decodeCompactionSummary,
|
|
6
|
+
} from "./responses/compaction";
|
|
7
|
+
import { BRIDGE_REASONING_PREFIX } from "./responses/reasoning-envelope";
|
|
8
|
+
|
|
9
|
+
const CODEX_BACKEND = "https://chatgpt.com/backend-api/codex";
|
|
10
|
+
const FIRST_PARTY_CODEX_ORIGINATORS = new Set([
|
|
11
|
+
"codex_cli_rs",
|
|
12
|
+
"codex-tui",
|
|
13
|
+
"codex_vscode",
|
|
14
|
+
"codex_atlas",
|
|
15
|
+
"codex_chatgpt_desktop",
|
|
16
|
+
]);
|
|
17
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
18
|
+
"connection",
|
|
19
|
+
"keep-alive",
|
|
20
|
+
"proxy-authenticate",
|
|
21
|
+
"proxy-authorization",
|
|
22
|
+
"te",
|
|
23
|
+
"trailer",
|
|
24
|
+
"transfer-encoding",
|
|
25
|
+
"upgrade",
|
|
26
|
+
"host",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export type NativeFetch = (request: Request) => Promise<Response>;
|
|
30
|
+
export type NativeCodexEndpoint = "models" | "responses" | "responses/compact" | "alpha/search";
|
|
31
|
+
|
|
32
|
+
type JsonObject = Record<string, unknown>;
|
|
33
|
+
type BridgeCompactionItem = JsonObject & { type: "compaction"; encrypted_content: string };
|
|
34
|
+
|
|
35
|
+
function firstPartyCodexOriginator(value: string): boolean {
|
|
36
|
+
return FIRST_PARTY_CODEX_ORIGINATORS.has(value)
|
|
37
|
+
|| /^Codex [A-Za-z0-9][A-Za-z0-9._ -]{0,63}$/.test(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Current Codex clients identify themselves as `<originator>/<cargo semver> (...)`. The models
|
|
42
|
+
* backend requires the release-only `major.minor.patch` value even when the client is an alpha.
|
|
43
|
+
* Derive it only from the documented first-party Codex prefix; an arbitrary browser or proxy
|
|
44
|
+
* User-Agent is not evidence of a Codex version and leaves the original request untouched.
|
|
45
|
+
*/
|
|
46
|
+
export function codexClientVersionFromUserAgent(userAgent: string | null): string | undefined {
|
|
47
|
+
if (!userAgent) return undefined;
|
|
48
|
+
const separator = userAgent.indexOf("/");
|
|
49
|
+
if (separator < 1) return undefined;
|
|
50
|
+
const originator = userAgent.slice(0, separator);
|
|
51
|
+
if (!firstPartyCodexOriginator(originator)) return undefined;
|
|
52
|
+
const version = /^(\d{1,6})\.(\d{1,6})\.(\d{1,6})(?:[-+][0-9A-Za-z.-]+)?(?:\s|$)/
|
|
53
|
+
.exec(userAgent.slice(separator + 1));
|
|
54
|
+
return version ? `${version[1]}.${version[2]}.${version[3]}` : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isObject(value: unknown): value is JsonObject {
|
|
58
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isBridgeReasoningItem(value: unknown): value is JsonObject {
|
|
62
|
+
if (!isObject(value) || value.type !== "reasoning") return false;
|
|
63
|
+
const encrypted = value.encrypted_content;
|
|
64
|
+
if (typeof encrypted === "string" && encrypted.startsWith(BRIDGE_REASONING_PREFIX)) return true;
|
|
65
|
+
return typeof value.id === "string"
|
|
66
|
+
&& /^rs_[0-9a-f]{32}$/i.test(value.id)
|
|
67
|
+
&& (encrypted === undefined || encrypted === null)
|
|
68
|
+
&& (Array.isArray(value.summary) || Array.isArray(value.content));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isBridgeCompactionItem(value: unknown): value is BridgeCompactionItem {
|
|
72
|
+
return isObject(value)
|
|
73
|
+
&& value.type === "compaction"
|
|
74
|
+
&& typeof value.encrypted_content === "string"
|
|
75
|
+
&& value.encrypted_content.startsWith(BRIDGE_COMPACTION_PREFIX);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Response item ids are scoped to the backend that created them. A ChatGPT Web response is
|
|
80
|
+
* generated locally, so replaying its `rs_*` id after switching back to native Codex makes the
|
|
81
|
+
* official backend try to load an item it has never stored. The same boundary applies to local
|
|
82
|
+
* `ocx1:` compaction checkpoints: preserve their decoded summary as a normal input message rather
|
|
83
|
+
* than asking the official backend to decrypt a bridge-owned envelope. Once either artifact proves
|
|
84
|
+
* that the history crossed providers, send the complete item content without provider-local ids.
|
|
85
|
+
*/
|
|
86
|
+
export function scrubBridgeArtifactsForNative(value: unknown): { value: unknown; changed: boolean } {
|
|
87
|
+
if (!isObject(value)
|
|
88
|
+
|| !Array.isArray(value.input)
|
|
89
|
+
|| !value.input.some(item => isBridgeReasoningItem(item) || isBridgeCompactionItem(item))) {
|
|
90
|
+
return { value, changed: false };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const input = value.input.flatMap(item => {
|
|
94
|
+
if (!isObject(item)) return [item];
|
|
95
|
+
const clean = { ...item };
|
|
96
|
+
delete clean.id;
|
|
97
|
+
if (isBridgeCompactionItem(clean)) {
|
|
98
|
+
const summary = decodeCompactionSummary(clean.encrypted_content);
|
|
99
|
+
if (summary === null) throw new Error("Invalid ChatGPT Web compaction checkpoint");
|
|
100
|
+
return [{
|
|
101
|
+
type: "message",
|
|
102
|
+
role: "user",
|
|
103
|
+
content: [{ type: "input_text", text: `${SUMMARY_PREFIX}\n\n${summary}` }],
|
|
104
|
+
}];
|
|
105
|
+
}
|
|
106
|
+
if (clean.type !== "reasoning") return [clean];
|
|
107
|
+
|
|
108
|
+
if (typeof clean.encrypted_content === "string"
|
|
109
|
+
&& clean.encrypted_content.startsWith(BRIDGE_REASONING_PREFIX)) {
|
|
110
|
+
delete clean.encrypted_content;
|
|
111
|
+
} else if (clean.encrypted_content === null) {
|
|
112
|
+
delete clean.encrypted_content;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const hasSummary = Array.isArray(clean.summary) && clean.summary.length > 0;
|
|
116
|
+
const hasContent = Array.isArray(clean.content) && clean.content.length > 0;
|
|
117
|
+
const hasNativeEncryptedContent = typeof clean.encrypted_content === "string";
|
|
118
|
+
return hasSummary || hasContent || hasNativeEncryptedContent ? [clean] : [];
|
|
119
|
+
});
|
|
120
|
+
const clean: JsonObject = { ...value, input };
|
|
121
|
+
delete clean.previous_response_id;
|
|
122
|
+
return { value: clean, changed: true };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function endToEndHeaders(source: Headers): Headers {
|
|
126
|
+
const headers = new Headers();
|
|
127
|
+
for (const [name, value] of source) {
|
|
128
|
+
if (!HOP_BY_HOP_HEADERS.has(name.toLowerCase())) headers.append(name, value);
|
|
129
|
+
}
|
|
130
|
+
headers.delete("content-length");
|
|
131
|
+
return headers;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Terminator every Responses SSE stream ends with; nothing after it carries meaning. */
|
|
135
|
+
const SSE_TERMINATOR = "data: [DONE]";
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* ChatGPT's backend routinely resets the native Codex connection instead of closing it cleanly,
|
|
139
|
+
* which Bun surfaces as ECONNRESET while reading the body. Passed through untouched that reaches
|
|
140
|
+
* Codex as a truncated HTTP body and the opaque "error decoding response body".
|
|
141
|
+
*
|
|
142
|
+
* A reset that arrives after the stream already delivered `data: [DONE]` is an unclean TCP close on
|
|
143
|
+
* a turn that finished: every byte the protocol defines has been forwarded, so the stream is closed
|
|
144
|
+
* normally rather than failed. A reset before that genuinely truncated the turn and is still raised,
|
|
145
|
+
* because inventing a terminal event there would tell Codex a turn ended when it did not.
|
|
146
|
+
*/
|
|
147
|
+
function withUncleanCloseTolerance(
|
|
148
|
+
body: ReadableStream<Uint8Array>,
|
|
149
|
+
isEventStream: boolean,
|
|
150
|
+
onUncleanClose?: (bytes: number) => void,
|
|
151
|
+
): ReadableStream<Uint8Array> {
|
|
152
|
+
if (!isEventStream) return body;
|
|
153
|
+
const reader = body.getReader();
|
|
154
|
+
const decoder = new TextDecoder();
|
|
155
|
+
let lineBuffer = "";
|
|
156
|
+
let completed = false;
|
|
157
|
+
let bytes = 0;
|
|
158
|
+
const inspectLines = (text: string): void => {
|
|
159
|
+
lineBuffer += text;
|
|
160
|
+
let newline = lineBuffer.indexOf("\n");
|
|
161
|
+
while (newline >= 0) {
|
|
162
|
+
const line = lineBuffer.slice(0, newline).replace(/\r$/, "");
|
|
163
|
+
lineBuffer = lineBuffer.slice(newline + 1);
|
|
164
|
+
if (line === SSE_TERMINATOR) completed = true;
|
|
165
|
+
newline = lineBuffer.indexOf("\n");
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
const inspectTrailingLine = (): void => {
|
|
169
|
+
// A reset can arrive before the final line separator. Treat only an exact unterminated
|
|
170
|
+
// terminator line as complete; text embedded in a JSON data payload must not qualify.
|
|
171
|
+
if (lineBuffer.replace(/\r$/, "") === SSE_TERMINATOR) completed = true;
|
|
172
|
+
};
|
|
173
|
+
return new ReadableStream<Uint8Array>({
|
|
174
|
+
async pull(controller) {
|
|
175
|
+
try {
|
|
176
|
+
const chunk = await reader.read();
|
|
177
|
+
if (chunk.done) {
|
|
178
|
+
inspectLines(decoder.decode());
|
|
179
|
+
inspectTrailingLine();
|
|
180
|
+
controller.close();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
bytes += chunk.value.byteLength;
|
|
184
|
+
inspectLines(decoder.decode(chunk.value, { stream: true }));
|
|
185
|
+
controller.enqueue(chunk.value);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
inspectTrailingLine();
|
|
188
|
+
if (!completed) {
|
|
189
|
+
controller.error(error);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
onUncleanClose?.(bytes);
|
|
193
|
+
controller.close();
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
cancel(reason) {
|
|
197
|
+
return reader.cancel(reason);
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function forwardNativeCodexRequest(
|
|
203
|
+
request: Request,
|
|
204
|
+
endpoint: NativeCodexEndpoint,
|
|
205
|
+
fetchUpstream: NativeFetch = fetch,
|
|
206
|
+
decodedBody?: unknown,
|
|
207
|
+
): Promise<Response> {
|
|
208
|
+
const authorization = request.headers.get("authorization") ?? "";
|
|
209
|
+
if (!authorization.startsWith("Bearer ") || authorization.length <= "Bearer ".length) {
|
|
210
|
+
throw new Error("Native Codex passthrough requires the incoming Bearer authorization");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const incomingUrl = new URL(request.url);
|
|
214
|
+
if (endpoint === "models" && !incomingUrl.searchParams.has("client_version")) {
|
|
215
|
+
const clientVersion = codexClientVersionFromUserAgent(request.headers.get("user-agent"));
|
|
216
|
+
if (clientVersion) incomingUrl.searchParams.set("client_version", clientVersion);
|
|
217
|
+
}
|
|
218
|
+
const headers = endToEndHeaders(request.headers);
|
|
219
|
+
if (endpoint === "models") headers.delete("if-none-match");
|
|
220
|
+
const method = endpoint === "models" ? "GET" : "POST";
|
|
221
|
+
let body: BodyInit | undefined;
|
|
222
|
+
if (method === "POST") {
|
|
223
|
+
const parseRequest = decodedBody === undefined ? request.clone() : undefined;
|
|
224
|
+
const originalBody = await request.arrayBuffer();
|
|
225
|
+
const scrubbed = scrubBridgeArtifactsForNative(
|
|
226
|
+
decodedBody === undefined ? await readJsonRequestBody(parseRequest!) : decodedBody,
|
|
227
|
+
);
|
|
228
|
+
if (scrubbed.changed) {
|
|
229
|
+
headers.delete("content-encoding");
|
|
230
|
+
body = JSON.stringify(scrubbed.value);
|
|
231
|
+
} else {
|
|
232
|
+
body = originalBody;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const upstreamRequest = new Request(`${CODEX_BACKEND}/${endpoint}${incomingUrl.search}`, {
|
|
236
|
+
method,
|
|
237
|
+
headers,
|
|
238
|
+
...(body ? { body } : {}),
|
|
239
|
+
signal: request.signal,
|
|
240
|
+
});
|
|
241
|
+
const upstream = await fetchUpstream(upstreamRequest);
|
|
242
|
+
const responseHeaders = endToEndHeaders(upstream.headers);
|
|
243
|
+
const isEventStream = (upstream.headers.get("content-type") ?? "")
|
|
244
|
+
.toLowerCase()
|
|
245
|
+
.includes("text/event-stream");
|
|
246
|
+
return new Response(
|
|
247
|
+
upstream.body
|
|
248
|
+
? withUncleanCloseTolerance(upstream.body, isEventStream, bytes => {
|
|
249
|
+
console.warn(
|
|
250
|
+
`[codex-chatgpt-web] native_upstream_unclean_close endpoint=${endpoint} bytes=${bytes}`
|
|
251
|
+
+ " (turn had already completed; closing the client stream normally)",
|
|
252
|
+
);
|
|
253
|
+
})
|
|
254
|
+
: upstream.body,
|
|
255
|
+
{
|
|
256
|
+
status: upstream.status,
|
|
257
|
+
statusText: upstream.statusText,
|
|
258
|
+
headers: responseHeaders,
|
|
259
|
+
},
|
|
260
|
+
);
|
|
261
|
+
}
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
export interface CordisContext {
|
|
8
|
+
effect?: (cb: () => void | Promise<void> | (() => void) | (() => Promise<void>)) => void;
|
|
9
|
+
logger?: (name: string) => {
|
|
10
|
+
info(msg: string): void;
|
|
11
|
+
warn(msg: string): void;
|
|
12
|
+
error(msg: string): void;
|
|
13
|
+
debug(msg: string): void;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const name = "dsh-chatgpt-web";
|
|
18
|
+
export const inject = [];
|
|
19
|
+
|
|
20
|
+
export interface ChatGPTWebPluginConfig {
|
|
21
|
+
/** Host to bind or check for health (default: 127.0.0.1). */
|
|
22
|
+
host?: string;
|
|
23
|
+
/** Port the sidecar listens on (default: 17841). */
|
|
24
|
+
port?: number;
|
|
25
|
+
/** Automatically start the sidecar daemon if not running (default: true). */
|
|
26
|
+
autoStart?: boolean;
|
|
27
|
+
/** Maximum milliseconds to wait for the sidecar to report ready (default: 30000). */
|
|
28
|
+
readyTimeoutMs?: number;
|
|
29
|
+
/** Explicit path to the bun executable (optional). */
|
|
30
|
+
bunPath?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
import { pathToFileURL } from "node:url";
|
|
34
|
+
|
|
35
|
+
const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
36
|
+
|
|
37
|
+
function resolveLauncher(customBunPath?: string): { cmd: string; args: string[] } {
|
|
38
|
+
const libCliPath = resolve(ROOT_DIR, "lib", "cli.js");
|
|
39
|
+
if (existsSync(libCliPath)) {
|
|
40
|
+
return {
|
|
41
|
+
cmd: process.execPath,
|
|
42
|
+
args: [libCliPath, "serve"],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Development fallback: check for bun or tsx
|
|
47
|
+
if (customBunPath && existsSync(customBunPath)) {
|
|
48
|
+
return { cmd: customBunPath, args: ["run", "src/cli.ts", "serve"] };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const winBun = join(homedir(), ".bun", "bin", "bun.exe");
|
|
52
|
+
if (existsSync(winBun)) {
|
|
53
|
+
return { cmd: winBun, args: ["run", "src/cli.ts", "serve"] };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const tsxPath = resolve(ROOT_DIR, "../deepseek-harness/node_modules/tsx/dist/esm/index.mjs");
|
|
57
|
+
if (existsSync(tsxPath)) {
|
|
58
|
+
return {
|
|
59
|
+
cmd: process.execPath,
|
|
60
|
+
args: ["--import", pathToFileURL(tsxPath).href, "src/cli.ts", "serve"],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { cmd: process.execPath, args: ["src/cli.ts", "serve"] };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function isSidecarHealthy(host: string, port: number): Promise<boolean> {
|
|
68
|
+
try {
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
const timer = setTimeout(() => controller.abort(), 1500);
|
|
71
|
+
const res = await fetch(`http://${host}:${port}/healthz`, {
|
|
72
|
+
signal: controller.signal,
|
|
73
|
+
});
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
if (!res.ok) return false;
|
|
76
|
+
const body = (await res.json()) as { status?: string };
|
|
77
|
+
return body.status === "ok";
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function apply(ctx: CordisContext, config: ChatGPTWebPluginConfig = {}): void {
|
|
84
|
+
const host = config.host || "127.0.0.1";
|
|
85
|
+
const port = config.port || 17841;
|
|
86
|
+
const autoStart = config.autoStart !== false;
|
|
87
|
+
const readyTimeoutMs = config.readyTimeoutMs || 30_000;
|
|
88
|
+
const logger = typeof ctx.logger === "function" ? ctx.logger("chatgpt-web") : console;
|
|
89
|
+
|
|
90
|
+
let spawnedProcess: ChildProcess | undefined;
|
|
91
|
+
|
|
92
|
+
const startDaemon = async () => {
|
|
93
|
+
const alreadyHealthy = await isSidecarHealthy(host, port);
|
|
94
|
+
if (alreadyHealthy) {
|
|
95
|
+
logger.info(`[dsh-chatgpt-web] Sidecar already running and healthy at http://${host}:${port}/v1`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!autoStart) {
|
|
100
|
+
logger.warn(`[dsh-chatgpt-web] Sidecar is offline and autoStart is false. Start it manually with 'bun run src/cli.ts serve'`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const launcher = resolveLauncher(config.bunPath);
|
|
105
|
+
logger.info(`[dsh-chatgpt-web] Starting dsh-chatgpt-web daemon via ${launcher.cmd} at http://${host}:${port}/v1...`);
|
|
106
|
+
|
|
107
|
+
const child = spawn(launcher.cmd, launcher.args, {
|
|
108
|
+
cwd: ROOT_DIR,
|
|
109
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
110
|
+
windowsHide: true,
|
|
111
|
+
env: {
|
|
112
|
+
...process.env,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
spawnedProcess = child;
|
|
117
|
+
|
|
118
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
119
|
+
const text = chunk.toString().trim();
|
|
120
|
+
if (text && typeof logger.debug === "function") logger.debug(`[sidecar] ${text}`);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
124
|
+
const text = chunk.toString().trim();
|
|
125
|
+
if (text && typeof logger.debug === "function") logger.debug(`[sidecar:err] ${text}`);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
child.on("error", (err) => {
|
|
129
|
+
logger.error(`[dsh-chatgpt-web] Failed to launch sidecar process: ${err.message}`);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
child.on("exit", (code, signal) => {
|
|
133
|
+
if (code !== 0 && code !== null) {
|
|
134
|
+
logger.warn(`[dsh-chatgpt-web] Sidecar process exited with code ${code} (signal: ${signal})`);
|
|
135
|
+
}
|
|
136
|
+
spawnedProcess = undefined;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Wait for healthcheck
|
|
140
|
+
const deadline = Date.now() + readyTimeoutMs;
|
|
141
|
+
while (Date.now() < deadline) {
|
|
142
|
+
if (await isSidecarHealthy(host, port)) {
|
|
143
|
+
logger.info(`[dsh-chatgpt-web] Sidecar ready and accepting turns at http://${host}:${port}/v1`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
logger.error(`[dsh-chatgpt-web] Sidecar did not become healthy within ${readyTimeoutMs}ms`);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const stopDaemon = async () => {
|
|
153
|
+
if (!spawnedProcess) return;
|
|
154
|
+
|
|
155
|
+
logger.info("[dsh-chatgpt-web] Stopping sidecar daemon...");
|
|
156
|
+
try {
|
|
157
|
+
const controller = new AbortController();
|
|
158
|
+
const timer = setTimeout(() => controller.abort(), 2000);
|
|
159
|
+
await fetch(`http://${host}:${port}/admin/shutdown`, {
|
|
160
|
+
method: "POST",
|
|
161
|
+
signal: controller.signal,
|
|
162
|
+
}).catch(() => {});
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
} catch {
|
|
165
|
+
// ignore
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (spawnedProcess && !spawnedProcess.killed) {
|
|
169
|
+
spawnedProcess.kill("SIGTERM");
|
|
170
|
+
}
|
|
171
|
+
spawnedProcess = undefined;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
if (typeof ctx.effect === "function") {
|
|
175
|
+
ctx.effect(() => {
|
|
176
|
+
void startDaemon();
|
|
177
|
+
return () => {
|
|
178
|
+
void stopDaemon();
|
|
179
|
+
};
|
|
180
|
+
});
|
|
181
|
+
} else {
|
|
182
|
+
void startDaemon();
|
|
183
|
+
process.once("beforeExit", () => void stopDaemon());
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export default {
|
|
188
|
+
name,
|
|
189
|
+
inject,
|
|
190
|
+
apply,
|
|
191
|
+
};
|
package/src/process.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { spawnSync, type SpawnSyncOptions } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export interface CommandResult {
|
|
4
|
+
status: number;
|
|
5
|
+
stdout: string;
|
|
6
|
+
stderr: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function processRunning(
|
|
10
|
+
pid: unknown,
|
|
11
|
+
probe: (pid: number, signal: 0) => void = process.kill,
|
|
12
|
+
): boolean {
|
|
13
|
+
if (!Number.isInteger(pid) || (pid as number) < 1) return false;
|
|
14
|
+
try {
|
|
15
|
+
probe(pid as number, 0);
|
|
16
|
+
return true;
|
|
17
|
+
} catch (error) {
|
|
18
|
+
// Windows and hardened Unix environments can deny signalling an existing process. EPERM is
|
|
19
|
+
// existence evidence, not proof that the launcher/browser/tunnel owner disappeared.
|
|
20
|
+
return (error as NodeJS.ErrnoException)?.code === "EPERM";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function runCommand(command: string, args: string[], options: SpawnSyncOptions = {}): CommandResult {
|
|
25
|
+
const result = spawnSync(command, args, {
|
|
26
|
+
encoding: "utf8",
|
|
27
|
+
stdio: "pipe",
|
|
28
|
+
...options,
|
|
29
|
+
});
|
|
30
|
+
if (result.error) throw result.error;
|
|
31
|
+
return {
|
|
32
|
+
status: result.status ?? 1,
|
|
33
|
+
stdout: typeof result.stdout === "string" ? result.stdout : result.stdout?.toString("utf8") ?? "",
|
|
34
|
+
stderr: typeof result.stderr === "string" ? result.stderr : result.stderr?.toString("utf8") ?? "",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function runChecked(command: string, args: string[], options: SpawnSyncOptions = {}): CommandResult {
|
|
39
|
+
const result = runCommand(command, args, options);
|
|
40
|
+
if (result.status !== 0) {
|
|
41
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`;
|
|
42
|
+
throw new Error(`${command} ${args.join(" ")} failed: ${detail}`);
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote compaction v2 support for ROUTED providers.
|
|
3
|
+
*
|
|
4
|
+
* Codex decides "this provider supports remote compaction" by provider name (built-in `OpenAI`),
|
|
5
|
+
* and Design B points that provider at this proxy — so Codex sends remote compaction v2 requests
|
|
6
|
+
* for EVERY routed model. The request is a normal /responses call whose input ends with
|
|
7
|
+
* `{"type":"compaction_trigger"}`; codex-rs `collect_compaction_output` then requires the stream
|
|
8
|
+
* to carry EXACTLY ONE `{"type":"compaction","encrypted_content":...}` output item
|
|
9
|
+
* (compact_remote_v2.rs) or it fatals with "expected exactly one compaction output item".
|
|
10
|
+
*
|
|
11
|
+
* Routed models cannot produce OpenAI's encrypted blob, so the proxy runs the model as a plain
|
|
12
|
+
* summarizer and wraps the summary text in a transparent envelope: `ocx1:` + base64(utf8 summary).
|
|
13
|
+
* Codex stores the item and replays it in later input; the parser decodes our envelope back into
|
|
14
|
+
* plain text for routed models. Real OpenAI-encrypted blobs (no `ocx1:` prefix) are opaque —
|
|
15
|
+
* routed models get a short "history was compacted" note instead.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const BRIDGE_COMPACTION_PREFIX = "ocx1:";
|
|
19
|
+
|
|
20
|
+
/** Mirrors codex-rs core/templates/compact/prompt.md (the local-compaction instruction). */
|
|
21
|
+
export const COMPACT_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.
|
|
22
|
+
|
|
23
|
+
Include:
|
|
24
|
+
- Current progress and key decisions made
|
|
25
|
+
- Important context, constraints, or user preferences
|
|
26
|
+
- What remains to be done (clear next steps)
|
|
27
|
+
- Any critical data, examples, or references needed to continue
|
|
28
|
+
|
|
29
|
+
Be concise, structured, and focused on helping the next LLM seamlessly continue the work.`;
|
|
30
|
+
|
|
31
|
+
/** Mirrors codex-rs core/templates/compact/summary_prefix.md (framing for a replayed summary). */
|
|
32
|
+
export const SUMMARY_PREFIX = "Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis:";
|
|
33
|
+
|
|
34
|
+
export const OPAQUE_COMPACTION_NOTE = "[earlier conversation was compacted; the summary is stored in a format this model cannot read]";
|
|
35
|
+
|
|
36
|
+
/** Codex v1 uses one newline after the prefix; the transparent v2 replay uses two. */
|
|
37
|
+
export function isReadableCompactionSummaryText(value: unknown): value is string {
|
|
38
|
+
return typeof value === "string" && value.startsWith(`${SUMMARY_PREFIX}\n`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function encodeCompactionSummary(summary: string): string {
|
|
42
|
+
return BRIDGE_COMPACTION_PREFIX + Buffer.from(summary, "utf-8").toString("base64");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Decode an `ocx1:` envelope; returns null for real (OpenAI-encrypted) blobs or garbage. */
|
|
46
|
+
export function decodeCompactionSummary(encryptedContent: string): string | null {
|
|
47
|
+
if (!encryptedContent.startsWith(BRIDGE_COMPACTION_PREFIX)) return null;
|
|
48
|
+
try {
|
|
49
|
+
return Buffer.from(encryptedContent.slice(BRIDGE_COMPACTION_PREFIX.length), "base64").toString("utf-8");
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Render a replayed compaction item as plain user-visible text for a routed model. */
|
|
56
|
+
export function compactionItemToText(encryptedContent: string | undefined): string {
|
|
57
|
+
const decoded = typeof encryptedContent === "string" ? decodeCompactionSummary(encryptedContent) : null;
|
|
58
|
+
return decoded ? `${SUMMARY_PREFIX}\n\n${decoded}` : OPAQUE_COMPACTION_NOTE;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Remote compaction v1 (`POST /responses/compact`, unary) — codex-rs installs the returned
|
|
63
|
+
* `{"output":[ResponseItem...]}` as the REPLACEMENT history (compact_remote.rs
|
|
64
|
+
* process_compacted_history). Mirror codex-rs local `build_compacted_history`: recent real user
|
|
65
|
+
* messages within a token budget, then one user message `SUMMARY_PREFIX\n<summary>`. Plain user
|
|
66
|
+
* message items parse as real user messages on the codex side (event_mapping parse_user_message);
|
|
67
|
+
* contextual wrappers are filtered there, and v2-style `compaction` items are NOT expected here.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/** codex-rs compact.rs COMPACT_USER_MESSAGE_MAX_TOKENS = 20k tokens (~4 chars/token). */
|
|
71
|
+
const COMPACT_V1_RETAINED_CHAR_BUDGET = 20_000 * 4;
|
|
72
|
+
|
|
73
|
+
type CompactMessageItem = Record<string, unknown>;
|
|
74
|
+
|
|
75
|
+
interface CompactContentBlock extends Record<string, unknown> {
|
|
76
|
+
type?: string;
|
|
77
|
+
text?: string;
|
|
78
|
+
image_url?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Codex can persist unavailable historical images as a one-pixel PNG. Replaying that sentinel as
|
|
83
|
+
* a real attachment produces an opaque black tile in ChatGPT and consumes one attachment slot,
|
|
84
|
+
* but carries no visual information. Treat every 1x1 PNG data URL as non-semantic transport state.
|
|
85
|
+
*/
|
|
86
|
+
export function isOnePixelPngDataUrl(value: unknown): value is string {
|
|
87
|
+
if (typeof value !== "string" || !value.startsWith("data:image/png;base64,")) return false;
|
|
88
|
+
try {
|
|
89
|
+
const png = Buffer.from(value.slice("data:image/png;base64,".length), "base64");
|
|
90
|
+
return png.length >= 24
|
|
91
|
+
&& png.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
|
|
92
|
+
&& png.readUInt32BE(16) === 1
|
|
93
|
+
&& png.readUInt32BE(20) === 1;
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Extract original user message items from a Responses `input` array.
|
|
101
|
+
*
|
|
102
|
+
* Keeping the original item metadata matters: Codex uses it after `/responses/compact` to
|
|
103
|
+
* distinguish real user turns from contextual user-role wrappers. Images remain structured
|
|
104
|
+
* `input_image` blocks so the browser adapter can upload them as attachments; their data URL is
|
|
105
|
+
* never copied into the textual ChatGPT transport envelope.
|
|
106
|
+
*/
|
|
107
|
+
export function extractCompactUserMessages(input: unknown): CompactMessageItem[] {
|
|
108
|
+
if (!Array.isArray(input)) return [];
|
|
109
|
+
const out: CompactMessageItem[] = [];
|
|
110
|
+
for (const item of input) {
|
|
111
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
|
112
|
+
const rec = item as CompactMessageItem & { type?: string; role?: string; content?: unknown };
|
|
113
|
+
if (rec.type !== undefined && rec.type !== "message") continue;
|
|
114
|
+
if (rec.role !== "user") continue;
|
|
115
|
+
if (isReadableCompactionSummaryText(
|
|
116
|
+
compactContentBlocks(rec).filter(textBlock).map(block => block.text).join(""),
|
|
117
|
+
)) continue;
|
|
118
|
+
out.push(structuredClone(rec));
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function compactUserMessageItem(text: string): CompactMessageItem {
|
|
124
|
+
return { type: "message", role: "user", content: [{ type: "input_text", text }] };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function compactContentBlocks(item: CompactMessageItem): CompactContentBlock[] {
|
|
128
|
+
if (typeof item.content === "string") {
|
|
129
|
+
return [{ type: "input_text", text: item.content }];
|
|
130
|
+
}
|
|
131
|
+
if (!Array.isArray(item.content)) return [];
|
|
132
|
+
return item.content
|
|
133
|
+
.filter((block): block is CompactContentBlock => Boolean(block && typeof block === "object" && !Array.isArray(block)))
|
|
134
|
+
.map(block => structuredClone(block));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function textBlock(block: CompactContentBlock): boolean {
|
|
138
|
+
return (block.type === "input_text" || block.type === "text") && typeof block.text === "string";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function imageBlock(block: CompactContentBlock): boolean {
|
|
142
|
+
return block.type === "input_image"
|
|
143
|
+
&& typeof block.image_url === "string"
|
|
144
|
+
&& !isOnePixelPngDataUrl(block.image_url);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Build the v1 compact replacement history.
|
|
149
|
+
*
|
|
150
|
+
* Text follows Codex's 20k-token retained-user-message budget. Image history is independently
|
|
151
|
+
* bounded to ChatGPT's ten-attachment limit, newest first. This prevents an old image corpus from
|
|
152
|
+
* immediately refilling Codex's context window after a successful compact while still preserving
|
|
153
|
+
* the visual context the browser model can actually receive.
|
|
154
|
+
*/
|
|
155
|
+
export function buildCompactV1Output(
|
|
156
|
+
userMessages: CompactMessageItem[],
|
|
157
|
+
summary: string,
|
|
158
|
+
maxImages = 10,
|
|
159
|
+
): CompactMessageItem[] {
|
|
160
|
+
const selected: CompactMessageItem[] = [];
|
|
161
|
+
let remaining = COMPACT_V1_RETAINED_CHAR_BUDGET;
|
|
162
|
+
let retainedImages = 0;
|
|
163
|
+
for (let i = userMessages.length - 1; i >= 0 && (remaining > 0 || retainedImages < maxImages); i--) {
|
|
164
|
+
const message = structuredClone(userMessages[i]!);
|
|
165
|
+
const blocks = compactContentBlocks(message);
|
|
166
|
+
const retainedReversed: CompactContentBlock[] = [];
|
|
167
|
+
for (let blockIndex = blocks.length - 1; blockIndex >= 0; blockIndex -= 1) {
|
|
168
|
+
const block = blocks[blockIndex]!;
|
|
169
|
+
if (imageBlock(block)) {
|
|
170
|
+
if (retainedImages < maxImages) {
|
|
171
|
+
retainedImages += 1;
|
|
172
|
+
retainedReversed.push(block);
|
|
173
|
+
}
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (!textBlock(block) || remaining === 0) continue;
|
|
177
|
+
const text = block.text!;
|
|
178
|
+
if (text.length <= remaining) {
|
|
179
|
+
remaining -= text.length;
|
|
180
|
+
retainedReversed.push({ ...block, type: "input_text", text });
|
|
181
|
+
} else {
|
|
182
|
+
retainedReversed.push({ ...block, type: "input_text", text: text.slice(text.length - remaining) });
|
|
183
|
+
remaining = 0;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const content = retainedReversed.reverse();
|
|
187
|
+
if (content.length > 0) {
|
|
188
|
+
message.type = "message";
|
|
189
|
+
message.role = "user";
|
|
190
|
+
message.content = content;
|
|
191
|
+
selected.push(message);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
selected.reverse();
|
|
195
|
+
// codex-rs compact.rs uses "{SUMMARY_PREFIX}\n{summary}" (single newline) and detects stored
|
|
196
|
+
// summaries by that exact prefix — keep the same shape.
|
|
197
|
+
const summaryText = summary.trim().length > 0 ? `${SUMMARY_PREFIX}\n${summary}` : "(no summary available)";
|
|
198
|
+
return [...selected, compactUserMessageItem(summaryText)];
|
|
199
|
+
}
|