@fiodos/cli 0.1.25 → 0.1.28

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.
@@ -61,14 +61,22 @@ const {
61
61
  buildBridgeModule,
62
62
  insertImportBlock,
63
63
  insertRegisterBlock,
64
+ deriveBridgeAnchor,
64
65
  } = require('./wireHandlers');
65
66
 
66
67
  const SESSION_BASENAME = 'session.generated';
67
68
  const DOC_BASENAME = 'FYODOS_SESSION.md';
68
69
  const IS_AUTH_EXPORT = 'fyodosIsAuthenticated';
69
70
  const USER_ID_EXPORT = 'fyodosGetUserId';
71
+ const CAPS_EXPORT = 'fyodosGetSessionCapabilities';
70
72
  const BRIDGE_IS_AUTH = '__fyodos_is_authenticated';
71
73
  const BRIDGE_USER_ID = '__fyodos_user_id';
74
+ const BRIDGE_CAPABILITIES = '__fyodos_capabilities';
75
+
76
+ // Capability flags are short snake_case names ("has_payment_method"). Anything
77
+ // else is dropped: a malformed name could never match the manifest's
78
+ // requiredCapabilities anyway, so wiring it would be dead weight.
79
+ const CAPABILITY_NAME_RE = /^[a-z][a-z0-9_]{0,63}$/;
72
80
 
73
81
  // Session edits carry their OWN markers: wireHandlers' stripFiodosBlocks
74
82
  // removes every FYODOS:BRIDGE block in a file, so if the session registration
@@ -128,11 +136,46 @@ function verifyExpr({ expr, importedNames, extraOk = new Set(), what }) {
128
136
  return null;
129
137
  }
130
138
 
139
+ /**
140
+ * Verify the AI's fine-grained session capabilities (Eje B audience map:
141
+ * "has_payment_method", "is_admin"…) INDIVIDUALLY: a hallucinated capability
142
+ * is dropped with an honest note while the rest of the session wiring still
143
+ * applies — one bad flag must never cost the whole auth awareness.
144
+ * Returns { caps: [{ name, expr }], notes: [reason...] }.
145
+ */
146
+ function verifyCapabilities({ rawCapabilities, exprKey, importedNames, extraOk = new Set() }) {
147
+ const caps = [];
148
+ const notes = [];
149
+ const seen = new Set();
150
+ for (const c of Array.isArray(rawCapabilities) ? rawCapabilities : []) {
151
+ const name = String((c && c.name) || '').trim();
152
+ const expr = String((c && c[exprKey]) || '').trim();
153
+ if (!CAPABILITY_NAME_RE.test(name)) {
154
+ notes.push(`capability '${name || '(unnamed)'}' dropped: not a short snake_case name`);
155
+ continue;
156
+ }
157
+ if (seen.has(name)) continue;
158
+ if (!expr) {
159
+ notes.push(`capability '${name}' dropped: no '${exprKey}' expression was provided`);
160
+ continue;
161
+ }
162
+ const err = verifyExpr({ expr, importedNames, extraOk, what: `capability '${name}'` });
163
+ if (err) {
164
+ notes.push(`capability '${name}' dropped: ${err}`);
165
+ continue;
166
+ }
167
+ seen.add(name);
168
+ caps.push({ name, expr });
169
+ }
170
+ return { caps: caps.slice(0, 8), notes };
171
+ }
172
+
131
173
  /**
132
174
  * Turn the AI's `session` block into a verified plan, or { reason }.
133
175
  * Plan shapes:
134
- * { strategy:'module', imports, isAuthExpr, userIdExpr|null }
135
- * { strategy:'bridge', bridge:{ file, anchor, scope, imports, isAuthInvoke, userIdInvoke|null } }
176
+ * { strategy:'module', imports, isAuthExpr, userIdExpr|null, capabilities, capabilityNotes }
177
+ * { strategy:'bridge', bridge:{ file, anchor, scope, imports, isAuthInvoke, userIdInvoke|null,
178
+ * capabilities }, capabilityNotes }
136
179
  */
