@youdie006/prodex 0.40.2 → 0.40.4
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/blocker-report.js +7 -1
- package/dist/browser-send-lock.js +21 -1
- package/dist/chatgpt-browser.js +351 -51
- package/dist/cli-args.js +5 -0
- package/dist/cli-help.js +9 -4
- package/dist/cli-pro.js +165 -33
- package/dist/config.js +45 -6
- package/dist/continue-thread.js +133 -0
- package/dist/mcp.js +12 -2
- package/package.json +1 -1
package/dist/chatgpt-browser.js
CHANGED
|
@@ -437,17 +437,29 @@ export function resolveHeadlessPreference(explicit, env = process.env) {
|
|
|
437
437
|
const raw = (env.PRODEX_HEADLESS ?? "").trim().toLowerCase();
|
|
438
438
|
return raw === "1" || raw === "true" || raw === "yes";
|
|
439
439
|
}
|
|
440
|
-
|
|
440
|
+
/**
|
|
441
|
+
* The two verdicts read different text on purpose.
|
|
442
|
+
*
|
|
443
|
+
* `text` answers "is this a login screen": it has to be the sample with chat
|
|
444
|
+
* MESSAGES excluded, or an old conversation quoting a signup page reports the
|
|
445
|
+
* session as logged out.
|
|
446
|
+
*
|
|
447
|
+
* `loggedInSignalText` answers "is the app here": the sidebar furniture, which
|
|
448
|
+
* survives in whichever sample happened to keep it. Measured live, that is not
|
|
449
|
+
* always the filtered one - on a project home the filter left 111 characters
|
|
450
|
+
* of a banner while document.body.innerText held the whole sidebar.
|
|
451
|
+
*/
|
|
452
|
+
export function inferLoggedInLikely(text, visibleButtonLabels = [], loggedInSignalText = text) {
|
|
441
453
|
// Only sign-up prompts and explicit login/sign-up buttons count as logged-out signals. Bare
|
|
442
454
|
// "Log in"/"로그인" substrings appear in the menus and footers of a logged-in page, so matching
|
|
443
455
|
// them against the full page text falsely reported logged-in Pro users as logged out.
|
|
444
456
|
const hasLoginPrompt = text.includes("Sign up for free") ||
|
|
445
457
|
text.includes("무료로 가입") ||
|
|
446
458
|
visibleButtonLabels.some((label) => /^(log in|sign up|로그인|회원가입)$/i.test(label.trim()));
|
|
447
|
-
const hasNewChat =
|
|
448
|
-
const hasProjectNav =
|
|
459
|
+
const hasNewChat = loggedInSignalText.includes("New chat") || loggedInSignalText.includes("새 채팅");
|
|
460
|
+
const hasProjectNav = loggedInSignalText.includes("Projects") || loggedInSignalText.includes("프로젝트");
|
|
449
461
|
const hasProfileButton = visibleButtonLabels.some((label) => /profile|account|프로필|계정/i.test(label));
|
|
450
|
-
const hasPlanHint = /\bPro\b|Plus|Team|Enterprise|매우 높음|Extra High/i.test(
|
|
462
|
+
const hasPlanHint = /\bPro\b|Plus|Team|Enterprise|매우 높음|Extra High/i.test(loggedInSignalText);
|
|
451
463
|
return !hasLoginPrompt && hasNewChat && (hasProfileButton || hasProjectNav || hasPlanHint);
|
|
452
464
|
}
|
|
453
465
|
export function isUsableChatGptAnswer(answer) {
|
|
@@ -491,6 +503,17 @@ export function isUsableChatGptAnswer(answer) {
|
|
|
491
503
|
return false;
|
|
492
504
|
return true;
|
|
493
505
|
}
|
|
506
|
+
/**
|
|
507
|
+
* What comes back when the turn finished and wrote no text.
|
|
508
|
+
*
|
|
509
|
+
* Measured with `--tool create-image`: the image was generated and rendered,
|
|
510
|
+
* the assistant message in the transcript came back finished with an empty
|
|
511
|
+
* part (the image lives in a separate tool message), and prodex waited out the
|
|
512
|
+
* whole budget before reporting a timeout for a result that was already there.
|
|
513
|
+
* The thread URL travels with every answer, so saying so and pointing at it
|
|
514
|
+
* beats six minutes of silence.
|
|
515
|
+
*/
|
|
516
|
+
export const CHATGPT_NON_TEXT_ANSWER_NOTE = "[no text answer] ChatGPT finished this turn without writing any text - an image or another non-text result. Open the thread to see it.";
|
|
494
517
|
/**
|
|
495
518
|
* Who decides the answer is finished: the transcript, when it can be read.
|
|
496
519
|
*
|
|
@@ -508,9 +531,16 @@ export function classifyTranscriptRead(state, sentPrompt) {
|
|
|
508
531
|
return "unavailable";
|
|
509
532
|
if (state.ok)
|
|
510
533
|
return "answer";
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
534
|
+
// A turn that FINISHED with no text is not a turn still being written. The
|
|
535
|
+
// transcript only reports answer_empty after checking the turn ended, and
|
|
536
|
+
// calling it "pending" is what made an image request wait out its whole
|
|
537
|
+
// budget: measured, `--tool create-image` produced the image, its assistant
|
|
538
|
+
// message came back finished with an empty part, and the send spent six and
|
|
539
|
+
// a half minutes "stabilizing" before reporting a timeout for an answer that
|
|
540
|
+
// was sitting in the thread.
|
|
541
|
+
if (state.reason === "answer_empty")
|
|
542
|
+
return "no_text";
|
|
543
|
+
return state.reason === "answer_not_finished" || state.reason === "no_assistant_message" ? "pending" : "unavailable";
|
|
514
544
|
}
|
|
515
545
|
/**
|
|
516
546
|
* Should prodex drag the tab back to the thread it pinned?
|
|
@@ -528,8 +558,11 @@ export function shouldRecoverThreadNavigation(args) {
|
|
|
528
558
|
return false;
|
|
529
559
|
if (!conversationIdFromThreadUrl(pinnedThreadUrl))
|
|
530
560
|
return false;
|
|
531
|
-
if (lastTranscriptClassification === "pending" ||
|
|
561
|
+
if (lastTranscriptClassification === "pending" ||
|
|
562
|
+
lastTranscriptClassification === "answer" ||
|
|
563
|
+
lastTranscriptClassification === "no_text") {
|
|
532
564
|
return false;
|
|
565
|
+
}
|
|
533
566
|
return !chatGptUrlsReferToSameTarget(currentUrl, pinnedThreadUrl);
|
|
534
567
|
}
|
|
535
568
|
export function hasFreshChatGptAnswer(previousAssistantMessageCount, state) {
|
|
@@ -842,9 +875,23 @@ export function detectChatGptPageBlocker(state) {
|
|
|
842
875
|
return detectChatGptBlocker(state.blockerScanTextSample ?? state.blockerTextSample ?? state.textSample, state.visibleButtonLabels);
|
|
843
876
|
}
|
|
844
877
|
export function inferChatGptPageLoggedInLikely(state) {
|
|
845
|
-
//
|
|
846
|
-
//
|
|
847
|
-
|
|
878
|
+
// The logged-in signals live in the sidebar - "New chat", "Projects", the
|
|
879
|
+
// plan hint - so this needs the sample that HAS the sidebar in it. That was
|
|
880
|
+
// meant to be blockerTextSample, and measured live on a project home it is
|
|
881
|
+
// not: its text walk keeps only nodes whose own parent has a box, and what
|
|
882
|
+
// survived there was 111 characters of a promotional banner while
|
|
883
|
+
// document.body.innerText carried the whole sidebar. So a logged-in Pro
|
|
884
|
+
// account on a working page was told to go and log in.
|
|
885
|
+
//
|
|
886
|
+
// The logged-OUT question keeps the message-excluded sample, so a chat
|
|
887
|
+
// quoting a signup page cannot report the session as dead. The logged-IN
|
|
888
|
+
// question reads both, because the sidebar turns up in whichever one kept
|
|
889
|
+
// it - and being wrong in that direction is caught at once by the composer
|
|
890
|
+
// check beside it, while being wrong the other way tells someone with a
|
|
891
|
+
// working browser to go and log in.
|
|
892
|
+
const messageExcluded = state.blockerTextSample ?? state.textSample;
|
|
893
|
+
const anySample = [state.textSample, state.blockerTextSample].filter(Boolean).join(String.fromCharCode(10));
|
|
894
|
+
return inferLoggedInLikely(messageExcluded, state.visibleButtonLabels, anySample);
|
|
848
895
|
}
|
|
849
896
|
function hasLikelyChatGptLoginPrompt(haystack) {
|
|
850
897
|
const hasSpecificSignup = /sign up for free|무료로 가입/i.test(haystack);
|
|
@@ -1334,6 +1381,49 @@ async function dispatchEscapeKey(cdp) {
|
|
|
1334
1381
|
* stamp is what proves the old document is gone; the URL alone can be read off
|
|
1335
1382
|
* the very page we are trying to leave.
|
|
1336
1383
|
*/
|
|
1384
|
+
/**
|
|
1385
|
+
* Put the tab on a specific conversation, for a thread prodex itself resolved.
|
|
1386
|
+
*/
|
|
1387
|
+
async function openChatGptThread(cdp, url) {
|
|
1388
|
+
const conversationId = conversationIdFromThreadUrl(url);
|
|
1389
|
+
if (!conversationId)
|
|
1390
|
+
throw new Error(`Not a ChatGPT conversation URL: ${url}`);
|
|
1391
|
+
await cdp.evaluate(`location.assign(${JSON.stringify(url)})`);
|
|
1392
|
+
const deadline = Date.now() + RELOAD_SETTLE_TIMEOUT_MS;
|
|
1393
|
+
while (Date.now() < deadline) {
|
|
1394
|
+
await sleep(250);
|
|
1395
|
+
try {
|
|
1396
|
+
if (await cdp.evaluate(chatGptThreadReadyExpression(conversationId)))
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
catch (error) {
|
|
1400
|
+
// Between documents the context is gone and the next poll lands on the
|
|
1401
|
+
// new one; a command timeout means the tab stopped answering.
|
|
1402
|
+
if (cdpCommandTimedOut(error))
|
|
1403
|
+
throw error;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
throw new ChatGptBrowserBlockerError(chatGptThreadUnavailableBlocker(url));
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* The conversation a follow-up names cannot be opened.
|
|
1410
|
+
*
|
|
1411
|
+
* Retrying cannot undelete a thread, and the generic "resolve the visible
|
|
1412
|
+
* browser issue manually" this used to fall back to describes a browser that
|
|
1413
|
+
* is working fine - measured on a thread whose project had been deleted: the
|
|
1414
|
+
* cause was named in the message and then thrown away by the catch-all next
|
|
1415
|
+
* step underneath it.
|
|
1416
|
+
*/
|
|
1417
|
+
export function chatGptThreadUnavailableBlocker(url) {
|
|
1418
|
+
return {
|
|
1419
|
+
code: "thread_unavailable",
|
|
1420
|
+
message: `ChatGPT did not open the conversation to continue (${url}). It may have been deleted, or its project was.`,
|
|
1421
|
+
retryable: false,
|
|
1422
|
+
next_step: "That conversation cannot be reached, and retrying will not bring it back. Send without --continue to start a new one, " +
|
|
1423
|
+
"or name a different consult with --continue-task <task_id> (`prodex pro list` shows them).",
|
|
1424
|
+
thread: url
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1337
1427
|
async function openFreshChatGptHome(cdp) {
|
|
1338
1428
|
await cdp.evaluate(markDocumentForReloadExpression());
|
|
1339
1429
|
await cdp.evaluate(`location.assign("https://chatgpt.com/")`);
|
|
@@ -1615,6 +1705,20 @@ export function reloadedDocumentReadyExpression(extraCondition = "true") {
|
|
|
1615
1705
|
* home that fails to load, and the composer candidate is the real editor
|
|
1616
1706
|
* rather than the broad selector that also matches a hidden fallback.
|
|
1617
1707
|
*/
|
|
1708
|
+
/**
|
|
1709
|
+
* True once the tab is on this conversation and has rendered a real composer.
|
|
1710
|
+
*
|
|
1711
|
+
* The id is compared rather than the whole URL because ChatGPT rewrites the
|
|
1712
|
+
* project part of it (measured: the same project appears with and without its
|
|
1713
|
+
* name), and the composer has to be the real editor rather than the hidden 0x0
|
|
1714
|
+
* fallback that the broad selector also matches.
|
|
1715
|
+
*/
|
|
1716
|
+
export function chatGptThreadReadyExpression(conversationId) {
|
|
1717
|
+
return `(() => {${composerExpressionHelpers()}
|
|
1718
|
+
if (!location.href.includes(${JSON.stringify(conversationId)})) return false;
|
|
1719
|
+
return Boolean(findChatGptComposerCandidate());
|
|
1720
|
+
})()`;
|
|
1721
|
+
}
|
|
1618
1722
|
export function freshChatGptHomeReadyExpression() {
|
|
1619
1723
|
return reloadedDocumentReadyExpression(`/^https:\\/\\/chatgpt\\.com\\/?(?:[?#].*)?$/.test(location.href) && (() => {${composerExpressionHelpers()}
|
|
1620
1724
|
return Boolean(findChatGptComposerCandidate());
|
|
@@ -1731,9 +1835,13 @@ export function composerProjectBinding(input) {
|
|
|
1731
1835
|
const named = /^new\s+chat\s+in\s+(.+)$/i.exec(placeholder)?.[1] ?? /^(.+?)\uc5d0\uc11c\s*\uc0c8\s*\ucc44\ud305$/.exec(placeholder)?.[1];
|
|
1732
1836
|
if (named)
|
|
1733
1837
|
return named.trim().toLowerCase() === wanted ? "bound" : "elsewhere";
|
|
1734
|
-
// The
|
|
1735
|
-
//
|
|
1736
|
-
|
|
1838
|
+
// The label a plain new chat carries: recognised, and it names no project, so
|
|
1839
|
+
// the composer belongs to none. Two wordings measured on the same live root -
|
|
1840
|
+
// the hidden fallback textarea says "Ask ChatGPT" while the editor prodex
|
|
1841
|
+
// actually reads says "Chat with ChatGPT" - and reading only the first left
|
|
1842
|
+
// the clearest case of "this composer is not the project's" reported as a
|
|
1843
|
+
// label that could not be read.
|
|
1844
|
+
if (/^(?:ask|chat with)\s+chatgpt$/i.test(placeholder))
|
|
1737
1845
|
return "elsewhere";
|
|
1738
1846
|
return "unknown";
|
|
1739
1847
|
}
|
|
@@ -1893,11 +2001,18 @@ export function projectItemRectExpression(name) {
|
|
|
1893
2001
|
}
|
|
1894
2002
|
let target = opt ? (opt.closest('a,[role="link"],li') || opt.parentElement) : null;
|
|
1895
2003
|
if (!target) {
|
|
2004
|
+
// The fallback for a sidebar whose option buttons this cannot read. It
|
|
2005
|
+
// used to take the first row CONTAINING the name, which is the substring
|
|
2006
|
+
// match the exact comparison above exists to prevent: asking for "Codex"
|
|
2007
|
+
// took "Codex Review" and sent the prompt into a project nobody named.
|
|
2008
|
+
// A row's first line is its name; anything else here is a guess, and a
|
|
2009
|
+
// guess about which project to post into is the failure being fixed.
|
|
1896
2010
|
const icons = [...document.querySelectorAll('[data-testid="project-folder-icon"]')];
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
}
|
|
2011
|
+
const rowName = (row) => ((row.innerText || row.textContent || "").split("\\n").map((line) => line.trim()).find((line) => line.length > 0) || "");
|
|
2012
|
+
const rows = icons.map((ic) => ic.closest('a,li,[role="link"]') || ic.parentElement?.parentElement).filter(Boolean);
|
|
2013
|
+
const named = rows.filter((row) => rowName(row).toLowerCase() === wanted.toLowerCase());
|
|
2014
|
+
if (named.length > 1) return { ok: false, reason: "project name matches multiple sidebar projects; rename one to disambiguate" };
|
|
2015
|
+
if (named.length === 1) target = named[0];
|
|
1901
2016
|
}
|
|
1902
2017
|
if (!target) {
|
|
1903
2018
|
return { ok: false, reason: "project not found in sidebar (" + optionButtons.length + " projects visible; names are matched exactly first, then case-insensitively - check the exact sidebar spelling)" };
|
|
@@ -2093,6 +2208,21 @@ async function selectPickerModel(cdp, requested, warnings = []) {
|
|
|
2093
2208
|
* without this the step that follows reported "the picker did not expose its
|
|
2094
2209
|
* power slider" on a picker that was simply shut.
|
|
2095
2210
|
*/
|
|
2211
|
+
/**
|
|
2212
|
+
* How to apply the stored Chat-surface preference on a page that renders no
|
|
2213
|
+
* surface toggle at all.
|
|
2214
|
+
*
|
|
2215
|
+
* Reloading in place is the only option for a send that has to stay where it
|
|
2216
|
+
* is - a pinned thread, a continuation. On a project home it is the wrong one:
|
|
2217
|
+
* a hard load of one comes back as ChatGPT's error page (measured on two
|
|
2218
|
+
* projects), which leaves the send on a document with no sidebar, and the
|
|
2219
|
+
* project step then reports the project missing from a sidebar that was never
|
|
2220
|
+
* drawn. A send that is going to navigate anyway takes the root, which loads.
|
|
2221
|
+
*/
|
|
2222
|
+
export function chatSurfaceRecoveryPlan(input) {
|
|
2223
|
+
const onProjectPage = /^https:\/\/chatgpt\.com\/g\/g-p-/.test(input.href);
|
|
2224
|
+
return input.mayLeaveCurrentPage && onProjectPage ? "fresh-root" : "reload";
|
|
2225
|
+
}
|
|
2096
2226
|
/**
|
|
2097
2227
|
* Put the browser back on ChatGPT's Chat surface when it has drifted onto Work.
|
|
2098
2228
|
*
|
|
@@ -2100,7 +2230,7 @@ async function selectPickerModel(cdp, requested, warnings = []) {
|
|
|
2100
2230
|
* the page announces which one is live, so a drifted browser silently drives
|
|
2101
2231
|
* the wrong picker. Returns a warning to carry to the caller when it moved.
|
|
2102
2232
|
*/
|
|
2103
|
-
async function ensureChatSurface(cdp) {
|
|
2233
|
+
async function ensureChatSurface(cdp, options) {
|
|
2104
2234
|
const read = async () => {
|
|
2105
2235
|
try {
|
|
2106
2236
|
return await cdp.evaluate(chatSurfaceProbeExpression());
|
|
@@ -2158,8 +2288,23 @@ async function ensureChatSurface(cdp) {
|
|
|
2158
2288
|
try {
|
|
2159
2289
|
await cdp.evaluate(selectChatSurfaceExpression());
|
|
2160
2290
|
// Reading the persisted value back right after writing it proves nothing;
|
|
2161
|
-
// the
|
|
2162
|
-
|
|
2291
|
+
// the new document rendering its composer is what proves the switch.
|
|
2292
|
+
const plan = chatSurfaceRecoveryPlan({
|
|
2293
|
+
href: await cdp.evaluate("location.href"),
|
|
2294
|
+
mayLeaveCurrentPage: options.mayLeaveCurrentPage
|
|
2295
|
+
});
|
|
2296
|
+
let applied;
|
|
2297
|
+
if (plan === "fresh-root") {
|
|
2298
|
+
// Throws when the root never rendered a composer, which the catch below
|
|
2299
|
+
// turns into the same "could not be switched back" warning as a reload
|
|
2300
|
+
// that never settled.
|
|
2301
|
+
await openFreshChatGptHome(cdp);
|
|
2302
|
+
applied = true;
|
|
2303
|
+
}
|
|
2304
|
+
else {
|
|
2305
|
+
applied = await reloadAndAwaitComposer(cdp, RELOAD_SETTLE_TIMEOUT_MS);
|
|
2306
|
+
}
|
|
2307
|
+
if (applied && (await confirm()))
|
|
2163
2308
|
return note;
|
|
2164
2309
|
}
|
|
2165
2310
|
catch (error) {
|
|
@@ -2601,11 +2746,27 @@ async function createChatGptProject(cdp, name) {
|
|
|
2601
2746
|
}
|
|
2602
2747
|
}
|
|
2603
2748
|
/** The placeholder of the composer the send will actually type into. */
|
|
2604
|
-
|
|
2749
|
+
/**
|
|
2750
|
+
* Read the label the composer carries, wherever it keeps it.
|
|
2751
|
+
*
|
|
2752
|
+
* Measured live on a project home: the editor prodex types into is the
|
|
2753
|
+
* contenteditable div, and it carries the label ONLY as `aria-label` -
|
|
2754
|
+
* `data-placeholder` is null on it. The 0x0 textarea beside it does carry
|
|
2755
|
+
* `placeholder`, but the composer finder rejects that one on size, exactly as
|
|
2756
|
+
* it should. Reading a single attribute meant the label was there and prodex
|
|
2757
|
+
* could not see it, so every project send refused with "the placeholder could
|
|
2758
|
+
* not be read" - the fail-closed branch doing its job on a page that was fine.
|
|
2759
|
+
*/
|
|
2760
|
+
export function composerProjectBindingExpression() {
|
|
2605
2761
|
return `(() => {${composerExpressionHelpers()}
|
|
2606
2762
|
const node = findChatGptComposerCandidate();
|
|
2607
2763
|
if (!node) return { found: false };
|
|
2608
|
-
|
|
2764
|
+
const label =
|
|
2765
|
+
node.getAttribute("data-placeholder") ||
|
|
2766
|
+
node.getAttribute("placeholder") ||
|
|
2767
|
+
node.getAttribute("aria-label") ||
|
|
2768
|
+
"";
|
|
2769
|
+
return { found: true, placeholder: label };
|
|
2609
2770
|
})()`;
|
|
2610
2771
|
}
|
|
2611
2772
|
/**
|
|
@@ -2620,9 +2781,20 @@ async function waitForComposerProjectBinding(cdp, project, timeoutMs) {
|
|
|
2620
2781
|
const deadline = Date.now() + timeoutMs;
|
|
2621
2782
|
let verdict = "unknown";
|
|
2622
2783
|
for (;;) {
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
.
|
|
2784
|
+
let read;
|
|
2785
|
+
try {
|
|
2786
|
+
read = await cdp.evaluate(composerProjectBindingExpression());
|
|
2787
|
+
}
|
|
2788
|
+
catch (error) {
|
|
2789
|
+
// A read that lands between documents answers about neither, and the next
|
|
2790
|
+
// poll lands on the new one. A command timeout is a different thing: the
|
|
2791
|
+
// tab stopped answering, and swallowing it spent this whole budget and
|
|
2792
|
+
// the recovery's before refusing with "the placeholder could not be
|
|
2793
|
+
// read" - a binding failure reported for a browser that was gone.
|
|
2794
|
+
if (cdpCommandTimedOut(error))
|
|
2795
|
+
throw error;
|
|
2796
|
+
read = { found: false };
|
|
2797
|
+
}
|
|
2626
2798
|
if (read.found) {
|
|
2627
2799
|
const sample = composerProjectBinding({
|
|
2628
2800
|
...(read.placeholder !== undefined ? { placeholder: read.placeholder } : {}),
|
|
@@ -2691,6 +2863,35 @@ async function navigateToExistingProject(cdp, project) {
|
|
|
2691
2863
|
}
|
|
2692
2864
|
}
|
|
2693
2865
|
}
|
|
2866
|
+
/**
|
|
2867
|
+
* The refusal to post into a project prodex cannot confirm, as a blocker.
|
|
2868
|
+
*
|
|
2869
|
+
* It used to be a plain Error whose English prose a regex in the CLI matched to
|
|
2870
|
+
* recover the code - so the difference between "the composer belongs elsewhere"
|
|
2871
|
+
* and "the browser stopped answering" survived only as a sentence, and a
|
|
2872
|
+
* reworded message would have quietly become an unclassified send failure. The
|
|
2873
|
+
* wording still carries the phrase the classifier keys on, because older
|
|
2874
|
+
* senders and other paths still reach it as text.
|
|
2875
|
+
*
|
|
2876
|
+
* The project it OFFERED instead is deliberately absent: naming it would put
|
|
2877
|
+
* another project's name in a persisted record, which redaction covers only for
|
|
2878
|
+
* the ones this send asked for.
|
|
2879
|
+
*/
|
|
2880
|
+
export function projectNotBoundBlocker(input) {
|
|
2881
|
+
const detail = input.reason === "elsewhere"
|
|
2882
|
+
? "after entering it, the composer still offers a chat that belongs somewhere else"
|
|
2883
|
+
: input.reason === "unknown"
|
|
2884
|
+
? "after entering it, the composer's placeholder could not be read, so where the prompt would land is unknown"
|
|
2885
|
+
: "it read as this project's after entering it and no longer does";
|
|
2886
|
+
return {
|
|
2887
|
+
code: "project_not_bound",
|
|
2888
|
+
message: `ChatGPT composer did not bind to project "${input.project}": ${detail}, so nothing was sent.` +
|
|
2889
|
+
(input.recoveryNote ?? ""),
|
|
2890
|
+
retryable: true,
|
|
2891
|
+
next_step: "Nothing was sent, so nothing landed in the wrong project. Retry - the composer normally binds on the next navigation - " +
|
|
2892
|
+
"or open the project once in the visible browser and send again."
|
|
2893
|
+
};
|
|
2894
|
+
}
|
|
2694
2895
|
/**
|
|
2695
2896
|
* The project name the composer has to agree with before anything is typed,
|
|
2696
2897
|
* or undefined for a send that pins no project.
|
|
@@ -2715,11 +2916,18 @@ export function composerBindingTarget(options) {
|
|
|
2715
2916
|
async function selectProject(cdp, options) {
|
|
2716
2917
|
const wanted = composerBindingTarget(options);
|
|
2717
2918
|
if (!wanted)
|
|
2718
|
-
return;
|
|
2919
|
+
return undefined;
|
|
2719
2920
|
if (options.projectNew)
|
|
2720
2921
|
await createChatGptProject(cdp, options.projectNew);
|
|
2721
|
-
|
|
2922
|
+
// Clicking the sidebar row when the composer already belongs to this project
|
|
2923
|
+
// is work that can only fail. Measured: a send into the project the tab was
|
|
2924
|
+
// already sitting in refused with "another element covers its click point" -
|
|
2925
|
+
// a promotional banner over the sidebar - on a page that was ready to accept
|
|
2926
|
+
// the prompt. The read below is the same evidence the gate accepts, so a
|
|
2927
|
+
// composer that already answers with this project needs no navigation.
|
|
2928
|
+
else if ((await waitForComposerProjectBinding(cdp, options.project, 0)) !== "bound") {
|
|
2722
2929
|
await navigateToExistingProject(cdp, options.project);
|
|
2930
|
+
}
|
|
2723
2931
|
// A sidebar SPA navigation moves the URL to the target project while the
|
|
2724
2932
|
// composer can stay bound to the PREVIOUS project's conversation target, so
|
|
2725
2933
|
// the send silently creates the thread in the OLD project (reproduced live
|
|
@@ -2766,20 +2974,17 @@ async function selectProject(cdp, options) {
|
|
|
2766
2974
|
// read is not evidence that the composer is this project's, and what it
|
|
2767
2975
|
// guards against - a prompt posted into another project, recorded under
|
|
2768
2976
|
// the requested one - costs far more than a send the caller can retry.
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
? "the composer still offers a chat that belongs somewhere else"
|
|
2775
|
-
: "the composer's placeholder could not be read, so where the prompt would land is unknown";
|
|
2776
|
-
throw new Error(`ChatGPT composer did not bind to project "${wanted}": after entering it, ${detail}, ` +
|
|
2777
|
-
`so nothing was sent.${recoveryNote}`);
|
|
2977
|
+
throw new ChatGptBrowserBlockerError(projectNotBoundBlocker({
|
|
2978
|
+
project: wanted,
|
|
2979
|
+
reason: binding,
|
|
2980
|
+
...(recoveryNote ? { recoveryNote } : {})
|
|
2981
|
+
}));
|
|
2778
2982
|
}
|
|
2779
2983
|
const composerReady = await waitForExpressionTrue(cdp, `Boolean(document.querySelector('#prompt-textarea,[contenteditable="true"],textarea'))`, PROJECT_NAVIGATION_TIMEOUT_MS);
|
|
2780
2984
|
if (!composerReady) {
|
|
2781
2985
|
throw new Error(`ChatGPT composer did not appear after entering project "${wanted}"`);
|
|
2782
2986
|
}
|
|
2987
|
+
return chatGptProjectIdFromUrl(await cdp.evaluate("location.href"));
|
|
2783
2988
|
}
|
|
2784
2989
|
// Read the finished answer from an existing ChatGPT thread WITHOUT sending a new
|
|
2785
2990
|
// prompt. Recovers a consult whose send timed out but whose answer ChatGPT
|
|
@@ -2923,7 +3128,7 @@ async function readTranscriptAnswer(page, conversationId, sentPrompt) {
|
|
|
2923
3128
|
const answer = resolveTranscriptCitations(transcript.text, transcript.references).trim();
|
|
2924
3129
|
return answer.length > 0
|
|
2925
3130
|
? { classification: "answer", answer: { answer, modelSlug: transcript.modelSlug } }
|
|
2926
|
-
: { classification: "
|
|
3131
|
+
: { classification: "no_text" };
|
|
2927
3132
|
}
|
|
2928
3133
|
// A page that has not reported the prompt posting within this long is worth
|
|
2929
3134
|
// double-checking against the transcript; the probe is a couple of small fetches.
|
|
@@ -2963,7 +3168,10 @@ export async function sendChatGptPrompt(options) {
|
|
|
2963
3168
|
if (options.newChat && normalizedTargetUrl) {
|
|
2964
3169
|
throw new Error("newChat cannot be combined with targetUrl: a fresh chat navigates away from the pinned tab.");
|
|
2965
3170
|
}
|
|
2966
|
-
|
|
3171
|
+
// A resolved thread is reached by navigating, so page discovery must not
|
|
3172
|
+
// demand a tab already sitting on it.
|
|
3173
|
+
const requireTabAtTargetUrl = options.navigateToTargetUrl ? undefined : normalizedTargetUrl;
|
|
3174
|
+
const pageResult = await findChatGptPage(port, computePageDiscoveryTimeout(timeoutMs), requireTabAtTargetUrl);
|
|
2967
3175
|
if (!pageResult.ok) {
|
|
2968
3176
|
throwBlockerOrError(pageResult.blocker, "ChatGPT browser page is not available");
|
|
2969
3177
|
}
|
|
@@ -2971,8 +3179,8 @@ export async function sendChatGptPrompt(options) {
|
|
|
2971
3179
|
if (pageResult.blocker) {
|
|
2972
3180
|
throw new ChatGptBrowserBlockerError(pageResult.blocker);
|
|
2973
3181
|
}
|
|
2974
|
-
if (
|
|
2975
|
-
assertChatGptTargetTabAvailable(
|
|
3182
|
+
if (requireTabAtTargetUrl) {
|
|
3183
|
+
assertChatGptTargetTabAvailable(requireTabAtTargetUrl);
|
|
2976
3184
|
}
|
|
2977
3185
|
assertChatGptPageAvailable();
|
|
2978
3186
|
}
|
|
@@ -3119,14 +3327,21 @@ export async function sendChatGptPrompt(options) {
|
|
|
3119
3327
|
awaitingResponseChoice: status.awaitingResponseChoice === true,
|
|
3120
3328
|
...(options.newChat !== undefined ? { newChat: options.newChat } : {}),
|
|
3121
3329
|
...(options.project !== undefined ? { project: options.project } : {}),
|
|
3122
|
-
...(options.projectNew !== undefined ? { projectNew: options.projectNew } : {})
|
|
3330
|
+
...(options.projectNew !== undefined ? { projectNew: options.projectNew } : {}),
|
|
3331
|
+
// Navigating to another conversation leaves the parked one alone, the
|
|
3332
|
+
// same way a fresh chat or a project home does.
|
|
3333
|
+
...(options.navigateToTargetUrl ? { newChat: true } : {})
|
|
3123
3334
|
})) {
|
|
3124
3335
|
throw new ChatGptBrowserBlockerError(chatGptResponseChoiceBlocker(true));
|
|
3125
3336
|
}
|
|
3126
3337
|
assertChatGptIdleAndReadyForPrompt(status, busyBlocker, true);
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3338
|
+
// Only a PINNED target has to be under the tab already; a resolved thread is
|
|
3339
|
+
// navigated to below, and asserting the match here would refuse the send for
|
|
3340
|
+
// the tab merely being somewhere else - which is the whole reason a
|
|
3341
|
+
// continuation resolves from records rather than from the tab.
|
|
3342
|
+
if (requireTabAtTargetUrl)
|
|
3343
|
+
assertChatGptTargetUrlMatches(status.url, requireTabAtTargetUrl);
|
|
3344
|
+
assertVisibleChatGptTab(status.visibilityState, status.url, requireTabAtTargetUrl);
|
|
3130
3345
|
emitProgress("tab_ready");
|
|
3131
3346
|
// Progress details deliberately avoid project names (receipts redact them too).
|
|
3132
3347
|
const selectionSummary = [
|
|
@@ -3147,6 +3362,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3147
3362
|
process.stderr.write(`DBG-SEND +${Date.now() - sendStartedAt}ms ${msg}\n`);
|
|
3148
3363
|
};
|
|
3149
3364
|
let beforeSubmit;
|
|
3365
|
+
let boundProjectId;
|
|
3150
3366
|
let submitButtonFound = false;
|
|
3151
3367
|
let wantsDeepResearch = false;
|
|
3152
3368
|
const sendWarnings = [];
|
|
@@ -3191,12 +3407,20 @@ export async function sendChatGptPrompt(options) {
|
|
|
3191
3407
|
// keeps Work's composer, and switching afterwards does not move it.
|
|
3192
3408
|
// Max and Ultra are rungs of Work's slider, so asking for one means staying
|
|
3193
3409
|
// there; anything else belongs on Chat, whose top step is Pro.
|
|
3410
|
+
if (normalizedTargetUrl && options.navigateToTargetUrl) {
|
|
3411
|
+
await openChatGptThread(cdp, normalizedTargetUrl);
|
|
3412
|
+
}
|
|
3194
3413
|
if (!effortNeedsWorkSurface(options.effort)) {
|
|
3195
|
-
|
|
3414
|
+
// Leaving the current page is safe only for a send that was going to
|
|
3415
|
+
// navigate anyway; a continuation or a pinned tab has to be reloaded
|
|
3416
|
+
// where it stands, because that page IS the destination.
|
|
3417
|
+
const surfaceWarning = await ensureChatSurface(cdp, {
|
|
3418
|
+
mayLeaveCurrentPage: Boolean(options.newChat || options.project || options.projectNew)
|
|
3419
|
+
});
|
|
3196
3420
|
if (surfaceWarning)
|
|
3197
3421
|
sendWarnings.push(surfaceWarning);
|
|
3198
3422
|
}
|
|
3199
|
-
await selectProject(cdp, options);
|
|
3423
|
+
boundProjectId = await selectProject(cdp, options);
|
|
3200
3424
|
try {
|
|
3201
3425
|
await selectModelReasoning(cdp, options, sendWarnings);
|
|
3202
3426
|
}
|
|
@@ -3230,8 +3454,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3230
3454
|
const stillBound = await waitForComposerProjectBinding(cdp, boundProject, PROJECT_NAVIGATION_TIMEOUT_MS);
|
|
3231
3455
|
dbgSend(`project binding before typing=${stillBound}`);
|
|
3232
3456
|
if (stillBound !== "bound") {
|
|
3233
|
-
throw new
|
|
3234
|
-
`no longer does, so nothing was sent.`);
|
|
3457
|
+
throw new ChatGptBrowserBlockerError(projectNotBoundBlocker({ project: boundProject, reason: "drifted" }));
|
|
3235
3458
|
}
|
|
3236
3459
|
}
|
|
3237
3460
|
// Attach BEFORE typing: the upload is the slow part, and a file that
|
|
@@ -3428,6 +3651,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3428
3651
|
answer: transcript.answer,
|
|
3429
3652
|
modelHints: finalState?.modelHints ?? [],
|
|
3430
3653
|
...(transcript.modelSlug ? { modelSlug: transcript.modelSlug } : finalState?.modelSlug ? { modelSlug: finalState.modelSlug } : {}),
|
|
3654
|
+
...(boundProjectId ? { boundProjectId } : {}),
|
|
3431
3655
|
warnings: withDialogNote([...sendWarnings, selectionMismatchWarning({ ...(options.model !== undefined ? { model: options.model } : {}), ...(options.effort !== undefined ? { effort: options.effort } : {}), ...((transcript.modelSlug || finalState?.modelSlug) ? { modelSlug: (transcript.modelSlug || finalState?.modelSlug) } : {}) })]).filter((warning) => Boolean(warning))
|
|
3432
3656
|
};
|
|
3433
3657
|
};
|
|
@@ -3506,6 +3730,13 @@ export async function sendChatGptPrompt(options) {
|
|
|
3506
3730
|
lastTranscriptClassification = transcript.classification;
|
|
3507
3731
|
if (transcript.answer)
|
|
3508
3732
|
return transcriptResult(transcript.answer);
|
|
3733
|
+
// A finished turn with no text is an answer of a different shape - an
|
|
3734
|
+
// image, measured - and waiting for words it will never write spends
|
|
3735
|
+
// the whole budget and then calls the result a timeout. The page must
|
|
3736
|
+
// agree it has stopped generating before this counts.
|
|
3737
|
+
if (transcript.classification === "no_text") {
|
|
3738
|
+
return transcriptResult({ answer: CHATGPT_NON_TEXT_ANSWER_NOTE, modelSlug: "" });
|
|
3739
|
+
}
|
|
3509
3740
|
}
|
|
3510
3741
|
if (shouldRecoverThreadNavigation({ pinnedThreadUrl, currentUrl: finalState?.url, lastTranscriptClassification })) {
|
|
3511
3742
|
if (recoveredNavigations >= 2) {
|
|
@@ -3576,6 +3807,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3576
3807
|
answer: completed.answer.trim(),
|
|
3577
3808
|
modelHints: completed.modelHints,
|
|
3578
3809
|
...(completed.modelSlug ? { modelSlug: completed.modelSlug } : {}),
|
|
3810
|
+
...(boundProjectId ? { boundProjectId } : {}),
|
|
3579
3811
|
warnings: withDialogNote([...sendWarnings, selectionMismatchWarning({ ...(options.model !== undefined ? { model: options.model } : {}), ...(options.effort !== undefined ? { effort: options.effort } : {}), ...(completed.modelSlug !== undefined ? { modelSlug: completed.modelSlug } : {}) })]).filter((warning) => Boolean(warning))
|
|
3580
3812
|
};
|
|
3581
3813
|
}
|
|
@@ -3590,6 +3822,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3590
3822
|
answer: completed.answer.trim(),
|
|
3591
3823
|
modelHints: completed.modelHints,
|
|
3592
3824
|
...(completed.modelSlug ? { modelSlug: completed.modelSlug } : {}),
|
|
3825
|
+
...(boundProjectId ? { boundProjectId } : {}),
|
|
3593
3826
|
warnings: withDialogNote([
|
|
3594
3827
|
...sendWarnings,
|
|
3595
3828
|
...(selectionMismatchWarning({ ...(options.model !== undefined ? { model: options.model } : {}), ...(options.effort !== undefined ? { effort: options.effort } : {}), ...(completed.modelSlug !== undefined ? { modelSlug: completed.modelSlug } : {}) }) ? [selectionMismatchWarning({ ...(options.model !== undefined ? { model: options.model } : {}), ...(options.effort !== undefined ? { effort: options.effort } : {}), ...(completed.modelSlug !== undefined ? { modelSlug: completed.modelSlug } : {}) })] : []),
|
|
@@ -4793,8 +5026,13 @@ export function activeComposerToolsExpression(labels) {
|
|
|
4793
5026
|
// selection did not take.
|
|
4794
5027
|
const el = document.querySelector('#prompt-textarea,[contenteditable="true"]');
|
|
4795
5028
|
const form = el ? (el.closest("form") || el.parentElement) : null;
|
|
4796
|
-
const text = (el ? el.innerText || "" : "") + String.fromCharCode(10) + (form ? form.innerText || "" : "");
|
|
4797
|
-
|
|
5029
|
+
const text = ((el ? el.innerText || "" : "") + String.fromCharCode(10) + (form ? form.innerText || "" : "")).toLowerCase();
|
|
5030
|
+
// Case-insensitively: prodex carries the label as the menu spells it
|
|
5031
|
+
// ("Create image") while the page has been measured using "Create Image"
|
|
5032
|
+
// for the same tool. This is hardening, not a fix for a failure anyone has
|
|
5033
|
+
// seen - the create-image activation failure measured on this account
|
|
5034
|
+
// survives it, and its cause is still open.
|
|
5035
|
+
return { ok: true, active: ${labelsJson}.filter((label) => text.includes(String(label).toLowerCase())) };
|
|
4798
5036
|
})()`;
|
|
4799
5037
|
}
|
|
4800
5038
|
export function composerTextStateExpression(expectedText, toolLabels = []) {
|
|
@@ -5299,6 +5537,68 @@ export function deepResearchReportExpression(conversationId) {
|
|
|
5299
5537
|
* Thread urls come in a plain (`/c/<id>`) and a project (`/g/g-p-.../c/<id>`)
|
|
5300
5538
|
* shape; both end in the conversation id the backend API is keyed by.
|
|
5301
5539
|
*/
|
|
5540
|
+
/**
|
|
5541
|
+
* The project a ChatGPT URL belongs to, as its stable id.
|
|
5542
|
+
*
|
|
5543
|
+
* Measured live within one send: the SAME project renders as
|
|
5544
|
+
* `/g/g-p-<hash>/project` on its home and `/g/g-p-<hash>-<name>/c/<id>` on the
|
|
5545
|
+
* thread that came out of it. Comparing the slugs whole would call those two
|
|
5546
|
+
* different projects, so only the hash - the part that does not depend on how
|
|
5547
|
+
* the page felt like writing the name - identifies it.
|
|
5548
|
+
*/
|
|
5549
|
+
export function chatGptProjectIdFromUrl(url) {
|
|
5550
|
+
if (!url)
|
|
5551
|
+
return undefined;
|
|
5552
|
+
const match = /\/g\/g-p-([0-9a-f]+)(?:[-/?#]|$)/i.exec(url);
|
|
5553
|
+
return match ? match[1].toLowerCase() : undefined;
|
|
5554
|
+
}
|
|
5555
|
+
/**
|
|
5556
|
+
* Where the prompt actually landed, against where it was aimed.
|
|
5557
|
+
*
|
|
5558
|
+
* The receipt recorded the project the caller ASKED for, which is intent, not
|
|
5559
|
+
* evidence: a send that ended up somewhere else was recorded under the name of
|
|
5560
|
+
* the place it never reached. The answered thread's URL carries the project it
|
|
5561
|
+
* really belongs to, and the project step knows the id it bound to, so the two
|
|
5562
|
+
* can be compared instead of assumed. "unverified" is its own answer - better
|
|
5563
|
+
* than a receipt that certifies what nobody checked.
|
|
5564
|
+
*/
|
|
5565
|
+
export function destinationVerification(input) {
|
|
5566
|
+
const answeredProjectId = chatGptProjectIdFromUrl(input.answeredUrl);
|
|
5567
|
+
const destination = !input.answeredUrl ? "unknown" : answeredProjectId ? "project" : "root";
|
|
5568
|
+
if (!input.requestedProject)
|
|
5569
|
+
return { destination };
|
|
5570
|
+
if (destination === "unknown")
|
|
5571
|
+
return { destination, verified: false };
|
|
5572
|
+
// Landing outside every project is wrong on its own evidence: it needs no id
|
|
5573
|
+
// to compare against, and requiring one would have retired a warning that
|
|
5574
|
+
// caught this in the field.
|
|
5575
|
+
if (destination === "root") {
|
|
5576
|
+
return {
|
|
5577
|
+
destination,
|
|
5578
|
+
verified: false,
|
|
5579
|
+
warning: "project_landing_warning: a project was requested but the answered thread is a root chat, so it landed OUTSIDE the project. " +
|
|
5580
|
+
"Move it via the thread menu (Move to project) or re-run; list projects with `prodex pro browser projects`."
|
|
5581
|
+
};
|
|
5582
|
+
}
|
|
5583
|
+
// In a project, with nothing to check it against: not a warning, but not a
|
|
5584
|
+
// verified landing either.
|
|
5585
|
+
if (!input.boundProjectId)
|
|
5586
|
+
return { destination, verified: false };
|
|
5587
|
+
const verified = answeredProjectId === input.boundProjectId;
|
|
5588
|
+
return {
|
|
5589
|
+
destination,
|
|
5590
|
+
verified,
|
|
5591
|
+
...(verified
|
|
5592
|
+
? {}
|
|
5593
|
+
: {
|
|
5594
|
+
// Naming the project it landed in would put another project's name in
|
|
5595
|
+
// a persisted record; the thread URL is already there for anyone who
|
|
5596
|
+
// needs to go look.
|
|
5597
|
+
warning: "project_landing_warning: the answered thread belongs to a different project than the one this send entered. " +
|
|
5598
|
+
"Open the thread URL in the receipt to see where it went."
|
|
5599
|
+
})
|
|
5600
|
+
};
|
|
5601
|
+
}
|
|
5302
5602
|
export function conversationIdFromThreadUrl(url) {
|
|
5303
5603
|
const match = /\/c\/([0-9a-fA-F-]{16,})/.exec(url);
|
|
5304
5604
|
return match ? match[1] : undefined;
|
package/dist/cli-args.js
CHANGED
|
@@ -260,6 +260,9 @@ export const ASK_PRO_BOOLEAN_FLAGS = new Set([
|
|
|
260
260
|
"--no-auto-login",
|
|
261
261
|
// Send outside any project for once, overriding a pinned default.
|
|
262
262
|
"--no-project",
|
|
263
|
+
// Continue the conversation a previous consult left off in, resolved from
|
|
264
|
+
// this repo's own records rather than from whatever the shared tab shows.
|
|
265
|
+
"--continue",
|
|
263
266
|
// Send even when the requested model/effort could not be applied, rather
|
|
264
267
|
// than stopping. Off by default: an answer from a step nobody asked for is
|
|
265
268
|
// usually thrown away.
|
|
@@ -268,6 +271,8 @@ export const ASK_PRO_BOOLEAN_FLAGS = new Set([
|
|
|
268
271
|
export const ASK_PRO_SELECTION_VALUE_FLAGS = ["--project", "--project-new", "--model", "--pro-mode", "--effort"];
|
|
269
272
|
export const ASK_PRO_VALUE_FLAGS = new Set([
|
|
270
273
|
"--cwd",
|
|
274
|
+
// Continue one NAMED past consult, when "the last one" is not the one meant.
|
|
275
|
+
"--continue-task",
|
|
271
276
|
"--file",
|
|
272
277
|
// Upload the file itself (pdf/pptx/image) instead of inlining its text.
|
|
273
278
|
"--attach",
|