@youdie006/prodex 0.21.3 → 0.23.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 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` (starts a browsed research run and hands you back the thread URL - prodex does NOT return the report, because a finished deep research report does not render in prodex's browser session even after a hard reload; read it in your own browser), `--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.
38
+ The answer prints to your terminal and is saved under `.bridge/` for later (`prodex pro latest` re-prints it). It is read from the conversation transcript rather than scraped off the page, so markdown tables and fenced code arrive intact, citations keep their links, and a tab that drifts to another conversation mid-wait no longer costs you the answer; the rendered page stays as a fallback. 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
 
@@ -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: resolveTranscriptCitations(report.report, report.references).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);
@@ -1726,6 +1750,26 @@ export async function recoverChatGptAnswerFromThread(options) {
1726
1750
  warnings: []
1727
1751
  };
1728
1752
  }
1753
+ /**
1754
+ * Read the answer from the conversation transcript, or undefined when it is not
1755
+ * there yet. The transcript trails the rendered stream by a beat, so callers
1756
+ * either poll it or fall back to the DOM text.
1757
+ */
1758
+ async function readTranscriptAnswer(page, conversationId) {
1759
+ let transcript;
1760
+ try {
1761
+ transcript = await evaluateOnPage(page, transcriptAnswerExpression(conversationId), { timeoutMs: 30_000 });
1762
+ }
1763
+ catch {
1764
+ // Transcript unavailable (endpoint changed, transient failure): the DOM
1765
+ // reader still runs, so this never blocks a send.
1766
+ return undefined;
1767
+ }
1768
+ if (!transcript.ok || transcript.text.trim().length === 0)
1769
+ return undefined;
1770
+ const answer = resolveTranscriptCitations(transcript.text, transcript.references).trim();
1771
+ return answer.length > 0 ? { answer, modelSlug: transcript.modelSlug } : undefined;
1772
+ }
1729
1773
  export async function sendChatGptPrompt(options) {
1730
1774
  const port = resolveCdpPort(options.port);
1731
1775
  const timeoutMs = options.timeoutMs ?? 90_000;
@@ -1838,6 +1882,7 @@ export async function sendChatGptPrompt(options) {
1838
1882
  };
1839
1883
  let beforeSubmit;
1840
1884
  let submitButtonFound = false;
1885
+ let wantsDeepResearch = false;
1841
1886
  const sendWarnings = [];
1842
1887
  const cdp = await connectCdp(page.webSocketDebuggerUrl);
1843
1888
  try {
@@ -1872,7 +1917,7 @@ export async function sendChatGptPrompt(options) {
1872
1917
  emitProgress("selecting", `attached ${uploaded.attached.join(", ")}`);
1873
1918
  }
1874
1919
  const toolLabels = (options.tools ?? []).map(resolveComposerToolLabel);
1875
- const wantsDeepResearch = toolLabels.includes(DEEP_RESEARCH_TOOL_LABEL);
1920
+ wantsDeepResearch = toolLabels.includes(DEEP_RESEARCH_TOOL_LABEL);
1876
1921
  if (toolLabels.length > 0)
1877
1922
  emitProgress("selecting", `tools=${toolLabels.join(", ")}`);
1878
1923
  await insertComposerTextViaCdp(cdp, options.prompt, page, toolLabels);
@@ -1932,11 +1977,6 @@ export async function sendChatGptPrompt(options) {
1932
1977
  }
1933
1978
  await sleep(1_000);
1934
1979
  }
1935
- // Do not wait for a report this browser will never render.
1936
- const threadNow = await cdp.evaluate("location.href");
1937
- if (typeof threadNow === "string" && threadNow.length > 0) {
1938
- throw new ChatGptBrowserBlockerError(deepResearchUnreadableBlocker(threadNow));
1939
- }
1940
1980
  if (!pressed) {
1941
1981
  sendWarnings.push("deep_research_start_not_found: no start control appeared for the deep research run. If ChatGPT asked a clarifying question instead, answer it with a follow-up consult in the same thread.");
1942
1982
  }
@@ -2009,13 +2049,85 @@ export async function sendChatGptPrompt(options) {
2009
2049
  // silently, with a receipt (caught live). Nothing about that is recoverable
2010
2050
  // after the fact, so the wait either stays on this thread or fails loudly.
2011
2051
  const pinnedThreadUrl = finalState?.url;
2052
+ // Pin the CONVERSATION, not the tab. The transcript reader fetches by id, so
2053
+ // it keeps working when the tab wanders off the thread - which is exactly how
2054
+ // a finished answer was lost: the tab returned to the project page, the url
2055
+ // still matched the pin taken before ChatGPT rewrote it, and the DOM reader
2056
+ // sat on zero assistant messages until the budget ran out.
2057
+ let transcriptConversationId = pinnedThreadUrl ? conversationIdFromThreadUrl(pinnedThreadUrl) : undefined;
2058
+ const transcriptResult = (transcript) => {
2059
+ emitProgress("answered", `transcript (${transcript.answer.length} chars)`);
2060
+ return {
2061
+ url: finalState?.url ?? pinnedThreadUrl ?? "",
2062
+ title: finalState?.title ?? "",
2063
+ answer: transcript.answer,
2064
+ modelHints: finalState?.modelHints ?? [],
2065
+ ...(transcript.modelSlug ? { modelSlug: transcript.modelSlug } : finalState?.modelSlug ? { modelSlug: finalState.modelSlug } : {}),
2066
+ warnings: [...sendWarnings, modelSelectionWarning(options.model, transcript.modelSlug || finalState?.modelSlug)].filter((warning) => Boolean(warning))
2067
+ };
2068
+ };
2069
+ // Deep research never reaches the DOM answer wait below: the report is
2070
+ // rendered by a widget app in an iframe, so the main frame stays empty even
2071
+ // when the run has finished. Read the run out of the conversation transcript
2072
+ // instead, which is where the widget keeps its state.
2073
+ if (wantsDeepResearch) {
2074
+ const conversationId = pinnedThreadUrl ? conversationIdFromThreadUrl(pinnedThreadUrl) : undefined;
2075
+ if (!conversationId)
2076
+ throw new ChatGptBrowserBlockerError(deepResearchUnreadableBlocker(pinnedThreadUrl ?? "https://chatgpt.com/"));
2077
+ let lastState;
2078
+ while (Date.now() - started < timeoutMs) {
2079
+ try {
2080
+ lastState = await evaluateOnPage(page, deepResearchReportExpression(conversationId), { timeoutMs: 60_000 });
2081
+ }
2082
+ catch {
2083
+ // Transient CDP/network failure: keep polling until the budget runs out.
2084
+ await sleep(5_000);
2085
+ continue;
2086
+ }
2087
+ if (lastState.ok && lastState.report.trim().length > 0) {
2088
+ const report = resolveTranscriptCitations(lastState.report, lastState.references).trim();
2089
+ emitProgress("answered", `deep research report (${report.length} chars)`);
2090
+ return {
2091
+ url: pinnedThreadUrl ?? "",
2092
+ title: finalState?.title ?? "",
2093
+ answer: report,
2094
+ modelHints: finalState?.modelHints ?? [],
2095
+ ...(finalState?.modelSlug ? { modelSlug: finalState.modelSlug } : {}),
2096
+ warnings: sendWarnings
2097
+ };
2098
+ }
2099
+ emitProgress("waiting", `deep research ${lastState.status || lastState.reason} (${formatDurationMs(Date.now() - started)})`);
2100
+ // Each poll pulls the whole transcript, which a research run grows into
2101
+ // the hundreds of KB - so poll on a calm cadence, not a tight one.
2102
+ await sleep(15_000);
2103
+ }
2104
+ throw new ChatGptBrowserBlockerError({
2105
+ code: "deep_research_still_running",
2106
+ message: `The deep research run was still ${lastState?.status || "in progress"} after ${formatDurationMs(timeoutMs)}.`,
2107
+ retryable: true,
2108
+ next_step: `Fetch the report once it finishes with \`prodex pro browser recover --target-url ${pinnedThreadUrl}\`, or read it in your browser: ${pinnedThreadUrl}`,
2109
+ ...(pinnedThreadUrl ? { thread: pinnedThreadUrl } : {})
2110
+ });
2111
+ }
2012
2112
  let recoveredNavigations = 0;
2013
2113
  const answerIsStable = createChatGptAnswerStabilityTracker();
2014
2114
  while (Date.now() - started < timeoutMs) {
2015
2115
  await sleep(1000);
2016
2116
  try {
2017
2117
  finalState = await evaluateOnPage(page, answerExpression());
2018
- if (pinnedThreadUrl && finalState?.url && !chatGptUrlsReferToSameTarget(finalState.url, pinnedThreadUrl)) {
2118
+ if (!transcriptConversationId && finalState?.url)
2119
+ transcriptConversationId = conversationIdFromThreadUrl(finalState.url);
2120
+ // The transcript is the same data the page renders, minus the rendering:
2121
+ // markdown instead of flattened innerText, an explicit finish state
2122
+ // instead of caret heuristics, and the model that actually answered.
2123
+ if (transcriptConversationId && !finalState.generating) {
2124
+ const transcript = await readTranscriptAnswer(page, transcriptConversationId);
2125
+ if (transcript)
2126
+ return transcriptResult(transcript);
2127
+ }
2128
+ // Only the DOM reader depends on which thread the tab is showing; once the
2129
+ // conversation id is known, a wandering tab is harmless.
2130
+ if (!transcriptConversationId && pinnedThreadUrl && finalState?.url && !chatGptUrlsReferToSameTarget(finalState.url, pinnedThreadUrl)) {
2019
2131
  if (recoveredNavigations >= 2) {
2020
2132
  throw new ChatGptBrowserBlockerError({
2021
2133
  code: "thread_navigated_away",
@@ -2050,8 +2162,20 @@ export async function sendChatGptPrompt(options) {
2050
2162
  // mid-stream, and the streaming caret renders as a literal trailing
2051
2163
  // character that can outlive the stop button. The tracker requires extra
2052
2164
  // confirmations for caret-suspect tails (see its doc comment).
2053
- if (answerIsStable(finalState.answer, finalState.generating))
2165
+ if (answerIsStable(finalState.answer, finalState.generating)) {
2166
+ // The rendered answer settles a beat before the server transcript does.
2167
+ // Give the transcript that beat: it carries markdown (tables and fenced
2168
+ // code that innerText flattens) and the model that actually answered.
2169
+ if (transcriptConversationId) {
2170
+ for (let attempt = 0; attempt < 6; attempt += 1) {
2171
+ const transcript = await readTranscriptAnswer(page, transcriptConversationId);
2172
+ if (transcript)
2173
+ return transcriptResult(transcript);
2174
+ await sleep(1_500);
2175
+ }
2176
+ }
2054
2177
  break;
2178
+ }
2055
2179
  }
2056
2180
  const completed = finalState;
2057
2181
  if (completed && hasFreshChatGptAnswer(beforeSubmit.assistantMessageCount, completed)) {
@@ -2883,23 +3007,165 @@ export function chunkComposerText(text, size = COMPOSER_INSERT_CHUNK_CHARS) {
2883
3007
  * (measured live), because nothing pressed it.
2884
3008
  */
2885
3009
  /**
2886
- * Deep research reports are not readable from prodex's browser session.
2887
- * Measured against a run the user finished themselves: the SAME thread, same
2888
- * account, hard-reloaded here, renders only the prompt turn and an empty
2889
- * result turn (roles ["user"], 72 characters of page) while their own browser
2890
- * shows the completed report. Rather than spend a 30-minute budget waiting for
2891
- * text that never arrives, prodex hands back the thread and says where to read
2892
- * it.
3010
+ * The report is read from the conversation transcript, which is keyed by the
3011
+ * conversation id in the thread url. Without that id there is nothing to poll,
3012
+ * so hand the run back rather than waiting on a page that never renders it -
3013
+ * deep research draws into a widget iframe, leaving the thread DOM empty.
2893
3014
  */
2894
3015
  export function deepResearchUnreadableBlocker(threadUrl) {
2895
3016
  return {
2896
3017
  code: "deep_research_not_readable",
2897
- message: "The deep research run was started, but prodex cannot read deep research reports from its browser session - they do not render there.",
2898
- retryable: false,
2899
- next_step: `Open the run in your own browser and read it there: ${threadUrl}`,
3018
+ message: "The deep research run was started, but prodex could not tell which conversation it landed in, so it cannot fetch the report.",
3019
+ retryable: true,
3020
+ next_step: `Read the run in your browser, or fetch it once it finishes with \`prodex pro browser recover --target-url ${threadUrl}\`: ${threadUrl}`,
2900
3021
  thread: threadUrl
2901
3022
  };
2902
3023
  }
3024
+ /**
3025
+ * The transcript is the same data the UI renders, minus the rendering: it
3026
+ * carries the answer as markdown (tables and fenced code survive, which
3027
+ * innerText flattens), an explicit finish state, and the model that actually
3028
+ * answered. Walk from `current_node` up the parents so a regenerated turn reads
3029
+ * the branch the UI is on, not an abandoned sibling.
3030
+ */
3031
+ export function transcriptAnswerExpression(conversationId) {
3032
+ return `(async () => {
3033
+ const fail = (reason, extra) => Object.assign({ ok: false, reason, status: "", endTurn: false, isComplete: false, text: "", modelSlug: "", references: [] }, extra || {});
3034
+ let token = "";
3035
+ try {
3036
+ const session = await fetch("/api/auth/session", { credentials: "include" });
3037
+ if (!session.ok) return fail("session_http_" + session.status);
3038
+ const parsed = await session.json();
3039
+ token = (parsed && parsed.accessToken) || "";
3040
+ } catch (error) {
3041
+ return fail("session_error");
3042
+ }
3043
+ let conversation;
3044
+ try {
3045
+ const response = await fetch("/backend-api/conversation/" + ${JSON.stringify(conversationId)}, {
3046
+ credentials: "include",
3047
+ headers: token ? { Authorization: "Bearer " + token } : {}
3048
+ });
3049
+ if (!response.ok) return fail("conversation_http_" + response.status);
3050
+ conversation = await response.json();
3051
+ } catch (error) {
3052
+ return fail("conversation_error");
3053
+ }
3054
+ const mapping = (conversation && conversation.mapping) || {};
3055
+ const chain = [];
3056
+ let nodeId = conversation && conversation.current_node;
3057
+ let guard = 0;
3058
+ while (nodeId && mapping[nodeId] && guard < 2000) {
3059
+ guard += 1;
3060
+ if (mapping[nodeId].message) chain.push(mapping[nodeId].message);
3061
+ nodeId = mapping[nodeId].parent;
3062
+ }
3063
+ const message = chain.find(
3064
+ (entry) => entry && entry.author && entry.author.role === "assistant" && entry.content && entry.content.content_type === "text"
3065
+ );
3066
+ if (!message) return fail("no_assistant_message");
3067
+ const parts = (message.content.parts || []).filter((part) => typeof part === "string");
3068
+ const text = parts.join("");
3069
+ const metadata = message.metadata || {};
3070
+ const state = {
3071
+ status: message.status || "",
3072
+ endTurn: message.end_turn === true,
3073
+ isComplete: metadata.is_complete === true,
3074
+ text,
3075
+ modelSlug: metadata.model_slug || "",
3076
+ references: Array.isArray(metadata.content_references) ? metadata.content_references : []
3077
+ };
3078
+ if (state.status !== "finished_successfully" || !state.endTurn) return fail("answer_not_finished", state);
3079
+ if (!text) return fail("answer_empty", state);
3080
+ return Object.assign({ ok: true, reason: "" }, state);
3081
+ })()`;
3082
+ }
3083
+ // ChatGPT marks citations with private-use delimiters (U+E200 opens, U+E202
3084
+ // separates, U+E201 closes) and keeps the real sources in content_references.
3085
+ const CITATION_MARKER_PATTERN = /\uE200[^\uE200-\uE206]*(?:[\uE202\uE204-\uE206][^\uE200-\uE206]*)*[\uE201\uE203]/g;
3086
+ /**
3087
+ * Turn those markers into ordinary markdown links, so a saved answer keeps the
3088
+ * sources instead of the private-use noise (or, as in the rendered DOM, nothing
3089
+ * at all). Markers with no matching reference are dropped.
3090
+ */
3091
+ export function resolveTranscriptCitations(text, references = []) {
3092
+ const byMarker = new Map();
3093
+ for (const reference of references) {
3094
+ if (reference && typeof reference.matched_text === "string" && reference.matched_text.length > 0) {
3095
+ byMarker.set(reference.matched_text, reference);
3096
+ }
3097
+ }
3098
+ const linksFor = (reference) => {
3099
+ const seen = new Set();
3100
+ const links = [];
3101
+ for (const item of reference?.items ?? []) {
3102
+ const url = item?.url;
3103
+ if (!url || seen.has(url))
3104
+ continue;
3105
+ seen.add(url);
3106
+ links.push(`[${(item.title || url).trim()}](${url})`);
3107
+ }
3108
+ return links.length > 0 ? ` ${links.join(" ")}` : "";
3109
+ };
3110
+ let resolved = text;
3111
+ for (const [marker, reference] of byMarker) {
3112
+ resolved = resolved.split(marker).join(linksFor(reference));
3113
+ }
3114
+ // Anything still delimited had no reference to restore: strip it so private-use
3115
+ // characters never reach a receipt.
3116
+ return resolved.replace(CITATION_MARKER_PATTERN, "");
3117
+ }
3118
+ export function deepResearchReportExpression(conversationId) {
3119
+ return `(async () => {
3120
+ const fail = (reason, status) => ({ ok: false, reason, status: status || "", report: "", chars: 0, references: [] });
3121
+ let token = "";
3122
+ try {
3123
+ const session = await fetch("/api/auth/session", { credentials: "include" });
3124
+ if (!session.ok) return fail("session_http_" + session.status);
3125
+ const parsed = await session.json();
3126
+ token = (parsed && parsed.accessToken) || "";
3127
+ } catch (error) {
3128
+ return fail("session_error");
3129
+ }
3130
+ let conversation;
3131
+ try {
3132
+ const response = await fetch("/backend-api/conversation/" + ${JSON.stringify(conversationId)}, {
3133
+ credentials: "include",
3134
+ headers: token ? { Authorization: "Bearer " + token } : {}
3135
+ });
3136
+ if (!response.ok) return fail("conversation_http_" + response.status);
3137
+ conversation = await response.json();
3138
+ } catch (error) {
3139
+ return fail("conversation_error");
3140
+ }
3141
+ const nodes = Object.keys((conversation && conversation.mapping) || {}).map((key) => conversation.mapping[key]);
3142
+ const widgetNode = nodes.find(
3143
+ (node) => node && node.message && node.message.metadata && node.message.metadata.chatgpt_sdk && node.message.metadata.chatgpt_sdk.widget_state
3144
+ );
3145
+ if (!widgetNode) return fail("no_widget_state");
3146
+ let state;
3147
+ try {
3148
+ state = JSON.parse(widgetNode.message.metadata.chatgpt_sdk.widget_state);
3149
+ } catch (error) {
3150
+ return fail("widget_state_unparsable");
3151
+ }
3152
+ const status = (state && state.status) || "";
3153
+ const message = (state && state.report_message) || null;
3154
+ const parts = message && message.content && message.content.parts;
3155
+ const report = Array.isArray(parts) ? parts.filter((part) => typeof part === "string").join("") : "";
3156
+ const references = message && message.metadata && Array.isArray(message.metadata.content_references) ? message.metadata.content_references : [];
3157
+ if (!report) return fail("report_not_ready", status);
3158
+ return { ok: true, reason: "", status, report, chars: report.length, references };
3159
+ })()`;
3160
+ }
3161
+ /**
3162
+ * Thread urls come in a plain (`/c/<id>`) and a project (`/g/g-p-.../c/<id>`)
3163
+ * shape; both end in the conversation id the backend API is keyed by.
3164
+ */
3165
+ export function conversationIdFromThreadUrl(url) {
3166
+ const match = /\/c\/([0-9a-fA-F-]{16,})/.exec(url);
3167
+ return match ? match[1] : undefined;
3168
+ }
2903
3169
  export function deepResearchStartButtonRectExpression() {
2904
3170
  return `(() => {${CLICK_POINT_SNIPPET}
2905
3171
  const buttons = [...document.querySelectorAll('button,[role="button"]')];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.21.3",
3
+ "version": "0.23.0",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",