@steipete/oracle 0.17.3 → 0.19.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 +1 -1
- package/dist/bin/oracle-cli.js +75 -64
- package/dist/bin/oracle-mcp.js +0 -0
- package/dist/src/browser/actions/archiveConversation.js +31 -7
- package/dist/src/browser/actions/assistantResponse.js +43 -1
- package/dist/src/browser/actions/attachmentContext.js +239 -0
- package/dist/src/browser/actions/attachmentDataTransfer.js +19 -1
- package/dist/src/browser/actions/attachmentEvidence.js +78 -0
- package/dist/src/browser/actions/attachmentProgress.js +41 -0
- package/dist/src/browser/actions/attachmentPrompt.js +130 -0
- package/dist/src/browser/actions/attachments.js +306 -355
- package/dist/src/browser/actions/deepResearch.js +2 -1
- package/dist/src/browser/actions/modelSelection.js +13 -3
- package/dist/src/browser/actions/navigation.js +1 -1
- package/dist/src/browser/actions/promptComposer.js +361 -186
- package/dist/src/browser/actions/remoteFileTransfer.js +4 -2
- package/dist/src/browser/actions/thinkingStatus.js +2 -5
- package/dist/src/browser/actions/thinkingTime.js +267 -15
- package/dist/src/browser/attachRunning.js +46 -2
- package/dist/src/browser/attachmentValidation.js +53 -0
- package/dist/src/browser/cancellation.js +105 -0
- package/dist/src/browser/chatgptImages.js +2 -0
- package/dist/src/browser/chatgptThrottle.js +104 -0
- package/dist/src/browser/chromeLifecycle.js +267 -43
- package/dist/src/browser/config.js +15 -3
- package/dist/src/browser/detect.js +1 -1
- package/dist/src/browser/index.js +447 -263
- package/dist/src/browser/modelDisplay.js +9 -0
- package/dist/src/browser/pageActions.js +2 -2
- package/dist/src/browser/policies.js +6 -2
- package/dist/src/browser/profileState.js +114 -9
- package/dist/src/browser/projectSourcesRunner.js +88 -17
- package/dist/src/browser/prompt.js +272 -113
- package/dist/src/browser/providers/chatgptDomProvider.js +2 -0
- package/dist/src/browser/reattach.js +14 -4
- package/dist/src/browser/sessionRunner.js +71 -11
- package/dist/src/browser/tabLeaseRegistry.js +414 -38
- package/dist/src/browser/utils.js +20 -2
- package/dist/src/cli/browserConfig.js +40 -15
- package/dist/src/cli/browserDefaults.js +13 -0
- package/dist/src/cli/browserTabs.js +12 -29
- package/dist/src/cli/detachedSession.js +69 -0
- package/dist/src/cli/dryRun.js +58 -47
- package/dist/src/cli/harvestIntegrity.js +140 -0
- package/dist/src/cli/projectSources.js +5 -3
- package/dist/src/cli/serveBrowserConfig.js +24 -0
- package/dist/src/cli/sessionDisplay.js +9 -2
- package/dist/src/cli/sessionRunner.js +91 -7
- package/dist/src/config.js +1 -0
- package/dist/src/gemini-web/client.js +67 -21
- package/dist/src/gemini-web/executor.js +4 -0
- package/dist/src/gemini-web/http.js +15 -0
- package/dist/src/gemini-web/models.js +15 -11
- package/dist/src/mcp/server.js +11 -14
- package/dist/src/mcp/tools/chatgptImage.js +4 -4
- package/dist/src/mcp/tools/consult.js +97 -26
- package/dist/src/mcp/tools/projectSources.js +4 -6
- package/dist/src/mcp/tools/sessionResources.js +1 -1
- package/dist/src/mcp/tools/sessions.js +2 -2
- package/dist/src/mcp/tools/wait.js +182 -0
- package/dist/src/mcp/types.js +7 -0
- package/dist/src/oracle/errors.js +11 -0
- package/dist/src/oracle/providerRoutePlan.js +3 -2
- package/dist/src/remote/client.js +70 -6
- package/dist/src/remote/health.js +6 -2
- package/dist/src/remote/runSlots.js +83 -0
- package/dist/src/remote/server.js +351 -72
- package/dist/src/sessionManager.js +36 -1
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/package.json +25 -23
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/dist/bin/oracle.js +0 -683
- package/dist/docs-site/.nojekyll +0 -0
- package/dist/docs-site/CNAME +0 -1
- package/dist/docs-site/RELEASING.html +0 -410
- package/dist/docs-site/agents.html +0 -374
- package/dist/docs-site/anthropic.html +0 -368
- package/dist/docs-site/bridge.html +0 -400
- package/dist/docs-site/browser-mode.html +0 -588
- package/dist/docs-site/chromium-forks.html +0 -347
- package/dist/docs-site/cli-reference.html +0 -346
- package/dist/docs-site/configuration.html +0 -436
- package/dist/docs-site/favicon.svg +0 -14
- package/dist/docs-site/followup.html +0 -368
- package/dist/docs-site/gemini.html +0 -379
- package/dist/docs-site/grok.html +0 -325
- package/dist/docs-site/index.html +0 -360
- package/dist/docs-site/install.html +0 -335
- package/dist/docs-site/linux.html +0 -321
- package/dist/docs-site/llms.txt +0 -43
- package/dist/docs-site/manual-tests.html +0 -595
- package/dist/docs-site/mcp.html +0 -368
- package/dist/docs-site/multimodel.html +0 -364
- package/dist/docs-site/mythical-pro-agents.html +0 -360
- package/dist/docs-site/notifier.html +0 -338
- package/dist/docs-site/openai-endpoints.html +0 -387
- package/dist/docs-site/openrouter.html +0 -344
- package/dist/docs-site/quickstart.html +0 -369
- package/dist/docs-site/refactor/ux.html +0 -532
- package/dist/docs-site/sessions.html +0 -388
- package/dist/docs-site/social-card.png +0 -0
- package/dist/docs-site/social-card.svg +0 -79
- package/dist/docs-site/spec.html +0 -363
- package/dist/docs-site/testing.html +0 -320
- package/dist/docs-site/tui-debug.html +0 -326
- package/dist/docs-site/windows-work.html +0 -323
- package/dist/docs-site/windows.html +0 -320
- package/dist/markdansi/types/index.js +0 -4
- package/dist/oracle/bin/oracle-cli.js +0 -472
- package/dist/oracle/src/browser/actions/assistantResponse.js +0 -471
- package/dist/oracle/src/browser/actions/attachments.js +0 -82
- package/dist/oracle/src/browser/actions/modelSelection.js +0 -190
- package/dist/oracle/src/browser/actions/navigation.js +0 -75
- package/dist/oracle/src/browser/actions/promptComposer.js +0 -167
- package/dist/oracle/src/browser/chromeLifecycle.js +0 -104
- package/dist/oracle/src/browser/config.js +0 -33
- package/dist/oracle/src/browser/constants.js +0 -40
- package/dist/oracle/src/browser/cookies.js +0 -210
- package/dist/oracle/src/browser/domDebug.js +0 -36
- package/dist/oracle/src/browser/index.js +0 -331
- package/dist/oracle/src/browser/pageActions.js +0 -5
- package/dist/oracle/src/browser/prompt.js +0 -88
- package/dist/oracle/src/browser/promptSummary.js +0 -20
- package/dist/oracle/src/browser/sessionRunner.js +0 -80
- package/dist/oracle/src/browser/types.js +0 -1
- package/dist/oracle/src/browser/utils.js +0 -62
- package/dist/oracle/src/browserMode.js +0 -1
- package/dist/oracle/src/cli/browserConfig.js +0 -44
- package/dist/oracle/src/cli/dryRun.js +0 -59
- package/dist/oracle/src/cli/engine.js +0 -17
- package/dist/oracle/src/cli/errorUtils.js +0 -9
- package/dist/oracle/src/cli/help.js +0 -70
- package/dist/oracle/src/cli/markdownRenderer.js +0 -15
- package/dist/oracle/src/cli/options.js +0 -103
- package/dist/oracle/src/cli/promptRequirement.js +0 -14
- package/dist/oracle/src/cli/rootAlias.js +0 -30
- package/dist/oracle/src/cli/sessionCommand.js +0 -77
- package/dist/oracle/src/cli/sessionDisplay.js +0 -270
- package/dist/oracle/src/cli/sessionRunner.js +0 -94
- package/dist/oracle/src/heartbeat.js +0 -43
- package/dist/oracle/src/oracle/client.js +0 -48
- package/dist/oracle/src/oracle/config.js +0 -29
- package/dist/oracle/src/oracle/errors.js +0 -101
- package/dist/oracle/src/oracle/files.js +0 -220
- package/dist/oracle/src/oracle/format.js +0 -33
- package/dist/oracle/src/oracle/fsAdapter.js +0 -7
- package/dist/oracle/src/oracle/oscProgress.js +0 -60
- package/dist/oracle/src/oracle/request.js +0 -48
- package/dist/oracle/src/oracle/run.js +0 -444
- package/dist/oracle/src/oracle/tokenStats.js +0 -39
- package/dist/oracle/src/oracle/types.js +0 -1
- package/dist/oracle/src/oracle.js +0 -9
- package/dist/oracle/src/sessionManager.js +0 -205
- package/dist/oracle/src/version.js +0 -39
- package/dist/scripts/chrome/browser-tools.js +0 -295
- package/dist/src/browser/chromeCookies.js +0 -312
- package/dist/src/browser/keytarShim.js +0 -56
- package/dist/src/browser/profileSync.js +0 -141
- package/dist/src/browser/windowsCookies.js +0 -219
|
@@ -4,6 +4,7 @@ import { waitForAttachmentVisible } from "./attachments.js";
|
|
|
4
4
|
import { delay } from "../utils.js";
|
|
5
5
|
import { logDomFailure } from "../domDebug.js";
|
|
6
6
|
import { transferAttachmentViaDataTransfer } from "./attachmentDataTransfer.js";
|
|
7
|
+
import { beginAttachmentEvidence } from "./attachmentEvidence.js";
|
|
7
8
|
/**
|
|
8
9
|
* Upload file to remote Chrome by transferring content via CDP
|
|
9
10
|
* Used when browser is on a different machine than CLI
|
|
@@ -28,10 +29,11 @@ export async function uploadAttachmentViaDataTransfer(deps, attachment, logger)
|
|
|
28
29
|
await logDomFailure(runtime, logger, "file-input");
|
|
29
30
|
throw new Error("Unable to locate ChatGPT file attachment input.");
|
|
30
31
|
}
|
|
31
|
-
const
|
|
32
|
+
const evidenceId = await beginAttachmentEvidence(runtime, path.basename(attachment.path));
|
|
33
|
+
const transferResult = await transferAttachmentViaDataTransfer(runtime, attachment, fileInputSelector, deps.navigationUrl);
|
|
32
34
|
logger(`File transferred: ${transferResult.fileName} (${transferResult.size} bytes)`);
|
|
33
35
|
// Give ChatGPT a moment to process the file
|
|
34
36
|
await delay(500);
|
|
35
|
-
await waitForAttachmentVisible(runtime, transferResult.fileName, 10_000, logger);
|
|
37
|
+
await waitForAttachmentVisible(runtime, transferResult.fileName, 10_000, logger, evidenceId);
|
|
36
38
|
logger("Attachment queued");
|
|
37
39
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { formatElapsed } from "../../oracle/format.js";
|
|
2
2
|
import { ASSISTANT_ROLE_SELECTOR, CONVERSATION_TURN_SELECTOR, STOP_BUTTON_SELECTORS, } from "../constants.js";
|
|
3
|
+
import { buildConversationTurnListExpression } from "../conversationTurns.js";
|
|
3
4
|
const THINKING_STALE_HINT_MS = 10 * 60_000;
|
|
4
5
|
export function startThinkingStatusMonitor(Runtime, logger, options = {}) {
|
|
5
6
|
const intervalMs = resolveThinkingStatusInterval(options.intervalMs);
|
|
@@ -438,7 +439,6 @@ const ACTIVE_THINKING_LABELS = [
|
|
|
438
439
|
function buildThinkingActivityPredicateJs(fnName, detailed) {
|
|
439
440
|
const stopLiteral = JSON.stringify(STOP_BUTTON_SELECTORS.join(", "));
|
|
440
441
|
const activeLabelsLiteral = JSON.stringify(ACTIVE_THINKING_LABELS);
|
|
441
|
-
const conversationLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
|
|
442
442
|
const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
|
|
443
443
|
const strong = detailed ? "{ active: true, strong: true }" : "true";
|
|
444
444
|
const weak = detailed ? "{ active: true, strong: false }" : "true";
|
|
@@ -446,7 +446,6 @@ function buildThinkingActivityPredicateJs(fnName, detailed) {
|
|
|
446
446
|
return `const ${fnName} = () => {
|
|
447
447
|
const STOP_SELECTOR = ${stopLiteral};
|
|
448
448
|
const ACTIVE_LABELS = ${activeLabelsLiteral};
|
|
449
|
-
const CONVERSATION_SELECTOR = ${conversationLiteral};
|
|
450
449
|
const ASSISTANT_SELECTOR = ${assistantLiteral};
|
|
451
450
|
const isVisible = (node) => {
|
|
452
451
|
if (!(node instanceof HTMLElement)) return false;
|
|
@@ -549,9 +548,7 @@ function buildThinkingActivityPredicateJs(fnName, detailed) {
|
|
|
549
548
|
// a visible progress bar mounted indefinitely (review P1), and a document-wide veto would
|
|
550
549
|
// then hold thinkingActive true until the watchdog timeout on a completed response. The
|
|
551
550
|
// sidecar check below covers verified reasoning panels; here only the latest turn counts.
|
|
552
|
-
const turns = (
|
|
553
|
-
try { return document.querySelectorAll(CONVERSATION_SELECTOR); } catch { return []; }
|
|
554
|
-
})();
|
|
551
|
+
const turns = ${buildConversationTurnListExpression()};
|
|
555
552
|
const lastTurn = turns.length ? turns[turns.length - 1] : null;
|
|
556
553
|
if (
|
|
557
554
|
lastTurn instanceof HTMLElement &&
|
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
import { MENU_CONTAINER_SELECTOR, MENU_ITEM_SELECTOR, MODEL_BUTTON_SELECTOR, } from "../constants.js";
|
|
2
2
|
import { logDomFailure } from "../domDebug.js";
|
|
3
3
|
import { buildClickDispatcher } from "./domEvents.js";
|
|
4
|
+
import { BrowserAutomationError } from "../../oracle/errors.js";
|
|
5
|
+
export class ThinkingTierUnavailableError extends BrowserAutomationError {
|
|
6
|
+
requestedLevel;
|
|
7
|
+
requestedLabel;
|
|
8
|
+
optionLabel;
|
|
9
|
+
notice;
|
|
10
|
+
confirmedTarget;
|
|
11
|
+
constructor(requestedLevel, requestedLabel, optionLabel, notice, confirmedTarget) {
|
|
12
|
+
const message = `Thinking time: ${optionLabel ?? requestedLabel} is unavailable on this account (${notice ?? "no reason given"}); refusing to submit without confirmed ${confirmedTarget}.`;
|
|
13
|
+
super(message, {
|
|
14
|
+
stage: "thinking-tier-unavailable",
|
|
15
|
+
requestedLevel,
|
|
16
|
+
requestedLabel,
|
|
17
|
+
optionLabel,
|
|
18
|
+
notice,
|
|
19
|
+
confirmedTarget,
|
|
20
|
+
});
|
|
21
|
+
this.name = "ThinkingTierUnavailableError";
|
|
22
|
+
this.requestedLevel = requestedLevel;
|
|
23
|
+
this.requestedLabel = requestedLabel;
|
|
24
|
+
this.optionLabel = optionLabel;
|
|
25
|
+
this.notice = notice;
|
|
26
|
+
this.confirmedTarget = confirmedTarget;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function confirmedThinkingTarget(level, capitalizedLevel, targetModelKind, observedModelKind) {
|
|
30
|
+
const strictModelKind = targetModelKind ?? observedModelKind;
|
|
31
|
+
return level === "pro" ? "Pro" : strictModelKind === "pro" ? "Pro Extended" : capitalizedLevel;
|
|
32
|
+
}
|
|
4
33
|
const BROWSER_THINKING_LOG_PREFIX = "[browser] Thinking time:";
|
|
5
34
|
function formatBrowserThinkingLog(message) {
|
|
6
35
|
return `${BROWSER_THINKING_LOG_PREFIX} ${message.replace(/^Thinking time:\s*/, "")}`;
|
|
@@ -23,8 +52,8 @@ function logPickerDiagnostic(result, logger) {
|
|
|
23
52
|
/**
|
|
24
53
|
* Selects a thinking-time level in ChatGPT's composer.
|
|
25
54
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
55
|
+
* Returns the UI selection observed at capturedAt, not backend attestation.
|
|
56
|
+
* Explicit Pro and Pro Extended requests still fail closed when unconfirmed.
|
|
28
57
|
*/
|
|
29
58
|
export async function ensureThinkingTime(Runtime, level, logger, desiredModel) {
|
|
30
59
|
const result = await evaluateThinkingTimeSelection(Runtime, level, desiredModel);
|
|
@@ -36,13 +65,36 @@ export async function ensureThinkingTime(Runtime, level, logger, desiredModel) {
|
|
|
36
65
|
// own, independently of the legacy Pro-model + "extended" combination.
|
|
37
66
|
const strictProEffort = level === "pro" ||
|
|
38
67
|
((targetModelKind === "pro" || observedModelKind === "pro") && level === "extended");
|
|
68
|
+
const evidence = (status, resolvedLabel) => ({
|
|
69
|
+
requestedLevel: level,
|
|
70
|
+
status,
|
|
71
|
+
resolvedLabel,
|
|
72
|
+
verified: status === "already-selected" || status === "switched",
|
|
73
|
+
strictFailClosed: strictProEffort,
|
|
74
|
+
targetModelKind: targetModelKind ?? null,
|
|
75
|
+
observedModelKind: observedModelKind ?? null,
|
|
76
|
+
source: "chatgpt-thinking-picker",
|
|
77
|
+
capturedAt: new Date().toISOString(),
|
|
78
|
+
});
|
|
39
79
|
switch (result?.status) {
|
|
40
80
|
case "already-selected":
|
|
41
81
|
logger(formatBrowserThinkingLog(`${result.label ?? capitalizedLevel} (already selected)`));
|
|
42
|
-
return;
|
|
82
|
+
return evidence("already-selected", result.label ?? null);
|
|
43
83
|
case "switched":
|
|
44
84
|
logger(formatBrowserThinkingLog(result.label ?? capitalizedLevel));
|
|
45
|
-
return;
|
|
85
|
+
return evidence("switched", result.label ?? null);
|
|
86
|
+
case "option-disabled": {
|
|
87
|
+
await logDomFailure(Runtime, logger, "thinking-option-disabled");
|
|
88
|
+
logPickerDiagnostic(result, logger);
|
|
89
|
+
if (strictProEffort) {
|
|
90
|
+
throw new ThinkingTierUnavailableError(level, capitalizedLevel, result.label ?? null, result.notice ?? null, confirmedThinkingTarget(level, capitalizedLevel, targetModelKind, observedModelKind));
|
|
91
|
+
}
|
|
92
|
+
// A non-strict caller goes on to submit, so this log must not borrow the
|
|
93
|
+
// strict error's "refusing to submit" wording: the request really is sent,
|
|
94
|
+
// at whatever effort ChatGPT already had selected.
|
|
95
|
+
logger(formatBrowserThinkingLog(`${result.label ?? capitalizedLevel} is unavailable on this account (${result.notice ?? "no reason given"}); keeping the effort already selected in ChatGPT.`));
|
|
96
|
+
return evidence("unverified", null);
|
|
97
|
+
}
|
|
46
98
|
case "chip-not-found":
|
|
47
99
|
case "menu-not-found":
|
|
48
100
|
case "option-not-found":
|
|
@@ -67,7 +119,7 @@ export async function ensureThinkingTime(Runtime, level, logger, desiredModel) {
|
|
|
67
119
|
? "the effort in ChatGPT is unconfirmed"
|
|
68
120
|
: "keeping the effort already selected in ChatGPT";
|
|
69
121
|
logger(formatBrowserThinkingLog(`${message}; ${outcome}.`));
|
|
70
|
-
return;
|
|
122
|
+
return evidence("unverified", null);
|
|
71
123
|
}
|
|
72
124
|
default: {
|
|
73
125
|
await logDomFailure(Runtime, logger, "thinking-time-unknown");
|
|
@@ -77,7 +129,7 @@ export async function ensureThinkingTime(Runtime, level, logger, desiredModel) {
|
|
|
77
129
|
throw new Error(`Thinking time: unknown outcome selecting ${capitalizedLevel}; refusing to submit without confirmed ${target}.`);
|
|
78
130
|
}
|
|
79
131
|
logger(formatBrowserThinkingLog(`unknown outcome selecting ${capitalizedLevel}; continuing with ChatGPT default.`));
|
|
80
|
-
return;
|
|
132
|
+
return evidence("unverified", null);
|
|
81
133
|
}
|
|
82
134
|
}
|
|
83
135
|
}
|
|
@@ -97,6 +149,9 @@ export async function ensureThinkingTimeIfAvailable(Runtime, level, logger, desi
|
|
|
97
149
|
case "switched":
|
|
98
150
|
logger(formatBrowserThinkingLog(result.label ?? capitalizedLevel));
|
|
99
151
|
return true;
|
|
152
|
+
case "option-disabled":
|
|
153
|
+
logger(formatBrowserThinkingLog(`${result.label ?? capitalizedLevel} is unavailable on this account (${result.notice ?? "no reason given"}); keeping the effort already selected in ChatGPT.`));
|
|
154
|
+
return false;
|
|
100
155
|
case "chip-not-found":
|
|
101
156
|
case "menu-not-found":
|
|
102
157
|
case "option-not-found":
|
|
@@ -147,12 +202,12 @@ function buildThinkingTimeExpression(level, desiredModel) {
|
|
|
147
202
|
const TARGET_MODEL_KIND = ${targetModelKindLiteral};
|
|
148
203
|
const TARGET_IS_GPT56_MODEL = ${targetIsGpt56ModelLiteral};
|
|
149
204
|
|
|
150
|
-
// Multilingual matchers: English level token + observed
|
|
205
|
+
// Multilingual matchers: English level token + observed localized variants.
|
|
151
206
|
const LEVEL_TOKENS = {
|
|
152
|
-
light: ['light', 'instant', 'sofort', 'leicht', '最速', '轻', '极速'],
|
|
153
|
-
standard: ['standard', 'medium', 'mittel', '中程度', '标准', '中'],
|
|
154
|
-
extended: ['extended', 'high', 'hoch', 'erweitert', '高い', '扩展', '深度', '加强', '高'],
|
|
155
|
-
'extra-high': ['extra high', 'sehr hoch', '非常に高い', '极高'],
|
|
207
|
+
light: ['light', 'instant', 'sofort', 'leicht', '最速', '轻', '极速', '즉시'],
|
|
208
|
+
standard: ['standard', 'medium', 'mittel', '中程度', '标准', '中', '중간'],
|
|
209
|
+
extended: ['extended', 'high', 'hoch', 'erweitert', '高い', '扩展', '深度', '加强', '高', '높음'],
|
|
210
|
+
'extra-high': ['extra high', 'sehr hoch', '非常に高い', '极高', '매우 높음'],
|
|
156
211
|
heavy: ['heavy', 'schwer', '重度', '加重'],
|
|
157
212
|
};
|
|
158
213
|
// Pro is a tier you can request, but it is also a MODEL name, so it must never
|
|
@@ -171,7 +226,7 @@ function buildThinkingTimeExpression(level, desiredModel) {
|
|
|
171
226
|
const INTELLIGENCE_WAIT_MS = 2500;
|
|
172
227
|
|
|
173
228
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
174
|
-
// Keep CJK characters, including Japanese kana, so
|
|
229
|
+
// Keep CJK characters, including Japanese kana and Hangul, so labels survive
|
|
175
230
|
// normalization before being matched against LEVEL_TOKENS and picker controls.
|
|
176
231
|
const normalize = (value) => (value || '')
|
|
177
232
|
// Compose first so NFD umlauts fold too, then map them onto ASCII before
|
|
@@ -182,7 +237,7 @@ function buildThinkingTimeExpression(level, desiredModel) {
|
|
|
182
237
|
.replace(/ö/g, 'o')
|
|
183
238
|
.replace(/ü/g, 'u')
|
|
184
239
|
.replace(/ß/g, 'ss')
|
|
185
|
-
.replace(/[^a-z0-9\\u3040-\\u30ff\\u4e00-\\u9fff]+/g, ' ')
|
|
240
|
+
.replace(/[^a-z0-9\\u3040-\\u30ff\\u4e00-\\u9fff\\uac00-\\ud7af]+/g, ' ')
|
|
186
241
|
.replace(/\\s+/g, ' ')
|
|
187
242
|
.trim();
|
|
188
243
|
const hasToken = (text, token) => normalize(text).split(' ').includes(token);
|
|
@@ -211,6 +266,8 @@ function buildThinkingTimeExpression(level, desiredModel) {
|
|
|
211
266
|
if (token === 'extra high') return hasPhrase(t, 'extra high');
|
|
212
267
|
if (token === 'hoch') return hasPhrase(t, 'hoch') && !hasPhrase(t, 'sehr hoch');
|
|
213
268
|
if (token === 'sehr hoch') return hasPhrase(t, 'sehr hoch');
|
|
269
|
+
if (token === '높음') return hasPhrase(t, '높음') && !hasPhrase(t, '매우 높음');
|
|
270
|
+
if (token === '매우 높음') return hasPhrase(t, '매우 높음');
|
|
214
271
|
if (token === '高い' || token === '非常に高い') {
|
|
215
272
|
return t === token || hasToken(t, token);
|
|
216
273
|
}
|
|
@@ -292,6 +349,21 @@ function buildThinkingTimeExpression(level, desiredModel) {
|
|
|
292
349
|
.replace(/\\s+/g, ' ')
|
|
293
350
|
.trim()
|
|
294
351
|
.slice(0, maxLength);
|
|
352
|
+
const isOptionDisabled = (node) => {
|
|
353
|
+
if (!node || typeof node.getAttribute !== 'function') return false;
|
|
354
|
+
// data-disabled is Radix's valueless-presence convention, but an explicit
|
|
355
|
+
// "false" must not read as disabled: that would refuse a perfectly usable
|
|
356
|
+
// tier and report it as unavailable.
|
|
357
|
+
const dataDisabled = node.getAttribute('data-disabled');
|
|
358
|
+
const dataDisabledOn = dataDisabled !== null && String(dataDisabled).toLowerCase() !== 'false';
|
|
359
|
+
return (
|
|
360
|
+
node.getAttribute('aria-disabled') === 'true' ||
|
|
361
|
+
dataDisabledOn ||
|
|
362
|
+
(node.getAttribute('data-state') || '').toLowerCase() === 'disabled' ||
|
|
363
|
+
Boolean(node.disabled) ||
|
|
364
|
+
node.getAttribute('disabled') !== null
|
|
365
|
+
);
|
|
366
|
+
};
|
|
295
367
|
const describeNode = (el) => {
|
|
296
368
|
if (!el || typeof el.getAttribute !== 'function') return null;
|
|
297
369
|
let rect = null;
|
|
@@ -314,7 +386,10 @@ function buildThinkingTimeExpression(level, desiredModel) {
|
|
|
314
386
|
ariaChecked: el.getAttribute('aria-checked'),
|
|
315
387
|
ariaSelected: el.getAttribute('aria-selected'),
|
|
316
388
|
ariaHaspopup: el.getAttribute('aria-haspopup'),
|
|
389
|
+
ariaDisabled: el.getAttribute('aria-disabled'),
|
|
390
|
+
dataDisabled: el.getAttribute('data-disabled'),
|
|
317
391
|
dataState: el.getAttribute('data-state'),
|
|
392
|
+
disabled: isOptionDisabled(el),
|
|
318
393
|
text: redactDiagnosticText(el.textContent, 80),
|
|
319
394
|
rect,
|
|
320
395
|
};
|
|
@@ -590,6 +665,77 @@ function buildThinkingTimeExpression(level, desiredModel) {
|
|
|
590
665
|
}
|
|
591
666
|
return matchesLevel(normalizedLabel);
|
|
592
667
|
};
|
|
668
|
+
const nonEmptyNotice = (value) => {
|
|
669
|
+
const text = redactDiagnosticText(value, 160);
|
|
670
|
+
return text || null;
|
|
671
|
+
};
|
|
672
|
+
// The notice must be ROW-OWNED, and preferably this hover's own.
|
|
673
|
+
//
|
|
674
|
+
// NOTE: no backticks in this comment — it lives inside the injected template
|
|
675
|
+
// literal, where a backtick would terminate the string.
|
|
676
|
+
//
|
|
677
|
+
// A document-wide role=tooltip scan is what this must never become: novelty is
|
|
678
|
+
// not causality, and an unrelated control with an armed open-delay can mount its
|
|
679
|
+
// tooltip inside this hover's window. Every pass below is anchored on the row.
|
|
680
|
+
//
|
|
681
|
+
// Preference order, strongest provenance first:
|
|
682
|
+
// 1. an id this hover ADDED whose target is role=tooltip — Radix keeps an
|
|
683
|
+
// application-supplied description and APPENDS its tooltip id when opening,
|
|
684
|
+
// so the delta is what separates the real notice from a permanent blurb;
|
|
685
|
+
// 2. any other id this hover added;
|
|
686
|
+
// 3. an already-associated role=tooltip target, for a tooltip that was open
|
|
687
|
+
// before the probe arrived;
|
|
688
|
+
// 4. the row's static title, only once the poll has expired.
|
|
689
|
+
//
|
|
690
|
+
// Passes 3 and 4 are row-owned but NOT causal: a page that permanently points a
|
|
691
|
+
// disabled row at generic role=tooltip help, or gives it a generic title, will
|
|
692
|
+
// have that text reported. That is accepted deliberately — the value is an opaque
|
|
693
|
+
// notice for a human or a caller to interpret, not a parsed reset time — and the
|
|
694
|
+
// verified live target has neither at rest.
|
|
695
|
+
const describedIds = (option) =>
|
|
696
|
+
(option?.getAttribute?.('aria-describedby') || '').split(/\\s+/).filter(Boolean);
|
|
697
|
+
const isTooltipNode = (node) => node?.getAttribute?.('role') === 'tooltip';
|
|
698
|
+
const readDisabledNotice = (option, priorIds, allowTitle) => {
|
|
699
|
+
const ids = describedIds(option);
|
|
700
|
+
const prior = priorIds instanceof Set ? priorIds : new Set();
|
|
701
|
+
const fresh = ids.filter((id) => !prior.has(id));
|
|
702
|
+
for (const pass of [
|
|
703
|
+
fresh.filter((id) => isTooltipNode(document.getElementById?.(id))),
|
|
704
|
+
fresh,
|
|
705
|
+
ids.filter((id) => isTooltipNode(document.getElementById?.(id))),
|
|
706
|
+
]) {
|
|
707
|
+
for (const id of pass) {
|
|
708
|
+
const notice = nonEmptyNotice(document.getElementById?.(id)?.textContent);
|
|
709
|
+
if (notice) return notice;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (!allowTitle) return null;
|
|
713
|
+
return nonEmptyNotice(option?.getAttribute?.('title'));
|
|
714
|
+
};
|
|
715
|
+
const waitForDisabledNotice = async (option, priorIds) => {
|
|
716
|
+
const deadline = performance.now() + 400;
|
|
717
|
+
while (performance.now() < deadline) {
|
|
718
|
+
const notice = readDisabledNotice(option, priorIds, false);
|
|
719
|
+
if (notice) return notice;
|
|
720
|
+
await sleep(50);
|
|
721
|
+
}
|
|
722
|
+
// Only now may a static title speak: the association had its full window.
|
|
723
|
+
return readDisabledNotice(option, priorIds, true);
|
|
724
|
+
};
|
|
725
|
+
// Opening a tooltip stacks another Radix dismissable layer over the menu, and
|
|
726
|
+
// the topmost layer eats the Escape. One blind Escape therefore leaves the
|
|
727
|
+
// effort menu open, which matters for the non-strict caller that goes on to
|
|
728
|
+
// submit. Dismiss, let the layer unmount, and only Escape again while a menu is
|
|
729
|
+
// still there — never two unconditional Escapes, which could close an unrelated
|
|
730
|
+
// outer surface.
|
|
731
|
+
const closeMenusAfterTooltip = async () => {
|
|
732
|
+
closeOpenMenus();
|
|
733
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
734
|
+
await sleep(50);
|
|
735
|
+
if (!Array.from(document.querySelectorAll(MENU_CONTAINER_SELECTOR)).some(isVisible)) return;
|
|
736
|
+
closeOpenMenus();
|
|
737
|
+
}
|
|
738
|
+
};
|
|
593
739
|
const selectAndVerify = async (trigger, findOption, modelKindOverride = null) => {
|
|
594
740
|
const triggerModelKind =
|
|
595
741
|
modelKindOverride ||
|
|
@@ -615,6 +761,22 @@ function buildThinkingTimeExpression(level, desiredModel) {
|
|
|
615
761
|
}
|
|
616
762
|
if (!option) return failure('option-not-found', { modelKind: triggerModelKind });
|
|
617
763
|
const label = option.textContent?.trim?.() || null;
|
|
764
|
+
if (isOptionDisabled(option)) {
|
|
765
|
+
// Captured BEFORE the hover: the ids already here describe the row for other
|
|
766
|
+
// reasons and cannot be this hover's reason.
|
|
767
|
+
const priorIds = new Set(describedIds(option));
|
|
768
|
+
dispatchHoverSequence(option);
|
|
769
|
+
const notice = await waitForDisabledNotice(option, priorIds);
|
|
770
|
+
const result = failure('option-disabled', {
|
|
771
|
+
// The label is page text that reaches logs and diagnostics, so it is
|
|
772
|
+
// redacted like every other reported string.
|
|
773
|
+
label: redactDiagnosticText(option.textContent, 80) || null,
|
|
774
|
+
notice,
|
|
775
|
+
modelKind: triggerModelKind,
|
|
776
|
+
});
|
|
777
|
+
await closeMenusAfterTooltip();
|
|
778
|
+
return result;
|
|
779
|
+
}
|
|
618
780
|
if (optionIsSelected(option)) {
|
|
619
781
|
closeOpenMenus();
|
|
620
782
|
return { status: 'already-selected', label };
|
|
@@ -693,11 +855,11 @@ function buildThinkingTimeExpression(level, desiredModel) {
|
|
|
693
855
|
// token matching cannot be used here — substring is deliberate, as in
|
|
694
856
|
// countEffortLevels above.
|
|
695
857
|
const ADVANCED_WORDS = [
|
|
696
|
-
'advanced', 'erweitert', '高级', '詳細設定', '詳細表示',
|
|
858
|
+
'advanced', 'erweitert', '高级', '詳細設定', '詳細表示', '고급',
|
|
697
859
|
'avanzado', 'avancado', 'avance',
|
|
698
860
|
];
|
|
699
861
|
const EFFORT_WORDS = [
|
|
700
|
-
'effort', 'aufwand', '强度', '努力', '推論レベル',
|
|
862
|
+
'effort', 'aufwand', '强度', '努力', '推論レベル', '思考量', '추론 수준',
|
|
701
863
|
'esfuerzo', 'esforco', 'sforzo', 'inspanning', 'wysilek',
|
|
702
864
|
];
|
|
703
865
|
const containsAny = (label, words) => words.some((word) => label.includes(word));
|
|
@@ -796,6 +958,94 @@ function buildThinkingTimeExpression(level, desiredModel) {
|
|
|
796
958
|
return null;
|
|
797
959
|
};
|
|
798
960
|
|
|
961
|
+
// The direct-slider rollout removes the Effort submenu entirely. Its keyboard
|
|
962
|
+
// owner announces the actual tier via aria-describedby; neither the pill nor
|
|
963
|
+
// the slider's maximum position alone proves that Pro was selected.
|
|
964
|
+
const selectDirectEffortSlider = async (menu) => {
|
|
965
|
+
const view = menu.querySelector?.('[data-model-selection-view="true"]');
|
|
966
|
+
const simple = view?.querySelector?.('[data-testid="composer-model-picker-slider-simple-view"]');
|
|
967
|
+
if (!simple || simple.getAttribute('data-active') !== 'true' || !isVisible(simple)) return null;
|
|
968
|
+
const resolve = () => {
|
|
969
|
+
const currentView = menu.querySelector?.('[data-model-selection-view="true"]');
|
|
970
|
+
const currentSimple = currentView?.querySelector?.('[data-testid="composer-model-picker-slider-simple-view"]');
|
|
971
|
+
if (currentSimple?.getAttribute('data-active') !== 'true') return null;
|
|
972
|
+
const slider = currentSimple.querySelector('[data-model-reasoning-effort-slider]');
|
|
973
|
+
const control = slider?.closest?.('[role="menuitem"]');
|
|
974
|
+
const thumb = slider?.querySelector?.('[role="slider"]');
|
|
975
|
+
if (!control || !thumb || !isVisible(control)) return null;
|
|
976
|
+
const levels = ['light', 'standard', 'extended', 'extra-high', 'pro'];
|
|
977
|
+
// The selected label leads localized aria-describedby prose, while punctuation
|
|
978
|
+
// and ordinal grammar vary by locale. Match only that leading label and let
|
|
979
|
+
// the thumb's numeric ARIA state independently prove its position.
|
|
980
|
+
const readLeadingLevel = (description) => {
|
|
981
|
+
const value = (description ?? '').normalize('NFC').trim();
|
|
982
|
+
if (!value) return null;
|
|
983
|
+
const matches = levels.flatMap((level, index) => {
|
|
984
|
+
const token = [...TARGET_LEVEL_TOKENS[level]]
|
|
985
|
+
.sort((left, right) => right.length - left.length)
|
|
986
|
+
.find((candidate) => {
|
|
987
|
+
const normalizedCandidate = candidate.normalize('NFC');
|
|
988
|
+
const prefix = value.slice(0, normalizedCandidate.length);
|
|
989
|
+
if (normalize(prefix) !== normalize(normalizedCandidate)) return false;
|
|
990
|
+
// normalize() erases Unicode letters and marks; inspect the boundary intact.
|
|
991
|
+
const suffix = value.slice(prefix.length);
|
|
992
|
+
return !suffix || /^[\\s\\p{P}]/u.test(suffix);
|
|
993
|
+
});
|
|
994
|
+
return token
|
|
995
|
+
? [{ level, index, label: value.slice(0, token.normalize('NFC').length) }]
|
|
996
|
+
: [];
|
|
997
|
+
});
|
|
998
|
+
return matches.length === 1 ? matches[0] : null;
|
|
999
|
+
};
|
|
1000
|
+
const selections = describedIds(control)
|
|
1001
|
+
.map((id) => readLeadingLevel(document.getElementById?.(id)?.textContent ?? ''))
|
|
1002
|
+
.filter(Boolean);
|
|
1003
|
+
if (selections.length !== 1) return null;
|
|
1004
|
+
const { label, index, level } = selections[0];
|
|
1005
|
+
// This adapter owns the observed five-tier layout only. A different range
|
|
1006
|
+
// or contradictory announcement must not turn a numeric guess into proof.
|
|
1007
|
+
if (thumb.getAttribute('aria-valuemin') !== '0' || thumb.getAttribute('aria-valuemax') !== '4' ||
|
|
1008
|
+
thumb.getAttribute('aria-valuenow') !== String(index)) return null;
|
|
1009
|
+
return { control, label, index, level };
|
|
1010
|
+
};
|
|
1011
|
+
let current = resolve();
|
|
1012
|
+
const finish = (result) => { closeOpenMenus(); return result; };
|
|
1013
|
+
// The picker can expose its simple view before the keyboard owner is mounted or visible.
|
|
1014
|
+
const readyDeadline = performance.now() + MAX_WAIT_MS;
|
|
1015
|
+
while (!current && performance.now() < readyDeadline) {
|
|
1016
|
+
await sleep(100);
|
|
1017
|
+
current = resolve();
|
|
1018
|
+
}
|
|
1019
|
+
if (!current) return finish(failure('selection-unverified'));
|
|
1020
|
+
// Preserve the legacy Pro-model + extended contract on unified pickers.
|
|
1021
|
+
const target = TARGET_MODEL_KIND === 'pro' && TARGET_LEVEL === 'extended' ? 'pro' : TARGET_LEVEL;
|
|
1022
|
+
const targetIndex = ['light', 'standard', 'extended', 'extra-high', 'pro'].indexOf(target);
|
|
1023
|
+
if (targetIndex < 0) {
|
|
1024
|
+
if (TARGET_IS_GPT56_MODEL && TARGET_LEVEL === 'heavy' && current.level === 'pro') {
|
|
1025
|
+
return finish({ status: 'already-selected', label: current.label });
|
|
1026
|
+
}
|
|
1027
|
+
return finish(failure('option-not-found'));
|
|
1028
|
+
}
|
|
1029
|
+
if (current.level === target) return finish({ status: 'already-selected', label: current.label });
|
|
1030
|
+
const deadline = performance.now() + MAX_WAIT_MS;
|
|
1031
|
+
for (let attempt = 0; attempt < 4 && performance.now() < deadline; attempt += 1) {
|
|
1032
|
+
if (isOptionDisabled(current.control)) return finish(failure('option-disabled', { label: current.label }));
|
|
1033
|
+
const previousIndex = current.index;
|
|
1034
|
+
const key = targetIndex > previousIndex ? 'ArrowRight' : 'ArrowLeft';
|
|
1035
|
+
current.control.focus?.();
|
|
1036
|
+
current.control.dispatchEvent(new KeyboardEvent('keydown', { key, code: key, bubbles: true, cancelable: true }));
|
|
1037
|
+
current = null;
|
|
1038
|
+
while (performance.now() < deadline) {
|
|
1039
|
+
await sleep(100);
|
|
1040
|
+
const next = resolve();
|
|
1041
|
+
if (next && next.index !== previousIndex) { current = next; break; }
|
|
1042
|
+
}
|
|
1043
|
+
if (!current) return finish(failure('selection-unverified'));
|
|
1044
|
+
if (current.level === target) return finish({ status: 'switched', label: current.label });
|
|
1045
|
+
}
|
|
1046
|
+
return finish(failure('selection-unverified'));
|
|
1047
|
+
};
|
|
1048
|
+
|
|
799
1049
|
// Current ChatGPT exposes a standalone Pro or Thinking composer pill whose
|
|
800
1050
|
// controlled menu contains the effort levels. Prefer this ownership boundary
|
|
801
1051
|
// before probing older model-picker layouts.
|
|
@@ -920,6 +1170,8 @@ function buildThinkingTimeExpression(level, desiredModel) {
|
|
|
920
1170
|
while (performance.now() < deadline) {
|
|
921
1171
|
const menu = findVisibleEffortMenu(composerEffortPill);
|
|
922
1172
|
if (menu) {
|
|
1173
|
+
const sliderResult = await selectDirectEffortSlider(menu);
|
|
1174
|
+
if (sliderResult) return sliderResult;
|
|
923
1175
|
const proEffortResult = await selectProEffortFromSubmenu();
|
|
924
1176
|
if (proEffortResult) {
|
|
925
1177
|
return proEffortResult;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { discoverDevToolsActivePortCandidates, } from "./detect.js";
|
|
1
|
+
import { discoverDevToolsActivePortCandidates, formatWebSocketHost, resolveDevToolsActivePortDiscoveryRoots, } from "./detect.js";
|
|
2
2
|
export async function resolveAttachRunningConnection(config, logger) {
|
|
3
3
|
const host = config.remoteChrome?.host ?? "127.0.0.1";
|
|
4
4
|
const port = config.remoteChrome?.port ?? 9222;
|
|
@@ -12,7 +12,17 @@ export async function resolveAttachRunningConnection(config, logger) {
|
|
|
12
12
|
.filter((candidate) => candidate.port === port)
|
|
13
13
|
.sort(compareDevToolsCandidates);
|
|
14
14
|
if (candidates.length === 0) {
|
|
15
|
-
|
|
15
|
+
const probe = await probeDevToolsBrowserWSEndpoint({ host, port });
|
|
16
|
+
if (probe.ok) {
|
|
17
|
+
return {
|
|
18
|
+
host,
|
|
19
|
+
port,
|
|
20
|
+
browserWSEndpoint: probe.browserWSEndpoint,
|
|
21
|
+
profileRoot: null,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const discoveryRoots = resolveDevToolsActivePortDiscoveryRoots();
|
|
25
|
+
throw new Error(`No running browser matched ${host}:${port}. DevToolsActivePort discovery searched ${discoveryRoots.join(", ") || "no roots"}, and endpoint probe http://${formatWebSocketHost(host)}:${port}/json/version failed: ${probe.error}.`);
|
|
16
26
|
}
|
|
17
27
|
const candidate = candidates[0];
|
|
18
28
|
logger(`Selected attach-running browser metadata from ${candidate.path}`);
|
|
@@ -23,6 +33,40 @@ export async function resolveAttachRunningConnection(config, logger) {
|
|
|
23
33
|
profileRoot: candidate.profileRoot,
|
|
24
34
|
};
|
|
25
35
|
}
|
|
36
|
+
async function probeDevToolsBrowserWSEndpoint({ host, port, attempts = 2, timeoutMs = 1000, }) {
|
|
37
|
+
const versionUrl = `http://${formatWebSocketHost(host)}:${port}/json/version`;
|
|
38
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
39
|
+
try {
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
42
|
+
try {
|
|
43
|
+
const response = await fetch(versionUrl, { signal: controller.signal });
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
throw new Error(`HTTP ${response.status}`);
|
|
46
|
+
}
|
|
47
|
+
const version = (await response.json());
|
|
48
|
+
if (typeof version.webSocketDebuggerUrl !== "string" || !version.webSocketDebuggerUrl) {
|
|
49
|
+
throw new Error("response did not include webSocketDebuggerUrl");
|
|
50
|
+
}
|
|
51
|
+
return { ok: true, browserWSEndpoint: version.webSocketDebuggerUrl };
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
// Headers can arrive before a stalled body; bound and clean up the whole request.
|
|
55
|
+
clearTimeout(timeout);
|
|
56
|
+
controller.abort();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (attempt < attempts - 1) {
|
|
61
|
+
await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1)));
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
65
|
+
return { ok: false, error: message };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { ok: false, error: "unreachable" };
|
|
69
|
+
}
|
|
26
70
|
function compareDevToolsCandidates(left, right) {
|
|
27
71
|
if (right.mtimeMs !== left.mtimeMs) {
|
|
28
72
|
return right.mtimeMs - left.mtimeMs;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
function compareStrings(left, right) {
|
|
3
|
+
if (left < right)
|
|
4
|
+
return -1;
|
|
5
|
+
if (left > right)
|
|
6
|
+
return 1;
|
|
7
|
+
return 0;
|
|
8
|
+
}
|
|
9
|
+
export function findAttachmentBasenameCollisions(attachments) {
|
|
10
|
+
const attachmentsByBasename = new Map();
|
|
11
|
+
for (const attachment of attachments) {
|
|
12
|
+
const basename = path.basename(attachment.path);
|
|
13
|
+
const matchingAttachments = attachmentsByBasename.get(basename) ?? [];
|
|
14
|
+
matchingAttachments.push(attachment);
|
|
15
|
+
attachmentsByBasename.set(basename, matchingAttachments);
|
|
16
|
+
}
|
|
17
|
+
return [...attachmentsByBasename.entries()]
|
|
18
|
+
.filter(([, matchingAttachments]) => matchingAttachments.length > 1)
|
|
19
|
+
.sort(([leftBasename], [rightBasename]) => compareStrings(leftBasename, rightBasename))
|
|
20
|
+
.map(([basename, matchingAttachments]) => ({
|
|
21
|
+
basename,
|
|
22
|
+
attachments: [...matchingAttachments].sort((left, right) => compareStrings(left.displayPath, right.displayPath) ||
|
|
23
|
+
compareStrings(left.path, right.path)),
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
export function buildAttachmentBasenameCollisionDetails(collisions, displayFile) {
|
|
27
|
+
const collisionDetails = collisions.map((collision) => ({
|
|
28
|
+
basename: collision.basename,
|
|
29
|
+
files: collision.attachments.map(displayFile),
|
|
30
|
+
}));
|
|
31
|
+
return {
|
|
32
|
+
collisions: collisionDetails,
|
|
33
|
+
files: collisionDetails.flatMap((collision) => collision.files),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function formatAttachmentBasenameCollisionMessage(subject, collisions) {
|
|
37
|
+
const collisionLines = collisions.length === 1
|
|
38
|
+
? [
|
|
39
|
+
`${subject} cannot safely include multiple files named "${collisions[0].basename}":`,
|
|
40
|
+
...collisions[0].files.map((file) => `- ${file}`),
|
|
41
|
+
]
|
|
42
|
+
: [
|
|
43
|
+
`${subject} cannot safely include files with colliding basenames:`,
|
|
44
|
+
...collisions.flatMap((collision) => [
|
|
45
|
+
`Files named "${collision.basename}":`,
|
|
46
|
+
...collision.files.map((file) => `- ${file}`),
|
|
47
|
+
]),
|
|
48
|
+
];
|
|
49
|
+
return [
|
|
50
|
+
...collisionLines,
|
|
51
|
+
"Use --browser-bundle-files (and --browser-bundle-format zip for raw or binary files), or choose unique names before retrying.",
|
|
52
|
+
].join("\n");
|
|
53
|
+
}
|