@youdie006/prodex 0.40.1 → 0.40.3
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 +525 -63
- package/dist/cli-args.js +5 -0
- package/dist/cli-help.js +9 -4
- package/dist/cli-pro.js +162 -33
- package/dist/config.js +40 -6
- package/dist/continue-thread.js +84 -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) {
|
|
@@ -842,9 +854,23 @@ export function detectChatGptPageBlocker(state) {
|
|
|
842
854
|
return detectChatGptBlocker(state.blockerScanTextSample ?? state.blockerTextSample ?? state.textSample, state.visibleButtonLabels);
|
|
843
855
|
}
|
|
844
856
|
export function inferChatGptPageLoggedInLikely(state) {
|
|
845
|
-
//
|
|
846
|
-
//
|
|
847
|
-
|
|
857
|
+
// The logged-in signals live in the sidebar - "New chat", "Projects", the
|
|
858
|
+
// plan hint - so this needs the sample that HAS the sidebar in it. That was
|
|
859
|
+
// meant to be blockerTextSample, and measured live on a project home it is
|
|
860
|
+
// not: its text walk keeps only nodes whose own parent has a box, and what
|
|
861
|
+
// survived there was 111 characters of a promotional banner while
|
|
862
|
+
// document.body.innerText carried the whole sidebar. So a logged-in Pro
|
|
863
|
+
// account on a working page was told to go and log in.
|
|
864
|
+
//
|
|
865
|
+
// The logged-OUT question keeps the message-excluded sample, so a chat
|
|
866
|
+
// quoting a signup page cannot report the session as dead. The logged-IN
|
|
867
|
+
// question reads both, because the sidebar turns up in whichever one kept
|
|
868
|
+
// it - and being wrong in that direction is caught at once by the composer
|
|
869
|
+
// check beside it, while being wrong the other way tells someone with a
|
|
870
|
+
// working browser to go and log in.
|
|
871
|
+
const messageExcluded = state.blockerTextSample ?? state.textSample;
|
|
872
|
+
const anySample = [state.textSample, state.blockerTextSample].filter(Boolean).join(String.fromCharCode(10));
|
|
873
|
+
return inferLoggedInLikely(messageExcluded, state.visibleButtonLabels, anySample);
|
|
848
874
|
}
|
|
849
875
|
function hasLikelyChatGptLoginPrompt(haystack) {
|
|
850
876
|
const hasSpecificSignup = /sign up for free|무료로 가입/i.test(haystack);
|
|
@@ -1056,8 +1082,29 @@ decidedBusyBlocker, busyVerdictDecided = false) {
|
|
|
1056
1082
|
const busyBlocker = busyVerdictDecided ? decidedBusyBlocker : chatGptBusyBlocker(status);
|
|
1057
1083
|
if (busyBlocker)
|
|
1058
1084
|
throw new ChatGptBrowserBlockerError(busyBlocker);
|
|
1085
|
+
// ChatGPT's own error page carries no composer and none of the logged-in
|
|
1086
|
+
// furniture the login check reads, so the assert below calls a perfectly
|
|
1087
|
+
// good session logged out and sends the person looking for a login screen
|
|
1088
|
+
// that is not there. Measured live: a project home that failed to load left
|
|
1089
|
+
// the tab on that page, and the next send - the retry the project blocker
|
|
1090
|
+
// asks for - reported "missing a clear logged-in ChatGPT session".
|
|
1091
|
+
if (looksLikeChatGptErrorPage({ bodyText: status.textSample, hasComposer: status.hasComposer })) {
|
|
1092
|
+
throw new ChatGptBrowserBlockerError(chatGptErrorPageBlocker());
|
|
1093
|
+
}
|
|
1059
1094
|
assertChatGptReadyForPrompt(inferChatGptPageLoggedInLikely(status), status.hasComposer, status.openDialogText);
|
|
1060
1095
|
}
|
|
1096
|
+
/** The tab is on ChatGPT's error page: the page failed to load, and says nothing about the session. */
|
|
1097
|
+
export function chatGptErrorPageBlocker() {
|
|
1098
|
+
return {
|
|
1099
|
+
code: "chatgpt_error_page",
|
|
1100
|
+
message: "ChatGPT browser is reachable, but the tab is on ChatGPT's own error page, which has no prompt composer.",
|
|
1101
|
+
retryable: true,
|
|
1102
|
+
// Not "the session is fine": this page carries none of the furniture that
|
|
1103
|
+
// would show it either way. Not "reload it" either - measured, a project
|
|
1104
|
+
// home reloads straight back into this page, while the site root loads.
|
|
1105
|
+
next_step: "The page failed to load, which says nothing about the session. Open a normal chat in the visible browser, then retry."
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1061
1108
|
/**
|
|
1062
1109
|
* Whether a page with no composer is worth one reload before giving up.
|
|
1063
1110
|
*
|
|
@@ -1302,6 +1349,36 @@ async function dispatchEscapeKey(cdp) {
|
|
|
1302
1349
|
await cdp.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
|
|
1303
1350
|
await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
|
|
1304
1351
|
}
|
|
1352
|
+
/**
|
|
1353
|
+
* Open a fresh ChatGPT root document, and prove that is where the tab landed.
|
|
1354
|
+
*
|
|
1355
|
+
* The recovery for a composer bound to the wrong project: only a new document
|
|
1356
|
+
* sheds a stale binding, and the site root loads where a project home does
|
|
1357
|
+
* not. Checked rather than best-effort, because the caller clicks a sidebar
|
|
1358
|
+
* row on whatever page this leaves behind - and clicking it on the OLD
|
|
1359
|
+
* document walks straight back into the binding it is trying to shed. The
|
|
1360
|
+
* stamp is what proves the old document is gone; the URL alone can be read off
|
|
1361
|
+
* the very page we are trying to leave.
|
|
1362
|
+
*/
|
|
1363
|
+
async function openFreshChatGptHome(cdp) {
|
|
1364
|
+
await cdp.evaluate(markDocumentForReloadExpression());
|
|
1365
|
+
await cdp.evaluate(`location.assign("https://chatgpt.com/")`);
|
|
1366
|
+
const deadline = Date.now() + RELOAD_SETTLE_TIMEOUT_MS;
|
|
1367
|
+
while (Date.now() < deadline) {
|
|
1368
|
+
await sleep(250);
|
|
1369
|
+
try {
|
|
1370
|
+
if (await cdp.evaluate(freshChatGptHomeReadyExpression()))
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
catch (error) {
|
|
1374
|
+
// The execution context is gone between documents; the next poll lands
|
|
1375
|
+
// on the new one. A command timeout is different: it closed the socket.
|
|
1376
|
+
if (cdpCommandTimedOut(error))
|
|
1377
|
+
throw error;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
throw new Error("ChatGPT did not open a fresh home page with a composer");
|
|
1381
|
+
}
|
|
1305
1382
|
// Poll a boolean page expression instead of sleeping a fixed duration, so slow
|
|
1306
1383
|
// renders wait longer and fast ones do not waste time.
|
|
1307
1384
|
async function waitForExpressionTrue(cdp, expression, timeoutMs, intervalMs = 150) {
|
|
@@ -1555,6 +1632,20 @@ export function reloadedDocumentReadyExpression(extraCondition = "true") {
|
|
|
1555
1632
|
return Boolean(${extraCondition});
|
|
1556
1633
|
})()`;
|
|
1557
1634
|
}
|
|
1635
|
+
/**
|
|
1636
|
+
* True only on a NEWLY loaded chatgpt.com root that has rendered a composer.
|
|
1637
|
+
*
|
|
1638
|
+
* Every clause answers a way the old check could pass on the page we are
|
|
1639
|
+
* trying to leave: the stamp proves the document is not the one that asked
|
|
1640
|
+
* for the navigation, the route proves it is the root rather than the project
|
|
1641
|
+
* home that fails to load, and the composer candidate is the real editor
|
|
1642
|
+
* rather than the broad selector that also matches a hidden fallback.
|
|
1643
|
+
*/
|
|
1644
|
+
export function freshChatGptHomeReadyExpression() {
|
|
1645
|
+
return reloadedDocumentReadyExpression(`/^https:\\/\\/chatgpt\\.com\\/?(?:[?#].*)?$/.test(location.href) && (() => {${composerExpressionHelpers()}
|
|
1646
|
+
return Boolean(findChatGptComposerCandidate());
|
|
1647
|
+
})()`);
|
|
1648
|
+
}
|
|
1558
1649
|
/** Polled reloads return as soon as the new document has its composer; this only bounds a page that never gets there. */
|
|
1559
1650
|
const RELOAD_SETTLE_TIMEOUT_MS = 12_000;
|
|
1560
1651
|
/**
|
|
@@ -1602,17 +1693,75 @@ async function reloadPageAndAwaitComposer(page) {
|
|
|
1602
1693
|
cdp.close();
|
|
1603
1694
|
}
|
|
1604
1695
|
}
|
|
1696
|
+
// Every alternative is wording measured on the error page itself, anchored so
|
|
1697
|
+
// the whole body has to be made of them and nothing else. It may repeat one:
|
|
1698
|
+
// the heading and the button carry the same words.
|
|
1699
|
+
const CHATGPT_ERROR_PAGE_BODY = /^(?:(?:something went wrong|please try again later|please try again|try again|다시\s*시도)[.!]?(?:\s+|$))+$/i;
|
|
1700
|
+
/**
|
|
1701
|
+
* ChatGPT's error page: a document whose whole body is a retry affordance.
|
|
1702
|
+
*
|
|
1703
|
+
* Measured live - a direct load of a project home came back with a body of
|
|
1704
|
+
* exactly "Try again", one button, and no composer, while the same route
|
|
1705
|
+
* reached by clicking the sidebar worked and the rest of ChatGPT loaded fine.
|
|
1706
|
+
* Reporting that as a missing composer describes a symptom and names nothing
|
|
1707
|
+
* to do about it.
|
|
1708
|
+
*
|
|
1709
|
+
* The body has to BE the error, not merely contain a retry word somewhere: a
|
|
1710
|
+
* positive now navigates the visible tab away, so "Retry settings" and a
|
|
1711
|
+
* transient "Retrying..." must not qualify. Wording decides, not length - the
|
|
1712
|
+
* old 40-character ceiling turned down "Something went wrong. Please try again
|
|
1713
|
+
* later." for being one sentence too long. An error page prodex does not
|
|
1714
|
+
* recognise stays a missing composer: a worse message, not a wrong action.
|
|
1715
|
+
*/
|
|
1716
|
+
export function looksLikeChatGptErrorPage(input) {
|
|
1717
|
+
if (input.hasComposer)
|
|
1718
|
+
return false;
|
|
1719
|
+
// The DOM read leaves the heading and the button separated by whitespace.
|
|
1720
|
+
const text = input.bodyText.replace(/\s+/g, " ").trim();
|
|
1721
|
+
if (!text)
|
|
1722
|
+
return false;
|
|
1723
|
+
// A backstop for the alternation below rather than a classifier of its own:
|
|
1724
|
+
// the longest body it can accept is well under this, so anything longer is a
|
|
1725
|
+
// page with content and is not worth matching against.
|
|
1726
|
+
if (text.length > 200)
|
|
1727
|
+
return false;
|
|
1728
|
+
return CHATGPT_ERROR_PAGE_BODY.test(text);
|
|
1729
|
+
}
|
|
1605
1730
|
/**
|
|
1606
|
-
*
|
|
1731
|
+
* Which project the composer will post into, read from its own placeholder.
|
|
1732
|
+
*
|
|
1733
|
+
* ChatGPT labels a project composer with the project it belongs to ("New chat
|
|
1734
|
+
* in <name>"), and measured live that label follows a sidebar navigation from
|
|
1735
|
+
* one project to another and back. That makes it the cheap answer to the
|
|
1736
|
+
* question the old hard reload tried to force: is this composer the project's,
|
|
1737
|
+
* or the one the tab arrived with.
|
|
1607
1738
|
*
|
|
1608
|
-
*
|
|
1609
|
-
*
|
|
1610
|
-
*
|
|
1611
|
-
*
|
|
1612
|
-
*
|
|
1739
|
+
* Only a recognised phrasing decides anything. A placeholder that merely
|
|
1740
|
+
* CONTAINS the name is not evidence - "New chat in Notes Archive" contains
|
|
1741
|
+
* "Notes" - and neither is an unrecognised one, which is why a locale this
|
|
1742
|
+
* cannot read comes back "unknown" rather than "bound". What "unknown" is
|
|
1743
|
+
* worth is the caller's to decide, not this function's.
|
|
1613
1744
|
*/
|
|
1614
|
-
export function
|
|
1615
|
-
|
|
1745
|
+
export function composerProjectBinding(input) {
|
|
1746
|
+
const placeholder = (input.placeholder ?? "").trim();
|
|
1747
|
+
if (!placeholder)
|
|
1748
|
+
return "unknown";
|
|
1749
|
+
const wanted = input.projectName.trim().toLowerCase();
|
|
1750
|
+
if (!wanted)
|
|
1751
|
+
return "unknown";
|
|
1752
|
+
// Equality where the phrasing is known, because sidebar rows are matched by
|
|
1753
|
+
// exact name: "Notes" and "Notes Archive" are two projects, and a composer
|
|
1754
|
+
// belonging to one must not pass for the other. The spacing of the template
|
|
1755
|
+
// is loose because that is the page's to choose; the NAME is compared as it
|
|
1756
|
+
// is, since two projects may differ by exactly the spacing in it.
|
|
1757
|
+
const named = /^new\s+chat\s+in\s+(.+)$/i.exec(placeholder)?.[1] ?? /^(.+?)\uc5d0\uc11c\s*\uc0c8\s*\ucc44\ud305$/.exec(placeholder)?.[1];
|
|
1758
|
+
if (named)
|
|
1759
|
+
return named.trim().toLowerCase() === wanted ? "bound" : "elsewhere";
|
|
1760
|
+
// The placeholder a plain new chat carries: recognised, and it names no
|
|
1761
|
+
// project, so the composer belongs to none.
|
|
1762
|
+
if (/^ask\s+chatgpt$/i.test(placeholder))
|
|
1763
|
+
return "elsewhere";
|
|
1764
|
+
return "unknown";
|
|
1616
1765
|
}
|
|
1617
1766
|
export function powerSliderPresentExpression() {
|
|
1618
1767
|
return `Boolean(document.querySelector('[data-testid="composer-intelligence-picker-content"] [role="slider"]'))`;
|
|
@@ -1770,11 +1919,18 @@ export function projectItemRectExpression(name) {
|
|
|
1770
1919
|
}
|
|
1771
1920
|
let target = opt ? (opt.closest('a,[role="link"],li') || opt.parentElement) : null;
|
|
1772
1921
|
if (!target) {
|
|
1922
|
+
// The fallback for a sidebar whose option buttons this cannot read. It
|
|
1923
|
+
// used to take the first row CONTAINING the name, which is the substring
|
|
1924
|
+
// match the exact comparison above exists to prevent: asking for "Codex"
|
|
1925
|
+
// took "Codex Review" and sent the prompt into a project nobody named.
|
|
1926
|
+
// A row's first line is its name; anything else here is a guess, and a
|
|
1927
|
+
// guess about which project to post into is the failure being fixed.
|
|
1773
1928
|
const icons = [...document.querySelectorAll('[data-testid="project-folder-icon"]')];
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
}
|
|
1929
|
+
const rowName = (row) => ((row.innerText || row.textContent || "").split("\\n").map((line) => line.trim()).find((line) => line.length > 0) || "");
|
|
1930
|
+
const rows = icons.map((ic) => ic.closest('a,li,[role="link"]') || ic.parentElement?.parentElement).filter(Boolean);
|
|
1931
|
+
const named = rows.filter((row) => rowName(row).toLowerCase() === wanted.toLowerCase());
|
|
1932
|
+
if (named.length > 1) return { ok: false, reason: "project name matches multiple sidebar projects; rename one to disambiguate" };
|
|
1933
|
+
if (named.length === 1) target = named[0];
|
|
1778
1934
|
}
|
|
1779
1935
|
if (!target) {
|
|
1780
1936
|
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)" };
|
|
@@ -1970,6 +2126,21 @@ async function selectPickerModel(cdp, requested, warnings = []) {
|
|
|
1970
2126
|
* without this the step that follows reported "the picker did not expose its
|
|
1971
2127
|
* power slider" on a picker that was simply shut.
|
|
1972
2128
|
*/
|
|
2129
|
+
/**
|
|
2130
|
+
* How to apply the stored Chat-surface preference on a page that renders no
|
|
2131
|
+
* surface toggle at all.
|
|
2132
|
+
*
|
|
2133
|
+
* Reloading in place is the only option for a send that has to stay where it
|
|
2134
|
+
* is - a pinned thread, a continuation. On a project home it is the wrong one:
|
|
2135
|
+
* a hard load of one comes back as ChatGPT's error page (measured on two
|
|
2136
|
+
* projects), which leaves the send on a document with no sidebar, and the
|
|
2137
|
+
* project step then reports the project missing from a sidebar that was never
|
|
2138
|
+
* drawn. A send that is going to navigate anyway takes the root, which loads.
|
|
2139
|
+
*/
|
|
2140
|
+
export function chatSurfaceRecoveryPlan(input) {
|
|
2141
|
+
const onProjectPage = /^https:\/\/chatgpt\.com\/g\/g-p-/.test(input.href);
|
|
2142
|
+
return input.mayLeaveCurrentPage && onProjectPage ? "fresh-root" : "reload";
|
|
2143
|
+
}
|
|
1973
2144
|
/**
|
|
1974
2145
|
* Put the browser back on ChatGPT's Chat surface when it has drifted onto Work.
|
|
1975
2146
|
*
|
|
@@ -1977,7 +2148,7 @@ async function selectPickerModel(cdp, requested, warnings = []) {
|
|
|
1977
2148
|
* the page announces which one is live, so a drifted browser silently drives
|
|
1978
2149
|
* the wrong picker. Returns a warning to carry to the caller when it moved.
|
|
1979
2150
|
*/
|
|
1980
|
-
async function ensureChatSurface(cdp) {
|
|
2151
|
+
async function ensureChatSurface(cdp, options) {
|
|
1981
2152
|
const read = async () => {
|
|
1982
2153
|
try {
|
|
1983
2154
|
return await cdp.evaluate(chatSurfaceProbeExpression());
|
|
@@ -2035,8 +2206,23 @@ async function ensureChatSurface(cdp) {
|
|
|
2035
2206
|
try {
|
|
2036
2207
|
await cdp.evaluate(selectChatSurfaceExpression());
|
|
2037
2208
|
// Reading the persisted value back right after writing it proves nothing;
|
|
2038
|
-
// the
|
|
2039
|
-
|
|
2209
|
+
// the new document rendering its composer is what proves the switch.
|
|
2210
|
+
const plan = chatSurfaceRecoveryPlan({
|
|
2211
|
+
href: await cdp.evaluate("location.href"),
|
|
2212
|
+
mayLeaveCurrentPage: options.mayLeaveCurrentPage
|
|
2213
|
+
});
|
|
2214
|
+
let applied;
|
|
2215
|
+
if (plan === "fresh-root") {
|
|
2216
|
+
// Throws when the root never rendered a composer, which the catch below
|
|
2217
|
+
// turns into the same "could not be switched back" warning as a reload
|
|
2218
|
+
// that never settled.
|
|
2219
|
+
await openFreshChatGptHome(cdp);
|
|
2220
|
+
applied = true;
|
|
2221
|
+
}
|
|
2222
|
+
else {
|
|
2223
|
+
applied = await reloadAndAwaitComposer(cdp, RELOAD_SETTLE_TIMEOUT_MS);
|
|
2224
|
+
}
|
|
2225
|
+
if (applied && (await confirm()))
|
|
2040
2226
|
return note;
|
|
2041
2227
|
}
|
|
2042
2228
|
catch (error) {
|
|
@@ -2477,13 +2663,79 @@ async function createChatGptProject(cdp, name) {
|
|
|
2477
2663
|
throw error;
|
|
2478
2664
|
}
|
|
2479
2665
|
}
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2666
|
+
/** The placeholder of the composer the send will actually type into. */
|
|
2667
|
+
/**
|
|
2668
|
+
* Read the label the composer carries, wherever it keeps it.
|
|
2669
|
+
*
|
|
2670
|
+
* Measured live on a project home: the editor prodex types into is the
|
|
2671
|
+
* contenteditable div, and it carries the label ONLY as `aria-label` -
|
|
2672
|
+
* `data-placeholder` is null on it. The 0x0 textarea beside it does carry
|
|
2673
|
+
* `placeholder`, but the composer finder rejects that one on size, exactly as
|
|
2674
|
+
* it should. Reading a single attribute meant the label was there and prodex
|
|
2675
|
+
* could not see it, so every project send refused with "the placeholder could
|
|
2676
|
+
* not be read" - the fail-closed branch doing its job on a page that was fine.
|
|
2677
|
+
*/
|
|
2678
|
+
export function composerProjectBindingExpression() {
|
|
2679
|
+
return `(() => {${composerExpressionHelpers()}
|
|
2680
|
+
const node = findChatGptComposerCandidate();
|
|
2681
|
+
if (!node) return { found: false };
|
|
2682
|
+
const label =
|
|
2683
|
+
node.getAttribute("data-placeholder") ||
|
|
2684
|
+
node.getAttribute("placeholder") ||
|
|
2685
|
+
node.getAttribute("aria-label") ||
|
|
2686
|
+
"";
|
|
2687
|
+
return { found: true, placeholder: label };
|
|
2688
|
+
})()`;
|
|
2689
|
+
}
|
|
2690
|
+
/**
|
|
2691
|
+
* Wait for the composer to belong to the project we just entered.
|
|
2692
|
+
*
|
|
2693
|
+
* The binding arrives with the project page rather than with the URL, so this
|
|
2694
|
+
* polls rather than reading once. A composer that never appears, or one whose
|
|
2695
|
+
* placeholder cannot be read, comes back "unknown" - which the caller treats
|
|
2696
|
+
* as a failure, so this must not report it lightly.
|
|
2697
|
+
*/
|
|
2698
|
+
async function waitForComposerProjectBinding(cdp, project, timeoutMs) {
|
|
2699
|
+
const deadline = Date.now() + timeoutMs;
|
|
2700
|
+
let verdict = "unknown";
|
|
2701
|
+
for (;;) {
|
|
2702
|
+
let read;
|
|
2703
|
+
try {
|
|
2704
|
+
read = await cdp.evaluate(composerProjectBindingExpression());
|
|
2705
|
+
}
|
|
2706
|
+
catch (error) {
|
|
2707
|
+
// A read that lands between documents answers about neither, and the next
|
|
2708
|
+
// poll lands on the new one. A command timeout is a different thing: the
|
|
2709
|
+
// tab stopped answering, and swallowing it spent this whole budget and
|
|
2710
|
+
// the recovery's before refusing with "the placeholder could not be
|
|
2711
|
+
// read" - a binding failure reported for a browser that was gone.
|
|
2712
|
+
if (cdpCommandTimedOut(error))
|
|
2713
|
+
throw error;
|
|
2714
|
+
read = { found: false };
|
|
2715
|
+
}
|
|
2716
|
+
if (read.found) {
|
|
2717
|
+
const sample = composerProjectBinding({
|
|
2718
|
+
...(read.placeholder !== undefined ? { placeholder: read.placeholder } : {}),
|
|
2719
|
+
projectName: project
|
|
2720
|
+
});
|
|
2721
|
+
if (sample === "bound")
|
|
2722
|
+
return sample;
|
|
2723
|
+
// Keep the worse reading. A composer seen belonging somewhere else stays
|
|
2724
|
+
// evidence of that even if the next sample lands mid-render with no
|
|
2725
|
+
// placeholder to read - overwriting it turned a known wrong destination
|
|
2726
|
+
// into an unknown one, which reads like the softer failure it is not.
|
|
2727
|
+
if (sample === "elsewhere" || verdict === "unknown")
|
|
2728
|
+
verdict = sample;
|
|
2729
|
+
}
|
|
2730
|
+
if (Date.now() >= deadline)
|
|
2731
|
+
return verdict;
|
|
2732
|
+
await sleep(250);
|
|
2484
2733
|
}
|
|
2485
|
-
|
|
2486
|
-
|
|
2734
|
+
}
|
|
2735
|
+
// Enter an EXISTING project by clicking its sidebar row. Leaves the tab on
|
|
2736
|
+
// that project's page; whether the composer came with it is the caller's
|
|
2737
|
+
// question, not this one's.
|
|
2738
|
+
async function navigateToExistingProject(cdp, project) {
|
|
2487
2739
|
const hrefBefore = await cdp.evaluate("location.href");
|
|
2488
2740
|
// Poll for the project row instead of a single check: right after a
|
|
2489
2741
|
// --new-chat navigation the sidebar's Projects section has not hydrated yet
|
|
@@ -2493,7 +2745,7 @@ async function selectProject(cdp, options) {
|
|
|
2493
2745
|
let hit = { ok: false };
|
|
2494
2746
|
const projectDeadline = Date.now() + 6_000;
|
|
2495
2747
|
for (;;) {
|
|
2496
|
-
hit = await cdp.evaluate(projectItemRectExpression(
|
|
2748
|
+
hit = await cdp.evaluate(projectItemRectExpression(project));
|
|
2497
2749
|
if (hit.ok && hit.x !== undefined && hit.y !== undefined)
|
|
2498
2750
|
break;
|
|
2499
2751
|
if (Date.now() >= projectDeadline)
|
|
@@ -2502,9 +2754,9 @@ async function selectProject(cdp, options) {
|
|
|
2502
2754
|
}
|
|
2503
2755
|
if (!hit.ok || hit.x === undefined || hit.y === undefined) {
|
|
2504
2756
|
const detail = hit.reason && hit.reason !== "project not found in sidebar" ? ` (${hit.reason})` : "";
|
|
2505
|
-
throw new Error(`ChatGPT project not found in sidebar: ${
|
|
2757
|
+
throw new Error(`ChatGPT project not found in sidebar: ${project}${detail} List the visible names with \`prodex pro browser projects\`.`);
|
|
2506
2758
|
}
|
|
2507
|
-
await verifiedClickWithRetry(cdp, () => cdp.evaluate(projectItemRectExpression(
|
|
2759
|
+
await verifiedClickWithRetry(cdp, () => cdp.evaluate(projectItemRectExpression(project)), `project ${project}`);
|
|
2508
2760
|
const navigated = await waitForExpressionTrue(cdp, `location.href !== ${JSON.stringify(hrefBefore)}`, PROJECT_NAVIGATION_TIMEOUT_MS);
|
|
2509
2761
|
if (!navigated) {
|
|
2510
2762
|
// The href staying put is fine ONLY when the tab was already on THIS
|
|
@@ -2514,7 +2766,7 @@ async function selectProject(cdp, options) {
|
|
|
2514
2766
|
// project - which would silently send the prompt into the wrong project.
|
|
2515
2767
|
const alreadyInRequestedProject = await cdp.evaluate(`(() => {
|
|
2516
2768
|
if (!/^https:\\/\\/chatgpt\\.com\\/g\\/g-p-/.test(location.href)) return false;
|
|
2517
|
-
const name = ${JSON.stringify(
|
|
2769
|
+
const name = ${JSON.stringify(project)}.toLowerCase();
|
|
2518
2770
|
// Case-insensitive EQUALITY (not substring): matches the case-insensitive
|
|
2519
2771
|
// sidebar-row lookup (so "codex" is accepted while sitting on "Codex"),
|
|
2520
2772
|
// but a stalled cross-project navigation must NOT be accepted just because
|
|
@@ -2525,42 +2777,132 @@ async function selectProject(cdp, options) {
|
|
|
2525
2777
|
return [...document.querySelectorAll('h1,[role="heading"]')].some((h) => (h.innerText || "").trim().toLowerCase() === name);
|
|
2526
2778
|
})()`);
|
|
2527
2779
|
if (!alreadyInRequestedProject) {
|
|
2528
|
-
throw new Error(`Clicking project "${
|
|
2780
|
+
throw new Error(`Clicking project "${project}" did not navigate the visible tab. If the tab is already inside this project, omit --project and retry.`);
|
|
2529
2781
|
}
|
|
2530
2782
|
}
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2783
|
+
}
|
|
2784
|
+
/**
|
|
2785
|
+
* The refusal to post into a project prodex cannot confirm, as a blocker.
|
|
2786
|
+
*
|
|
2787
|
+
* It used to be a plain Error whose English prose a regex in the CLI matched to
|
|
2788
|
+
* recover the code - so the difference between "the composer belongs elsewhere"
|
|
2789
|
+
* and "the browser stopped answering" survived only as a sentence, and a
|
|
2790
|
+
* reworded message would have quietly become an unclassified send failure. The
|
|
2791
|
+
* wording still carries the phrase the classifier keys on, because older
|
|
2792
|
+
* senders and other paths still reach it as text.
|
|
2793
|
+
*
|
|
2794
|
+
* The project it OFFERED instead is deliberately absent: naming it would put
|
|
2795
|
+
* another project's name in a persisted record, which redaction covers only for
|
|
2796
|
+
* the ones this send asked for.
|
|
2797
|
+
*/
|
|
2798
|
+
export function projectNotBoundBlocker(input) {
|
|
2799
|
+
const detail = input.reason === "elsewhere"
|
|
2800
|
+
? "after entering it, the composer still offers a chat that belongs somewhere else"
|
|
2801
|
+
: input.reason === "unknown"
|
|
2802
|
+
? "after entering it, the composer's placeholder could not be read, so where the prompt would land is unknown"
|
|
2803
|
+
: "it read as this project's after entering it and no longer does";
|
|
2804
|
+
return {
|
|
2805
|
+
code: "project_not_bound",
|
|
2806
|
+
message: `ChatGPT composer did not bind to project "${input.project}": ${detail}, so nothing was sent.` +
|
|
2807
|
+
(input.recoveryNote ?? ""),
|
|
2808
|
+
retryable: true,
|
|
2809
|
+
next_step: "Nothing was sent, so nothing landed in the wrong project. Retry - the composer normally binds on the next navigation - " +
|
|
2810
|
+
"or open the project once in the visible browser and send again."
|
|
2811
|
+
};
|
|
2812
|
+
}
|
|
2813
|
+
/**
|
|
2814
|
+
* The project name the composer has to agree with before anything is typed,
|
|
2815
|
+
* or undefined for a send that pins no project.
|
|
2816
|
+
*
|
|
2817
|
+
* A project the send just created is exactly as able to post into the wrong
|
|
2818
|
+
* place as one it navigated to - the create flow leaves the tab on a project
|
|
2819
|
+
* home like any other - so both answer here, and the binding gate reads this
|
|
2820
|
+
* rather than the pinned name alone.
|
|
2821
|
+
*/
|
|
2822
|
+
export function composerBindingTarget(options) {
|
|
2823
|
+
return options.projectNew ?? options.project;
|
|
2824
|
+
}
|
|
2825
|
+
/**
|
|
2826
|
+
* Put the tab in the project this send is for, and refuse unless the composer
|
|
2827
|
+
* agrees that is where it posts.
|
|
2828
|
+
*
|
|
2829
|
+
* Both ways in share the gate. A project prodex just created is no safer than
|
|
2830
|
+
* one it navigated to: the create flow waits for A composer, and the composer
|
|
2831
|
+
* that answers can still be the one the tab arrived with - the same silent
|
|
2832
|
+
* wrong-project send, with the new project's name in the receipt.
|
|
2833
|
+
*/
|
|
2834
|
+
async function selectProject(cdp, options) {
|
|
2835
|
+
const wanted = composerBindingTarget(options);
|
|
2836
|
+
if (!wanted)
|
|
2837
|
+
return undefined;
|
|
2838
|
+
if (options.projectNew)
|
|
2839
|
+
await createChatGptProject(cdp, options.projectNew);
|
|
2840
|
+
// Clicking the sidebar row when the composer already belongs to this project
|
|
2841
|
+
// is work that can only fail. Measured: a send into the project the tab was
|
|
2842
|
+
// already sitting in refused with "another element covers its click point" -
|
|
2843
|
+
// a promotional banner over the sidebar - on a page that was ready to accept
|
|
2844
|
+
// the prompt. The read below is the same evidence the gate accepts, so a
|
|
2845
|
+
// composer that already answers with this project needs no navigation.
|
|
2846
|
+
else if ((await waitForComposerProjectBinding(cdp, options.project, 0)) !== "bound") {
|
|
2847
|
+
await navigateToExistingProject(cdp, options.project);
|
|
2848
|
+
}
|
|
2849
|
+
// A sidebar SPA navigation moves the URL to the target project while the
|
|
2850
|
+
// composer can stay bound to the PREVIOUS project's conversation target, so
|
|
2851
|
+
// the send silently creates the thread in the OLD project (reproduced live
|
|
2852
|
+
// via PRODEX_DEBUG_SEND: baseline URL on the requested project, yet the
|
|
2853
|
+
// prompt posted into the project the tab came from).
|
|
2854
|
+
//
|
|
2855
|
+
// Hard-reloading the project home used to rebind it, and that stopped
|
|
2856
|
+
// working: measured on two different projects, EVERY hard load of a project
|
|
2857
|
+
// home - Page.reload and location.assign alike - comes back as ChatGPT's
|
|
2858
|
+
// error page with no composer, while the sidebar navigation that got us
|
|
2859
|
+
// here renders in under two seconds. So read the binding instead of forcing
|
|
2860
|
+
// it - the composer says which project it posts into.
|
|
2861
|
+
//
|
|
2862
|
+
// Read on every path in. A URL that never moved is not proof about the
|
|
2863
|
+
// composer either: the route and the title can already be this project's
|
|
2864
|
+
// while the composer still belongs to the thread the tab was left on, and
|
|
2865
|
+
// that path used to skip this check entirely - as did creating a project,
|
|
2866
|
+
// which reached the send with nothing checked at all.
|
|
2867
|
+
let binding = await waitForComposerProjectBinding(cdp, wanted, PROJECT_NAVIGATION_TIMEOUT_MS);
|
|
2868
|
+
let recoveryNote = "";
|
|
2869
|
+
if (binding !== "bound") {
|
|
2870
|
+
// The one recovery that cannot inherit a stale binding, and the only one
|
|
2871
|
+
// still available: a fresh document - the site root loads fine, unlike a
|
|
2872
|
+
// project home - and then the same sidebar navigation over again.
|
|
2873
|
+
try {
|
|
2874
|
+
await openFreshChatGptHome(cdp);
|
|
2875
|
+
await verifiedClickWithRetry(cdp, () => cdp.evaluate(projectItemRectExpression(wanted)), `project ${wanted}`);
|
|
2876
|
+
// The click's navigation has to land before the placeholder means
|
|
2877
|
+
// anything; read on the page we came from, it answers about the wrong
|
|
2878
|
+
// document.
|
|
2879
|
+
const entered = await waitForExpressionTrue(cdp, `/\\/g\\/g-p-/.test(location.href)`, PROJECT_NAVIGATION_TIMEOUT_MS);
|
|
2880
|
+
if (!entered)
|
|
2881
|
+
throw new Error("the sidebar click did not reach a project page");
|
|
2882
|
+
binding = await waitForComposerProjectBinding(cdp, wanted, PROJECT_NAVIGATION_TIMEOUT_MS);
|
|
2883
|
+
}
|
|
2884
|
+
catch (recoveryError) {
|
|
2885
|
+
// Dropping why the recovery failed would leave the refusal below saying
|
|
2886
|
+
// only that the binding is still wrong, which is the less useful half.
|
|
2887
|
+
recoveryNote = ` Recovery failed: ${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`;
|
|
2558
2888
|
}
|
|
2559
2889
|
}
|
|
2890
|
+
if (binding !== "bound") {
|
|
2891
|
+
// Refuse on "unknown" as well as "elsewhere". A placeholder prodex cannot
|
|
2892
|
+
// read is not evidence that the composer is this project's, and what it
|
|
2893
|
+
// guards against - a prompt posted into another project, recorded under
|
|
2894
|
+
// the requested one - costs far more than a send the caller can retry.
|
|
2895
|
+
throw new ChatGptBrowserBlockerError(projectNotBoundBlocker({
|
|
2896
|
+
project: wanted,
|
|
2897
|
+
reason: binding,
|
|
2898
|
+
...(recoveryNote ? { recoveryNote } : {})
|
|
2899
|
+
}));
|
|
2900
|
+
}
|
|
2560
2901
|
const composerReady = await waitForExpressionTrue(cdp, `Boolean(document.querySelector('#prompt-textarea,[contenteditable="true"],textarea'))`, PROJECT_NAVIGATION_TIMEOUT_MS);
|
|
2561
2902
|
if (!composerReady) {
|
|
2562
|
-
throw new Error(`ChatGPT composer did not appear after entering project "${
|
|
2903
|
+
throw new Error(`ChatGPT composer did not appear after entering project "${wanted}"`);
|
|
2563
2904
|
}
|
|
2905
|
+
return chatGptProjectIdFromUrl(await cdp.evaluate("location.href"));
|
|
2564
2906
|
}
|
|
2565
2907
|
// Read the finished answer from an existing ChatGPT thread WITHOUT sending a new
|
|
2566
2908
|
// prompt. Recovers a consult whose send timed out but whose answer ChatGPT
|
|
@@ -2819,6 +3161,40 @@ export async function sendChatGptPrompt(options) {
|
|
|
2819
3161
|
busyBlocker = busyBlockerAfterTranscriptCheck(chatGptBusyBlocker(status), await readTranscriptCompletion(page, status.url));
|
|
2820
3162
|
}
|
|
2821
3163
|
}
|
|
3164
|
+
// ChatGPT's error page does not come back on a reload - measured: a project
|
|
3165
|
+
// home that failed reloaded straight back into it - and the tab then stays
|
|
3166
|
+
// there for every later send, including the retry its own blocker asks for.
|
|
3167
|
+
// Going home is the recovery that works, and an unpinned send has nothing to
|
|
3168
|
+
// lose by leaving an error page.
|
|
3169
|
+
if (looksLikeChatGptErrorPage({ bodyText: status.textSample, hasComposer: status.hasComposer })) {
|
|
3170
|
+
// A send pinned to a thread is not one of those. Navigating to the root
|
|
3171
|
+
// would trade the page that explains the failure for a target mismatch
|
|
3172
|
+
// that does not, and leave the pinned tab somewhere it was not asked to
|
|
3173
|
+
// go, so report the error page and keep the tab where it is.
|
|
3174
|
+
if (normalizedTargetUrl)
|
|
3175
|
+
throw new ChatGptBrowserBlockerError(chatGptErrorPageBlocker());
|
|
3176
|
+
emitProgress("waiting", "tab on ChatGPT's error page; opening a working page");
|
|
3177
|
+
try {
|
|
3178
|
+
await evaluateOnPage(page, `location.assign("https://chatgpt.com/")`);
|
|
3179
|
+
await waitForFreshChatGptPage(page, RELOAD_SETTLE_TIMEOUT_MS);
|
|
3180
|
+
let fresh = await readSettledChatGptPageStatus(page);
|
|
3181
|
+
fresh = await ensureVisibleChatGptPage(port, page, fresh);
|
|
3182
|
+
const blockerAfterHome = detectChatGptPageBlocker(fresh);
|
|
3183
|
+
if (blockerAfterHome)
|
|
3184
|
+
throw new ChatGptBrowserBlockerError(blockerAfterHome);
|
|
3185
|
+
// The busy verdict above was decided about the page we just left, and it
|
|
3186
|
+
// is handed to the readiness assert as already decided. Carrying it over
|
|
3187
|
+
// would let a root page that is generating an answer be typed into.
|
|
3188
|
+
busyBlocker = busyBlockerAfterTranscriptCheck(chatGptBusyBlocker(fresh), await readTranscriptCompletion(page, fresh.url));
|
|
3189
|
+
status = fresh;
|
|
3190
|
+
}
|
|
3191
|
+
catch (error) {
|
|
3192
|
+
// A blocker is the answer; anything else leaves the original status, and
|
|
3193
|
+
// the readiness assert below reports the error page it still sees.
|
|
3194
|
+
if (error instanceof ChatGptBrowserBlockerError || cdpCommandTimedOut(error))
|
|
3195
|
+
throw error;
|
|
3196
|
+
}
|
|
3197
|
+
}
|
|
2822
3198
|
// A thread can be left rendered with no composer at all - measured after a
|
|
2823
3199
|
// send, zero contenteditables and zero textareas on the page - and every
|
|
2824
3200
|
// retry then lands on the same dead page and reports the same "missing a
|
|
@@ -2894,6 +3270,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
2894
3270
|
process.stderr.write(`DBG-SEND +${Date.now() - sendStartedAt}ms ${msg}\n`);
|
|
2895
3271
|
};
|
|
2896
3272
|
let beforeSubmit;
|
|
3273
|
+
let boundProjectId;
|
|
2897
3274
|
let submitButtonFound = false;
|
|
2898
3275
|
let wantsDeepResearch = false;
|
|
2899
3276
|
const sendWarnings = [];
|
|
@@ -2939,11 +3316,16 @@ export async function sendChatGptPrompt(options) {
|
|
|
2939
3316
|
// Max and Ultra are rungs of Work's slider, so asking for one means staying
|
|
2940
3317
|
// there; anything else belongs on Chat, whose top step is Pro.
|
|
2941
3318
|
if (!effortNeedsWorkSurface(options.effort)) {
|
|
2942
|
-
|
|
3319
|
+
// Leaving the current page is safe only for a send that was going to
|
|
3320
|
+
// navigate anyway; a continuation or a pinned tab has to be reloaded
|
|
3321
|
+
// where it stands, because that page IS the destination.
|
|
3322
|
+
const surfaceWarning = await ensureChatSurface(cdp, {
|
|
3323
|
+
mayLeaveCurrentPage: Boolean(options.newChat || options.project || options.projectNew)
|
|
3324
|
+
});
|
|
2943
3325
|
if (surfaceWarning)
|
|
2944
3326
|
sendWarnings.push(surfaceWarning);
|
|
2945
3327
|
}
|
|
2946
|
-
await selectProject(cdp, options);
|
|
3328
|
+
boundProjectId = await selectProject(cdp, options);
|
|
2947
3329
|
try {
|
|
2948
3330
|
await selectModelReasoning(cdp, options, sendWarnings);
|
|
2949
3331
|
}
|
|
@@ -2965,6 +3347,21 @@ export async function sendChatGptPrompt(options) {
|
|
|
2965
3347
|
// into; a --project/--project-new hop lands on a page with its own counts.
|
|
2966
3348
|
beforeSubmit = await evaluateOnPage(page, answerExpression());
|
|
2967
3349
|
dbgSend(`baseline url=${beforeSubmit.url} user=${beforeSubmit.userMessageCount} assistant=${beforeSubmit.assistantMessageCount}`);
|
|
3350
|
+
// Read the binding once more, on the composer this send is about to type
|
|
3351
|
+
// into. Everything between selectProject and here - the model picker, the
|
|
3352
|
+
// power slider - opens and closes over the composer, and a re-render is
|
|
3353
|
+
// exactly when it can come back bound to the project the tab arrived with.
|
|
3354
|
+
// This is not atomic and does not pretend to be; it closes a window that
|
|
3355
|
+
// measurably existed. It has to run BEFORE the attachments and the text,
|
|
3356
|
+
// because a composer holding a prompt no longer shows a placeholder.
|
|
3357
|
+
const boundProject = composerBindingTarget(options);
|
|
3358
|
+
if (boundProject) {
|
|
3359
|
+
const stillBound = await waitForComposerProjectBinding(cdp, boundProject, PROJECT_NAVIGATION_TIMEOUT_MS);
|
|
3360
|
+
dbgSend(`project binding before typing=${stillBound}`);
|
|
3361
|
+
if (stillBound !== "bound") {
|
|
3362
|
+
throw new ChatGptBrowserBlockerError(projectNotBoundBlocker({ project: boundProject, reason: "drifted" }));
|
|
3363
|
+
}
|
|
3364
|
+
}
|
|
2968
3365
|
// Attach BEFORE typing: the upload is the slow part, and a file that
|
|
2969
3366
|
// arrives after the prompt is submitted is a file ChatGPT never saw.
|
|
2970
3367
|
if (options.attachments && options.attachments.length > 0) {
|
|
@@ -3159,6 +3556,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3159
3556
|
answer: transcript.answer,
|
|
3160
3557
|
modelHints: finalState?.modelHints ?? [],
|
|
3161
3558
|
...(transcript.modelSlug ? { modelSlug: transcript.modelSlug } : finalState?.modelSlug ? { modelSlug: finalState.modelSlug } : {}),
|
|
3559
|
+
...(boundProjectId ? { boundProjectId } : {}),
|
|
3162
3560
|
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))
|
|
3163
3561
|
};
|
|
3164
3562
|
};
|
|
@@ -3307,6 +3705,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3307
3705
|
answer: completed.answer.trim(),
|
|
3308
3706
|
modelHints: completed.modelHints,
|
|
3309
3707
|
...(completed.modelSlug ? { modelSlug: completed.modelSlug } : {}),
|
|
3708
|
+
...(boundProjectId ? { boundProjectId } : {}),
|
|
3310
3709
|
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))
|
|
3311
3710
|
};
|
|
3312
3711
|
}
|
|
@@ -3321,6 +3720,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
3321
3720
|
answer: completed.answer.trim(),
|
|
3322
3721
|
modelHints: completed.modelHints,
|
|
3323
3722
|
...(completed.modelSlug ? { modelSlug: completed.modelSlug } : {}),
|
|
3723
|
+
...(boundProjectId ? { boundProjectId } : {}),
|
|
3324
3724
|
warnings: withDialogNote([
|
|
3325
3725
|
...sendWarnings,
|
|
3326
3726
|
...(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 } : {}) })] : []),
|
|
@@ -5030,6 +5430,68 @@ export function deepResearchReportExpression(conversationId) {
|
|
|
5030
5430
|
* Thread urls come in a plain (`/c/<id>`) and a project (`/g/g-p-.../c/<id>`)
|
|
5031
5431
|
* shape; both end in the conversation id the backend API is keyed by.
|
|
5032
5432
|
*/
|
|
5433
|
+
/**
|
|
5434
|
+
* The project a ChatGPT URL belongs to, as its stable id.
|
|
5435
|
+
*
|
|
5436
|
+
* Measured live within one send: the SAME project renders as
|
|
5437
|
+
* `/g/g-p-<hash>/project` on its home and `/g/g-p-<hash>-<name>/c/<id>` on the
|
|
5438
|
+
* thread that came out of it. Comparing the slugs whole would call those two
|
|
5439
|
+
* different projects, so only the hash - the part that does not depend on how
|
|
5440
|
+
* the page felt like writing the name - identifies it.
|
|
5441
|
+
*/
|
|
5442
|
+
export function chatGptProjectIdFromUrl(url) {
|
|
5443
|
+
if (!url)
|
|
5444
|
+
return undefined;
|
|
5445
|
+
const match = /\/g\/g-p-([0-9a-f]+)(?:[-/?#]|$)/i.exec(url);
|
|
5446
|
+
return match ? match[1].toLowerCase() : undefined;
|
|
5447
|
+
}
|
|
5448
|
+
/**
|
|
5449
|
+
* Where the prompt actually landed, against where it was aimed.
|
|
5450
|
+
*
|
|
5451
|
+
* The receipt recorded the project the caller ASKED for, which is intent, not
|
|
5452
|
+
* evidence: a send that ended up somewhere else was recorded under the name of
|
|
5453
|
+
* the place it never reached. The answered thread's URL carries the project it
|
|
5454
|
+
* really belongs to, and the project step knows the id it bound to, so the two
|
|
5455
|
+
* can be compared instead of assumed. "unverified" is its own answer - better
|
|
5456
|
+
* than a receipt that certifies what nobody checked.
|
|
5457
|
+
*/
|
|
5458
|
+
export function destinationVerification(input) {
|
|
5459
|
+
const answeredProjectId = chatGptProjectIdFromUrl(input.answeredUrl);
|
|
5460
|
+
const destination = !input.answeredUrl ? "unknown" : answeredProjectId ? "project" : "root";
|
|
5461
|
+
if (!input.requestedProject)
|
|
5462
|
+
return { destination };
|
|
5463
|
+
if (destination === "unknown")
|
|
5464
|
+
return { destination, verified: false };
|
|
5465
|
+
// Landing outside every project is wrong on its own evidence: it needs no id
|
|
5466
|
+
// to compare against, and requiring one would have retired a warning that
|
|
5467
|
+
// caught this in the field.
|
|
5468
|
+
if (destination === "root") {
|
|
5469
|
+
return {
|
|
5470
|
+
destination,
|
|
5471
|
+
verified: false,
|
|
5472
|
+
warning: "project_landing_warning: a project was requested but the answered thread is a root chat, so it landed OUTSIDE the project. " +
|
|
5473
|
+
"Move it via the thread menu (Move to project) or re-run; list projects with `prodex pro browser projects`."
|
|
5474
|
+
};
|
|
5475
|
+
}
|
|
5476
|
+
// In a project, with nothing to check it against: not a warning, but not a
|
|
5477
|
+
// verified landing either.
|
|
5478
|
+
if (!input.boundProjectId)
|
|
5479
|
+
return { destination, verified: false };
|
|
5480
|
+
const verified = answeredProjectId === input.boundProjectId;
|
|
5481
|
+
return {
|
|
5482
|
+
destination,
|
|
5483
|
+
verified,
|
|
5484
|
+
...(verified
|
|
5485
|
+
? {}
|
|
5486
|
+
: {
|
|
5487
|
+
// Naming the project it landed in would put another project's name in
|
|
5488
|
+
// a persisted record; the thread URL is already there for anyone who
|
|
5489
|
+
// needs to go look.
|
|
5490
|
+
warning: "project_landing_warning: the answered thread belongs to a different project than the one this send entered. " +
|
|
5491
|
+
"Open the thread URL in the receipt to see where it went."
|
|
5492
|
+
})
|
|
5493
|
+
};
|
|
5494
|
+
}
|
|
5033
5495
|
export function conversationIdFromThreadUrl(url) {
|
|
5034
5496
|
const match = /\/c\/([0-9a-fA-F-]{16,})/.exec(url);
|
|
5035
5497
|
return match ? match[1] : undefined;
|