@zibby/core 0.1.21 → 0.1.22

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.
Files changed (167) hide show
  1. package/dist/agents/base.js +17 -0
  2. package/dist/backend-client.js +1 -0
  3. package/dist/constants/tool-names.js +1 -0
  4. package/dist/constants/zibby-scratch.js +1 -0
  5. package/dist/constants.js +1 -0
  6. package/dist/enrichment/base.js +1 -0
  7. package/dist/enrichment/enrichers/accessibility-enricher.js +1 -0
  8. package/dist/enrichment/enrichers/dom-enricher.js +1 -0
  9. package/dist/enrichment/enrichers/page-state-enricher.js +1 -0
  10. package/dist/enrichment/enrichers/position-enricher.js +1 -0
  11. package/dist/enrichment/index.js +1 -0
  12. package/dist/enrichment/mcp-integration.js +1 -0
  13. package/dist/enrichment/mcp-ref-enricher.js +1 -0
  14. package/dist/enrichment/pipeline.js +3 -0
  15. package/dist/enrichment/trace-text-enricher.js +1 -0
  16. package/dist/framework/agents/assistant-strategy.js +5 -0
  17. package/dist/framework/agents/base.js +1 -0
  18. package/dist/framework/agents/claude-strategy.js +4 -0
  19. package/dist/framework/agents/codex-strategy.js +4 -0
  20. package/dist/framework/agents/cursor-strategy.js +32 -0
  21. package/dist/framework/agents/gemini-strategy.js +11 -0
  22. package/dist/framework/agents/index.js +13 -0
  23. package/dist/framework/agents/middleware/assistant-round-pipeline.js +3 -0
  24. package/dist/framework/agents/providers/base.js +1 -0
  25. package/dist/framework/agents/providers/index.js +1 -0
  26. package/dist/framework/agents/providers/openai-transport.js +2 -0
  27. package/dist/framework/agents/providers/openai.js +1 -0
  28. package/dist/framework/agents/providers/transport-base.js +1 -0
  29. package/dist/framework/agents/utils/auth-resolver.js +1 -0
  30. package/dist/framework/agents/utils/cursor-output-formatter.js +1 -0
  31. package/dist/framework/agents/utils/openai-proxy-formatter.js +9 -0
  32. package/dist/framework/agents/utils/payload-budget.js +3 -0
  33. package/dist/framework/agents/utils/structured-output-formatter.js +21 -0
  34. package/dist/framework/code-generator.js +10 -0
  35. package/dist/framework/constants.js +1 -0
  36. package/dist/framework/context-loader.js +5 -0
  37. package/dist/framework/function-bridge.js +2 -0
  38. package/dist/framework/function-skill-registry.js +1 -0
  39. package/dist/framework/graph-compiler.js +1 -0
  40. package/dist/framework/graph.js +5 -0
  41. package/dist/framework/index.js +1 -0
  42. package/dist/framework/mcp-client.js +2 -0
  43. package/dist/framework/node-registry.js +9 -0
  44. package/dist/framework/node.js +5 -0
  45. package/dist/framework/output-parser.js +3 -0
  46. package/dist/framework/skill-registry.js +1 -0
  47. package/dist/framework/state-utils.js +1 -0
  48. package/dist/framework/state.js +1 -0
  49. package/dist/framework/tool-resolver.js +1 -0
  50. package/dist/index.js +8 -0
  51. package/dist/runtime/generation/base.js +1 -0
  52. package/dist/runtime/generation/index.js +3 -0
  53. package/dist/runtime/generation/mcp-ref-strategy.js +41 -0
  54. package/dist/runtime/generation/stable-id-strategy.js +16 -0
  55. package/dist/runtime/stable-id-runtime.js +1 -0
  56. package/dist/runtime/verification/base.js +1 -0
  57. package/dist/runtime/verification/index.js +3 -0
  58. package/dist/runtime/verification/playwright-json-strategy.js +1 -0
  59. package/dist/runtime/zibby-runtime.js +1 -0
  60. package/dist/sync/index.js +1 -0
  61. package/dist/sync/uploader.js +1 -0
  62. package/dist/tools/run-playwright-test.js +5 -0
  63. package/dist/utils/adf-converter.js +7 -0
  64. package/dist/utils/ast-utils.js +1 -0
  65. package/dist/utils/ci-setup.js +5 -0
  66. package/dist/utils/cursor-mcp-isolated-home.js +1 -0
  67. package/dist/utils/cursor-utils.js +18 -0
  68. package/dist/utils/live-frame-discovery.js +1 -0
  69. package/dist/utils/logger.js +1 -0
  70. package/dist/utils/mcp-config-writer.js +10 -0
  71. package/dist/utils/mission-control-from-run-states.js +1 -0
  72. package/dist/utils/node-schema-parser.js +1 -0
  73. package/dist/utils/parallel-config.js +1 -0
  74. package/dist/utils/post-process-events.js +1 -0
  75. package/dist/utils/result-handler.js +1 -0
  76. package/{src → dist}/utils/ripple-effect.js +3 -12
  77. package/dist/utils/run-capacity-coordinator.js +1 -0
  78. package/dist/utils/run-capacity-queue.js +2 -0
  79. package/dist/utils/run-index-merge.js +1 -0
  80. package/dist/utils/run-index-post-cli.js +1 -0
  81. package/dist/utils/run-registry.js +3 -0
  82. package/dist/utils/run-state-session.js +2 -0
  83. package/dist/utils/selector-generator.js +4 -0
  84. package/dist/utils/session-state-constants.js +1 -0
  85. package/dist/utils/session-state-live-runs.js +1 -0
  86. package/dist/utils/streaming-parser.js +4 -0
  87. package/dist/utils/test-post-processor.js +18 -0
  88. package/dist/utils/timeline.js +14 -0
  89. package/dist/utils/trace-parser.js +2 -0
  90. package/dist/utils/video-organizer.js +3 -0
  91. package/package.json +49 -35
  92. package/templates/browser-test-automation/README.md +29 -7
  93. package/templates/browser-test-automation/chat.mjs +36 -0
  94. package/templates/browser-test-automation/graph.mjs +5 -9
  95. package/templates/browser-test-automation/nodes/execute-live.mjs +30 -58
  96. package/templates/browser-test-automation/nodes/generate-script.mjs +32 -12
  97. package/templates/browser-test-automation/nodes/utils.mjs +153 -10
  98. package/templates/browser-test-automation/pipeline-ids.js +12 -0
  99. package/templates/browser-test-automation/result-handler.mjs +78 -2
  100. package/templates/browser-test-automation/run-index.mjs +418 -0
  101. package/scripts/export-default-workflows.js +0 -51
  102. package/scripts/patch-cursor-mcp.js +0 -174
  103. package/scripts/setup-ci.sh +0 -115
  104. package/scripts/setup-official-playwright-mcp.sh +0 -226
  105. package/scripts/test-with-video.sh +0 -49
  106. package/src/agents/base.js +0 -361
  107. package/src/constants.js +0 -47
  108. package/src/enrichment/base.js +0 -49
  109. package/src/enrichment/enrichers/accessibility-enricher.js +0 -197
  110. package/src/enrichment/enrichers/dom-enricher.js +0 -171
  111. package/src/enrichment/enrichers/page-state-enricher.js +0 -129
  112. package/src/enrichment/enrichers/position-enricher.js +0 -67
  113. package/src/enrichment/index.js +0 -96
  114. package/src/enrichment/mcp-integration.js +0 -149
  115. package/src/enrichment/mcp-ref-enricher.js +0 -78
  116. package/src/enrichment/pipeline.js +0 -192
  117. package/src/enrichment/trace-text-enricher.js +0 -115
  118. package/src/framework/AGENTS.md +0 -98
  119. package/src/framework/agents/base.js +0 -72
  120. package/src/framework/agents/claude-strategy.js +0 -278
  121. package/src/framework/agents/cursor-strategy.js +0 -544
  122. package/src/framework/agents/index.js +0 -105
  123. package/src/framework/agents/utils/cursor-output-formatter.js +0 -67
  124. package/src/framework/agents/utils/openai-proxy-formatter.js +0 -249
  125. package/src/framework/code-generator.js +0 -301
  126. package/src/framework/constants.js +0 -33
  127. package/src/framework/context-loader.js +0 -101
  128. package/src/framework/function-bridge.js +0 -78
  129. package/src/framework/function-skill-registry.js +0 -20
  130. package/src/framework/graph-compiler.js +0 -342
  131. package/src/framework/graph.js +0 -610
  132. package/src/framework/index.js +0 -28
  133. package/src/framework/node-registry.js +0 -163
  134. package/src/framework/node.js +0 -259
  135. package/src/framework/output-parser.js +0 -71
  136. package/src/framework/skill-registry.js +0 -55
  137. package/src/framework/state-utils.js +0 -52
  138. package/src/framework/state.js +0 -67
  139. package/src/framework/tool-resolver.js +0 -65
  140. package/src/index.js +0 -345
  141. package/src/runtime/generation/base.js +0 -46
  142. package/src/runtime/generation/index.js +0 -70
  143. package/src/runtime/generation/mcp-ref-strategy.js +0 -197
  144. package/src/runtime/generation/stable-id-strategy.js +0 -170
  145. package/src/runtime/stable-id-runtime.js +0 -248
  146. package/src/runtime/verification/base.js +0 -44
  147. package/src/runtime/verification/index.js +0 -67
  148. package/src/runtime/verification/playwright-json-strategy.js +0 -119
  149. package/src/runtime/zibby-runtime.js +0 -299
  150. package/src/sync/index.js +0 -2
  151. package/src/sync/uploader.js +0 -29
  152. package/src/tools/run-playwright-test.js +0 -158
  153. package/src/utils/adf-converter.js +0 -68
  154. package/src/utils/ast-utils.js +0 -37
  155. package/src/utils/ci-setup.js +0 -124
  156. package/src/utils/cursor-utils.js +0 -71
  157. package/src/utils/logger.js +0 -144
  158. package/src/utils/mcp-config-writer.js +0 -115
  159. package/src/utils/node-schema-parser.js +0 -522
  160. package/src/utils/post-process-events.js +0 -55
  161. package/src/utils/result-handler.js +0 -102
  162. package/src/utils/selector-generator.js +0 -239
  163. package/src/utils/streaming-parser.js +0 -387
  164. package/src/utils/test-post-processor.js +0 -211
  165. package/src/utils/timeline.js +0 -217
  166. package/src/utils/trace-parser.js +0 -325
  167. package/src/utils/video-organizer.js +0 -91
