@youdie006/prodex 0.27.3 → 0.28.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.
@@ -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/cli-help.js CHANGED
@@ -27,9 +27,9 @@ Ask / consult commands:
27
27
  prodex pro browser projects [--source-cli /absolute/path/to/dist/cli.js] [--port 9333] [--timeout-ms 15000] # read-only list of sidebar project names (for --project)
28
28
  prodex pro browser recover [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] --target-url <thread-url> [--timeout-ms 60000] # recover a finished answer from a thread whose send timed out
29
29
  prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--port 9333] [--timeout-ms 300000] [--busy-wait-ms 600000] [--target-url url --confirm-target] [--new-chat] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"] [--project "name" | --project-new "name"] "prompt" # explicit visible-browser send
30
- prodex pro latest [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
30
+ prodex pro latest [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
31
31
  prodex pro list [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
32
- prodex pro show <task-id|latest> [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
32
+ prodex pro show <task-id|latest> [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
33
33
 
34
34
  Bridge ledger (durable tasks/results/receipts/sessions under .bridge/):
35
35
  prodex init [--cwd /absolute/path/to/repo]
@@ -168,9 +168,9 @@ Commands:
168
168
  prodex pro browser smoke [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
169
169
  prodex pro browser models [--source-cli /absolute/path/to/dist/cli.js]
170
170
  prodex pro browser ask [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--target-url url --confirm-target] [--new-chat] [--stdin] [--json] [--auto-login|--no-auto-login] [--file path] [--attach path] [--tool deep-research|web-search|create-image] [--model Pro] [--pro-mode 기본|확장] [--effort 즉시|중간|높음|"매우 높음"] [--project "name" | --project-new "name"] "prompt"
171
- prodex pro latest [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
171
+ prodex pro latest [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
172
172
  prodex pro list [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
173
- prodex pro show <task-id|latest> [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo]
173
+ prodex pro show <task-id|latest> [--source-cli /absolute/path/to/dist/cli.js] [--cwd /absolute/path/to/repo] [--json]
174
174
 
175
175
  Use \`prodex pro ask\` for dry-run/manual previews.
176
176
  Use \`prodex pro browser ask\` only when you want an explicit visible-browser send.
package/dist/cli-pro.js CHANGED
@@ -575,7 +575,7 @@ export async function runProCommand(rest, io, runCliFn) {
575
575
  if (subcommand === "latest") {
576
576
  if (printHelpIfRequested(proArgs, "pro latest", io.stdout, printProHelp, { valueFlags: ["--cwd", "--source-cli"] }))
577
577
  return 0;
578
- assertOnlyOptions(proArgs, "pro latest", ["--cwd", "--source-cli"]);
578
+ assertOnlyOptions(proArgs, "pro latest", ["--cwd", "--source-cli"], ["--json"]);
579
579
  const targetCwd = resolveCwdFlag(io.cwd, proArgs);
580
580
  const targetStore = new BridgeStore(targetCwd);
581
581
  const sourceCli = resolveOptionalFileFlag(io.cwd, proArgs, "--source-cli");
@@ -589,13 +589,19 @@ export async function runProCommand(rest, io, runCliFn) {
589
589
  }
590
590
  if (!consult)
591
591
  throw new Error("No GPT Pro answers found");
592
- io.stdout(formatProAnswer(consult, sourceCli, answerOptions));
592
+ if (proArgs.includes("--json")) {
593
+ const modelUsed = await recordedModelUsed(targetStore, consult.task.id);
594
+ io.stdout(formatProAnswerJson(consult, sourceCli, answerOptions, modelUsed ? { modelUsed } : {}));
595
+ }
596
+ else {
597
+ io.stdout(formatProAnswer(consult, sourceCli, answerOptions));
598
+ }
593
599
  return 0;
594
600
  }
595
601
  if (subcommand === "show") {
596
602
  if (printHelpIfRequested(proArgs, "pro show", io.stdout, printProHelp, { valueFlags: ["--cwd", "--source-cli"], maxPositionals: 1 }))
597
603
  return 0;
598
- const [taskId] = readPositionalsWithOptions(proArgs, "pro show", 1, ["--cwd", "--source-cli"]);
604
+ const [taskId] = readPositionalsWithOptions(proArgs, "pro show", 1, ["--cwd", "--source-cli"], ["--json"]);
599
605
  if (!taskId)
600
606
  throw new Error("pro show requires <task-id|latest>");
601
607
  const targetCwd = resolveCwdFlag(io.cwd, proArgs);
@@ -611,7 +617,13 @@ export async function runProCommand(rest, io, runCliFn) {
611
617
  }
612
618
  if (!consult)
613
619
  throw new Error(taskId === "latest" ? "No GPT Pro answers found" : `GPT Pro answer not found: ${taskId}`);
614
- io.stdout(formatProAnswer(consult, sourceCli, answerOptions));
620
+ if (proArgs.includes("--json")) {
621
+ const modelUsed = await recordedModelUsed(targetStore, consult.task.id);
622
+ io.stdout(formatProAnswerJson(consult, sourceCli, answerOptions, modelUsed ? { modelUsed } : {}));
623
+ }
624
+ else {
625
+ io.stdout(formatProAnswer(consult, sourceCli, answerOptions));
626
+ }
615
627
  return 0;
616
628
  }
617
629
  if (subcommand === "debate-prompt") {
@@ -1432,6 +1444,42 @@ export function browserSendBlockerFromError(error) {
1432
1444
  next_step: "Resolve the visible browser issue manually, then rerun the consult if needed."
1433
1445
  };
1434
1446
  }
1447
+ /**
1448
+ * The same answer as `formatProAnswer`, shaped for a program.
1449
+ *
1450
+ * Every other read path is JSON; these two printed only the human rendering,
1451
+ * so an agent after the thread or the model that produced an answer had to
1452
+ * scrape prose.
1453
+ */
1454
+ export function formatProAnswerJson(consult, sourceCli, options = {}, extras = {}) {
1455
+ const blocker = sourceAwareProAnswerBlocker(consult, sourceCli, options);
1456
+ return JSON.stringify({
1457
+ task_id: consult.task.id,
1458
+ status: consult.result.status,
1459
+ thread: consult.task.provenance.thread ?? null,
1460
+ created_at: consult.result.created_at,
1461
+ answer: consult.result.summary,
1462
+ ...(extras.modelUsed ? { model_used: extras.modelUsed } : {}),
1463
+ warnings: consult.result.warnings ?? [],
1464
+ ...(blocker ? { blocker } : {})
1465
+ }, null, 2);
1466
+ }
1467
+ /**
1468
+ * Which model actually answered, from the receipt that recorded the answer.
1469
+ * It lives there rather than on the task because it is what ChatGPT tagged the
1470
+ * message with, not what prodex asked for.
1471
+ */
1472
+ async function recordedModelUsed(store, taskId) {
1473
+ try {
1474
+ const receipts = await store.listReceiptsReadOnly({ kind: "consult_answer_saved", task_id: taskId });
1475
+ const value = receipts[0]?.metadata?.model_used;
1476
+ return typeof value === "string" ? value : undefined;
1477
+ }
1478
+ catch {
1479
+ // A missing or unreadable receipt just means the field is unknown.
1480
+ return undefined;
1481
+ }
1482
+ }
1435
1483
  export function formatProAnswer(consult, sourceCli, options = {}) {
1436
1484
  const blocker = sourceAwareProAnswerBlocker(consult, sourceCli, options);
1437
1485
  const summary = sourceAwareProAnswerSummary(consult.result.summary, consult.result.blocker, blocker);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.27.3",
3
+ "version": "0.28.1",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",