@fiodos/cli 0.1.28 → 0.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fiodos/cli",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "description": "Fiodos CLI — analyzes your app's source code and generates the in-app voice-agent manifest, then wires the orb. Powers `npx @fiodos/cli analyze`.",
5
5
  "type": "commonjs",
6
6
  "bin": {
package/src/aiAnalyze.js CHANGED
@@ -326,6 +326,34 @@ async function analyzeViaProxy({ apiKey, apiUrl, model, user, task = 'analysis',
326
326
  'Or pass --own-ai-key to analyze with your own AI key (code never reaches Fiodos).',
327
327
  );
328
328
  }
329
+ // PRE-FLIGHT platform check (analysis only, non-web platforms): the deployed
330
+ // backend may be OLDER than this CLI and not know the platform yet — its
331
+ // prompt builder then silently falls back to the generic web prompt and the
332
+ // resulting manifest misses the platform spec entirely (e.g. Shopify's
333
+ // execution blocks). The system-prompt endpoint echoes the platform it
334
+ // resolved, so a stale backend is detectable BEFORE spending any AI tokens.
335
+ // Fail-open on network/endpoint errors: the post-call echo guard still runs.
336
+ if (task === 'analysis' && platform && platform !== 'web') {
337
+ try {
338
+ const probe = await fetch(
339
+ `${apiUrl}/v1/developer/analysis/system-prompt?task=analysis&platform=${encodeURIComponent(platform)}`,
340
+ { headers: { 'x-api-key': apiKey } },
341
+ );
342
+ if (probe.ok) {
343
+ const meta = await probe.json();
344
+ if (meta && meta.platform && meta.platform !== platform) {
345
+ throw new Error(
346
+ `The Fiodos backend at ${apiUrl} does not support --platform ${platform} yet ` +
347
+ `(it would analyze as "${meta.platform}", missing the ${platform} platform spec). ` +
348
+ 'Update/redeploy the backend and re-run the analysis.',
349
+ );
350
+ }
351
+ }
352
+ } catch (e) {
353
+ if (String(e && e.message).includes('does not support --platform')) throw e;
354
+ /* preflight is best-effort — the post-call echo guard still protects */
355
+ }
356
+ }
329
357
  const res = await fetch(`${apiUrl}/v1/developer/analyze`, {
330
358
  method: 'POST',
331
359
  headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
@@ -351,6 +379,18 @@ async function analyzeViaProxy({ apiKey, apiUrl, model, user, task = 'analysis',
351
379
  throw new Error(`Hosted analysis failed: ${res.status} ${detail}`);
352
380
  }
353
381
  const data = await res.json();
382
+ // Platform degradation guard: an outdated backend that does not know the
383
+ // requested platform silently builds the generic WEB prompt instead — the
384
+ // manifest then LOOKS fine but misses the platform's spec (e.g. Shopify's
385
+ // declarative execution blocks, without which no commerce action can run).
386
+ // New backends echo the platform they actually used; a mismatch is fatal.
387
+ if (data.platform && platform && data.platform !== platform) {
388
+ throw new Error(
389
+ `The Fiodos backend at ${apiUrl} does not support --platform ${platform} yet ` +
390
+ `(it analyzed as "${data.platform}"). The manifest it would produce is missing ` +
391
+ `the ${platform} platform spec. Update/redeploy the backend and re-run the analysis.`,
392
+ );
393
+ }
354
394
  return {
355
395
  parsed: parseJsonContent(data.text || '{}'),
356
396
  usage: {
package/src/collect.js CHANGED
@@ -80,14 +80,27 @@ function tierFor(rel, platform) {
80
80
  if (/^sections\//.test(p)) return 2;
81
81
  return 3; // snippets/, config/ (settings_data.json can be huge), rest
82
82
  }
83
+ return tierForApp(p);
84
+ }
85
+
86
+ function tierForApp(p) {
83
87
  if (/^(src\/)?app\//.test(p)) return 0; // expo-router / Next screens
84
- if (/(^|\/)(screens?|pages?|views?|routes?|activities|fragments)\//i.test(p)) return 0;
85
- if (/(screen|page|view|activity|fragment)\.(dart|kt|swift)$/i.test(p)) return 0;
88
+ // `sections/` and `panels/` are SCREEN BODIES in shell-based apps (a Shell
89
+ // page switches between section components): they carry the visible controls
90
+ // (color pickers, toggles) the analyzer must read to emit their actions.
91
+ // Ranking them as generic components (tier 3) silently dropped them under
92
+ // budget pressure and whole screens became invisible to the AI.
93
+ if (/(^|\/)(screens?|pages?|views?|routes?|sections?|panels?|activities|fragments)\//i.test(p)) return 0;
94
+ if (/(screen|page|view|activity|fragment|section)\.(dart|kt|swift)$/i.test(p)) return 0;
95
+ // Central state gets its OWN tier right after screens: the providers/stores
96
+ // define the setters that make screen controls wire-able actions. Sharing a
97
+ // tier with the (numerous) service/lib files let alphabetical fill starve
98
+ // out AppProvider-style files, and the AI had to guess every handler name.
86
99
  if (/(^|\/)(state|store|stores|context|contexts|redux|providers?|blocs?|cubits?|viewmodels?)\//i.test(p)) return 1;
87
100
  if (/(viewmodel|notifier|bloc|cubit|store|controller)\.(dart|kt|swift)$/i.test(p)) return 1;
88
- if (/(^|\/)(services?|api|db|data|domain|agent|handlers?|actions?|lib|utils|repositories|repository|usecases?)\//i.test(p)) return 1;
89
- if (/(^|\/)(hooks)\//.test(p)) return 2;
90
- return 3; // generic components / ui
101
+ if (/(^|\/)(services?|api|db|data|domain|agent|handlers?|actions?|lib|utils|repositories|repository|usecases?)\//i.test(p)) return 2;
102
+ if (/(^|\/)(hooks)\//.test(p)) return 3;
103
+ return 4; // generic components / ui
91
104
  }
92
105
 
93
106
  function collectFiles(appRoot, { maxInputTokens = 150_000, platform = 'mobile' } = {}) {
@@ -108,7 +121,21 @@ function collectFiles(appRoot, { maxInputTokens = 150_000, platform = 'mobile' }
108
121
  if (!codeExt.has(path.extname(entry.name))) continue;
109
122
  if (SKIP_FILES.test(rel)) continue;
110
123
  if (isNative && SKIP_NATIVE_FILES.test(rel)) continue;
111
- const content = fs.readFileSync(full, 'utf8');
124
+ let content = fs.readFileSync(full, 'utf8');
125
+ if (platform === 'shopify' && /^locales\//.test(rel.replace(/\\/g, '/'))) {
126
+ // Theme-editor translations (merchant-facing settings strings), not
127
+ // storefront language evidence — pure noise for the analysis.
128
+ if (/\.schema\.json$/.test(rel)) continue;
129
+ // Non-default storefront locales: what matters is THAT the language is
130
+ // published (the filename carries the code) — that is the evidence the
131
+ // MULTILINGUAL rule needs to emit appLanguages/bubblePrompts. Sending
132
+ // each file whole (tens of KB × dozens of locales) blew the budget and
133
+ // the alphabetical cut dropped exactly the important ones (en/es
134
+ // arrived after bg/cs/da…), so the AI saw a "monolingual" store.
135
+ if (!/\.default\.json$/.test(rel)) {
136
+ content = '{"__fyodos_note": "published storefront locale (content elided; this language IS offered to shoppers — count it in appLanguages/bubblePrompts)"}';
137
+ }
138
+ }
112
139
  files.push({ rel, content, tier: tierFor(rel, platform), chars: content.length });
113
140
  }
114
141
  };
@@ -118,10 +145,48 @@ function collectFiles(appRoot, { maxInputTokens = 150_000, platform = 'mobile' }
118
145
  files.sort((a, b) => a.tier - b.tier || a.rel.localeCompare(b.rel));
119
146
 
120
147
  const budgetChars = maxInputTokens * CHARS_PER_TOKEN;
148
+
149
+ // Floor-reserved fill. A pure greedy fill let a fat tier 0 (many large
150
+ // screen files) consume the WHOLE budget and push out tier 1 entirely —
151
+ // i.e. the app's central state/providers (AppProvider, stores) vanished
152
+ // from the analysis, so the AI could not see the setters that make
153
+ // configuration controls real actions and had to guess every handler name.
154
+ // Each lower tier gets a guaranteed FLOOR (share of the total budget,
155
+ // capped at its real demand); screens still get everything left over, and a
156
+ // final backfill hands unused room to deferred files in tier order.
157
+ const FLOOR_SHARE = { 1: 0.2, 2: 0.05, 3: 0.02, 4: 0.02 };
158
+ const demand = new Map();
159
+ for (const f of files) demand.set(f.tier, (demand.get(f.tier) ?? 0) + f.chars);
160
+
121
161
  const included = [];
122
- const omitted = [];
162
+ const deferred = [];
123
163
  let used = 0;
124
- for (const f of files) {
164
+ const tiers = [...demand.keys()].sort((a, b) => a - b);
165
+ for (const tier of tiers) {
166
+ // Reserve, for every LOWER-priority tier that still has files, the smaller
167
+ // of its floor and what it actually needs.
168
+ let reserved = 0;
169
+ for (const t of tiers) {
170
+ if (t <= tier) continue;
171
+ reserved += Math.min((FLOOR_SHARE[t] ?? 0) * budgetChars, demand.get(t));
172
+ }
173
+ const available = budgetChars - used - reserved;
174
+ let tierUsed = 0;
175
+ for (const f of files) {
176
+ if (f.tier !== tier) continue;
177
+ if (tierUsed + f.chars <= available) {
178
+ included.push(f);
179
+ tierUsed += f.chars;
180
+ } else {
181
+ deferred.push(f);
182
+ }
183
+ }
184
+ used += tierUsed;
185
+ }
186
+ // Backfill: any room the floors did not consume goes back to the deferred
187
+ // files, highest tier priority first.
188
+ const omitted = [];
189
+ for (const f of deferred) {
125
190
  if (used + f.chars <= budgetChars) {
126
191
  included.push(f);
127
192
  used += f.chars;
@@ -129,6 +194,8 @@ function collectFiles(appRoot, { maxInputTokens = 150_000, platform = 'mobile' }
129
194
  omitted.push({ rel: f.rel, chars: f.chars, tier: f.tier });
130
195
  }
131
196
  }
197
+ // Restore deterministic tier/path order after the backfill.
198
+ included.sort((a, b) => a.tier - b.tier || a.rel.localeCompare(b.rel));
132
199
 
133
200
  return {
134
201
  included,
package/src/verify.js CHANGED
@@ -122,6 +122,79 @@ function sanitizeBubblePrompt(raw) {
122
122
  return s || undefined;
123
123
  }
124
124
 
125
+ /** "es-ES" / "ES" → "es" (base language code, lowercased); undefined when unusable. */
126
+ function sanitizeLanguageCode(raw) {
127
+ const code = String(raw || '').split(/[-_]/)[0].trim().toLowerCase();
128
+ return /^[a-z]{2,3}$/.test(code) ? code : undefined;
129
+ }
130
+
131
+ /**
132
+ * Sanitize a route's `bubblePrompts` translation map (fail-open, item never
133
+ * dropped): keeps only valid base-language keys whose values survive the same
134
+ * bubblePrompt hygiene. Returns undefined when nothing usable remains, so the
135
+ * SDK falls back to the single `bubblePrompt`.
136
+ */
137
+ function sanitizeBubblePrompts(raw) {
138
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
139
+ const out = {};
140
+ for (const [key, value] of Object.entries(raw)) {
141
+ const lang = sanitizeLanguageCode(key);
142
+ const prompt = sanitizeBubblePrompt(value);
143
+ if (lang && prompt && !(lang in out)) out[lang] = prompt;
144
+ }
145
+ return Object.keys(out).length > 0 ? out : undefined;
146
+ }
147
+
148
+ /**
149
+ * Max length (chars) of a dashboard `labels` translation — it renders as a
150
+ * short UI label (route/action name), never a sentence.
151
+ */
152
+ const MAX_LABEL = 60;
153
+
154
+ /** Sanitize one dashboard label translation string (see sanitizeBubblePrompt). */
155
+ function sanitizeLabelText(raw) {
156
+ if (typeof raw !== 'string') return undefined;
157
+ let s = raw.replace(/\s+/g, ' ').trim();
158
+ s = s.replace(/^["'«»“”]+|["'«»“”]+$/g, '').trim();
159
+ if (!s) return undefined;
160
+ if (s.length > MAX_LABEL) s = s.slice(0, MAX_LABEL).trim();
161
+ return s || undefined;
162
+ }
163
+
164
+ /**
165
+ * Sanitize a route/action's `labels` translation map (fail-open, item never
166
+ * dropped): keeps only valid base-language keys whose values survive label
167
+ * hygiene. Returns undefined when nothing usable remains, so the dashboard
168
+ * falls back to the primary `label`. Unlike `bubblePrompts`, this map is
169
+ * REQUIRED by the analyzer prompt (es/en, unconditional) — but the CLI still
170
+ * never blocks a manifest over a missing/malformed translation.
171
+ */
172
+ function sanitizeLabels(raw) {
173
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
174
+ const out = {};
175
+ for (const [key, value] of Object.entries(raw)) {
176
+ const lang = sanitizeLanguageCode(key);
177
+ const label = sanitizeLabelText(value);
178
+ if (lang && label && !(lang in out)) out[lang] = label;
179
+ }
180
+ return Object.keys(out).length > 0 ? out : undefined;
181
+ }
182
+
183
+ /**
184
+ * Sanitize the manifest-level `appLanguages` list (fail-open): dedupes and
185
+ * normalizes to base codes, preserving the model's order (primary first).
186
+ * Returns undefined when nothing usable remains (monolingual apps).
187
+ */
188
+ function sanitizeAppLanguages(raw) {
189
+ if (!Array.isArray(raw)) return undefined;
190
+ const out = [];
191
+ for (const entry of raw) {
192
+ const lang = sanitizeLanguageCode(entry);
193
+ if (lang && !out.includes(lang)) out.push(lang);
194
+ }
195
+ return out.length > 0 ? out : undefined;
196
+ }
197
+
125
198
  /** Normalize an expo-router href for comparison: strip (groups), trailing /index. */
126
199
  function normalizeHref(href) {
127
200
  return ('/' + String(href || '')
@@ -399,6 +472,15 @@ function verifyManifest(manifest, evidence, sentFiles, staticRoutes, opts = {})
399
472
  }
400
473
  if (bubble) route.bubblePrompt = bubble;
401
474
  else delete route.bubblePrompt;
475
+ // Multilingual bubble translations (fail-open): keep only clean entries so
476
+ // the orb can follow the app's active language; drop the map when empty.
477
+ const bubbles = sanitizeBubblePrompts(route.bubblePrompts);
478
+ if (bubbles) route.bubblePrompts = bubbles;
479
+ else delete route.bubblePrompts;
480
+ // Dashboard label translations (es/en) — see sanitizeLabels doc.
481
+ const routeLabels = sanitizeLabels(route.labels);
482
+ if (routeLabels) route.labels = routeLabels;
483
+ else delete route.labels;
402
484
  }
403
485
 
404
486
  // Schema coercion the model sometimes needs (same as the old engine).
@@ -414,10 +496,21 @@ function verifyManifest(manifest, evidence, sentFiles, staticRoutes, opts = {})
414
496
  action.confirmationMessageTemplate = `Confirm: ${action.label || action.intent}?`;
415
497
  provenance.notes.push(`action '${action.intent}': confirmation template filled mechanically`);
416
498
  }
499
+ // Dashboard label translations (es/en) — see sanitizeLabels doc.
500
+ const actionLabels = sanitizeLabels(action.labels);
501
+ if (actionLabels) action.labels = actionLabels;
502
+ else delete action.labels;
417
503
  }
418
504
 
505
+ // App-level supported languages (fail-open): normalized base codes, primary
506
+ // first; dropped entirely when the analysis found no i18n evidence.
507
+ const appLanguages = sanitizeAppLanguages(manifest.appLanguages);
508
+ const cleanManifest = { ...manifest, routes: dedupedRoutes, actions: dedupedActions };
509
+ if (appLanguages) cleanManifest.appLanguages = appLanguages;
510
+ else delete cleanManifest.appLanguages;
511
+
419
512
  return {
420
- manifest: { ...manifest, routes: dedupedRoutes, actions: dedupedActions },
513
+ manifest: cleanManifest,
421
514
  provenance,
422
515
  };
423
516
  }
package/src/verifyWire.js CHANGED
@@ -29,7 +29,14 @@ function normRel(p) {
29
29
  /** Extract "path(line,col): error" / "ERROR ... path:line" style lines. */
30
30
  function extractErrorLines(output) {
31
31
  const lines = String(output || '').split('\n');
32
- return lines.filter((l) => /error\s+TS\d+|: error|ERROR\b|is not assignable|Cannot find|does not exist/i.test(l));
32
+ return lines.filter((l) => {
33
+ // npm's failure banner ("npm error path …", "npm ERR! command failed") is
34
+ // the WRAPPER around a failed script, not a diagnostic. Counting it as an
35
+ // error made an action fail with "compilation: npm error path <dir>" even
36
+ // when every real tsc line had been classified as style-only.
37
+ if (/^\s*npm\s+(error|ERR!)/i.test(l)) return false;
38
+ return /error\s+TS\d+|: error|ERROR\b|is not assignable|Cannot find|does not exist/i.test(l);
39
+ });
33
40
  }
34
41
 
35
42
  /**
@@ -576,6 +576,36 @@ function deriveBridgeAnchor(content, invoke) {
576
576
  return null;
577
577
  }
578
578
 
579
+ /**
580
+ * "Once wired, never regressed": when the file ALREADY contains a generated
581
+ * block registering this intent (from a previous verified run), reuse that
582
+ * position instead of re-rolling the AI's anchor. Re-running the wiring is
583
+ * otherwise nondeterministic — a bad anchor + an unlucky AI correction can
584
+ * un-wire an action that worked yesterday. Returns the nearest line ABOVE the
585
+ * existing block that survives the strip and is unique in the stripped
586
+ * content (a deterministic anchor for the same spot), or null.
587
+ */
588
+ function reuseExistingBlockAnchor(rawContent, strippedContent, intent) {
589
+ const blockRe = new RegExp(
590
+ `(${esc(EDIT_START_PREFIX)}[^\\n]*)\\n[\\s\\S]*?${esc(EDIT_END)}`,
591
+ 'g',
592
+ );
593
+ let m;
594
+ while ((m = blockRe.exec(rawContent))) {
595
+ if (!GENERATED_HEADER_RE.test(m[1])) continue;
596
+ if (!new RegExp(`"${esc(intent)}"\\s*:`).test(m[0])) continue;
597
+ const lines = rawContent.slice(0, m.index).split('\n');
598
+ for (let i = lines.length - 1; i >= 0; i--) {
599
+ const line = lines[i];
600
+ const t = line.trim();
601
+ if (!t || t.startsWith('//') || t.startsWith('/*') || t.startsWith('*')) continue;
602
+ if (line.includes('FYODOS:')) continue;
603
+ if (countOccurrences(strippedContent, line) === 1) return line;
604
+ }
605
+ }
606
+ return null;
607
+ }
608
+
579
609
  /**
580
610
  * True when an anchor that appears several times is HARMLESS: every occurrence
581
611
  * resolves to the same actual insertion point (the nearest JSX `return`), so
@@ -634,7 +664,13 @@ function buildAiBridgeEntry({ appRoot, fyodosDirRel, base, aiW }) {
634
664
  const occ = anchor ? countOccurrences(content, anchor) : 0;
635
665
  if (occ !== 1) {
636
666
  const scope = b.scope === 'class-field' ? 'class-field' : 'statement';
637
- if (occ > 1 && scope === 'statement' && ambiguousAnchorSameInsertion(content, anchor)) {
667
+ const reused = reuseExistingBlockAnchor(rawContent, content, base.intent);
668
+ if (reused) {
669
+ // A previous run already wired this intent here and verified it; the
670
+ // proven position beats re-rolling an ambiguous AI anchor.
671
+ anchor = reused;
672
+ anchorDerived = true;
673
+ } else if (occ > 1 && scope === 'statement' && ambiguousAnchorSameInsertion(content, anchor)) {
638
674
  // Keep the AI anchor: every occurrence lands on the same insertion point.
639
675
  } else {
640
676
  const derived = deriveBridgeAnchor(content, invoke);
@@ -675,6 +711,13 @@ function buildAiBridgeEntry({ appRoot, fyodosDirRel, base, aiW }) {
675
711
  const rawFrom = normRel(imp && imp.from);
676
712
  const kind = (imp && imp.kind) || 'named';
677
713
  if (!name || !rawFrom) return { reason: 'a bridge import is incomplete (missing name/from)' };
714
+ // The component often ALREADY imports the symbol (useApp, a store hook…).
715
+ // Re-importing it produces "Duplicate identifier" and fails the candidate,
716
+ // so an already-in-scope name is accepted without adding a second import.
717
+ if (new RegExp(`^\\s*import\\b[^;\\n]*[^\\w$]${esc(name)}[^\\w$][^;\\n]*from`, 'm').test(content)) {
718
+ importedNames.add(name);
719
+ continue;
720
+ }
678
721
  const from = resolveSourceFile(appRoot, rawFrom);
679
722
  if (!from) return { reason: `the bridge import points to a non-existent file: '${rawFrom}'` };
680
723
  if (kind === 'named' && !fileHasSymbol(appRoot, from, name)) return { reason: `named import '${name}' does not exist in '${from}'` };
@@ -1291,9 +1334,14 @@ function stripFiodosBlocks(content) {
1291
1334
  );
1292
1335
  }
1293
1336
 
1294
- /** Insert an import block right after the file's last top-level import line. */
1337
+ /** Insert an import block right after the file's last top-level import statement.
1338
+ * Matches the WHOLE statement through its module string — a per-line regex put
1339
+ * the block INSIDE multi-line imports (`import {\n a,\n} from 'x';`), corrupting
1340
+ * the file (TS1003 "Identifier expected") and failing the wiring of every action
1341
+ * in that file. `[^'"]*` cannot skip a quote, so the match always stops at the
1342
+ * first quoted module specifier, then runs to that line's end. */
1295
1343
  function insertImportBlock(content, importBlock) {
1296
- const importLineRe = /^[ \t]*import\b[^\n]*\n/gm;
1344
+ const importLineRe = /^[ \t]*import\b[^'"]*['"][^'"]+['"][^\n]*\n/gm;
1297
1345
  let lastEnd = -1;
1298
1346
  let m;
1299
1347
  while ((m = importLineRe.exec(content))) lastEnd = m.index + m[0].length;
@@ -1351,6 +1399,26 @@ function insertRegisterBlock(content, file, anchor, block, scope) {
1351
1399
  return content.slice(0, endIdx) + '\n' + block + content.slice(endIdx);
1352
1400
  }
1353
1401
 
1402
+ // When the anchor's own line is unmistakably a body STATEMENT (a declaration
1403
+ // or a bare call ending in `;`), insert right AFTER it. Snapping to the
1404
+ // nearest `return` instead can land the registration inside a conditional
1405
+ // early-return branch (`if (authLoading) { … return <Spinner/> }`): it
1406
+ // compiles, but in normal use that branch never runs and the bridge is never
1407
+ // registered — the action silently dies at runtime.
1408
+ const anchorLineStart = content.lastIndexOf('\n', at) + 1;
1409
+ const anchorLineEnd = content.indexOf('\n', at);
1410
+ const anchorLine = content.slice(anchorLineStart, anchorLineEnd === -1 ? content.length : anchorLineEnd).trim();
1411
+ const isStatementLine =
1412
+ /^(const|let|var)\b.*;\s*$/.test(anchorLine) || /^[\w$][\w$.]*\((?:[^()]|\([^()]*\))*\);\s*$/.test(anchorLine);
1413
+ if (isStatementLine) {
1414
+ // A multi-line declaration (`const { a, b } =\n useApp();`) ends at the
1415
+ // first `;` at/after the anchor, not at the anchor's own line end.
1416
+ const semi = content.indexOf(';', at);
1417
+ const stmtEnd = semi === -1 ? (anchorLineEnd === -1 ? content.length : anchorLineEnd) : content.indexOf('\n', semi);
1418
+ const endIdx = stmtEnd === -1 ? content.length : stmtEnd;
1419
+ return content.slice(0, endIdx) + '\n' + block + content.slice(endIdx);
1420
+ }
1421
+
1354
1422
  const ret = nearestJsxReturn(content, at);
1355
1423
  if (ret !== -1) {
1356
1424
  const lineStart = content.lastIndexOf('\n', ret) + 1;
@@ -1500,6 +1568,34 @@ function pickRelevantFiles(files, evidence, candidate, intent) {
1500
1568
  for (const imp of (candidate.bridge && candidate.bridge.imports) || []) if (imp && imp.from) wanted.add(normRel(imp.from));
1501
1569
  for (const r of candidate.requiredSymbols || []) if (r && r.file) wanted.add(normRel(r.file));
1502
1570
  }
1571
+ // The most common unfixable failure is "symbol X is not available in the
1572
+ // proposed file": the fix lives in ANOTHER component that consumes X. Give
1573
+ // the corrector every file that mentions the invoked symbols (capped) so it
1574
+ // can retarget the bridge instead of blindly retrying the same file.
1575
+ const invokeSrc = String(
1576
+ (candidate && ((candidate.bridge && candidate.bridge.invoke) || candidate.call)) || '',
1577
+ );
1578
+ const symbols = new Set();
1579
+ let sm;
1580
+ const symRe = /(?:^|[^.\w$])([A-Za-z_$][\w$]{2,})\s*\(/g;
1581
+ while ((sm = symRe.exec(invokeSrc))) {
1582
+ if (!JS_KEYWORDS.has(sm[1]) && !SAFE_GLOBALS.has(sm[1])) symbols.add(sm[1]);
1583
+ }
1584
+ if (symbols.size) {
1585
+ let extra = 0;
1586
+ for (const f of files) {
1587
+ if (extra >= 4) break;
1588
+ if (wanted.has(normRel(f.rel))) continue;
1589
+ const content = String(f.content || '');
1590
+ for (const sym of symbols) {
1591
+ if (new RegExp(`(^|[^\\w$])${esc(sym)}([^\\w$]|$)`).test(content)) {
1592
+ wanted.add(normRel(f.rel));
1593
+ extra += 1;
1594
+ break;
1595
+ }
1596
+ }
1597
+ }
1598
+ }
1503
1599
  const picked = files.filter((f) => wanted.has(normRel(f.rel)));
1504
1600
  return picked.length ? picked : files.slice(0, 4);
1505
1601
  }
package/src/wireWeb.js CHANGED
@@ -134,7 +134,7 @@ function agentSnippetVue() {
134
134
  `</script>\n\n` +
135
135
  `<template>\n` +
136
136
  ` <!-- ...your app... -->\n` +
137
- ` <FiodosAgent :api-key="fyodosApiKey" :base-url="fyodosApiUrl" />\n` +
137
+ ` <FiodosAgent :api-key="fyodosApiKey" :base-url="fyodosApiUrl" locale-detection="document" />\n` +
138
138
  `</template>`
139
139
  );
140
140
  }
@@ -144,7 +144,7 @@ function agentSnippetSvelte() {
144
144
  `<script lang="ts">\n` +
145
145
  ` import FiodosAgent from '@fiodos/svelte/FiodosAgent.svelte';\n` +
146
146
  `</script>\n\n` +
147
- `<FiodosAgent apiKey={import.meta.env.VITE_FYODOS_API_KEY} baseUrl={import.meta.env.VITE_FYODOS_API_URL} />`
147
+ `<FiodosAgent apiKey={import.meta.env.VITE_FYODOS_API_KEY} baseUrl={import.meta.env.VITE_FYODOS_API_URL} localeDetection="document" />`
148
148
  );
149
149
  }
150
150
 
@@ -122,6 +122,10 @@ function buildEntryBootstrapBlock(opts = {}) {
122
122
  }
123
123
  }
124
124
  if (screenImportPath) lines.push(` getCurrentRoute: ${SCREEN_ROUTE_EXPORT},`);
125
+ // Language link (never left disconnected): the orb follows the page's live
126
+ // <html lang>, so analyzer bubble translations track the app's language
127
+ // switcher automatically.
128
+ lines.push(" localeDetection: 'document',");
125
129
  lines.push(' mount: true,');
126
130
  lines.push('});');
127
131
  lines.push(ORB_SCRIPT_END);
@@ -243,6 +247,10 @@ function agentJsx(framework, ts, indent = ' ') {
243
247
  `${indent}<FiodosAgent\n` +
244
248
  `${indent} apiKey={${keyExpr}}\n` +
245
249
  `${indent} apiUrl={${urlExpr}}\n` +
250
+ // Language link (never left disconnected): the orb follows the page's live
251
+ // <html lang>, so analyzer bubble translations track the app's language
252
+ // switcher automatically.
253
+ `${indent} localeDetection="document"\n` +
246
254
  `${indent}/>`
247
255
  );
248
256
  }
@@ -644,7 +652,7 @@ function injectVueSfc(plan) {
644
652
  }
645
653
  const block = [
646
654
  ORB_HTML_START,
647
- ' <FiodosAgent :api-key="fyodosApiKey" :base-url="fyodosApiUrl" />',
655
+ ' <FiodosAgent :api-key="fyodosApiKey" :base-url="fyodosApiUrl" locale-detection="document" />',
648
656
  ORB_HTML_END,
649
657
  ].join('\n');
650
658
  source = source.replace('</template>', `${block}\n</template>`);
@@ -662,7 +670,7 @@ function injectSvelteSfc(plan) {
662
670
  }
663
671
  const block = [
664
672
  ORB_HTML_START,
665
- '<FiodosAgent apiKey={import.meta.env.VITE_FYODOS_API_KEY} baseUrl={import.meta.env.VITE_FYODOS_API_URL} />',
673
+ '<FiodosAgent apiKey={import.meta.env.VITE_FYODOS_API_KEY} baseUrl={import.meta.env.VITE_FYODOS_API_URL} localeDetection="document" />',
666
674
  ORB_HTML_END,
667
675
  ].join('\n');
668
676
  if (source.includes('</main>')) {
@@ -699,7 +707,7 @@ function injectAngularComponent(plan) {
699
707
  );
700
708
  }
701
709
 
702
- const block = [ORB_HTML_START, '<fyodos-agent [apiKey]="fyodosApiKey" [baseUrl]="fyodosApiUrl" />', ORB_HTML_END].join('\n');
710
+ const block = [ORB_HTML_START, '<fyodos-agent [apiKey]="fyodosApiKey" [baseUrl]="fyodosApiUrl" localeDetection="document" />', ORB_HTML_END].join('\n');
703
711
  html = `${html.trimEnd()}\n${block}\n`;
704
712
  writeFile(target.file, ts);
705
713
  writeFile(templatePath, html);