@@ -2,6 +2,9 @@ import { readFileSync, existsSync } from 'fs';
2
2
 
3
3
  const ACTIONABLE_EVENTS = ['navigate', 'type', 'fill', 'click', 'select_option', 'select'];
4
4
 
5
+ // ARIA roles that accept text input via Playwright .fill()
6
+ const FILLABLE_ROLES = new Set(['textbox', 'combobox', 'spinbutton', 'searchbox']);
7
+
5
8
  /**
6
9
  * Load recorded browser events from a session's events.json,
7
10
  * filtering to only actionable events (navigate, fill, click, etc.)
@@ -51,23 +54,63 @@ function tokenOverlapScore(a, b) {
51
54
  return tokensA.filter(t => tokensB.some(tb => tb.includes(t) || t.includes(tb))).length;
52
55
  }
53
56
 
54
- /** Find a fallback selector for an element by fuzzy-matching against the selector map. */
55
- function findFallbackSelector(elementKey, selectorMap) {
57
+ /**
58
+ * Check whether an ARIA role is compatible with an action type.
59
+ * Prevents click actions from getting textbox fallbacks and vice versa.
60
+ */
61
+ function isRoleCompatible(role, actionType) {
62
+ if (!role || role === 'generic' || role === 'none') return true;
63
+ if (actionType === 'click' && FILLABLE_ROLES.has(role)) return false;
64
+ if ((actionType === 'fill' || actionType === 'type') && !FILLABLE_ROLES.has(role)) return false;
65
+ return true;
66
+ }
67
+
68
+ function formatRoleSelector(roleObj) {
69
+ if (!roleObj) return '';
70
+ const { role, name } = roleObj;
71
+ if (role === 'generic' || role === 'none') {
72
+ return name ? ` | fallback: getByText('${name}')` : '';
73
+ }
74
+ return ` | fallback: getByRole('${role}'${name ? `, { name: '${name}' }` : ''})`;
75
+ }
76
+
77
+ /**
78
+ * Find a fallback selector for an element by matching against the AI selector map.
79
+ * 1. Try exact description match first.
80
+ * 2. Try role.name containment — prefer the longest (most specific) role name found in the element description.
81
+ * 3. Fall back to fuzzy token matching with ≥2 token overlap threshold.
82
+ * 4. Validate ARIA role compatibility with the action type.
83
+ */
84
+ function findFallbackSelector(elementKey, selectorMap, actionType) {
85
+ if (selectorMap[elementKey]?.role && isRoleCompatible(selectorMap[elementKey].role.role, actionType)) {
86
+ return formatRoleSelector(selectorMap[elementKey].role);
87
+ }
88
+
89
+ let bestNameMatch = null;
90
+ let bestNameLen = 0;
91
+ for (const [, selectors] of Object.entries(selectorMap)) {
92
+ if (!isRoleCompatible(selectors.role?.role, actionType)) continue;
93
+ const roleName = selectors.role?.name;
94
+ if (!roleName) continue;
95
+ if (elementKey.includes(roleName.toLowerCase()) && roleName.length > bestNameLen) {
96
+ bestNameLen = roleName.length;
97
+ bestNameMatch = selectors;
98
+ }
99
+ }
100
+ if (bestNameMatch?.role) return formatRoleSelector(bestNameMatch.role);
101
+
56
102
  let bestMatch = null;
57
103
  let bestScore = 0;
58
104
  for (const [k, selectors] of Object.entries(selectorMap)) {
105
+ if (!isRoleCompatible(selectors.role?.role, actionType)) continue;
59
106
  const score = tokenOverlapScore(elementKey, k);
60
107
  if (score > bestScore) {
61
108
  bestScore = score;
62
109
  bestMatch = selectors;
63
110
  }
64
111
  }
65
- if (!bestMatch?.role || bestScore < 1) return '';
66
- const { role, name } = bestMatch.role;
67
- if (role === 'generic' || role === 'none') {
68
- return name ? ` | fallback: getByText('${name}')` : '';
69
- }
70
- return ` | fallback: getByRole('${role}'${name ? `, { name: '${name}' }` : ''})`;
112
+ if (!bestMatch?.role || bestScore < 2) return '';
113
+ return formatRoleSelector(bestMatch.role);
71
114
  }
72
115
 
73
116
  /** Extract visible text hint from element description for use as a getByText fallback. */
@@ -80,12 +123,91 @@ function extractTextHint(elementDesc) {
80
123
  return '';
81
124
  }
82
125
 
126
+ /**
127
+ * Mark duplicate stableIds across actions. When the same stableId appears
128
+ * on different steps, the second+ occurrence gets nullified with a marker
129
+ * so the LLM knows to use the fallback selector instead.
130
+ */
131
+ export function deduplicateStableIds(actions) {
132
+ const seen = new Map();
133
+ return actions.map((a, i) => {
134
+ if (!a.stableId || a.stableId === 'undefined') return a;
135
+ if (seen.has(a.stableId)) {
136
+ return { ...a, _dupOf: seen.get(a.stableId), stableId: null };
137
+ }
138
+ seen.set(a.stableId, i);
139
+ return a;
140
+ });
141
+ }
142
+
143
+ /**
144
+ * Detect a form-submission setup sequence at the start of recorded actions.
145
+ * Purely structural — no hardcoded keywords. Finds a block of fill/type/click
146
+ * actions near the start containing ≥2 fills, ending with a click (submit).
147
+ * The LLM decides what it is (login, registration, etc.) from context.
148
+ */
149
+ export function detectLoginPattern(actions) {
150
+ if (!actions || actions.length < 3) return null;
151
+
152
+ let start = 0;
153
+ while (start < actions.length && actions[start].type === 'navigate') start++;
154
+ if (start >= actions.length) return null;
155
+
156
+ let lastFillIdx = -1;
157
+ let fillCount = 0;
158
+ let i = start;
159
+ while (i < actions.length) {
160
+ const t = actions[i].type;
161
+ if (t === 'fill' || t === 'type') {
162
+ fillCount++;
163
+ lastFillIdx = i;
164
+ } else if (t !== 'click') {
165
+ break;
166
+ }
167
+ i++;
168
+ }
169
+
170
+ if (fillCount < 2 || lastFillIdx < 0) return null;
171
+
172
+ let submitIdx = -1;
173
+ for (let j = lastFillIdx + 1; j < actions.length; j++) {
174
+ if (actions[j].type === 'click') { submitIdx = j; break; }
175
+ if (actions[j].type !== 'fill' && actions[j].type !== 'type') break;
176
+ }
177
+ if (submitIdx < 0) return null;
178
+
179
+ const remaining = actions.length - submitIdx - 1;
180
+ if (remaining < 1) return null;
181
+
182
+ return {
183
+ startIndex: 0,
184
+ endIndex: submitIdx,
185
+ fillCount,
186
+ actions: actions.slice(0, submitIdx + 1),
187
+ };
188
+ }
189
+
83
190
  /** Format a single recorded event into a human-readable action line with stableId and fallback selector. */
84
191
  export function formatAction(event, index, selectorMap) {
85
192
  const num = index + 1;
86
193
  const reason = event.reasoning ? ` | reason: "${event.reasoning}"` : '';
87
194
  const hasStableId = event.stableId && event.stableId !== 'undefined';
88
- const fallback = findFallbackSelector((event.element || '').toLowerCase(), selectorMap);
195
+
196
+ if (event._dupOf !== undefined) {
197
+ const dupHint = `[DUPLICATE_STABLE_ID — same as step ${event._dupOf + 1}, use fallback]`;
198
+ const fallback = findFallbackSelector((event.element || '').toLowerCase(), selectorMap, event.type);
199
+ const textFb = !fallback ? (() => { const h = extractTextHint(event.element); return h ? ` | fallback: getByText('${h}')` : ''; })() : '';
200
+ switch (event.type) {
201
+ case 'click':
202
+ return `${num}. CLICK ${dupHint} - ${event.element || 'element'}${fallback || textFb}${reason}`;
203
+ case 'fill': case 'type':
204
+ return `${num}. FILL ${dupHint} with "${event.value}" - ${event.element || 'field'}${fallback || textFb}${reason}`;
205
+ default:
206
+ return `${num}. ${event.type} ${dupHint} ${event.value || ''}${fallback || textFb}${reason}`;
207
+ }
208
+ }
209
+
210
+ const fallback = findFallbackSelector((event.element || '').toLowerCase(), selectorMap, event.type);
89
211
  const idPart = hasStableId ? `[stableId="${event.stableId}"]` : '[NO_STABLE_ID]';
90
212
 
91
213
  let textFallback = '';
@@ -111,14 +233,35 @@ export function formatAction(event, index, selectorMap) {
111
233
  /**
112
234
  * Build the full RECORDED ACTIONS prompt block by loading events.json
113
235
  * and merging with AI-captured selectors from execution output.
236
+ * Auto-deduplicates stableIds before formatting.
114
237
  */
115
238
  export function formatRecordedActions(sessionPath, executionActions) {
116
- const recorded = loadRecordedActions(sessionPath);
239
+ let recorded = loadRecordedActions(sessionPath);
117
240
  if (recorded.length === 0) return '';
241
+ recorded = deduplicateStableIds(recorded);
118
242
  const selectorMap = buildSelectorMap(executionActions);
119
243
  return `RECORDED ACTIONS:\n${recorded.map((e, i) => formatAction(e, i, selectorMap)).join('\n')}\n\nGenerate a CLEAN script from these — not a 1:1 replay. If stableId fails, use the fallback selector.`;
120
244
  }
121
245
 
246
+ /**
247
+ * Format a prompt hint when a setup sequence (form submission) is detected
248
+ * at the start of the recorded actions. Tells the LLM to check for or
249
+ * create a shared fixture — no prescriptive file names or code patterns.
250
+ */
251
+ export function formatSetupHint(setup) {
252
+ if (!setup) return '';
253
+ return `
254
+ SETUP SEQUENCE DETECTED (steps ${setup.startIndex + 1}–${setup.endIndex + 1}, ${setup.fillCount} form fields):
255
+ The first ${setup.endIndex + 1} actions are a form-submission sequence that will repeat across tests.
256
+ BEFORE writing ANY new files, you MUST search the codebase:
257
+ 1. Search tests/ for existing fixture, helper, or setup files (e.g. grep for "export.*function" in tests/)
258
+ 2. Read any found files to see what they export
259
+ 3. If an existing function already handles this setup → import and reuse it, skip steps ${setup.startIndex + 1}–${setup.endIndex + 1}
260
+ 4. ONLY create a new fixture if nothing existing covers these steps
261
+ DO NOT create a duplicate fixture. Reuse what exists.
262
+ `;
263
+ }
264
+
122
265
  /** Format preflight assertions into a simple numbered checklist for the execute_live prompt. */
123
266
  export function formatAssertionChecklist(assertions) {
124
267
  if (!assertions?.length) return '';
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Browser-test **template only** — ordered graph node ids used for:
3
+ * - JSONL `recordKind: 'progress'` validation in this template’s `run-index.mjs`
4
+ * - Studio UI that visualizes this workflow (imports from this path, not from `src/`).
5
+ *
6
+ * Other workflows should define their own ordered id list alongside their template.
7
+ */
8
+ export const BROWSER_TEST_PIPELINE_NODE_IDS = Object.freeze([
9
+ 'preflight',
10
+ 'execute_live',
11
+ 'generate_script',
12
+ ]);
@@ -4,8 +4,17 @@
4
4
  * and assertion → event ID resolution.
5
5
  */
6
6
 
7
- import { writeFileSync, existsSync, readFileSync, readdirSync, renameSync, statSync } from 'fs';
8
- import { join } from 'path';
7
+ import {
8
+ writeFileSync,
9
+ existsSync,
10
+ readFileSync,
11
+ readdirSync,
12
+ renameSync,
13
+ statSync,
14
+ copyFileSync,
15
+ mkdirSync,
16
+ } from 'fs';
17
+ import { join, isAbsolute, normalize } from 'path';
9
18
  import { spawnSync } from 'child_process';
10
19
  import { ResultHandler, logger } from '@zibby/core';
11
20
 
@@ -283,4 +292,71 @@ export class BrowserTestResultHandler extends ResultHandler {
283
292
  console.warn('⚠️ Could not enrich events:', err.message);
284
293
  }
285
294
  }
295
+
296
+ /**
297
+ * Copy the final Playwright/Selenium file into `session/generate_script/` under stable names
298
+ * so Studio (Electron + web bridge) can load scripts without resolving arbitrary `scriptPath`.
299
+ * Safe to run even when the agent already wrote into that folder.
300
+ */
301
+ static ensureStudioCodegenMirror(sessionRoot, cwd) {
302
+ if (!sessionRoot || typeof sessionRoot !== 'string') return;
303
+ const gsDir = join(sessionRoot, 'generate_script');
304
+ const resultPath = join(gsDir, 'result.json');
305
+ if (!existsSync(resultPath)) return;
306
+
307
+ let data;
308
+ try {
309
+ data = JSON.parse(readFileSync(resultPath, 'utf-8'));
310
+ } catch {
311
+ return;
312
+ }
313
+ const raw = data?.scriptPath;
314
+ if (!raw || typeof raw !== 'string' || !raw.trim()) return;
315
+
316
+ const t = raw.trim();
317
+ const candidates = [];
318
+ if (isAbsolute(t)) candidates.push(normalize(t));
319
+ if (cwd && typeof cwd === 'string') candidates.push(join(cwd, t));
320
+ candidates.push(join(sessionRoot, t));
321
+ candidates.push(join(gsDir, t));
322
+
323
+ let src = null;
324
+ for (const c of candidates) {
325
+ try {
326
+ if (existsSync(c) && statSync(c).isFile()) {
327
+ src = c;
328
+ break;
329
+ }
330
+ } catch {
331
+ /* ignore */
332
+ }
333
+ }
334
+ if (!src) {
335
+ logger.debug(`[Studio] Codegen mirror: could not resolve scriptPath "${t}"`);
336
+ return;
337
+ }
338
+
339
+ const lower = src.toLowerCase();
340
+ const destName = lower.endsWith('.py')
341
+ ? 'test.selenium.py'
342
+ : lower.endsWith('.ts') || lower.endsWith('.tsx')
343
+ ? 'playwright.spec.ts'
344
+ : 'generated-test.spec.js';
345
+ const dest = join(gsDir, destName);
346
+
347
+ try {
348
+ if (existsSync(dest)) {
349
+ try {
350
+ if (statSync(dest).mtimeMs >= statSync(src).mtimeMs) return;
351
+ } catch {
352
+ /* replace if unsure */
353
+ }
354
+ }
355
+ mkdirSync(gsDir, { recursive: true });
356
+ copyFileSync(src, dest);
357
+ logger.info(`[Studio] Mirrored script for discovery: ${dest}`);
358
+ } catch (e) {
359
+ logger.debug(`[Studio] Codegen mirror failed: ${e.message}`);
360
+ }
361
+ }
286
362
  }