@youdie006/prodex 0.40.3 → 0.40.4
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/chatgpt-browser.js +124 -17
- package/dist/cli-pro.js +15 -0
- package/dist/config.js +6 -1
- package/dist/continue-thread.js +55 -6
- package/package.json +1 -1
package/dist/chatgpt-browser.js
CHANGED
|
@@ -503,6 +503,17 @@ export function isUsableChatGptAnswer(answer) {
|
|
|
503
503
|
return false;
|
|
504
504
|
return true;
|
|
505
505
|
}
|
|
506
|
+
/**
|
|
507
|
+
* What comes back when the turn finished and wrote no text.
|
|
508
|
+
*
|
|
509
|
+
* Measured with `--tool create-image`: the image was generated and rendered,
|
|
510
|
+
* the assistant message in the transcript came back finished with an empty
|
|
511
|
+
* part (the image lives in a separate tool message), and prodex waited out the
|
|
512
|
+
* whole budget before reporting a timeout for a result that was already there.
|
|
513
|
+
* The thread URL travels with every answer, so saying so and pointing at it
|
|
514
|
+
* beats six minutes of silence.
|
|
515
|
+
*/
|
|
516
|
+
export const CHATGPT_NON_TEXT_ANSWER_NOTE = "[no text answer] ChatGPT finished this turn without writing any text - an image or another non-text result. Open the thread to see it.";
|
|
506
517
|
/**
|
|
507
518
|
* Who decides the answer is finished: the transcript, when it can be read.
|
|
508
519
|
*
|
|
@@ -520,9 +531,16 @@ export function classifyTranscriptRead(state, sentPrompt) {
|
|
|
520
531
|
return "unavailable";
|
|
521
532
|
if (state.ok)
|
|
522
533
|
return "answer";
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
534
|
+
// A turn that FINISHED with no text is not a turn still being written. The
|
|
535
|
+
// transcript only reports answer_empty after checking the turn ended, and
|
|
536
|
+
// calling it "pending" is what made an image request wait out its whole
|
|
537
|
+
// budget: measured, `--tool create-image` produced the image, its assistant
|
|
538
|
+
// message came back finished with an empty part, and the send spent six and
|
|
539
|
+
// a half minutes "stabilizing" before reporting a timeout for an answer that
|
|
540
|
+
// was sitting in the thread.
|
|
541
|
+
if (state.reason === "answer_empty")
|
|
542
|
+
return "no_text";
|
|
543
|
+
return state.reason === "answer_not_finished" || state.reason === "no_assistant_message" ? "pending" : "unavailable";
|
|
526
544
|
}
|
|
527
545
|
/**
|
|
528
546
|
* Should prodex drag the tab back to the thread it pinned?
|
|
@@ -540,8 +558,11 @@ export function shouldRecoverThreadNavigation(args) {
|
|
|
540
558
|
return false;
|
|
541
559
|
if (!conversationIdFromThreadUrl(pinnedThreadUrl))
|
|
542
560
|
return false;
|
|
543
|
-
if (lastTranscriptClassification === "pending" ||
|
|
561
|
+
if (lastTranscriptClassification === "pending" ||
|
|
562
|
+
lastTranscriptClassification === "answer" ||
|
|
563
|
+
lastTranscriptClassification === "no_text") {
|
|
544
564
|
return false;
|
|
565
|
+
}
|
|
545
566
|
return !chatGptUrlsReferToSameTarget(currentUrl, pinnedThreadUrl);
|
|
546
567
|
}
|
|
547
568
|
export function hasFreshChatGptAnswer(previousAssistantMessageCount, state) {
|
|
@@ -1360,6 +1381,49 @@ async function dispatchEscapeKey(cdp) {
|
|
|
1360
1381
|
* stamp is what proves the old document is gone; the URL alone can be read off
|
|
1361
1382
|
* the very page we are trying to leave.
|
|
1362
1383
|
*/
|
|
1384
|
+
/**
|
|
1385
|
+
* Put the tab on a specific conversation, for a thread prodex itself resolved.
|
|
1386
|
+
*/
|
|
1387
|
+
async function openChatGptThread(cdp, url) {
|
|
1388
|
+
const conversationId = conversationIdFromThreadUrl(url);
|
|
1389
|
+
if (!conversationId)
|
|
1390
|
+
throw new Error(`Not a ChatGPT conversation URL: ${url}`);
|
|
1391
|
+
await cdp.evaluate(`location.assign(${JSON.stringify(url)})`);
|
|
1392
|
+
const deadline = Date.now() + RELOAD_SETTLE_TIMEOUT_MS;
|
|
1393
|
+
while (Date.now() < deadline) {
|
|
1394
|
+
await sleep(250);
|
|
1395
|
+
try {
|
|
1396
|
+
if (await cdp.evaluate(chatGptThreadReadyExpression(conversationId)))
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
catch (error) {
|
|
1400
|
+
// Between documents the context is gone and the next poll lands on the
|
|
1401
|
+
// new one; a command timeout means the tab stopped answering.
|
|
1402
|
+
if (cdpCommandTimedOut(error))
|
|
1403
|
+
throw error;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
throw new ChatGptBrowserBlockerError(chatGptThreadUnavailableBlocker(url));
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* The conversation a follow-up names cannot be opened.
|
|
1410
|
+
*
|
|
1411
|
+
* Retrying cannot undelete a thread, and the generic "resolve the visible
|
|
1412
|
+
* browser issue manually" this used to fall back to describes a browser that
|
|
1413
|
+
* is working fine - measured on a thread whose project had been deleted: the
|
|
1414
|
+
* cause was named in the message and then thrown away by the catch-all next
|
|
1415
|
+
* step underneath it.
|
|
1416
|
+
*/
|
|
1417
|
+
export function chatGptThreadUnavailableBlocker(url) {
|
|
1418
|
+
return {
|
|
1419
|
+
code: "thread_unavailable",
|
|
1420
|
+
message: `ChatGPT did not open the conversation to continue (${url}). It may have been deleted, or its project was.`,
|
|
1421
|
+
retryable: false,
|
|
1422
|
+
next_step: "That conversation cannot be reached, and retrying will not bring it back. Send without --continue to start a new one, " +
|
|
1423
|
+
"or name a different consult with --continue-task <task_id> (`prodex pro list` shows them).",
|
|
1424
|
+
thread: url
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1363
1427
|
async function openFreshChatGptHome(cdp) {
|
|
1364
1428
|
await cdp.evaluate(markDocumentForReloadExpression());
|
|
1365
1429
|
await cdp.evaluate(`location.assign("https://chatgpt.com/")`);
|
|
@@ -1641,6 +1705,20 @@ export function reloadedDocumentReadyExpression(extraCondition = "true") {
|
|
|
1641
1705
|
* home that fails to load, and the composer candidate is the real editor
|
|
1642
1706
|
* rather than the broad selector that also matches a hidden fallback.
|
|
1643
1707
|
*/
|
|
1708
|
+
/**
|
|
1709
|
+
* True once the tab is on this conversation and has rendered a real composer.
|
|
1710
|
+
*
|
|
1711
|
+
* The id is compared rather than the whole URL because ChatGPT rewrites the
|
|
1712
|
+
* project part of it (measured: the same project appears with and without its
|
|
1713
|
+
* name), and the composer has to be the real editor rather than the hidden 0x0
|
|
1714
|
+
* fallback that the broad selector also matches.
|
|
1715
|
+
*/
|
|
1716
|
+
export function chatGptThreadReadyExpression(conversationId) {
|
|
1717
|
+
return `(() => {${composerExpressionHelpers()}
|
|
1718
|
+
if (!location.href.includes(${JSON.stringify(conversationId)})) return false;
|
|
1719
|
+
return Boolean(findChatGptComposerCandidate());
|
|
1720
|
+
})()`;
|
|
1721
|
+
}
|
|
1644
1722
|
export function freshChatGptHomeReadyExpression() {
|
|
1645
1723
|
return reloadedDocumentReadyExpression(`/^https:\\/\\/chatgpt\\.com\\/?(?:[?#].*)?$/.test(location.href) && (() => {${composerExpressionHelpers()}
|
|
1646
1724
|
return Boolean(findChatGptComposerCandidate());
|
|
@@ -1757,9 +1835,13 @@ export function composerProjectBinding(input) {
|
|
|
1757
1835
|
const named = /^new\s+chat\s+in\s+(.+)$/i.exec(placeholder)?.[1] ?? /^(.+?)\uc5d0\uc11c\s*\uc0c8\s*\ucc44\ud305$/.exec(placeholder)?.[1];
|
|
1758
1836
|
if (named)
|
|
1759
1837
|
return named.trim().toLowerCase() === wanted ? "bound" : "elsewhere";
|
|
1760
|
-
// The
|
|
1761
|
-
//
|
|
1762
|
-
|
|
1838
|
+
// The label a plain new chat carries: recognised, and it names no project, so
|
|
1839
|
+
// the composer belongs to none. Two wordings measured on the same live root -
|
|
1840
|
+
// the hidden fallback textarea says "Ask ChatGPT" while the editor prodex
|
|
1841
|
+
// actually reads says "Chat with ChatGPT" - and reading only the first left
|
|
1842
|
+
// the clearest case of "this composer is not the project's" reported as a
|
|
1843
|
+
// label that could not be read.
|
|
1844
|
+
if (/^(?:ask|chat with)\s+chatgpt$/i.test(placeholder))
|
|
1763
1845
|
return "elsewhere";
|
|
1764
1846
|
return "unknown";
|
|
1765
1847
|
}
|
|
@@ -3046,7 +3128,7 @@ async function readTranscriptAnswer(page, conversationId, sentPrompt) {
|
|
|
3046
3128
|
const answer = resolveTranscriptCitations(transcript.text, transcript.references).trim();
|
|
3047
3129
|
return answer.length > 0
|
|
3048
3130
|
? { classification: "answer", answer: { answer, modelSlug: transcript.modelSlug } }
|
|
3049
|
-
: { classification: "
|
|
3131
|
+
: { classification: "no_text" };
|
|
3050
3132
|
}
|
|
3051
3133
|
// A page that has not reported the prompt posting within this long is worth
|
|
3052
3134
|
// double-checking against the transcript; the probe is a couple of small fetches.
|
|
@@ -3086,7 +3168,10 @@ export async function sendChatGptPrompt(options) {
|
|
|
3086
3168
|
if (options.newChat && normalizedTargetUrl) {
|
|
3087
3169
|
throw new Error("newChat cannot be combined with targetUrl: a fresh chat navigates away from the pinned tab.");
|
|
3088
3170
|
}
|
|
3089
|
-
|
|
3171
|
+
// A resolved thread is reached by navigating, so page discovery must not
|
|
3172
|
+
// demand a tab already sitting on it.
|
|
3173
|
+
const requireTabAtTargetUrl = options.navigateToTargetUrl ? undefined : normalizedTargetUrl;
|
|
3174
|
+
const pageResult = await findChatGptPage(port, computePageDiscoveryTimeout(timeoutMs), requireTabAtTargetUrl);
|
|
3090
3175
|
if (!pageResult.ok) {
|
|
3091
3176
|
throwBlockerOrError(pageResult.blocker, "ChatGPT browser page is not available");
|
|
3092
3177
|
}
|
|
@@ -3094,8 +3179,8 @@ export async function sendChatGptPrompt(options) {
|
|
|
3094
3179
|
if (pageResult.blocker) {
|
|
3095
3180
|
throw new ChatGptBrowserBlockerError(pageResult.blocker);
|
|
3096
3181
|
}
|
|
3097
|
-
if (
|
|
3098
|
-
assertChatGptTargetTabAvailable(
|
|
3182
|
+
if (requireTabAtTargetUrl) {
|
|
3183
|
+
assertChatGptTargetTabAvailable(requireTabAtTargetUrl);
|
|
3099
3184
|
}
|
|
3100
3185
|
assertChatGptPageAvailable();
|
|
3101
3186
|
}
|
|
@@ -3242,14 +3327,21 @@ export async function sendChatGptPrompt(options) {
|
|
|
3242
3327
|
awaitingResponseChoice: status.awaitingResponseChoice === true,
|
|
3243
3328
|
...(options.newChat !== undefined ? { newChat: options.newChat } : {}),
|
|
3244
3329
|
...(options.project !== undefined ? { project: options.project } : {}),
|
|
3245
|
-
...(options.projectNew !== undefined ? { projectNew: options.projectNew } : {})
|
|
3330
|
+
...(options.projectNew !== undefined ? { projectNew: options.projectNew } : {}),
|
|
3331
|
+
// Navigating to another conversation leaves the parked one alone, the
|
|
3332
|
+
// same way a fresh chat or a project home does.
|
|
3333
|
+
...(options.navigateToTargetUrl ? { newChat: true } : {})
|
|
3246
3334
|
})) {
|
|
3247
3335
|
throw new ChatGptBrowserBlockerError(chatGptResponseChoiceBlocker(true));
|
|
3248
3336
|
}
|
|
3249
3337
|
assertChatGptIdleAndReadyForPrompt(status, busyBlocker, true);
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3338
|
+
// Only a PINNED target has to be under the tab already; a resolved thread is
|
|
3339
|
+
// navigated to below, and asserting the match here would refuse the send for
|
|
3340
|
+
// the tab merely being somewhere else - which is the whole reason a
|
|
3341
|
+
// continuation resolves from records rather than from the tab.
|
|
3342
|
+
if (requireTabAtTargetUrl)
|
|
3343
|
+
assertChatGptTargetUrlMatches(status.url, requireTabAtTargetUrl);
|
|
3344
|
+
assertVisibleChatGptTab(status.visibilityState, status.url, requireTabAtTargetUrl);
|
|
3253
3345
|
emitProgress("tab_ready");
|
|
3254
3346
|
// Progress details deliberately avoid project names (receipts redact them too).
|
|
3255
3347
|
const selectionSummary = [
|
|
@@ -3315,6 +3407,9 @@ export async function sendChatGptPrompt(options) {
|
|
|
3315
3407
|
// keeps Work's composer, and switching afterwards does not move it.
|
|
3316
3408
|
// Max and Ultra are rungs of Work's slider, so asking for one means staying
|
|
3317
3409
|
// there; anything else belongs on Chat, whose top step is Pro.
|
|
3410
|
+
if (normalizedTargetUrl && options.navigateToTargetUrl) {
|
|
3411
|
+
await openChatGptThread(cdp, normalizedTargetUrl);
|
|
3412
|
+
}
|
|
3318
3413
|
if (!effortNeedsWorkSurface(options.effort)) {
|
|
3319
3414
|
// Leaving the current page is safe only for a send that was going to
|
|
3320
3415
|
// navigate anyway; a continuation or a pinned tab has to be reloaded
|
|
@@ -3635,6 +3730,13 @@ export async function sendChatGptPrompt(options) {
|
|
|
3635
3730
|
lastTranscriptClassification = transcript.classification;
|
|
3636
3731
|
if (transcript.answer)
|
|
3637
3732
|
return transcriptResult(transcript.answer);
|
|
3733
|
+
// A finished turn with no text is an answer of a different shape - an
|
|
3734
|
+
// image, measured - and waiting for words it will never write spends
|
|
3735
|
+
// the whole budget and then calls the result a timeout. The page must
|
|
3736
|
+
// agree it has stopped generating before this counts.
|
|
3737
|
+
if (transcript.classification === "no_text") {
|
|
3738
|
+
return transcriptResult({ answer: CHATGPT_NON_TEXT_ANSWER_NOTE, modelSlug: "" });
|
|
3739
|
+
}
|
|
3638
3740
|
}
|
|
3639
3741
|
if (shouldRecoverThreadNavigation({ pinnedThreadUrl, currentUrl: finalState?.url, lastTranscriptClassification })) {
|
|
3640
3742
|
if (recoveredNavigations >= 2) {
|
|
@@ -4924,8 +5026,13 @@ export function activeComposerToolsExpression(labels) {
|
|
|
4924
5026
|
// selection did not take.
|
|
4925
5027
|
const el = document.querySelector('#prompt-textarea,[contenteditable="true"]');
|
|
4926
5028
|
const form = el ? (el.closest("form") || el.parentElement) : null;
|
|
4927
|
-
const text = (el ? el.innerText || "" : "") + String.fromCharCode(10) + (form ? form.innerText || "" : "");
|
|
4928
|
-
|
|
5029
|
+
const text = ((el ? el.innerText || "" : "") + String.fromCharCode(10) + (form ? form.innerText || "" : "")).toLowerCase();
|
|
5030
|
+
// Case-insensitively: prodex carries the label as the menu spells it
|
|
5031
|
+
// ("Create image") while the page has been measured using "Create Image"
|
|
5032
|
+
// for the same tool. This is hardening, not a fix for a failure anyone has
|
|
5033
|
+
// seen - the create-image activation failure measured on this account
|
|
5034
|
+
// survives it, and its cause is still open.
|
|
5035
|
+
return { ok: true, active: ${labelsJson}.filter((label) => text.includes(String(label).toLowerCase())) };
|
|
4929
5036
|
})()`;
|
|
4930
5037
|
}
|
|
4931
5038
|
export function composerTextStateExpression(expectedText, toolLabels = []) {
|
package/dist/cli-pro.js
CHANGED
|
@@ -1313,6 +1313,9 @@ export async function runAskProCommand(rest, io) {
|
|
|
1313
1313
|
port: browserPort,
|
|
1314
1314
|
prompt: bundle.sendText,
|
|
1315
1315
|
targetUrl: normalizedTargetUrl,
|
|
1316
|
+
// A thread prodex resolved from its own records is reached by
|
|
1317
|
+
// navigating; a --target-url the person confirmed is not moved.
|
|
1318
|
+
...(continuedFromTaskId ? { navigateToTargetUrl: true } : {}),
|
|
1316
1319
|
timeoutMs: browserTimeoutMs,
|
|
1317
1320
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
1318
1321
|
...(tools.length > 0 ? { tools } : {}),
|
|
@@ -1432,6 +1435,13 @@ export async function runAskProCommand(rest, io) {
|
|
|
1432
1435
|
io.stderr(warning);
|
|
1433
1436
|
if (consult.modelSlug)
|
|
1434
1437
|
io.stderr(`model_used: ${consult.modelSlug}`);
|
|
1438
|
+
// Which conversation this followed, and where the answer actually landed.
|
|
1439
|
+
// The progress line that says it is filtered out of MCP notes, so an
|
|
1440
|
+
// agent asking for a follow-up had no way to know which thread it got -
|
|
1441
|
+
// or whether the answer is in the project it asked for.
|
|
1442
|
+
if (continuedFromTaskId)
|
|
1443
|
+
io.stderr(`continued_from: ${continuedFromTaskId}`);
|
|
1444
|
+
io.stderr(`destination: ${destination.destination}${destination.verified === undefined ? "" : ` verified=${destination.verified}`}`);
|
|
1435
1445
|
// "Can I count this as a Pro review?" - answered here rather than left
|
|
1436
1446
|
// for whoever reads the receipt to work out from two other fields.
|
|
1437
1447
|
const proVerified = proSelectionVerified({
|
|
@@ -1525,6 +1535,11 @@ export async function runAskProCommand(rest, io) {
|
|
|
1525
1535
|
status: result.status,
|
|
1526
1536
|
thread: consult.url,
|
|
1527
1537
|
answer: result.summary,
|
|
1538
|
+
...(continuedFromTaskId ? { continued_from: continuedFromTaskId } : {}),
|
|
1539
|
+
destination: {
|
|
1540
|
+
observed: destination.destination,
|
|
1541
|
+
...(destination.verified !== undefined ? { verified: destination.verified } : {})
|
|
1542
|
+
},
|
|
1528
1543
|
warnings: persistenceWarnings
|
|
1529
1544
|
}, null, 2));
|
|
1530
1545
|
}
|
package/dist/config.js
CHANGED
|
@@ -252,8 +252,13 @@ export async function loadBrowserDefaults(cwd) {
|
|
|
252
252
|
// stop applying with nothing said, so a consult that should have landed in
|
|
253
253
|
// a project lands in the general chat and looks like it worked.
|
|
254
254
|
if (!isMissingFileError(error)) {
|
|
255
|
+
// Say only what is true. Passing the flags explicitly does NOT get past
|
|
256
|
+
// this - the config is read before any of them are looked at - and
|
|
257
|
+
// advice that does not work is worse than none. Moving the file aside
|
|
258
|
+
// does work: a repo with no config has no defaults and sends fine.
|
|
255
259
|
throw new Error(`${error instanceof Error ? error.message : String(error)} Until then prodex will not apply the browser defaults ` +
|
|
256
|
-
`pinned there (project, model),
|
|
260
|
+
`pinned there (project, model), and every send from this repo stops here. Move .bridge/config.local.json aside ` +
|
|
261
|
+
`if this repo does not need the HTTP MCP surface.`, { cause: error });
|
|
257
262
|
}
|
|
258
263
|
repo = undefined;
|
|
259
264
|
}
|
package/dist/continue-thread.js
CHANGED
|
@@ -28,6 +28,41 @@ export function chatGptProjectSlug(name) {
|
|
|
28
28
|
.replace(/[^a-z0-9]+/g, "-")
|
|
29
29
|
.replace(/^-+|-+$/g, "");
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Whether a recorded URL is a conversation someone can go back to.
|
|
33
|
+
*
|
|
34
|
+
* Not everything prodex records as a "thread" is one. Measured across this
|
|
35
|
+
* machine's records: six of them are `https://chatgpt.com/?temporary-chat=true`
|
|
36
|
+
* - a temporary chat is never saved, so returning to that URL opens a fresh
|
|
37
|
+
* empty one. Continuing into it would look like a follow-up and be a new
|
|
38
|
+
* conversation with none of the context, which is the failure --continue
|
|
39
|
+
* exists to remove rather than reproduce.
|
|
40
|
+
*/
|
|
41
|
+
export function isChatGptConversationUrl(threadUrl) {
|
|
42
|
+
return /\/c\/[^/?#]+/.test(threadUrl);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The project ids these recorded threads have been seen using, by project name.
|
|
46
|
+
*
|
|
47
|
+
* Some thread URLs carry the project name after its id and some carry only the
|
|
48
|
+
* id (measured: three of this machine's hundred-odd records). The named ones
|
|
49
|
+
* teach the mapping, so the bare ones can still be recognised instead of being
|
|
50
|
+
* invisible to every follow-up - which would quietly continue an OLDER
|
|
51
|
+
* conversation while the newest one sat unmatched.
|
|
52
|
+
*/
|
|
53
|
+
export function projectIdsByName(threadUrls) {
|
|
54
|
+
const byName = new Map();
|
|
55
|
+
for (const url of threadUrls) {
|
|
56
|
+
const match = /\/g\/g-p-([0-9a-f]+)-([^/?#]+)/i.exec(url);
|
|
57
|
+
if (!match)
|
|
58
|
+
continue;
|
|
59
|
+
const name = match[2].toLowerCase();
|
|
60
|
+
const ids = byName.get(name) ?? new Set();
|
|
61
|
+
ids.add(match[1].toLowerCase());
|
|
62
|
+
byName.set(name, ids);
|
|
63
|
+
}
|
|
64
|
+
return byName;
|
|
65
|
+
}
|
|
31
66
|
/**
|
|
32
67
|
* Whether a recorded thread belongs to the project this send is for.
|
|
33
68
|
*
|
|
@@ -35,18 +70,22 @@ export function chatGptProjectSlug(name) {
|
|
|
35
70
|
* so a follow-up meant for the general chat cannot walk into a project - and a
|
|
36
71
|
* project's follow-up cannot land in another project's conversation.
|
|
37
72
|
*/
|
|
38
|
-
export function threadMatchesProject(threadUrl, project) {
|
|
73
|
+
export function threadMatchesProject(threadUrl, project, knownProjectIds) {
|
|
39
74
|
const projectSegment = /\/g\/(g-p-[^/?#]+)/.exec(threadUrl)?.[1];
|
|
40
75
|
if (!project)
|
|
41
76
|
return projectSegment === undefined;
|
|
42
77
|
if (!projectSegment)
|
|
43
78
|
return false;
|
|
44
79
|
const slug = chatGptProjectSlug(project);
|
|
45
|
-
if (!slug)
|
|
46
|
-
return false;
|
|
47
80
|
// The id comes first and the name follows it, so an exact suffix match keeps
|
|
48
81
|
// "notes" from answering for "notes-archive".
|
|
49
|
-
|
|
82
|
+
if (slug && projectSegment.toLowerCase().endsWith(`-${slug}`))
|
|
83
|
+
return true;
|
|
84
|
+
// No name in the URL: fall back to the id this project has been seen using.
|
|
85
|
+
// Nothing is guessed - the id came from another record of the same project -
|
|
86
|
+
// and it survives a rename, which the name comparison cannot.
|
|
87
|
+
const id = /^g-p-([0-9a-f]+)/i.exec(projectSegment)?.[1]?.toLowerCase();
|
|
88
|
+
return Boolean(id && knownProjectIds?.has(id));
|
|
50
89
|
}
|
|
51
90
|
/**
|
|
52
91
|
* The thread a follow-up should continue, or why it cannot be resolved.
|
|
@@ -57,9 +96,16 @@ export function threadMatchesProject(threadUrl, project) {
|
|
|
57
96
|
* the conversation is the failure this exists to prevent.
|
|
58
97
|
*/
|
|
59
98
|
export function resolveContinuationThread(input) {
|
|
60
|
-
const withThread = input.consults.filter((consult) =>
|
|
99
|
+
const withThread = input.consults.filter((consult) => consult.thread && isChatGptConversationUrl(consult.thread));
|
|
61
100
|
if (input.taskId) {
|
|
62
101
|
const named = withThread.find((consult) => consult.taskId === input.taskId);
|
|
102
|
+
const recordedButUnusable = !named && input.consults.some((consult) => consult.taskId === input.taskId);
|
|
103
|
+
if (recordedButUnusable) {
|
|
104
|
+
return {
|
|
105
|
+
error: `Consult "${input.taskId}" has no conversation to continue - it was a temporary chat, or it never reached one. ` +
|
|
106
|
+
`A temporary chat is never saved, so there is nothing to go back to.`
|
|
107
|
+
};
|
|
108
|
+
}
|
|
63
109
|
if (!named) {
|
|
64
110
|
return {
|
|
65
111
|
error: `No recorded consult thread for "${input.taskId}". List what is here with \`prodex pro list\`, ` +
|
|
@@ -68,9 +114,12 @@ export function resolveContinuationThread(input) {
|
|
|
68
114
|
}
|
|
69
115
|
return { target: { taskId: named.taskId, thread: named.thread } };
|
|
70
116
|
}
|
|
117
|
+
const knownProjectIds = input.project
|
|
118
|
+
? projectIdsByName(withThread.map((consult) => consult.thread)).get(chatGptProjectSlug(input.project))
|
|
119
|
+
: undefined;
|
|
71
120
|
const candidates = withThread
|
|
72
121
|
.filter((consult) => consult.status === "done")
|
|
73
|
-
.filter((consult) => threadMatchesProject(consult.thread, input.project))
|
|
122
|
+
.filter((consult) => threadMatchesProject(consult.thread, input.project, knownProjectIds))
|
|
74
123
|
.sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
|
|
75
124
|
const latest = candidates[0];
|
|
76
125
|
if (!latest) {
|