@youdie006/prodex 0.27.2 → 0.28.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.
@@ -1915,6 +1915,23 @@ async function readTranscriptAnswer(page, conversationId, sentPrompt) {
1915
1915
  ? { classification: "answer", answer: { answer, modelSlug: transcript.modelSlug } }
1916
1916
  : { classification: "pending" };
1917
1917
  }
1918
+ // A page that has not reported the prompt posting within this long is worth
1919
+ // double-checking against the transcript; the probe is a couple of small fetches.
1920
+ const ACCEPTANCE_TRANSCRIPT_PROBE_AFTER_MS = 20_000;
1921
+ const ACCEPTANCE_TRANSCRIPT_PROBE_EVERY_MS = 10_000;
1922
+ /** Which conversation, if any, already holds the prompt this send posted. */
1923
+ async function findLandedConversation(page, prompt) {
1924
+ try {
1925
+ const candidates = await evaluateOnPage(page, recentConversationsExpression(4), {
1926
+ timeoutMs: 30_000
1927
+ });
1928
+ return pickLandedConversation(candidates ?? [], prompt);
1929
+ }
1930
+ catch {
1931
+ // Transcript unavailable: the caller falls back to the page.
1932
+ return undefined;
1933
+ }
1934
+ }
1918
1935
  export async function sendChatGptPrompt(options) {
1919
1936
  const port = resolveCdpPort(options.port);
1920
1937
  const timeoutMs = options.timeoutMs ?? 90_000;
@@ -2135,8 +2152,27 @@ export async function sendChatGptPrompt(options) {
2135
2152
  const acceptDeadline = computePromptAcceptanceDeadline(timeoutMs, started);
2136
2153
  let accepted = false;
2137
2154
  let finalState;
2155
+ // Seeded either from the url once ChatGPT rewrites it, or - when the page
2156
+ // never showed the prompt post - from the transcript that proves it did.
2157
+ let transcriptConversationId;
2158
+ // Ask the transcript early rather than only at the deadline. Acceptance runs
2159
+ // on the full send budget, so a page that stops reporting the prompt posting
2160
+ // used to burn all twenty minutes before saying anything - while the prompt
2161
+ // sat in a conversation the whole time.
2162
+ let nextTranscriptProbeAt = started + ACCEPTANCE_TRANSCRIPT_PROBE_AFTER_MS;
2138
2163
  while (Date.now() < acceptDeadline) {
2139
2164
  await sleep(500);
2165
+ if (Date.now() >= nextTranscriptProbeAt) {
2166
+ nextTranscriptProbeAt = Date.now() + ACCEPTANCE_TRANSCRIPT_PROBE_EVERY_MS;
2167
+ const landed = await findLandedConversation(page, options.prompt);
2168
+ if (landed) {
2169
+ transcriptConversationId = landed;
2170
+ accepted = true;
2171
+ sendWarnings.push("prompt_acceptance_unreadable: the page never showed the prompt posting, but the transcript has it - continuing on the conversation the transcript names.");
2172
+ dbgSend(`acceptance recovered from transcript conversation=${landed}`);
2173
+ break;
2174
+ }
2175
+ }
2140
2176
  try {
2141
2177
  finalState = await evaluateOnPage(page, answerExpression());
2142
2178
  }
@@ -2186,7 +2222,19 @@ export async function sendChatGptPrompt(options) {
2186
2222
  catch {
2187
2223
  // best effort: fall back to submit-button signal only
2188
2224
  }
2189
- throw acceptanceTimeoutError({ timeoutMs, composerStillHasText, submitButtonFound });
2225
+ // Before calling this a failed send: did the prompt actually land? Reading
2226
+ // acceptance off the page means a changed DOM reports "never posted" for a
2227
+ // prompt that posted fine, and the caller's retry asks ChatGPT the same
2228
+ // question twice. The transcript is the ground truth.
2229
+ const landed = await findLandedConversation(page, options.prompt);
2230
+ if (landed) {
2231
+ transcriptConversationId = landed;
2232
+ accepted = true;
2233
+ sendWarnings.push("prompt_acceptance_unreadable: the page never showed the prompt posting, but the transcript has it - continuing on the conversation the transcript names.");
2234
+ dbgSend(`acceptance recovered from transcript conversation=${landed}`);
2235
+ }
2236
+ if (!accepted)
2237
+ throw acceptanceTimeoutError({ timeoutMs, composerStillHasText, submitButtonFound });
2190
2238
  }
2191
2239
  // Pin the conversation the prompt actually landed in. The browser is shared
2192
2240
  // (other agents, the user, tooling), and a tab that moves mid-wait made
@@ -2199,7 +2247,9 @@ export async function sendChatGptPrompt(options) {
2199
2247
  // a finished answer was lost: the tab returned to the project page, the url
2200
2248
  // still matched the pin taken before ChatGPT rewrote it, and the DOM reader
2201
2249
  // sat on zero assistant messages until the budget ran out.
2202
- let transcriptConversationId = pinnedThreadUrl ? conversationIdFromThreadUrl(pinnedThreadUrl) : undefined;
2250
+ // Acceptance may already have adopted one from the transcript; otherwise take
2251
+ // it from the url ChatGPT rewrote to.
2252
+ transcriptConversationId ??= pinnedThreadUrl ? conversationIdFromThreadUrl(pinnedThreadUrl) : undefined;
2203
2253
  const transcriptResult = (transcript) => {
2204
2254
  emitProgress("answered", `transcript (${transcript.answer.length} chars)`);
2205
2255
  return {
@@ -3218,12 +3268,70 @@ export function deepResearchUnreadableBlocker(threadUrl) {
3218
3268
  };
3219
3269
  }
3220
3270
  /**
3221
- * The transcript is the same data the UI renders, minus the rendering: it
3222
- * carries the answer as markdown (tables and fenced code survive, which
3223
- * innerText flattens), an explicit finish state, and the model that actually
3224
- * answered. Walk from `current_node` up the parents so a regenerated turn reads
3225
- * the branch the UI is on, not an abandoned sibling.
3271
+ * The most recently updated conversations, each with the prompt it opens with.
3272
+ *
3273
+ * Acceptance is otherwise read off the page: if the DOM changes shape, a prompt
3274
+ * that DID post looks like one that never left, the send fails, and the
3275
+ * caller's retry asks ChatGPT the same question twice. The transcript settles
3276
+ * it - the conversation either holds our prompt or it does not.
3277
+ *
3278
+ * Only the head of each prompt is returned: a research transcript runs to
3279
+ * hundreds of KB and none of that is needed to recognise it.
3226
3280
  */
3281
+ export function recentConversationsExpression(limit = 4) {
3282
+ return `(async () => {
3283
+ const out = [];
3284
+ let token = "";
3285
+ try {
3286
+ const session = await fetch("/api/auth/session", { credentials: "include" });
3287
+ if (!session.ok) return out;
3288
+ const parsed = await session.json();
3289
+ token = (parsed && parsed.accessToken) || "";
3290
+ } catch (error) {
3291
+ return out;
3292
+ }
3293
+ const headers = token ? { Authorization: "Bearer " + token } : {};
3294
+ let items = [];
3295
+ try {
3296
+ const response = await fetch("/backend-api/conversations?offset=0&limit=${limit}&order=updated", {
3297
+ credentials: "include",
3298
+ headers
3299
+ });
3300
+ if (!response.ok) return out;
3301
+ const listed = await response.json();
3302
+ items = (listed && listed.items) || [];
3303
+ } catch (error) {
3304
+ return out;
3305
+ }
3306
+ for (const item of items) {
3307
+ if (!item || !item.id) continue;
3308
+ try {
3309
+ const response = await fetch("/backend-api/conversation/" + item.id, { credentials: "include", headers });
3310
+ if (!response.ok) continue;
3311
+ const conversation = await response.json();
3312
+ const mapping = (conversation && conversation.mapping) || {};
3313
+ const chain = [];
3314
+ let nodeId = conversation && conversation.current_node;
3315
+ let guard = 0;
3316
+ while (nodeId && mapping[nodeId] && guard < 2000) {
3317
+ guard += 1;
3318
+ if (mapping[nodeId].message) chain.push(mapping[nodeId].message);
3319
+ nodeId = mapping[nodeId].parent;
3320
+ }
3321
+ const user = chain.find((entry) => entry && entry.author && entry.author.role === "user" && entry.content);
3322
+ const text = user ? (user.content.parts || []).filter((part) => typeof part === "string").join("") : "";
3323
+ out.push({ id: item.id, userText: text.slice(0, 600) });
3324
+ } catch (error) {
3325
+ // A conversation we cannot read is simply not a match.
3326
+ }
3327
+ }
3328
+ return out;
3329
+ })()`;
3330
+ }
3331
+ /** Which of those conversations is the one this send posted into, if any. */
3332
+ export function pickLandedConversation(candidates, sentPrompt) {
3333
+ return candidates.find((candidate) => transcriptMatchesSentPrompt(candidate.userText, sentPrompt))?.id;
3334
+ }
3227
3335
  export function transcriptAnswerExpression(conversationId) {
3228
3336
  return `(async () => {
3229
3337
  const fail = (reason, extra) => Object.assign({ ok: false, reason, status: "", endTurn: false, isComplete: false, text: "", modelSlug: "", references: [], userText: "" }, extra || {});
package/dist/tui-run.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * what keeps the interactive path from drifting away from the documented flags.
8
8
  */
9
9
  import readline from "node:readline";
10
- import { consultArgsFromChoices, moveCursor, renderContextPanel, renderProgressBar, renderSelectList, toggleSelection } from "./tui.js";
10
+ import { consultArgsFromChoices, moveCursor, progressLabel, renderContextPanel, renderProgressBar, renderSelectList, toggleSelection } from "./tui.js";
11
11
  const ESC = "";
12
12
  const CLEAR = `${ESC}[2J${ESC}[H`;
13
13
  // The alternate screen buffer: the terminal comes back exactly as it was, so a
@@ -272,7 +272,7 @@ export async function runInteractiveConsult(io, deps) {
272
272
  }, 250);
273
273
  try {
274
274
  const code = await deps.runConsult(args, (line) => {
275
- label = line.replace(/^progress:\s*/, "").slice(0, 60);
275
+ label = progressLabel(line).slice(0, 60);
276
276
  });
277
277
  clearInterval(timer);
278
278
  io.write(CLEAR_LINE);
package/dist/tui.js CHANGED
@@ -115,6 +115,25 @@ function formatElapsed(ms) {
115
115
  const rest = seconds % 60;
116
116
  return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`;
117
117
  }
118
+ /**
119
+ * Turn a `progress:` line from the send into the label beside the bar.
120
+ *
121
+ * The bar already carries the elapsed clock, so a label that opens with
122
+ * "waiting 2m 57s" prints the same number twice and pushes the part worth
123
+ * reading off to the right. Keep the detail, drop the duplicate.
124
+ */
125
+ export function progressLabel(line) {
126
+ const text = line.replace(/^progress:\s*/, "").trim();
127
+ const waiting = /^waiting\s+[0-9]+[a-z]*(?:\s+[0-9]+[a-z]*)*\s*(.*)$/i.exec(text);
128
+ if (!waiting)
129
+ return text;
130
+ const detail = waiting[1].trim();
131
+ if (detail.length === 0)
132
+ return "waiting";
133
+ // The send wraps its detail in parentheses; the bar reads better without them.
134
+ const unwrapped = /^\((.*)\)$/.exec(detail);
135
+ return (unwrapped ? unwrapped[1] : detail).trim();
136
+ }
118
137
  const SPINNER = ["|", "/", "-", "\\"];
119
138
  /**
120
139
  * A Pro consult can run for many minutes with nothing on screen. The bar fills
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.27.2",
3
+ "version": "0.28.0",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",