@buoy-gg/mcp 5.0.0 → 6.0.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.
@@ -296,7 +296,7 @@ async function waitForBroker(url, attempts = 20) {
296
296
  var delay = (ms) => new Promise((r) => setTimeout(r, ms));
297
297
 
298
298
  // src/version.ts
299
- var VERSION = "5.0.0" ? "5.0.0" : "0.0.0-dev";
299
+ var VERSION = "6.0.0" ? "6.0.0" : "0.0.0-dev";
300
300
  var PACKAGE_NAME = "@buoy-gg/mcp";
301
301
 
302
302
  // src/update/checkForUpdate.ts
@@ -528,7 +528,8 @@ function buildAutomationConfig(input, base = {}) {
528
528
  coolDownMs,
529
529
  discardWarmupRuns,
530
530
  shuffleCases,
531
- reloadBetweenCases: input.reloadBetweenCases ?? base.reloadBetweenCases ?? false
531
+ reloadBetweenCases: input.reloadBetweenCases ?? base.reloadBetweenCases ?? false,
532
+ captureRenders: input.captureRenders ?? base.captureRenders ?? true
532
533
  };
533
534
  return { config, warnings };
534
535
  }
@@ -542,7 +543,8 @@ function describeBenchmarkSettings(config) {
542
543
  `**${secs(config.perCaseDurationMs)} measured per page**`,
543
544
  runs > 1 ? `**${runs} runs each** (median taken)` : `**1 run each** (no median \u2014 noisier)`,
544
545
  config.reloadBetweenCases ? "**full reload between pages** (no state leaks across tests)" : "no reload between pages",
545
- config.shuffleCases ? "shuffled order (cancels thermal bias)" : "fixed order"
546
+ config.shuffleCases ? "shuffled order (cancels thermal bias)" : "fixed order",
547
+ config.captureRenders !== false ? "render capture on (per-component why-data)" : "render capture off"
546
548
  ];
547
549
  const perPageMs = config.settleMs + config.perCaseDurationMs + (config.coolDownMs ?? 0) + 1800;
548
550
  return `${parts.join(", ")}. That's ~${secs(perPageMs)} wall-clock per page; for ${config.cases.length} page(s) \xD7 ${runs} run(s), **${est}** total.`;
@@ -597,7 +599,9 @@ function rankBatchFromIndex(index, batchId, casesOrder, failures = []) {
597
599
  uiJank: num(median?.uiJankFrames),
598
600
  deviceMaxRefreshRate: num(median?.deviceMaxRefreshRate),
599
601
  deltaUiFps: null,
600
- deltaJsFps: null
602
+ deltaJsFps: null,
603
+ renderCommits: num(median?.renderCommits),
604
+ topRenderers: median?.topRenderers ?? []
601
605
  };
602
606
  return { ...base, flags: failed ? [] : computeFlags(base) };
603
607
  });
@@ -686,6 +690,16 @@ function formatRankingMarkdown(ranking) {
686
690
  `**\u26A0\uFE0F At-risk:** ${ranking.flagged.map((x) => `${x.caseName} (${x.flags.join("; ")})`).join(" \xB7 ")}`
687
691
  );
688
692
  }
693
+ const renderLines = ranking.rows.filter((r) => !r.failed && (r.renderCommits != null || r.topRenderers.length > 0)).map((r) => {
694
+ const top = r.topRenderers.map((t) => `${t.name} \xD7${t.renders} (${t.totalMs}ms)`).join(", ");
695
+ const commits = r.renderCommits != null ? `${r.renderCommits} commits` : "";
696
+ return `- **${r.caseName}** \u2014 ${[commits, top].filter(Boolean).join(": ")}`;
697
+ });
698
+ if (renderLines.length) {
699
+ lines.push("");
700
+ lines.push("**Renders (median run)** \u2014 heaviest components by render self-time:");
701
+ lines.push(...renderLines);
702
+ }
689
703
  lines.push("");
690
704
  lines.push(
691
705
  `_Deltas vs baseline (case #0). Don't pick on one metric \u2014 explain the trade-off (e.g. "A is cheapest but drops UI frames \u2192 jank risk; B costs more CPU but caps nothing")._`
@@ -744,7 +758,8 @@ async function readDeviceSettings(sync, deviceId) {
744
758
  coolDownMs,
745
759
  discardWarmupRuns,
746
760
  shuffleCases,
747
- reloadBetweenCases
761
+ reloadBetweenCases,
762
+ captureRenders
748
763
  } = cfg;
749
764
  return {
750
765
  perCaseDurationMs,
@@ -754,7 +769,8 @@ async function readDeviceSettings(sync, deviceId) {
754
769
  coolDownMs,
755
770
  discardWarmupRuns,
756
771
  shuffleCases,
757
- reloadBetweenCases
772
+ reloadBetweenCases,
773
+ captureRenders
758
774
  };
759
775
  } catch {
760
776
  return {};
@@ -858,6 +874,9 @@ function registerPerfMonitorTools(server, sync) {
858
874
  discardWarmupRuns: z2.number().optional().describe("Discard first N runs of each case before taking the median. Default 0."),
859
875
  shuffleCases: z2.boolean().optional().describe("Interleave + shuffle case order to cancel thermal bias. Default on for >3 cases."),
860
876
  reloadBetweenCases: z2.boolean().optional().describe("Fully reload JS between cases (needs <AutomationResumer/> mounted). Default false."),
877
+ captureRenders: z2.boolean().optional().describe(
878
+ "Capture per-component render counts/durations during each run (needs @buoy-gg/highlight-updates installed; dev builds only). Adds slight JS-thread overhead applied equally to every case. Default true."
879
+ ),
861
880
  deviceId: z2.string().optional().describe("Target device id. Omit to auto-select when one device is connected."),
862
881
  maxWaitMs: z2.number().optional().describe("Safety cap for how long to wait for completion. Defaults to ~3\xD7 the estimate + 60s.")
863
882
  }
@@ -1121,6 +1140,7 @@ ${JSON.stringify(settings, null, 2)}
1121
1140
  discardWarmupRuns: z2.number().optional().describe("Discard first N runs of each page before the median."),
1122
1141
  shuffleCases: z2.boolean().optional().describe("Interleave + shuffle case order to cancel thermal bias."),
1123
1142
  reloadBetweenCases: z2.boolean().optional().describe("Fully reload JS between pages (needs <AutomationResumer/> mounted)."),
1143
+ captureRenders: z2.boolean().optional().describe("Capture per-component render counts/durations during each run."),
1124
1144
  deviceId: z2.string().optional().describe("Target device id (auto-selected if one is connected).")
1125
1145
  }
1126
1146
  },
