@youdie006/prodex 0.21.2 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/chatgpt-browser.js +144 -3
- package/dist/cli-pro.js +10 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ prodex ask --file src/auth.ts "Review this for security holes"
|
|
|
35
35
|
|
|
36
36
|
`prodex ask` is the short form of `prodex pro browser ask`; the full form and every flag work identically. In an interactive terminal, `login` keeps watching the opened window and tells you exactly which manual step is still missing (log in, clear a check, open a chat) until it reports READY. If you skip `login` and the browser is not running, an interactive `ask` recovers on its own: it launches the dedicated browser, waits for your saved session to be READY, and retries the send once (disable with `--no-auto-login`; scripts opt in with `--auto-login`). While ChatGPT thinks, `prodex` prints progress to stderr (connecting, prompt sent, elapsed seconds while generating), so a multi-minute Pro answer never looks frozen.
|
|
37
37
|
|
|
38
|
-
The answer prints to your terminal and is saved under `.bridge/` for later (`prodex pro latest` re-prints it). Add `--new-chat` to send into a fresh chat (recommended for repeated consults - long threads eventually confuse send detection). For a structured second-opinion debate between your coding agent and GPT Pro, `prodex pro debate-prompt --topic "..."` prints a ready-to-paste orchestration prompt. `prodex` drives the picker you can see and will not send into a tab it cannot read, so leave the dedicated window on a ChatGPT tab; it sends quietly in the background without stealing focus. Prefer no window at all? See [virtual display](#no-window-at-all-virtual-display-recommended). Pin per-repo defaults once - `prodex setup --model Pro --project "your-project"` - so every ask runs Pro (20-minute timeout) inside that project instead of whatever the ChatGPT UI last had selected; list exact sidebar project names with `prodex pro browser projects`. Pass `--file` more than once to inline several files. `--file` puts a text file's CONTENTS into the prompt; `--attach` uploads the file itself, which is the only way to hand ChatGPT a pdf, pptx, xlsx or image and let it parse the original (`prodex ask --attach deck.pptx "Review slides 40-60"`). Both are restricted to paths inside the repo, so an agent cannot upload `~/.ssh` by asking nicely. The upload happens before the prompt is submitted and prodex waits for ChatGPT to finish accepting the file - the browser process reads the path, so the file has to live on the machine running the browser. `--tool` turns on a ChatGPT composer tool for that send: `--tool deep-research` (a browsed report - the timeout rises to 30 minutes automatically, and
|
|
38
|
+
The answer prints to your terminal and is saved under `.bridge/` for later (`prodex pro latest` re-prints it). Add `--new-chat` to send into a fresh chat (recommended for repeated consults - long threads eventually confuse send detection). For a structured second-opinion debate between your coding agent and GPT Pro, `prodex pro debate-prompt --topic "..."` prints a ready-to-paste orchestration prompt. `prodex` drives the picker you can see and will not send into a tab it cannot read, so leave the dedicated window on a ChatGPT tab; it sends quietly in the background without stealing focus. Prefer no window at all? See [virtual display](#no-window-at-all-virtual-display-recommended). Pin per-repo defaults once - `prodex setup --model Pro --project "your-project"` - so every ask runs Pro (20-minute timeout) inside that project instead of whatever the ChatGPT UI last had selected; list exact sidebar project names with `prodex pro browser projects`. Pass `--file` more than once to inline several files. `--file` puts a text file's CONTENTS into the prompt; `--attach` uploads the file itself, which is the only way to hand ChatGPT a pdf, pptx, xlsx or image and let it parse the original (`prodex ask --attach deck.pptx "Review slides 40-60"`). Both are restricted to paths inside the repo, so an agent cannot upload `~/.ssh` by asking nicely. The upload happens before the prompt is submitted and prodex waits for ChatGPT to finish accepting the file - the browser process reads the path, so the file has to live on the machine running the browser. `--tool` turns on a ChatGPT composer tool for that send: `--tool deep-research` (a browsed report - the timeout rises to 30 minutes automatically; prodex presses the start control, waits out the run and returns the full report. The report is read from the conversation transcript rather than the page, because deep research renders inside a widget iframe that leaves the thread looking empty - which also means `prodex pro browser recover --target-url <thread>` fetches a research report that finished after a timeout), `--tool web-search` (current facts with sources), `--tool create-image`. Any other label the menu shows works too, so a tool ChatGPT adds later needs no prodex release. When the thread is still generating a previous answer (common right after a timed-out Pro send), the send automatically queues behind it up to the timeout budget; tune that with `--busy-wait-ms` (0 fails fast with a `response_in_progress` blocker). See [First Pro Login](#first-pro-login) for the full flow, and the [FAQ](#faq) if a send stops.
|
|
39
39
|
|
|
40
40
|
## Core Shape
|
|
41
41
|
|
package/dist/chatgpt-browser.js
CHANGED
|
@@ -1672,6 +1672,30 @@ export async function recoverChatGptAnswerFromThread(options) {
|
|
|
1672
1672
|
// In-tab navigation (location.assign, not Page.navigate which has crashed the
|
|
1673
1673
|
// instance) so we read the requested thread, not whatever was open.
|
|
1674
1674
|
await cdp.evaluate(`location.assign(${JSON.stringify(url)})`);
|
|
1675
|
+
// A deep research thread has no assistant message to recover - its report
|
|
1676
|
+
// lives in the widget state on the conversation transcript. Check that
|
|
1677
|
+
// first so `recover` works on research threads at all.
|
|
1678
|
+
const conversationId = conversationIdFromThreadUrl(url);
|
|
1679
|
+
if (conversationId) {
|
|
1680
|
+
try {
|
|
1681
|
+
const report = await evaluateOnPage(page.page, deepResearchReportExpression(conversationId), {
|
|
1682
|
+
timeoutMs: 60_000
|
|
1683
|
+
});
|
|
1684
|
+
if (report.ok && report.report.trim().length > 0) {
|
|
1685
|
+
return {
|
|
1686
|
+
url,
|
|
1687
|
+
title: "",
|
|
1688
|
+
answer: report.report.trim(),
|
|
1689
|
+
modelHints: [],
|
|
1690
|
+
warnings: []
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
catch {
|
|
1695
|
+
// Not a research thread, or the transcript API is unavailable: fall
|
|
1696
|
+
// through to the normal DOM recovery below.
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1675
1699
|
const deadline = Date.now() + timeoutMs;
|
|
1676
1700
|
while (Date.now() < deadline) {
|
|
1677
1701
|
await sleep(500);
|
|
@@ -1838,6 +1862,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
1838
1862
|
};
|
|
1839
1863
|
let beforeSubmit;
|
|
1840
1864
|
let submitButtonFound = false;
|
|
1865
|
+
let wantsDeepResearch = false;
|
|
1841
1866
|
const sendWarnings = [];
|
|
1842
1867
|
const cdp = await connectCdp(page.webSocketDebuggerUrl);
|
|
1843
1868
|
try {
|
|
@@ -1872,7 +1897,7 @@ export async function sendChatGptPrompt(options) {
|
|
|
1872
1897
|
emitProgress("selecting", `attached ${uploaded.attached.join(", ")}`);
|
|
1873
1898
|
}
|
|
1874
1899
|
const toolLabels = (options.tools ?? []).map(resolveComposerToolLabel);
|
|
1875
|
-
|
|
1900
|
+
wantsDeepResearch = toolLabels.includes(DEEP_RESEARCH_TOOL_LABEL);
|
|
1876
1901
|
if (toolLabels.length > 0)
|
|
1877
1902
|
emitProgress("selecting", `tools=${toolLabels.join(", ")}`);
|
|
1878
1903
|
await insertComposerTextViaCdp(cdp, options.prompt, page, toolLabels);
|
|
@@ -2004,6 +2029,48 @@ export async function sendChatGptPrompt(options) {
|
|
|
2004
2029
|
// silently, with a receipt (caught live). Nothing about that is recoverable
|
|
2005
2030
|
// after the fact, so the wait either stays on this thread or fails loudly.
|
|
2006
2031
|
const pinnedThreadUrl = finalState?.url;
|
|
2032
|
+
// Deep research never reaches the DOM answer wait below: the report is
|
|
2033
|
+
// rendered by a widget app in an iframe, so the main frame stays empty even
|
|
2034
|
+
// when the run has finished. Read the run out of the conversation transcript
|
|
2035
|
+
// instead, which is where the widget keeps its state.
|
|
2036
|
+
if (wantsDeepResearch) {
|
|
2037
|
+
const conversationId = pinnedThreadUrl ? conversationIdFromThreadUrl(pinnedThreadUrl) : undefined;
|
|
2038
|
+
if (!conversationId)
|
|
2039
|
+
throw new ChatGptBrowserBlockerError(deepResearchUnreadableBlocker(pinnedThreadUrl ?? "https://chatgpt.com/"));
|
|
2040
|
+
let lastState;
|
|
2041
|
+
while (Date.now() - started < timeoutMs) {
|
|
2042
|
+
try {
|
|
2043
|
+
lastState = await evaluateOnPage(page, deepResearchReportExpression(conversationId), { timeoutMs: 60_000 });
|
|
2044
|
+
}
|
|
2045
|
+
catch {
|
|
2046
|
+
// Transient CDP/network failure: keep polling until the budget runs out.
|
|
2047
|
+
await sleep(5_000);
|
|
2048
|
+
continue;
|
|
2049
|
+
}
|
|
2050
|
+
if (lastState.ok && lastState.report.trim().length > 0) {
|
|
2051
|
+
emitProgress("answered", `deep research report (${lastState.chars} chars)`);
|
|
2052
|
+
return {
|
|
2053
|
+
url: pinnedThreadUrl ?? "",
|
|
2054
|
+
title: finalState?.title ?? "",
|
|
2055
|
+
answer: lastState.report.trim(),
|
|
2056
|
+
modelHints: finalState?.modelHints ?? [],
|
|
2057
|
+
...(finalState?.modelSlug ? { modelSlug: finalState.modelSlug } : {}),
|
|
2058
|
+
warnings: sendWarnings
|
|
2059
|
+
};
|
|
2060
|
+
}
|
|
2061
|
+
emitProgress("waiting", `deep research ${lastState.status || lastState.reason} (${formatDurationMs(Date.now() - started)})`);
|
|
2062
|
+
// Each poll pulls the whole transcript, which a research run grows into
|
|
2063
|
+
// the hundreds of KB - so poll on a calm cadence, not a tight one.
|
|
2064
|
+
await sleep(15_000);
|
|
2065
|
+
}
|
|
2066
|
+
throw new ChatGptBrowserBlockerError({
|
|
2067
|
+
code: "deep_research_still_running",
|
|
2068
|
+
message: `The deep research run was still ${lastState?.status || "in progress"} after ${formatDurationMs(timeoutMs)}.`,
|
|
2069
|
+
retryable: true,
|
|
2070
|
+
next_step: `Fetch the report once it finishes with \`prodex pro browser recover --target-url ${pinnedThreadUrl}\`, or read it in your browser: ${pinnedThreadUrl}`,
|
|
2071
|
+
...(pinnedThreadUrl ? { thread: pinnedThreadUrl } : {})
|
|
2072
|
+
});
|
|
2073
|
+
}
|
|
2007
2074
|
let recoveredNavigations = 0;
|
|
2008
2075
|
const answerIsStable = createChatGptAnswerStabilityTracker();
|
|
2009
2076
|
while (Date.now() - started < timeoutMs) {
|
|
@@ -2877,6 +2944,70 @@ export function chunkComposerText(text, size = COMPOSER_INSERT_CHUNK_CHARS) {
|
|
|
2877
2944
|
* on that: a run left waiting produced zero assistant messages for 30+ minutes
|
|
2878
2945
|
* (measured live), because nothing pressed it.
|
|
2879
2946
|
*/
|
|
2947
|
+
/**
|
|
2948
|
+
* The report is read from the conversation transcript, which is keyed by the
|
|
2949
|
+
* conversation id in the thread url. Without that id there is nothing to poll,
|
|
2950
|
+
* so hand the run back rather than waiting on a page that never renders it -
|
|
2951
|
+
* deep research draws into a widget iframe, leaving the thread DOM empty.
|
|
2952
|
+
*/
|
|
2953
|
+
export function deepResearchUnreadableBlocker(threadUrl) {
|
|
2954
|
+
return {
|
|
2955
|
+
code: "deep_research_not_readable",
|
|
2956
|
+
message: "The deep research run was started, but prodex could not tell which conversation it landed in, so it cannot fetch the report.",
|
|
2957
|
+
retryable: true,
|
|
2958
|
+
next_step: `Read the run in your browser, or fetch it once it finishes with \`prodex pro browser recover --target-url ${threadUrl}\`: ${threadUrl}`,
|
|
2959
|
+
thread: threadUrl
|
|
2960
|
+
};
|
|
2961
|
+
}
|
|
2962
|
+
export function deepResearchReportExpression(conversationId) {
|
|
2963
|
+
return `(async () => {
|
|
2964
|
+
const fail = (reason, status) => ({ ok: false, reason, status: status || "", report: "", chars: 0 });
|
|
2965
|
+
let token = "";
|
|
2966
|
+
try {
|
|
2967
|
+
const session = await fetch("/api/auth/session", { credentials: "include" });
|
|
2968
|
+
if (!session.ok) return fail("session_http_" + session.status);
|
|
2969
|
+
const parsed = await session.json();
|
|
2970
|
+
token = (parsed && parsed.accessToken) || "";
|
|
2971
|
+
} catch (error) {
|
|
2972
|
+
return fail("session_error");
|
|
2973
|
+
}
|
|
2974
|
+
let conversation;
|
|
2975
|
+
try {
|
|
2976
|
+
const response = await fetch("/backend-api/conversation/" + ${JSON.stringify(conversationId)}, {
|
|
2977
|
+
credentials: "include",
|
|
2978
|
+
headers: token ? { Authorization: "Bearer " + token } : {}
|
|
2979
|
+
});
|
|
2980
|
+
if (!response.ok) return fail("conversation_http_" + response.status);
|
|
2981
|
+
conversation = await response.json();
|
|
2982
|
+
} catch (error) {
|
|
2983
|
+
return fail("conversation_error");
|
|
2984
|
+
}
|
|
2985
|
+
const nodes = Object.keys((conversation && conversation.mapping) || {}).map((key) => conversation.mapping[key]);
|
|
2986
|
+
const widgetNode = nodes.find(
|
|
2987
|
+
(node) => node && node.message && node.message.metadata && node.message.metadata.chatgpt_sdk && node.message.metadata.chatgpt_sdk.widget_state
|
|
2988
|
+
);
|
|
2989
|
+
if (!widgetNode) return fail("no_widget_state");
|
|
2990
|
+
let state;
|
|
2991
|
+
try {
|
|
2992
|
+
state = JSON.parse(widgetNode.message.metadata.chatgpt_sdk.widget_state);
|
|
2993
|
+
} catch (error) {
|
|
2994
|
+
return fail("widget_state_unparsable");
|
|
2995
|
+
}
|
|
2996
|
+
const status = (state && state.status) || "";
|
|
2997
|
+
const parts = state && state.report_message && state.report_message.content && state.report_message.content.parts;
|
|
2998
|
+
const report = Array.isArray(parts) ? parts.filter((part) => typeof part === "string").join("") : "";
|
|
2999
|
+
if (!report) return fail("report_not_ready", status);
|
|
3000
|
+
return { ok: true, reason: "", status, report, chars: report.length };
|
|
3001
|
+
})()`;
|
|
3002
|
+
}
|
|
3003
|
+
/**
|
|
3004
|
+
* Thread urls come in a plain (`/c/<id>`) and a project (`/g/g-p-.../c/<id>`)
|
|
3005
|
+
* shape; both end in the conversation id the backend API is keyed by.
|
|
3006
|
+
*/
|
|
3007
|
+
export function conversationIdFromThreadUrl(url) {
|
|
3008
|
+
const match = /\/c\/([0-9a-fA-F-]{16,})/.exec(url);
|
|
3009
|
+
return match ? match[1] : undefined;
|
|
3010
|
+
}
|
|
2880
3011
|
export function deepResearchStartButtonRectExpression() {
|
|
2881
3012
|
return `(() => {${CLICK_POINT_SNIPPET}
|
|
2882
3013
|
const buttons = [...document.querySelectorAll('button,[role="button"]')];
|
|
@@ -3002,7 +3133,17 @@ export function answerExpression() {
|
|
|
3002
3133
|
});
|
|
3003
3134
|
const assistantMessages = messages.filter((message) => message.role === "assistant");
|
|
3004
3135
|
const userMessages = messages.filter((message) => message.role === "user");
|
|
3005
|
-
|
|
3136
|
+
// Deep research renders no assistant-role node at all: the thread is
|
|
3137
|
+
// conversation-turn sections, the prompt in the first and the report in a
|
|
3138
|
+
// later one (measured live - roles were ["user"] only while a research ran).
|
|
3139
|
+
// Fall back to the last turn that is NOT the user's, so such an answer is
|
|
3140
|
+
// readable instead of looking like "no answer" forever.
|
|
3141
|
+
const turnAnswers = assistantMessages.length > 0 ? [] : [...document.querySelectorAll('[data-testid^="conversation-turn"]')]
|
|
3142
|
+
.filter((turn) => !turn.querySelector('[data-message-author-role="user"]'))
|
|
3143
|
+
.map((turn) => ({ role: "assistant", text: (turn.innerText || "").trim(), modelSlug: undefined }))
|
|
3144
|
+
.filter((turn) => turn.text.length > 0);
|
|
3145
|
+
const effectiveAssistants = assistantMessages.length > 0 ? assistantMessages : turnAnswers;
|
|
3146
|
+
const assistant = effectiveAssistants.at(-1);
|
|
3006
3147
|
const buttons = [...document.querySelectorAll('button,[role="button"]')]
|
|
3007
3148
|
.filter((node) => !!(node.offsetWidth || node.offsetHeight || node.getClientRects().length))
|
|
3008
3149
|
.filter((node) => !node.closest(excludedTextSelector))
|
|
@@ -3021,7 +3162,7 @@ export function answerExpression() {
|
|
|
3021
3162
|
blockerScanTextSample: visibleTextOutsideMessages(blockerScanExcludedSelector).slice(0, 12000),
|
|
3022
3163
|
visibleButtonLabels: buttons,
|
|
3023
3164
|
generating: placeholder || Boolean(document.querySelector(${streamingSelector})) || buttons.some((label) => generatingControlPattern.test(label)),
|
|
3024
|
-
assistantMessageCount:
|
|
3165
|
+
assistantMessageCount: effectiveAssistants.length,
|
|
3025
3166
|
userMessageCount: userMessages.length,
|
|
3026
3167
|
// ChatGPT tags each assistant message with the model that produced it -
|
|
3027
3168
|
// the only ground truth for "did the Pro selection actually take".
|
package/dist/cli-pro.js
CHANGED
|
@@ -948,6 +948,10 @@ export async function runAskProCommand(rest, io) {
|
|
|
948
948
|
const name = selectionMetadata.project;
|
|
949
949
|
return name ? text.split(name).join("<project>") : text;
|
|
950
950
|
};
|
|
951
|
+
// Where the prompt actually landed beats where the caller aimed: with
|
|
952
|
+
// --new-chat there is no target url, and a blocker that started a run
|
|
953
|
+
// still has a thread worth handing back.
|
|
954
|
+
const blockedThread = blocker.thread ?? normalizedTargetUrl;
|
|
951
955
|
const persistedBlocker = {
|
|
952
956
|
...blocker,
|
|
953
957
|
message: redactProject(blocker.message),
|
|
@@ -965,7 +969,7 @@ export async function runAskProCommand(rest, io) {
|
|
|
965
969
|
direction: "codex_to_chatgpt",
|
|
966
970
|
backend: "chatgpt-control",
|
|
967
971
|
task_id: task.id,
|
|
968
|
-
thread:
|
|
972
|
+
thread: blockedThread,
|
|
969
973
|
status: "blocked",
|
|
970
974
|
blocker: persistedBlocker,
|
|
971
975
|
warnings: []
|
|
@@ -977,7 +981,7 @@ export async function runAskProCommand(rest, io) {
|
|
|
977
981
|
// Keep stdout machine-parseable for --json consumers on the blocked
|
|
978
982
|
// path too; the human-readable error still goes to stderr via throw.
|
|
979
983
|
if (jsonOutput) {
|
|
980
|
-
io.stdout(JSON.stringify({ task_id: task.id, status: "blocked", thread:
|
|
984
|
+
io.stdout(JSON.stringify({ task_id: task.id, status: "blocked", thread: blockedThread ?? null, answer: null, warnings: [], blocker }, null, 2));
|
|
981
985
|
}
|
|
982
986
|
throw new Error(formatBlockedConsultRecordedMessage(message, task.id, sourceCli, { cwd: targetCwd }));
|
|
983
987
|
}
|
|
@@ -1313,7 +1317,10 @@ export function browserSendBlockerFromError(error) {
|
|
|
1313
1317
|
code: blocker.code,
|
|
1314
1318
|
message: blocker.message,
|
|
1315
1319
|
retryable: blocker.retryable,
|
|
1316
|
-
...("next_step" in blocker && typeof blocker.next_step === "string" ? { next_step: blocker.next_step } : {})
|
|
1320
|
+
...("next_step" in blocker && typeof blocker.next_step === "string" ? { next_step: blocker.next_step } : {}),
|
|
1321
|
+
// A blocker that knows which thread the prompt landed in is the only
|
|
1322
|
+
// place that URL exists - keep it so callers get a link, not prose.
|
|
1323
|
+
...("thread" in blocker && typeof blocker.thread === "string" ? { thread: blocker.thread } : {})
|
|
1317
1324
|
};
|
|
1318
1325
|
}
|
|
1319
1326
|
const message = errorMessage(error);
|