letfixit 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.
Files changed (46) hide show
  1. package/.env.example +33 -0
  2. package/README.md +226 -0
  3. package/adapters/android/build.gradle +28 -0
  4. package/adapters/android/src/main/kotlin/dev/perfanalyzer/ChoreographerAdapter.kt +43 -0
  5. package/adapters/android/src/main/kotlin/dev/perfanalyzer/LogcatReader.kt +64 -0
  6. package/adapters/android/src/main/kotlin/dev/perfanalyzer/MemoryObserver.kt +46 -0
  7. package/adapters/android/src/main/kotlin/dev/perfanalyzer/OkHttpInterceptor.kt +53 -0
  8. package/adapters/android/src/main/kotlin/dev/perfanalyzer/PerfAnalyzer.kt +100 -0
  9. package/adapters/flutter/lib/perf_analyzer.dart +73 -0
  10. package/adapters/flutter/lib/src/frame_observer.dart +45 -0
  11. package/adapters/flutter/lib/src/network_observer.dart +72 -0
  12. package/adapters/flutter/lib/src/rebuild_observer.dart +57 -0
  13. package/adapters/flutter/lib/src/universal_event.dart +50 -0
  14. package/adapters/flutter/lib/src/vm_service_adapter.dart +81 -0
  15. package/adapters/flutter/pubspec.lock +184 -0
  16. package/adapters/flutter/pubspec.yaml +13 -0
  17. package/config/model.manifest.example.json +108 -0
  18. package/config/model.manifest.json +108 -0
  19. package/core/schema/universal_event.dart +63 -0
  20. package/dashboard/dist/assets/index-D5TCSsvB.js +107 -0
  21. package/dashboard/dist/index.html +118 -0
  22. package/package.json +62 -0
  23. package/scripts/encrypt-model.js +86 -0
  24. package/scripts/fill-manifest.js +67 -0
  25. package/scripts/load-env.js +20 -0
  26. package/scripts/model_crypto.js +14 -0
  27. package/scripts/setup-model.js +269 -0
  28. package/server/adb_bridge.js +201 -0
  29. package/server/adb_score.js +77 -0
  30. package/server/ai_proxy.js +373 -0
  31. package/server/analysis_engine.js +222 -0
  32. package/server/android_bridge.js +124 -0
  33. package/server/android_live.js +167 -0
  34. package/server/data/device_gpu_db.json +465 -0
  35. package/server/device_db.js +94 -0
  36. package/server/device_review.js +146 -0
  37. package/server/finding_generator.js +56 -0
  38. package/server/flutter_bridge.js +414 -0
  39. package/server/index.js +619 -0
  40. package/server/ios_bridge.js +181 -0
  41. package/server/model_config.js +54 -0
  42. package/server/report_template.js +169 -0
  43. package/server/session_manager.js +370 -0
  44. package/server/widget_gpu_classifier.js +77 -0
  45. package/server/widget_triage.js +78 -0
  46. package/setup.sh +160 -0