@@ -1136,7 +1156,8 @@ ${JSON.stringify(settings, null, 2)}
1136
1156
  "runsPerCase",
1137
1157
  "discardWarmupRuns",
1138
1158
  "shuffleCases",
1139
- "reloadBetweenCases"
1159
+ "reloadBetweenCases",
1160
+ "captureRenders"
1140
1161
  ];
1141
1162
  const overrides = {};
1142
1163
  for (const k of keys) {
@@ -1566,42 +1587,68 @@ function registerStorageTools(server, sync) {
1566
1587
  server.registerTool(
1567
1588
  "get_storage",
1568
1589
  {
1569
- description: "Read the app's AsyncStorage keys. Compact by DEFAULT \u2014 keys only \u2014 so it's token-cheap; pass includeValues:true for the values (truncated), or a `pattern` substring to narrow. Buoy's own internal keys are already filtered out. Use for 'what's in storage / read this key'.",
1590
+ description: "Read the app's storage keys across ALL backends: AsyncStorage, MMKV (per instance), and Expo SecureStore. Compact by DEFAULT \u2014 keys only \u2014 so it's token-cheap; pass includeValues:true for the values (truncated), or a `pattern` substring to narrow. Buoy's own internal keys are already filtered out. Use for 'what's in storage / read this key'.",
1570
1591
  inputSchema: {
1571
1592
  pattern: z8.string().optional().describe("Only keys containing this substring (case-insensitive)."),
1572
1593
  includeValues: z8.boolean().optional().describe("Include each key's value (truncated). Default false (keys only)."),
1573
- limit: z8.number().optional().describe("Max keys to return. Default 100."),
1594
+ limit: z8.number().optional().describe("Max keys per backend section. Default 100."),
1574
1595
  deviceId: z8.string().optional().describe("Target device id (auto-selected if one is connected).")
1575
1596
  }
1576
1597
  },
1577
1598
  async (args) => {
1578
1599
  try {
1579
1600
  const target = sync.requireProDevice(args.deviceId);
1601
+ const pattern = args.pattern?.toLowerCase();
1602
+ const limit = typeof args.limit === "number" && args.limit > 0 ? args.limit : 100;
1603
+ const matches = (k) => !pattern || k.toLowerCase().includes(pattern);
1604
+ const sections = [];
1580
1605
  const allKeys = await sync.callAction(STORAGE_TOOL_ID, "async.getAllKeys", {}, target);
1581
1606
  if (!Array.isArray(allKeys)) {
1582
1607
  return fail8("No storage result from the device. The storage tool may be inactive.");
1583
1608
  }
1584
- const pattern = args.pattern?.toLowerCase();
1585
- const matched = pattern ? allKeys.filter((k) => k.toLowerCase().includes(pattern)) : allKeys;
1586
- const limit = typeof args.limit === "number" && args.limit > 0 ? args.limit : 100;
1587
- const keys = matched.slice(0, limit);
1588
- if (keys.length === 0) {
1589
- return text7(`No storage keys${pattern ? ` matching "${args.pattern}"` : ""} (${allKeys.length} total).`);
1609
+ const asyncKeys = allKeys.filter(matches).slice(0, limit);
1610
+ if (asyncKeys.length > 0) {
1611
+ let lines;
1612
+ if (args.includeValues) {
1613
+ const pairs = await sync.callAction(STORAGE_TOOL_ID, "async.multiGet", { keys: asyncKeys }, target);
1614
+ const map = new Map((pairs ?? []).map(([k, v]) => [k, v]));
1615
+ lines = asyncKeys.map((k) => `- \`${k}\` = ${truncate(map.get(k) ?? null)}`);
1616
+ } else {
1617
+ lines = asyncKeys.map((k) => `- \`${k}\``);
1618
+ }
1619
+ sections.push(`**AsyncStorage (${asyncKeys.length}/${allKeys.length} keys):**
1620
+ ${lines.join("\n")}`);
1590
1621
  }
1591
- let lines;
1592
- if (args.includeValues) {
1593
- const pairs = await sync.callAction(STORAGE_TOOL_ID, "async.multiGet", { keys }, target);
1594
- const map = new Map((pairs ?? []).map(([k, v]) => [k, v]));
1595
- lines = keys.map((k) => `- \`${k}\` = ${truncate(map.get(k) ?? null)}`);
1596
- } else {
1597
- lines = keys.map((k) => `- \`${k}\``);
1622
+ const mmkv = await sync.callAction(STORAGE_TOOL_ID, "mmkv.snapshot", {}, target).catch(() => null);
1623
+ for (const inst of mmkv ?? []) {
1624
+ const entries = inst.entries.filter((e) => matches(e.key)).slice(0, limit);
1625
+ if (entries.length === 0) continue;
1626
+ const lines = entries.map(
1627
+ (e) => args.includeValues ? `- \`${e.key}\` (${e.valueType}) = ${truncate(e.value)}` : `- \`${e.key}\``
1628
+ );
1629
+ const flags = [inst.encrypted && "encrypted", inst.readOnly && "read-only"].filter(Boolean).join(", ");
1630
+ sections.push(`**MMKV \`${inst.id}\`${flags ? ` (${flags})` : ""} (${entries.length}/${inst.entries.length} keys):**
1631
+ ${lines.join("\n")}`);
1598
1632
  }
1599
- const filteredNote = pattern ? ` matching "${args.pattern}"` : "";
1600
- const header = `**Storage \u2014 ${allKeys.length} keys (showing ${keys.length}${filteredNote}):**`;
1601
- const note = args.includeValues ? "" : "\n\n_Keys only. Pass includeValues:true for values, or a pattern to filter. Modify with storage_action._";
1602
- return text7(`${header}
1603
-
1604
- ${lines.join("\n")}${note}`);
1633
+ const secure = await sync.callAction(STORAGE_TOOL_ID, "secure.keys", {}, target).catch(() => null);
1634
+ const secureKeys = (secure ?? []).filter((d) => matches(d.key)).slice(0, limit);
1635
+ if (secureKeys.length > 0) {
1636
+ const lines = await Promise.all(
1637
+ secureKeys.map(async (d) => {
1638
+ if (d.requireAuthentication) return `- \`${d.key}\` \u{1F512} (biometric-protected \u2014 not read)`;
1639
+ if (!args.includeValues) return `- \`${d.key}\``;
1640
+ const v = await sync.callAction(STORAGE_TOOL_ID, "secure.get", { key: d.key }, target).catch(() => null);
1641
+ return `- \`${d.key}\` = ${truncate(v)}`;
1642
+ })
1643
+ );
1644
+ sections.push(`**SecureStore (${secureKeys.length} registered keys):**
1645
+ ${lines.join("\n")}`);
1646
+ }
1647
+ if (sections.length === 0) {
1648
+ return text7(`No storage keys${pattern ? ` matching "${args.pattern}"` : ""} in any backend.`);
1649
+ }
1650
+ const note = args.includeValues ? "" : "\n\n_Keys only. Pass includeValues:true for values, or a pattern to filter. Modify AsyncStorage with storage_action._";
1651
+ return text7(`${sections.join("\n\n")}${note}`);
1605
1652
  } catch (e) {
1606
1653
  return fail8(errMsg8(e));
1607
1654
  }
package/dist/cli.js CHANGED
@@ -336,7 +336,7 @@ var import_node_os = __toESM(require("os"));
336
336
  var import_node_path = __toESM(require("path"));
337
337
 
338
338
  // src/version.ts
339
- var VERSION = "5.0.0" ? "5.0.0" : "0.0.0-dev";
339
+ var VERSION = "6.0.0" ? "6.0.0" : "0.0.0-dev";
340
340
  var PACKAGE_NAME = "@buoy-gg/mcp";
341
341
 
342
342
  // src/update/checkForUpdate.ts
@@ -570,7 +570,8 @@ function buildAutomationConfig(input, base = {}) {
570
570
  coolDownMs,
571
571
  discardWarmupRuns,
572
572
  shuffleCases,
573
- reloadBetweenCases: input.reloadBetweenCases ?? base.reloadBetweenCases ?? false
573
+ reloadBetweenCases: input.reloadBetweenCases ?? base.reloadBetweenCases ?? false,
574
+ captureRenders: input.captureRenders ?? base.captureRenders ?? true
574
575
  };
575
576
  return { config, warnings };
576
577
  }
@@ -584,7 +585,8 @@ function describeBenchmarkSettings(config) {
584
585
  `**${secs(config.perCaseDurationMs)} measured per page**`,
585
586
  runs > 1 ? `**${runs} runs each** (median taken)` : `**1 run each** (no median \u2014 noisier)`,
586
587
  config.reloadBetweenCases ? "**full reload between pages** (no state leaks across tests)" : "no reload between pages",
587
- config.shuffleCases ? "shuffled order (cancels thermal bias)" : "fixed order"
588
+ config.shuffleCases ? "shuffled order (cancels thermal bias)" : "fixed order",
589
+ config.captureRenders !== false ? "render capture on (per-component why-data)" : "render capture off"
588
590
  ];
589
591
  const perPageMs = config.settleMs + config.perCaseDurationMs + (config.coolDownMs ?? 0) + 1800;
590
592
  return `${parts.join(", ")}. That's ~${secs(perPageMs)} wall-clock per page; for ${config.cases.length} page(s) \xD7 ${runs} run(s), **${est}** total.`;
@@ -639,7 +641,9 @@ function rankBatchFromIndex(index, batchId, casesOrder, failures = []) {
639
641
  uiJank: num(median?.uiJankFrames),
640
642
  deviceMaxRefreshRate: num(median?.deviceMaxRefreshRate),
641
643
  deltaUiFps: null,
642
- deltaJsFps: null
644
+ deltaJsFps: null,
645
+ renderCommits: num(median?.renderCommits),
646
+ topRenderers: median?.topRenderers ?? []
643
647
  };
644
648
  return { ...base, flags: failed ? [] : computeFlags(base) };
645
649
  });
@@ -728,6 +732,16 @@ function formatRankingMarkdown(ranking) {
728
732
  `**\u26A0\uFE0F At-risk:** ${ranking.flagged.map((x) => `${x.caseName} (${x.flags.join("; ")})`).join(" \xB7 ")}`
729
733
  );
730
734
  }
735
+ const renderLines = ranking.rows.filter((r) => !r.failed && (r.renderCommits != null || r.topRenderers.length > 0)).map((r) => {
736
+ const top = r.topRenderers.map((t) => `${t.name} \xD7${t.renders} (${t.totalMs}ms)`).join(", ");
737
+ const commits = r.renderCommits != null ? `${r.renderCommits} commits` : "";
738
+ return `- **${r.caseName}** \u2014 ${[commits, top].filter(Boolean).join(": ")}`;
739
+ });
740
+ if (renderLines.length) {
741
+ lines.push("");
742
+ lines.push("**Renders (median run)** \u2014 heaviest components by render self-time:");
743
+ lines.push(...renderLines);
744
+ }
731
745
  lines.push("");
732
746
  lines.push(
733
747
  `_Deltas vs baseline (case #0). Don't pick on one metric \u2014 explain the trade-off (e.g. "A is cheapest but drops UI frames \u2192 jank risk; B costs more CPU but caps nothing")._`
@@ -780,7 +794,8 @@ async function readDeviceSettings(sync, deviceId) {
780
794
  coolDownMs,
781
795
  discardWarmupRuns,
782
796
  shuffleCases,
783
- reloadBetweenCases
797
+ reloadBetweenCases,
798
+ captureRenders
784
799
  } = cfg;
785
800
  return {
786
801
  perCaseDurationMs,
@@ -790,7 +805,8 @@ async function readDeviceSettings(sync, deviceId) {
790
805
  coolDownMs,
791
806
  discardWarmupRuns,
792
807
  shuffleCases,
793
- reloadBetweenCases
808
+ reloadBetweenCases,
809
+ captureRenders
794
810
  };
795
811
  } catch {
796
812
  return {};
@@ -894,6 +910,9 @@ function registerPerfMonitorTools(server, sync) {
894
910
  discardWarmupRuns: import_zod2.z.number().optional().describe("Discard first N runs of each case before taking the median. Default 0."),
895
911
  shuffleCases: import_zod2.z.boolean().optional().describe("Interleave + shuffle case order to cancel thermal bias. Default on for >3 cases."),
896
912
  reloadBetweenCases: import_zod2.z.boolean().optional().describe("Fully reload JS between cases (needs <AutomationResumer/> mounted). Default false."),
913
+ captureRenders: import_zod2.z.boolean().optional().describe(
914
+ "Capture per-component render counts/durations during each run (needs @buoy-gg/highlight-updates installed; dev builds only). Adds slight JS-thread overhead applied equally to every case. Default true."
915
+ ),
897
916
  deviceId: import_zod2.z.string().optional().describe("Target device id. Omit to auto-select when one device is connected."),
898
917
  maxWaitMs: import_zod2.z.number().optional().describe("Safety cap for how long to wait for completion. Defaults to ~3\xD7 the estimate + 60s.")
899
918
  }
@@ -1157,6 +1176,7 @@ ${JSON.stringify(settings, null, 2)}
1157
1176
  discardWarmupRuns: import_zod2.z.number().optional().describe("Discard first N runs of each page before the median."),
1158
1177
  shuffleCases: import_zod2.z.boolean().optional().describe("Interleave + shuffle case order to cancel thermal bias."),
1159
1178
  reloadBetweenCases: import_zod2.z.boolean().optional().describe("Fully reload JS between pages (needs <AutomationResumer/> mounted)."),
1179
+ captureRenders: import_zod2.z.boolean().optional().describe("Capture per-component render counts/durations during each run."),
1160
1180
  deviceId: import_zod2.z.string().optional().describe("Target device id (auto-selected if one is connected).")
1161
1181
  }
1162
1182
  },
@@ -1172,7 +1192,8 @@ ${JSON.stringify(settings, null, 2)}
1172
1192
  "runsPerCase",
1173
1193
  "discardWarmupRuns",
1174
1194
  "shuffleCases",
1175
- "reloadBetweenCases"
1195
+ "reloadBetweenCases",
1196
+ "captureRenders"
1176
1197
  ];
1177
1198
  const overrides = {};
1178
1199
  for (const k of keys) {
@@ -1602,42 +1623,68 @@ function registerStorageTools(server, sync) {
1602
1623
  server.registerTool(
1603
1624
  "get_storage",
1604
1625
  {
1605
- description: "Read the app's AsyncStorage keys. Compact by DEFAULT \u2014 keys only \u2014 so it's token-cheap; pass includeValues:true for the values (truncated), or a `pattern` substring to narrow. Buoy's own internal keys are already filtered out. Use for 'what's in storage / read this key'.",
1626
+ description: "Read the app's storage keys across ALL backends: AsyncStorage, MMKV (per instance), and Expo SecureStore. Compact by DEFAULT \u2014 keys only \u2014 so it's token-cheap; pass includeValues:true for the values (truncated), or a `pattern` substring to narrow. Buoy's own internal keys are already filtered out. Use for 'what's in storage / read this key'.",
1606
1627
  inputSchema: {
1607
1628
  pattern: import_zod8.z.string().optional().describe("Only keys containing this substring (case-insensitive)."),
1608
1629
  includeValues: import_zod8.z.boolean().optional().describe("Include each key's value (truncated). Default false (keys only)."),
1609
- limit: import_zod8.z.number().optional().describe("Max keys to return. Default 100."),
1630
+ limit: import_zod8.z.number().optional().describe("Max keys per backend section. Default 100."),
1610
1631
  deviceId: import_zod8.z.string().optional().describe("Target device id (auto-selected if one is connected).")
1611
1632
  }
1612
1633
  },
1613
1634
  async (args) => {
1614
1635
  try {
1615
1636
  const target = sync.requireProDevice(args.deviceId);
1637
+ const pattern = args.pattern?.toLowerCase();
1638
+ const limit = typeof args.limit === "number" && args.limit > 0 ? args.limit : 100;
1639
+ const matches = (k) => !pattern || k.toLowerCase().includes(pattern);
1640
+ const sections = [];
1616
1641
  const allKeys = await sync.callAction(STORAGE_TOOL_ID, "async.getAllKeys", {}, target);
1617
1642
  if (!Array.isArray(allKeys)) {
1618
1643
  return fail8("No storage result from the device. The storage tool may be inactive.");
1619
1644
  }
1620
- const pattern = args.pattern?.toLowerCase();
1621
- const matched = pattern ? allKeys.filter((k) => k.toLowerCase().includes(pattern)) : allKeys;
1622
- const limit = typeof args.limit === "number" && args.limit > 0 ? args.limit : 100;
1623
- const keys = matched.slice(0, limit);
1624
- if (keys.length === 0) {
1625
- return text7(`No storage keys${pattern ? ` matching "${args.pattern}"` : ""} (${allKeys.length} total).`);
1645
+ const asyncKeys = allKeys.filter(matches).slice(0, limit);
1646
+ if (asyncKeys.length > 0) {
1647
+ let lines;
1648
+ if (args.includeValues) {
1649
+ const pairs = await sync.callAction(STORAGE_TOOL_ID, "async.multiGet", { keys: asyncKeys }, target);
1650
+ const map = new Map((pairs ?? []).map(([k, v]) => [k, v]));
1651
+ lines = asyncKeys.map((k) => `- \`${k}\` = ${truncate(map.get(k) ?? null)}`);
1652
+ } else {
1653
+ lines = asyncKeys.map((k) => `- \`${k}\``);
1654
+ }
1655
+ sections.push(`**AsyncStorage (${asyncKeys.length}/${allKeys.length} keys):**
1656
+ ${lines.join("\n")}`);
1626
1657
  }
1627
- let lines;
1628
- if (args.includeValues) {
1629
- const pairs = await sync.callAction(STORAGE_TOOL_ID, "async.multiGet", { keys }, target);
1630
- const map = new Map((pairs ?? []).map(([k, v]) => [k, v]));
1631
- lines = keys.map((k) => `- \`${k}\` = ${truncate(map.get(k) ?? null)}`);
1632
- } else {
1633
- lines = keys.map((k) => `- \`${k}\``);
1658
+ const mmkv = await sync.callAction(STORAGE_TOOL_ID, "mmkv.snapshot", {}, target).catch(() => null);
1659
+ for (const inst of mmkv ?? []) {
1660
+ const entries = inst.entries.filter((e) => matches(e.key)).slice(0, limit);
1661
+ if (entries.length === 0) continue;
1662
+ const lines = entries.map(
1663
+ (e) => args.includeValues ? `- \`${e.key}\` (${e.valueType}) = ${truncate(e.value)}` : `- \`${e.key}\``
1664
+ );
1665
+ const flags = [inst.encrypted && "encrypted", inst.readOnly && "read-only"].filter(Boolean).join(", ");
1666
+ sections.push(`**MMKV \`${inst.id}\`${flags ? ` (${flags})` : ""} (${entries.length}/${inst.entries.length} keys):**
1667
+ ${lines.join("\n")}`);
1634
1668
  }
1635
- const filteredNote = pattern ? ` matching "${args.pattern}"` : "";
1636
- const header = `**Storage \u2014 ${allKeys.length} keys (showing ${keys.length}${filteredNote}):**`;
1637
- const note = args.includeValues ? "" : "\n\n_Keys only. Pass includeValues:true for values, or a pattern to filter. Modify with storage_action._";
1638
- return text7(`${header}
1639
-
1640
- ${lines.join("\n")}${note}`);
1669
+ const secure = await sync.callAction(STORAGE_TOOL_ID, "secure.keys", {}, target).catch(() => null);
1670
+ const secureKeys = (secure ?? []).filter((d) => matches(d.key)).slice(0, limit);
1671
+ if (secureKeys.length > 0) {
1672
+ const lines = await Promise.all(
1673
+ secureKeys.map(async (d) => {
1674
+ if (d.requireAuthentication) return `- \`${d.key}\` \u{1F512} (biometric-protected \u2014 not read)`;
1675
+ if (!args.includeValues) return `- \`${d.key}\``;
1676
+ const v = await sync.callAction(STORAGE_TOOL_ID, "secure.get", { key: d.key }, target).catch(() => null);
1677
+ return `- \`${d.key}\` = ${truncate(v)}`;
1678
+ })
1679
+ );
1680
+ sections.push(`**SecureStore (${secureKeys.length} registered keys):**
1681
+ ${lines.join("\n")}`);
1682
+ }
1683
+ if (sections.length === 0) {
1684
+ return text7(`No storage keys${pattern ? ` matching "${args.pattern}"` : ""} in any backend.`);
1685
+ }
1686
+ const note = args.includeValues ? "" : "\n\n_Keys only. Pass includeValues:true for values, or a pattern to filter. Modify AsyncStorage with storage_action._";
1687
+ return text7(`${sections.join("\n\n")}${note}`);
1641
1688
  } catch (e) {
1642
1689
  return fail8(errMsg8(e));
1643
1690
  }
package/dist/cli.mjs CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  SyncClient,
4
4
  createMcpServer,
5
5
  runInit
6
- } from "./chunk-SQKZOREF.mjs";
6
+ } from "./chunk-3PEIMUN7.mjs";
7
7
 
8
8
  // src/cli.ts
9
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
package/dist/index.d.mts CHANGED
@@ -204,9 +204,10 @@ declare function registerReduxTools(server: McpServer, sync: SyncClient): void;
204
204
 
205
205
  /**
206
206
  * The storage pack: a friendly, compact reader + writer over the app's
207
- * AsyncStorage. Reuses the storage adapter's existing async.* actions; the
208
- * device already strips Buoy's own devtool keys at the source, so only real app
209
- * keys are returned.
207
+ * storage backends — AsyncStorage, MMKV (all registered instances), and Expo
208
+ * SecureStore (registered keys). Reuses the storage adapter's async.*, mmkv.*
209
+ * and secure.* actions; the device already strips Buoy's own devtool keys at
210
+ * the source, so only real app keys are returned.
210
211
  */
211
212
  declare function registerStorageTools(server: McpServer, sync: SyncClient): void;
212
213
 
@@ -274,6 +275,8 @@ interface AutomationConfig {
274
275
  coolDownMs?: number;
275
276
  discardWarmupRuns?: number;
276
277
  shuffleCases?: boolean;
278
+ /** Capture per-component render counts/durations each run. Default true. */
279
+ captureRenders?: boolean;
277
280
  }
278
281
  /** A lightweight saved-recording summary (the device's index entry). */
279
282
  interface IndexEntry {
@@ -298,6 +301,14 @@ interface IndexEntry {
298
301
  caseId?: string;
299
302
  runIndex?: number;
300
303
  isMedianRun?: boolean;
304
+ /** Render-capture mirror (absent when capture was unavailable/disabled). */
305
+ renderCommits?: number;
306
+ renderTotalMs?: number;
307
+ topRenderers?: {
308
+ name: string;
309
+ renders: number;
310
+ totalMs: number;
311
+ }[];
301
312
  }
302
313
  /** Per-metric min/max/avg/p95 channel. */
303
314
  interface AggregateChannel {
@@ -337,9 +348,10 @@ interface BuildConfigInput {
337
348
  discardWarmupRuns?: number;
338
349
  shuffleCases?: boolean;
339
350
  reloadBetweenCases?: boolean;
351
+ captureRenders?: boolean;
340
352
  }
341
353
  /** The settings subset a driver inherits from the device's saved config. */
342
- type BenchmarkSettings = Partial<Pick<AutomationConfig, "perCaseDurationMs" | "settleMs" | "navTimeoutMs" | "runsPerCase" | "coolDownMs" | "discardWarmupRuns" | "shuffleCases" | "reloadBetweenCases">>;
354
+ type BenchmarkSettings = Partial<Pick<AutomationConfig, "perCaseDurationMs" | "settleMs" | "navTimeoutMs" | "runsPerCase" | "coolDownMs" | "discardWarmupRuns" | "shuffleCases" | "reloadBetweenCases" | "captureRenders">>;
343
355
  /**
344
356
  * Build + validate the AutomationConfig the device expects. Throws on bad input.
345
357
  *
@@ -372,6 +384,14 @@ interface RankRow {
372
384
  deltaJsFps: number | null;
373
385
  /** At-risk axes for this case (e.g. "UI thread drops frames", "CPU near cap"). */
374
386
  flags: string[];
387
+ /** React commits during the median run (null when capture was off/unavailable). */
388
+ renderCommits: number | null;
389
+ /** Top components by total render self-time during the median run. */
390
+ topRenderers: {
391
+ name: string;
392
+ renders: number;
393
+ totalMs: number;
394
+ }[];
375
395
  }
376
396
  interface BatchRanking {
377
397
  batchId: string;
package/dist/index.d.ts CHANGED
@@ -204,9 +204,10 @@ declare function registerReduxTools(server: McpServer, sync: SyncClient): void;
204
204
 
205
205
  /**
206
206
  * The storage pack: a friendly, compact reader + writer over the app's
207
- * AsyncStorage. Reuses the storage adapter's existing async.* actions; the
208
- * device already strips Buoy's own devtool keys at the source, so only real app
209
- * keys are returned.
207
+ * storage backends — AsyncStorage, MMKV (all registered instances), and Expo
208
+ * SecureStore (registered keys). Reuses the storage adapter's async.*, mmkv.*
209
+ * and secure.* actions; the device already strips Buoy's own devtool keys at
210
+ * the source, so only real app keys are returned.
210
211
  */
211
212
  declare function registerStorageTools(server: McpServer, sync: SyncClient): void;
212
213
 
@@ -274,6 +275,8 @@ interface AutomationConfig {
274
275
  coolDownMs?: number;
275
276
  discardWarmupRuns?: number;
276
277
  shuffleCases?: boolean;
278
+ /** Capture per-component render counts/durations each run. Default true. */
279
+ captureRenders?: boolean;
277
280
  }
278
281
  /** A lightweight saved-recording summary (the device's index entry). */
279
282
  interface IndexEntry {
@@ -298,6 +301,14 @@ interface IndexEntry {
298
301
  caseId?: string;
299
302
  runIndex?: number;
300
303
  isMedianRun?: boolean;
304
+ /** Render-capture mirror (absent when capture was unavailable/disabled). */
305
+ renderCommits?: number;
306
+ renderTotalMs?: number;
307
+ topRenderers?: {
308
+ name: string;
309
+ renders: number;
310
+ totalMs: number;
311
+ }[];
301
312
  }
302
313
  /** Per-metric min/max/avg/p95 channel. */
303
314
  interface AggregateChannel {
@@ -337,9 +348,10 @@ interface BuildConfigInput {
337
348
  discardWarmupRuns?: number;
338
349
  shuffleCases?: boolean;
339
350
  reloadBetweenCases?: boolean;
351
+ captureRenders?: boolean;
340
352
  }
341
353
  /** The settings subset a driver inherits from the device's saved config. */
342
- type BenchmarkSettings = Partial<Pick<AutomationConfig, "perCaseDurationMs" | "settleMs" | "navTimeoutMs" | "runsPerCase" | "coolDownMs" | "discardWarmupRuns" | "shuffleCases" | "reloadBetweenCases">>;
354
+ type BenchmarkSettings = Partial<Pick<AutomationConfig, "perCaseDurationMs" | "settleMs" | "navTimeoutMs" | "runsPerCase" | "coolDownMs" | "discardWarmupRuns" | "shuffleCases" | "reloadBetweenCases" | "captureRenders">>;
343
355
  /**
344
356
  * Build + validate the AutomationConfig the device expects. Throws on bad input.
345
357
  *
@@ -372,6 +384,14 @@ interface RankRow {
372
384
  deltaJsFps: number | null;
373
385
  /** At-risk axes for this case (e.g. "UI thread drops frames", "CPU near cap"). */
374
386
  flags: string[];
387
+ /** React commits during the median run (null when capture was off/unavailable). */
388
+ renderCommits: number | null;
389
+ /** Top components by total render self-time during the median run. */
390
+ topRenderers: {
391
+ name: string;
392
+ renders: number;
393
+ totalMs: number;
394
+ }[];
375
395
  }
376
396
  interface BatchRanking {
377
397
  batchId: string;
package/dist/index.js CHANGED
@@ -371,7 +371,7 @@ var import_node_os = __toESM(require("os"));
371
371
  var import_node_path = __toESM(require("path"));
372
372
 
373
373
  // src/version.ts
374
- var VERSION = "5.0.0" ? "5.0.0" : "0.0.0-dev";
374
+ var VERSION = "6.0.0" ? "6.0.0" : "0.0.0-dev";
375
375
  var PACKAGE_NAME = "@buoy-gg/mcp";
376
376
 
377
377
  // src/update/checkForUpdate.ts
@@ -605,7 +605,8 @@ function buildAutomationConfig(input, base = {}) {
605
605
  coolDownMs,
606
606
  discardWarmupRuns,
607
607
  shuffleCases,
608
- reloadBetweenCases: input.reloadBetweenCases ?? base.reloadBetweenCases ?? false
608
+ reloadBetweenCases: input.reloadBetweenCases ?? base.reloadBetweenCases ?? false,
609
+ captureRenders: input.captureRenders ?? base.captureRenders ?? true
609
610
  };
610
611
  return { config, warnings };
611
612
  }
@@ -619,7 +620,8 @@ function describeBenchmarkSettings(config) {
619
620
  `**${secs(config.perCaseDurationMs)} measured per page**`,
620
621
  runs > 1 ? `**${runs} runs each** (median taken)` : `**1 run each** (no median \u2014 noisier)`,
621
622
  config.reloadBetweenCases ? "**full reload between pages** (no state leaks across tests)" : "no reload between pages",
622
- config.shuffleCases ? "shuffled order (cancels thermal bias)" : "fixed order"
623
+ config.shuffleCases ? "shuffled order (cancels thermal bias)" : "fixed order",
624
+ config.captureRenders !== false ? "render capture on (per-component why-data)" : "render capture off"
623
625
  ];
624
626
  const perPageMs = config.settleMs + config.perCaseDurationMs + (config.coolDownMs ?? 0) + 1800;
625
627
  return `${parts.join(", ")}. That's ~${secs(perPageMs)} wall-clock per page; for ${config.cases.length} page(s) \xD7 ${runs} run(s), **${est}** total.`;
@@ -674,7 +676,9 @@ function rankBatchFromIndex(index, batchId, casesOrder, failures = []) {
674
676
  uiJank: num(median?.uiJankFrames),
675
677
  deviceMaxRefreshRate: num(median?.deviceMaxRefreshRate),
676
678
  deltaUiFps: null,
677
- deltaJsFps: null
679
+ deltaJsFps: null,
680
+ renderCommits: num(median?.renderCommits),
681
+ topRenderers: median?.topRenderers ?? []
678
682
  };
679
683
  return { ...base, flags: failed ? [] : computeFlags(base) };
680
684
  });
@@ -763,6 +767,16 @@ function formatRankingMarkdown(ranking) {
763
767
  `**\u26A0\uFE0F At-risk:** ${ranking.flagged.map((x) => `${x.caseName} (${x.flags.join("; ")})`).join(" \xB7 ")}`
764
768
  );
765
769
  }
770
+ const renderLines = ranking.rows.filter((r) => !r.failed && (r.renderCommits != null || r.topRenderers.length > 0)).map((r) => {
771
+ const top = r.topRenderers.map((t) => `${t.name} \xD7${t.renders} (${t.totalMs}ms)`).join(", ");
772
+ const commits = r.renderCommits != null ? `${r.renderCommits} commits` : "";
773
+ return `- **${r.caseName}** \u2014 ${[commits, top].filter(Boolean).join(": ")}`;
774
+ });
775
+ if (renderLines.length) {
776
+ lines.push("");
777
+ lines.push("**Renders (median run)** \u2014 heaviest components by render self-time:");
778
+ lines.push(...renderLines);
779
+ }
766
780
  lines.push("");
767
781
  lines.push(
768
782
  `_Deltas vs baseline (case #0). Don't pick on one metric \u2014 explain the trade-off (e.g. "A is cheapest but drops UI frames \u2192 jank risk; B costs more CPU but caps nothing")._`
@@ -815,7 +829,8 @@ async function readDeviceSettings(sync, deviceId) {
815
829
  coolDownMs,
816
830
  discardWarmupRuns,
817
831
  shuffleCases,
818
- reloadBetweenCases
832
+ reloadBetweenCases,
833
+ captureRenders
819
834
  } = cfg;
820
835
  return {
821
836
  perCaseDurationMs,
@@ -825,7 +840,8 @@ async function readDeviceSettings(sync, deviceId) {
825
840
  coolDownMs,
826
841
  discardWarmupRuns,
827
842
  shuffleCases,
828
- reloadBetweenCases
843
+ reloadBetweenCases,
844
+ captureRenders
829
845
  };
830
846
  } catch {
831
847
  return {};
@@ -929,6 +945,9 @@ function registerPerfMonitorTools(server, sync) {
929
945
  discardWarmupRuns: import_zod2.z.number().optional().describe("Discard first N runs of each case before taking the median. Default 0."),
930
946
  shuffleCases: import_zod2.z.boolean().optional().describe("Interleave + shuffle case order to cancel thermal bias. Default on for >3 cases."),
931
947
  reloadBetweenCases: import_zod2.z.boolean().optional().describe("Fully reload JS between cases (needs <AutomationResumer/> mounted). Default false."),
948
+ captureRenders: import_zod2.z.boolean().optional().describe(
949
+ "Capture per-component render counts/durations during each run (needs @buoy-gg/highlight-updates installed; dev builds only). Adds slight JS-thread overhead applied equally to every case. Default true."
950
+ ),
932
951
  deviceId: import_zod2.z.string().optional().describe("Target device id. Omit to auto-select when one device is connected."),
933
952
  maxWaitMs: import_zod2.z.number().optional().describe("Safety cap for how long to wait for completion. Defaults to ~3\xD7 the estimate + 60s.")
934
953
  }
@@ -1192,6 +1211,7 @@ ${JSON.stringify(settings, null, 2)}
1192
1211
  discardWarmupRuns: import_zod2.z.number().optional().describe("Discard first N runs of each page before the median."),
1193
1212
  shuffleCases: import_zod2.z.boolean().optional().describe("Interleave + shuffle case order to cancel thermal bias."),
1194
1213
  reloadBetweenCases: import_zod2.z.boolean().optional().describe("Fully reload JS between pages (needs <AutomationResumer/> mounted)."),
1214
+ captureRenders: import_zod2.z.boolean().optional().describe("Capture per-component render counts/durations during each run."),
1195
1215
  deviceId: import_zod2.z.string().optional().describe("Target device id (auto-selected if one is connected).")
1196
1216
  }
1197
1217
  },
@@ -1207,7 +1227,8 @@ ${JSON.stringify(settings, null, 2)}
1207
1227
  "runsPerCase",
1208
1228
  "discardWarmupRuns",
1209
1229
  "shuffleCases",
1210
- "reloadBetweenCases"
1230
+ "reloadBetweenCases",
1231
+ "captureRenders"
1211
1232
  ];
1212
1233
  const overrides = {};
1213
1234
  for (const k of keys) {
@@ -1637,42 +1658,68 @@ function registerStorageTools(server, sync) {
1637
1658
  server.registerTool(
1638
1659
  "get_storage",
1639
1660
  {
1640
- description: "Read the app's AsyncStorage keys. Compact by DEFAULT \u2014 keys only \u2014 so it's token-cheap; pass includeValues:true for the values (truncated), or a `pattern` substring to narrow. Buoy's own internal keys are already filtered out. Use for 'what's in storage / read this key'.",
1661
+ description: "Read the app's storage keys across ALL backends: AsyncStorage, MMKV (per instance), and Expo SecureStore. Compact by DEFAULT \u2014 keys only \u2014 so it's token-cheap; pass includeValues:true for the values (truncated), or a `pattern` substring to narrow. Buoy's own internal keys are already filtered out. Use for 'what's in storage / read this key'.",
1641
1662
  inputSchema: {
1642
1663
  pattern: import_zod8.z.string().optional().describe("Only keys containing this substring (case-insensitive)."),
1643
1664
  includeValues: import_zod8.z.boolean().optional().describe("Include each key's value (truncated). Default false (keys only)."),
1644
- limit: import_zod8.z.number().optional().describe("Max keys to return. Default 100."),
1665
+ limit: import_zod8.z.number().optional().describe("Max keys per backend section. Default 100."),
1645
1666
  deviceId: import_zod8.z.string().optional().describe("Target device id (auto-selected if one is connected).")
1646
1667
  }
1647
1668
  },
1648
1669
  async (args) => {
1649
1670
  try {
1650
1671
  const target = sync.requireProDevice(args.deviceId);
1672
+ const pattern = args.pattern?.toLowerCase();
1673
+ const limit = typeof args.limit === "number" && args.limit > 0 ? args.limit : 100;
1674
+ const matches = (k) => !pattern || k.toLowerCase().includes(pattern);
1675
+ const sections = [];
1651
1676
  const allKeys = await sync.callAction(STORAGE_TOOL_ID, "async.getAllKeys", {}, target);
1652
1677
  if (!Array.isArray(allKeys)) {
1653
1678
  return fail8("No storage result from the device. The storage tool may be inactive.");
1654
1679
  }
1655
- const pattern = args.pattern?.toLowerCase();
1656
- const matched = pattern ? allKeys.filter((k) => k.toLowerCase().includes(pattern)) : allKeys;
1657
- const limit = typeof args.limit === "number" && args.limit > 0 ? args.limit : 100;
1658
- const keys = matched.slice(0, limit);
1659
- if (keys.length === 0) {
1660
- return text7(`No storage keys${pattern ? ` matching "${args.pattern}"` : ""} (${allKeys.length} total).`);
1680
+ const asyncKeys = allKeys.filter(matches).slice(0, limit);
1681
+ if (asyncKeys.length > 0) {
1682
+ let lines;
1683
+ if (args.includeValues) {
1684
+ const pairs = await sync.callAction(STORAGE_TOOL_ID, "async.multiGet", { keys: asyncKeys }, target);
1685
+ const map = new Map((pairs ?? []).map(([k, v]) => [k, v]));
1686
+ lines = asyncKeys.map((k) => `- \`${k}\` = ${truncate(map.get(k) ?? null)}`);
1687
+ } else {
1688
+ lines = asyncKeys.map((k) => `- \`${k}\``);
1689
+ }
1690
+ sections.push(`**AsyncStorage (${asyncKeys.length}/${allKeys.length} keys):**
1691
+ ${lines.join("\n")}`);
1661
1692
  }
1662
- let lines;
1663
- if (args.includeValues) {
1664
- const pairs = await sync.callAction(STORAGE_TOOL_ID, "async.multiGet", { keys }, target);
1665
- const map = new Map((pairs ?? []).map(([k, v]) => [k, v]));
1666
- lines = keys.map((k) => `- \`${k}\` = ${truncate(map.get(k) ?? null)}`);
1667
- } else {
1668
- lines = keys.map((k) => `- \`${k}\``);
1693
+ const mmkv = await sync.callAction(STORAGE_TOOL_ID, "mmkv.snapshot", {}, target).catch(() => null);
1694
+ for (const inst of mmkv ?? []) {
1695
+ const entries = inst.entries.filter((e) => matches(e.key)).slice(0, limit);
1696
+ if (entries.length === 0) continue;
1697
+ const lines = entries.map(
1698
+ (e) => args.includeValues ? `- \`${e.key}\` (${e.valueType}) = ${truncate(e.value)}` : `- \`${e.key}\``
1699
+ );
1700
+ const flags = [inst.encrypted && "encrypted", inst.readOnly && "read-only"].filter(Boolean).join(", ");
1701
+ sections.push(`**MMKV \`${inst.id}\`${flags ? ` (${flags})` : ""} (${entries.length}/${inst.entries.length} keys):**
1702
+ ${lines.join("\n")}`);
1669
1703
  }
1670
- const filteredNote = pattern ? ` matching "${args.pattern}"` : "";
1671
- const header = `**Storage \u2014 ${allKeys.length} keys (showing ${keys.length}${filteredNote}):**`;
1672
- const note = args.includeValues ? "" : "\n\n_Keys only. Pass includeValues:true for values, or a pattern to filter. Modify with storage_action._";
1673
- return text7(`${header}
1674
-
1675
- ${lines.join("\n")}${note}`);
1704
+ const secure = await sync.callAction(STORAGE_TOOL_ID, "secure.keys", {}, target).catch(() => null);
1705
+ const secureKeys = (secure ?? []).filter((d) => matches(d.key)).slice(0, limit);
1706
+ if (secureKeys.length > 0) {
1707
+ const lines = await Promise.all(
1708
+ secureKeys.map(async (d) => {
1709
+ if (d.requireAuthentication) return `- \`${d.key}\` \u{1F512} (biometric-protected \u2014 not read)`;
1710
+ if (!args.includeValues) return `- \`${d.key}\``;
1711
+ const v = await sync.callAction(STORAGE_TOOL_ID, "secure.get", { key: d.key }, target).catch(() => null);
1712
+ return `- \`${d.key}\` = ${truncate(v)}`;
1713
+ })
1714
+ );
1715
+ sections.push(`**SecureStore (${secureKeys.length} registered keys):**
1716
+ ${lines.join("\n")}`);
1717
+ }
1718
+ if (sections.length === 0) {
1719
+ return text7(`No storage keys${pattern ? ` matching "${args.pattern}"` : ""} in any backend.`);
1720
+ }
1721
+ const note = args.includeValues ? "" : "\n\n_Keys only. Pass includeValues:true for values, or a pattern to filter. Modify AsyncStorage with storage_action._";
1722
+ return text7(`${sections.join("\n\n")}${note}`);
1676
1723
  } catch (e) {
1677
1724
  return fail8(errMsg8(e));
1678
1725
  }
package/dist/index.mjs CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  registerStorageTools,
28
28
  registerZustandTools,
29
29
  runInit
30
- } from "./chunk-SQKZOREF.mjs";
30
+ } from "./chunk-3PEIMUN7.mjs";
31
31
  export {
32
32
  ALL_DEVICES,
33
33
  BROKER_PORT,
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@buoy-gg/mcp",
3
- "version": "5.0.0",
3
+ "mcpName": "io.github.buoy-gg/mcp",
4
+ "version": "6.0.0",
4
5
  "description": "MCP server for Buoy devtools — lets any LLM copilot drive Buoy tools (perf-monitor benchmarks, etc.) over the devtool sync protocol",
5
6
  "main": "dist/index.js",
6
7
  "module": "dist/index.mjs",
@@ -30,7 +31,7 @@
30
31
  "@modelcontextprotocol/sdk": "^1.29.0",
31
32
  "socket.io-client": "^4.8.1",
32
33
  "zod": "^3.25.0",
33
- "@buoy-gg/sync-broker": "5.0.0"
34
+ "@buoy-gg/sync-broker": "6.0.0"
34
35
  },
35
36
  "devDependencies": {
36
37
  "@types/node": "^22.0.0",