@youdie006/prodex 0.36.3 → 0.36.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chatgpt-browser.js +97 -24
- package/dist/picker-interaction.js +40 -0
- package/dist/store.js +38 -9
- package/package.json +1 -1
- package/scripts/release-check.mjs +20 -3
package/dist/chatgpt-browser.js
CHANGED
|
@@ -5,6 +5,7 @@ import net from "node:net";
|
|
|
5
5
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import os from "node:os";
|
|
8
|
+
import { readPowerSliderSelection } from "./picker-interaction.js";
|
|
8
9
|
export class ChatGptBrowserBlockerError extends Error {
|
|
9
10
|
blocker;
|
|
10
11
|
constructor(blocker) {
|
|
@@ -654,23 +655,38 @@ export function proModeNotAppliedWarning(proMode, sliderEffort) {
|
|
|
654
655
|
"Drop --pro-mode here, or clear a saved default with `prodex setup --clear-pro-mode`.");
|
|
655
656
|
}
|
|
656
657
|
/**
|
|
657
|
-
* Which
|
|
658
|
+
* Which control each request belongs to.
|
|
658
659
|
*
|
|
659
|
-
*
|
|
660
|
-
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
*
|
|
664
|
-
*
|
|
660
|
+
* The picker holds two of them: a `role="slider"` for effort (Instant..Pro) and
|
|
661
|
+
* `menuitemradio` rows for the models. Selection used to send both through the
|
|
662
|
+
* slider (`effort ?? model`), so a model name walked the slider looking for a
|
|
663
|
+
* step it could never be - and the refusal quoted the slider's TEXT, which
|
|
664
|
+
* contains the model names, so the message read as "it is right there in the
|
|
665
|
+
* list". `--model Pro` only ever worked when the slider happened to sit on its
|
|
666
|
+
* top step; once it moved to Instant the same command failed.
|
|
667
|
+
*
|
|
668
|
+
* Pro stopped being a model and became that top step, but it is the pinned
|
|
669
|
+
* default in every repo configured before the change, so it is honoured where
|
|
670
|
+
* it now lives rather than failing every one of them - out loud.
|
|
665
671
|
*/
|
|
666
|
-
export function
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
if (
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
672
|
+
export function pickerSelectionPlan(input) {
|
|
673
|
+
const plan = {};
|
|
674
|
+
const modelIsNowAnEffort = input.model !== undefined && /^\s*pro\s*$/i.test(input.model);
|
|
675
|
+
if (input.effort !== undefined)
|
|
676
|
+
plan.sliderLabel = input.effort;
|
|
677
|
+
else if (modelIsNowAnEffort)
|
|
678
|
+
plan.sliderLabel = input.model;
|
|
679
|
+
if (input.model !== undefined && !modelIsNowAnEffort)
|
|
680
|
+
plan.modelLabel = input.model;
|
|
681
|
+
if (modelIsNowAnEffort) {
|
|
682
|
+
plan.warning =
|
|
683
|
+
`model_is_now_an_effort: ChatGPT's picker lists Pro as an effort step, not a model, so --model ${input.model} was ` +
|
|
684
|
+
(input.effort === undefined
|
|
685
|
+
? "applied to the effort slider instead. Say --effort Pro to be explicit"
|
|
686
|
+
: `ignored in favour of --effort ${input.effort}`) +
|
|
687
|
+
", or clear a saved default with `prodex setup --model \"\"`.";
|
|
688
|
+
}
|
|
689
|
+
return plan;
|
|
674
690
|
}
|
|
675
691
|
export function chatGptBusyBlocker(state) {
|
|
676
692
|
// Waiting for a choice is not generating. Conflating them turned a click
|
|
@@ -1285,6 +1301,14 @@ const CLICK_POINT_SNIPPET = `
|
|
|
1285
1301
|
el.scrollIntoView({ block: "center", inline: "nearest" });
|
|
1286
1302
|
const r = el.getBoundingClientRect();
|
|
1287
1303
|
if (r.width < 1 || r.height < 1) return { ok: false, reason: "target element has no visible area" };
|
|
1304
|
+
// An inert row cannot be clicked at ANY point, so say that instead of
|
|
1305
|
+
// blaming whatever hit-testing returns instead. Measured on the current
|
|
1306
|
+
// picker: the model rows carry pointer-events:none and sit outside the
|
|
1307
|
+
// menu's own box, so every coordinate on them resolves to the effort
|
|
1308
|
+
// control and the refusal read as "another element covers it".
|
|
1309
|
+
if (getComputedStyle(el).pointerEvents === "none") {
|
|
1310
|
+
return { ok: false, reason: "target element is not clickable (pointer-events: none)" };
|
|
1311
|
+
}
|
|
1288
1312
|
el.setAttribute('data-prodex-click', '1');
|
|
1289
1313
|
const x = Math.round(r.x + r.width / 2);
|
|
1290
1314
|
const y = Math.round(r.y + (yCap ? Math.min(r.height / 2, yCap) : r.height / 2));
|
|
@@ -1367,15 +1391,24 @@ export function powerSliderStateExpression() {
|
|
|
1367
1391
|
const slider = document.querySelector('[role="slider"]');
|
|
1368
1392
|
const menu = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
|
|
1369
1393
|
const lines = menu ? (menu.innerText || "").split(String.fromCharCode(10)).map((l) => l.trim()).filter(Boolean) : [];
|
|
1370
|
-
const after = (label) => { const i = lines.indexOf(label); return i >= 0 ? lines[i + 1] : null; };
|
|
1371
1394
|
if (!slider) return { ok: false, reason: "power slider not found", lines };
|
|
1395
|
+
const readPowerSliderSelection = ${readPowerSliderSelection.toString()};
|
|
1396
|
+
const selection = readPowerSliderSelection({
|
|
1397
|
+
sliderValueText: slider.getAttribute("aria-valuetext"),
|
|
1398
|
+
items: menu ? [...menu.querySelectorAll('[role="menuitemradio"],[role="menuitem"]')].map((item) => ({
|
|
1399
|
+
role: item.getAttribute("role"),
|
|
1400
|
+
text: item.innerText || item.textContent || "",
|
|
1401
|
+
checked: item.getAttribute("aria-checked") === "true",
|
|
1402
|
+
containsSlider: item.contains(slider)
|
|
1403
|
+
})) : []
|
|
1404
|
+
});
|
|
1372
1405
|
return {
|
|
1373
1406
|
ok: true,
|
|
1374
1407
|
position: Number(slider.getAttribute("aria-valuenow")),
|
|
1375
1408
|
min: Number(slider.getAttribute("aria-valuemin")),
|
|
1376
1409
|
max: Number(slider.getAttribute("aria-valuemax")),
|
|
1377
|
-
model:
|
|
1378
|
-
effort:
|
|
1410
|
+
model: selection.model,
|
|
1411
|
+
effort: selection.effort,
|
|
1379
1412
|
lines
|
|
1380
1413
|
};
|
|
1381
1414
|
})()`;
|
|
@@ -1544,6 +1577,38 @@ const POWER_SLIDER_APPEAR_ATTEMPTS = 5;
|
|
|
1544
1577
|
* menu must already be open. Returns the quota line so the caller can warn
|
|
1545
1578
|
* when Pro runs are nearly spent.
|
|
1546
1579
|
*/
|
|
1580
|
+
// Click a model row. They are ordinary `menuitemradio` entries in the same
|
|
1581
|
+
// menu as the effort slider, so the existing by-label lookup reaches them; what
|
|
1582
|
+
// was missing is anything routing a model here instead of into the slider.
|
|
1583
|
+
/**
|
|
1584
|
+
* Whether a model selection failure is the picker not offering it at all.
|
|
1585
|
+
*
|
|
1586
|
+
* Those two cases - the row is absent, or it is inert - are the UI declining to
|
|
1587
|
+
* provide the choice, and they must not kill a send that is otherwise fine. It
|
|
1588
|
+
* is the same answer prodex already gives for a Pro sub-mode it cannot apply.
|
|
1589
|
+
* Anything else (a menu that would not open, a click that would not land) is a
|
|
1590
|
+
* real failure and stays one, so a genuine break is not buried under a warning.
|
|
1591
|
+
*/
|
|
1592
|
+
export function modelSelectionUnavailableWarning(requested, reason, available) {
|
|
1593
|
+
if (!/menu item not found|not clickable/i.test(reason))
|
|
1594
|
+
return undefined;
|
|
1595
|
+
const offers = available?.length ? ` It offers: ${available.join(", ")}.` : "";
|
|
1596
|
+
return (`model_not_applied: this ChatGPT picker does not offer "${requested}" as a selectable model, ` +
|
|
1597
|
+
`so the send used whatever the composer already had.${offers} ` +
|
|
1598
|
+
'Pick an effort with --effort instead, or clear a saved default with `prodex setup --model ""`.');
|
|
1599
|
+
}
|
|
1600
|
+
async function selectPickerModel(cdp, requested, warnings = []) {
|
|
1601
|
+
const hit = await cdp.evaluate(menuItemRectExpression(requested));
|
|
1602
|
+
if (!hit.ok || hit.x === undefined || hit.y === undefined) {
|
|
1603
|
+
const unavailable = modelSelectionUnavailableWarning(requested, hit.reason ?? "", hit.available);
|
|
1604
|
+
if (unavailable) {
|
|
1605
|
+
warnings.push(unavailable);
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
throw new Error(hit.reason ?? `ChatGPT's model picker has no "${requested}" model.`);
|
|
1609
|
+
}
|
|
1610
|
+
await verifiedClickWithRetry(cdp, () => cdp.evaluate(menuItemRectExpression(requested)), requested);
|
|
1611
|
+
}
|
|
1547
1612
|
async function selectPowerStep(cdp, requested) {
|
|
1548
1613
|
// The menu paints a moment after it opens, and on a page that has just been
|
|
1549
1614
|
// built - a project created seconds ago - that moment is longer. Failing the
|
|
@@ -1650,18 +1715,26 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
|
|
|
1650
1715
|
// it is not, so both UI generations work.
|
|
1651
1716
|
const sliderState = await cdp.evaluate(powerSliderStateExpression());
|
|
1652
1717
|
if (sliderState?.ok) {
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1718
|
+
// The slider is the EFFORT control and the models are radios beside it.
|
|
1719
|
+
// Sending a model name into the slider made it walk every step looking
|
|
1720
|
+
// for one it could never be.
|
|
1721
|
+
const plan = pickerSelectionPlan({
|
|
1722
|
+
...(options.model !== undefined ? { model: options.model } : {}),
|
|
1723
|
+
...(options.effort !== undefined ? { effort: options.effort } : {})
|
|
1724
|
+
});
|
|
1725
|
+
if (plan.warning)
|
|
1726
|
+
selectionWarnings.push(plan.warning);
|
|
1727
|
+
if (plan.sliderLabel) {
|
|
1728
|
+
await selectPowerStep(cdp, plan.sliderLabel);
|
|
1729
|
+
}
|
|
1730
|
+
if (plan.modelLabel) {
|
|
1731
|
+
await selectPickerModel(cdp, plan.modelLabel, selectionWarnings);
|
|
1656
1732
|
}
|
|
1657
1733
|
// This branch cannot honour a sub-mode, and used to return without
|
|
1658
1734
|
// saying so - the caller got a clean receipt for a 확장 it never got.
|
|
1659
1735
|
const proModeWarning = proModeNotAppliedWarning(options.proMode, sliderState.effort ?? undefined);
|
|
1660
1736
|
if (proModeWarning)
|
|
1661
1737
|
selectionWarnings.push(proModeWarning);
|
|
1662
|
-
const modelWarning = modelIgnoredForEffortWarning(options.model, options.effort);
|
|
1663
|
-
if (modelWarning)
|
|
1664
|
-
selectionWarnings.push(modelWarning);
|
|
1665
1738
|
await dispatchEscapeKey(cdp);
|
|
1666
1739
|
return;
|
|
1667
1740
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Pure decision helpers for ChatGPT's composer picker, kept out of the page. */
|
|
2
|
+
/** Read model and effort from both the current mixed-control picker and its prior labeled-row form. */
|
|
3
|
+
export function readPowerSliderSelection(snapshot) {
|
|
4
|
+
const items = snapshot.items.map((item) => ({
|
|
5
|
+
...item,
|
|
6
|
+
lines: item.text
|
|
7
|
+
.split("\n")
|
|
8
|
+
.map((line) => line.trim())
|
|
9
|
+
.filter(Boolean)
|
|
10
|
+
}));
|
|
11
|
+
const powerLabels = new Set([
|
|
12
|
+
"instant",
|
|
13
|
+
"즉시",
|
|
14
|
+
"빠름",
|
|
15
|
+
"fast",
|
|
16
|
+
"medium",
|
|
17
|
+
"중간",
|
|
18
|
+
"보통",
|
|
19
|
+
"high",
|
|
20
|
+
"높음",
|
|
21
|
+
"extra high",
|
|
22
|
+
"extrahigh",
|
|
23
|
+
"very high",
|
|
24
|
+
"매우 높음",
|
|
25
|
+
"매우높음",
|
|
26
|
+
"pro",
|
|
27
|
+
"프로"
|
|
28
|
+
]);
|
|
29
|
+
const legacyModel = items.find((item) => item.lines[0] === "Model")?.lines[1] ?? null;
|
|
30
|
+
const legacyEffort = items.find((item) => item.lines[0] === "Effort")?.lines[1] ?? null;
|
|
31
|
+
const checkedModel = items.find((item) => item.role === "menuitemradio" && item.checked)?.lines[0] ?? null;
|
|
32
|
+
const sliderOwner = items.find((item) => item.role === "menuitem" && item.containsSlider)?.lines[0] ?? null;
|
|
33
|
+
const visiblePowerLabel = items.find((item) => item.role === "menuitem" && powerLabels.has((item.lines[0] ?? "").toLowerCase()))?.lines[0] ?? null;
|
|
34
|
+
const accessibleCandidate = snapshot.sliderValueText?.trim() || null;
|
|
35
|
+
const accessibleValue = accessibleCandidate && powerLabels.has(accessibleCandidate.toLowerCase()) ? accessibleCandidate : null;
|
|
36
|
+
return {
|
|
37
|
+
model: legacyModel ?? checkedModel,
|
|
38
|
+
effort: accessibleValue ?? legacyEffort ?? sliderOwner ?? visiblePowerLabel
|
|
39
|
+
};
|
|
40
|
+
}
|
package/dist/store.js
CHANGED
|
@@ -55,6 +55,10 @@ export async function assertUsableBridgeRoot(root) {
|
|
|
55
55
|
return;
|
|
56
56
|
throw new Error(`Bridge root is not a usable repo directory: ${root}${looksLikeDevicePath ? " (that is a file-descriptor/device path, not a repo)" : ""}. prodex uses the process working directory when no --cwd is given, so a server started from a pipe or a deleted directory lands here. Pass --cwd /absolute/path/to/repo, or set PRODEX_CWD=/absolute/path/to/repo (works for the MCP server, which takes no flags).`);
|
|
57
57
|
}
|
|
58
|
+
// How many record files to read at once. High enough to hide per-file latency
|
|
59
|
+
// on a slow mount, low enough that a big store cannot run the process out of
|
|
60
|
+
// file descriptors.
|
|
61
|
+
const READ_ALL_CONCURRENCY = 32;
|
|
58
62
|
export class BridgeStore {
|
|
59
63
|
root;
|
|
60
64
|
bridgeDir;
|
|
@@ -878,15 +882,40 @@ export class BridgeStore {
|
|
|
878
882
|
async readAll(kind, parseRecord, options = {}) {
|
|
879
883
|
const dir = this.dir(kind);
|
|
880
884
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
881
|
-
const
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
885
|
+
const ids = entries
|
|
886
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
887
|
+
.map((entry) => entry.name.replace(/\.json$/, ""))
|
|
888
|
+
.filter((id) => isBridgeRecordId(kind, id));
|
|
889
|
+
// Read them together. The records are independent, but this loop used to
|
|
890
|
+
// await each one, so the cost was the file count times the filesystem's
|
|
891
|
+
// latency: measured on a Windows mount at ~24ms a file, 505 receipts took
|
|
892
|
+
// 12s, and `pro latest` paid it on every call because verifying ONE result
|
|
893
|
+
// reads every receipt. Bounded so a large store cannot exhaust the fd table.
|
|
894
|
+
const items = new Array(ids.length);
|
|
895
|
+
// The sequential loop threw on the FIRST bad record and never reached the
|
|
896
|
+
// rest, so that is the error callers were told about. Keep reporting the
|
|
897
|
+
// earliest one rather than whichever read happens to lose the race.
|
|
898
|
+
let failure;
|
|
899
|
+
let next = 0;
|
|
900
|
+
const readOne = async () => {
|
|
901
|
+
for (;;) {
|
|
902
|
+
const index = next;
|
|
903
|
+
next += 1;
|
|
904
|
+
if (index >= ids.length)
|
|
905
|
+
return;
|
|
906
|
+
const id = ids[index];
|
|
907
|
+
try {
|
|
908
|
+
items[index] = this.parseRecord(kind, id, await this.readRecordJson(kind, id, options), parseRecord);
|
|
909
|
+
}
|
|
910
|
+
catch (error) {
|
|
911
|
+
if (!failure || index < failure.index)
|
|
912
|
+
failure = { index, error };
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
await Promise.all(Array.from({ length: Math.min(READ_ALL_CONCURRENCY, ids.length) }, readOne));
|
|
917
|
+
if (failure)
|
|
918
|
+
throw failure.error;
|
|
890
919
|
return items;
|
|
891
920
|
}
|
|
892
921
|
parseRecord(kind, id, value, parseRecord) {
|
package/package.json
CHANGED
|
@@ -133,7 +133,23 @@ async function readRequiredPackageJson(packageJsonPath) {
|
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
// Budgets sized from what each check actually does, not from one number for all
|
|
137
|
+
// of them. Measured on a developer machine whose repo sits on a 9p mount:
|
|
138
|
+
// smoke:package takes 244s because it packs, installs and drives the package
|
|
139
|
+
// end to end, and the suite takes ~50s. A single 180s cap killed smoke:package
|
|
140
|
+
// mid-run and reported "release verification failed" with only npm's banner as
|
|
141
|
+
// evidence, because the process died before it printed anything - while running
|
|
142
|
+
// the same command directly passed every check. CI runs this same path before
|
|
143
|
+
// publishing, so the cap was a thin margin in the release path too.
|
|
136
144
|
async function runFullReleaseVerification(rootDir) {
|
|
145
|
+
// Declared here, not at module scope: this file runs its entry point during
|
|
146
|
+
// module evaluation, so a top-level const is still in its temporal dead zone
|
|
147
|
+
// by the time this is called.
|
|
148
|
+
const CHECK_TIMEOUT_MS = {
|
|
149
|
+
default: 300_000,
|
|
150
|
+
"npm test": 900_000,
|
|
151
|
+
"npm run smoke:package": 1_800_000
|
|
152
|
+
};
|
|
137
153
|
const checks = [
|
|
138
154
|
["npm", ["test"]],
|
|
139
155
|
["npm", ["run", "typecheck"]],
|
|
@@ -142,7 +158,8 @@ async function runFullReleaseVerification(rootDir) {
|
|
|
142
158
|
["node", ["dist/cli.js", "doctor"]]
|
|
143
159
|
];
|
|
144
160
|
for (const [command, commandArgs] of checks) {
|
|
145
|
-
|
|
161
|
+
const key = [command, ...commandArgs].join(" ");
|
|
162
|
+
await run(commandForPlatform(command), commandArgs, rootDir, CHECK_TIMEOUT_MS[key] ?? CHECK_TIMEOUT_MS.default);
|
|
146
163
|
}
|
|
147
164
|
}
|
|
148
165
|
|
|
@@ -328,13 +345,13 @@ function formatPathList(paths) {
|
|
|
328
345
|
return paths.length > 8 ? `${shown}, ... (${paths.length} files)` : shown;
|
|
329
346
|
}
|
|
330
347
|
|
|
331
|
-
async function run(command, commandArgs, cwd) {
|
|
348
|
+
async function run(command, commandArgs, cwd, timeoutMs = 300_000) {
|
|
332
349
|
const commandLine = [command, ...commandArgs].join(" ");
|
|
333
350
|
console.log(`release_check: ${commandLine}`);
|
|
334
351
|
try {
|
|
335
352
|
await execFileAsync(command, commandArgs, {
|
|
336
353
|
cwd,
|
|
337
|
-
timeout:
|
|
354
|
+
timeout: timeoutMs,
|
|
338
355
|
maxBuffer: 20 * 1024 * 1024
|
|
339
356
|
});
|
|
340
357
|
} catch (error) {
|