@youdie006/prodex 0.36.2 → 0.36.4

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.
@@ -654,23 +654,38 @@ export function proModeNotAppliedWarning(proMode, sliderEffort) {
654
654
  "Drop --pro-mode here, or clear a saved default with `prodex setup --clear-pro-mode`.");
655
655
  }
656
656
  /**
657
- * Which of two flags the slider actually obeyed.
657
+ * Which control each request belongs to.
658
658
  *
659
- * On this picker one control carries both: "Pro" is the top EFFORT step, not a
660
- * model. Naming a model AND an effort therefore asks for two positions of the
661
- * same slider, and the send takes `effort ?? model` - so the model is dropped.
662
- * Measured: `--model Pro --effort 중간` answered from gpt-5-6-thinking with an
663
- * empty warnings list. Asking for Pro and quietly getting a cheaper model is
664
- * the kind of thing a receipt exists to catch.
659
+ * The picker holds two of them: a `role="slider"` for effort (Instant..Pro) and
660
+ * `menuitemradio` rows for the models. Selection used to send both through the
661
+ * slider (`effort ?? model`), so a model name walked the slider looking for a
662
+ * step it could never be - and the refusal quoted the slider's TEXT, which
663
+ * contains the model names, so the message read as "it is right there in the
664
+ * list". `--model Pro` only ever worked when the slider happened to sit on its
665
+ * top step; once it moved to Instant the same command failed.
666
+ *
667
+ * Pro stopped being a model and became that top step, but it is the pinned
668
+ * default in every repo configured before the change, so it is honoured where
669
+ * it now lives rather than failing every one of them - out loud.
665
670
  */