137
180
  function prepareSessionPlan({ appRoot, session, fyodosDirRel }) {
138
181
  if (!session || typeof session !== 'object') return { reason: 'no session block' };
@@ -153,7 +196,7 @@ function prepareSessionPlan({ appRoot, session, fyodosDirRel }) {
153
196
  if (session.strategy === 'bridge') {
154
197
  const b = session.bridge || {};
155
198
  const file = normRel(b.file);
156
- const anchor = String(b.anchor || '').trim();
199
+ let anchor = String(b.anchor || '').trim();
157
200
  const isAuthInvoke = String(b.isAuthenticatedInvoke || '').trim();
158
201
  const userIdInvoke = String(b.userIdInvoke || '').trim() || null;
159
202
 
@@ -161,30 +204,49 @@ function prepareSessionPlan({ appRoot, session, fyodosDirRel }) {
161
204
  const content = readFileSafe(appRoot, file);
162
205
  if (content == null) return { reason: `could not read the component '${file}'` };
163
206
  if (!isAuthInvoke) return { reason: `no isAuthenticated expression was provided for '${file}'` };
164
- if (!anchor) return { reason: `no insertion anchor was provided in '${file}'` };
165
207
  // The anchor must be unique in the file WITHOUT counting our own previous
166
- // insertions (idempotent re-runs).
167
- const occ = countOccurrences(stripSessionBlocks(content), anchor);
168
- if (occ === 0) return { reason: `no safe insertion point was found in '${file}'` };
169
- if (occ > 1) return { reason: `the insertion anchor appears ${occ} times in '${file}'; it is ambiguous` };
208
+ // insertions (idempotent re-runs). When the AI's anchor is missing or
209
+ // ambiguous, derive a deterministic one from the declaration of the symbol
210
+ // the invoke reads (same rescue as handler bridges) instead of giving up
211
+ // safety is still guaranteed by the compile check + revert below.
212
+ const cleanContent = stripSessionBlocks(content);
213
+ const occ = anchor ? countOccurrences(cleanContent, anchor) : 0;
214
+ if (occ !== 1) {
215
+ const derived = deriveBridgeAnchor(cleanContent, isAuthInvoke);
216
+ if (derived) anchor = derived;
217
+ else if (!anchor || occ === 0) return { reason: `no safe insertion point was found in '${file}'` };
218
+ else return { reason: `the insertion anchor appears ${occ} times in '${file}'; it is ambiguous` };
219
+ }
170
220
 
171
221
  const v = verifyImports({ appRoot, rawImports: b.imports, importBaseDirRel: path.dirname(file) });
172
222
  if (v.reason) return v;
173
223
 
174
224
  // Identifiers must be in-file, imported, args-free locals or safe globals.
175
225
  const inFileOk = new Set();
176
- for (const expr of [isAuthInvoke, userIdInvoke].filter(Boolean)) {
226
+ const capExprs = (Array.isArray(session.capabilities) ? session.capabilities : [])
227
+ .map((c) => String((c && c.invoke) || '').trim())
228
+ .filter(Boolean);
229
+ for (const expr of [isAuthInvoke, userIdInvoke, ...capExprs].filter(Boolean)) {
177
230
  for (const id of freeIdentifiers(expr)) {
178
231
  if (fileHasSymbol(appRoot, file, id)) inFileOk.add(id);
179
232
  }
233
+ }
234
+ for (const expr of [isAuthInvoke, userIdInvoke].filter(Boolean)) {
180
235
  const err = verifyExpr({ expr, importedNames: v.importedNames, extraOk: inFileOk, what: 'session' });
181
236
  if (err) return { reason: `${err} — in '${file}'` };
182
237
  }
238
+ const capsV = verifyCapabilities({
239
+ rawCapabilities: session.capabilities,
240
+ exprKey: 'invoke',
241
+ importedNames: v.importedNames,
242
+ extraOk: inFileOk,
243
+ });
183
244
 
184
245
  return {
185
246
  plan: {
186
247
  strategy: 'bridge',
187
248
  reason: String(session.reason || ''),
249
+ capabilityNotes: capsV.notes,
188
250
  bridge: {
189
251
  file,
190
252
  anchor,
@@ -192,6 +254,7 @@ function prepareSessionPlan({ appRoot, session, fyodosDirRel }) {
192
254
  imports: v.imports,
193
255
  isAuthInvoke,
194
256
  userIdInvoke,
257
+ capabilities: capsV.caps,
195
258
  importPathToBridge: relImport(path.dirname(file), path.join(fyodosDirRel, BRIDGE_BASENAME)),
196
259
  },
197
260
  },
@@ -209,6 +272,11 @@ function prepareSessionPlan({ appRoot, session, fyodosDirRel }) {
209
272
  const err = verifyExpr({ expr, importedNames: v.importedNames, what: 'session' });
210
273
  if (err) return { reason: err };
211
274
  }
275
+ const capsV = verifyCapabilities({
276
+ rawCapabilities: session.capabilities,
277
+ exprKey: 'call',
278
+ importedNames: v.importedNames,
279
+ });
212
280
 
213
281
  return {
214
282
  plan: {
@@ -217,6 +285,8 @@ function prepareSessionPlan({ appRoot, session, fyodosDirRel }) {
217
285
  imports: v.imports,
218
286
  isAuthExpr,
219
287
  userIdExpr,
288
+ capabilities: capsV.caps,
289
+ capabilityNotes: capsV.notes,
220
290
  },
221
291
  };
222
292
  }
@@ -240,6 +310,8 @@ function buildSessionModule(plan, opts = {}) {
240
310
  const esm = opts.esm !== false;
241
311
  const boolRet = ts ? ': boolean | null' : '';
242
312
  const strRet = ts ? ': string | null' : '';
313
+ const capsRet = ts ? ': Record<string, boolean> | null' : '';
314
+ const capsDecl = ts ? ': Record<string, boolean>' : '';
243
315
 
244
316
  const L = [];
245
317
  L.push(
@@ -282,6 +354,27 @@ function buildSessionModule(plan, opts = {}) {
282
354
  L.push(' return null;');
283
355
  L.push(' }');
284
356
  L.push('}');
357
+ L.push('');
358
+ L.push('/**');
359
+ L.push(' * Fine-grained session capability flags (audience map: has_payment_method,');
360
+ L.push(' * is_admin…). null = UNKNOWN — the orb then filters NOTHING (fail-open).');
361
+ L.push(' * Boolean flags only; never counts, names or any user data.');
362
+ L.push(' */');
363
+ L.push(`export function ${CAPS_EXPORT}()${capsRet} {`);
364
+ L.push(' try {');
365
+ L.push(` const fn = getFiodosBridge()[${JSON.stringify(BRIDGE_CAPABILITIES)}];`);
366
+ L.push(" if (typeof fn !== 'function') return null;");
367
+ L.push(' const raw = fn();');
368
+ L.push(" if (!raw || typeof raw !== 'object') return null;");
369
+ L.push(` const caps${capsDecl} = {};`);
370
+ L.push(' for (const k of Object.keys(raw)) {');
371
+ L.push(" if (typeof raw[k] === 'boolean') caps[k] = raw[k];");
372
+ L.push(' }');
373
+ L.push(' return Object.keys(caps).length > 0 ? caps : null;');
374
+ L.push(' } catch {');
375
+ L.push(' return null;');
376
+ L.push(' }');
377
+ L.push('}');
285
378
  } else {
286
379
  for (const imp of plan.imports || []) L.push(importLine(imp, esm));
287
380
  if ((plan.imports || []).length) L.push('');
@@ -310,9 +403,33 @@ function buildSessionModule(plan, opts = {}) {
310
403
  L.push(' return null;');
311
404
  }
312
405
  L.push('}');
406
+ L.push('');
407
+ L.push('/**');
408
+ L.push(' * Fine-grained session capability flags (audience map: has_payment_method,');
409
+ L.push(' * is_admin…). null = UNKNOWN — the orb then filters NOTHING (fail-open).');
410
+ L.push(' * Boolean flags only; never counts, names or any user data.');
411
+ L.push(' */');
412
+ L.push(`export function ${CAPS_EXPORT}()${capsRet} {`);
413
+ const moduleCaps = Array.isArray(plan.capabilities) ? plan.capabilities : [];
414
+ if (moduleCaps.length > 0) {
415
+ L.push(` const caps${capsDecl} = {};`);
416
+ for (const c of moduleCaps) {
417
+ // Each flag is isolated: one throwing read stays UNKNOWN (omitted)
418
+ // instead of blanking every other capability.
419
+ L.push(' try {');
420
+ L.push(` caps[${JSON.stringify(c.name)}] = Boolean(${c.expr});`);
421
+ L.push(' } catch {');
422
+ L.push(' /* unknown — omitted (fail-open) */');
423
+ L.push(' }');
424
+ }
425
+ L.push(' return Object.keys(caps).length > 0 ? caps : null;');
426
+ } else {
427
+ L.push(' return null;');
428
+ }
429
+ L.push('}');
313
430
  }
314
431
  L.push('');
315
- if (!esm) L.push(`module.exports = { ${IS_AUTH_EXPORT}, ${USER_ID_EXPORT} };`);
432
+ if (!esm) L.push(`module.exports = { ${IS_AUTH_EXPORT}, ${USER_ID_EXPORT}, ${CAPS_EXPORT} };`);
316
433
  let body = L.join('\n');
317
434
  if (!esm) {
318
435
  // CommonJS build: `export function` is invalid — rewrite to plain functions.
@@ -329,6 +446,11 @@ function planSessionComponentEdit(appRoot, plan) {
329
446
 
330
447
  const methods = [` ${JSON.stringify(BRIDGE_IS_AUTH)}: () => (${b.isAuthInvoke}),`];
331
448
  if (b.userIdInvoke) methods.push(` ${JSON.stringify(BRIDGE_USER_ID)}: () => (${b.userIdInvoke}),`);
449
+ const caps = Array.isArray(b.capabilities) ? b.capabilities : [];
450
+ if (caps.length > 0) {
451
+ const entries = caps.map((c) => `${JSON.stringify(c.name)}: Boolean(${c.expr})`).join(', ');
452
+ methods.push(` ${JSON.stringify(BRIDGE_CAPABILITIES)}: () => ({ ${entries} }),`);
453
+ }
332
454
 
333
455
  const importLines = [`import { registerFiodosBridge } from '${b.importPathToBridge}';`];
334
456
  for (const imp of b.imports || []) importLines.push(importLine(imp, true));
@@ -399,10 +521,22 @@ function buildSessionDoc(plan, ctx = {}) {
399
521
  L.push('');
400
522
  L.push(`- App: \`${appName}\``);
401
523
  L.push(`- Strategy: \`${plan.strategy}\`${plan.reason ? ` — ${plan.reason}` : ''}`);
402
- L.push(`- Creates \`${sessionRel}\` — exports \`${IS_AUTH_EXPORT}()\` and \`${USER_ID_EXPORT}()\`.`);
524
+ L.push(`- Creates \`${sessionRel}\` — exports \`${IS_AUTH_EXPORT}()\`, \`${USER_ID_EXPORT}()\` and \`${CAPS_EXPORT}()\`.`);
403
525
  if (editedFile) {
404
526
  L.push(`- Edits \`${editedFile}\` (only reversible \`FYODOS:SESSION\` blocks) to register the live session.`);
405
527
  }
528
+ const planCaps =
529
+ plan.strategy === 'bridge'
530
+ ? (plan.bridge && plan.bridge.capabilities) || []
531
+ : plan.capabilities || [];
532
+ if (planCaps.length > 0) {
533
+ L.push(
534
+ `- Capability flags (audience map): ${planCaps.map((c) => `\`${c.name}\``).join(', ')} — boolean-only, read live from the same session source. Actions whose \`requiredCapabilities\` report false are hidden from that user's orb.`,
535
+ );
536
+ }
537
+ for (const note of plan.capabilityNotes || []) {
538
+ L.push(`- Capability left out: ${note}.`);
539
+ }
406
540
  L.push(`- Verification: ${verified ? 'build check passed' : verifyNote || 'compile check unavailable — applied with the mechanical symbol verification only'}.`);
407
541
  L.push('');
408
542
  L.push('## Honesty contract (tri-state)');
@@ -612,8 +746,10 @@ module.exports = {
612
746
  SESSION_BASENAME,
613
747
  IS_AUTH_EXPORT,
614
748
  USER_ID_EXPORT,
749
+ CAPS_EXPORT,
615
750
  BRIDGE_IS_AUTH,
616
751
  BRIDGE_USER_ID,
752
+ BRIDGE_CAPABILITIES,
617
753
  SESSION_EDIT_START,
618
754
  SESSION_EDIT_END,
619
755
  };
package/src/wireWeb.js CHANGED
@@ -29,6 +29,8 @@ const {
29
29
  addRegistriesToBootstrapSource,
30
30
  addSessionToMountSource,
31
31
  addSessionToBootstrapSource,
32
+ addScreenToMountSource,
33
+ addScreenToBootstrapSource,
32
34
  buildEntryBootstrapBlock,
33
35
  IMPORT_NAME,
34
36
  WRAPPER_BASENAME,
@@ -511,6 +513,17 @@ function connectOrbSession(appRoot, opts = {}) {
511
513
  const { sessionFileAbs } = opts;
512
514
  if (!sessionFileAbs || !fs.existsSync(sessionFileAbs)) return { status: 'no-session-file' };
513
515
 
516
+ // Only connect the capability getter when the generated module actually
517
+ // exports it — referencing a missing export would break the app's build.
518
+ let sessionHasCapabilities = false;
519
+ try {
520
+ sessionHasCapabilities = fs
521
+ .readFileSync(sessionFileAbs, 'utf8')
522
+ .includes('fyodosGetSessionCapabilities');
523
+ } catch {
524
+ sessionHasCapabilities = false;
525
+ }
526
+
514
527
  const framework = detectFramework(appRoot);
515
528
  const target = detectTarget(appRoot, framework);
516
529
  if (!target || !target.file) return { status: 'no-target' };
@@ -527,7 +540,7 @@ function connectOrbSession(appRoot, opts = {}) {
527
540
  const fileRel = path.relative(appRoot, target.file);
528
541
  const backups = backupFiles([target.file]);
529
542
  try {
530
- const res = addSessionToBootstrapSource(source, importPath);
543
+ const res = addSessionToBootstrapSource(source, importPath, { sessionHasCapabilities });
531
544
  if (!res.changed) {
532
545
  return { status: res.reason === 'already' ? 'already' : res.reason || 'not-mounted', file: fileRel };
533
546
  }
@@ -564,7 +577,82 @@ function connectOrbSession(appRoot, opts = {}) {
564
577
  const fileRel = path.relative(appRoot, mountFile);
565
578
  const backups = backupFiles([mountFile]);
566
579
  try {
567
- const res = addSessionToMountSource(source, importPath);
580
+ const res = addSessionToMountSource(source, importPath, { sessionHasCapabilities });
581
+ if (!res.changed) {
582
+ return { status: res.reason || 'not-mounted', file: fileRel };
583
+ }
584
+ fs.writeFileSync(mountFile, res.source);
585
+ return { status: 'connected', file: fileRel };
586
+ } catch (e) {
587
+ revertFiles(backups);
588
+ return { status: 'failed', file: fileRel, error: e };
589
+ }
590
+ }
591
+
592
+ /**
593
+ * Fourth pass — connect the generated SCREEN-TRUTH module to the mounted orb,
594
+ * so the agent knows WHICH SCREEN the user is on even when screens are client
595
+ * state instead of URLs. Mirrors connectOrbSession: idempotent, reversible,
596
+ * never overrides a developer-wired getCurrentRoute.
597
+ */
598
+ function connectOrbScreen(appRoot, opts = {}) {
599
+ const { screenFileAbs } = opts;
600
+ if (!screenFileAbs || !fs.existsSync(screenFileAbs)) return { status: 'no-screen-file' };
601
+
602
+ const framework = detectFramework(appRoot);
603
+ const target = detectTarget(appRoot, framework);
604
+ if (!target || !target.file) return { status: 'no-target' };
605
+
606
+ const bootstrapKinds = new Set(['vue-entry', 'svelte-entry', 'sveltekit-hooks']);
607
+ if (bootstrapKinds.has(target.kind)) {
608
+ let source;
609
+ try {
610
+ source = fs.readFileSync(target.file, 'utf8');
611
+ } catch {
612
+ return { status: 'not-mounted' };
613
+ }
614
+ const importPath = registryRelImport(target.file, screenFileAbs);
615
+ const fileRel = path.relative(appRoot, target.file);
616
+ const backups = backupFiles([target.file]);
617
+ try {
618
+ const res = addScreenToBootstrapSource(source, importPath);
619
+ if (!res.changed) {
620
+ return { status: res.reason === 'already' ? 'already' : res.reason || 'not-mounted', file: fileRel };
621
+ }
622
+ fs.writeFileSync(target.file, res.source);
623
+ return { status: 'connected', file: fileRel };
624
+ } catch (e) {
625
+ revertFiles(backups);
626
+ return { status: 'failed', file: fileRel, error: e };
627
+ }
628
+ }
629
+
630
+ const reactKinds = new Set(['vite', 'next-pages', 'next-app']);
631
+ if (!reactKinds.has(target.kind)) return { status: 'unsupported-framework', framework };
632
+
633
+ let mountFile = target.file;
634
+ if (target.kind === 'next-app') {
635
+ const dir = path.dirname(target.file);
636
+ mountFile =
637
+ [path.join(dir, `${WRAPPER_BASENAME}.tsx`), path.join(dir, `${WRAPPER_BASENAME}.jsx`)].find((f) =>
638
+ fs.existsSync(f),
639
+ ) || null;
640
+ if (!mountFile) return { status: 'not-mounted' };
641
+ }
642
+
643
+ let source;
644
+ try {
645
+ source = fs.readFileSync(mountFile, 'utf8');
646
+ } catch {
647
+ return { status: 'not-mounted' };
648
+ }
649
+ if (!/<FiodosAgent\b/.test(source)) return { status: 'not-mounted' };
650
+
651
+ const importPath = registryRelImport(mountFile, screenFileAbs);
652
+ const fileRel = path.relative(appRoot, mountFile);
653
+ const backups = backupFiles([mountFile]);
654
+ try {
655
+ const res = addScreenToMountSource(source, importPath);
568
656
  if (!res.changed) {
569
657
  return { status: res.reason || 'not-mounted', file: fileRel };
570
658
  }
@@ -576,6 +664,29 @@ function connectOrbSession(appRoot, opts = {}) {
576
664
  }
577
665
  }
578
666
 
667
+ function reportScreenConnectResult(result, colors = {}, opts = {}) {
668
+ const quiet = opts.quiet !== false;
669
+ if (!result) return;
670
+ const { blue, cyan, dim, reset } = colors;
671
+ const tag = `${cyan || ''}◉${reset || ''} ${blue || ''}Fiodos${reset || ''}`;
672
+ switch (result.status) {
673
+ case 'connected':
674
+ console.error(`${tag} · screen truth connected to the orb mount (${result.file})`);
675
+ break;
676
+ case 'already':
677
+ if (!quiet) console.error(`${tag} · ${dim || ''}screen truth already connected to the mount${reset || ''}`);
678
+ break;
679
+ case 'dev-wired':
680
+ if (!quiet) {
681
+ console.error(`${tag} · ${dim || ''}the mount already has its own getCurrentRoute — left as is${reset || ''}`);
682
+ }
683
+ break;
684
+ default:
685
+ if (!quiet) console.error(`${tag} · ${dim || ''}screen truth not connected to the mount (${result.status})${reset || ''}`);
686
+ break;
687
+ }
688
+ }
689
+
579
690
  function reportSessionConnectResult(result, colors = {}, opts = {}) {
580
691
  const quiet = opts.quiet !== false;
581
692
  if (!result) return;
@@ -632,6 +743,8 @@ module.exports = {
632
743
  reportRegistriesResult,
633
744
  connectOrbSession,
634
745
  reportSessionConnectResult,
746
+ connectOrbScreen,
747
+ reportScreenConnectResult,
635
748
  detectFramework,
636
749
  detectTarget,
637
750
  };
@@ -87,9 +87,15 @@ const BOOTSTRAP_REGISTRY_EXPORT = 'fyodosGeneratedRegistries';
87
87
  // the mount automatically so auth awareness needs NO manual step.
88
88
  const SESSION_IS_AUTH_EXPORT = 'fyodosIsAuthenticated';
89
89
  const SESSION_USER_ID_EXPORT = 'fyodosGetUserId';
90
+ // Capability flags (audience map) — only connected when the generated session
91
+ // module actually exports it (older generated files do not).
92
+ const SESSION_CAPS_EXPORT = 'fyodosGetSessionCapabilities';
93
+ // Export of the generated screen-truth module (see wireScreen.js). Connected
94
+ // to the mount automatically so state-driven screen awareness needs NO manual step.
95
+ const SCREEN_ROUTE_EXPORT = 'fyodosGetCurrentRoute';
90
96
 
91
97
  function buildEntryBootstrapBlock(opts = {}) {
92
- const { registryImportPath, sessionImportPath } = opts;
98
+ const { registryImportPath, sessionImportPath, screenImportPath, sessionHasCapabilities } = opts;
93
99
  const lines = [];
94
100
  lines.push(ORB_SCRIPT_START);
95
101
  lines.push(`import { createFiodosAgent as ${BOOTSTRAP_ALIAS} } from '@fiodos/web-core';`);
@@ -97,9 +103,12 @@ function buildEntryBootstrapBlock(opts = {}) {
97
103
  lines.push(`import { ${BOOTSTRAP_REGISTRY_EXPORT} } from '${registryImportPath}';`);
98
104
  }
99
105
  if (sessionImportPath) {
100
- lines.push(
101
- `import { ${SESSION_IS_AUTH_EXPORT}, ${SESSION_USER_ID_EXPORT} } from '${sessionImportPath}';`,
102
- );
106
+ const sessionNames = [SESSION_IS_AUTH_EXPORT, SESSION_USER_ID_EXPORT];
107
+ if (sessionHasCapabilities) sessionNames.push(SESSION_CAPS_EXPORT);
108
+ lines.push(`import { ${sessionNames.join(', ')} } from '${sessionImportPath}';`);
109
+ }
110
+ if (screenImportPath) {
111
+ lines.push(`import { ${SCREEN_ROUTE_EXPORT} } from '${screenImportPath}';`);
103
112
  }
104
113
  lines.push(`${BOOTSTRAP_ALIAS}({`);
105
114
  lines.push(' apiKey: import.meta.env.VITE_FYODOS_API_KEY,');
@@ -108,7 +117,11 @@ function buildEntryBootstrapBlock(opts = {}) {
108
117
  if (sessionImportPath) {
109
118
  lines.push(` isAuthenticated: ${SESSION_IS_AUTH_EXPORT},`);
110
119
  lines.push(` getUserId: ${SESSION_USER_ID_EXPORT},`);
120
+ if (sessionHasCapabilities) {
121
+ lines.push(` getSessionCapabilities: ${SESSION_CAPS_EXPORT},`);
122
+ }
111
123
  }
124
+ if (screenImportPath) lines.push(` getCurrentRoute: ${SCREEN_ROUTE_EXPORT},`);
112
125
  lines.push(' mount: true,');
113
126
  lines.push('});');
114
127
  lines.push(ORB_SCRIPT_END);
@@ -136,10 +149,17 @@ function addRegistriesToBootstrapSource(source, registryImportPath) {
136
149
  if (/\n\s*registries:\s/.test(source.slice(source.indexOf(ORB_SCRIPT_START)))) {
137
150
  return { changed: false, source, reason: 'already' };
138
151
  }
139
- // Preserve a session connection a previous pass already added to the block.
152
+ // Preserve session/screen connections previous passes already added.
140
153
  const sessionImportPath = extractBootstrapImportPath(source, SESSION_IS_AUTH_EXPORT);
154
+ const sessionHasCapabilities = Boolean(extractBootstrapImportPath(source, SESSION_CAPS_EXPORT));
155
+ const screenImportPath = extractBootstrapImportPath(source, SCREEN_ROUTE_EXPORT);
141
156
  const stripped = stripMarkedRegion(source, ORB_SCRIPT_START, ORB_SCRIPT_END).replace(/\n{3,}$/, '\n');
142
- const block = buildEntryBootstrapBlock({ registryImportPath, sessionImportPath });
157
+ const block = buildEntryBootstrapBlock({
158
+ registryImportPath,
159
+ sessionImportPath,
160
+ screenImportPath,
161
+ sessionHasCapabilities,
162
+ });
143
163
  const next = insertBlockAfterLastImport(stripped.trimEnd(), block);
144
164
  return { changed: true, source: next.endsWith('\n') ? next : `${next}\n` };
145
165
  }
@@ -157,14 +177,42 @@ function extractBootstrapImportPath(source, name) {
157
177
  // Idempotently rewrite an existing entry-bootstrap block to carry the generated
158
178
  // SESSION connection (third pass, after session wiring). Preserves the
159
179
  // registries the second pass added. Returns { changed, source, reason? }.
160
- function addSessionToBootstrapSource(source, sessionImportPath) {
180
+ function addSessionToBootstrapSource(source, sessionImportPath, opts = {}) {
161
181
  if (!source.includes(ORB_SCRIPT_START)) return { changed: false, source, reason: 'not-mounted' };
162
182
  if (/\n\s*isAuthenticated:\s/.test(source.slice(source.indexOf(ORB_SCRIPT_START)))) {
163
183
  return { changed: false, source, reason: 'already' };
164
184
  }
165
185
  const registryImportPath = extractBootstrapImportPath(source, BOOTSTRAP_REGISTRY_EXPORT);
186
+ const screenImportPath = extractBootstrapImportPath(source, SCREEN_ROUTE_EXPORT);
187
+ const stripped = stripMarkedRegion(source, ORB_SCRIPT_START, ORB_SCRIPT_END).replace(/\n{3,}$/, '\n');
188
+ const block = buildEntryBootstrapBlock({
189
+ registryImportPath,
190
+ sessionImportPath,
191
+ screenImportPath,
192
+ sessionHasCapabilities: Boolean(opts.sessionHasCapabilities),
193
+ });
194
+ const next = insertBlockAfterLastImport(stripped.trimEnd(), block);
195
+ return { changed: true, source: next.endsWith('\n') ? next : `${next}\n` };
196
+ }
197
+
198
+ // Idempotently rewrite an existing entry-bootstrap block to carry the generated
199
+ // SCREEN-TRUTH connection (fourth pass, after screen wiring). Preserves the
200
+ // registries and session earlier passes added. Returns { changed, source, reason? }.
201
+ function addScreenToBootstrapSource(source, screenImportPath) {
202
+ if (!source.includes(ORB_SCRIPT_START)) return { changed: false, source, reason: 'not-mounted' };
203
+ if (/\n\s*getCurrentRoute:\s/.test(source.slice(source.indexOf(ORB_SCRIPT_START)))) {
204
+ return { changed: false, source, reason: 'already' };
205
+ }
206
+ const registryImportPath = extractBootstrapImportPath(source, BOOTSTRAP_REGISTRY_EXPORT);
207
+ const sessionImportPath = extractBootstrapImportPath(source, SESSION_IS_AUTH_EXPORT);
208
+ const sessionHasCapabilities = Boolean(extractBootstrapImportPath(source, SESSION_CAPS_EXPORT));
166
209
  const stripped = stripMarkedRegion(source, ORB_SCRIPT_START, ORB_SCRIPT_END).replace(/\n{3,}$/, '\n');
167
- const block = buildEntryBootstrapBlock({ registryImportPath, sessionImportPath });
210
+ const block = buildEntryBootstrapBlock({
211
+ registryImportPath,
212
+ sessionImportPath,
213
+ screenImportPath,
214
+ sessionHasCapabilities,
215
+ });
168
216
  const next = insertBlockAfterLastImport(stripped.trimEnd(), block);
169
217
  return { changed: true, source: next.endsWith('\n') ? next : `${next}\n` };
170
218
  }
@@ -244,7 +292,8 @@ function addRegistriesToMountSource(source, exportName, importPath) {
244
292
  // `getUserId={fyodosGetUserId}` + the import. Skips (never overrides) a mount
245
293
  // where the developer already wired their own isAuthenticated. Same
246
294
  // reversibility model as addRegistriesToMountSource.
247
- function addSessionToMountSource(source, importPath) {
295
+ function addSessionToMountSource(source, importPath, opts = {}) {
296
+ const withCapabilities = Boolean(opts.sessionHasCapabilities);
248
297
  const tagRe = /([ \t]*)<FiodosAgent\b[\s\S]*?\/>/;
249
298
  const m = source.match(tagRe);
250
299
  if (!m) return { changed: false, source, reason: 'not-mounted' };
@@ -259,15 +308,47 @@ function addSessionToMountSource(source, importPath) {
259
308
  const propMatch = tag.match(/\n([ \t]*)(?:apiKey|api-key|apiUrl|baseUrl|registries)/);
260
309
  const propIndent = propMatch ? propMatch[1] : `${baseIndent} `;
261
310
  const hasUserId = /\bgetUserId=/.test(tag);
311
+ const addCaps = withCapabilities && !/\bgetSessionCapabilities=/.test(tag);
262
312
  const props =
263
313
  `\n${propIndent}isAuthenticated={${SESSION_IS_AUTH_EXPORT}}` +
264
- (hasUserId ? '' : `\n${propIndent}getUserId={${SESSION_USER_ID_EXPORT}}`);
314
+ (hasUserId ? '' : `\n${propIndent}getUserId={${SESSION_USER_ID_EXPORT}}`) +
315
+ (addCaps ? `\n${propIndent}getSessionCapabilities={${SESSION_CAPS_EXPORT}}` : '');
265
316
  const newTag = tag.replace(/\n?[ \t]*\/>$/, `${props}\n${baseIndent}/>`);
266
317
  let next = source.replace(tag, newTag);
267
- const names = hasUserId
268
- ? SESSION_IS_AUTH_EXPORT
269
- : `${SESSION_IS_AUTH_EXPORT}, ${SESSION_USER_ID_EXPORT}`;
270
- const importLine = `import { ${names} } from '${importPath}';`;
318
+ const nameList = [SESSION_IS_AUTH_EXPORT];
319
+ if (!hasUserId) nameList.push(SESSION_USER_ID_EXPORT);
320
+ if (addCaps) nameList.push(SESSION_CAPS_EXPORT);
321
+ const importLine = `import { ${nameList.join(', ')} } from '${importPath}';`;
322
+ if (!next.includes(importLine)) {
323
+ next = insertImportAfterLastImport(next, importLine);
324
+ }
325
+ return { changed: true, source: next };
326
+ }
327
+
328
+ // Idempotently connect the generated SCREEN-TRUTH module to the mounted
329
+ // <FiodosAgent/>: adds `getCurrentRoute={fyodosGetCurrentRoute}` + the import.
330
+ // Skips (never overrides) a mount where the developer already wired their own
331
+ // getCurrentRoute. Same reversibility model as addSessionToMountSource.
332
+ function addScreenToMountSource(source, importPath) {
333
+ const tagRe = /([ \t]*)<FiodosAgent\b[\s\S]*?\/>/;
334
+ const m = source.match(tagRe);
335
+ if (!m) return { changed: false, source, reason: 'not-mounted' };
336
+ const tag = m[0];
337
+ if (new RegExp(`getCurrentRoute=\\{${escapeRe(SCREEN_ROUTE_EXPORT)}\\}`).test(tag)) {
338
+ return { changed: false, source, reason: 'already' };
339
+ }
340
+ if (/\bgetCurrentRoute=/.test(tag)) {
341
+ return { changed: false, source, reason: 'dev-wired' };
342
+ }
343
+ const baseIndent = m[1] || '';
344
+ const propMatch = tag.match(/\n([ \t]*)(?:apiKey|api-key|apiUrl|baseUrl|registries)/);
345
+ const propIndent = propMatch ? propMatch[1] : `${baseIndent} `;
346
+ const newTag = tag.replace(
347
+ /\n?[ \t]*\/>$/,
348
+ `\n${propIndent}getCurrentRoute={${SCREEN_ROUTE_EXPORT}}\n${baseIndent}/>`,
349
+ );
350
+ let next = source.replace(tag, newTag);
351
+ const importLine = `import { ${SCREEN_ROUTE_EXPORT} } from '${importPath}';`;
271
352
  if (!next.includes(importLine)) {
272
353
  next = insertImportAfterLastImport(next, importLine);
273
354
  }
@@ -732,7 +813,10 @@ module.exports = {
732
813
  addRegistriesToBootstrapSource,
733
814
  addSessionToMountSource,
734
815
  addSessionToBootstrapSource,
816
+ addScreenToMountSource,
817
+ addScreenToBootstrapSource,
735
818
  buildEntryBootstrapBlock,
736
819
  IMPORT_NAME,
737
820
  WRAPPER_BASENAME,
821
+ SESSION_CAPS_EXPORT,
738
822
  };