@fiodos/cli 0.1.22 → 0.1.25

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/src/wireWeb.js CHANGED
@@ -27,6 +27,8 @@ const {
27
27
  registryRelImport,
28
28
  addRegistriesToMountSource,
29
29
  addRegistriesToBootstrapSource,
30
+ addSessionToMountSource,
31
+ addSessionToBootstrapSource,
30
32
  buildEntryBootstrapBlock,
31
33
  IMPORT_NAME,
32
34
  WRAPPER_BASENAME,
@@ -499,6 +501,104 @@ function connectOrbRegistries(appRoot, opts = {}) {
499
501
  }
500
502
  }
501
503
 
504
+ /**
505
+ * Third pass — connect the generated SESSION module to the mounted orb, so the
506
+ * agent KNOWS whether the user is signed in (requiresAuth gate) and keys the
507
+ * conversation memory per user. Mirrors connectOrbRegistries: idempotent,
508
+ * reversible, never overrides a developer-wired isAuthenticated.
509
+ */
510
+ function connectOrbSession(appRoot, opts = {}) {
511
+ const { sessionFileAbs } = opts;
512
+ if (!sessionFileAbs || !fs.existsSync(sessionFileAbs)) return { status: 'no-session-file' };
513
+
514
+ const framework = detectFramework(appRoot);
515
+ const target = detectTarget(appRoot, framework);
516
+ if (!target || !target.file) return { status: 'no-target' };
517
+
518
+ const bootstrapKinds = new Set(['vue-entry', 'svelte-entry', 'sveltekit-hooks']);
519
+ if (bootstrapKinds.has(target.kind)) {
520
+ let source;
521
+ try {
522
+ source = fs.readFileSync(target.file, 'utf8');
523
+ } catch {
524
+ return { status: 'not-mounted' };
525
+ }
526
+ const importPath = registryRelImport(target.file, sessionFileAbs);
527
+ const fileRel = path.relative(appRoot, target.file);
528
+ const backups = backupFiles([target.file]);
529
+ try {
530
+ const res = addSessionToBootstrapSource(source, importPath);
531
+ if (!res.changed) {
532
+ return { status: res.reason === 'already' ? 'already' : res.reason || 'not-mounted', file: fileRel };
533
+ }
534
+ fs.writeFileSync(target.file, res.source);
535
+ return { status: 'connected', file: fileRel };
536
+ } catch (e) {
537
+ revertFiles(backups);
538
+ return { status: 'failed', file: fileRel, error: e };
539
+ }
540
+ }
541
+
542
+ const reactKinds = new Set(['vite', 'next-pages', 'next-app']);
543
+ if (!reactKinds.has(target.kind)) return { status: 'unsupported-framework', framework };
544
+
545
+ let mountFile = target.file;
546
+ if (target.kind === 'next-app') {
547
+ const dir = path.dirname(target.file);
548
+ mountFile =
549
+ [path.join(dir, `${WRAPPER_BASENAME}.tsx`), path.join(dir, `${WRAPPER_BASENAME}.jsx`)].find((f) =>
550
+ fs.existsSync(f),
551
+ ) || null;
552
+ if (!mountFile) return { status: 'not-mounted' };
553
+ }
554
+
555
+ let source;
556
+ try {
557
+ source = fs.readFileSync(mountFile, 'utf8');
558
+ } catch {
559
+ return { status: 'not-mounted' };
560
+ }
561
+ if (!/<FiodosAgent\b/.test(source)) return { status: 'not-mounted' };
562
+
563
+ const importPath = registryRelImport(mountFile, sessionFileAbs);
564
+ const fileRel = path.relative(appRoot, mountFile);
565
+ const backups = backupFiles([mountFile]);
566
+ try {
567
+ const res = addSessionToMountSource(source, importPath);
568
+ if (!res.changed) {
569
+ return { status: res.reason || 'not-mounted', file: fileRel };
570
+ }
571
+ fs.writeFileSync(mountFile, res.source);
572
+ return { status: 'connected', file: fileRel };
573
+ } catch (e) {
574
+ revertFiles(backups);
575
+ return { status: 'failed', file: fileRel, error: e };
576
+ }
577
+ }
578
+
579
+ function reportSessionConnectResult(result, colors = {}, opts = {}) {
580
+ const quiet = opts.quiet !== false;
581
+ if (!result) return;
582
+ const { blue, cyan, dim, reset } = colors;
583
+ const tag = `${cyan || ''}◉${reset || ''} ${blue || ''}Fiodos${reset || ''}`;
584
+ switch (result.status) {
585
+ case 'connected':
586
+ console.error(`${tag} · session connected to the orb mount (${result.file})`);
587
+ break;
588
+ case 'already':
589
+ if (!quiet) console.error(`${tag} · ${dim || ''}session already connected to the mount${reset || ''}`);
590
+ break;
591
+ case 'dev-wired':
592
+ if (!quiet) {
593
+ console.error(`${tag} · ${dim || ''}the mount already has its own isAuthenticated — left as is${reset || ''}`);
594
+ }
595
+ break;
596
+ default:
597
+ if (!quiet) console.error(`${tag} · ${dim || ''}session not connected to the mount (${result.status})${reset || ''}`);
598
+ break;
599
+ }
600
+ }
601
+
502
602
  function reportRegistriesResult(result, colors = {}, opts = {}) {
503
603
  const quiet = opts.quiet !== false;
504
604
  if (!result) return;
@@ -530,6 +630,8 @@ module.exports = {
530
630
  reportWireResult,
531
631
  connectOrbRegistries,
532
632
  reportRegistriesResult,
633
+ connectOrbSession,
634
+ reportSessionConnectResult,
533
635
  detectFramework,
534
636
  detectTarget,
535
637
  };
@@ -83,19 +83,32 @@ function insertBlockAfterLastImport(source, block) {
83
83
  // FYODOS:ORB markers → idempotent (re-runs strip & rewrite) and reversible.
84
84
  const BOOTSTRAP_ALIAS = '__fiodosCreateAgent';
85
85
  const BOOTSTRAP_REGISTRY_EXPORT = 'fyodosGeneratedRegistries';
86
+ // Exports of the generated session module (see wireSession.js). Connected to
87
+ // the mount automatically so auth awareness needs NO manual step.
88
+ const SESSION_IS_AUTH_EXPORT = 'fyodosIsAuthenticated';
89
+ const SESSION_USER_ID_EXPORT = 'fyodosGetUserId';
86
90
 
87
91
  function buildEntryBootstrapBlock(opts = {}) {
88
- const { registryImportPath } = opts;
92
+ const { registryImportPath, sessionImportPath } = opts;
89
93
  const lines = [];
90
94
  lines.push(ORB_SCRIPT_START);
91
95
  lines.push(`import { createFiodosAgent as ${BOOTSTRAP_ALIAS} } from '@fiodos/web-core';`);
92
96
  if (registryImportPath) {
93
97
  lines.push(`import { ${BOOTSTRAP_REGISTRY_EXPORT} } from '${registryImportPath}';`);
94
98
  }
99
+ if (sessionImportPath) {
100
+ lines.push(
101
+ `import { ${SESSION_IS_AUTH_EXPORT}, ${SESSION_USER_ID_EXPORT} } from '${sessionImportPath}';`,
102
+ );
103
+ }
95
104
  lines.push(`${BOOTSTRAP_ALIAS}({`);
96
105
  lines.push(' apiKey: import.meta.env.VITE_FYODOS_API_KEY,');
97
106
  lines.push(' baseUrl: import.meta.env.VITE_FYODOS_API_URL,');
98
107
  if (registryImportPath) lines.push(` registries: ${BOOTSTRAP_REGISTRY_EXPORT},`);
108
+ if (sessionImportPath) {
109
+ lines.push(` isAuthenticated: ${SESSION_IS_AUTH_EXPORT},`);
110
+ lines.push(` getUserId: ${SESSION_USER_ID_EXPORT},`);
111
+ }
99
112
  lines.push(' mount: true,');
100
113
  lines.push('});');
101
114
  lines.push(ORB_SCRIPT_END);
@@ -123,8 +136,35 @@ function addRegistriesToBootstrapSource(source, registryImportPath) {
123
136
  if (/\n\s*registries:\s/.test(source.slice(source.indexOf(ORB_SCRIPT_START)))) {
124
137
  return { changed: false, source, reason: 'already' };
125
138
  }
139
+ // Preserve a session connection a previous pass already added to the block.
140
+ const sessionImportPath = extractBootstrapImportPath(source, SESSION_IS_AUTH_EXPORT);
141
+ const stripped = stripMarkedRegion(source, ORB_SCRIPT_START, ORB_SCRIPT_END).replace(/\n{3,}$/, '\n');
142
+ const block = buildEntryBootstrapBlock({ registryImportPath, sessionImportPath });
143
+ const next = insertBlockAfterLastImport(stripped.trimEnd(), block);
144
+ return { changed: true, source: next.endsWith('\n') ? next : `${next}\n` };
145
+ }
146
+
147
+ // Import path of the module that exports `name` inside our bootstrap block (or null).
148
+ function extractBootstrapImportPath(source, name) {
149
+ const startIdx = source.indexOf(ORB_SCRIPT_START);
150
+ if (startIdx === -1) return null;
151
+ const endIdx = source.indexOf(ORB_SCRIPT_END, startIdx);
152
+ const block = source.slice(startIdx, endIdx === -1 ? undefined : endIdx);
153
+ const m = block.match(new RegExp(`import\\s*\\{[^}]*\\b${name}\\b[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`));
154
+ return m ? m[1] : null;
155
+ }
156
+
157
+ // Idempotently rewrite an existing entry-bootstrap block to carry the generated
158
+ // SESSION connection (third pass, after session wiring). Preserves the
159
+ // registries the second pass added. Returns { changed, source, reason? }.
160
+ function addSessionToBootstrapSource(source, sessionImportPath) {
161
+ if (!source.includes(ORB_SCRIPT_START)) return { changed: false, source, reason: 'not-mounted' };
162
+ if (/\n\s*isAuthenticated:\s/.test(source.slice(source.indexOf(ORB_SCRIPT_START)))) {
163
+ return { changed: false, source, reason: 'already' };
164
+ }
165
+ const registryImportPath = extractBootstrapImportPath(source, BOOTSTRAP_REGISTRY_EXPORT);
126
166
  const stripped = stripMarkedRegion(source, ORB_SCRIPT_START, ORB_SCRIPT_END).replace(/\n{3,}$/, '\n');
127
- const block = buildEntryBootstrapBlock({ registryImportPath });
167
+ const block = buildEntryBootstrapBlock({ registryImportPath, sessionImportPath });
128
168
  const next = insertBlockAfterLastImport(stripped.trimEnd(), block);
129
169
  return { changed: true, source: next.endsWith('\n') ? next : `${next}\n` };
130
170
  }
@@ -199,6 +239,41 @@ function addRegistriesToMountSource(source, exportName, importPath) {
199
239
  return { changed: true, source: next };
200
240
  }
201
241
 
242
+ // Idempotently connect the generated SESSION module to the mounted
243
+ // <FiodosAgent/>: adds `isAuthenticated={fyodosIsAuthenticated}` and
244
+ // `getUserId={fyodosGetUserId}` + the import. Skips (never overrides) a mount
245
+ // where the developer already wired their own isAuthenticated. Same
246
+ // reversibility model as addRegistriesToMountSource.
247
+ function addSessionToMountSource(source, importPath) {
248
+ const tagRe = /([ \t]*)<FiodosAgent\b[\s\S]*?\/>/;
249
+ const m = source.match(tagRe);
250
+ if (!m) return { changed: false, source, reason: 'not-mounted' };
251
+ const tag = m[0];
252
+ if (new RegExp(`isAuthenticated=\\{${escapeRe(SESSION_IS_AUTH_EXPORT)}\\}`).test(tag)) {
253
+ return { changed: false, source, reason: 'already' };
254
+ }
255
+ if (/\bisAuthenticated=/.test(tag)) {
256
+ return { changed: false, source, reason: 'dev-wired' };
257
+ }
258
+ const baseIndent = m[1] || '';
259
+ const propMatch = tag.match(/\n([ \t]*)(?:apiKey|api-key|apiUrl|baseUrl|registries)/);
260
+ const propIndent = propMatch ? propMatch[1] : `${baseIndent} `;
261
+ const hasUserId = /\bgetUserId=/.test(tag);
262
+ const props =
263
+ `\n${propIndent}isAuthenticated={${SESSION_IS_AUTH_EXPORT}}` +
264
+ (hasUserId ? '' : `\n${propIndent}getUserId={${SESSION_USER_ID_EXPORT}}`);
265
+ const newTag = tag.replace(/\n?[ \t]*\/>$/, `${props}\n${baseIndent}/>`);
266
+ 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}';`;
271
+ if (!next.includes(importLine)) {
272
+ next = insertImportAfterLastImport(next, importLine);
273
+ }
274
+ return { changed: true, source: next };
275
+ }
276
+
202
277
  /**
203
278
  * Assess whether we can inject safely. Returns { ok, reason?, strategy, files, rel }.
204
279
  */
@@ -601,13 +676,15 @@ function writeConsentDoc(appRoot, plan) {
601
676
  'Edits sit between `FYODOS:ORB:START` / `FYODOS:ORB:END` markers. Remove those',
602
677
  'blocks (and the Fiodos import) to revert.',
603
678
  '',
604
- '## Apps with login (connect auth + screenthe only manual step)',
679
+ '## Apps with login (auth + identitywired AUTOMATICALLY)',
680
+ '',
681
+ 'For apps with login the installer also detects WHERE your session state lives',
682
+ 'and connects it on its own: it generates `src/fyodos/session.generated.*` and',
683
+ 'adds `isAuthenticated={fyodosIsAuthenticated}` / `getUserId={fyodosGetUserId}`',
684
+ 'to the mount (see `src/fyodos/FYODOS_SESSION.md`). Nothing to do by hand.',
605
685
  '',
606
- 'The orb runs with just `apiKey`/`apiUrl`. But only YOUR app knows whether the',
607
- 'user is signed in and which screen they are on, so for apps with login you',
608
- 'connect that in one place. Without it the agent can loop ("do you want to sign',
609
- "in?\") and can't tell it already signed you in. Add these props to",
610
- '`<FiodosAgent/>` (a commented scaffold is already next to the mount):',
686
+ 'ONLY if the installer reported it could not verify your session source (or you',
687
+ 'want to override it), wire the props yourself:',
611
688
  '',
612
689
  '```tsx',
613
690
  '<FiodosAgent',
@@ -619,6 +696,10 @@ function writeConsentDoc(appRoot, plan) {
619
696
  ' getCurrentRoute={() => window.location.pathname}',
620
697
  ' // optional one-line human context (session + screen):',
621
698
  " getUserContextText={() => (session ? 'Session: signed in.' : 'Session: signed out.')}",
699
+ ' // RECOMMENDED: a stable id for the signed-in user (any string; it is',
700
+ ' // hashed locally). Keys the conversation memory per user, so a logout or',
701
+ ' // account switch NEVER leaks the previous conversation on shared devices:',
702
+ ' getUserId={() => session?.userId ?? null}',
622
703
  '/>',
623
704
  '```',
624
705
  '',
@@ -649,6 +730,8 @@ module.exports = {
649
730
  registryRelImport,
650
731
  addRegistriesToMountSource,
651
732
  addRegistriesToBootstrapSource,
733
+ addSessionToMountSource,
734
+ addSessionToBootstrapSource,
652
735
  buildEntryBootstrapBlock,
653
736
  IMPORT_NAME,
654
737
  WRAPPER_BASENAME,
package/src/llm.js DELETED
@@ -1,144 +0,0 @@
1
- /**
2
- * Layer 3 — EXPLICIT LLM calls inside the pipeline.
3
- *
4
- * The static layers (1+2) extract facts; this layer makes judgment calls the
5
- * code cannot express, and every call is LOGGED so the final report can show
6
- * exactly which decisions came from the model vs from static analysis:
7
- *
8
- * 1. classifyApp — app type vs the pattern catalog
9
- * 2. curateActions — which detected callables deserve agent exposure,
10
- * category, sensitivity, parameters, label
11
- * 3. writeRouteMeta — intent ids, labels and example phrases for routes
12
- * 4. patternGapFill — given the matched pattern, propose what's missing
13
- *
14
- * Model: gpt-4o-mini via the OpenAI REST API (OPENAI_API_KEY env var).
15
- */
16
- 'use strict';
17
-
18
- const LLM_LOG = [];
19
-
20
- function getLog() {
21
- return LLM_LOG;
22
- }
23
-
24
- async function callLLM(taskName, system, user, { maxTokens = 2000 } = {}) {
25
- const apiKey = process.env.OPENAI_API_KEY;
26
- if (!apiKey) throw new Error('OPENAI_API_KEY is required for the LLM layer');
27
-
28
- const t0 = Date.now();
29
- const res = await fetch('https://api.openai.com/v1/chat/completions', {
30
- method: 'POST',
31
- headers: {
32
- 'Content-Type': 'application/json',
33
- Authorization: `Bearer ${apiKey}`,
34
- },
35
- body: JSON.stringify({
36
- model: process.env.AUTO_MANIFEST_MODEL || 'gpt-4o-mini',
37
- messages: [
38
- { role: 'system', content: system },
39
- { role: 'user', content: user },
40
- ],
41
- temperature: 0.2,
42
- max_tokens: maxTokens,
43
- response_format: { type: 'json_object' },
44
- }),
45
- });
46
- if (!res.ok) {
47
- const body = await res.text();
48
- throw new Error(`LLM call '${taskName}' failed: ${res.status} ${body.slice(0, 300)}`);
49
- }
50
- const data = await res.json();
51
- const content = data.choices?.[0]?.message?.content || '{}';
52
- let parsed;
53
- try {
54
- parsed = JSON.parse(content);
55
- } catch {
56
- throw new Error(`LLM call '${taskName}' returned non-JSON output`);
57
- }
58
- LLM_LOG.push({
59
- task: taskName,
60
- ms: Date.now() - t0,
61
- promptChars: system.length + user.length,
62
- usage: data.usage || null,
63
- });
64
- return parsed;
65
- }
66
-
67
- /** 1. Classify the app against the available pattern catalog. */
68
- async function classifyApp(appSummary, patternSummaries) {
69
- const system =
70
- 'You classify a mobile app into one of the available integration patterns, or "none". ' +
71
- 'Answer JSON: {"pattern": "<id-or-none>", "confidence": 0..1, "appKind": "<free text>", "reason": "<short>"}';
72
- const user =
73
- `Available patterns:\n${patternSummaries.map((p) => `- id=${p.id}: ${p.summary}`).join('\n')}\n\n` +
74
- `App evidence (from static analysis):\n${JSON.stringify(appSummary, null, 1).slice(0, 4000)}`;
75
- return callLLM('classifyApp', system, user, { maxTokens: 300 });
76
- }
77
-
78
- /**
79
- * 2. Curate detected callables into agent actions.
80
- * Batched: large candidate lists are processed in chunks so no candidate is
81
- * silently dropped by prompt-size truncation.
82
- */
83
- async function curateActions(appKind, candidates) {
84
- const BATCH = 14;
85
- const all = [];
86
- for (let i = 0; i < candidates.length; i += BATCH) {
87
- const chunk = candidates.slice(i, i + BATCH);
88
- const res = await curateActionsBatch(appKind, chunk);
89
- all.push(...(res.actions || []));
90
- }
91
- return { actions: all };
92
- }
93
-
94
- async function curateActionsBatch(appKind, candidates) {
95
- const system =
96
- 'You design the action surface of an in-app voice agent. Input: callables detected by static ' +
97
- 'analysis of a mobile app (handlers, store actions, buttons, alerts). For EACH candidate decide:\n' +
98
- '- expose: should the voice agent be able to trigger it? (skip pure-UI plumbing like toggling pickers, ' +
99
- 'rendering callbacks, backdrop handlers, text-input onChange)\n' +
100
- '- intent: snake_case id; label: short human label (English); category: one of discovery|workout|history|account|navigation|other\n' +
101
- '- requireConfirmation: true only for destructive/sensitive operations (deleting data, losing progress, signing out, spending money)\n' +
102
- '- confirmationMessage: required question if requireConfirmation\n' +
103
- '- parameters: object of {name:{type,required,description}} ONLY when the user must provide a value by voice (e.g. a search query)\n' +
104
- '- requiresVisibleContent: true when the action operates on an item the user is looking at\n' +
105
- '- examples: 3-4 natural English voice phrases\n' +
106
- '- evidence_used: which input evidence justified your decision\n' +
107
- 'Be conservative: when a candidate looks like internal plumbing, expose=false. ' +
108
- 'Sign-in/sign-up/verification flows requiring typed credentials should be expose=false ' +
109
- '(a voice agent cannot dictate passwords safely).\n' +
110
- 'Return a decision for EVERY candidate. "intent" MUST be a plain string.\n' +
111
- 'Answer JSON: {"actions": [{...candidate fields above, "source_name": "<input name>"}]}';
112
- const user =
113
- `App kind: ${appKind}\n\nCandidates (with static evidence):\n` +
114
- JSON.stringify(candidates, null, 1).slice(0, 14000);
115
- return callLLM('curateActions', system, user, { maxTokens: 4000 });
116
- }
117
-
118
- /** 3. Route intents, labels, examples. */
119
- async function writeRouteMeta(appKind, routes) {
120
- const system =
121
- 'You name navigation intents for an in-app voice agent. For each route give: ' +
122
- 'intent (snake_case), label (short English), examples (3-4 natural English voice phrases). ' +
123
- 'Skip auth screens the agent should not navigate to mid-session if any seem like sign-in/sign-up (mark skip=true). ' +
124
- 'For dynamic routes ([param]) mark dynamic=true and skip=true (they need a runtime parameter the static manifest cannot provide). ' +
125
- 'Answer JSON: {"routes": [{"routePath": "...", "intent": "...", "label": "...", "examples": [...], "skip": false, "skipReason": ""}]}';
126
- const user = `App kind: ${appKind}\n\nRoutes:\n${JSON.stringify(routes, null, 1)}`;
127
- return callLLM('writeRouteMeta', system, user, { maxTokens: 2500 });
128
- }
129
-
130
- /** 4. Pattern gap-fill: what does the matched pattern expect that we missed? */
131
- async function patternGapFill(patternId, patternText, draftManifestSummary) {
132
- const system =
133
- 'You compare a draft voice-agent manifest against an integration pattern document. ' +
134
- 'List actions/routes the pattern suggests SHOULD usually exist for this app type but are missing or ' +
135
- 'under-specified in the draft. Only suggest things genuinely implied by the pattern; do not invent. ' +
136
- 'Answer JSON: {"suggestions": [{"kind": "action"|"route", "intent": "...", "label": "...", "why": "...", ' +
137
- '"confidence": 0..1}], "patternUseful": true|false, "notes": "<honest assessment of how much this pattern helped>"}';
138
- const user =
139
- `Pattern (${patternId}):\n${patternText.slice(0, 5000)}\n\nDraft manifest summary:\n` +
140
- JSON.stringify(draftManifestSummary, null, 1).slice(0, 5000);
141
- return callLLM('patternGapFill', system, user, { maxTokens: 1200 });
142
- }
143
-
144
- module.exports = { classifyApp, curateActions, writeRouteMeta, patternGapFill, getLog };