@youdie006/prodex 0.24.0 → 0.25.1

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/bundle.js CHANGED
@@ -12,10 +12,14 @@ export async function buildDryRunBundle(root, input) {
12
12
  input.prompt.trim()
13
13
  ];
14
14
  const files = [];
15
+ // The prompt leads so the instruction is never buried under file dumps.
16
+ const sendSections = [input.prompt.trim()];
15
17
  for (const file of input.files) {
16
18
  const content = await readRepoFile(root, file, { maxLines: 500 });
17
19
  files.push({ path: file, role: "context", bytes: Buffer.byteLength(content.content, "utf8") });
18
- sections.push("", `## File: ${file}`, "", "```text", content.content, "```");
20
+ const fileSection = ["", `## File: ${file}`, "", "```text", content.content, "```"];
21
+ sections.push(...fileSection);
22
+ sendSections.push(...fileSection);
19
23
  }
20
24
  return {
21
25
  schema_version: SCHEMA_VERSION,
@@ -24,6 +28,7 @@ export async function buildDryRunBundle(root, input) {
24
28
  prompt: input.prompt,
25
29
  files,
26
30
  text: sections.join("\n"),
31
+ sendText: sendSections.join("\n"),
27
32
  created_at: nowIso()
28
33
  };
29
34
  }
@@ -402,8 +402,63 @@ export function isUsableChatGptAnswer(answer) {
402
402
  if (headerOnly)
403
403
  return false;
404
404
  }
405
+ // A composer tool renders its own progress panel into the assistant turn
406
+ // before any text exists. Measured on a --tool web-search send: "Searching
407
+ // the web" over an "Answer now" button, which the page reports as a settled
408
+ // two-line answer. Only treat it as a placeholder when it is the WHOLE
409
+ // content, so an answer that discusses searching still counts.
410
+ if (lineCount <= 2) {
411
+ const toolPanelOnly = normalized
412
+ .split(/\r?\n/)
413
+ .map((line) => line.trim())
414
+ .filter(Boolean)
415
+ .every((line) => /^(searching the web|웹\s*검색\s*중|answer now|지금\s*답변|검색\s*중)$/i.test(line));
416
+ if (toolPanelOnly)
417
+ return false;
418
+ }
405
419
  return true;
406
420
  }
421
+ /**
422
+ * Who decides the answer is finished: the transcript, when it can be read.
423
+ *
424
+ * The page only guesses - a tool's progress panel looks exactly like a settled
425
+ * two-line answer, and a consult once returned "Searching the web / Answer now"
426
+ * as its result. When the transcript is reachable AND belongs to this consult,
427
+ * its state is authoritative: "pending" keeps the wait alive even if the page
428
+ * looks done. "unavailable" (unreachable, or a different conversation) is the
429
+ * only case that falls back to reading the page.
430
+ */
431
+ export function classifyTranscriptRead(state, sentPrompt) {
432
+ if (!state)
433
+ return "unavailable";
434
+ if (!transcriptMatchesSentPrompt(state.userText, sentPrompt))
435
+ return "unavailable";
436
+ if (state.ok)
437
+ return "answer";
438
+ return state.reason === "answer_not_finished" || state.reason === "no_assistant_message" || state.reason === "answer_empty"
439
+ ? "pending"
440
+ : "unavailable";
441
+ }
442
+ /**
443
+ * Should prodex drag the tab back to the thread it pinned?
444
+ *
445
+ * Only when the pin is a real conversation, and only when the page is the only
446
+ * way left to read the answer. Pinning a project or new-chat page (the url a
447
+ * send starts on, before ChatGPT rewrites it to /c/<id>) made prodex treat its
448
+ * OWN conversation as a stray tab and navigate away from the answer it was
449
+ * waiting for. And while the transcript can answer, moving someone else's tab
450
+ * back buys nothing.
451
+ */
452
+ export function shouldRecoverThreadNavigation(args) {
453
+ const { pinnedThreadUrl, currentUrl, lastTranscriptClassification } = args;
454
+ if (!pinnedThreadUrl || !currentUrl)
455
+ return false;
456
+ if (!conversationIdFromThreadUrl(pinnedThreadUrl))
457
+ return false;
458
+ if (lastTranscriptClassification === "pending" || lastTranscriptClassification === "answer")
459
+ return false;
460
+ return !chatGptUrlsReferToSameTarget(currentUrl, pinnedThreadUrl);
461
+ }
407
462
  export function hasFreshChatGptAnswer(previousAssistantMessageCount, state) {
408
463
  return state.assistantMessageCount > previousAssistantMessageCount && isUsableChatGptAnswer(state.answer) && !state.generating;
409
464
  }