666
- export function modelIgnoredForEffortWarning(model, effort) {
667
- if (!model || !effort)
668
- return undefined;
669
- if (model.trim().toLowerCase() === effort.trim().toLowerCase())
670
- return undefined;
671
- return (`model_ignored: this ChatGPT picker sets the model and the effort with one slider, ` +
672
- `so --model ${model} and --effort ${effort} name two positions of it. The send used ${effort}. ` +
673
- `Pass only one of them to say which you meant.`);
671
+ export function pickerSelectionPlan(input) {
672
+ const plan = {};
673
+ const modelIsNowAnEffort = input.model !== undefined && /^\s*pro\s*$/i.test(input.model);
674
+ if (input.effort !== undefined)
675
+ plan.sliderLabel = input.effort;
676
+ else if (modelIsNowAnEffort)
677
+ plan.sliderLabel = input.model;
678
+ if (input.model !== undefined && !modelIsNowAnEffort)
679
+ plan.modelLabel = input.model;
680
+ if (modelIsNowAnEffort) {
681
+ plan.warning =
682
+ `model_is_now_an_effort: ChatGPT's picker lists Pro as an effort step, not a model, so --model ${input.model} was ` +
683
+ (input.effort === undefined
684
+ ? "applied to the effort slider instead. Say --effort Pro to be explicit"
685
+ : `ignored in favour of --effort ${input.effort}`) +
686
+ ", or clear a saved default with `prodex setup --model \"\"`.";
687
+ }
688
+ return plan;
674
689
  }
675
690
  export function chatGptBusyBlocker(state) {
676
691
  // Waiting for a choice is not generating. Conflating them turned a click
@@ -1544,6 +1559,17 @@ const POWER_SLIDER_APPEAR_ATTEMPTS = 5;
1544
1559
  * menu must already be open. Returns the quota line so the caller can warn
1545
1560
  * when Pro runs are nearly spent.
1546
1561
  */
1562
+ // Click a model row. They are ordinary `menuitemradio` entries in the same
1563
+ // menu as the effort slider, so the existing by-label lookup reaches them; what
1564
+ // was missing is anything routing a model here instead of into the slider.
1565
+ async function selectPickerModel(cdp, requested) {
1566
+ const hit = await cdp.evaluate(menuItemRectExpression(requested));
1567
+ if (!hit.ok || hit.x === undefined || hit.y === undefined) {
1568
+ const available = hit.available?.length ? ` The picker offers: ${hit.available.join(", ")}.` : "";
1569
+ throw new Error(`ChatGPT's model picker has no "${requested}" model.${available}`);
1570
+ }
1571
+ await verifiedClickWithRetry(cdp, () => cdp.evaluate(menuItemRectExpression(requested)), requested);
1572
+ }
1547
1573
  async function selectPowerStep(cdp, requested) {
1548
1574
  // The menu paints a moment after it opens, and on a page that has just been
1549
1575
  // built - a project created seconds ago - that moment is longer. Failing the
@@ -1650,18 +1676,26 @@ async function selectModelReasoning(cdp, options, selectionWarnings = []) {
1650
1676
  // it is not, so both UI generations work.
1651
1677
  const sliderState = await cdp.evaluate(powerSliderStateExpression());
1652
1678
  if (sliderState?.ok) {
1653
- const wanted = options.effort ?? options.model;
1654
- if (wanted) {
1655
- await selectPowerStep(cdp, wanted);
1679
+ // The slider is the EFFORT control and the models are radios beside it.
1680
+ // Sending a model name into the slider made it walk every step looking
1681
+ // for one it could never be.
1682
+ const plan = pickerSelectionPlan({
1683
+ ...(options.model !== undefined ? { model: options.model } : {}),
1684
+ ...(options.effort !== undefined ? { effort: options.effort } : {})
1685
+ });
1686
+ if (plan.warning)
1687
+ selectionWarnings.push(plan.warning);
1688
+ if (plan.sliderLabel) {
1689
+ await selectPowerStep(cdp, plan.sliderLabel);
1690
+ }
1691
+ if (plan.modelLabel) {
1692
+ await selectPickerModel(cdp, plan.modelLabel);
1656
1693
  }
1657
1694
  // This branch cannot honour a sub-mode, and used to return without
1658
1695
  // saying so - the caller got a clean receipt for a 확장 it never got.
1659
1696
  const proModeWarning = proModeNotAppliedWarning(options.proMode, sliderState.effort ?? undefined);
1660
1697
  if (proModeWarning)
1661
1698
  selectionWarnings.push(proModeWarning);
1662
- const modelWarning = modelIgnoredForEffortWarning(options.model, options.effort);
1663
- if (modelWarning)
1664
- selectionWarnings.push(modelWarning);
1665
1699
  await dispatchEscapeKey(cdp);
1666
1700
  return;
1667
1701
  }
@@ -3954,6 +3988,30 @@ export function transcriptMatchesSentPrompt(userText, sentPrompt) {
3954
3988
  // separates, U+E201 closes) and keeps the real sources in content_references.
3955
3989
  const CITATION_DELIMITER_PATTERN = /[\uE200-\uE206]/;
3956
3990
  const CITATION_MARKER_PATTERN = /\uE200[^\uE200-\uE206]*(?:[\uE202\uE204-\uE206][^\uE200-\uE206]*)*[\uE201\uE203]/g;
3991
+ /**
3992
+ * The words a citation marker was wrapping, if any.
3993
+ *
3994
+ * The markers come in two shapes. `<E200>cite<E202>turn0search0<E201>` is a
3995
+ * bare anchor - kind then reference id, nothing a reader would miss. But
3996
+ * `<E200>url<E202>Timeanddate.com — World Clock<E202>turn0search0<E201>` puts a
3997
+ * visible title in the middle, and that title is part of the sentence. Caught by
3998
+ * diffing a saved answer against its thread: a web-search reply ended at a
3999
+ * dangling "Source:" because the title had been deleted along with the marker.
4000
+ */
4001
+ export function citationMarkerText(marker) {
4002
+ // The first segment is the kind and the rest are a mix of reference tokens and,
4003
+ // for some kinds, prose. Sorting them by SHAPE rather than by kind keeps a
4004
+ // title from a kind not seen yet: `cite` can carry several ids
4005
+ // (`cite<E202>turn0search19<E202>turn0search5`), and dropping every
4006
+ // id-shaped segment leaves nothing there, which is right.
4007
+ const referenceToken = /^turn\d+[a-z]+\d+$/i;
4008
+ const segments = marker.replace(/^[\uE200-\uE206]|[\uE200-\uE206]$/g, "").split(/[\uE200-\uE206]/);
4009
+ return segments
4010
+ .slice(1)
4011
+ .filter((segment) => segment.length > 0 && !referenceToken.test(segment))
4012
+ .join(" ")
4013
+ .trim();
4014
+ }
3957
4015
  /**
3958
4016
  * Turn those markers into ordinary markdown links, so a saved answer keeps the
3959
4017
  * sources instead of the private-use noise (or, as in the rendered DOM, nothing
@@ -3984,11 +4042,13 @@ export function resolveTranscriptCitations(text, references = []) {
3984
4042
  };
3985
4043
  let resolved = text;
3986
4044
  for (const [marker, reference] of byMarker) {
3987
- resolved = resolved.split(marker).join(linksFor(reference));
4045
+ // No items to link is not permission to delete the sentence: fall back to
4046
+ // whatever the marker was wrapping.
4047
+ resolved = resolved.split(marker).join(linksFor(reference) || citationMarkerText(marker));
3988
4048
  }
3989
- // Anything still delimited had no reference to restore: strip it so private-use
3990
- // characters never reach a receipt.
3991
- return resolved.replace(CITATION_MARKER_PATTERN, "");
4049
+ // Anything still delimited had no reference to restore. The delimiters must not
4050
+ // reach a receipt, but the words between them still belong to the answer.
4051
+ return resolved.replace(CITATION_MARKER_PATTERN, (marker) => citationMarkerText(marker));
3992
4052
  }
3993
4053
  export function deepResearchReportExpression(conversationId) {
3994
4054
  return `(async () => {
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 items = [];
882
- for (const entry of entries) {
883
- if (!entry.isFile() || !entry.name.endsWith(".json"))
884
- continue;
885
- const id = entry.name.replace(/\.json$/, "");
886
- if (!isBridgeRecordId(kind, id))
887
- continue;
888
- items.push(this.parseRecord(kind, id, await this.readRecordJson(kind, id, options), parseRecord));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.36.2",
3
+ "version": "0.36.4",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",
@@ -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
- await run(commandForPlatform(command), commandArgs, rootDir);
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: 180_000,
354
+ timeout: timeoutMs,
338
355
  maxBuffer: 20 * 1024 * 1024
339
356
  });
340
357
  } catch (error) {