@indykish/oracle 0.9.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/LICENSE +21 -0
- package/README.md +215 -0
- package/assets-oracle-icon.png +0 -0
- package/dist/bin/oracle-cli.js +1252 -0
- package/dist/bin/oracle-mcp.js +6 -0
- package/dist/scripts/agent-send.js +147 -0
- package/dist/scripts/browser-tools.js +536 -0
- package/dist/scripts/check.js +21 -0
- package/dist/scripts/debug/extract-chatgpt-response.js +53 -0
- package/dist/scripts/docs-list.js +110 -0
- package/dist/scripts/git-policy.js +125 -0
- package/dist/scripts/run-cli.js +14 -0
- package/dist/scripts/runner.js +1378 -0
- package/dist/scripts/test-browser.js +103 -0
- package/dist/scripts/test-remote-chrome.js +68 -0
- package/dist/src/bridge/connection.js +103 -0
- package/dist/src/bridge/userConfigFile.js +28 -0
- package/dist/src/browser/actions/assistantResponse.js +1067 -0
- package/dist/src/browser/actions/attachmentDataTransfer.js +138 -0
- package/dist/src/browser/actions/attachments.js +1910 -0
- package/dist/src/browser/actions/domEvents.js +19 -0
- package/dist/src/browser/actions/modelSelection.js +485 -0
- package/dist/src/browser/actions/navigation.js +445 -0
- package/dist/src/browser/actions/promptComposer.js +485 -0
- package/dist/src/browser/actions/remoteFileTransfer.js +37 -0
- package/dist/src/browser/actions/thinkingTime.js +206 -0
- package/dist/src/browser/chromeLifecycle.js +344 -0
- package/dist/src/browser/config.js +103 -0
- package/dist/src/browser/constants.js +71 -0
- package/dist/src/browser/cookies.js +191 -0
- package/dist/src/browser/detect.js +164 -0
- package/dist/src/browser/domDebug.js +36 -0
- package/dist/src/browser/index.js +1741 -0
- package/dist/src/browser/modelStrategy.js +13 -0
- package/dist/src/browser/pageActions.js +5 -0
- package/dist/src/browser/policies.js +43 -0
- package/dist/src/browser/profileState.js +280 -0
- package/dist/src/browser/prompt.js +152 -0
- package/dist/src/browser/promptSummary.js +20 -0
- package/dist/src/browser/reattach.js +186 -0
- package/dist/src/browser/reattachHelpers.js +382 -0
- package/dist/src/browser/sessionRunner.js +119 -0
- package/dist/src/browser/types.js +1 -0
- package/dist/src/browser/utils.js +122 -0
- package/dist/src/browserMode.js +1 -0
- package/dist/src/cli/bridge/claudeConfig.js +54 -0
- package/dist/src/cli/bridge/client.js +73 -0
- package/dist/src/cli/bridge/codexConfig.js +43 -0
- package/dist/src/cli/bridge/doctor.js +107 -0
- package/dist/src/cli/bridge/host.js +259 -0
- package/dist/src/cli/browserConfig.js +278 -0
- package/dist/src/cli/browserDefaults.js +81 -0
- package/dist/src/cli/bundleWarnings.js +9 -0
- package/dist/src/cli/clipboard.js +10 -0
- package/dist/src/cli/detach.js +11 -0
- package/dist/src/cli/dryRun.js +105 -0
- package/dist/src/cli/duplicatePromptGuard.js +14 -0
- package/dist/src/cli/engine.js +41 -0
- package/dist/src/cli/errorUtils.js +9 -0
- package/dist/src/cli/format.js +13 -0
- package/dist/src/cli/help.js +77 -0
- package/dist/src/cli/hiddenAliases.js +22 -0
- package/dist/src/cli/markdownBundle.js +17 -0
- package/dist/src/cli/markdownRenderer.js +97 -0
- package/dist/src/cli/notifier.js +306 -0
- package/dist/src/cli/options.js +281 -0
- package/dist/src/cli/oscUtils.js +2 -0
- package/dist/src/cli/promptRequirement.js +17 -0
- package/dist/src/cli/renderFlags.js +9 -0
- package/dist/src/cli/renderOutput.js +26 -0
- package/dist/src/cli/rootAlias.js +30 -0
- package/dist/src/cli/runOptions.js +78 -0
- package/dist/src/cli/sessionCommand.js +111 -0
- package/dist/src/cli/sessionDisplay.js +567 -0
- package/dist/src/cli/sessionRunner.js +602 -0
- package/dist/src/cli/sessionTable.js +92 -0
- package/dist/src/cli/tagline.js +258 -0
- package/dist/src/cli/tui/index.js +486 -0
- package/dist/src/cli/writeOutputPath.js +21 -0
- package/dist/src/config.js +26 -0
- package/dist/src/gemini-web/client.js +328 -0
- package/dist/src/gemini-web/executor.js +285 -0
- package/dist/src/gemini-web/index.js +1 -0
- package/dist/src/gemini-web/types.js +1 -0
- package/dist/src/heartbeat.js +43 -0
- package/dist/src/mcp/server.js +40 -0
- package/dist/src/mcp/tools/consult.js +290 -0
- package/dist/src/mcp/tools/sessionResources.js +75 -0
- package/dist/src/mcp/tools/sessions.js +105 -0
- package/dist/src/mcp/types.js +22 -0
- package/dist/src/mcp/utils.js +37 -0
- package/dist/src/oracle/background.js +141 -0
- package/dist/src/oracle/claude.js +101 -0
- package/dist/src/oracle/client.js +197 -0
- package/dist/src/oracle/config.js +227 -0
- package/dist/src/oracle/errors.js +132 -0
- package/dist/src/oracle/files.js +378 -0
- package/dist/src/oracle/finishLine.js +32 -0
- package/dist/src/oracle/format.js +30 -0
- package/dist/src/oracle/fsAdapter.js +10 -0
- package/dist/src/oracle/gemini.js +195 -0
- package/dist/src/oracle/logging.js +36 -0
- package/dist/src/oracle/markdown.js +46 -0
- package/dist/src/oracle/modelResolver.js +183 -0
- package/dist/src/oracle/multiModelRunner.js +153 -0
- package/dist/src/oracle/oscProgress.js +24 -0
- package/dist/src/oracle/promptAssembly.js +13 -0
- package/dist/src/oracle/request.js +50 -0
- package/dist/src/oracle/run.js +596 -0
- package/dist/src/oracle/runUtils.js +31 -0
- package/dist/src/oracle/tokenEstimate.js +37 -0
- package/dist/src/oracle/tokenStats.js +39 -0
- package/dist/src/oracle/tokenStringifier.js +24 -0
- package/dist/src/oracle/types.js +1 -0
- package/dist/src/oracle.js +12 -0
- package/dist/src/oracleHome.js +13 -0
- package/dist/src/remote/client.js +129 -0
- package/dist/src/remote/health.js +113 -0
- package/dist/src/remote/remoteServiceConfig.js +31 -0
- package/dist/src/remote/server.js +533 -0
- package/dist/src/remote/types.js +1 -0
- package/dist/src/sessionManager.js +637 -0
- package/dist/src/sessionStore.js +56 -0
- package/dist/src/version.js +39 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.swift +45 -0
- package/dist/vendor/oracle-notifier/README.md +24 -0
- package/dist/vendor/oracle-notifier/build-notifier.sh +93 -0
- package/package.json +115 -0
- package/vendor/oracle-notifier/OracleNotifier.swift +45 -0
- package/vendor/oracle-notifier/README.md +24 -0
- package/vendor/oracle-notifier/build-notifier.sh +93 -0
|
@@ -0,0 +1,1067 @@
|
|
|
1
|
+
import { ANSWER_SELECTORS, ASSISTANT_ROLE_SELECTOR, CONVERSATION_TURN_SELECTOR, COPY_BUTTON_SELECTOR, FINISHED_ACTIONS_SELECTOR, STOP_BUTTON_SELECTOR, } from '../constants.js';
|
|
2
|
+
import { delay } from '../utils.js';
|
|
3
|
+
import { logDomFailure, logConversationSnapshot, buildConversationDebugExpression } from '../domDebug.js';
|
|
4
|
+
import { buildClickDispatcher } from './domEvents.js';
|
|
5
|
+
const ASSISTANT_POLL_TIMEOUT_ERROR = 'assistant-response-watchdog-timeout';
|
|
6
|
+
function isAnswerNowPlaceholderText(normalized) {
|
|
7
|
+
const text = normalized.trim();
|
|
8
|
+
if (!text)
|
|
9
|
+
return false;
|
|
10
|
+
// Learned: "Pro thinking" shows a placeholder turn that contains "Answer now".
|
|
11
|
+
// That is not the final answer and must be ignored in browser automation.
|
|
12
|
+
if (text === 'chatgpt said:' || text === 'chatgpt said')
|
|
13
|
+
return true;
|
|
14
|
+
if (text.includes('file upload request') && (text.includes('pro thinking') || text.includes('chatgpt said'))) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
return text.includes('answer now') && (text.includes('pro thinking') || text.includes('chatgpt said'));
|
|
18
|
+
}
|
|
19
|
+
export async function waitForAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex) {
|
|
20
|
+
const start = Date.now();
|
|
21
|
+
logger('Waiting for ChatGPT response');
|
|
22
|
+
// Learned: two paths are needed:
|
|
23
|
+
// 1) DOM observer (fast when mutations fire),
|
|
24
|
+
// 2) snapshot poller (fallback when observers miss or JS stalls).
|
|
25
|
+
const expression = buildResponseObserverExpression(timeoutMs, minTurnIndex);
|
|
26
|
+
const evaluationPromise = Runtime.evaluate({ expression, awaitPromise: true, returnByValue: true });
|
|
27
|
+
const raceReadyEvaluation = evaluationPromise.then((value) => ({ kind: 'evaluation', value }), (error) => {
|
|
28
|
+
throw { source: 'evaluation', error };
|
|
29
|
+
});
|
|
30
|
+
// Use AbortController to stop the poller when the evaluation wins the race,
|
|
31
|
+
// preventing abandoned polling loops from consuming resources.
|
|
32
|
+
const pollerAbort = new AbortController();
|
|
33
|
+
const pollerPromise = pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex, pollerAbort.signal).then((value) => ({ kind: 'poll', value }), (error) => {
|
|
34
|
+
throw { source: 'poll', error };
|
|
35
|
+
});
|
|
36
|
+
let evaluation = null;
|
|
37
|
+
try {
|
|
38
|
+
const winner = await Promise.race([raceReadyEvaluation, pollerPromise]);
|
|
39
|
+
if (winner.kind === 'poll') {
|
|
40
|
+
if (!winner.value) {
|
|
41
|
+
throw { source: 'poll', error: new Error(ASSISTANT_POLL_TIMEOUT_ERROR) };
|
|
42
|
+
}
|
|
43
|
+
logger('Captured assistant response via snapshot watchdog');
|
|
44
|
+
evaluationPromise.catch(() => undefined);
|
|
45
|
+
await terminateRuntimeExecution(Runtime);
|
|
46
|
+
return winner.value;
|
|
47
|
+
}
|
|
48
|
+
// Evaluation won - abort the poller to prevent it from running until timeout
|
|
49
|
+
pollerAbort.abort();
|
|
50
|
+
evaluation = winner.value;
|
|
51
|
+
}
|
|
52
|
+
catch (wrappedError) {
|
|
53
|
+
if (wrappedError && typeof wrappedError === 'object' && 'source' in wrappedError && 'error' in wrappedError) {
|
|
54
|
+
const { source, error } = wrappedError;
|
|
55
|
+
if (source === 'poll' && error instanceof Error && error.message === ASSISTANT_POLL_TIMEOUT_ERROR) {
|
|
56
|
+
evaluation = await evaluationPromise;
|
|
57
|
+
}
|
|
58
|
+
else if (source === 'poll') {
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
else if (source === 'evaluation') {
|
|
62
|
+
const recovered = await recoverAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex);
|
|
63
|
+
if (recovered) {
|
|
64
|
+
return recovered;
|
|
65
|
+
}
|
|
66
|
+
await logDomFailure(Runtime, logger, 'assistant-response');
|
|
67
|
+
throw error ?? new Error('Failed to capture assistant response');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
throw wrappedError;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (!evaluation) {
|
|
75
|
+
await logDomFailure(Runtime, logger, 'assistant-response');
|
|
76
|
+
throw new Error('Failed to capture assistant response');
|
|
77
|
+
}
|
|
78
|
+
const parsed = await parseAssistantEvaluationResult(Runtime, evaluation, logger);
|
|
79
|
+
if (!parsed) {
|
|
80
|
+
let remainingMs = Math.max(0, timeoutMs - (Date.now() - start));
|
|
81
|
+
if (remainingMs > 0) {
|
|
82
|
+
const recovered = await recoverAssistantResponse(Runtime, remainingMs, logger, minTurnIndex);
|
|
83
|
+
if (recovered) {
|
|
84
|
+
return recovered;
|
|
85
|
+
}
|
|
86
|
+
remainingMs = Math.max(0, timeoutMs - (Date.now() - start));
|
|
87
|
+
if (remainingMs > 0) {
|
|
88
|
+
const polled = await Promise.race([
|
|
89
|
+
pollerPromise.catch(() => null),
|
|
90
|
+
delay(remainingMs).then(() => null),
|
|
91
|
+
]);
|
|
92
|
+
if (polled && polled.kind === 'poll' && polled.value) {
|
|
93
|
+
return polled.value;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
await logDomFailure(Runtime, logger, 'assistant-response');
|
|
98
|
+
throw new Error('Unable to capture assistant response');
|
|
99
|
+
}
|
|
100
|
+
const refreshed = await refreshAssistantSnapshot(Runtime, parsed, logger, minTurnIndex);
|
|
101
|
+
const candidate = refreshed ?? parsed;
|
|
102
|
+
// The evaluation path can race ahead of completion. If ChatGPT is still streaming, wait for the watchdog poller.
|
|
103
|
+
const elapsedMs = Date.now() - start;
|
|
104
|
+
const remainingMs = Math.max(0, timeoutMs - elapsedMs);
|
|
105
|
+
if (remainingMs > 0) {
|
|
106
|
+
const [stopVisible, completionVisible] = await Promise.all([
|
|
107
|
+
isStopButtonVisible(Runtime),
|
|
108
|
+
isCompletionVisible(Runtime),
|
|
109
|
+
]);
|
|
110
|
+
if (stopVisible) {
|
|
111
|
+
logger('Assistant still generating; waiting for completion');
|
|
112
|
+
const completed = await pollAssistantCompletion(Runtime, remainingMs, minTurnIndex);
|
|
113
|
+
if (completed) {
|
|
114
|
+
return completed;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
else if (completionVisible) {
|
|
118
|
+
// No-op: completion UI surfaced and stop button is gone.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return candidate;
|
|
122
|
+
}
|
|
123
|
+
export async function readAssistantSnapshot(Runtime, minTurnIndex) {
|
|
124
|
+
const { result } = await Runtime.evaluate({
|
|
125
|
+
expression: buildAssistantSnapshotExpression(minTurnIndex),
|
|
126
|
+
returnByValue: true,
|
|
127
|
+
});
|
|
128
|
+
const value = result?.value;
|
|
129
|
+
if (value && typeof value === 'object') {
|
|
130
|
+
const snapshot = value;
|
|
131
|
+
if (typeof minTurnIndex === 'number' && Number.isFinite(minTurnIndex)) {
|
|
132
|
+
const turnIndex = typeof snapshot.turnIndex === 'number' ? snapshot.turnIndex : null;
|
|
133
|
+
if (turnIndex === null) {
|
|
134
|
+
return snapshot;
|
|
135
|
+
}
|
|
136
|
+
if (turnIndex < minTurnIndex) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return snapshot;
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
export async function captureAssistantMarkdown(Runtime, meta, logger) {
|
|
145
|
+
const { result } = await Runtime.evaluate({
|
|
146
|
+
expression: buildCopyExpression(meta),
|
|
147
|
+
returnByValue: true,
|
|
148
|
+
awaitPromise: true,
|
|
149
|
+
});
|
|
150
|
+
if (result?.value?.success && typeof result.value.markdown === 'string') {
|
|
151
|
+
return result.value.markdown;
|
|
152
|
+
}
|
|
153
|
+
const status = result?.value?.status;
|
|
154
|
+
if (status && status !== 'missing-button') {
|
|
155
|
+
logger(`Copy button fallback status: ${status}`);
|
|
156
|
+
await logDomFailure(Runtime, logger, 'copy-markdown');
|
|
157
|
+
}
|
|
158
|
+
if (!status) {
|
|
159
|
+
await logDomFailure(Runtime, logger, 'copy-markdown');
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
export function buildAssistantExtractorForTest(name) {
|
|
164
|
+
return buildAssistantExtractor(name);
|
|
165
|
+
}
|
|
166
|
+
export function buildConversationDebugExpressionForTest() {
|
|
167
|
+
return buildConversationDebugExpression();
|
|
168
|
+
}
|
|
169
|
+
export function buildMarkdownFallbackExtractorForTest(minTurnLiteral = '0') {
|
|
170
|
+
return buildMarkdownFallbackExtractor(minTurnLiteral);
|
|
171
|
+
}
|
|
172
|
+
export function buildCopyExpressionForTest(meta = {}) {
|
|
173
|
+
return buildCopyExpression(meta);
|
|
174
|
+
}
|
|
175
|
+
async function recoverAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex) {
|
|
176
|
+
const recoveryTimeoutMs = Math.max(0, timeoutMs);
|
|
177
|
+
if (recoveryTimeoutMs === 0) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
const recovered = await waitForCondition(async () => {
|
|
181
|
+
const snapshot = await readAssistantSnapshot(Runtime, minTurnIndex);
|
|
182
|
+
return normalizeAssistantSnapshot(snapshot);
|
|
183
|
+
}, recoveryTimeoutMs, 400);
|
|
184
|
+
if (recovered) {
|
|
185
|
+
logger('Recovered assistant response via polling fallback');
|
|
186
|
+
return recovered;
|
|
187
|
+
}
|
|
188
|
+
await logConversationSnapshot(Runtime, logger).catch(() => undefined);
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
async function parseAssistantEvaluationResult(_Runtime, evaluation, _logger) {
|
|
192
|
+
const { result } = evaluation;
|
|
193
|
+
if (result.type === 'object' && result.value && typeof result.value === 'object' && 'text' in result.value) {
|
|
194
|
+
const html = typeof result.value.html === 'string'
|
|
195
|
+
? (result.value.html ?? undefined)
|
|
196
|
+
: undefined;
|
|
197
|
+
const turnId = typeof result.value.turnId === 'string'
|
|
198
|
+
? (result.value.turnId ?? undefined)
|
|
199
|
+
: undefined;
|
|
200
|
+
const messageId = typeof result.value.messageId === 'string'
|
|
201
|
+
? (result.value.messageId ?? undefined)
|
|
202
|
+
: undefined;
|
|
203
|
+
const text = cleanAssistantText(String(result.value.text ?? ''));
|
|
204
|
+
const normalized = text.toLowerCase();
|
|
205
|
+
if (isAnswerNowPlaceholderText(normalized)) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
return { text, html, meta: { turnId, messageId } };
|
|
209
|
+
}
|
|
210
|
+
const fallbackText = typeof result.value === 'string' ? cleanAssistantText(result.value) : '';
|
|
211
|
+
if (!fallbackText) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
if (isAnswerNowPlaceholderText(fallbackText.toLowerCase())) {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
return { text: fallbackText, html: undefined, meta: {} };
|
|
218
|
+
}
|
|
219
|
+
async function refreshAssistantSnapshot(Runtime, current, logger, minTurnIndex) {
|
|
220
|
+
const deadline = Date.now() + 5_000;
|
|
221
|
+
let best = null;
|
|
222
|
+
let stableCycles = 0;
|
|
223
|
+
const stableTarget = 3;
|
|
224
|
+
while (Date.now() < deadline) {
|
|
225
|
+
// Learned: short/fast answers can race; poll a few extra cycles to pick up messageId + full text.
|
|
226
|
+
const latestSnapshot = await readAssistantSnapshot(Runtime, minTurnIndex).catch(() => null);
|
|
227
|
+
const latest = normalizeAssistantSnapshot(latestSnapshot);
|
|
228
|
+
if (latest) {
|
|
229
|
+
if (!best ||
|
|
230
|
+
latest.text.length > best.text.length ||
|
|
231
|
+
(!best.meta.messageId && latest.meta.messageId)) {
|
|
232
|
+
best = latest;
|
|
233
|
+
stableCycles = 0;
|
|
234
|
+
}
|
|
235
|
+
else if (latest.text.trim() === best.text.trim()) {
|
|
236
|
+
stableCycles += 1;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (best && stableCycles >= stableTarget) {
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
await delay(300);
|
|
243
|
+
}
|
|
244
|
+
if (!best) {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
const currentLength = cleanAssistantText(current.text).trim().length;
|
|
248
|
+
const latestLength = best.text.length;
|
|
249
|
+
const hasBetterId = !current.meta?.messageId && Boolean(best.meta.messageId);
|
|
250
|
+
const isLonger = latestLength > currentLength;
|
|
251
|
+
const hasDifferentText = best.text.trim() !== current.text.trim();
|
|
252
|
+
if (isLonger || hasBetterId || hasDifferentText) {
|
|
253
|
+
logger('Refreshed assistant response via latest snapshot');
|
|
254
|
+
return best;
|
|
255
|
+
}
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
async function terminateRuntimeExecution(Runtime) {
|
|
259
|
+
if (typeof Runtime.terminateExecution !== 'function') {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
await Runtime.terminateExecution();
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
// ignore termination failures
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
async function pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex, abortSignal) {
|
|
270
|
+
const watchdogDeadline = Date.now() + timeoutMs;
|
|
271
|
+
let previousLength = 0;
|
|
272
|
+
let stableCycles = 0;
|
|
273
|
+
let lastChangeAt = Date.now();
|
|
274
|
+
while (Date.now() < watchdogDeadline) {
|
|
275
|
+
// Check abort signal to stop polling when another path won the race
|
|
276
|
+
if (abortSignal?.aborted) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
const snapshot = await readAssistantSnapshot(Runtime, minTurnIndex);
|
|
280
|
+
const normalized = normalizeAssistantSnapshot(snapshot);
|
|
281
|
+
if (normalized) {
|
|
282
|
+
const currentLength = normalized.text.length;
|
|
283
|
+
if (currentLength > previousLength) {
|
|
284
|
+
previousLength = currentLength;
|
|
285
|
+
stableCycles = 0;
|
|
286
|
+
lastChangeAt = Date.now();
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
stableCycles += 1;
|
|
290
|
+
}
|
|
291
|
+
const [stopVisible, completionVisible] = await Promise.all([
|
|
292
|
+
isStopButtonVisible(Runtime),
|
|
293
|
+
isCompletionVisible(Runtime),
|
|
294
|
+
]);
|
|
295
|
+
const shortAnswer = currentLength > 0 && currentLength < 16;
|
|
296
|
+
const mediumAnswer = currentLength >= 16 && currentLength < 40;
|
|
297
|
+
const longAnswer = currentLength >= 40 && currentLength < 500;
|
|
298
|
+
// Learned: short answers need a longer stability window or they truncate.
|
|
299
|
+
// Learned: long streaming responses (esp. thinking models) can pause mid-stream;
|
|
300
|
+
// use progressively longer windows to avoid truncation (#71).
|
|
301
|
+
const completionStableTarget = shortAnswer ? 12 : mediumAnswer ? 8 : longAnswer ? 6 : 8;
|
|
302
|
+
const requiredStableCycles = shortAnswer ? 12 : mediumAnswer ? 8 : longAnswer ? 8 : 10;
|
|
303
|
+
const stableMs = Date.now() - lastChangeAt;
|
|
304
|
+
const minStableMs = shortAnswer ? 8000 : mediumAnswer ? 1200 : longAnswer ? 2000 : 3000;
|
|
305
|
+
// Require stop button to disappear before treating completion as final.
|
|
306
|
+
if (!stopVisible) {
|
|
307
|
+
const stableEnough = stableCycles >= requiredStableCycles && stableMs >= minStableMs;
|
|
308
|
+
const completionEnough = completionVisible && stableCycles >= completionStableTarget && stableMs >= minStableMs;
|
|
309
|
+
if (completionEnough || stableEnough) {
|
|
310
|
+
return normalized;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
previousLength = 0;
|
|
316
|
+
stableCycles = 0;
|
|
317
|
+
}
|
|
318
|
+
await delay(400);
|
|
319
|
+
}
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
async function isStopButtonVisible(Runtime) {
|
|
323
|
+
try {
|
|
324
|
+
const { result } = await Runtime.evaluate({
|
|
325
|
+
expression: `Boolean(document.querySelector('${STOP_BUTTON_SELECTOR}'))`,
|
|
326
|
+
returnByValue: true,
|
|
327
|
+
});
|
|
328
|
+
return Boolean(result?.value);
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
async function isCompletionVisible(Runtime) {
|
|
335
|
+
try {
|
|
336
|
+
const { result } = await Runtime.evaluate({
|
|
337
|
+
expression: `(() => {
|
|
338
|
+
// Find the LAST assistant turn to check completion status
|
|
339
|
+
// Must match the same logic as buildAssistantExtractor for consistency
|
|
340
|
+
const ASSISTANT_SELECTOR = '${ASSISTANT_ROLE_SELECTOR}';
|
|
341
|
+
const isAssistantTurn = (node) => {
|
|
342
|
+
if (!(node instanceof HTMLElement)) return false;
|
|
343
|
+
const turnAttr = (node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
|
|
344
|
+
if (turnAttr === 'assistant') return true;
|
|
345
|
+
const role = (node.getAttribute('data-message-author-role') || node.dataset?.messageAuthorRole || '').toLowerCase();
|
|
346
|
+
if (role === 'assistant') return true;
|
|
347
|
+
const testId = (node.getAttribute('data-testid') || '').toLowerCase();
|
|
348
|
+
if (testId.includes('assistant')) return true;
|
|
349
|
+
return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const turns = Array.from(document.querySelectorAll('${CONVERSATION_TURN_SELECTOR}'));
|
|
353
|
+
let lastAssistantTurn = null;
|
|
354
|
+
for (let i = turns.length - 1; i >= 0; i--) {
|
|
355
|
+
if (isAssistantTurn(turns[i])) {
|
|
356
|
+
lastAssistantTurn = turns[i];
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (!lastAssistantTurn) {
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
// Check if the last assistant turn has finished action buttons (copy, thumbs up/down, share)
|
|
364
|
+
if (lastAssistantTurn.querySelector('${FINISHED_ACTIONS_SELECTOR}')) {
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
// Also check for "Done" text in the last assistant turn's markdown
|
|
368
|
+
const markdowns = lastAssistantTurn.querySelectorAll('.markdown');
|
|
369
|
+
return Array.from(markdowns).some((n) => (n.textContent || '').trim() === 'Done');
|
|
370
|
+
})()`,
|
|
371
|
+
returnByValue: true,
|
|
372
|
+
});
|
|
373
|
+
return Boolean(result?.value);
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
function normalizeAssistantSnapshot(snapshot) {
|
|
380
|
+
const text = snapshot?.text ? cleanAssistantText(snapshot.text) : '';
|
|
381
|
+
if (!text.trim()) {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
const normalized = text.toLowerCase();
|
|
385
|
+
// "Pro thinking" often renders a placeholder turn containing an "Answer now" gate.
|
|
386
|
+
// Treat it as incomplete so browser mode keeps waiting for the real assistant text.
|
|
387
|
+
if (isAnswerNowPlaceholderText(normalized)) {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
// Ignore user echo turns that can show up in project view fallbacks.
|
|
391
|
+
if (normalized.startsWith('you said')) {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
text,
|
|
396
|
+
html: snapshot?.html ?? undefined,
|
|
397
|
+
meta: { turnId: snapshot?.turnId ?? undefined, messageId: snapshot?.messageId ?? undefined },
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
async function waitForCondition(getter, timeoutMs, pollIntervalMs = 400) {
|
|
401
|
+
const deadline = Date.now() + timeoutMs;
|
|
402
|
+
while (Date.now() < deadline) {
|
|
403
|
+
const value = await getter();
|
|
404
|
+
if (value) {
|
|
405
|
+
return value;
|
|
406
|
+
}
|
|
407
|
+
await delay(pollIntervalMs);
|
|
408
|
+
}
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
function buildAssistantSnapshotExpression(minTurnIndex) {
|
|
412
|
+
const minTurnLiteral = typeof minTurnIndex === 'number' && Number.isFinite(minTurnIndex) && minTurnIndex >= 0
|
|
413
|
+
? Math.floor(minTurnIndex)
|
|
414
|
+
: -1;
|
|
415
|
+
return `(() => {
|
|
416
|
+
const MIN_TURN_INDEX = ${minTurnLiteral};
|
|
417
|
+
// Learned: the default turn DOM misses project view; keep a fallback extractor.
|
|
418
|
+
${buildAssistantExtractor('extractAssistantTurn')}
|
|
419
|
+
const extracted = extractAssistantTurn();
|
|
420
|
+
const isPlaceholder = (snapshot) => {
|
|
421
|
+
const normalized = String(snapshot?.text ?? '').toLowerCase().trim();
|
|
422
|
+
if (normalized === 'chatgpt said:' || normalized === 'chatgpt said') return true;
|
|
423
|
+
if (normalized.includes('file upload request') && (normalized.includes('pro thinking') || normalized.includes('chatgpt said'))) {
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
return normalized.includes('answer now') && (normalized.includes('pro thinking') || normalized.includes('chatgpt said'));
|
|
427
|
+
};
|
|
428
|
+
if (extracted && extracted.text && !isPlaceholder(extracted)) {
|
|
429
|
+
return extracted;
|
|
430
|
+
}
|
|
431
|
+
// Fallback for ChatGPT project view: answers can live outside conversation turns.
|
|
432
|
+
const fallback = ${buildMarkdownFallbackExtractor('MIN_TURN_INDEX')};
|
|
433
|
+
return fallback ?? extracted;
|
|
434
|
+
})()`;
|
|
435
|
+
}
|
|
436
|
+
function buildResponseObserverExpression(timeoutMs, minTurnIndex) {
|
|
437
|
+
const selectorsLiteral = JSON.stringify(ANSWER_SELECTORS);
|
|
438
|
+
const conversationLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
|
|
439
|
+
const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
|
|
440
|
+
const minTurnLiteral = typeof minTurnIndex === 'number' && Number.isFinite(minTurnIndex) && minTurnIndex >= 0
|
|
441
|
+
? Math.floor(minTurnIndex)
|
|
442
|
+
: -1;
|
|
443
|
+
return `(() => {
|
|
444
|
+
${buildClickDispatcher()}
|
|
445
|
+
const SELECTORS = ${selectorsLiteral};
|
|
446
|
+
const STOP_SELECTOR = '${STOP_BUTTON_SELECTOR}';
|
|
447
|
+
const FINISHED_SELECTOR = '${FINISHED_ACTIONS_SELECTOR}';
|
|
448
|
+
const CONVERSATION_SELECTOR = ${conversationLiteral};
|
|
449
|
+
const ASSISTANT_SELECTOR = ${assistantLiteral};
|
|
450
|
+
// Learned: settling avoids capturing mid-stream HTML; keep short.
|
|
451
|
+
const settleDelayMs = 800;
|
|
452
|
+
const isAnswerNowPlaceholder = (snapshot) => {
|
|
453
|
+
const normalized = String(snapshot?.text ?? '').toLowerCase().trim();
|
|
454
|
+
if (normalized === 'chatgpt said:' || normalized === 'chatgpt said') return true;
|
|
455
|
+
if (normalized.includes('file upload request') && (normalized.includes('pro thinking') || normalized.includes('chatgpt said'))) {
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
return normalized.includes('answer now') && (normalized.includes('pro thinking') || normalized.includes('chatgpt said'));
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
// Helper to detect assistant turns - must match buildAssistantExtractor logic for consistency.
|
|
462
|
+
const isAssistantTurn = (node) => {
|
|
463
|
+
if (!(node instanceof HTMLElement)) return false;
|
|
464
|
+
const turnAttr = (node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
|
|
465
|
+
if (turnAttr === 'assistant') return true;
|
|
466
|
+
const role = (node.getAttribute('data-message-author-role') || node.dataset?.messageAuthorRole || '').toLowerCase();
|
|
467
|
+
if (role === 'assistant') return true;
|
|
468
|
+
const testId = (node.getAttribute('data-testid') || '').toLowerCase();
|
|
469
|
+
if (testId.includes('assistant')) return true;
|
|
470
|
+
return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
const MIN_TURN_INDEX = ${minTurnLiteral};
|
|
474
|
+
${buildAssistantExtractor('extractFromTurns')}
|
|
475
|
+
// Learned: some layouts (project view) render markdown without assistant turn wrappers.
|
|
476
|
+
const extractFromMarkdownFallback = ${buildMarkdownFallbackExtractor('MIN_TURN_INDEX')};
|
|
477
|
+
|
|
478
|
+
const acceptSnapshot = (snapshot) => {
|
|
479
|
+
if (!snapshot) return null;
|
|
480
|
+
const index = typeof snapshot.turnIndex === 'number' ? snapshot.turnIndex : -1;
|
|
481
|
+
if (MIN_TURN_INDEX >= 0) {
|
|
482
|
+
if (index < 0 || index < MIN_TURN_INDEX) {
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return snapshot;
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const captureViaObserver = () =>
|
|
490
|
+
new Promise((resolve, reject) => {
|
|
491
|
+
const deadline = Date.now() + ${timeoutMs};
|
|
492
|
+
let stopInterval = null;
|
|
493
|
+
let timeoutId = null;
|
|
494
|
+
let cleanedUp = false;
|
|
495
|
+
let observer = null;
|
|
496
|
+
|
|
497
|
+
// Centralized cleanup to prevent resource leaks
|
|
498
|
+
const cleanup = () => {
|
|
499
|
+
if (cleanedUp) return;
|
|
500
|
+
cleanedUp = true;
|
|
501
|
+
if (stopInterval) {
|
|
502
|
+
clearInterval(stopInterval);
|
|
503
|
+
stopInterval = null;
|
|
504
|
+
}
|
|
505
|
+
if (timeoutId) {
|
|
506
|
+
clearTimeout(timeoutId);
|
|
507
|
+
timeoutId = null;
|
|
508
|
+
}
|
|
509
|
+
if (observer) {
|
|
510
|
+
try {
|
|
511
|
+
observer.disconnect();
|
|
512
|
+
} catch {
|
|
513
|
+
// ignore disconnect errors
|
|
514
|
+
}
|
|
515
|
+
observer = null;
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
const observerCallback = () => {
|
|
520
|
+
if (cleanedUp) return;
|
|
521
|
+
try {
|
|
522
|
+
const extractedRaw = extractFromTurns();
|
|
523
|
+
const extractedCandidate =
|
|
524
|
+
extractedRaw && !isAnswerNowPlaceholder(extractedRaw) ? extractedRaw : null;
|
|
525
|
+
let extracted = acceptSnapshot(extractedCandidate);
|
|
526
|
+
if (!extracted) {
|
|
527
|
+
const fallbackRaw = extractFromMarkdownFallback();
|
|
528
|
+
const fallbackCandidate =
|
|
529
|
+
fallbackRaw && !isAnswerNowPlaceholder(fallbackRaw) ? fallbackRaw : null;
|
|
530
|
+
extracted = acceptSnapshot(fallbackCandidate);
|
|
531
|
+
}
|
|
532
|
+
if (extracted) {
|
|
533
|
+
cleanup();
|
|
534
|
+
resolve(extracted);
|
|
535
|
+
} else if (Date.now() > deadline) {
|
|
536
|
+
cleanup();
|
|
537
|
+
reject(new Error('Response timeout'));
|
|
538
|
+
}
|
|
539
|
+
} catch (error) {
|
|
540
|
+
cleanup();
|
|
541
|
+
reject(error);
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
observer = new MutationObserver(observerCallback);
|
|
546
|
+
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
|
547
|
+
|
|
548
|
+
stopInterval = setInterval(() => {
|
|
549
|
+
if (cleanedUp) return;
|
|
550
|
+
const stop = document.querySelector(STOP_SELECTOR);
|
|
551
|
+
if (!stop) {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
const isStopButton =
|
|
555
|
+
stop.getAttribute('data-testid') === 'stop-button' || stop.getAttribute('aria-label')?.toLowerCase()?.includes('stop');
|
|
556
|
+
if (isStopButton) {
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
dispatchClickSequence(stop);
|
|
560
|
+
}, 500);
|
|
561
|
+
|
|
562
|
+
timeoutId = setTimeout(() => {
|
|
563
|
+
cleanup();
|
|
564
|
+
reject(new Error('Response timeout'));
|
|
565
|
+
}, ${timeoutMs});
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
// Check if the last assistant turn has finished (scoped to avoid detecting old turns).
|
|
569
|
+
const isLastAssistantTurnFinished = () => {
|
|
570
|
+
const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
|
|
571
|
+
let lastAssistantTurn = null;
|
|
572
|
+
for (let i = turns.length - 1; i >= 0; i--) {
|
|
573
|
+
if (isAssistantTurn(turns[i])) {
|
|
574
|
+
lastAssistantTurn = turns[i];
|
|
575
|
+
break;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (!lastAssistantTurn) return false;
|
|
579
|
+
// Check for action buttons in this specific turn
|
|
580
|
+
if (lastAssistantTurn.querySelector(FINISHED_SELECTOR)) return true;
|
|
581
|
+
// Check for "Done" text in this turn's markdown
|
|
582
|
+
const markdowns = lastAssistantTurn.querySelectorAll('.markdown');
|
|
583
|
+
return Array.from(markdowns).some((n) => (n.textContent || '').trim() === 'Done');
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
const waitForSettle = async (snapshot) => {
|
|
587
|
+
// Learned: short answers can be 1-2 tokens; enforce longer settle windows to avoid truncation.
|
|
588
|
+
// Learned: long streaming responses (esp. thinking models) can pause mid-stream;
|
|
589
|
+
// use progressively longer windows to avoid truncation (#71).
|
|
590
|
+
const initialLength = snapshot?.text?.length ?? 0;
|
|
591
|
+
const shortAnswer = initialLength > 0 && initialLength < 16;
|
|
592
|
+
const mediumAnswer = initialLength >= 16 && initialLength < 40;
|
|
593
|
+
const longAnswer = initialLength >= 40 && initialLength < 500;
|
|
594
|
+
const settleWindowMs = shortAnswer ? 12_000 : mediumAnswer ? 5_000 : longAnswer ? 8_000 : 10_000;
|
|
595
|
+
const settleIntervalMs = 400;
|
|
596
|
+
const deadline = Date.now() + settleWindowMs;
|
|
597
|
+
let latest = snapshot;
|
|
598
|
+
let lastLength = snapshot?.text?.length ?? 0;
|
|
599
|
+
let stableCycles = 0;
|
|
600
|
+
const stableTarget = shortAnswer ? 6 : mediumAnswer ? 3 : longAnswer ? 5 : 6;
|
|
601
|
+
while (Date.now() < deadline) {
|
|
602
|
+
await new Promise((resolve) => setTimeout(resolve, settleIntervalMs));
|
|
603
|
+
const refreshedRaw = extractFromTurns();
|
|
604
|
+
const refreshedCandidate =
|
|
605
|
+
refreshedRaw && !isAnswerNowPlaceholder(refreshedRaw) ? refreshedRaw : null;
|
|
606
|
+
let refreshed = acceptSnapshot(refreshedCandidate);
|
|
607
|
+
if (!refreshed) {
|
|
608
|
+
const fallbackRaw = extractFromMarkdownFallback();
|
|
609
|
+
const fallbackCandidate =
|
|
610
|
+
fallbackRaw && !isAnswerNowPlaceholder(fallbackRaw) ? fallbackRaw : null;
|
|
611
|
+
refreshed = acceptSnapshot(fallbackCandidate);
|
|
612
|
+
}
|
|
613
|
+
const nextLength = refreshed?.text?.length ?? lastLength;
|
|
614
|
+
if (refreshed && nextLength >= lastLength) {
|
|
615
|
+
latest = refreshed;
|
|
616
|
+
}
|
|
617
|
+
if (nextLength > lastLength) {
|
|
618
|
+
lastLength = nextLength;
|
|
619
|
+
stableCycles = 0;
|
|
620
|
+
} else {
|
|
621
|
+
stableCycles += 1;
|
|
622
|
+
}
|
|
623
|
+
const stopVisible = Boolean(document.querySelector(STOP_SELECTOR));
|
|
624
|
+
const finishedVisible = isLastAssistantTurnFinished();
|
|
625
|
+
|
|
626
|
+
if (finishedVisible || (!stopVisible && stableCycles >= stableTarget)) {
|
|
627
|
+
break;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return latest ?? snapshot;
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
const extractedRaw = extractFromTurns();
|
|
634
|
+
const extractedCandidate = extractedRaw && !isAnswerNowPlaceholder(extractedRaw) ? extractedRaw : null;
|
|
635
|
+
let extracted = acceptSnapshot(extractedCandidate);
|
|
636
|
+
if (!extracted) {
|
|
637
|
+
const fallbackRaw = extractFromMarkdownFallback();
|
|
638
|
+
const fallbackCandidate = fallbackRaw && !isAnswerNowPlaceholder(fallbackRaw) ? fallbackRaw : null;
|
|
639
|
+
extracted = acceptSnapshot(fallbackCandidate);
|
|
640
|
+
}
|
|
641
|
+
if (extracted) {
|
|
642
|
+
return waitForSettle(extracted);
|
|
643
|
+
}
|
|
644
|
+
return captureViaObserver().then((payload) => waitForSettle(payload));
|
|
645
|
+
})()`;
|
|
646
|
+
}
|
|
647
|
+
function buildAssistantExtractor(functionName) {
|
|
648
|
+
const conversationLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
|
|
649
|
+
const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
|
|
650
|
+
return `const ${functionName} = () => {
|
|
651
|
+
${buildClickDispatcher()}
|
|
652
|
+
const CONVERSATION_SELECTOR = ${conversationLiteral};
|
|
653
|
+
const ASSISTANT_SELECTOR = ${assistantLiteral};
|
|
654
|
+
const isAssistantTurn = (node) => {
|
|
655
|
+
if (!(node instanceof HTMLElement)) return false;
|
|
656
|
+
const turnAttr = (node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
|
|
657
|
+
if (turnAttr === 'assistant') {
|
|
658
|
+
return true;
|
|
659
|
+
}
|
|
660
|
+
const role = (node.getAttribute('data-message-author-role') || node.dataset?.messageAuthorRole || '').toLowerCase();
|
|
661
|
+
if (role === 'assistant') {
|
|
662
|
+
return true;
|
|
663
|
+
}
|
|
664
|
+
const testId = (node.getAttribute('data-testid') || '').toLowerCase();
|
|
665
|
+
if (testId.includes('assistant')) {
|
|
666
|
+
return true;
|
|
667
|
+
}
|
|
668
|
+
return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
const expandCollapsibles = (root) => {
|
|
672
|
+
const buttons = Array.from(root.querySelectorAll('button'));
|
|
673
|
+
for (const button of buttons) {
|
|
674
|
+
const label = (button.textContent || '').toLowerCase();
|
|
675
|
+
const testid = (button.getAttribute('data-testid') || '').toLowerCase();
|
|
676
|
+
if (
|
|
677
|
+
label.includes('more') ||
|
|
678
|
+
label.includes('expand') ||
|
|
679
|
+
label.includes('show') ||
|
|
680
|
+
testid.includes('markdown') ||
|
|
681
|
+
testid.includes('toggle')
|
|
682
|
+
) {
|
|
683
|
+
dispatchClickSequence(button);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
|
|
689
|
+
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
|
690
|
+
const turn = turns[index];
|
|
691
|
+
if (!isAssistantTurn(turn)) {
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
const messageRoot = turn.querySelector(ASSISTANT_SELECTOR) ?? turn;
|
|
695
|
+
expandCollapsibles(messageRoot);
|
|
696
|
+
const preferred =
|
|
697
|
+
(messageRoot.matches?.('.markdown') || messageRoot.matches?.('[data-message-content]') ? messageRoot : null) ||
|
|
698
|
+
messageRoot.querySelector('.markdown') ||
|
|
699
|
+
messageRoot.querySelector('[data-message-content]') ||
|
|
700
|
+
messageRoot.querySelector('[data-testid*="message"]') ||
|
|
701
|
+
messageRoot.querySelector('[data-testid*="assistant"]') ||
|
|
702
|
+
messageRoot.querySelector('.prose') ||
|
|
703
|
+
messageRoot.querySelector('[class*="markdown"]');
|
|
704
|
+
const contentRoot = preferred ?? messageRoot;
|
|
705
|
+
if (!contentRoot) {
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
const innerText = contentRoot?.innerText ?? '';
|
|
709
|
+
const textContent = contentRoot?.textContent ?? '';
|
|
710
|
+
const text = innerText.trim().length > 0 ? innerText : textContent;
|
|
711
|
+
const html = contentRoot?.innerHTML ?? '';
|
|
712
|
+
const messageId = messageRoot.getAttribute('data-message-id');
|
|
713
|
+
const turnId = messageRoot.getAttribute('data-testid');
|
|
714
|
+
if (text.trim()) {
|
|
715
|
+
return { text, html, messageId, turnId, turnIndex: index };
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return null;
|
|
719
|
+
};`;
|
|
720
|
+
}
|
|
721
|
+
function buildMarkdownFallbackExtractor(minTurnLiteral) {
|
|
722
|
+
const turnIndexValue = minTurnLiteral ? `(${minTurnLiteral} >= 0 ? ${minTurnLiteral} : null)` : 'null';
|
|
723
|
+
return `(() => {
|
|
724
|
+
const __minTurn = ${turnIndexValue};
|
|
725
|
+
const roots = [
|
|
726
|
+
document.querySelector('section[data-testid="screen-threadFlyOut"]'),
|
|
727
|
+
document.querySelector('[data-testid="chat-thread"]'),
|
|
728
|
+
document.querySelector('main'),
|
|
729
|
+
document.querySelector('[role="main"]'),
|
|
730
|
+
].filter(Boolean);
|
|
731
|
+
if (roots.length === 0) return null;
|
|
732
|
+
const markdownSelector = '.markdown,[data-message-content],[data-testid*="message"],.prose,[class*="markdown"]';
|
|
733
|
+
const isExcluded = (node) =>
|
|
734
|
+
Boolean(
|
|
735
|
+
node?.closest?.(
|
|
736
|
+
'nav, aside, [data-testid*="sidebar"], [data-testid*="chat-history"], [data-testid*="composer"], form',
|
|
737
|
+
),
|
|
738
|
+
);
|
|
739
|
+
const scoreRoot = (node) => {
|
|
740
|
+
const actions = node.querySelectorAll('${FINISHED_ACTIONS_SELECTOR}').length;
|
|
741
|
+
const assistants = node.querySelectorAll('[data-message-author-role="assistant"], [data-turn="assistant"]').length;
|
|
742
|
+
const markdowns = node.querySelectorAll(markdownSelector).length;
|
|
743
|
+
return actions * 10 + assistants * 5 + markdowns;
|
|
744
|
+
};
|
|
745
|
+
let root = roots[0];
|
|
746
|
+
let bestScore = scoreRoot(root);
|
|
747
|
+
for (let i = 1; i < roots.length; i += 1) {
|
|
748
|
+
const candidate = roots[i];
|
|
749
|
+
const score = scoreRoot(candidate);
|
|
750
|
+
if (score > bestScore) {
|
|
751
|
+
bestScore = score;
|
|
752
|
+
root = candidate;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
if (!root) return null;
|
|
756
|
+
const CONVERSATION_SELECTOR = '${CONVERSATION_TURN_SELECTOR}';
|
|
757
|
+
const turnNodes = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
|
|
758
|
+
const hasTurns = turnNodes.length > 0;
|
|
759
|
+
const resolveTurnIndex = (node) => {
|
|
760
|
+
const turn = node?.closest?.(CONVERSATION_SELECTOR);
|
|
761
|
+
if (!turn) return null;
|
|
762
|
+
const idx = turnNodes.indexOf(turn);
|
|
763
|
+
return idx >= 0 ? idx : null;
|
|
764
|
+
};
|
|
765
|
+
const isAfterMinTurn = (node) => {
|
|
766
|
+
if (__minTurn === null) return true;
|
|
767
|
+
if (!hasTurns) return true;
|
|
768
|
+
const idx = resolveTurnIndex(node);
|
|
769
|
+
return idx !== null && idx >= __minTurn;
|
|
770
|
+
};
|
|
771
|
+
const normalize = (value) => String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
|
|
772
|
+
const collectUserText = (scope) => {
|
|
773
|
+
if (!scope?.querySelectorAll) return '';
|
|
774
|
+
const userTurns = Array.from(scope.querySelectorAll('[data-message-author-role="user"], [data-turn="user"]'));
|
|
775
|
+
const lastUser = userTurns[userTurns.length - 1];
|
|
776
|
+
return lastUser ? normalize(lastUser.innerText || lastUser.textContent || '') : '';
|
|
777
|
+
};
|
|
778
|
+
const userText = collectUserText(root) || collectUserText(document);
|
|
779
|
+
const isUserEcho = (text) => {
|
|
780
|
+
if (!userText) return false;
|
|
781
|
+
const normalized = normalize(text);
|
|
782
|
+
if (!normalized) return false;
|
|
783
|
+
return normalized === userText || normalized.startsWith(userText);
|
|
784
|
+
};
|
|
785
|
+
const markdowns = Array.from(root.querySelectorAll(markdownSelector))
|
|
786
|
+
.filter((node) => !isExcluded(node))
|
|
787
|
+
.filter((node) => {
|
|
788
|
+
const container = node.closest('[data-message-author-role], [data-turn]');
|
|
789
|
+
if (!container) return true;
|
|
790
|
+
const role =
|
|
791
|
+
(container.getAttribute('data-message-author-role') || container.getAttribute('data-turn') || '').toLowerCase();
|
|
792
|
+
return role !== 'user';
|
|
793
|
+
});
|
|
794
|
+
if (markdowns.length === 0) return null;
|
|
795
|
+
const actionButtons = Array.from(root.querySelectorAll('${FINISHED_ACTIONS_SELECTOR}'));
|
|
796
|
+
const actionMarkdowns = [];
|
|
797
|
+
for (const button of actionButtons) {
|
|
798
|
+
const container =
|
|
799
|
+
button.closest('${CONVERSATION_TURN_SELECTOR}') ||
|
|
800
|
+
button.closest('[data-message-author-role="assistant"], [data-turn="assistant"]') ||
|
|
801
|
+
button.closest('[data-message-author-role], [data-turn]') ||
|
|
802
|
+
button.closest('[data-testid*="assistant"]');
|
|
803
|
+
if (!container || container === root || container === document.body) continue;
|
|
804
|
+
const scoped = Array.from(container.querySelectorAll(markdownSelector))
|
|
805
|
+
.filter((node) => !isExcluded(node))
|
|
806
|
+
.filter((node) => {
|
|
807
|
+
const roleNode = node.closest('[data-message-author-role], [data-turn]');
|
|
808
|
+
if (!roleNode) return true;
|
|
809
|
+
const role =
|
|
810
|
+
(roleNode.getAttribute('data-message-author-role') || roleNode.getAttribute('data-turn') || '').toLowerCase();
|
|
811
|
+
return role !== 'user';
|
|
812
|
+
});
|
|
813
|
+
if (scoped.length === 0) continue;
|
|
814
|
+
for (const node of scoped) {
|
|
815
|
+
actionMarkdowns.push(node);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
const assistantMarkdowns = markdowns.filter((node) => {
|
|
819
|
+
const container = node.closest('[data-message-author-role], [data-turn], [data-testid*="assistant"]');
|
|
820
|
+
if (!container) return false;
|
|
821
|
+
const role =
|
|
822
|
+
(container.getAttribute('data-message-author-role') || container.getAttribute('data-turn') || '').toLowerCase();
|
|
823
|
+
if (role === 'assistant') return true;
|
|
824
|
+
const testId = (container.getAttribute('data-testid') || '').toLowerCase();
|
|
825
|
+
return testId.includes('assistant');
|
|
826
|
+
});
|
|
827
|
+
const hasAssistantIndicators = Boolean(
|
|
828
|
+
root.querySelector('${FINISHED_ACTIONS_SELECTOR}') ||
|
|
829
|
+
root.querySelector('[data-message-author-role="assistant"], [data-turn="assistant"], [data-testid*="assistant"]'),
|
|
830
|
+
);
|
|
831
|
+
const allowMarkdownFallback = hasAssistantIndicators || hasTurns || Boolean(userText);
|
|
832
|
+
const candidates =
|
|
833
|
+
actionMarkdowns.length > 0
|
|
834
|
+
? actionMarkdowns
|
|
835
|
+
: assistantMarkdowns.length > 0
|
|
836
|
+
? assistantMarkdowns
|
|
837
|
+
: allowMarkdownFallback
|
|
838
|
+
? markdowns
|
|
839
|
+
: [];
|
|
840
|
+
for (let i = candidates.length - 1; i >= 0; i -= 1) {
|
|
841
|
+
const node = candidates[i];
|
|
842
|
+
if (!node) continue;
|
|
843
|
+
if (!isAfterMinTurn(node)) continue;
|
|
844
|
+
const text = (node.innerText || node.textContent || '').trim();
|
|
845
|
+
if (!text) continue;
|
|
846
|
+
if (isUserEcho(text)) continue;
|
|
847
|
+
const html = node.innerHTML ?? '';
|
|
848
|
+
const turnIndex = resolveTurnIndex(node);
|
|
849
|
+
return { text, html, messageId: null, turnId: null, turnIndex };
|
|
850
|
+
}
|
|
851
|
+
return null;
|
|
852
|
+
})`;
|
|
853
|
+
}
|
|
854
|
+
function buildCopyExpression(meta) {
|
|
855
|
+
return `(() => {
|
|
856
|
+
${buildClickDispatcher()}
|
|
857
|
+
const BUTTON_SELECTOR = '${COPY_BUTTON_SELECTOR}';
|
|
858
|
+
const TIMEOUT_MS = 10000;
|
|
859
|
+
|
|
860
|
+
const locateButton = () => {
|
|
861
|
+
const hint = ${JSON.stringify(meta ?? {})};
|
|
862
|
+
if (hint?.messageId) {
|
|
863
|
+
const node = document.querySelector('[data-message-id="' + hint.messageId + '"]');
|
|
864
|
+
const buttons = node ? Array.from(node.querySelectorAll('${COPY_BUTTON_SELECTOR}')) : [];
|
|
865
|
+
const button = buttons.at(-1) ?? null;
|
|
866
|
+
if (button) {
|
|
867
|
+
return button;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
if (hint?.turnId) {
|
|
871
|
+
const node = document.querySelector('[data-testid="' + hint.turnId + '"]');
|
|
872
|
+
const buttons = node ? Array.from(node.querySelectorAll('${COPY_BUTTON_SELECTOR}')) : [];
|
|
873
|
+
const button = buttons.at(-1) ?? null;
|
|
874
|
+
if (button) {
|
|
875
|
+
return button;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
const CONVERSATION_SELECTOR = ${JSON.stringify(CONVERSATION_TURN_SELECTOR)};
|
|
879
|
+
const ASSISTANT_SELECTOR = '${ASSISTANT_ROLE_SELECTOR}';
|
|
880
|
+
const isAssistantTurn = (node) => {
|
|
881
|
+
if (!(node instanceof HTMLElement)) return false;
|
|
882
|
+
const turnAttr = (node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
|
|
883
|
+
if (turnAttr === 'assistant') return true;
|
|
884
|
+
const role = (node.getAttribute('data-message-author-role') || node.dataset?.messageAuthorRole || '').toLowerCase();
|
|
885
|
+
if (role === 'assistant') return true;
|
|
886
|
+
const testId = (node.getAttribute('data-testid') || '').toLowerCase();
|
|
887
|
+
if (testId.includes('assistant')) return true;
|
|
888
|
+
return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
|
|
889
|
+
};
|
|
890
|
+
const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
|
|
891
|
+
for (let i = turns.length - 1; i >= 0; i -= 1) {
|
|
892
|
+
const turn = turns[i];
|
|
893
|
+
if (!isAssistantTurn(turn)) continue;
|
|
894
|
+
const button = turn.querySelector(BUTTON_SELECTOR);
|
|
895
|
+
if (button) {
|
|
896
|
+
return button;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
const all = Array.from(document.querySelectorAll(BUTTON_SELECTOR));
|
|
900
|
+
for (let i = all.length - 1; i >= 0; i -= 1) {
|
|
901
|
+
const button = all[i];
|
|
902
|
+
const turn = button?.closest?.(CONVERSATION_SELECTOR);
|
|
903
|
+
if (turn && isAssistantTurn(turn)) {
|
|
904
|
+
return button;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
return null;
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
const interceptClipboard = () => {
|
|
911
|
+
const clipboard = navigator.clipboard;
|
|
912
|
+
const state = { text: '', updatedAt: 0 };
|
|
913
|
+
if (!clipboard) {
|
|
914
|
+
return { state, restore: () => {} };
|
|
915
|
+
}
|
|
916
|
+
const originalWriteText = clipboard.writeText;
|
|
917
|
+
const originalWrite = clipboard.write;
|
|
918
|
+
clipboard.writeText = (value) => {
|
|
919
|
+
state.text = typeof value === 'string' ? value : '';
|
|
920
|
+
state.updatedAt = Date.now();
|
|
921
|
+
return Promise.resolve();
|
|
922
|
+
};
|
|
923
|
+
clipboard.write = async (items) => {
|
|
924
|
+
try {
|
|
925
|
+
const list = Array.isArray(items) ? items : items ? [items] : [];
|
|
926
|
+
for (const item of list) {
|
|
927
|
+
if (!item) continue;
|
|
928
|
+
const types = Array.isArray(item.types) ? item.types : [];
|
|
929
|
+
if (types.includes('text/plain') && typeof item.getType === 'function') {
|
|
930
|
+
const blob = await item.getType('text/plain');
|
|
931
|
+
const text = await blob.text();
|
|
932
|
+
state.text = text ?? '';
|
|
933
|
+
state.updatedAt = Date.now();
|
|
934
|
+
break;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
} catch {
|
|
938
|
+
state.text = '';
|
|
939
|
+
state.updatedAt = Date.now();
|
|
940
|
+
}
|
|
941
|
+
return Promise.resolve();
|
|
942
|
+
};
|
|
943
|
+
return {
|
|
944
|
+
state,
|
|
945
|
+
restore: () => {
|
|
946
|
+
clipboard.writeText = originalWriteText;
|
|
947
|
+
clipboard.write = originalWrite;
|
|
948
|
+
},
|
|
949
|
+
};
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
return new Promise((resolve) => {
|
|
953
|
+
const deadline = Date.now() + TIMEOUT_MS;
|
|
954
|
+
const waitForButton = () => {
|
|
955
|
+
const button = locateButton();
|
|
956
|
+
if (button) {
|
|
957
|
+
const interception = interceptClipboard();
|
|
958
|
+
let settled = false;
|
|
959
|
+
let pollId = null;
|
|
960
|
+
let timeoutId = null;
|
|
961
|
+
const finish = (payload) => {
|
|
962
|
+
if (settled) {
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
settled = true;
|
|
966
|
+
if (pollId) {
|
|
967
|
+
clearInterval(pollId);
|
|
968
|
+
}
|
|
969
|
+
if (timeoutId) {
|
|
970
|
+
clearTimeout(timeoutId);
|
|
971
|
+
}
|
|
972
|
+
button.removeEventListener('copy', handleCopy, true);
|
|
973
|
+
interception.restore?.();
|
|
974
|
+
resolve(payload);
|
|
975
|
+
};
|
|
976
|
+
|
|
977
|
+
const readIntercepted = () => {
|
|
978
|
+
const markdown = interception.state.text ?? '';
|
|
979
|
+
const updatedAt = interception.state.updatedAt ?? 0;
|
|
980
|
+
return { success: Boolean(markdown.trim()), markdown, updatedAt };
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
let lastText = '';
|
|
984
|
+
let stableTicks = 0;
|
|
985
|
+
const requiredStableTicks = 3;
|
|
986
|
+
const requiredStableMs = 250;
|
|
987
|
+
const maybeFinish = () => {
|
|
988
|
+
const payload = readIntercepted();
|
|
989
|
+
if (!payload.success) return;
|
|
990
|
+
if (payload.markdown !== lastText) {
|
|
991
|
+
lastText = payload.markdown;
|
|
992
|
+
stableTicks = 0;
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
stableTicks += 1;
|
|
996
|
+
const ageMs = Date.now() - (payload.updatedAt || 0);
|
|
997
|
+
if (stableTicks >= requiredStableTicks && ageMs >= requiredStableMs) {
|
|
998
|
+
finish(payload);
|
|
999
|
+
}
|
|
1000
|
+
};
|
|
1001
|
+
|
|
1002
|
+
const handleCopy = () => {
|
|
1003
|
+
maybeFinish();
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
button.addEventListener('copy', handleCopy, true);
|
|
1007
|
+
button.scrollIntoView({ block: 'center', behavior: 'instant' });
|
|
1008
|
+
dispatchClickSequence(button);
|
|
1009
|
+
pollId = setInterval(maybeFinish, 120);
|
|
1010
|
+
timeoutId = setTimeout(() => {
|
|
1011
|
+
button.removeEventListener('copy', handleCopy, true);
|
|
1012
|
+
finish({ success: false, status: 'timeout' });
|
|
1013
|
+
}, TIMEOUT_MS);
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
if (Date.now() > deadline) {
|
|
1017
|
+
resolve({ success: false, status: 'missing-button' });
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
setTimeout(waitForButton, 120);
|
|
1021
|
+
};
|
|
1022
|
+
|
|
1023
|
+
waitForButton();
|
|
1024
|
+
});
|
|
1025
|
+
})()`;
|
|
1026
|
+
}
|
|
1027
|
+
const LANGUAGE_TAGS = new Set([
|
|
1028
|
+
'copy code',
|
|
1029
|
+
'markdown',
|
|
1030
|
+
'bash',
|
|
1031
|
+
'sh',
|
|
1032
|
+
'shell',
|
|
1033
|
+
'javascript',
|
|
1034
|
+
'typescript',
|
|
1035
|
+
'ts',
|
|
1036
|
+
'js',
|
|
1037
|
+
'yaml',
|
|
1038
|
+
'json',
|
|
1039
|
+
'python',
|
|
1040
|
+
'py',
|
|
1041
|
+
'go',
|
|
1042
|
+
'java',
|
|
1043
|
+
'c',
|
|
1044
|
+
'c++',
|
|
1045
|
+
'cpp',
|
|
1046
|
+
'c#',
|
|
1047
|
+
'php',
|
|
1048
|
+
'ruby',
|
|
1049
|
+
'rust',
|
|
1050
|
+
'swift',
|
|
1051
|
+
'kotlin',
|
|
1052
|
+
'html',
|
|
1053
|
+
'css',
|
|
1054
|
+
'sql',
|
|
1055
|
+
'text',
|
|
1056
|
+
].map((token) => token.toLowerCase()));
|
|
1057
|
+
function cleanAssistantText(text) {
|
|
1058
|
+
const normalized = text.replace(/\u00a0/g, ' ');
|
|
1059
|
+
const lines = normalized.split(/\r?\n/);
|
|
1060
|
+
const filtered = lines.filter((line) => {
|
|
1061
|
+
const trimmed = line.trim().toLowerCase();
|
|
1062
|
+
if (LANGUAGE_TAGS.has(trimmed))
|
|
1063
|
+
return false;
|
|
1064
|
+
return true;
|
|
1065
|
+
});
|
|
1066
|
+
return filtered.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
1067
|
+
}
|