@@ -1750,25 +1805,23 @@ export async function recoverChatGptAnswerFromThread(options) {
1750
1805
  warnings: []
1751
1806
  };
1752
1807
  }
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) {
1808
+ async function readTranscriptAnswer(page, conversationId, sentPrompt) {
1759
1809
  let transcript;
1760
1810
  try {
1761
1811
  transcript = await evaluateOnPage(page, transcriptAnswerExpression(conversationId), { timeoutMs: 30_000 });
1762
1812
  }
1763
1813
  catch {
1764
- // Transcript unavailable (endpoint changed, transient failure): the DOM
1814
+ // Transcript unreachable (endpoint changed, transient failure): the DOM
1765
1815
  // reader still runs, so this never blocks a send.
1766
- return undefined;
1816
+ return { classification: "unavailable" };
1767
1817
  }
1768
- if (!transcript.ok || transcript.text.trim().length === 0)
1769
- return undefined;
1818
+ const classification = classifyTranscriptRead(transcript, sentPrompt);
1819
+ if (classification !== "answer")
1820
+ return { classification };
1770
1821
  const answer = resolveTranscriptCitations(transcript.text, transcript.references).trim();
1771
- return answer.length > 0 ? { answer, modelSlug: transcript.modelSlug } : undefined;
1822
+ return answer.length > 0
1823
+ ? { classification: "answer", answer: { answer, modelSlug: transcript.modelSlug } }
1824
+ : { classification: "pending" };
1772
1825
  }
1773
1826
  export async function sendChatGptPrompt(options) {
1774
1827
  const port = resolveCdpPort(options.port);
@@ -2084,7 +2137,7 @@ export async function sendChatGptPrompt(options) {
2084
2137
  await sleep(5_000);
2085
2138
  continue;
2086
2139
  }
2087
- if (lastState.ok && lastState.report.trim().length > 0) {
2140
+ if (lastState.ok && lastState.report.trim().length > 0 && transcriptMatchesSentPrompt(lastState.userText, options.prompt)) {
2088
2141
  const report = resolveTranscriptCitations(lastState.report, lastState.references).trim();
2089
2142
  emitProgress("answered", `deep research report (${report.length} chars)`);
2090
2143
  return {
@@ -2110,24 +2163,28 @@ export async function sendChatGptPrompt(options) {
2110
2163
  });
2111
2164
  }
2112
2165
  let recoveredNavigations = 0;
2166
+ let lastTranscriptClassification;
2113
2167
  const answerIsStable = createChatGptAnswerStabilityTracker();
2114
2168
  while (Date.now() - started < timeoutMs) {
2115
2169
  await sleep(1000);
2116
2170
  try {
2117
2171
  finalState = await evaluateOnPage(page, answerExpression());
2172
+ // First conversation id wins. Re-deriving it every poll would let a tab
2173
+ // that wandered to another thread redirect the read to a stranger's
2174
+ // conversation - and the prompt check below is the second line of defence,
2175
+ // not the first.
2118
2176
  if (!transcriptConversationId && finalState?.url)
2119
2177
  transcriptConversationId = conversationIdFromThreadUrl(finalState.url);
2120
2178
  // The transcript is the same data the page renders, minus the rendering:
2121
2179
  // markdown instead of flattened innerText, an explicit finish state
2122
2180
  // instead of caret heuristics, and the model that actually answered.
2123
2181
  if (transcriptConversationId && !finalState.generating) {
2124
- const transcript = await readTranscriptAnswer(page, transcriptConversationId);
2125
- if (transcript)
2126
- return transcriptResult(transcript);
2182
+ const transcript = await readTranscriptAnswer(page, transcriptConversationId, options.prompt);
2183
+ lastTranscriptClassification = transcript.classification;
2184
+ if (transcript.answer)
2185
+ return transcriptResult(transcript.answer);
2127
2186
  }
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)) {
2187
+ if (shouldRecoverThreadNavigation({ pinnedThreadUrl, currentUrl: finalState?.url, lastTranscriptClassification })) {
2131
2188
  if (recoveredNavigations >= 2) {
2132
2189
  throw new ChatGptBrowserBlockerError({
2133
2190
  code: "thread_navigated_away",
@@ -2167,12 +2224,16 @@ export async function sendChatGptPrompt(options) {
2167
2224
  // Give the transcript that beat: it carries markdown (tables and fenced
2168
2225
  // code that innerText flattens) and the model that actually answered.
2169
2226
  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
- }
2227
+ const transcript = await readTranscriptAnswer(page, transcriptConversationId, options.prompt);
2228
+ lastTranscriptClassification = transcript.classification;
2229
+ if (transcript.answer)
2230
+ return transcriptResult(transcript.answer);
2231
+ // The transcript can read this conversation and says it is not done:
2232
+ // believe it over a page that merely looks settled. A tool's progress
2233
+ // panel renders exactly like a two-line answer, and that is how a
2234
+ // consult once returned "Searching the web / Answer now" as its result.
2235
+ if (transcript.classification === "pending")
2236
+ continue;
2176
2237
  }
2177
2238
  break;
2178
2239
  }
@@ -3030,7 +3091,7 @@ export function deepResearchUnreadableBlocker(threadUrl) {
3030
3091
  */
3031
3092
  export function transcriptAnswerExpression(conversationId) {
3032
3093
  return `(async () => {
3033
- const fail = (reason, extra) => Object.assign({ ok: false, reason, status: "", endTurn: false, isComplete: false, text: "", modelSlug: "", references: [] }, extra || {});
3094
+ const fail = (reason, extra) => Object.assign({ ok: false, reason, status: "", endTurn: false, isComplete: false, text: "", modelSlug: "", references: [], userText: "" }, extra || {});
3034
3095
  let token = "";
3035
3096
  try {
3036
3097
  const session = await fetch("/api/auth/session", { credentials: "include" });
@@ -3063,7 +3124,9 @@ export function transcriptAnswerExpression(conversationId) {
3063
3124
  const message = chain.find(
3064
3125
  (entry) => entry && entry.author && entry.author.role === "assistant" && entry.content && entry.content.content_type === "text"
3065
3126
  );
3066
- if (!message) return fail("no_assistant_message");
3127
+ const userMessage = chain.find((entry) => entry && entry.author && entry.author.role === "user" && entry.content);
3128
+ const userText = userMessage ? (userMessage.content.parts || []).filter((part) => typeof part === "string").join("") : "";
3129
+ if (!message) return fail("no_assistant_message", { userText });
3067
3130
  const parts = (message.content.parts || []).filter((part) => typeof part === "string");
3068
3131
  const text = parts.join("");
3069
3132
  const metadata = message.metadata || {};
@@ -3073,13 +3136,41 @@ export function transcriptAnswerExpression(conversationId) {
3073
3136
  isComplete: metadata.is_complete === true,
3074
3137
  text,
3075
3138
  modelSlug: metadata.model_slug || "",
3076
- references: Array.isArray(metadata.content_references) ? metadata.content_references : []
3139
+ references: Array.isArray(metadata.content_references) ? metadata.content_references : [],
3140
+ userText
3077
3141
  };
3078
3142
  if (state.status !== "finished_successfully" || !state.endTurn) return fail("answer_not_finished", state);
3079
3143
  if (!text) return fail("answer_empty", state);
3080
3144
  return Object.assign({ ok: true, reason: "" }, state);
3081
3145
  })()`;
3082
3146
  }
3147
+ const NORMALIZED_PROMPT_MATCH_CHARS = 120;
3148
+ /**
3149
+ * Does this transcript belong to the consult that is waiting on it?
3150
+ *
3151
+ * The browser is shared with the user and other agents. Reading "whatever
3152
+ * conversation the tab shows" once cost a consult its answer - prodex saved a
3153
+ * different conversation as the result. Identity is checked against the prompt
3154
+ * that was actually sent, normalized because ChatGPT re-wraps whitespace, and
3155
+ * as a containment test because the transcript wraps the prompt: a composer
3156
+ * tool prefixes it ("@Deep research ...") and attachments append to it.
3157
+ */
3158
+ export function transcriptMatchesSentPrompt(userText, sentPrompt) {
3159
+ // The composer escapes markdown when it stores what was typed ("## File" is
3160
+ // kept as "\\## File", fences as escaped backticks), so undo that before
3161
+ // comparing - otherwise every prompt carrying markdown, which is every
3162
+ // --file send, looks like a different conversation.
3163
+ const normalize = (value) => value
3164
+ .replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1")
3165
+ .replace(/\s+/g, " ")
3166
+ .trim();
3167
+ const seen = normalize(userText);
3168
+ const sent = normalize(sentPrompt);
3169
+ if (seen.length === 0 || sent.length === 0)
3170
+ return false;
3171
+ const expected = sent.slice(0, NORMALIZED_PROMPT_MATCH_CHARS);
3172
+ return seen.includes(expected);
3173
+ }
3083
3174
  // ChatGPT marks citations with private-use delimiters (U+E200 opens, U+E202
3084
3175
  // separates, U+E201 closes) and keeps the real sources in content_references.
3085
3176
  const CITATION_DELIMITER_PATTERN = /[\uE200-\uE206]/;
@@ -3122,7 +3213,7 @@ export function resolveTranscriptCitations(text, references = []) {
3122
3213
  }
3123
3214
  export function deepResearchReportExpression(conversationId) {
3124
3215
  return `(async () => {
3125
- const fail = (reason, status) => ({ ok: false, reason, status: status || "", report: "", chars: 0, references: [] });
3216
+ const fail = (reason, status, userText) => ({ ok: false, reason, status: status || "", report: "", chars: 0, references: [], userText: userText || "" });
3126
3217
  let token = "";
3127
3218
  try {
3128
3219
  const session = await fetch("/api/auth/session", { credentials: "include" });
@@ -3143,7 +3234,8 @@ export function deepResearchReportExpression(conversationId) {
3143
3234
  } catch (error) {
3144
3235
  return fail("conversation_error");
3145
3236
  }
3146
- const nodes = Object.keys((conversation && conversation.mapping) || {}).map((key) => conversation.mapping[key]);
3237
+ const mapping = (conversation && conversation.mapping) || {};
3238
+ const nodes = Object.keys(mapping).map((key) => mapping[key]);
3147
3239
  const widgetNode = nodes.find(
3148
3240
  (node) => node && node.message && node.message.metadata && node.message.metadata.chatgpt_sdk && node.message.metadata.chatgpt_sdk.widget_state
3149
3241
  );
@@ -3155,12 +3247,22 @@ export function deepResearchReportExpression(conversationId) {
3155
3247
  return fail("widget_state_unparsable");
3156
3248
  }
3157
3249
  const status = (state && state.status) || "";
3250
+ const chain = [];
3251
+ let walkId = conversation && conversation.current_node;
3252
+ let walkGuard = 0;
3253
+ while (walkId && mapping[walkId] && walkGuard < 2000) {
3254
+ walkGuard += 1;
3255
+ if (mapping[walkId].message) chain.push(mapping[walkId].message);
3256
+ walkId = mapping[walkId].parent;
3257
+ }
3258
+ const userNode = chain.find((entry) => entry && entry.author && entry.author.role === "user" && entry.content);
3259
+ const userText = userNode ? (userNode.content.parts || []).filter((part) => typeof part === "string").join("") : "";
3158
3260
  const message = (state && state.report_message) || null;
3159
3261
  const parts = message && message.content && message.content.parts;
3160
3262
  const report = Array.isArray(parts) ? parts.filter((part) => typeof part === "string").join("") : "";
3161
3263
  const references = message && message.metadata && Array.isArray(message.metadata.content_references) ? message.metadata.content_references : [];
3162
- if (!report) return fail("report_not_ready", status);
3163
- return { ok: true, reason: "", status, report, chars: report.length, references };
3264
+ if (!report) return fail("report_not_ready", status, userText);
3265
+ return { ok: true, reason: "", status, report, chars: report.length, references, userText };
3164
3266
  })()`;
3165
3267
  }
3166
3268
  /**
@@ -3296,17 +3398,11 @@ export function answerExpression() {
3296
3398
  });
3297
3399
  const assistantMessages = messages.filter((message) => message.role === "assistant");
3298
3400
  const userMessages = messages.filter((message) => message.role === "user");
3299
- // Deep research renders no assistant-role node at all: the thread is
3300
- // conversation-turn sections, the prompt in the first and the report in a
3301
- // later one (measured live - roles were ["user"] only while a research ran).
3302
- // Fall back to the last turn that is NOT the user's, so such an answer is
3303
- // readable instead of looking like "no answer" forever.
3304
- const turnAnswers = assistantMessages.length > 0 ? [] : [...document.querySelectorAll('[data-testid^="conversation-turn"]')]
3305
- .filter((turn) => !turn.querySelector('[data-message-author-role="user"]'))
3306
- .map((turn) => ({ role: "assistant", text: (turn.innerText || "").trim(), modelSlug: undefined }))
3307
- .filter((turn) => turn.text.length > 0);
3308
- const effectiveAssistants = assistantMessages.length > 0 ? assistantMessages : turnAnswers;
3309
- const assistant = effectiveAssistants.at(-1);
3401
+ // A turn without an assistant message is NOT an answer. Treating one as an
3402
+ // answer (a 0.21.3 fallback for deep research, which is read from the
3403
+ // transcript now) turned a tool's progress panel into a 28-character
3404
+ // "answer" that a consult returned as its result.
3405
+ const assistant = assistantMessages.at(-1);
3310
3406
  const buttons = [...document.querySelectorAll('button,[role="button"]')]
3311
3407
  .filter((node) => !!(node.offsetWidth || node.offsetHeight || node.getClientRects().length))
3312
3408
  .filter((node) => !node.closest(excludedTextSelector))
@@ -3319,13 +3415,16 @@ export function answerExpression() {
3319
3415
  return {
3320
3416
  title: document.title,
3321
3417
  url: location.href,
3322
- answer: assistant ? answer : text.slice(-4000),
3418
+ // No assistant message means no answer. This used to hand back the last
3419
+ // 4000 characters of the page, which reads as sidebar and navigation text
3420
+ // dressed up as a reply.
3421
+ answer,
3323
3422
  textSample: text.slice(0, 12000),
3324
3423
  blockerTextSample: visibleTextOutsideMessages(excludedTextSelector).slice(0, 12000),
3325
3424
  blockerScanTextSample: visibleTextOutsideMessages(blockerScanExcludedSelector).slice(0, 12000),
3326
3425
  visibleButtonLabels: buttons,
3327
3426
  generating: placeholder || Boolean(document.querySelector(${streamingSelector})) || buttons.some((label) => generatingControlPattern.test(label)),
3328
- assistantMessageCount: effectiveAssistants.length,
3427
+ assistantMessageCount: assistantMessages.length,
3329
3428
  userMessageCount: userMessages.length,
3330
3429
  // ChatGPT tags each assistant message with the model that produced it -
3331
3430
  // the only ground truth for "did the Pro selection actually take".
package/dist/cli-pro.js CHANGED
@@ -52,7 +52,7 @@ export async function runChatgptCommand(rest, io) {
52
52
  const task = await targetStore.createTask({
53
53
  source: "codex",
54
54
  title: "GPT Pro smoke",
55
- prompt: bundle.text,
55
+ prompt: bundle.sendText,
56
56
  repo_id: "default",
57
57
  provenance: {
58
58
  adapter: "chatgpt-control",
@@ -852,7 +852,8 @@ export async function runAskProCommand(rest, io) {
852
852
  const task = await targetStore.createTask({
853
853
  source: "codex",
854
854
  title: "GPT Pro consult",
855
- prompt: bundle.text,
855
+ // Record what ChatGPT actually received, not the preview rendering.
856
+ prompt: bundle.sendText,
856
857
  repo_id: "default",
857
858
  files: files.map((file) => ({ path: file, role: "context" })),
858
859
  provenance: {
@@ -901,7 +902,7 @@ export async function runAskProCommand(rest, io) {
901
902
  // fast. A dead holder is still reaped immediately.
902
903
  const sendOnce = () => withBrowserSendLock(busyWaitMs ?? browserTimeoutMs ?? 0, (detail) => io.stderr(`progress: ${detail}`), () => sendChatGptPrompt({
903
904
  port: browserPort,
904
- prompt: bundle.text,
905
+ prompt: bundle.sendText,
905
906
  targetUrl: normalizedTargetUrl,
906
907
  timeoutMs: browserTimeoutMs,
907
908
  ...(attachments.length > 0 ? { attachments } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.24.0",
3
+ "version": "0.25.1",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",