@youdie006/prodex 0.3.0 → 0.5.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 +26 -6
- package/dist/chatgpt-browser.d.ts +21 -0
- package/dist/chatgpt-browser.js +345 -62
- package/dist/chatgpt-browser.js.map +1 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +173 -34
- package/dist/cli.js.map +1 -1
- package/dist/store.d.ts +5 -1
- package/dist/store.js +57 -12
- package/dist/store.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -196,18 +196,30 @@ prodex pro browser ask --model Pro --pro-mode 확장 --project "my-project" "Rev
|
|
|
196
196
|
prodex pro browser ask --effort "매우 높음" "Draft the release notes"
|
|
197
197
|
```
|
|
198
198
|
|
|
199
|
-
- `--model`
|
|
200
|
-
- `--pro-mode 기본|확장` selects the Pro sub-mode; it applies only when the model is Pro.
|
|
201
|
-
- `--effort 즉시|중간|높음|"매우 높음"` sets the reasoning effort for non-Pro models. English aliases `instant`/`medium`/`high`/`max` are accepted. `--pro-mode` and `--effort` are different model axes and cannot be combined.
|
|
202
|
-
- `--project "name"` enters an existing sidebar project before sending. Creating a new project from the CLI is not supported yet; make it in ChatGPT first, then pass `--project`.
|
|
199
|
+
Prerequisite: selection matches menu items by their visible text in the **Korean ChatGPT UI** (즉시/중간/높음/매우 높음, Pro 기본/확장, "프로젝트" sidebar labels). If your ChatGPT display language is not Korean, the `--effort`/`--pro-mode`/`--project` flags will fail with "menu item not found"; either switch the UI language, send without selection flags, or use the escape hatch: `--model "<exact label>"` clicks any radio entry in the picker by the exact text your UI shows (run `pro browser models` to list them).
|
|
203
200
|
|
|
204
|
-
|
|
201
|
+
To see the labels your account currently shows, list them read-only (opens the menu, reads it, presses Escape — nothing is selected):
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
prodex pro browser models
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
- `--model` picks the composer model by its exact menu label. `Pro` is verified end-to-end. Models whose menu entry opens a submenu of variants (for example GPT-5.5) are rejected with a clear error instead of silently keeping the previous model; direct variant selection is planned.
|
|
208
|
+
- `--pro-mode 기본|확장` selects the Pro sub-mode; it applies only when the model is Pro. `확장` can think for minutes, so it raises the default `--timeout-ms` from 90000 to 300000 (an explicit `--timeout-ms` always wins).
|
|
209
|
+
- `--effort 즉시|중간|높음|"매우 높음"` sets the reasoning effort. English aliases `instant`/`medium`/`high`/`max` are accepted. The effort options and Pro share one radio group in ChatGPT, so picking an effort switches the composer to the standard reasoning model and deselects Pro; for the same reason `--pro-mode` and `--effort` cannot be combined.
|
|
210
|
+
- `--project "name"` enters an existing sidebar project before sending. `--project-new "name"` creates a new project (sidebar 새 프로젝트 popover, committed with Enter) and sends inside it. Neither can be combined with `--target-url` (the project step would navigate away from the confirmed tab), and `--project-new` never comes from saved defaults — creating a project is always an explicit per-ask choice.
|
|
211
|
+
|
|
212
|
+
Selection clicks are guarded: prodex refuses to click a control that is covered or out of view, waits for the menu to actually open instead of sleeping a fixed delay, and treats a menu that stays open after a pick as a failed selection. If any step fails, it backs out with Escape and reports a blocker instead of sending with the wrong model. Note that an applied selection stays active in your ChatGPT session after the send — switch back manually if you were on a different model.
|
|
213
|
+
|
|
214
|
+
Persist defaults so you can omit these flags on routine asks; a per-ask flag always overrides the saved default. View saved defaults with `prodex status`, clear one with the matching `--clear-*` flag, or answer a short wizard instead of remembering flags:
|
|
205
215
|
|
|
206
216
|
```bash
|
|
207
217
|
prodex setup --model Pro --pro-mode 확장 --project "my-project"
|
|
218
|
+
prodex setup --clear-project
|
|
219
|
+
prodex setup --interactive # asks model / Pro sub-mode or effort / project
|
|
208
220
|
```
|
|
209
221
|
|
|
210
|
-
Whatever selection is applied is recorded on the consult receipt (`metadata.selection`). `prodex` only clicks the picker you can see; it never selects a model, effort, or project silently outside the visible browser.
|
|
222
|
+
Whatever selection is applied is recorded on the consult receipt (`metadata.selection`); receipt display output redacts the project name, keeping only the model axes visible. `prodex` only clicks the picker you can see; it never selects a model, effort, or project silently outside the visible browser.
|
|
211
223
|
|
|
212
224
|
For a source checkout, keep the explicit send and inspection commands source-aware too:
|
|
213
225
|
|
|
@@ -230,6 +242,14 @@ prodex results reseal <task-id> --confirm-current-result
|
|
|
230
242
|
|
|
231
243
|
This writes a new local `task_completed` receipt for the current result payload. Prefer the explicit task id you just reviewed; `latest` is accepted for convenience but resolves from the current raw result list at execution time. It does not reseal unsigned receipts, forged receipts, or receipts that already point at a different result digest.
|
|
232
244
|
|
|
245
|
+
Receipts are HMAC-signed with a local key in `.bridge/receipt-key.local`. If you suspect the key was exposed, rotate it:
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
prodex receipts rotate-key
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
New receipts are signed with the fresh key; previous keys stay in the file (verification only) so receipts signed before the rotation remain trusted.
|
|
252
|
+
|
|
233
253
|
To send into a specific visible Project or thread, open that ChatGPT URL in the dedicated browser first, confirm it is the right destination, then pass the same URL:
|
|
234
254
|
|
|
235
255
|
```bash
|
|
@@ -128,12 +128,33 @@ export declare function getChatGptBrowserStatus(options?: {
|
|
|
128
128
|
port?: number;
|
|
129
129
|
timeoutMs?: number;
|
|
130
130
|
}): Promise<ChatGptBrowserStatus>;
|
|
131
|
+
export declare function menuOpenExpression(): string;
|
|
132
|
+
export declare function menuClosedExpression(): string;
|
|
133
|
+
export declare function menuItemPresentExpression(label: string): string;
|
|
131
134
|
export declare function modelButtonRectExpression(): string;
|
|
132
135
|
export declare function menuItemRectExpression(label: string): string;
|
|
133
136
|
export declare function submenuItemRectExpression(label: string): string;
|
|
137
|
+
export declare function proRadioRectExpression(): string;
|
|
134
138
|
export declare function proSubmenuExpanderRectExpression(): string;
|
|
135
139
|
export declare function projectItemRectExpression(name: string): string;
|
|
140
|
+
export declare function newProjectButtonRectExpression(): string;
|
|
141
|
+
export declare function newProjectNameInputRectExpression(): string;
|
|
142
|
+
export declare function newProjectNameInputVisibleExpression(): string;
|
|
136
143
|
export declare function sendChatGptPrompt(options: SendChatGptPromptOptions): Promise<SendChatGptPromptResult>;
|
|
144
|
+
export interface ChatGptModelOption {
|
|
145
|
+
label: string;
|
|
146
|
+
kind: "radio" | "submenu";
|
|
147
|
+
checked: boolean;
|
|
148
|
+
}
|
|
149
|
+
export interface ListChatGptModelOptionsResult {
|
|
150
|
+
url: string;
|
|
151
|
+
options: ChatGptModelOption[];
|
|
152
|
+
}
|
|
153
|
+
export declare function modelMenuOptionsExpression(): string;
|
|
154
|
+
export declare function listChatGptModelOptions(input?: {
|
|
155
|
+
port?: number;
|
|
156
|
+
timeoutMs?: number;
|
|
157
|
+
}): Promise<ListChatGptModelOptionsResult>;
|
|
137
158
|
export declare function statusExpression(): string;
|
|
138
159
|
export declare function setComposerTextExpression(text: string): string;
|
|
139
160
|
export declare function submitExpression(): string;
|
package/dist/chatgpt-browser.js
CHANGED
|
@@ -84,8 +84,15 @@ export function isUsableChatGptAnswer(answer) {
|
|
|
84
84
|
const normalized = answer.trim();
|
|
85
85
|
if (!normalized)
|
|
86
86
|
return false;
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
const stripped = normalized.replace(/\.+$/, "");
|
|
88
|
+
const lineCount = normalized.split(/\r?\n/).filter(Boolean).length;
|
|
89
|
+
if (/^(생각 중|thinking|thought for|thought about)/i.test(stripped)) {
|
|
90
|
+
return lineCount > 1;
|
|
91
|
+
}
|
|
92
|
+
// Model-prefixed thinking status, e.g. "Pro 생각 중": a single short line
|
|
93
|
+
// ending in 생각 중 is the placeholder, not an answer.
|
|
94
|
+
if (lineCount <= 1 && /(^|\s)생각 중$/.test(stripped)) {
|
|
95
|
+
return false;
|
|
89
96
|
}
|
|
90
97
|
return true;
|
|
91
98
|
}
|
|
@@ -396,57 +403,128 @@ export async function getChatGptBrowserStatus(options = {}) {
|
|
|
396
403
|
blocker
|
|
397
404
|
};
|
|
398
405
|
}
|
|
399
|
-
async function dispatchMouseClickAt(cdp, x, y) {
|
|
400
|
-
await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
|
|
401
|
-
await cdp.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", clickCount: 1 });
|
|
402
|
-
await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", clickCount: 1 });
|
|
403
|
-
}
|
|
404
406
|
async function dispatchEscapeKey(cdp) {
|
|
405
407
|
await cdp.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
|
|
406
408
|
await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
|
|
407
409
|
}
|
|
408
|
-
|
|
410
|
+
// Poll a boolean page expression instead of sleeping a fixed duration, so slow
|
|
411
|
+
// renders wait longer and fast ones do not waste time.
|
|
412
|
+
async function waitForExpressionTrue(cdp, expression, timeoutMs, intervalMs = 150) {
|
|
413
|
+
const startedAt = Date.now();
|
|
414
|
+
for (;;) {
|
|
415
|
+
if (await cdp.evaluate(expression))
|
|
416
|
+
return true;
|
|
417
|
+
if (Date.now() - startedAt >= timeoutMs)
|
|
418
|
+
return false;
|
|
419
|
+
await sleep(intervalMs);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const MENU_OPEN_TIMEOUT_MS = 5_000;
|
|
423
|
+
const MENU_SETTLE_TIMEOUT_MS = 5_000;
|
|
424
|
+
const PROJECT_NAVIGATION_TIMEOUT_MS = 8_000;
|
|
425
|
+
export function menuOpenExpression() {
|
|
426
|
+
return `Boolean(document.querySelector('[data-testid="composer-intelligence-picker-content"]'))`;
|
|
427
|
+
}
|
|
428
|
+
export function menuClosedExpression() {
|
|
429
|
+
return `!document.querySelector('[data-testid="composer-intelligence-picker-content"]')`;
|
|
430
|
+
}
|
|
431
|
+
export function menuItemPresentExpression(label) {
|
|
432
|
+
return `[...document.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')].some((r) => (r.textContent || "").trim() === ${JSON.stringify(label)})`;
|
|
433
|
+
}
|
|
434
|
+
// Shared click-point resolver: scroll the target into view, tag it with a
|
|
435
|
+
// temporary attribute, and return its center. The coverage check happens later
|
|
436
|
+
// in verifiedClickAt AFTER the pointer hovers the point, because ChatGPT uses
|
|
437
|
+
// hover-revealed controls (e.g. the Pro sub-mode chevron) that only become
|
|
438
|
+
// hit-testable once the row is hovered.
|
|
439
|
+
const CLICK_POINT_SNIPPET = `
|
|
440
|
+
const clickPoint = (el, yCap) => {
|
|
441
|
+
document.querySelectorAll('[data-prodex-click]').forEach((n) => n.removeAttribute('data-prodex-click'));
|
|
442
|
+
el.scrollIntoView({ block: "center", inline: "nearest" });
|
|
443
|
+
const r = el.getBoundingClientRect();
|
|
444
|
+
if (r.width < 1 || r.height < 1) return { ok: false, reason: "target element has no visible area" };
|
|
445
|
+
el.setAttribute('data-prodex-click', '1');
|
|
446
|
+
const x = Math.round(r.x + r.width / 2);
|
|
447
|
+
const y = Math.round(r.y + (yCap ? Math.min(r.height / 2, yCap) : r.height / 2));
|
|
448
|
+
return { ok: true, x, y };
|
|
449
|
+
};`;
|
|
450
|
+
function hoverVerifyExpression(x, y) {
|
|
409
451
|
return `(() => {
|
|
452
|
+
const el = document.querySelector('[data-prodex-click]');
|
|
453
|
+
const hit = document.elementFromPoint(${x}, ${y});
|
|
454
|
+
const ok = Boolean(el && hit && (hit === el || el.contains(hit) || hit.contains(el)));
|
|
455
|
+
if (el) el.removeAttribute('data-prodex-click');
|
|
456
|
+
return ok;
|
|
457
|
+
})()`;
|
|
458
|
+
}
|
|
459
|
+
// Move the pointer first, give hover styles a beat to apply, then confirm the
|
|
460
|
+
// tagged target is what would actually receive the click before pressing.
|
|
461
|
+
// These clicks land in the user's real session, so a covered or scrolled-out
|
|
462
|
+
// target must fail loudly instead of clicking whatever sits at the point.
|
|
463
|
+
async function verifiedClickAt(cdp, x, y, label) {
|
|
464
|
+
await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
|
|
465
|
+
await sleep(150);
|
|
466
|
+
const onTarget = await cdp.evaluate(hoverVerifyExpression(x, y));
|
|
467
|
+
if (!onTarget) {
|
|
468
|
+
throw new Error(`Refusing to click "${label}": another element covers its click point (overlay, scroll, or layout change). Retry, or interact manually in the visible browser.`);
|
|
469
|
+
}
|
|
470
|
+
await cdp.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", clickCount: 1 });
|
|
471
|
+
await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", clickCount: 1 });
|
|
472
|
+
}
|
|
473
|
+
export function modelButtonRectExpression() {
|
|
474
|
+
return `(() => {${CLICK_POINT_SNIPPET}
|
|
410
475
|
const c = document.querySelector('#prompt-textarea,[contenteditable="true"],textarea');
|
|
411
476
|
const scope = c ? (c.closest('form') || document) : document;
|
|
412
477
|
const b = [...scope.querySelectorAll('[aria-haspopup="menu"]')].find((el) => {
|
|
413
478
|
const t = (el.textContent || "").trim();
|
|
414
479
|
const aria = el.getAttribute("aria-label") || "";
|
|
415
|
-
return /\\S/.test(t) && !/파일|첨부|받아쓰기|음성|dictation|attach/i.test(t + aria);
|
|
480
|
+
return /\\S/.test(t) && !/파일|첨부|받아쓰기|음성|dictation|attach|file|voice|record|search|mic/i.test(t + aria);
|
|
416
481
|
});
|
|
417
482
|
if (!b) return { ok: false, reason: "model selector button not found" };
|
|
418
|
-
|
|
419
|
-
return { ok: true, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
483
|
+
return clickPoint(b);
|
|
420
484
|
})()`;
|
|
421
485
|
}
|
|
422
486
|
export function menuItemRectExpression(label) {
|
|
423
|
-
return `(() => {
|
|
487
|
+
return `(() => {${CLICK_POINT_SNIPPET}
|
|
424
488
|
const m = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
|
|
425
489
|
if (!m) return { ok: false, reason: "reasoning/model menu did not open" };
|
|
426
490
|
const items = [...m.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')];
|
|
427
491
|
const it = items.find((r) => (r.textContent || "").trim() === ${JSON.stringify(label)});
|
|
428
492
|
if (!it) return { ok: false, reason: "menu item not found", available: items.map((r) => (r.textContent || "").trim()).slice(0, 12) };
|
|
429
|
-
const
|
|
430
|
-
|
|
493
|
+
const point = clickPoint(it);
|
|
494
|
+
if (!point.ok) return point;
|
|
495
|
+
return { ...point, role: it.getAttribute("role"), haspopup: it.getAttribute("aria-haspopup") };
|
|
431
496
|
})()`;
|
|
432
497
|
}
|
|
433
498
|
export function submenuItemRectExpression(label) {
|
|
434
|
-
return `(() => {
|
|
499
|
+
return `(() => {${CLICK_POINT_SNIPPET}
|
|
435
500
|
const items = [...document.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')];
|
|
436
501
|
const it = items.find((r) => (r.textContent || "").trim() === ${JSON.stringify(label)});
|
|
437
502
|
if (!it) return { ok: false, reason: "submenu item not found" };
|
|
438
|
-
|
|
439
|
-
return { ok: true, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
503
|
+
return clickPoint(it);
|
|
440
504
|
})()`;
|
|
441
505
|
}
|
|
442
506
|
// "Pro" itself is a plain radio; its sub-modes (Pro 기본 / Pro 확장) live behind an
|
|
443
507
|
// unlabeled aria-haspopup="menu" chevron sitting next to the Pro radio. Clicking
|
|
444
508
|
// that chevron (hovering does not work) opens the sub-mode submenu.
|
|
509
|
+
// The Pro radio's visible label reflects the active sub-mode ("Pro", "Pro 기본",
|
|
510
|
+
// or "Pro 확장"), so it must be matched by prefix, never by exact text.
|
|
511
|
+
const PRO_RADIO_FINDER_SNIPPET = `
|
|
512
|
+
const findProRadio = (scope) =>
|
|
513
|
+
[...scope.querySelectorAll('[role="menuitemradio"]')].find((r) => /^Pro( |$)/.test((r.textContent || "").trim()));`;
|
|
514
|
+
export function proRadioRectExpression() {
|
|
515
|
+
return `(() => {${CLICK_POINT_SNIPPET}${PRO_RADIO_FINDER_SNIPPET}
|
|
516
|
+
const m = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
|
|
517
|
+
if (!m) return { ok: false, reason: "reasoning/model menu did not open" };
|
|
518
|
+
const proRadio = findProRadio(m);
|
|
519
|
+
if (!proRadio) return { ok: false, reason: "Pro option not found in the model menu" };
|
|
520
|
+
return clickPoint(proRadio);
|
|
521
|
+
})()`;
|
|
522
|
+
}
|
|
445
523
|
export function proSubmenuExpanderRectExpression() {
|
|
446
|
-
return `(() => {
|
|
524
|
+
return `(() => {${CLICK_POINT_SNIPPET}${PRO_RADIO_FINDER_SNIPPET}
|
|
447
525
|
const m = document.querySelector('[data-testid="composer-intelligence-picker-content"],[role="menu"]');
|
|
448
526
|
if (!m) return { ok: false, reason: "model menu is not open" };
|
|
449
|
-
const proRadio =
|
|
527
|
+
const proRadio = findProRadio(m);
|
|
450
528
|
if (!proRadio) return { ok: false, reason: "Pro option not found in the model menu" };
|
|
451
529
|
const proTop = proRadio.getBoundingClientRect().top;
|
|
452
530
|
const expanders = [...m.querySelectorAll('[role="menuitem"][aria-haspopup="menu"]')].filter((e) => {
|
|
@@ -460,12 +538,11 @@ export function proSubmenuExpanderRectExpression() {
|
|
|
460
538
|
if (dy < bestDy) { best = e; bestDy = dy; }
|
|
461
539
|
}
|
|
462
540
|
if (!best || bestDy > 40) return { ok: false, reason: "Pro sub-mode expander not found next to Pro" };
|
|
463
|
-
|
|
464
|
-
return { ok: true, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
541
|
+
return clickPoint(best);
|
|
465
542
|
})()`;
|
|
466
543
|
}
|
|
467
544
|
export function projectItemRectExpression(name) {
|
|
468
|
-
return `(() => {
|
|
545
|
+
return `(() => {${CLICK_POINT_SNIPPET}
|
|
469
546
|
const opt = [...document.querySelectorAll('[aria-label*="프로젝트 옵션 열기"]')].find((b) => (b.getAttribute("aria-label") || "").startsWith(${JSON.stringify(name)}));
|
|
470
547
|
let target = opt ? (opt.closest('a,[role="link"],li') || opt.parentElement) : null;
|
|
471
548
|
if (!target) {
|
|
@@ -476,10 +553,17 @@ export function projectItemRectExpression(name) {
|
|
|
476
553
|
}
|
|
477
554
|
}
|
|
478
555
|
if (!target) return { ok: false, reason: "project not found in sidebar" };
|
|
479
|
-
|
|
480
|
-
return { ok: true, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + Math.min(r.height / 2, 18)) };
|
|
556
|
+
return clickPoint(target, 18);
|
|
481
557
|
})()`;
|
|
482
558
|
}
|
|
559
|
+
// Clicking a menuitemradio commits the choice and closes the picker; a menu
|
|
560
|
+
// that stays open means the click did not land where we intended.
|
|
561
|
+
async function assertSelectionCommitted(cdp, label) {
|
|
562
|
+
const closed = await waitForExpressionTrue(cdp, menuClosedExpression(), MENU_SETTLE_TIMEOUT_MS);
|
|
563
|
+
if (!closed) {
|
|
564
|
+
throw new Error(`ChatGPT selection "${label}" did not commit; the model menu stayed open. Retry, or pick it manually in the visible browser.`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
483
567
|
async function selectModelReasoning(cdp, options) {
|
|
484
568
|
if (!options.model && !options.proMode && !options.effort)
|
|
485
569
|
return;
|
|
@@ -487,52 +571,178 @@ async function selectModelReasoning(cdp, options) {
|
|
|
487
571
|
if (!button.ok || button.x === undefined || button.y === undefined) {
|
|
488
572
|
throw new Error(button.reason ?? "Could not open the ChatGPT model selector");
|
|
489
573
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
const
|
|
496
|
-
if (
|
|
574
|
+
try {
|
|
575
|
+
await verifiedClickAt(cdp, button.x, button.y, "model selector");
|
|
576
|
+
const opened = await waitForExpressionTrue(cdp, menuOpenExpression(), MENU_OPEN_TIMEOUT_MS);
|
|
577
|
+
if (!opened)
|
|
578
|
+
throw new Error("ChatGPT model menu did not open after clicking the selector");
|
|
579
|
+
const wantsProMode = Boolean(options.proMode) && (!options.model || /pro/i.test(options.model));
|
|
580
|
+
if (wantsProMode && options.proMode) {
|
|
581
|
+
// Open the Pro sub-mode submenu via the chevron, then pick 기본/확장.
|
|
582
|
+
const expander = await cdp.evaluate(proSubmenuExpanderRectExpression());
|
|
583
|
+
if (!expander.ok || expander.x === undefined || expander.y === undefined) {
|
|
584
|
+
throw new Error(expander.reason ?? "Could not open the ChatGPT Pro sub-mode submenu");
|
|
585
|
+
}
|
|
586
|
+
await verifiedClickAt(cdp, expander.x, expander.y, "Pro sub-mode expander");
|
|
587
|
+
const subLabel = `Pro ${options.proMode}`;
|
|
588
|
+
const subVisible = await waitForExpressionTrue(cdp, menuItemPresentExpression(subLabel), MENU_OPEN_TIMEOUT_MS);
|
|
589
|
+
if (!subVisible)
|
|
590
|
+
throw new Error(`ChatGPT Pro sub-mode not found: ${subLabel}`);
|
|
591
|
+
const sub = await cdp.evaluate(submenuItemRectExpression(subLabel));
|
|
592
|
+
if (!sub.ok || sub.x === undefined || sub.y === undefined) {
|
|
593
|
+
throw new Error(sub.reason ?? `ChatGPT Pro sub-mode not clickable: ${subLabel}`);
|
|
594
|
+
}
|
|
595
|
+
await verifiedClickAt(cdp, sub.x, sub.y, subLabel);
|
|
596
|
+
await assertSelectionCommitted(cdp, subLabel);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
const primaryLabel = options.model ?? options.effort;
|
|
600
|
+
if (!primaryLabel) {
|
|
497
601
|
await dispatchEscapeKey(cdp);
|
|
498
|
-
|
|
602
|
+
return;
|
|
499
603
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
604
|
+
// --model Pro without a sub-mode: the Pro radio's label carries the active
|
|
605
|
+
// sub-mode, so exact-label lookup would miss it.
|
|
606
|
+
if (options.model && /^pro$/i.test(options.model)) {
|
|
607
|
+
const proRadio = await cdp.evaluate(proRadioRectExpression());
|
|
608
|
+
if (!proRadio.ok || proRadio.x === undefined || proRadio.y === undefined) {
|
|
609
|
+
throw new Error(proRadio.reason ?? "Pro option not found in the model menu");
|
|
610
|
+
}
|
|
611
|
+
await verifiedClickAt(cdp, proRadio.x, proRadio.y, "Pro");
|
|
612
|
+
await assertSelectionCommitted(cdp, "Pro");
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const primary = await cdp.evaluate(menuItemRectExpression(primaryLabel));
|
|
616
|
+
if (!primary.ok || primary.x === undefined || primary.y === undefined) {
|
|
617
|
+
throw new Error(`ChatGPT model/effort option not found: ${primaryLabel}${primary.available ? ` (available: ${primary.available.join(", ")})` : ""}`);
|
|
618
|
+
}
|
|
619
|
+
if (primary.role === "menuitem" && primary.haspopup === "menu") {
|
|
620
|
+
throw new Error(`ChatGPT model "${primaryLabel}" opens a submenu of variants instead of committing; selecting it is not supported yet. Supported today: reasoning efforts (--effort) and Pro via --pro-mode.`);
|
|
621
|
+
}
|
|
622
|
+
await verifiedClickAt(cdp, primary.x, primary.y, primaryLabel);
|
|
623
|
+
await assertSelectionCommitted(cdp, primaryLabel);
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
// Leave the user's screen clean: back out of any open menu before rethrowing.
|
|
627
|
+
try {
|
|
628
|
+
await dispatchEscapeKey(cdp);
|
|
629
|
+
await sleep(150);
|
|
505
630
|
await dispatchEscapeKey(cdp);
|
|
506
|
-
throw new Error(`ChatGPT Pro sub-mode not found: ${subLabel}`);
|
|
507
631
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
632
|
+
catch {
|
|
633
|
+
// best effort only
|
|
634
|
+
}
|
|
635
|
+
throw error;
|
|
511
636
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
637
|
+
}
|
|
638
|
+
export function newProjectButtonRectExpression() {
|
|
639
|
+
return `(() => {${CLICK_POINT_SNIPPET}
|
|
640
|
+
const el =
|
|
641
|
+
document.querySelector('button[aria-label="새 프로젝트"]') ||
|
|
642
|
+
[...document.querySelectorAll('button,[role="button"]')].find((b) =>
|
|
643
|
+
/새 프로젝트|new project/i.test(((b.textContent || "") + (b.getAttribute("aria-label") || "")).trim())
|
|
644
|
+
);
|
|
645
|
+
if (!el) return { ok: false, reason: "new-project button not found in the sidebar" };
|
|
646
|
+
return clickPoint(el);
|
|
647
|
+
})()`;
|
|
648
|
+
}
|
|
649
|
+
// The new-project popover has a single visible text input for the name;
|
|
650
|
+
// creation is committed by pressing Enter (there is no dedicated create button).
|
|
651
|
+
const VISIBLE_TEXT_INPUT_FINDER = `
|
|
652
|
+
const findNameInput = () =>
|
|
653
|
+
[...document.querySelectorAll('input[type="text"]')].find((i) => {
|
|
654
|
+
const r = i.getBoundingClientRect();
|
|
655
|
+
return r.width > 0 && r.height > 0;
|
|
656
|
+
});`;
|
|
657
|
+
export function newProjectNameInputRectExpression() {
|
|
658
|
+
return `(() => {${CLICK_POINT_SNIPPET}${VISIBLE_TEXT_INPUT_FINDER}
|
|
659
|
+
const input = findNameInput();
|
|
660
|
+
if (!input) return { ok: false, reason: "new-project name input did not appear" };
|
|
661
|
+
return clickPoint(input);
|
|
662
|
+
})()`;
|
|
663
|
+
}
|
|
664
|
+
export function newProjectNameInputVisibleExpression() {
|
|
665
|
+
return `(() => {${VISIBLE_TEXT_INPUT_FINDER}
|
|
666
|
+
return Boolean(findNameInput());
|
|
667
|
+
})()`;
|
|
668
|
+
}
|
|
669
|
+
function newProjectNameInputValueExpression() {
|
|
670
|
+
return `(() => {${VISIBLE_TEXT_INPUT_FINDER}
|
|
671
|
+
const input = findNameInput();
|
|
672
|
+
return input ? input.value : null;
|
|
673
|
+
})()`;
|
|
674
|
+
}
|
|
675
|
+
async function dispatchEnterKey(cdp) {
|
|
676
|
+
await cdp.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, text: "\r" });
|
|
677
|
+
await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13 });
|
|
678
|
+
}
|
|
679
|
+
// Creates a project in the user's real ChatGPT account: sidebar 새 프로젝트 →
|
|
680
|
+
// type the name into the popover input → Enter → wait for navigation into the
|
|
681
|
+
// new project. Backs out with Escape on any failure.
|
|
682
|
+
async function createChatGptProject(cdp, name) {
|
|
683
|
+
const hrefBefore = await cdp.evaluate("location.href");
|
|
684
|
+
const button = await cdp.evaluate(newProjectButtonRectExpression());
|
|
685
|
+
if (!button.ok || button.x === undefined || button.y === undefined) {
|
|
686
|
+
throw new Error(button.reason ?? "new-project button not found in the sidebar");
|
|
516
687
|
}
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
await
|
|
520
|
-
|
|
688
|
+
try {
|
|
689
|
+
await verifiedClickAt(cdp, button.x, button.y, "새 프로젝트");
|
|
690
|
+
const inputVisible = await waitForExpressionTrue(cdp, newProjectNameInputVisibleExpression(), MENU_OPEN_TIMEOUT_MS);
|
|
691
|
+
if (!inputVisible)
|
|
692
|
+
throw new Error("new-project name input did not appear");
|
|
693
|
+
const input = await cdp.evaluate(newProjectNameInputRectExpression());
|
|
694
|
+
if (!input.ok || input.x === undefined || input.y === undefined) {
|
|
695
|
+
throw new Error(input.reason ?? "new-project name input is not clickable");
|
|
696
|
+
}
|
|
697
|
+
await verifiedClickAt(cdp, input.x, input.y, "project name input");
|
|
698
|
+
await sleep(150);
|
|
699
|
+
await cdp.send("Input.insertText", { text: name });
|
|
700
|
+
await sleep(150);
|
|
701
|
+
const typed = await cdp.evaluate(newProjectNameInputValueExpression());
|
|
702
|
+
if (typed !== name) {
|
|
703
|
+
throw new Error(`could not type the project name into the new-project input (saw: ${typed === null ? "no input" : JSON.stringify(typed)})`);
|
|
704
|
+
}
|
|
705
|
+
await dispatchEnterKey(cdp);
|
|
706
|
+
const navigated = await waitForExpressionTrue(cdp, `location.href !== ${JSON.stringify(hrefBefore)}`, PROJECT_NAVIGATION_TIMEOUT_MS);
|
|
707
|
+
if (!navigated)
|
|
708
|
+
throw new Error(`Creating project "${name}" did not navigate into the new project`);
|
|
709
|
+
const composerReady = await waitForExpressionTrue(cdp, `Boolean(document.querySelector('#prompt-textarea,[contenteditable="true"],textarea'))`, PROJECT_NAVIGATION_TIMEOUT_MS);
|
|
710
|
+
if (!composerReady)
|
|
711
|
+
throw new Error(`ChatGPT composer did not appear after creating project "${name}"`);
|
|
712
|
+
}
|
|
713
|
+
catch (error) {
|
|
714
|
+
try {
|
|
715
|
+
await dispatchEscapeKey(cdp);
|
|
716
|
+
await sleep(150);
|
|
717
|
+
await dispatchEscapeKey(cdp);
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
// best effort only
|
|
721
|
+
}
|
|
722
|
+
throw error;
|
|
521
723
|
}
|
|
522
|
-
await dispatchMouseClickAt(cdp, primary.x, primary.y);
|
|
523
|
-
await sleep(400);
|
|
524
724
|
}
|
|
525
725
|
async function selectProject(cdp, options) {
|
|
526
726
|
if (options.projectNew) {
|
|
527
|
-
|
|
727
|
+
await createChatGptProject(cdp, options.projectNew);
|
|
728
|
+
return;
|
|
528
729
|
}
|
|
529
|
-
if (options.project)
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
730
|
+
if (!options.project)
|
|
731
|
+
return;
|
|
732
|
+
const hrefBefore = await cdp.evaluate("location.href");
|
|
733
|
+
const hit = await cdp.evaluate(projectItemRectExpression(options.project));
|
|
734
|
+
if (!hit.ok || hit.x === undefined || hit.y === undefined) {
|
|
735
|
+
const detail = hit.reason && hit.reason !== "project not found in sidebar" ? ` (${hit.reason})` : "";
|
|
736
|
+
throw new Error(`ChatGPT project not found in sidebar: ${options.project}${detail}`);
|
|
737
|
+
}
|
|
738
|
+
await verifiedClickAt(cdp, hit.x, hit.y, `project ${options.project}`);
|
|
739
|
+
const navigated = await waitForExpressionTrue(cdp, `location.href !== ${JSON.stringify(hrefBefore)}`, PROJECT_NAVIGATION_TIMEOUT_MS);
|
|
740
|
+
if (!navigated) {
|
|
741
|
+
throw new Error(`Clicking project "${options.project}" did not navigate the visible tab. If the tab is already inside this project, omit --project and retry.`);
|
|
742
|
+
}
|
|
743
|
+
const composerReady = await waitForExpressionTrue(cdp, `Boolean(document.querySelector('#prompt-textarea,[contenteditable="true"],textarea'))`, PROJECT_NAVIGATION_TIMEOUT_MS);
|
|
744
|
+
if (!composerReady) {
|
|
745
|
+
throw new Error(`ChatGPT composer did not appear after entering project "${options.project}"`);
|
|
536
746
|
}
|
|
537
747
|
}
|
|
538
748
|
export async function sendChatGptPrompt(options) {
|
|
@@ -566,12 +776,16 @@ export async function sendChatGptPrompt(options) {
|
|
|
566
776
|
if (busyBlocker) {
|
|
567
777
|
throw new ChatGptBrowserBlockerError(busyBlocker);
|
|
568
778
|
}
|
|
569
|
-
|
|
779
|
+
let beforeSubmit;
|
|
570
780
|
const cdp = await connectCdp(page.webSocketDebuggerUrl);
|
|
571
781
|
try {
|
|
572
782
|
await cdp.send("Runtime.enable");
|
|
573
783
|
await selectProject(cdp, options);
|
|
574
784
|
await selectModelReasoning(cdp, options);
|
|
785
|
+
// Capture the answer baseline AFTER any project navigation or model switch
|
|
786
|
+
// so assistant-message counts compare within the thread we actually send
|
|
787
|
+
// into; a --project/--project-new hop lands on a page with its own counts.
|
|
788
|
+
beforeSubmit = await evaluateOnPage(page, answerExpression());
|
|
575
789
|
const inserted = await cdp.evaluate(setComposerTextExpression(options.prompt));
|
|
576
790
|
if (!inserted.ok)
|
|
577
791
|
throw new Error(inserted.reason ?? "Could not insert text into ChatGPT composer");
|
|
@@ -638,6 +852,71 @@ export async function sendChatGptPrompt(options) {
|
|
|
638
852
|
warnings: []
|
|
639
853
|
};
|
|
640
854
|
}
|
|
855
|
+
export function modelMenuOptionsExpression() {
|
|
856
|
+
return `(() => {
|
|
857
|
+
const m = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
|
|
858
|
+
if (!m) return [];
|
|
859
|
+
return [...m.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')]
|
|
860
|
+
.map((it) => ({
|
|
861
|
+
label: (it.textContent || "").trim(),
|
|
862
|
+
kind: it.getAttribute("aria-haspopup") === "menu" ? "submenu" : "radio",
|
|
863
|
+
checked: it.getAttribute("aria-checked") === "true"
|
|
864
|
+
}))
|
|
865
|
+
.filter((o) => o.label.length > 0);
|
|
866
|
+
})()`;
|
|
867
|
+
}
|
|
868
|
+
// Read-only discovery: open the composer model menu, read the option labels,
|
|
869
|
+
// and press Escape. Nothing is clicked inside the menu, so the user's model
|
|
870
|
+
// selection is never changed.
|
|
871
|
+
export async function listChatGptModelOptions(input = {}) {
|
|
872
|
+
const port = input.port ?? 9333;
|
|
873
|
+
const timeoutMs = input.timeoutMs ?? 15_000;
|
|
874
|
+
const pageResult = await findChatGptPage(port, computePageDiscoveryTimeout(timeoutMs), undefined);
|
|
875
|
+
if (!pageResult.ok) {
|
|
876
|
+
throwBlockerOrError(pageResult.blocker, "ChatGPT browser page is not available");
|
|
877
|
+
}
|
|
878
|
+
if (!pageResult.page) {
|
|
879
|
+
if (pageResult.blocker)
|
|
880
|
+
throw new ChatGptBrowserBlockerError(pageResult.blocker);
|
|
881
|
+
assertChatGptPageAvailable();
|
|
882
|
+
}
|
|
883
|
+
const page = pageResult.page;
|
|
884
|
+
const status = await evaluateOnPage(page, statusExpression());
|
|
885
|
+
const blocker = detectChatGptPageBlocker(status);
|
|
886
|
+
if (blocker)
|
|
887
|
+
throw new ChatGptBrowserBlockerError(blocker);
|
|
888
|
+
assertChatGptReadyForPrompt(inferChatGptPageLoggedInLikely(status), status.hasComposer);
|
|
889
|
+
assertVisibleChatGptTab(status.visibilityState, status.url, undefined);
|
|
890
|
+
const cdp = await connectCdp(page.webSocketDebuggerUrl);
|
|
891
|
+
try {
|
|
892
|
+
await cdp.send("Runtime.enable");
|
|
893
|
+
const button = await cdp.evaluate(modelButtonRectExpression());
|
|
894
|
+
if (!button.ok || button.x === undefined || button.y === undefined) {
|
|
895
|
+
throw new Error(button.reason ?? "Could not open the ChatGPT model selector");
|
|
896
|
+
}
|
|
897
|
+
try {
|
|
898
|
+
await verifiedClickAt(cdp, button.x, button.y, "model selector");
|
|
899
|
+
const opened = await waitForExpressionTrue(cdp, menuOpenExpression(), MENU_OPEN_TIMEOUT_MS);
|
|
900
|
+
if (!opened)
|
|
901
|
+
throw new Error("ChatGPT model menu did not open after clicking the selector");
|
|
902
|
+
const options = await cdp.evaluate(modelMenuOptionsExpression());
|
|
903
|
+
return { url: status.url, options };
|
|
904
|
+
}
|
|
905
|
+
finally {
|
|
906
|
+
try {
|
|
907
|
+
await dispatchEscapeKey(cdp);
|
|
908
|
+
await sleep(150);
|
|
909
|
+
await dispatchEscapeKey(cdp);
|
|
910
|
+
}
|
|
911
|
+
catch {
|
|
912
|
+
// best effort only
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
finally {
|
|
917
|
+
cdp.close();
|
|
918
|
+
}
|
|
919
|
+
}
|
|
641
920
|
async function findChatGptPage(port, timeoutMs, targetUrl) {
|
|
642
921
|
try {
|
|
643
922
|
const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
@@ -810,7 +1089,9 @@ export function statusExpression() {
|
|
|
810
1089
|
}));
|
|
811
1090
|
const assistant = messages.filter((message) => message.role === "assistant").at(-1);
|
|
812
1091
|
const answer = assistant?.text || "";
|
|
813
|
-
const
|
|
1092
|
+
const ansStripped = answer.trim().replace(/\\.+$/, "");
|
|
1093
|
+
const ansLines = ansStripped.split(/\\r?\\n/).filter((l) => l.trim());
|
|
1094
|
+
const placeholder = /^(생각 중|thinking|thought for|thought about)/i.test(ansStripped) || (ansLines.length <= 1 && /(^|\\s)생각 중$/.test(ansStripped));
|
|
814
1095
|
const hasComposer = Boolean(findChatGptComposerCandidate());
|
|
815
1096
|
return {
|
|
816
1097
|
title: document.title,
|
|
@@ -960,7 +1241,9 @@ function answerExpression() {
|
|
|
960
1241
|
.map((node) => (node.innerText || node.getAttribute("aria-label") || node.getAttribute("data-testid") || "").trim())
|
|
961
1242
|
.filter(Boolean);
|
|
962
1243
|
const answer = assistant?.text || "";
|
|
963
|
-
const
|
|
1244
|
+
const ansStripped = answer.trim().replace(/\\.+$/, "");
|
|
1245
|
+
const ansLines = ansStripped.split(/\\r?\\n/).filter((l) => l.trim());
|
|
1246
|
+
const placeholder = /^(생각 중|thinking|thought for|thought about)/i.test(ansStripped) || (ansLines.length <= 1 && /(^|\\s)생각 중$/.test(ansStripped));
|
|
964
1247
|
return {
|
|
965
1248
|
title: document.title,
|
|
966
1249
|
url: location.href,
|