@fiodos/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +77 -0
- package/README.md +128 -0
- package/package.json +30 -0
- package/src/aiAnalyze.js +469 -0
- package/src/ast.js +263 -0
- package/src/collect.js +115 -0
- package/src/graph.js +160 -0
- package/src/index.js +667 -0
- package/src/llm.js +144 -0
- package/src/loadEnv.js +81 -0
- package/src/patterns.js +28 -0
- package/src/postWireTest.js +91 -0
- package/src/renderProbe.js +333 -0
- package/src/renderProbeVue.js +136 -0
- package/src/routes.js +81 -0
- package/src/verify.js +172 -0
- package/src/verifyWire.js +215 -0
- package/src/wireHandlers.js +1789 -0
- package/src/wireWeb.js +295 -0
- package/src/wireWebMount.js +435 -0
package/src/aiAnalyze.js
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AI-first analysis call: the model reads the app's SOURCE CODE directly
|
|
3
|
+
* and proposes the full AppManifest, with mandatory per-item evidence.
|
|
4
|
+
*
|
|
5
|
+
* PRIVACY NOTE (hybrid key model):
|
|
6
|
+
* - DEFAULT (proxy): the prompt — which contains the collected source files —
|
|
7
|
+
* is sent to the Fiodos backend, which calls the AI provider with Fiodos's
|
|
8
|
+
* key (see analyzeViaProxy). The code passes through Fiodos in this mode.
|
|
9
|
+
* - --own-ai-key: the prompt is sent to the AI provider directly with the
|
|
10
|
+
* DEVELOPER'S OWN key and never touches the Fiodos backend.
|
|
11
|
+
* Either way, only the resulting manifest is published later (never code).
|
|
12
|
+
*/
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
// USD per 1M tokens (list prices, June 2026).
|
|
16
|
+
const PRICES = {
|
|
17
|
+
'claude-opus-4-8': { input: 15.0, output: 75.0 },
|
|
18
|
+
'claude-opus-4-20250514': { input: 15.0, output: 75.0 },
|
|
19
|
+
'claude-sonnet-4-6': { input: 3.0, output: 15.0 },
|
|
20
|
+
'claude-sonnet-4-20250514': { input: 3.0, output: 15.0 },
|
|
21
|
+
'claude-haiku-4-5': { input: 1.0, output: 5.0 },
|
|
22
|
+
'gpt-5': { input: 1.25, output: 10.0 },
|
|
23
|
+
'gpt-5-mini': { input: 0.25, output: 2.0 },
|
|
24
|
+
'gpt-4.1': { input: 2.0, output: 8.0 },
|
|
25
|
+
'gpt-4o-mini': { input: 0.15, output: 0.6 },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Fallback only. The real model is chosen by the backend per plan (tier-based
|
|
29
|
+
// cost control) and sent to the CLI; this is used when there is no backend
|
|
30
|
+
// answer (e.g. --no-publish local runs or an operator override via --model).
|
|
31
|
+
const DEFAULT_MODEL = 'claude-opus-4-8';
|
|
32
|
+
|
|
33
|
+
const MODEL_ALIASES = {
|
|
34
|
+
opus: 'claude-opus-4-8',
|
|
35
|
+
'claude-opus': 'claude-opus-4-8',
|
|
36
|
+
'claude-opus-4-20250514': 'claude-opus-4-8',
|
|
37
|
+
sonnet: 'claude-sonnet-4-6',
|
|
38
|
+
'claude-sonnet': 'claude-sonnet-4-6',
|
|
39
|
+
'claude-sonnet-4-20250514': 'claude-sonnet-4-6',
|
|
40
|
+
haiku: 'claude-haiku-4-5',
|
|
41
|
+
'claude-haiku': 'claude-haiku-4-5',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function resolveModel(model) {
|
|
45
|
+
const raw = (model || DEFAULT_MODEL).trim();
|
|
46
|
+
return MODEL_ALIASES[raw] || raw;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isAnthropicModel(model) {
|
|
50
|
+
return resolveModel(model).startsWith('claude');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function manifestSchemaDoc() {
|
|
54
|
+
return `
|
|
55
|
+
AppManifest (all fields required unless noted):
|
|
56
|
+
{
|
|
57
|
+
"appId": string, "appName": string, "appDescription": string, "version": string,
|
|
58
|
+
"appType": string (short app-type label, e.g. "food delivery", "habit tracker"),
|
|
59
|
+
"appFlow": string (1-2 sentences describing the MAIN user flow, e.g. "Users browse restaurants, add dishes to a cart, then check out."),
|
|
60
|
+
"routes": [{
|
|
61
|
+
"intent": string (snake_case, UNIQUE across routes AND actions),
|
|
62
|
+
"label": string, "route": string (opaque route string for the app's navigation adapter — an expo-router href, a URL path, or "BACK"),
|
|
63
|
+
"description": string (one line: WHAT THIS SCREEN SHOWS and what the user can do there),
|
|
64
|
+
"examples": string[] (3-4 natural voice phrases, REQUIRED non-empty)
|
|
65
|
+
}],
|
|
66
|
+
"actions": [{
|
|
67
|
+
"intent": string (snake_case, UNIQUE across routes AND actions),
|
|
68
|
+
"label": string, "category": string,
|
|
69
|
+
"handler": string (name of the REAL function in the app code that implements it),
|
|
70
|
+
"description": string (what the action does, in clear plain language),
|
|
71
|
+
"preconditions": string (what must be true to run it, in words — e.g. "the cart must have at least one item"; empty string if none),
|
|
72
|
+
"effect": string (what happens AFTER it runs — the result/outcome the user perceives),
|
|
73
|
+
"relatedIntents": string[] (intents that commonly follow or pair with this one — flows; [] if none),
|
|
74
|
+
"requireConfirmation": boolean,
|
|
75
|
+
"confirmationMessageTemplate": string (REQUIRED if requireConfirmation true),
|
|
76
|
+
"parameters": { "<name>": {"type": "string"|"number"|"boolean", "required": boolean, "description": string (non-empty)} },
|
|
77
|
+
"contextRequirements": {"requiresVisibleContent"?: bool, "requiresAuth"?: bool} (optional),
|
|
78
|
+
"examples": string[] (3-4 natural voice phrases, REQUIRED non-empty)
|
|
79
|
+
}],
|
|
80
|
+
"policies": {"requireConfirmationForDestructive": true, "allowedWithoutAuth": string[], "contextRetentionTurns": 5}
|
|
81
|
+
}
|
|
82
|
+
Validation rules: no duplicate intents anywhere; every route/action needs non-empty examples;
|
|
83
|
+
actions need non-empty handler; requireConfirmation implies confirmationMessageTemplate.
|
|
84
|
+
The description/preconditions/effect/relatedIntents (actions), description (routes) and appType/appFlow
|
|
85
|
+
fields are the app's "manual": they go verbatim into the agent's prompt so it understands the app.
|
|
86
|
+
Base them on the REAL code you read; be precise and concise, never padding.`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Per-platform analysis guide. Web/mobile (JS) keep their original wording;
|
|
91
|
+
* native (Phase 1) reuses the SAME engine with language-specific hints so the
|
|
92
|
+
* model knows where routes/actions live in Dart/Swift/Kotlin code.
|
|
93
|
+
*/
|
|
94
|
+
const PLATFORM_GUIDES = {
|
|
95
|
+
web: {
|
|
96
|
+
appKindLabel: 'a web app (React, Next.js, Vite, react-router, or similar)',
|
|
97
|
+
screenWord: 'page/view/route',
|
|
98
|
+
routeHintLine:
|
|
99
|
+
'No reliable static route list is available for web apps, so infer routes/views from the router setup, page/route components, and navigation calls (react-router <Route>, Next.js pages, navigate()/<Link>). Routes you propose are NOT statically verified, so only include ones you can actually see in the code.',
|
|
100
|
+
actionHintLine:
|
|
101
|
+
'Actions are functions wired to UI: handlers inside components, store/context actions (Zustand, Redux, React Context), or service functions. Set "handler" to that real function name exactly.',
|
|
102
|
+
},
|
|
103
|
+
mobile: {
|
|
104
|
+
appKindLabel: 'a mobile app (Expo / React Native)',
|
|
105
|
+
screenWord: 'screen',
|
|
106
|
+
routeHintLine: 'A statically-scanned route list is provided — prefer those hrefs verbatim.',
|
|
107
|
+
actionHintLine:
|
|
108
|
+
'Actions are functions wired to UI: handlers inside components, store/context actions (Zustand, Redux, React Context), or service functions. Set "handler" to that real function name exactly.',
|
|
109
|
+
},
|
|
110
|
+
flutter: {
|
|
111
|
+
appKindLabel: 'a Flutter app (Dart; Widgets, Navigator/GoRouter/AutoRoute)',
|
|
112
|
+
screenWord: 'screen',
|
|
113
|
+
routeHintLine:
|
|
114
|
+
'No static route list is available. Infer screens from the app\'s route table (named routes in MaterialApp.routes, GoRouter/AutoRoute config, onGenerateRoute) and the Widget classes that act as pages (e.g. *Screen/*Page extends StatefulWidget/StatelessWidget). The "route" string MUST be the named route or path the app router uses (e.g. "/cart"), or "BACK". Routes are NOT statically verified — only include ones you can see in the code.',
|
|
115
|
+
actionHintLine:
|
|
116
|
+
'Actions are Dart methods that mutate state or call services: methods on State<...> classes, ChangeNotifier/Provider, Bloc/Cubit handlers, Riverpod notifiers, or repository/service functions wired to onPressed/onTap. Set "handler" to the exact Dart method/function name as written.',
|
|
117
|
+
},
|
|
118
|
+
ios: {
|
|
119
|
+
appKindLabel: 'a native iOS app (Swift / SwiftUI)',
|
|
120
|
+
screenWord: 'screen/view',
|
|
121
|
+
routeHintLine:
|
|
122
|
+
'No static route list is available. Infer screens from SwiftUI navigation (NavigationStack/NavigationLink destinations, .sheet/.fullScreenCover, tab items) and the View structs that act as screens. The "route" string should be a stable identifier the app\'s navigation can map (e.g. a view name or path), or "BACK". Routes are NOT statically verified — only include ones you can see in the code.',
|
|
123
|
+
actionHintLine:
|
|
124
|
+
'Actions are Swift functions that mutate state or call services: methods on ObservableObject/@Observable view models, functions wired to Button(action:) / .onTapGesture, or service/repository functions. Set "handler" to the exact Swift function name as written.',
|
|
125
|
+
},
|
|
126
|
+
android: {
|
|
127
|
+
appKindLabel: 'a native Android app (Kotlin / Jetpack Compose)',
|
|
128
|
+
screenWord: 'screen',
|
|
129
|
+
routeHintLine:
|
|
130
|
+
'No static route list is available. Infer screens from Navigation Compose (NavHost composable destinations, navController.navigate("...") route strings) or Activities/Fragments. The "route" string MUST be the destination route used by the NavController (e.g. "cart"), or "BACK". Routes are NOT statically verified — only include ones you can see in the code.',
|
|
131
|
+
actionHintLine:
|
|
132
|
+
'Actions are Kotlin functions that mutate state or call services: ViewModel functions (exposed to composables and wired to onClick), use-cases, or repository/service functions. Set "handler" to the exact Kotlin function name as written.',
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
function buildSystemPrompt(platform) {
|
|
137
|
+
const guide = PLATFORM_GUIDES[platform] || PLATFORM_GUIDES.mobile;
|
|
138
|
+
const { appKindLabel, screenWord, routeHintLine, actionHintLine } = guide;
|
|
139
|
+
return `You analyze the FULL source code of ${appKindLabel} and produce the manifest of an in-app voice agent (Fiodos AppManifest).
|
|
140
|
+
|
|
141
|
+
ROUTES: every ${screenWord} the user can navigate to. ${routeHintLine} Skip auth screens (sign-in/sign-up) and dynamic [param] routes. Always include a BACK route ("route": "BACK").
|
|
142
|
+
|
|
143
|
+
ACTIONS: things a user could trigger by voice. ${actionHintLine} CRITICAL RULES:
|
|
144
|
+
- Every action MUST be backed by a REAL function you can see in the provided code. Set "handler" to that real function's name exactly as written in the code.
|
|
145
|
+
- DO NOT invent actions that "apps like this usually have". If you cannot point to the code, leave it out. A hallucinated action is worse than a missed one: it fails in production in front of the user.
|
|
146
|
+
- State can live anywhere: Zustand stores, React Context providers (look for createContext / useReducer / useState exposed via provider value), service modules. Read them all.
|
|
147
|
+
- Skip pure UI plumbing (toggling pickers, onChange of inputs, sheet open/close) and credential entry (passwords cannot be dictated).
|
|
148
|
+
- Destructive/sensitive operations (delete data, logout, place/cancel orders, spend money) need requireConfirmation + confirmationMessageTemplate.
|
|
149
|
+
- "parameters" only when the user must supply a value by voice.
|
|
150
|
+
|
|
151
|
+
THE MANUAL (description/preconditions/effect/relatedIntents per action; description per screen; appType/appFlow):
|
|
152
|
+
This is the context the agent reads to understand the app, so it can reason instead of guessing. Derive every field from the REAL code:
|
|
153
|
+
- description: what the action actually does (read the handler's body).
|
|
154
|
+
- preconditions: state the code requires before it has an effect (e.g. a non-empty cart, an active order, a signed-in user). Empty string if none.
|
|
155
|
+
- effect: the observable outcome after it runs (state change, navigation, message).
|
|
156
|
+
- relatedIntents: other manifest intents this naturally flows into or from (e.g. add-to-cart → checkout). Use the exact intent ids; [] if none.
|
|
157
|
+
- route description: what the screen displays and what can be done there.
|
|
158
|
+
- appType/appFlow: the app category and its main end-to-end flow.
|
|
159
|
+
Be precise and concise — quality over volume. Never pad.
|
|
160
|
+
|
|
161
|
+
Also return EVIDENCE for every route and action: the file (exact relative path as given in the FILE headers) and symbol (function/property name) in the provided code that justifies it. Evidence is verified mechanically afterwards: an action whose file or symbol does not exist is dropped, and unverifiable items count against you.
|
|
162
|
+
|
|
163
|
+
${wiringGuide(platform)}
|
|
164
|
+
|
|
165
|
+
If a list of omitted files is provided, declare in "uncertain" anything you could not check because of it.
|
|
166
|
+
|
|
167
|
+
Answer ONLY valid JSON (no markdown fences, no commentary). Start with { and end with }:
|
|
168
|
+
{
|
|
169
|
+
"appKind": "<short app-type label, e.g. 'food delivery'>",
|
|
170
|
+
"manifest": { ...AppManifest per the schema provided... },
|
|
171
|
+
"evidence": {
|
|
172
|
+
"routes": {"<intent>": {"file": "...", "symbol": "..."}},
|
|
173
|
+
"actions": {"<intent>": {"file": "...", "symbol": "...", "whatItDoes": "..."}}
|
|
174
|
+
},
|
|
175
|
+
"wiring": {
|
|
176
|
+
"<actionIntent>": {
|
|
177
|
+
"strategy": "module" | "bridge",
|
|
178
|
+
"imports": [{"kind": "named"|"default"|"namespace", "name": "<local name>", "from": "<repo-relative file path exactly as in a FILE header>"}],
|
|
179
|
+
"call": "<MODULE strategy only: a single JS expression that performs the action; use params['<name>'] for manifest parameters; may use await>",
|
|
180
|
+
"bridge": {
|
|
181
|
+
"file": "<BRIDGE strategy only: the component file that owns the state>",
|
|
182
|
+
"scope": "statement" | "class-field",
|
|
183
|
+
"anchor": "<an EXACT, UNIQUE line of existing code in that file. The registration is inserted right AFTER it (or just BEFORE it if the line begins with 'return'), so it must be in the SAME scope where the action's function/state/injected instance is visible>",
|
|
184
|
+
"invoke": "<a JS expression valid at the anchor that performs the action using args.<paramName> for parameters. Call the component's OWN functions or the REAL injected instance via this.<field> (e.g. addTodo(args.title) or this.todoService.updateTodos({ id: Date.now(), title: args.title, done: false })). NEVER instantiate a new service with new X() and NEVER call inject() here>",
|
|
185
|
+
"imports": [{"kind": "named"|"default"|"namespace", "name": "<local name>", "from": "<repo-relative file>"}]
|
|
186
|
+
},
|
|
187
|
+
"requiredSymbols": [{"name": "<identifier that MUST exist>", "file": "<repo-relative file>"}],
|
|
188
|
+
"bridgeReason": "<ONLY when strategy=bridge: why no module-reachable target exists>"
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
"uncertain": ["<anything you were not sure about, honestly>"]
|
|
192
|
+
}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Wiring guidance — the model not only DETECTS each action, it also says HOW to
|
|
197
|
+
* call it from a STANDALONE generated module (the handler registry), reusing the
|
|
198
|
+
* same code it just read. The mechanical verifier confirms every requiredSymbol
|
|
199
|
+
* exists before any code is written, so the model proposes and the verifier
|
|
200
|
+
* guards. Only the JS web ecosystems consume this today.
|
|
201
|
+
*/
|
|
202
|
+
function wiringGuide(platform) {
|
|
203
|
+
if (!['web', 'mobile'].includes(platform)) {
|
|
204
|
+
return 'Native wiring is generated separately; you do not need to return a "wiring" block for this platform.';
|
|
205
|
+
}
|
|
206
|
+
return `WIRING (how to actually RUN each action from a standalone generated module):
|
|
207
|
+
For EVERY action, also return a "wiring" entry describing how a separate module (the generated handler registry) can invoke the real behaviour, REUSING the code you just read. This is the key output: the user must not wire anything by hand.
|
|
208
|
+
- Prefer "strategy": "module" whenever the behaviour is reachable WITHOUT being inside a component instance:
|
|
209
|
+
· an exported function/const (import it by name and call it),
|
|
210
|
+
· a singleton store accessed outside React, e.g. Zustand: useStore.getState().doThing(params['x']),
|
|
211
|
+
· an exported service/singleton object or class instance with a method,
|
|
212
|
+
· a local-first data layer exported from a module (e.g. a Dexie \`db\`: await db.items.add({ ... })), or any repository/DAO module.
|
|
213
|
+
Provide "imports" (exact repo-relative paths, same as the FILE headers; the generator rewrites them to relative paths) and a single "call" expression. Use params['name'] for every manifest parameter, by NAME, never by position. The expression may build objects/ids inline (crypto.randomUUID(), Date.now()).
|
|
214
|
+
- Use "strategy": "bridge" when the action can ONLY be performed through component-local state (React useState / Vue ref / Angular component field or an @Injectable service the component injects) with NO module-reachable store, service or data layer. Fiodos WILL edit the user's component (under explicit consent) to register a tiny bridge, so you MUST fill the "bridge" object precisely:
|
|
215
|
+
· "file": the component that owns the state.
|
|
216
|
+
· "scope": "statement" for function-style components (React function body, Vue <script setup>), or "class-field" for class components (Angular @Component classes) where a bare statement would be a syntax error.
|
|
217
|
+
· "anchor": an exact, UNIQUE existing line to place the registration next to, in the SAME scope where the action's function / reactive state / injected instance is in scope. It must appear only ONCE in the file.
|
|
218
|
+
– React: use the component's \`return (\` line (the registration is inserted just before it, so every function above is in scope).
|
|
219
|
+
– Vue <script setup>: any unique line works; the registration is appended at the end of the script automatically.
|
|
220
|
+
– Angular class: use scope "class-field" and anchor on an existing class FIELD line that comes AFTER the \`inject(...)\` field (so the injected instance is initialised first), e.g. \`public allTodos = this.todoSignalsService.todosState();\`.
|
|
221
|
+
· "invoke": a JS expression performing the action using args.<paramName>, calling the component's OWN in-scope functions or the REAL injected instance via \`this.<field>\` (Angular). CRITICAL: never write \`new SomeService()\` and never call \`inject()\` — a fresh/uncontextualised instance is disconnected from the UI; use the instance the component already holds (this.<field>).
|
|
222
|
+
· "bridge.imports": any imports the invoke needs that the file does not already have (e.g. a model class).
|
|
223
|
+
Also set "bridgeReason" explaining why it is component-local.
|
|
224
|
+
- ANGULAR @Injectable services (providedIn:'root', etc.): there is NO module-level instance you can import, and \`inject()\` ONLY works inside an injection context — a generated module handler is NOT one, so \`inject(Svc)\` would throw at runtime. NEVER use "strategy":"module" with \`inject(...)\` for these. ALWAYS use "strategy":"bridge" anchored in a component that already does \`private foo = inject(Svc)\`, with "scope":"class-field" and "invoke" using \`this.foo.method(...)\`. Worked example for an action add_todo whose handler is updateTodos on a service the form component injects as \`this.todoSignalsService\`:
|
|
225
|
+
{ "strategy":"bridge", "bridgeReason":"updateTodos lives on an @Injectable singleton only reachable via this.todoSignalsService inside the component",
|
|
226
|
+
"bridge": { "file":"src/app/.../todo-add-new-entry-form.component.ts", "scope":"class-field",
|
|
227
|
+
"anchor":"public allTodos = this.todoSignalsService.todosState();",
|
|
228
|
+
"invoke":"this.todoSignalsService.updateTodos({ id: Date.now(), title: args.title, description: args.description, done: false })" },
|
|
229
|
+
"requiredSymbols":[{"name":"todoSignalsService","file":"src/app/.../todo-add-new-entry-form.component.ts"},{"name":"updateTodos","file":"src/app/services/todo-signals.service.ts"}] }
|
|
230
|
+
- "requiredSymbols": list every identifier your "call"/"invoke"/"imports" depend on together with the file it lives in. These are verified mechanically; if any is missing, that action is flagged for review rather than wired wrong.
|
|
231
|
+
- NEVER reference secrets, env vars, or network calls. Only app state/data operations.
|
|
232
|
+
- The confirmation flow is enforced by the engine BEFORE your handler runs, so never add your own confirmation logic; just perform the action.`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function buildUserPrompt(appMeta, files, omitted, staticRoutes) {
|
|
236
|
+
const codeBlob = files.map((f) => `===== FILE: ${f.rel} =====\n${f.content}`).join('\n\n');
|
|
237
|
+
const routeHint = staticRoutes
|
|
238
|
+
.map((r) => `${r.routePath}${r.isDynamic ? ' (dynamic)' : ''} ← ${r.relFile}`)
|
|
239
|
+
.join('\n');
|
|
240
|
+
const omittedNote = omitted.length
|
|
241
|
+
? `\nFILES OMITTED FOR SIZE (declare blind spots in "uncertain"):\n${omitted.map((o) => o.rel).join('\n')}\n`
|
|
242
|
+
: '';
|
|
243
|
+
const routeBlock = staticRoutes.length
|
|
244
|
+
? `Statically-scanned routes (existence is verified — use these hrefs):\n${routeHint}\n`
|
|
245
|
+
: `No static route list (web app): infer routes/views from the router and navigation calls in the code below.\n`;
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
`App: ${appMeta.name}\nDescription: ${appMeta.description || '(none)'}\n` +
|
|
249
|
+
`App id / bundle id (if any): ${appMeta.bundleId || '(none)'}\nDependencies: ${(appMeta.dependencies || []).join(', ')}\n\n` +
|
|
250
|
+
routeBlock +
|
|
251
|
+
omittedNote +
|
|
252
|
+
`\nManifest schema:\n${manifestSchemaDoc()}\n\n` +
|
|
253
|
+
`FULL SOURCE CODE (${files.length} files):\n\n${codeBlob}`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function parseJsonContent(raw) {
|
|
258
|
+
let trimmed = (raw || '').trim();
|
|
259
|
+
if (!trimmed) return {};
|
|
260
|
+
if (trimmed.startsWith('```')) {
|
|
261
|
+
trimmed = trimmed.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/i, '').trim();
|
|
262
|
+
}
|
|
263
|
+
const start = trimmed.indexOf('{');
|
|
264
|
+
const end = trimmed.lastIndexOf('}');
|
|
265
|
+
if (start < 0 || end <= start) throw new Error('Model response is not JSON');
|
|
266
|
+
return JSON.parse(trimmed.slice(start, end + 1));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function completeAnthropic({ model, system, user }) {
|
|
270
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
271
|
+
if (!apiKey) {
|
|
272
|
+
throw new Error('ANTHROPIC_API_KEY is required (auto-loaded from backend/.env or your shell)');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
276
|
+
method: 'POST',
|
|
277
|
+
headers: {
|
|
278
|
+
'Content-Type': 'application/json',
|
|
279
|
+
'x-api-key': apiKey,
|
|
280
|
+
'anthropic-version': '2023-06-01',
|
|
281
|
+
},
|
|
282
|
+
body: JSON.stringify({
|
|
283
|
+
model,
|
|
284
|
+
max_tokens: 30000,
|
|
285
|
+
system: `${system}\n\nRespond with raw JSON only. No markdown code fences. No text before or after the JSON object.`,
|
|
286
|
+
messages: [{ role: 'user', content: user }],
|
|
287
|
+
}),
|
|
288
|
+
});
|
|
289
|
+
if (!res.ok) throw new Error(`Anthropic API ${res.status}: ${(await res.text()).slice(0, 400)}`);
|
|
290
|
+
const data = await res.json();
|
|
291
|
+
const textPart = (data.content || []).find((b) => b.type === 'text');
|
|
292
|
+
const raw = textPart?.text || '';
|
|
293
|
+
const usage = {
|
|
294
|
+
prompt_tokens: data.usage?.input_tokens || 0,
|
|
295
|
+
completion_tokens: data.usage?.output_tokens || 0,
|
|
296
|
+
};
|
|
297
|
+
return { parsed: parseJsonContent(raw), usage };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function completeOpenAI({ model, system, user }) {
|
|
301
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
302
|
+
if (!apiKey) {
|
|
303
|
+
throw new Error('OPENAI_API_KEY is required (auto-loaded from backend/.env or your shell)');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
307
|
+
method: 'POST',
|
|
308
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
309
|
+
body: JSON.stringify({
|
|
310
|
+
model,
|
|
311
|
+
messages: [
|
|
312
|
+
{ role: 'system', content: system },
|
|
313
|
+
{ role: 'user', content: user },
|
|
314
|
+
],
|
|
315
|
+
...(model.startsWith('gpt-5')
|
|
316
|
+
? { max_completion_tokens: 30000 }
|
|
317
|
+
: { max_tokens: 8000, temperature: 0.2 }),
|
|
318
|
+
response_format: { type: 'json_object' },
|
|
319
|
+
}),
|
|
320
|
+
});
|
|
321
|
+
if (!res.ok) throw new Error(`OpenAI API ${res.status}: ${(await res.text()).slice(0, 400)}`);
|
|
322
|
+
const data = await res.json();
|
|
323
|
+
const usage = data.usage || {};
|
|
324
|
+
return { parsed: parseJsonContent(data.choices?.[0]?.message?.content || '{}'), usage };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* PROXY mode (hybrid key model, DEFAULT): instead of calling the AI provider
|
|
329
|
+
* directly with the developer's own key, POST the prompt to the Fiodos backend,
|
|
330
|
+
* which runs the call with FIODOS'S key and returns the model's JSON. The
|
|
331
|
+
* developer needs no AI key. PRIVACY: the prompt (which contains the source
|
|
332
|
+
* code) travels to the Fiodos backend in this mode; see the CLI README.
|
|
333
|
+
* @returns {{ parsed, usage, serverModel, analysisToken }}
|
|
334
|
+
*/
|
|
335
|
+
async function analyzeViaProxy({ apiKey, apiUrl, model, system, user }) {
|
|
336
|
+
if (!apiKey) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
'Hosted analysis needs your Fiodos project API key (--api-key or FYODOS_API_KEY). ' +
|
|
339
|
+
'Or pass --own-ai-key to analyze with your own AI key (code never reaches Fiodos).',
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
const res = await fetch(`${apiUrl}/v1/developer/analyze`, {
|
|
343
|
+
method: 'POST',
|
|
344
|
+
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
|
|
345
|
+
body: JSON.stringify({ model, system, user }),
|
|
346
|
+
});
|
|
347
|
+
if (!res.ok) {
|
|
348
|
+
let detail = '';
|
|
349
|
+
try {
|
|
350
|
+
const j = await res.json();
|
|
351
|
+
detail = typeof j.detail === 'string' ? j.detail : JSON.stringify(j.detail || j || '');
|
|
352
|
+
} catch {
|
|
353
|
+
/* non-JSON error body */
|
|
354
|
+
}
|
|
355
|
+
if (res.status === 429) {
|
|
356
|
+
throw new Error(`Hosted analysis unavailable (plan quota or rate limit). ${detail}`);
|
|
357
|
+
}
|
|
358
|
+
if (res.status === 413) {
|
|
359
|
+
throw new Error('Project too large for hosted analysis — lower --max-input-tokens or use --own-ai-key.');
|
|
360
|
+
}
|
|
361
|
+
if (res.status === 503) {
|
|
362
|
+
throw new Error('Hosted analysis is not available right now — try again or use --own-ai-key.');
|
|
363
|
+
}
|
|
364
|
+
throw new Error(`Hosted analysis failed: ${res.status} ${detail}`);
|
|
365
|
+
}
|
|
366
|
+
const data = await res.json();
|
|
367
|
+
return {
|
|
368
|
+
parsed: parseJsonContent(data.text || '{}'),
|
|
369
|
+
usage: {
|
|
370
|
+
prompt_tokens: data.promptTokens || 0,
|
|
371
|
+
completion_tokens: data.completionTokens || 0,
|
|
372
|
+
},
|
|
373
|
+
serverModel: data.model || model,
|
|
374
|
+
analysisToken: data.analysisToken || null,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* @returns {{ parsed: object, usage: object, costUSD: number, elapsedMs: number, model: string, analysisToken: string|null }}
|
|
380
|
+
*
|
|
381
|
+
* When `useProxy` is true (default in the CLI), the AI call runs on the Fiodos
|
|
382
|
+
* backend with Fiodos's key; otherwise it runs locally with the developer's own
|
|
383
|
+
* provider key (the privacy-preserving path: code goes straight to the AI).
|
|
384
|
+
*/
|
|
385
|
+
async function analyzeWithAI({
|
|
386
|
+
appMeta, files, omitted, staticRoutes, model = DEFAULT_MODEL, platform = 'mobile',
|
|
387
|
+
useProxy = false, apiKey = '', apiUrl = '',
|
|
388
|
+
}) {
|
|
389
|
+
const resolved = resolveModel(model);
|
|
390
|
+
const system = buildSystemPrompt(platform);
|
|
391
|
+
const user = buildUserPrompt(appMeta, files, omitted, staticRoutes);
|
|
392
|
+
|
|
393
|
+
const t0 = Date.now();
|
|
394
|
+
let parsed;
|
|
395
|
+
let usage;
|
|
396
|
+
let serverModel = resolved;
|
|
397
|
+
let analysisToken = null;
|
|
398
|
+
if (useProxy) {
|
|
399
|
+
({ parsed, usage, serverModel, analysisToken } = await analyzeViaProxy({
|
|
400
|
+
apiKey, apiUrl, model: resolved, system, user,
|
|
401
|
+
}));
|
|
402
|
+
} else {
|
|
403
|
+
({ parsed, usage } = isAnthropicModel(resolved)
|
|
404
|
+
? await completeAnthropic({ model: resolved, system, user })
|
|
405
|
+
: await completeOpenAI({ model: resolved, system, user }));
|
|
406
|
+
}
|
|
407
|
+
const elapsedMs = Date.now() - t0;
|
|
408
|
+
|
|
409
|
+
const usedModel = serverModel || resolved;
|
|
410
|
+
const price = PRICES[usedModel] || { input: 0, output: 0 };
|
|
411
|
+
const costUSD =
|
|
412
|
+
((usage.prompt_tokens || 0) / 1e6) * price.input +
|
|
413
|
+
((usage.completion_tokens || 0) / 1e6) * price.output;
|
|
414
|
+
|
|
415
|
+
return { parsed, usage, costUSD, elapsedMs, model: usedModel, promptChars: user.length, analysisToken };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Focused, cheap re-prompt to FIX one action's wiring after it failed verification
|
|
420
|
+
* (compile error, or it ran but produced no effect). Sends only the relevant
|
|
421
|
+
* files + the concrete error, and asks for corrected wiring for that single
|
|
422
|
+
* action, following the same schema/guide as the full analysis. This is the
|
|
423
|
+
* "diagnose & correct" step of the install-time auto-correction loop.
|
|
424
|
+
*
|
|
425
|
+
* @returns {{ wiring: object|null, usage: object, costUSD: number }}
|
|
426
|
+
*/
|
|
427
|
+
async function correctActionWiring({
|
|
428
|
+
action, intent, evidence = {}, relevantFiles = [], prevWiring = null,
|
|
429
|
+
errorText = '', attempt = 1, model = DEFAULT_MODEL, platform = 'web',
|
|
430
|
+
}) {
|
|
431
|
+
const resolved = resolveModel(model);
|
|
432
|
+
const codeBlob = relevantFiles
|
|
433
|
+
.map((f) => `===== FILE: ${f.rel} =====\n${f.content}`)
|
|
434
|
+
.join('\n\n');
|
|
435
|
+
const system =
|
|
436
|
+
`You are fixing the WIRING of ONE Fiodos action that FAILED automatic verification during install. ` +
|
|
437
|
+
`Re-read the relevant code and the concrete error, diagnose the ROOT CAUSE, and return CORRECTED wiring for this single action.\n\n` +
|
|
438
|
+
`${wiringGuide(platform)}\n\n` +
|
|
439
|
+
`Common root causes & fixes:\n` +
|
|
440
|
+
`- Angular @Injectable service: NEVER strategy:module with inject(...) (throws outside an injection context). Use strategy:bridge, scope:class-field, anchored in a component that already does \`private x = inject(Svc)\`, invoke via this.x.method(...).\n` +
|
|
441
|
+
`- A TypeScript compile error means a type/shape mismatch: pass the exact object shape the real function expects (read its signature), or cast minimally.\n` +
|
|
442
|
+
`- "executed but no observable effect": you wired a getter/no-op or a detached copy; wire the function that actually mutates the shared state.\n` +
|
|
443
|
+
`- Unverified symbol/import: only reference names that exist in the files shown.\n\n` +
|
|
444
|
+
`Answer ONLY valid JSON: {"wiring": {"${intent}": { ...one wiring entry per the schema... }}}`;
|
|
445
|
+
const user =
|
|
446
|
+
`ACTION (manifest):\n${JSON.stringify(action, null, 1)}\n\n` +
|
|
447
|
+
`EVIDENCE: ${JSON.stringify(evidence[intent] || {}, null, 1)}\n\n` +
|
|
448
|
+
`YOUR PREVIOUS WIRING ATTEMPT (attempt ${attempt}) that FAILED:\n${JSON.stringify(prevWiring, null, 1)}\n\n` +
|
|
449
|
+
`VERIFICATION ERROR:\n${String(errorText).slice(0, 3000)}\n\n` +
|
|
450
|
+
`RELEVANT CODE:\n\n${codeBlob}`;
|
|
451
|
+
|
|
452
|
+
const { parsed, usage } = isAnthropicModel(resolved)
|
|
453
|
+
? await completeAnthropic({ model: resolved, system, user })
|
|
454
|
+
: await completeOpenAI({ model: resolved, system, user });
|
|
455
|
+
const price = PRICES[resolved] || { input: 0, output: 0 };
|
|
456
|
+
const costUSD =
|
|
457
|
+
((usage.prompt_tokens || 0) / 1e6) * price.input +
|
|
458
|
+
((usage.completion_tokens || 0) / 1e6) * price.output;
|
|
459
|
+
// Be tolerant of the shape the model returns: {wiring:{intent:{...}}}, or
|
|
460
|
+
// {intent:{...}}, or a bare single wiring entry {strategy/bridge/imports/call}.
|
|
461
|
+
let wiring = (parsed && parsed.wiring) || null;
|
|
462
|
+
if (!wiring && parsed && typeof parsed === 'object') {
|
|
463
|
+
if (parsed[intent]) wiring = { [intent]: parsed[intent] };
|
|
464
|
+
else if (parsed.strategy || parsed.bridge || parsed.imports || parsed.call) wiring = { [intent]: parsed };
|
|
465
|
+
}
|
|
466
|
+
return { wiring, usage, costUSD };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
module.exports = { analyzeWithAI, correctActionWiring, DEFAULT_MODEL, PRICES, resolveModel, isAnthropicModel };
|