@steipete/oracle 0.8.4 → 0.8.6
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 +30 -1
- package/dist/bin/oracle-cli.js +291 -16
- package/dist/scripts/debug/extract-chatgpt-response.js +53 -0
- package/dist/src/bridge/connection.js +103 -0
- package/dist/src/bridge/userConfigFile.js +28 -0
- package/dist/src/browser/actions/assistantResponse.js +85 -42
- package/dist/src/browser/actions/promptComposer.js +141 -32
- package/dist/src/browser/chromeLifecycle.js +78 -9
- package/dist/src/browser/config.js +14 -0
- package/dist/src/browser/detect.js +164 -0
- package/dist/src/browser/index.js +394 -24
- package/dist/src/browser/profileState.js +93 -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 +21 -0
- package/dist/src/cli/browserDefaults.js +21 -0
- package/dist/src/cli/engine.js +17 -1
- package/dist/src/cli/options.js +14 -0
- package/dist/src/cli/runOptions.js +4 -0
- package/dist/src/cli/sessionRunner.js +149 -0
- package/dist/src/cli/tui/index.js +1 -0
- package/dist/src/mcp/tools/consult.js +81 -15
- package/dist/src/mcp/tools/sessions.js +15 -6
- package/dist/src/mcp/types.js +4 -0
- package/dist/src/mcp/utils.js +12 -2
- package/dist/src/oracle/background.js +1 -2
- package/dist/src/oracle/client.js +5 -2
- package/dist/src/oracle/files.js +2 -2
- package/dist/src/oracle/modelResolver.js +33 -1
- package/dist/src/oracle/run.js +1 -0
- package/dist/src/remote/client.js +6 -5
- package/dist/src/remote/health.js +113 -0
- package/dist/src/remote/remoteServiceConfig.js +31 -0
- package/dist/src/remote/server.js +28 -1
- package/dist/src/sessionManager.js +72 -7
- package/dist/src/sessionStore.js +2 -2
- 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 +21 -21
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export function normalizeHostPort(hostname, port) {
|
|
4
|
+
const trimmed = hostname.trim();
|
|
5
|
+
const unwrapped = trimmed.startsWith('[') && trimmed.endsWith(']') ? trimmed.slice(1, -1) : trimmed;
|
|
6
|
+
if (unwrapped.includes(':')) {
|
|
7
|
+
return `[${unwrapped}]:${port}`;
|
|
8
|
+
}
|
|
9
|
+
return `${unwrapped}:${port}`;
|
|
10
|
+
}
|
|
11
|
+
export function parseHostPort(raw) {
|
|
12
|
+
const target = raw.trim();
|
|
13
|
+
if (!target) {
|
|
14
|
+
throw new Error('Expected host:port but received an empty value.');
|
|
15
|
+
}
|
|
16
|
+
const ipv6Match = target.match(/^\[(.+)]:(\d+)$/);
|
|
17
|
+
let hostname;
|
|
18
|
+
let portSegment;
|
|
19
|
+
if (ipv6Match) {
|
|
20
|
+
hostname = ipv6Match[1]?.trim();
|
|
21
|
+
portSegment = ipv6Match[2]?.trim();
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
const lastColon = target.lastIndexOf(':');
|
|
25
|
+
if (lastColon === -1) {
|
|
26
|
+
throw new Error(`Invalid host:port format: ${target}. Expected host:port (IPv6 must use [host]:port notation).`);
|
|
27
|
+
}
|
|
28
|
+
hostname = target.slice(0, lastColon).trim();
|
|
29
|
+
portSegment = target.slice(lastColon + 1).trim();
|
|
30
|
+
if (hostname.includes(':')) {
|
|
31
|
+
throw new Error(`Invalid host:port format: ${target}. Wrap IPv6 addresses in brackets, e.g. "[2001:db8::1]:9473".`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (!hostname) {
|
|
35
|
+
throw new Error(`Invalid host:port format: ${target}. Host portion is missing.`);
|
|
36
|
+
}
|
|
37
|
+
const port = Number.parseInt(portSegment ?? '', 10);
|
|
38
|
+
if (!Number.isFinite(port) || port <= 0 || port > 65_535) {
|
|
39
|
+
throw new Error(`Invalid port: "${portSegment ?? ''}". Expected a number between 1 and 65535.`);
|
|
40
|
+
}
|
|
41
|
+
return { hostname, port };
|
|
42
|
+
}
|
|
43
|
+
export function parseBridgeConnectionString(input) {
|
|
44
|
+
const raw = input.trim();
|
|
45
|
+
if (!raw) {
|
|
46
|
+
throw new Error('Missing connection string.');
|
|
47
|
+
}
|
|
48
|
+
let url;
|
|
49
|
+
try {
|
|
50
|
+
url = raw.includes('://') ? new URL(raw) : new URL(`oracle+tcp://${raw}`);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
throw new Error(`Invalid connection string: ${error instanceof Error ? error.message : String(error)}`);
|
|
54
|
+
}
|
|
55
|
+
const hostname = url.hostname?.trim();
|
|
56
|
+
const port = Number.parseInt(url.port ?? '', 10);
|
|
57
|
+
if (!hostname || !Number.isFinite(port) || port <= 0 || port > 65_535) {
|
|
58
|
+
throw new Error(`Invalid connection string host: ${raw}. Expected host:port.`);
|
|
59
|
+
}
|
|
60
|
+
const token = url.searchParams.get('token')?.trim() ?? '';
|
|
61
|
+
if (!token) {
|
|
62
|
+
throw new Error('Connection string is missing token. Expected "?token=...".');
|
|
63
|
+
}
|
|
64
|
+
const remoteHost = normalizeHostPort(hostname, port);
|
|
65
|
+
return { remoteHost, remoteToken: token };
|
|
66
|
+
}
|
|
67
|
+
export function formatBridgeConnectionString(connection, options = {}) {
|
|
68
|
+
const { hostname, port } = parseHostPort(connection.remoteHost);
|
|
69
|
+
const base = `oracle+tcp://${normalizeHostPort(hostname, port)}`;
|
|
70
|
+
if (!options.includeToken) {
|
|
71
|
+
return base;
|
|
72
|
+
}
|
|
73
|
+
const params = new URLSearchParams({ token: connection.remoteToken });
|
|
74
|
+
return `${base}?${params.toString()}`;
|
|
75
|
+
}
|
|
76
|
+
export function looksLikePath(value) {
|
|
77
|
+
return value.includes('/') || value.includes('\\') || value.endsWith('.json');
|
|
78
|
+
}
|
|
79
|
+
export async function readBridgeConnectionArtifact(filePath) {
|
|
80
|
+
const resolved = path.resolve(process.cwd(), filePath);
|
|
81
|
+
const raw = await fs.readFile(resolved, 'utf8');
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(raw);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
throw new Error(`Failed to parse connection artifact JSON at ${resolved}: ${error instanceof Error ? error.message : String(error)}`);
|
|
88
|
+
}
|
|
89
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
90
|
+
throw new Error(`Invalid connection artifact at ${resolved}: expected an object.`);
|
|
91
|
+
}
|
|
92
|
+
const remoteHost = parsed.remoteHost;
|
|
93
|
+
const remoteToken = parsed.remoteToken;
|
|
94
|
+
if (typeof remoteHost !== 'string' || remoteHost.trim().length === 0) {
|
|
95
|
+
throw new Error(`Invalid connection artifact at ${resolved}: remoteHost is missing.`);
|
|
96
|
+
}
|
|
97
|
+
if (typeof remoteToken !== 'string' || remoteToken.trim().length === 0) {
|
|
98
|
+
throw new Error(`Invalid connection artifact at ${resolved}: remoteToken is missing.`);
|
|
99
|
+
}
|
|
100
|
+
// Validate host formatting early so downstream checks don't crash.
|
|
101
|
+
parseHostPort(remoteHost);
|
|
102
|
+
return parsed;
|
|
103
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import JSON5 from 'json5';
|
|
4
|
+
export async function readUserConfigFile(configPath) {
|
|
5
|
+
try {
|
|
6
|
+
const raw = await fs.readFile(configPath, 'utf8');
|
|
7
|
+
const parsed = JSON5.parse(raw);
|
|
8
|
+
return { config: parsed ?? {}, loaded: true };
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
const code = error.code;
|
|
12
|
+
if (code === 'ENOENT') {
|
|
13
|
+
return { config: {}, loaded: false };
|
|
14
|
+
}
|
|
15
|
+
throw new Error(`Failed to read ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export async function writeUserConfigFile(configPath, config) {
|
|
19
|
+
const dir = path.dirname(configPath);
|
|
20
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
21
|
+
const contents = `${JSON.stringify(config, null, 2)}\n`;
|
|
22
|
+
const tempPath = `${configPath}.tmp-${process.pid}-${Date.now()}`;
|
|
23
|
+
await fs.writeFile(tempPath, contents, { encoding: 'utf8', mode: 0o600 });
|
|
24
|
+
await fs.rename(tempPath, configPath);
|
|
25
|
+
if (process.platform !== 'win32') {
|
|
26
|
+
await fs.chmod(configPath, 0o600).catch(() => undefined);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -27,23 +27,26 @@ export async function waitForAssistantResponse(Runtime, timeoutMs, logger, minTu
|
|
|
27
27
|
const raceReadyEvaluation = evaluationPromise.then((value) => ({ kind: 'evaluation', value }), (error) => {
|
|
28
28
|
throw { source: 'evaluation', error };
|
|
29
29
|
});
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return { kind: 'poll', value };
|
|
35
|
-
}, (error) => {
|
|
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) => {
|
|
36
34
|
throw { source: 'poll', error };
|
|
37
35
|
});
|
|
38
36
|
let evaluation = null;
|
|
39
37
|
try {
|
|
40
38
|
const winner = await Promise.race([raceReadyEvaluation, pollerPromise]);
|
|
41
39
|
if (winner.kind === 'poll') {
|
|
40
|
+
if (!winner.value) {
|
|
41
|
+
throw { source: 'poll', error: new Error(ASSISTANT_POLL_TIMEOUT_ERROR) };
|
|
42
|
+
}
|
|
42
43
|
logger('Captured assistant response via snapshot watchdog');
|
|
43
44
|
evaluationPromise.catch(() => undefined);
|
|
44
45
|
await terminateRuntimeExecution(Runtime);
|
|
45
46
|
return winner.value;
|
|
46
47
|
}
|
|
48
|
+
// Evaluation won - abort the poller to prevent it from running until timeout
|
|
49
|
+
pollerAbort.abort();
|
|
47
50
|
evaluation = winner.value;
|
|
48
51
|
}
|
|
49
52
|
catch (wrappedError) {
|
|
@@ -86,7 +89,7 @@ export async function waitForAssistantResponse(Runtime, timeoutMs, logger, minTu
|
|
|
86
89
|
pollerPromise.catch(() => null),
|
|
87
90
|
delay(remainingMs).then(() => null),
|
|
88
91
|
]);
|
|
89
|
-
if (polled && polled.kind === 'poll') {
|
|
92
|
+
if (polled && polled.kind === 'poll' && polled.value) {
|
|
90
93
|
return polled.value;
|
|
91
94
|
}
|
|
92
95
|
}
|
|
@@ -263,12 +266,16 @@ async function terminateRuntimeExecution(Runtime) {
|
|
|
263
266
|
// ignore termination failures
|
|
264
267
|
}
|
|
265
268
|
}
|
|
266
|
-
async function pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex) {
|
|
269
|
+
async function pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex, abortSignal) {
|
|
267
270
|
const watchdogDeadline = Date.now() + timeoutMs;
|
|
268
271
|
let previousLength = 0;
|
|
269
272
|
let stableCycles = 0;
|
|
270
273
|
let lastChangeAt = Date.now();
|
|
271
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
|
+
}
|
|
272
279
|
const snapshot = await readAssistantSnapshot(Runtime, minTurnIndex);
|
|
273
280
|
const normalized = normalizeAssistantSnapshot(snapshot);
|
|
274
281
|
if (normalized) {
|
|
@@ -286,11 +293,15 @@ async function pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex) {
|
|
|
286
293
|
isCompletionVisible(Runtime),
|
|
287
294
|
]);
|
|
288
295
|
const shortAnswer = currentLength > 0 && currentLength < 16;
|
|
296
|
+
const mediumAnswer = currentLength >= 16 && currentLength < 40;
|
|
297
|
+
const longAnswer = currentLength >= 40 && currentLength < 500;
|
|
289
298
|
// Learned: short answers need a longer stability window or they truncate.
|
|
290
|
-
|
|
291
|
-
|
|
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;
|
|
292
303
|
const stableMs = Date.now() - lastChangeAt;
|
|
293
|
-
const minStableMs = shortAnswer ? 8000 : 1200;
|
|
304
|
+
const minStableMs = shortAnswer ? 8000 : mediumAnswer ? 1200 : longAnswer ? 2000 : 3000;
|
|
294
305
|
// Require stop button to disappear before treating completion as final.
|
|
295
306
|
if (!stopVisible) {
|
|
296
307
|
const stableEnough = stableCycles >= requiredStableCycles && stableMs >= minStableMs;
|
|
@@ -479,33 +490,63 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex) {
|
|
|
479
490
|
new Promise((resolve, reject) => {
|
|
480
491
|
const deadline = Date.now() + ${timeoutMs};
|
|
481
492
|
let stopInterval = null;
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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;
|
|
492
516
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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);
|
|
497
531
|
}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
if (
|
|
502
|
-
|
|
532
|
+
if (extracted) {
|
|
533
|
+
cleanup();
|
|
534
|
+
resolve(extracted);
|
|
535
|
+
} else if (Date.now() > deadline) {
|
|
536
|
+
cleanup();
|
|
537
|
+
reject(new Error('Response timeout'));
|
|
503
538
|
}
|
|
504
|
-
|
|
539
|
+
} catch (error) {
|
|
540
|
+
cleanup();
|
|
541
|
+
reject(error);
|
|
505
542
|
}
|
|
506
|
-
}
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
observer = new MutationObserver(observerCallback);
|
|
507
546
|
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
|
547
|
+
|
|
508
548
|
stopInterval = setInterval(() => {
|
|
549
|
+
if (cleanedUp) return;
|
|
509
550
|
const stop = document.querySelector(STOP_SELECTOR);
|
|
510
551
|
if (!stop) {
|
|
511
552
|
return;
|
|
@@ -517,11 +558,9 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex) {
|
|
|
517
558
|
}
|
|
518
559
|
dispatchClickSequence(stop);
|
|
519
560
|
}, 500);
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
}
|
|
524
|
-
observer.disconnect();
|
|
561
|
+
|
|
562
|
+
timeoutId = setTimeout(() => {
|
|
563
|
+
cleanup();
|
|
525
564
|
reject(new Error('Response timeout'));
|
|
526
565
|
}, ${timeoutMs});
|
|
527
566
|
});
|
|
@@ -546,15 +585,19 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex) {
|
|
|
546
585
|
|
|
547
586
|
const waitForSettle = async (snapshot) => {
|
|
548
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).
|
|
549
590
|
const initialLength = snapshot?.text?.length ?? 0;
|
|
550
591
|
const shortAnswer = initialLength > 0 && initialLength < 16;
|
|
551
|
-
const
|
|
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;
|
|
552
595
|
const settleIntervalMs = 400;
|
|
553
596
|
const deadline = Date.now() + settleWindowMs;
|
|
554
597
|
let latest = snapshot;
|
|
555
598
|
let lastLength = snapshot?.text?.length ?? 0;
|
|
556
599
|
let stableCycles = 0;
|
|
557
|
-
const stableTarget = shortAnswer ? 6 : 3;
|
|
600
|
+
const stableTarget = shortAnswer ? 6 : mediumAnswer ? 3 : longAnswer ? 5 : 6;
|
|
558
601
|
while (Date.now() < deadline) {
|
|
559
602
|
await new Promise((resolve) => setTimeout(resolve, settleIntervalMs));
|
|
560
603
|
const refreshedRaw = extractFromTurns();
|
|
@@ -678,7 +721,7 @@ function buildAssistantExtractor(functionName) {
|
|
|
678
721
|
function buildMarkdownFallbackExtractor(minTurnLiteral) {
|
|
679
722
|
const turnIndexValue = minTurnLiteral ? `(${minTurnLiteral} >= 0 ? ${minTurnLiteral} : null)` : 'null';
|
|
680
723
|
return `(() => {
|
|
681
|
-
const
|
|
724
|
+
const __minTurn = ${turnIndexValue};
|
|
682
725
|
const roots = [
|
|
683
726
|
document.querySelector('section[data-testid="screen-threadFlyOut"]'),
|
|
684
727
|
document.querySelector('[data-testid="chat-thread"]'),
|
|
@@ -720,10 +763,10 @@ function buildMarkdownFallbackExtractor(minTurnLiteral) {
|
|
|
720
763
|
return idx >= 0 ? idx : null;
|
|
721
764
|
};
|
|
722
765
|
const isAfterMinTurn = (node) => {
|
|
723
|
-
if (
|
|
766
|
+
if (__minTurn === null) return true;
|
|
724
767
|
if (!hasTurns) return true;
|
|
725
768
|
const idx = resolveTurnIndex(node);
|
|
726
|
-
return idx !== null && idx >=
|
|
769
|
+
return idx !== null && idx >= __minTurn;
|
|
727
770
|
};
|
|
728
771
|
const normalize = (value) => String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
|
|
729
772
|
const collectUserText = (scope) => {
|
|
@@ -18,6 +18,13 @@ export async function submitPrompt(deps, prompt, logger) {
|
|
|
18
18
|
expression: `(() => {
|
|
19
19
|
${buildClickDispatcher()}
|
|
20
20
|
const SELECTORS = ${JSON.stringify(INPUT_SELECTORS)};
|
|
21
|
+
const isVisible = (node) => {
|
|
22
|
+
if (!node || typeof node.getBoundingClientRect !== 'function') {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
const rect = node.getBoundingClientRect();
|
|
26
|
+
return rect.width > 0 && rect.height > 0;
|
|
27
|
+
};
|
|
21
28
|
const focusNode = (node) => {
|
|
22
29
|
if (!node) {
|
|
23
30
|
return false;
|
|
@@ -39,13 +46,17 @@ export async function submitPrompt(deps, prompt, logger) {
|
|
|
39
46
|
return true;
|
|
40
47
|
};
|
|
41
48
|
|
|
49
|
+
const candidates = [];
|
|
42
50
|
for (const selector of SELECTORS) {
|
|
43
51
|
const node = document.querySelector(selector);
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
return { focused: true };
|
|
52
|
+
if (node) {
|
|
53
|
+
candidates.push(node);
|
|
47
54
|
}
|
|
48
55
|
}
|
|
56
|
+
const preferred = candidates.find((node) => isVisible(node)) || candidates[0];
|
|
57
|
+
if (preferred && focusNode(preferred)) {
|
|
58
|
+
return { focused: true };
|
|
59
|
+
}
|
|
49
60
|
return { focused: false };
|
|
50
61
|
})()`,
|
|
51
62
|
returnByValue: true,
|
|
@@ -65,18 +76,36 @@ export async function submitPrompt(deps, prompt, logger) {
|
|
|
65
76
|
expression: `(() => {
|
|
66
77
|
const editor = document.querySelector(${primarySelectorLiteral});
|
|
67
78
|
const fallback = document.querySelector(${fallbackSelectorLiteral});
|
|
79
|
+
const inputSelectors = ${JSON.stringify(INPUT_SELECTORS)};
|
|
80
|
+
const readValue = (node) => {
|
|
81
|
+
if (!node) return '';
|
|
82
|
+
if (node instanceof HTMLTextAreaElement) return node.value ?? '';
|
|
83
|
+
return node.innerText ?? '';
|
|
84
|
+
};
|
|
85
|
+
const isVisible = (node) => {
|
|
86
|
+
if (!node || typeof node.getBoundingClientRect !== 'function') return false;
|
|
87
|
+
const rect = node.getBoundingClientRect();
|
|
88
|
+
return rect.width > 0 && rect.height > 0;
|
|
89
|
+
};
|
|
90
|
+
const candidates = inputSelectors
|
|
91
|
+
.map((selector) => document.querySelector(selector))
|
|
92
|
+
.filter((node) => Boolean(node));
|
|
93
|
+
const active = candidates.find((node) => isVisible(node)) || candidates[0] || null;
|
|
68
94
|
return {
|
|
69
95
|
editorText: editor?.innerText ?? '',
|
|
70
96
|
fallbackValue: fallback?.value ?? '',
|
|
97
|
+
activeValue: active ? readValue(active) : '',
|
|
71
98
|
};
|
|
72
99
|
})()`,
|
|
73
100
|
returnByValue: true,
|
|
74
101
|
});
|
|
75
102
|
const editorTextRaw = verification.result?.value?.editorText ?? '';
|
|
76
103
|
const fallbackValueRaw = verification.result?.value?.fallbackValue ?? '';
|
|
104
|
+
const activeValueRaw = verification.result?.value?.activeValue ?? '';
|
|
77
105
|
const editorTextTrimmed = editorTextRaw?.trim?.() ?? '';
|
|
78
106
|
const fallbackValueTrimmed = fallbackValueRaw?.trim?.() ?? '';
|
|
79
|
-
|
|
107
|
+
const activeValueTrimmed = activeValueRaw?.trim?.() ?? '';
|
|
108
|
+
if (!editorTextTrimmed && !fallbackValueTrimmed && !activeValueTrimmed) {
|
|
80
109
|
// Learned: occasionally Input.insertText doesn't land in the editor; force textContent/value + input events.
|
|
81
110
|
await runtime.evaluate({
|
|
82
111
|
expression: `(() => {
|
|
@@ -100,16 +129,33 @@ export async function submitPrompt(deps, prompt, logger) {
|
|
|
100
129
|
expression: `(() => {
|
|
101
130
|
const editor = document.querySelector(${primarySelectorLiteral});
|
|
102
131
|
const fallback = document.querySelector(${fallbackSelectorLiteral});
|
|
132
|
+
const inputSelectors = ${JSON.stringify(INPUT_SELECTORS)};
|
|
133
|
+
const readValue = (node) => {
|
|
134
|
+
if (!node) return '';
|
|
135
|
+
if (node instanceof HTMLTextAreaElement) return node.value ?? '';
|
|
136
|
+
return node.innerText ?? '';
|
|
137
|
+
};
|
|
138
|
+
const isVisible = (node) => {
|
|
139
|
+
if (!node || typeof node.getBoundingClientRect !== 'function') return false;
|
|
140
|
+
const rect = node.getBoundingClientRect();
|
|
141
|
+
return rect.width > 0 && rect.height > 0;
|
|
142
|
+
};
|
|
143
|
+
const candidates = inputSelectors
|
|
144
|
+
.map((selector) => document.querySelector(selector))
|
|
145
|
+
.filter((node) => Boolean(node));
|
|
146
|
+
const active = candidates.find((node) => isVisible(node)) || candidates[0] || null;
|
|
103
147
|
return {
|
|
104
148
|
editorText: editor?.innerText ?? '',
|
|
105
149
|
fallbackValue: fallback?.value ?? '',
|
|
150
|
+
activeValue: active ? readValue(active) : '',
|
|
106
151
|
};
|
|
107
152
|
})()`,
|
|
108
153
|
returnByValue: true,
|
|
109
154
|
});
|
|
110
155
|
const observedEditor = postVerification.result?.value?.editorText ?? '';
|
|
111
156
|
const observedFallback = postVerification.result?.value?.fallbackValue ?? '';
|
|
112
|
-
const
|
|
157
|
+
const observedActive = postVerification.result?.value?.activeValue ?? '';
|
|
158
|
+
const observedLength = Math.max(observedEditor.length, observedFallback.length, observedActive.length);
|
|
113
159
|
if (promptLength >= 50_000 && observedLength > 0 && observedLength < promptLength - 2_000) {
|
|
114
160
|
// Learned: very large prompts can truncate silently; fail fast so we can fall back to file uploads.
|
|
115
161
|
await logDomFailure(runtime, logger, 'prompt-too-large');
|
|
@@ -144,10 +190,12 @@ export async function submitPrompt(deps, prompt, logger) {
|
|
|
144
190
|
export async function clearPromptComposer(Runtime, logger) {
|
|
145
191
|
const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
|
|
146
192
|
const fallbackSelectorLiteral = JSON.stringify(PROMPT_FALLBACK_SELECTOR);
|
|
193
|
+
const inputSelectorsLiteral = JSON.stringify(INPUT_SELECTORS);
|
|
147
194
|
const result = await Runtime.evaluate({
|
|
148
195
|
expression: `(() => {
|
|
149
196
|
const fallback = document.querySelector(${fallbackSelectorLiteral});
|
|
150
197
|
const editor = document.querySelector(${primarySelectorLiteral});
|
|
198
|
+
const inputSelectors = ${inputSelectorsLiteral};
|
|
151
199
|
let cleared = false;
|
|
152
200
|
if (fallback) {
|
|
153
201
|
fallback.value = '';
|
|
@@ -160,6 +208,24 @@ export async function clearPromptComposer(Runtime, logger) {
|
|
|
160
208
|
editor.dispatchEvent(new InputEvent('input', { bubbles: true, data: '', inputType: 'deleteByCut' }));
|
|
161
209
|
cleared = true;
|
|
162
210
|
}
|
|
211
|
+
const nodes = inputSelectors
|
|
212
|
+
.map((selector) => document.querySelector(selector))
|
|
213
|
+
.filter((node) => Boolean(node));
|
|
214
|
+
for (const node of nodes) {
|
|
215
|
+
if (!node) continue;
|
|
216
|
+
if (node instanceof HTMLTextAreaElement) {
|
|
217
|
+
node.value = '';
|
|
218
|
+
node.dispatchEvent(new InputEvent('input', { bubbles: true, data: '', inputType: 'deleteByCut' }));
|
|
219
|
+
node.dispatchEvent(new Event('change', { bubbles: true }));
|
|
220
|
+
cleared = true;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (node.isContentEditable || node.getAttribute('contenteditable') === 'true') {
|
|
224
|
+
node.textContent = '';
|
|
225
|
+
node.dispatchEvent(new InputEvent('input', { bubbles: true, data: '', inputType: 'deleteByCut' }));
|
|
226
|
+
cleared = true;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
163
229
|
return { cleared };
|
|
164
230
|
})()`,
|
|
165
231
|
returnByValue: true,
|
|
@@ -281,15 +347,34 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
|
|
|
281
347
|
const encodedPrompt = JSON.stringify(prompt.trim());
|
|
282
348
|
const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
|
|
283
349
|
const fallbackSelectorLiteral = JSON.stringify(PROMPT_FALLBACK_SELECTOR);
|
|
350
|
+
const inputSelectorsLiteral = JSON.stringify(INPUT_SELECTORS);
|
|
284
351
|
const stopSelectorLiteral = JSON.stringify(STOP_BUTTON_SELECTOR);
|
|
285
352
|
const assistantSelectorLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
|
|
286
|
-
const
|
|
353
|
+
const turnSelectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
|
|
354
|
+
let baseline = typeof baselineTurns === 'number' && Number.isFinite(baselineTurns) && baselineTurns >= 0
|
|
287
355
|
? Math.floor(baselineTurns)
|
|
288
|
-
:
|
|
356
|
+
: null;
|
|
357
|
+
if (baseline === null) {
|
|
358
|
+
try {
|
|
359
|
+
const { result } = await Runtime.evaluate({
|
|
360
|
+
expression: `document.querySelectorAll(${turnSelectorLiteral}).length`,
|
|
361
|
+
returnByValue: true,
|
|
362
|
+
});
|
|
363
|
+
const raw = typeof result?.value === 'number' ? result.value : Number(result?.value);
|
|
364
|
+
if (Number.isFinite(raw)) {
|
|
365
|
+
baseline = Math.max(0, Math.floor(raw));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
// ignore; baseline stays unknown
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const baselineLiteral = baseline ?? -1;
|
|
289
373
|
// Learned: ChatGPT can echo/format text; normalize markdown and use prefix matches to detect the sent prompt.
|
|
290
374
|
const script = `(() => {
|
|
291
|
-
|
|
292
|
-
|
|
375
|
+
const editor = document.querySelector(${primarySelectorLiteral});
|
|
376
|
+
const fallback = document.querySelector(${fallbackSelectorLiteral});
|
|
377
|
+
const inputSelectors = ${inputSelectorsLiteral};
|
|
293
378
|
const normalize = (value) => {
|
|
294
379
|
let text = value?.toLowerCase?.() ?? '';
|
|
295
380
|
// Strip markdown *markers* but keep content (ChatGPT renders fence markers differently).
|
|
@@ -303,35 +388,53 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
|
|
|
303
388
|
const CONVERSATION_SELECTOR = ${JSON.stringify(CONVERSATION_TURN_SELECTOR)};
|
|
304
389
|
const articles = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
|
|
305
390
|
const normalizedTurns = articles.map((node) => normalize(node?.innerText));
|
|
391
|
+
const readValue = (node) => {
|
|
392
|
+
if (!node) return '';
|
|
393
|
+
if (node instanceof HTMLTextAreaElement) return node.value ?? '';
|
|
394
|
+
return node.innerText ?? '';
|
|
395
|
+
};
|
|
396
|
+
const isVisible = (node) => {
|
|
397
|
+
if (!node || typeof node.getBoundingClientRect !== 'function') return false;
|
|
398
|
+
const rect = node.getBoundingClientRect();
|
|
399
|
+
return rect.width > 0 && rect.height > 0;
|
|
400
|
+
};
|
|
401
|
+
const inputs = inputSelectors
|
|
402
|
+
.map((selector) => document.querySelector(selector))
|
|
403
|
+
.filter((node) => Boolean(node));
|
|
404
|
+
const visibleInputs = inputs.filter((node) => isVisible(node));
|
|
405
|
+
const activeInputs = visibleInputs.length > 0 ? visibleInputs : inputs;
|
|
306
406
|
const userMatched =
|
|
307
407
|
normalizedPrompt.length > 0 && normalizedTurns.some((text) => text.includes(normalizedPrompt));
|
|
308
408
|
const prefixMatched =
|
|
309
409
|
normalizedPromptPrefix.length > 30 &&
|
|
310
410
|
normalizedTurns.some((text) => text.includes(normalizedPromptPrefix));
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
411
|
+
const lastTurn = normalizedTurns[normalizedTurns.length - 1] ?? '';
|
|
412
|
+
const lastMatched =
|
|
413
|
+
normalizedPrompt.length > 0 &&
|
|
414
|
+
(lastTurn.includes(normalizedPrompt) ||
|
|
415
|
+
(normalizedPromptPrefix.length > 30 && lastTurn.includes(normalizedPromptPrefix)));
|
|
416
|
+
const baseline = ${baselineLiteral};
|
|
417
|
+
const hasNewTurn = baseline < 0 ? false : normalizedTurns.length > baseline;
|
|
418
|
+
const stopVisible = Boolean(document.querySelector(${stopSelectorLiteral}));
|
|
419
|
+
const assistantVisible = Boolean(
|
|
420
|
+
document.querySelector(${assistantSelectorLiteral}) ||
|
|
421
|
+
document.querySelector('[data-testid*="assistant"]'),
|
|
422
|
+
);
|
|
423
|
+
// Learned: composer clearing + stop button or assistant presence is a reliable fallback signal.
|
|
324
424
|
const editorValue = editor?.innerText ?? '';
|
|
325
425
|
const fallbackValue = fallback?.value ?? '';
|
|
326
|
-
const
|
|
426
|
+
const activeEmpty =
|
|
427
|
+
activeInputs.length === 0 ? null : activeInputs.every((node) => !String(readValue(node)).trim());
|
|
428
|
+
const composerCleared = activeEmpty ?? !(String(editorValue).trim() || String(fallbackValue).trim());
|
|
327
429
|
const href = typeof location === 'object' && location.href ? location.href : '';
|
|
328
430
|
const inConversation = /\\/c\\//.test(href);
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
431
|
+
return {
|
|
432
|
+
baseline,
|
|
433
|
+
userMatched,
|
|
434
|
+
prefixMatched,
|
|
435
|
+
lastMatched,
|
|
436
|
+
hasNewTurn,
|
|
437
|
+
stopVisible,
|
|
335
438
|
assistantVisible,
|
|
336
439
|
composerCleared,
|
|
337
440
|
inConversation,
|
|
@@ -346,12 +449,14 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
|
|
|
346
449
|
const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
|
|
347
450
|
const info = result.value;
|
|
348
451
|
const turnsCount = result.value?.turnsCount;
|
|
349
|
-
|
|
452
|
+
const matchesPrompt = Boolean(info?.lastMatched || info?.userMatched || info?.prefixMatched);
|
|
453
|
+
const baselineUnknown = typeof info?.baseline === 'number' ? info.baseline < 0 : baselineLiteral < 0;
|
|
454
|
+
if (matchesPrompt && (baselineUnknown || info?.hasNewTurn)) {
|
|
350
455
|
return typeof turnsCount === 'number' && Number.isFinite(turnsCount) ? turnsCount : null;
|
|
351
456
|
}
|
|
352
457
|
const fallbackCommit = info?.composerCleared &&
|
|
353
|
-
(
|
|
354
|
-
|
|
458
|
+
Boolean(info?.hasNewTurn) &&
|
|
459
|
+
((info?.stopVisible ?? false) || info?.assistantVisible || info?.inConversation);
|
|
355
460
|
if (fallbackCommit) {
|
|
356
461
|
return typeof turnsCount === 'number' && Number.isFinite(turnsCount) ? turnsCount : null;
|
|
357
462
|
}
|
|
@@ -374,3 +479,7 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
|
|
|
374
479
|
}
|
|
375
480
|
throw new Error('Prompt did not appear in conversation before timeout (send may have failed)');
|
|
376
481
|
}
|
|
482
|
+
// biome-ignore lint/style/useNamingConvention: test-only export used in vitest suite
|
|
483
|
+
export const __test__ = {
|
|
484
|
+
verifyPromptCommitted,
|
|
485
|
+
};
|