@@ -0,0 +1,201 @@
1
+ // ── ADB bridge ────────────────────────────────────────────────────────────────
2
+ // Drives performance data collection on a physically-connected Android device
3
+ // through `adb`. Works on ANY installed app — no debuggable build required —
4
+ // because it reads the OS-side dumpsys counters (gfxinfo/meminfo/cpuinfo) that
5
+ // the framework maintains for every process.
6
+ //
7
+ // The parse* functions are pure and exported so they can be unit-tested against
8
+ // captured dumpsys output without a device attached.
9
+ const { execFile, spawn } = require('child_process');
10
+
11
+ const ADB = process.env.ADB_PATH || 'adb';
12
+
13
+ function sh(args, { timeout = 15000 } = {}) {
14
+ return new Promise((resolve, reject) => {
15
+ execFile(ADB, args, { timeout, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
16
+ if (err) return reject(new Error((stderr || err.message || '').toString().trim()));
17
+ resolve(stdout || '');
18
+ });
19
+ });
20
+ }
21
+
22
+ const target = (serial) => (serial ? ['-s', serial] : []);
23
+
24
+ /** Run an arbitrary `adb shell` command on a device. */
25
+ function shell(serial, cmd) {
26
+ return sh([...target(serial), 'shell', ...(Array.isArray(cmd) ? cmd : [cmd])]);
27
+ }
28
+
29
+ /** Is the adb binary reachable at all? */
30
+ async function isAvailable() {
31
+ try { await sh(['version']); return true; } catch { return false; }
32
+ }
33
+
34
+ /** PID of a running package (or null if not running). */
35
+ async function pidOf(serial, pkg) {
36
+ try {
37
+ const out = (await shell(serial, ['pidof', pkg])).trim();
38
+ const pid = parseInt(out.split(/\s+/)[0], 10);
39
+ return Number.isFinite(pid) ? pid : null;
40
+ } catch { return null; }
41
+ }
42
+
43
+ /** Package of the currently-focused/resumed activity (best-effort). */
44
+ async function foregroundPackage(serial) {
45
+ try {
46
+ const out = await shell(serial, ['dumpsys', 'activity', 'activities']).catch(() => '');
47
+ const m = out.match(/mResumedActivity[^\n]*\s([a-zA-Z][\w.]+)\/[\w.$]+/) ||
48
+ out.match(/topResumedActivity[^\n]*\s([a-zA-Z][\w.]+)\/[\w.$]+/);
49
+ if (m) return m[1];
50
+ const w = (await shell(serial, ['dumpsys', 'window']).catch(() => '')).match(/mCurrentFocus=[^\s]*\s([a-zA-Z][\w.]+)\//);
51
+ return w ? w[1] : null;
52
+ } catch { return null; }
53
+ }
54
+
55
+ /** Connected devices in state `device` → [{ serial, model }]. */
56
+ async function listDevices() {
57
+ const out = await sh(['devices', '-l']);
58
+ const devices = [];
59
+ for (const line of out.split('\n').slice(1)) {
60
+ const m = line.trim().match(/^(\S+)\s+device\b(.*)$/);
61
+ if (!m) continue;
62
+ const model = (m[2].match(/model:(\S+)/) || [])[1] || m[1];
63
+ devices.push({ serial: m[1], model: model.replace(/_/g, ' ') });
64
+ }
65
+ return devices;
66
+ }
67
+
68
+ async function deviceInfo(serial) {
69
+ const g = async (prop) => {
70
+ try { return (await sh([...target(serial), 'shell', 'getprop', prop])).trim(); }
71
+ catch { return ''; }
72
+ };
73
+ return {
74
+ brand: await g('ro.product.brand'),
75
+ model: await g('ro.product.model'),
76
+ os: 'Android', // generic OS label used by report/AI
77
+ osVersion: await g('ro.build.version.release'),
78
+ sdk: await g('ro.build.version.sdk'),
79
+ abi: await g('ro.product.cpu.abi'),
80
+ };
81
+ }
82
+
83
+ /** True if the package is installed on the device. */
84
+ async function isInstalled(serial, pkg) {
85
+ try {
86
+ const out = await sh([...target(serial), 'shell', 'pm', 'list', 'packages', pkg]);
87
+ return out.split('\n').some((l) => l.trim() === `package:${pkg}`);
88
+ } catch { return false; }
89
+ }
90
+
91
+ /** Launch the app's main LAUNCHER activity without needing its activity name. */
92
+ function launch(serial, pkg) {
93
+ return sh([...target(serial), 'shell', 'monkey', '-p', pkg, '-c', 'android.intent.category.LAUNCHER', '1']);
94
+ }
95
+ function forceStop(serial, pkg) {
96
+ return sh([...target(serial), 'shell', 'am', 'force-stop', pkg]);
97
+ }
98
+ /** Clear the running gfxinfo frame stats so the next capture is fresh. */
99
+ function resetGfx(serial, pkg) {
100
+ return sh([...target(serial), 'shell', 'dumpsys', 'gfxinfo', pkg, 'reset']);
101
+ }
102
+
103
+ /** Snapshot render / memory / cpu counters for the package right now. */
104
+ async function collect(serial, pkg) {
105
+ const [gfx, mem, cpu] = await Promise.all([
106
+ sh([...target(serial), 'shell', 'dumpsys', 'gfxinfo', pkg]).catch(() => ''),
107
+ sh([...target(serial), 'shell', 'dumpsys', 'meminfo', pkg]).catch(() => ''),
108
+ sh([...target(serial), 'shell', 'dumpsys', 'cpuinfo']).catch(() => ''),
109
+ ]);
110
+ return { ...parseGfxinfo(gfx), ...parseMeminfo(mem), cpuPct: parseCpu(cpu, pkg) };
111
+ }
112
+
113
+ // ── Pure parsers ───────────────────────────────────────────────────────────────
114
+ function parseGfxinfo(text) {
115
+ const num = (re) => { const m = text.match(re); return m ? parseFloat(m[1]) : null; };
116
+ const jank = text.match(/Janky frames:\s*(\d+)\s*\(([\d.]+)%\)/);
117
+ const gpuMem = text.match(/Total GPU memory usage:\s*\d+\s*bytes?,\s*([\d.]+)\s*MB/i);
118
+ return {
119
+ totalFrames: num(/Total frames rendered:\s*(\d+)/),
120
+ jankyFrames: jank ? parseInt(jank[1], 10) : null,
121
+ jankPct: jank ? parseFloat(jank[2]) : null,
122
+ p50: num(/50th percentile:\s*(\d+)ms/),
123
+ p90: num(/90th percentile:\s*(\d+)ms/),
124
+ p95: num(/95th percentile:\s*(\d+)ms/),
125
+ p99: num(/99th percentile:\s*(\d+)ms/),
126
+ slowUiThread: num(/Number Slow UI thread:\s*(\d+)/),
127
+ missedVsync: num(/Number Missed Vsync:\s*(\d+)/),
128
+ gpuMemMb: gpuMem ? parseFloat(gpuMem[1]) : null,
129
+ };
130
+ }
131
+
132
+ function parseMeminfo(text) {
133
+ // Android 8+: "TOTAL PSS: 123456 TOTAL RSS: ..."; older: "TOTAL 123456 ..."
134
+ const m = text.match(/TOTAL PSS:\s*(\d+)/i) || text.match(/^\s*TOTAL\s+(\d+)/im);
135
+ const pssKb = m ? parseInt(m[1], 10) : null;
136
+ const native = (text.match(/Native Heap\s+(\d+)/) || [])[1];
137
+ const dalvik = (text.match(/Dalvik Heap\s+(\d+)/) || [])[1];
138
+ return {
139
+ pssMb: pssKb != null ? parseFloat((pssKb / 1024).toFixed(1)) : null,
140
+ nativeHeapMb: native ? parseFloat((+native / 1024).toFixed(1)) : null,
141
+ dalvikHeapMb: dalvik ? parseFloat((+dalvik / 1024).toFixed(1)) : null,
142
+ };
143
+ }
144
+
145
+ // Per-frame timings from `dumpsys gfxinfo <pkg> framestats`. The PROFILEDATA
146
+ // blocks are CSV rows of nanosecond timestamps; we recover each frame's total,
147
+ // UI-thread (build) and GPU (raster) durations. `intendedVsync` is a monotonic
148
+ // key the live monitor uses to emit only frames it hasn't seen yet.
149
+ // cols: 0 Flags, 1 IntendedVsync, ... 11 IssueDrawCommandsStart, 13 FrameCompleted
150
+ function parseFramestats(text) {
151
+ const round = (n) => Math.round(n * 100) / 100;
152
+ const out = [];
153
+ for (const line of text.split('\n')) {
154
+ const l = line.trim();
155
+ if (!l || l.indexOf(',') === -1) continue;
156
+ const c = l.split(',');
157
+ if (c.length < 14) continue;
158
+ const n = c.map(Number);
159
+ if (n.some((x) => !Number.isFinite(x))) continue; // header / non-data row
160
+ const flags = n[0];
161
+ if (flags !== 0) continue; // skip layout-change / first-draw frames
162
+ const intendedVsync = n[1], issueDraw = n[11], frameCompleted = n[13];
163
+ if (!intendedVsync || !frameCompleted || frameCompleted <= intendedVsync) continue;
164
+ const totalMs = (frameCompleted - intendedVsync) / 1e6;
165
+ if (totalMs <= 0 || totalMs > 5000) continue; // sanity guard
166
+ out.push({
167
+ intendedVsync,
168
+ totalMs: round(totalMs),
169
+ buildMs: round(Math.max(0, (issueDraw - intendedVsync) / 1e6)),
170
+ rasterMs: round(Math.max(0, (frameCompleted - issueDraw) / 1e6)),
171
+ });
172
+ }
173
+ // Chronological by vsync so the live monitor's "last seen" cursor advances cleanly.
174
+ return out.sort((a, b) => a.intendedVsync - b.intendedVsync);
175
+ }
176
+
177
+ function parseCpu(text, pkg) {
178
+ // e.g. " 12% 3456/com.example.app: 8% user + 4% kernel"
179
+ for (const line of text.split('\n')) {
180
+ if (pkg && line.includes(pkg)) {
181
+ const m = line.trim().match(/([\d.]+)%/);
182
+ if (m) return parseFloat(m[1]);
183
+ }
184
+ }
185
+ return null;
186
+ }
187
+
188
+ /** Open a file with the OS default handler (used to pop the HTML report). */
189
+ function openFile(filePath) {
190
+ const cmd = process.platform === 'darwin' ? 'open'
191
+ : process.platform === 'win32' ? 'cmd' : 'xdg-open';
192
+ const args = process.platform === 'win32' ? ['/c', 'start', '', filePath] : [filePath];
193
+ try { spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref(); } catch (_) {}
194
+ }
195
+
196
+ module.exports = {
197
+ ADB, exec: sh, shell, target,
198
+ isAvailable, listDevices, deviceInfo, isInstalled, pidOf, foregroundPackage,
199
+ launch, forceStop, resetGfx, collect, openFile,
200
+ parseGfxinfo, parseMeminfo, parseCpu, parseFramestats,
201
+ };
@@ -0,0 +1,77 @@
1
+ // ── Deterministic scoring for an ADB performance review ──────────────────────
2
+ // Aggregates per-iteration metrics into sub-scores and an overall 0–100 rank,
3
+ // entirely in code (no LLM). The LLM only layers improvement suggestions on top
4
+ // of the issues detected here — same "compute in code, escalate only what needs
5
+ // judgement" philosophy as the Flutter module test.
6
+ const clamp = (n, lo = 0, hi = 100) => Math.max(lo, Math.min(hi, n));
7
+ const round = (n) => Math.round(n * 10) / 10;
8
+ const avg = (arr) => (arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0);
9
+
10
+ const FRAME_BUDGET_MS = 16.6;
11
+
12
+ // Missing metrics stay `null` (not 0) so a platform that doesn't capture a
13
+ // parameter — e.g. iOS render-only traces have no memory/CPU — is reported
14
+ // honestly as "n/a" rather than a misleading perfect score.
15
+ function aggregate(iterations) {
16
+ const col = (k) => iterations.map((i) => i[k]).filter((v) => v != null);
17
+ const orNull = (arr, f) => (arr.length ? f(arr) : null);
18
+ const mem = col('pssMb');
19
+ return {
20
+ jankPct: orNull(col('jankPct'), (a) => round(avg(a))),
21
+ p50: orNull(col('p50'), (a) => round(avg(a))),
22
+ p90: orNull(col('p90'), (a) => round(avg(a))),
23
+ p99: orNull(col('p99'), (a) => round(avg(a))),
24
+ peakMemMb: orNull(mem, (a) => round(Math.max(...a))),
25
+ avgMemMb: orNull(mem, (a) => round(avg(a))),
26
+ memGrowthMb: mem.length >= 2 ? round(mem[mem.length - 1] - mem[0]) : null,
27
+ cpuPct: orNull(col('cpuPct'), (a) => round(avg(a))),
28
+ gpuMemMb: orNull(col('gpuMemMb'), (a) => round(avg(a))),
29
+ };
30
+ }
31
+
32
+ // Each sub-score is only computed when its data is present; the overall is a
33
+ // weight-renormalized average of the sub-scores that ARE available.
34
+ function score(metrics) {
35
+ const m = metrics;
36
+ const has = (v) => v != null;
37
+
38
+ const parts = [];
39
+ if (has(m.jankPct) || has(m.p90) || has(m.p99)) {
40
+ const render = clamp(100 - (m.jankPct || 0) * 2 - Math.max(0, (m.p90 || 0) - FRAME_BUDGET_MS) * 3 - Math.max(0, (m.p99 || 0) - 32) * 1.2);
41
+ parts.push({ key: 'render', value: render, weight: 0.5 });
42
+ }
43
+ if (has(m.peakMemMb) || has(m.memGrowthMb)) {
44
+ const memory = clamp(100 - Math.max(0, (m.peakMemMb || 0) - 150) * 0.3 - Math.max(0, (m.memGrowthMb || 0)) * 2.5);
45
+ parts.push({ key: 'memory', value: memory, weight: 0.3 });
46
+ }
47
+ if (has(m.cpuPct)) {
48
+ const cpu = clamp(100 - Math.max(0, m.cpuPct - 30) * 1.6);
49
+ parts.push({ key: 'cpu', value: cpu, weight: 0.2 });
50
+ }
51
+
52
+ const totalW = parts.reduce((s, p) => s + p.weight, 0) || 1;
53
+ const overall = Math.round(parts.reduce((s, p) => s + p.value * p.weight, 0) / totalW);
54
+ const sub = (k) => { const p = parts.find((x) => x.key === k); return p ? Math.round(p.value) : null; };
55
+ return {
56
+ overall,
57
+ render: sub('render'),
58
+ memory: sub('memory'),
59
+ cpu: sub('cpu'),
60
+ grade: overall >= 85 ? 'excellent' : overall >= 70 ? 'good' : overall >= 50 ? 'fair' : 'poor',
61
+ };
62
+ }
63
+
64
+ // Guards use `> value`, which is false for null — a missing metric never
65
+ // produces a false issue.
66
+ function detectIssues(m) {
67
+ const issues = [];
68
+ if (m.jankPct > 5) issues.push({ area: 'render', text: `Jank on ${m.jankPct}% of frames (target < 5%).` });
69
+ if (m.p90 > FRAME_BUDGET_MS) issues.push({ area: 'render', text: `p90 frame ${m.p90}ms exceeds the ${FRAME_BUDGET_MS}ms budget.` });
70
+ if (m.p99 > 40) issues.push({ area: 'render', text: `p99 frame ${m.p99}ms — severe stalls on the worst frames.` });
71
+ if (m.memGrowthMb > 10) issues.push({ area: 'memory', text: `Memory grew ${m.memGrowthMb}MB across iterations — possible leak.` });
72
+ if (m.peakMemMb > 250) issues.push({ area: 'memory', text: `Peak memory ${m.peakMemMb}MB is high for a single flow.` });
73
+ if (m.cpuPct > 40) issues.push({ area: 'cpu', text: `CPU averaged ${m.cpuPct}% — likely background work or busy loops.` });
74
+ return issues;
75
+ }
76
+
77
+ module.exports = { aggregate, score, detectIssues, FRAME_BUDGET_MS };
@@ -0,0 +1,373 @@
1
+ require('dotenv').config();
2
+ const fetch = require('node-fetch');
3
+ const { generatePrompt, parseAIResponse } = require('./finding_generator');
4
+ const { triage } = require('./widget_triage');
5
+
6
+ const model = require('./model_config');
7
+
8
+ const LOCAL_AI_PORT = process.env.LOCAL_AI_PORT || 8080;
9
+ const GROQ_KEY = process.env.GROQ_API_KEY;
10
+ const GEMINI_KEY = process.env.GEMINI_API_KEY;
11
+ const CLAUDE_KEY = process.env.CLAUDE_API_KEY;
12
+ const ENABLE_LOCAL = process.env.ENABLE_LOCAL_AI !== 'false';
13
+
14
+ // AI availability summary — no longer auto-printed on require (the startup
15
+ // sequence in index.js owns the branded "Ultron n0-beta initializing…" output).
16
+ // Exposed for anything that still wants the detail.
17
+ function aiStatus() {
18
+ return {
19
+ localReady: ENABLE_LOCAL && model.isReady(),
20
+ groq: !!GROQ_KEY, gemini: !!GEMINI_KEY, claude: !!CLAUDE_KEY,
21
+ anyAvailable: (ENABLE_LOCAL && model.isReady()) || !!GROQ_KEY || !!GEMINI_KEY || !!CLAUDE_KEY,
22
+ };
23
+ }
24
+
25
+ // ── Main routing: B → A (groq → gemini → claude) ───────────────────────────
26
+ async function getAISuggestion(finding) {
27
+ const prompt = generatePrompt(finding);
28
+ const rule = finding.rule || 'finding';
29
+
30
+ // ── B: Local SmolLM2 ──────────────────────────────────────────────────────
31
+ if (ENABLE_LOCAL) {
32
+ const local = await tryLocal(prompt);
33
+ if (local) {
34
+ console.log(` 🤖 [local] AI suggestion for ${rule}`);
35
+ return local;
36
+ }
37
+ }
38
+
39
+ // ── A: Cloud fallbacks ────────────────────────────────────────────────────
40
+ if (GROQ_KEY) {
41
+ const groq = await tryGroq(prompt);
42
+ if (groq) {
43
+ console.log(` 🤖 [groq] AI suggestion for ${rule}`);
44
+ return groq;
45
+ }
46
+ }
47
+
48
+ if (GEMINI_KEY) {
49
+ const gemini = await tryGemini(prompt);
50
+ if (gemini) {
51
+ console.log(` 🤖 [gemini] AI suggestion for ${rule}`);
52
+ return gemini;
53
+ }
54
+ }
55
+
56
+ if (CLAUDE_KEY) {
57
+ const claude = await tryClaude(prompt);
58
+ if (claude) {
59
+ console.log(` 🤖 [claude] AI suggestion for ${rule}`);
60
+ return claude;
61
+ }
62
+ }
63
+
64
+ // ── All providers failed or unconfigured ──────────────────────────────────
65
+ console.log(` ⚠️ No AI available for ${rule} — configure a provider in .env`);
66
+ return null;
67
+ }
68
+
69
+ // ── Provider implementations ─────────────────────────────────────────────────
70
+
71
+ async function tryLocal(prompt) {
72
+ try {
73
+ // Use the chat completions endpoint so the model's chat template is applied.
74
+ // Nemotron is a reasoning model — it uses ~300-500 tokens for internal thinking
75
+ // before outputting the structured answer, so max_tokens must be high enough.
76
+ const res = await fetch(`http://localhost:${LOCAL_AI_PORT}/v1/chat/completions`, {
77
+ method: 'POST',
78
+ headers: { 'Content-Type': 'application/json' },
79
+ body: JSON.stringify({
80
+ model: 'local',
81
+ messages: [{ role: 'user', content: prompt }],
82
+ max_tokens: 900,
83
+ temperature: 0.1,
84
+ }),
85
+ timeout: 45000,
86
+ });
87
+ if (!res.ok) { console.log(` ↳ [local] HTTP ${res.status}`); return null; }
88
+ const data = await res.json();
89
+ const text = data.choices?.[0]?.message?.content;
90
+ if (!text) { console.log(' ↳ [local] empty content (reasoning still in progress — increase max_tokens?)'); return null; }
91
+ const parsed = parseAIResponse(text);
92
+ if (!parsed) { console.log(' ↳ [local] parse failed'); return null; }
93
+ return { ...parsed, source: 'local', confidence: 0.75 };
94
+ } catch (err) {
95
+ if (err.code !== 'ECONNREFUSED') console.log(` ↳ [local] ${err.message}`);
96
+ return null;
97
+ }
98
+ }
99
+
100
+ async function tryGroq(prompt) {
101
+ try {
102
+ const res = await fetch('https://api.groq.com/openai/v1/chat/completions', {
103
+ method: 'POST',
104
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${GROQ_KEY}` },
105
+ body: JSON.stringify({
106
+ model: 'mixtral-8x7b-32768',
107
+ messages: [{ role: 'user', content: prompt }],
108
+ max_tokens: 200,
109
+ temperature: 0.1,
110
+ }),
111
+ });
112
+ if (!res.ok) { console.log(` ↳ [groq] HTTP ${res.status}`); return null; }
113
+ const data = await res.json();
114
+ const text = data.choices?.[0]?.message?.content;
115
+ const parsed = parseAIResponse(text);
116
+ if (!parsed) { console.log(' ↳ [groq] parse failed'); return null; }
117
+ return { ...parsed, source: 'groq', confidence: 0.9 };
118
+ } catch (err) {
119
+ console.log(` ↳ [groq] ${err.message}`);
120
+ return null;
121
+ }
122
+ }
123
+
124
+ async function tryGemini(prompt) {
125
+ try {
126
+ const res = await fetch(
127
+ `https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=${GEMINI_KEY}`,
128
+ {
129
+ method: 'POST',
130
+ headers: { 'Content-Type': 'application/json' },
131
+ body: JSON.stringify({
132
+ contents: [{ parts: [{ text: prompt }] }],
133
+ generationConfig: { maxOutputTokens: 200, temperature: 0.1 },
134
+ }),
135
+ }
136
+ );
137
+ if (!res.ok) { console.log(` ↳ [gemini] HTTP ${res.status}`); return null; }
138
+ const data = await res.json();
139
+ const text = data.candidates?.[0]?.content?.parts?.[0]?.text;
140
+ const parsed = parseAIResponse(text);
141
+ if (!parsed) { console.log(' ↳ [gemini] parse failed'); return null; }
142
+ return { ...parsed, source: 'gemini', confidence: 0.9 };
143
+ } catch (err) {
144
+ console.log(` ↳ [gemini] ${err.message}`);
145
+ return null;
146
+ }
147
+ }
148
+
149
+ async function tryClaude(prompt) {
150
+ try {
151
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
152
+ method: 'POST',
153
+ headers: {
154
+ 'Content-Type': 'application/json',
155
+ 'x-api-key': CLAUDE_KEY,
156
+ 'anthropic-version': '2023-06-01',
157
+ },
158
+ body: JSON.stringify({
159
+ model: 'claude-sonnet-4-6',
160
+ max_tokens: 200,
161
+ messages: [{ role: 'user', content: prompt }],
162
+ }),
163
+ });
164
+ if (!res.ok) { console.log(` ↳ [claude] HTTP ${res.status}`); return null; }
165
+ const data = await res.json();
166
+ const text = data.content?.[0]?.text;
167
+ const parsed = parseAIResponse(text);
168
+ if (!parsed) { console.log(' ↳ [claude] parse failed'); return null; }
169
+ return { ...parsed, source: 'claude', confidence: 0.95 };
170
+ } catch (err) {
171
+ console.log(` ↳ [claude] ${err.message}`);
172
+ return null;
173
+ }
174
+ }
175
+
176
+ // ── Module Performance Test analysis (local Nemotron only) ────────────────────
177
+ // Called ONCE, after the user completes ≥5 runs of a module flow. Sends the
178
+ // aggregated widget table + per-device projection to the local model and asks
179
+ // for concrete per-widget optimization advice.
180
+ // `escalated` is the code-triaged shortlist — only these widgets reach the model.
181
+ function buildModulePrompt(agg, escalated) {
182
+ const deviceLines = agg.devices.map((d) => {
183
+ const worst = d.widgetRatings.filter((w) => w.rating === 'bad' || w.rating === 'ok')
184
+ .map((w) => `${w.name} (~${w.projectedMs}ms, ${w.rating})`).join(', ') || 'none flagged';
185
+ const apiStr = d.api ? `Android API ${d.api}` : d.ios_min ? `iOS ${d.ios_min}+` : '';
186
+ return `- ${d.name}: ${d.gpu} (${d.gpu_tier} tier), ${d.ram_gb}GB RAM, ${d.arch}, ${apiStr}\n Projected p90 frame: ${d.projectedP90}ms → overall "${d.overall}". Slow widgets: ${worst}`;
187
+ }).join('\n');
188
+
189
+ const widgetLines = escalated.map(({ widget: w, meta }) =>
190
+ `- ${w.name}: avg build ${w.avgBuildMs}ms, ${w.rebuilds} rebuilds, GPU cost ${w.gpuCost}${w.riskyTiers.length ? `, risky on [${w.riskyTiers.join(', ')}]` : ''}, worst projected ~${meta.worstProjected}ms${meta.worstDevice ? ` on ${meta.worstDevice.name}` : ''}`
191
+ ).join('\n');
192
+
193
+ return `You are a Flutter performance expert reviewing a module after ${agg.runsCompleted} profiled runs.
194
+
195
+ MODULE: ${agg.module}
196
+ Measured (on the connected device): median p90 frame ${agg.measured.p90FrameMs}ms, avg frame ${agg.measured.avgFrameMs}ms, jank ${agg.measured.jankPct}% of frames. Frame budget is 16.6ms (60Hz).
197
+
198
+ FLAGGED WIDGETS (these were pre-selected in code as the expensive/ambiguous ones — focus only on these):
199
+ ${widgetLines}
200
+
201
+ TARGET DEVICES (measurements projected onto each GPU tier):
202
+ ${deviceLines}
203
+
204
+ For each flagged widget above, give concrete optimization advice. For EACH, respond in exactly this block format and nothing else:
205
+
206
+ WIDGET: <widget name>
207
+ ISSUE: <one sentence — why it is slow, referencing the device tier if relevant>
208
+ FIX: <one sentence — the specific Flutter change>
209
+ CODE: <dart snippet, max 5 lines>
210
+ GAIN: <expected improvement, e.g. -8ms build or +12 FPS on low-tier devices>
211
+ ---`;
212
+ }
213
+
214
+ function parseModuleResponse(text) {
215
+ if (!text) return [];
216
+ // Strip <think> reasoning blocks if present
217
+ const clean = text.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
218
+ const blocks = clean.split(/\n-{3,}\n?/).map((b) => b.trim()).filter(Boolean);
219
+ const recs = [];
220
+ for (const b of blocks) {
221
+ const get = (k) => {
222
+ const m = b.match(new RegExp(`${k}:\\s*([\\s\\S]+?)(?=\\n[A-Z]+:|$)`));
223
+ return m ? m[1].trim() : null;
224
+ };
225
+ const widget = get('WIDGET');
226
+ const issue = get('ISSUE');
227
+ const fix = get('FIX');
228
+ if (!widget && !issue && !fix) continue;
229
+ recs.push({ widget, issue, fix, code: get('CODE'), gain: get('GAIN') });
230
+ }
231
+ return recs;
232
+ }
233
+
234
+ async function getModuleAnalysis(agg) {
235
+ // ── Step 1: code-first triage (no CPU, no model) ────────────────────────────
236
+ // Deterministically score every widget and split into "needs the LLM" vs
237
+ // "already explained in code". Cleared widgets never reach the model.
238
+ const { escalate, cleared } = triage(agg);
239
+ const clearedNotes = cleared.map((c) => ({
240
+ widget: c.widget,
241
+ issue: c.note,
242
+ fix: null, code: null, gain: null,
243
+ source: 'code', // rendered without an LLM badge
244
+ }));
245
+
246
+ console.log(` 🧮 [triage] "${agg.module}" — ${escalate.length} widget(s) escalated to LLM, ${cleared.length} auto-cleared in code`);
247
+
248
+ // ── Step 2: skip the model entirely when nothing is worth its time ──────────
249
+ if (escalate.length === 0) {
250
+ return {
251
+ ok: true, source: 'code', raw: '',
252
+ llmUsed: false,
253
+ recommendations: clearedNotes.slice(0, 8),
254
+ triage: { escalated: 0, cleared: cleared.length },
255
+ };
256
+ }
257
+
258
+ if (!ENABLE_LOCAL) {
259
+ return {
260
+ ok: true, source: 'code', raw: '', llmUsed: false,
261
+ recommendations: clearedNotes.slice(0, 8),
262
+ note: 'Local AI disabled (ENABLE_LOCAL_AI=false) — showing code-only triage.',
263
+ triage: { escalated: escalate.length, cleared: cleared.length },
264
+ };
265
+ }
266
+
267
+ // ── Step 3: only the flagged widgets go to the local model ──────────────────
268
+ const prompt = buildModulePrompt(agg, escalate);
269
+ try {
270
+ const res = await fetch(`http://localhost:${LOCAL_AI_PORT}/v1/chat/completions`, {
271
+ method: 'POST',
272
+ headers: { 'Content-Type': 'application/json' },
273
+ body: JSON.stringify({
274
+ model: 'local',
275
+ messages: [{ role: 'user', content: prompt }],
276
+ max_tokens: 1400,
277
+ temperature: 0.15,
278
+ }),
279
+ timeout: 90000,
280
+ });
281
+ if (!res.ok) {
282
+ return { ok: true, source: 'code', llmUsed: false, error: `Local model HTTP ${res.status}`,
283
+ recommendations: clearedNotes.slice(0, 8), triage: { escalated: escalate.length, cleared: cleared.length } };
284
+ }
285
+ const data = await res.json();
286
+ const raw = data.choices?.[0]?.message?.content || '';
287
+ const llmRecs = parseModuleResponse(raw).map((r) => ({ ...r, source: 'local' }));
288
+ // LLM advice first (the widgets that mattered), then the code-cleared notes.
289
+ const recommendations = [...llmRecs, ...clearedNotes].slice(0, 12);
290
+ console.log(` 🤖 [local] module analysis for "${agg.module}" → ${llmRecs.length} LLM rec(s) + ${clearedNotes.length} code note(s)`);
291
+ return { ok: true, source: 'local', raw, llmUsed: true, recommendations,
292
+ triage: { escalated: escalate.length, cleared: cleared.length } };
293
+ } catch (err) {
294
+ const msg = err.code === 'ECONNREFUSED'
295
+ ? 'Local model not running — showing code-only triage. Start it with ./setup.sh + npm start.'
296
+ : err.message;
297
+ console.log(` ⚠️ module analysis LLM step failed: ${msg}`);
298
+ // Graceful degrade: still return the deterministic triage results.
299
+ return { ok: true, source: 'code', llmUsed: false, error: msg,
300
+ recommendations: clearedNotes.slice(0, 8), triage: { escalated: escalate.length, cleared: cleared.length } };
301
+ }
302
+ }
303
+
304
+ // ── Connected-device performance-review suggestions ──────────────────────────
305
+ // Smart LLM use: the score + issues are already computed in code. We only call
306
+ // the model to turn detected issues into concrete platform fixes. If there are
307
+ // no issues, we skip the model entirely and return a code-only "all clear".
308
+ function buildDevicePrompt({ app, module, deviceInfo, metrics, issues }) {
309
+ const issueLines = issues.map((i) => `- [${i.area}] ${i.text}`).join('\n');
310
+ const platform = deviceInfo.os || 'mobile';
311
+ return `You are a ${platform} performance expert reviewing the "${module}" flow of app ${app}.
312
+ Device: ${deviceInfo.brand} ${deviceInfo.model}, ${deviceInfo.os} ${deviceInfo.osVersion} (${deviceInfo.abi}).
313
+
314
+ Measured across the profiled iterations:
315
+ - Jank: ${metrics.jankPct}% of frames | p90 frame ${metrics.p90}ms, p99 ${metrics.p99}ms (budget 16.6ms)
316
+ - Memory: peak ${metrics.peakMemMb}MB, growth ${metrics.memGrowthMb}MB across runs
317
+ - CPU: ${metrics.cpuPct}% average
318
+
319
+ Detected issues:
320
+ ${issueLines}
321
+
322
+ For each issue, give ONE concrete ${platform} optimization. Respond with blocks in EXACTLY this format, nothing else:
323
+
324
+ TITLE: <short imperative fix, e.g. "Reduce overdraw in the list">
325
+ DETAIL: <one or two sentences — the specific ${platform} change and why it helps>
326
+ ---`;
327
+ }
328
+
329
+ function parseAdbSuggestions(text) {
330
+ if (!text) return [];
331
+ const clean = text.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
332
+ return clean.split(/\n-{3,}\n?/).map((b) => b.trim()).filter(Boolean).map((b) => {
333
+ const t = b.match(/TITLE:\s*([\s\S]+?)(?=\nDETAIL:|$)/i);
334
+ const d = b.match(/DETAIL:\s*([\s\S]+?)$/i);
335
+ return { title: t ? t[1].trim() : null, detail: d ? d[1].trim() : b.trim() };
336
+ }).filter((s) => s.title || s.detail);
337
+ }
338
+
339
+ async function getDeviceSuggestions(ctx) {
340
+ const codeSuggestions = ctx.issues.map((i) => ({ title: i.text, detail: null, source: 'code' }));
341
+
342
+ if (ctx.issues.length === 0) {
343
+ return { ok: true, llmUsed: false, source: 'code',
344
+ suggestions: [{ title: 'Within budget on all measured parameters — no changes needed.', detail: null, source: 'code' }] };
345
+ }
346
+ if (!ENABLE_LOCAL) {
347
+ return { ok: true, llmUsed: false, source: 'code', suggestions: codeSuggestions,
348
+ note: 'Local AI disabled — showing code-detected issues only.' };
349
+ }
350
+
351
+ const prompt = buildDevicePrompt(ctx);
352
+ try {
353
+ const res = await fetch(`http://localhost:${LOCAL_AI_PORT}/v1/chat/completions`, {
354
+ method: 'POST',
355
+ headers: { 'Content-Type': 'application/json' },
356
+ body: JSON.stringify({ model: 'local', messages: [{ role: 'user', content: prompt }], max_tokens: 1200, temperature: 0.15 }),
357
+ timeout: 90000,
358
+ });
359
+ if (!res.ok) return { ok: true, llmUsed: false, source: 'code', suggestions: codeSuggestions, error: `Local model HTTP ${res.status}` };
360
+ const data = await res.json();
361
+ const raw = data.choices?.[0]?.message?.content || '';
362
+ const llm = parseAdbSuggestions(raw).map((s) => ({ ...s, source: 'local' }));
363
+ console.log(` 🤖 [local] device review "${ctx.module}" → ${llm.length} suggestion(s)`);
364
+ return { ok: true, llmUsed: llm.length > 0, source: llm.length ? 'local' : 'code',
365
+ suggestions: llm.length ? llm : codeSuggestions, raw };
366
+ } catch (err) {
367
+ const msg = err.code === 'ECONNREFUSED' ? 'Local model not running — showing code-detected issues only.' : err.message;
368
+ console.log(` ⚠️ device review LLM step failed: ${msg}`);
369
+ return { ok: true, llmUsed: false, source: 'code', suggestions: codeSuggestions, error: msg };
370
+ }
371
+ }
372
+
373
+ module.exports = { getAISuggestion, getModuleAnalysis, getDeviceSuggestions, aiStatus };