@youdie006/prodex 0.40.25 → 0.40.27
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/dist/chatgpt-browser.js +142 -26
- package/docs/releasing.md +73 -0
- package/package.json +1 -1
package/dist/chatgpt-browser.js
CHANGED
|
@@ -191,13 +191,52 @@ export const CHATGPT_BLOCKER_SCAN_EXCLUDED_ANCESTORS = `${'[data-message-author-
|
|
|
191
191
|
* sat in the thread for ten minutes while prodex waited for it. The model slug
|
|
192
192
|
* attribute went with it; where it is absent the model is simply not known.
|
|
193
193
|
*/
|
|
194
|
-
|
|
194
|
+
/**
|
|
195
|
+
* A message's text with its source pills spelled out.
|
|
196
|
+
*
|
|
197
|
+
* Measured 2026-10-06: ChatGPT renders each source as an inline pill
|
|
198
|
+
* ([data-chatgpt-copy-reference]) and innerText puts its label on a line of
|
|
199
|
+
* its own, so a page-read answer came back as "walnut-9" plus a line
|
|
200
|
+
* "cont-att", or ended in a bare "blog.rust-lang.org" that read as part of
|
|
201
|
+
* the answer. A web source becomes " [label](url)", as the transcript reader
|
|
202
|
+
* writes it; a file source has no link and becomes " [label]". The label is
|
|
203
|
+
* looked for on its own line first, so the same words earlier in the prose
|
|
204
|
+
* are left alone.
|
|
205
|
+
*/
|
|
206
|
+
export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
|
|
207
|
+
const renderedMessageText = (root) => {
|
|
208
|
+
let text = root.innerText || "";
|
|
209
|
+
const newline = String.fromCharCode(10);
|
|
210
|
+
const isSpace = (ch) => ch === " " || ch === newline || ch === String.fromCharCode(9) || ch === String.fromCharCode(13);
|
|
211
|
+
let cursor = 0;
|
|
212
|
+
for (const pill of [...root.querySelectorAll('[data-chatgpt-copy-reference]')]) {
|
|
213
|
+
const label = (pill.innerText || "").trim();
|
|
214
|
+
if (!label) continue;
|
|
215
|
+
const onOwnLine = text.indexOf(newline + label, cursor);
|
|
216
|
+
const at = onOwnLine >= 0 ? onOwnLine + 1 : text.indexOf(label, cursor);
|
|
217
|
+
if (at < 0) continue;
|
|
218
|
+
let start = at;
|
|
219
|
+
while (start > cursor && isSpace(text[start - 1])) start -= 1;
|
|
220
|
+
const link = pill.querySelector('a[href^="http"]');
|
|
221
|
+
const rendered = link ? " [" + label + "](" + link.getAttribute("href") + ")" : " [" + label + "]";
|
|
222
|
+
text = text.slice(0, start) + rendered + text.slice(at + label.length);
|
|
223
|
+
cursor = start + rendered.length;
|
|
224
|
+
}
|
|
225
|
+
return text;
|
|
226
|
+
};`;
|
|
227
|
+
const CHATGPT_MESSAGE_NODES_JS = `${CHATGPT_RENDERED_MESSAGE_TEXT_JS}
|
|
228
|
+
// ChatGPT keeps a previously opened conversation mounted but hidden
|
|
229
|
+
// (measured 2026-10-06: two turns without a box on a project home), and
|
|
230
|
+
// counting them made a fresh chat look like an existing conversation.
|
|
231
|
+
// A display:contents node has no box of its own, so its children decide.
|
|
232
|
+
const isRenderedMessageNode = (node) =>
|
|
233
|
+
node.getClientRects().length > 0 || [...(node.children || [])].some((child) => child.getClientRects().length > 0);
|
|
195
234
|
const chatMessageNodes = () => {
|
|
196
|
-
const legacy = [...document.querySelectorAll('[data-message-author-role]')];
|
|
235
|
+
const legacy = [...document.querySelectorAll('[data-message-author-role]')].filter(isRenderedMessageNode);
|
|
197
236
|
if (legacy.length > 0) {
|
|
198
237
|
return legacy.map((node) => ({ role: node.getAttribute('data-message-author-role'), node, textNode: node }));
|
|
199
238
|
}
|
|
200
|
-
const units = [...document.querySelectorAll('[data-chatgpt-search-unit-key]')];
|
|
239
|
+
const units = [...document.querySelectorAll('[data-chatgpt-search-unit-key]')].filter(isRenderedMessageNode);
|
|
201
240
|
return units
|
|
202
241
|
.filter((unit) => !units.some((other) => other !== unit && unit.contains(other)))
|
|
203
242
|
.map((unit) => {
|
|
@@ -3431,6 +3470,14 @@ export async function recoverChatGptAnswerFromThread(options) {
|
|
|
3431
3470
|
lastAnswer = "";
|
|
3432
3471
|
continue;
|
|
3433
3472
|
}
|
|
3473
|
+
// A thread that is still loading already has the target URL but no
|
|
3474
|
+
// turns (measured 2026-10-06), and judging the request on it refused a
|
|
3475
|
+
// recovery of the very request it was asked for.
|
|
3476
|
+
if (state.userMessageCount === 0) {
|
|
3477
|
+
stableRuns = 0;
|
|
3478
|
+
lastAnswer = "";
|
|
3479
|
+
continue;
|
|
3480
|
+
}
|
|
3434
3481
|
if (options.requestId && !chatGptRequestMarkerMatches(state.lastUserText ?? "", options.requestId)) {
|
|
3435
3482
|
throw new ChatGptBrowserBlockerError({
|
|
3436
3483
|
code: "request_mismatch",
|
|
@@ -5109,6 +5156,29 @@ export function insertComposerTextInPageExpression(text) {
|
|
|
5109
5156
|
export function composerFileInputSelector() {
|
|
5110
5157
|
return 'input[type="file"]:not([accept*="image"])';
|
|
5111
5158
|
}
|
|
5159
|
+
const COMPOSER_FILE_INPUT_TIMEOUT_MS = 15_000;
|
|
5160
|
+
const COMPOSER_TOOLS_MENU_ATTEMPTS = 3;
|
|
5161
|
+
export const PRODEX_ATTACH_INPUT_ATTRIBUTE = "data-prodex-attach-input";
|
|
5162
|
+
/**
|
|
5163
|
+
* Tags the general file input of the form that holds the VISIBLE composer, so
|
|
5164
|
+
* DOM.querySelector can resolve exactly that node. A project home also keeps
|
|
5165
|
+
* a hidden copy of an earlier page mounted with its own complete composer
|
|
5166
|
+
* form (measured 2026-10-06: two editor forms, the hidden one first), and a
|
|
5167
|
+
* structural selector cannot tell the two apart. Returns false when no
|
|
5168
|
+
* visible composer form carries a general file input.
|
|
5169
|
+
*/
|
|
5170
|
+
export function markComposerFileInputExpression() {
|
|
5171
|
+
const attribute = JSON.stringify(PRODEX_ATTACH_INPUT_ATTRIBUTE);
|
|
5172
|
+
return `(() => {${composerExpressionHelpers()}
|
|
5173
|
+
document.querySelectorAll('[' + ${attribute} + ']').forEach((node) => node.removeAttribute(${attribute}));
|
|
5174
|
+
const editor = findChatGptComposerCandidate();
|
|
5175
|
+
const form = editor && editor.closest('form');
|
|
5176
|
+
const input = form && form.querySelector(${JSON.stringify(composerFileInputSelector())});
|
|
5177
|
+
if (!input) return false;
|
|
5178
|
+
input.setAttribute(${attribute}, "1");
|
|
5179
|
+
return true;
|
|
5180
|
+
})()`;
|
|
5181
|
+
}
|
|
5112
5182
|
/**
|
|
5113
5183
|
* Lookup order for the attachment input. A conversation page also renders a
|
|
5114
5184
|
* second form without the prompt editor, earlier in the document and with its
|
|
@@ -5140,7 +5210,8 @@ export function attachmentStateExpression(fileNames) {
|
|
|
5140
5210
|
.join(String.fromCharCode(10));
|
|
5141
5211
|
const haystack = text + String.fromCharCode(10) + labels;
|
|
5142
5212
|
// Uploading a name that was used before makes ChatGPT show it as
|
|
5143
|
-
// "stem(3).ext",
|
|
5213
|
+
// "stem(3).ext", and since 2026-10 as "stem(20261006-012426).ext", so
|
|
5214
|
+
// either spelling counts as the same file.
|
|
5144
5215
|
const shownAsRenamed = (name) => {
|
|
5145
5216
|
const dot = name.lastIndexOf(".");
|
|
5146
5217
|
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
@@ -5149,7 +5220,7 @@ export function attachmentStateExpression(fileNames) {
|
|
|
5149
5220
|
while ((from = haystack.indexOf(stem + "(", from)) !== -1) {
|
|
5150
5221
|
let i = from + stem.length + 1;
|
|
5151
5222
|
const digitsStart = i;
|
|
5152
|
-
while (i < haystack.length && haystack[i] >= "0" && haystack[i] <= "9") i += 1;
|
|
5223
|
+
while (i < haystack.length && ((haystack[i] >= "0" && haystack[i] <= "9") || (i > digitsStart && haystack[i] === "-"))) i += 1;
|
|
5153
5224
|
if (i > digitsStart && haystack.startsWith(")" + ext, i)) return true;
|
|
5154
5225
|
from += 1;
|
|
5155
5226
|
}
|
|
@@ -5167,9 +5238,14 @@ export function attachmentStateExpression(fileNames) {
|
|
|
5167
5238
|
/** How many attachments are already sitting in the composer (read-only). */
|
|
5168
5239
|
export function attachmentPresenceExpression() {
|
|
5169
5240
|
return `(() => {
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5241
|
+
// The chip's remove button was "Remove file 1: name" and became
|
|
5242
|
+
// "Remove name" (measured 2026-10-06); a tool pill's "Remove Search" is
|
|
5243
|
+
// not a file. Chips on a hidden, still-mounted page have no box.
|
|
5244
|
+
const buttons = [...document.querySelectorAll('button[aria-label]')].filter((b) => {
|
|
5245
|
+
const label = b.getAttribute("aria-label") || "";
|
|
5246
|
+
if (!(/remove file|파일 제거|첨부 제거/i.test(label) || /^remove\\s+\\S.*\\.[A-Za-z0-9]{1,8}$/i.test(label))) return false;
|
|
5247
|
+
return b.getClientRects().length > 0;
|
|
5248
|
+
});
|
|
5173
5249
|
return { ok: true, removed: buttons.length };
|
|
5174
5250
|
})()`;
|
|
5175
5251
|
}
|
|
@@ -5197,24 +5273,43 @@ export async function attachFilesToComposer(cdp, absolutePaths, options = {}) {
|
|
|
5197
5273
|
// every later attach in that tab silently does nothing. A reload is the only
|
|
5198
5274
|
// reliable reset, and it costs a few seconds only when there is something to
|
|
5199
5275
|
// clear.
|
|
5200
|
-
|
|
5201
|
-
|
|
5202
|
-
|
|
5203
|
-
|
|
5204
|
-
|
|
5276
|
+
// "complete" is not "usable": the server-rendered editor is there before
|
|
5277
|
+
// the app attaches its file inputs (measured: complete at 3.3 s, inputs at
|
|
5278
|
+
// 5.1 s after a new-chat navigation), and every navigation path reaches
|
|
5279
|
+
// this point in that window sometimes. Wait for the input itself, before
|
|
5280
|
+
// the chip check (chips render with it) and before reading the DOM tree.
|
|
5281
|
+
const waitForComposerFileInput = async () => {
|
|
5282
|
+
const inputDeadline = Date.now() + COMPOSER_FILE_INPUT_TIMEOUT_MS;
|
|
5205
5283
|
for (;;) {
|
|
5206
|
-
|
|
5207
|
-
|
|
5208
|
-
|
|
5284
|
+
try {
|
|
5285
|
+
if (await cdp.evaluate(markComposerFileInputExpression()) === true)
|
|
5286
|
+
return true;
|
|
5287
|
+
}
|
|
5288
|
+
catch (error) {
|
|
5289
|
+
if (cdpCommandTimedOut(error))
|
|
5290
|
+
throw error;
|
|
5291
|
+
}
|
|
5292
|
+
if (Date.now() >= inputDeadline)
|
|
5293
|
+
return false;
|
|
5209
5294
|
await sleep(500);
|
|
5210
5295
|
}
|
|
5296
|
+
};
|
|
5297
|
+
let marked = await waitForComposerFileInput();
|
|
5298
|
+
const stale = await cdp.evaluate(attachmentPresenceExpression());
|
|
5299
|
+
if ((stale?.removed ?? 0) > 0) {
|
|
5300
|
+
// A fixed sleep and a composer check could both pass on the document being
|
|
5301
|
+
// left (a reload takes 5-8 s here), and the file then went into a page that
|
|
5302
|
+
// was replaced under it. The marked reload waits for the new document.
|
|
5303
|
+
await reloadAndAwaitComposer(cdp, 20_000);
|
|
5304
|
+
marked = await waitForComposerFileInput();
|
|
5211
5305
|
}
|
|
5212
5306
|
const document = await cdp.send("DOM.getDocument", { depth: -1, pierce: true });
|
|
5213
5307
|
const rootNodeId = document.result?.root?.nodeId;
|
|
5214
5308
|
if (rootNodeId === undefined)
|
|
5215
5309
|
throw new Error("Could not read the ChatGPT page DOM to attach files.");
|
|
5310
|
+
const selectors = marked ? [`[${PRODEX_ATTACH_INPUT_ATTRIBUTE}]`, ...composerFileInputSelectors()] : composerFileInputSelectors();
|
|
5216
5311
|
let inputNodeId;
|
|
5217
|
-
for (const selector of
|
|
5312
|
+
for (const selector of selectors) {
|
|
5218
5313
|
const input = await cdp.send("DOM.querySelector", { nodeId: rootNodeId, selector });
|
|
5219
5314
|
inputNodeId = input.result?.nodeId || undefined;
|
|
5220
5315
|
if (inputNodeId)
|
|
@@ -5406,20 +5501,41 @@ export async function enableComposerTools(cdp, labels) {
|
|
|
5406
5501
|
enabled.push(label);
|
|
5407
5502
|
continue;
|
|
5408
5503
|
}
|
|
5409
|
-
|
|
5504
|
+
// The server-rendered editor shows before the app attaches the composer's
|
|
5505
|
+
// controls (measured 2026-10-06), so a first miss is usually just early.
|
|
5506
|
+
let button = await cdp.evaluate(composerToolsButtonRectExpression());
|
|
5507
|
+
const buttonDeadline = Date.now() + COMPOSER_FILE_INPUT_TIMEOUT_MS;
|
|
5508
|
+
while ((!button.ok || button.x === undefined || button.y === undefined) && Date.now() < buttonDeadline) {
|
|
5509
|
+
await sleep(500);
|
|
5510
|
+
button = await cdp.evaluate(composerToolsButtonRectExpression());
|
|
5511
|
+
}
|
|
5410
5512
|
if (!button.ok || button.x === undefined || button.y === undefined) {
|
|
5411
5513
|
throw new Error(button.reason ?? "Could not open the ChatGPT composer tools menu");
|
|
5412
5514
|
}
|
|
5413
|
-
|
|
5515
|
+
// A click that lands before the app has wired the button opens nothing
|
|
5516
|
+
// (measured 2026-10-06), which looked exactly like a menu without the
|
|
5517
|
+
// tool. The button is clicked again, freshly located, before concluding
|
|
5518
|
+
// the tool is missing.
|
|
5414
5519
|
let entry = { ok: false };
|
|
5415
|
-
|
|
5416
|
-
|
|
5417
|
-
|
|
5520
|
+
for (let attempt = 0; attempt < COMPOSER_TOOLS_MENU_ATTEMPTS; attempt += 1) {
|
|
5521
|
+
if (attempt > 0) {
|
|
5522
|
+
await dispatchEscapeKey(cdp);
|
|
5523
|
+
const again = await cdp.evaluate(composerToolsButtonRectExpression());
|
|
5524
|
+
if (again.ok && again.x !== undefined && again.y !== undefined)
|
|
5525
|
+
button = again;
|
|
5526
|
+
}
|
|
5527
|
+
await dispatchMouseClickAt(cdp, button.x, button.y);
|
|
5528
|
+
const menuDeadline = Date.now() + 4_000;
|
|
5529
|
+
for (;;) {
|
|
5530
|
+
entry = await cdp.evaluate(composerToolEntryRectExpression(label));
|
|
5531
|
+
if (entry.ok && entry.x !== undefined && entry.y !== undefined)
|
|
5532
|
+
break;
|
|
5533
|
+
if (Date.now() >= menuDeadline)
|
|
5534
|
+
break;
|
|
5535
|
+
await sleep(250);
|
|
5536
|
+
}
|
|
5418
5537
|
if (entry.ok && entry.x !== undefined && entry.y !== undefined)
|
|
5419
5538
|
break;
|
|
5420
|
-
if (Date.now() >= menuDeadline)
|
|
5421
|
-
break;
|
|
5422
|
-
await sleep(250);
|
|
5423
5539
|
}
|
|
5424
5540
|
if (!entry.ok || entry.x === undefined || entry.y === undefined) {
|
|
5425
5541
|
await dispatchEscapeKey(cdp);
|
|
@@ -5944,7 +6060,7 @@ export function answerExpression() {
|
|
|
5944
6060
|
const tagged = node.closest('[data-message-model-slug]');
|
|
5945
6061
|
if (tagged) modelSlug = tagged.getAttribute('data-message-model-slug') || undefined;
|
|
5946
6062
|
}
|
|
5947
|
-
return { role, node, text: textNode
|
|
6063
|
+
return { role, node, text: renderedMessageText(textNode), modelSlug };
|
|
5948
6064
|
});
|
|
5949
6065
|
const assistantMessages = messages.filter((message) => message.role === "assistant");
|
|
5950
6066
|
const userMessages = messages.filter((message) => message.role === "user");
|
package/docs/releasing.md
CHANGED
|
@@ -16,6 +16,79 @@ Release target: `v0.40.13` on `main`. The GitHub Release records the resolved co
|
|
|
16
16
|
|
|
17
17
|
The release adds a guarded, opt-in transition and more accurate blocker reporting. It does not claim that headless Pro consultation is verified on either deployment target or that authentication can be retained indefinitely.
|
|
18
18
|
|
|
19
|
+
## 0.40.26 Release Record
|
|
20
|
+
|
|
21
|
+
Release commit `b413c24` on `main` (cut after PR #16, merge `af5cf20`); tag `v0.40.26`.
|
|
22
|
+
|
|
23
|
+
**Publishing**
|
|
24
|
+
- `publish.yml` run 37405732176 passed all six platforms on the first attempt, then npm publish with SLSA v1 provenance and the GitHub Release (2026-10-06 03:07 UTC).
|
|
25
|
+
- PR #16's own CI needed one rerun on macOS Intel for an unrelated 30 s timeout in `tests/mcp-consult.test.ts`.
|
|
26
|
+
- `npm run release:verify` passed on the release commit after a build.
|
|
27
|
+
|
|
28
|
+
**What it fixes**
|
|
29
|
+
- The ChatGPT page measured on 2026-10-06 broke several flows:
|
|
30
|
+
- Every project send was refused because a hidden, still-mounted conversation was counted.
|
|
31
|
+
- Attachments did not recognize timestamp-renamed uploads, picked a hidden composer form, and ran before the file input existed.
|
|
32
|
+
- Leftover chips were no longer detected.
|
|
33
|
+
- Composer tools were looked up or clicked before they worked.
|
|
34
|
+
- Found by a live matrix against the installed 0.40.25, where `--project` sends, project attachments, new-chat attachments after a repeat upload and `--tool create-image` failed.
|
|
35
|
+
|
|
36
|
+
**Installation on 2026-10-06**
|
|
37
|
+
- Global npm, each verified with `prodex --version` = 0.40.26.
|
|
38
|
+
- WSL, M3, Mac mini: installed. Running services were not restarted: 11 stdio MCP servers on WSL and 6 on M3 keep older code until their clients reconnect. The Mac mini had none running.
|
|
39
|
+
- M4: not reachable (hostname does not resolve); not updated.
|
|
40
|
+
|
|
41
|
+
**Live check after install (installed 0.40.26, shared WSL browser)**
|
|
42
|
+
All eight answered:
|
|
43
|
+
- `--project` plain and with `--attach`
|
|
44
|
+
- `--tool create-image`
|
|
45
|
+
- `--new-chat --attach`
|
|
46
|
+
- `--continue --attach`
|
|
47
|
+
- `--tool web-search --attach`
|
|
48
|
+
- `--temporary --attach`
|
|
49
|
+
- plain `--continue`
|
|
50
|
+
|
|
51
|
+
**Browser container, same day**
|
|
52
|
+
- Before the rebuild, the container build failed the same way: a project send was refused and a new-chat attachment timed out. Its tab then stopped answering CDP.
|
|
53
|
+
- The same fixes were adapted to the branch code, where the transcript reader and the hover-verified tools click differ. The image `sha256:8e5734ee733c` was rebuilt from `b5eac7d` with all patches so far. These are still uncommitted, and nothing was pushed to `feat/headless-browser-compatibility`.
|
|
54
|
+
- Branch suite: 1998 passed. The swap was gated on the send lock being free.
|
|
55
|
+
- Verified in the container, all seven answered:
|
|
56
|
+
- a project send
|
|
57
|
+
- a new-chat attachment
|
|
58
|
+
- an attachment on a continued thread
|
|
59
|
+
- a project attachment
|
|
60
|
+
- create-image
|
|
61
|
+
- web search with an attachment
|
|
62
|
+
- a temporary chat with an attachment
|
|
63
|
+
- An MCP client reconnect was needed after the swap.
|
|
64
|
+
|
|
65
|
+
## 0.40.25 Release Record
|
|
66
|
+
|
|
67
|
+
Release commit `3c66da3` on `main` (cut after PR #15, merge `9bd908c`); tag `v0.40.25`.
|
|
68
|
+
|
|
69
|
+
**Publishing**
|
|
70
|
+
- `publish.yml` run 37394019059 passed all six platforms on the first attempt, then npm publish with SLSA v1 provenance and the GitHub Release (2026-10-06 00:48 UTC).
|
|
71
|
+
- `npm run release:verify` passed on the release commit after a build.
|
|
72
|
+
|
|
73
|
+
**What it fixes**
|
|
74
|
+
- A send into an existing conversation from a tab on another one now waits for that page to finish loading.
|
|
75
|
+
|
|
76
|
+
**Installation on 2026-10-06**
|
|
77
|
+
- Global npm, each verified with `prodex --version` = 0.40.25.
|
|
78
|
+
- WSL, M3, Mac mini: installed. Running services were not restarted: 19 stdio MCP servers on WSL and 7 on M3 keep older code until their clients reconnect. The Mac mini had none running.
|
|
79
|
+
- M4: not reachable (hostname does not resolve); not updated.
|
|
80
|
+
- The browser container already carries the same fix (2026-10-02 rebuild, see 0.40.24). An MCP consult through it answered on 2026-10-06 after the client reconnected.
|
|
81
|
+
|
|
82
|
+
**Live check after install (installed 0.40.25, shared WSL browser)**
|
|
83
|
+
- The tab was first moved to a new chat.
|
|
84
|
+
- `--continue --attach` answered, with a baseline of 5 earlier user messages.
|
|
85
|
+
- `--new-chat --attach` answered.
|
|
86
|
+
- A plain `--continue` recalled the word set at the start of the thread.
|
|
87
|
+
|
|
88
|
+
**Real use since 0.40.24 (2026-10-02 to 2026-10-06)**
|
|
89
|
+
- `prodex pro blockers`: 3 of 18 host consults were blocked, all three the probes that reproduced the bugs fixed in 0.40.24 and 0.40.25.
|
|
90
|
+
- 0 of 5 container consults were blocked.
|
|
91
|
+
|
|
19
92
|
## 0.40.24 Release Record
|
|
20
93
|
|
|
21
94
|
Release commit `e927eee` on `main` (cut after PR #14, merge `393f720`); tag `v0.40.24`.
|