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.
- package/.env.example +33 -0
- package/README.md +226 -0
- package/adapters/android/build.gradle +28 -0
- package/adapters/android/src/main/kotlin/dev/perfanalyzer/ChoreographerAdapter.kt +43 -0
- package/adapters/android/src/main/kotlin/dev/perfanalyzer/LogcatReader.kt +64 -0
- package/adapters/android/src/main/kotlin/dev/perfanalyzer/MemoryObserver.kt +46 -0
- package/adapters/android/src/main/kotlin/dev/perfanalyzer/OkHttpInterceptor.kt +53 -0
- package/adapters/android/src/main/kotlin/dev/perfanalyzer/PerfAnalyzer.kt +100 -0
- package/adapters/flutter/lib/perf_analyzer.dart +73 -0
- package/adapters/flutter/lib/src/frame_observer.dart +45 -0
- package/adapters/flutter/lib/src/network_observer.dart +72 -0
- package/adapters/flutter/lib/src/rebuild_observer.dart +57 -0
- package/adapters/flutter/lib/src/universal_event.dart +50 -0
- package/adapters/flutter/lib/src/vm_service_adapter.dart +81 -0
- package/adapters/flutter/pubspec.lock +184 -0
- package/adapters/flutter/pubspec.yaml +13 -0
- package/config/model.manifest.example.json +108 -0
- package/config/model.manifest.json +108 -0
- package/core/schema/universal_event.dart +63 -0
- package/dashboard/dist/assets/index-D5TCSsvB.js +107 -0
- package/dashboard/dist/index.html +118 -0
- package/package.json +62 -0
- package/scripts/encrypt-model.js +86 -0
- package/scripts/fill-manifest.js +67 -0
- package/scripts/load-env.js +20 -0
- package/scripts/model_crypto.js +14 -0
- package/scripts/setup-model.js +269 -0
- package/server/adb_bridge.js +201 -0
- package/server/adb_score.js +77 -0
- package/server/ai_proxy.js +373 -0
- package/server/analysis_engine.js +222 -0
- package/server/android_bridge.js +124 -0
- package/server/android_live.js +167 -0
- package/server/data/device_gpu_db.json +465 -0
- package/server/device_db.js +94 -0
- package/server/device_review.js +146 -0
- package/server/finding_generator.js +56 -0
- package/server/flutter_bridge.js +414 -0
- package/server/index.js +619 -0
- package/server/ios_bridge.js +181 -0
- package/server/model_config.js +54 -0
- package/server/report_template.js +169 -0
- package/server/session_manager.js +370 -0
- package/server/widget_gpu_classifier.js +77 -0
- package/server/widget_triage.js +78 -0
- package/setup.sh +160 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>ParityFix — AI Realtime Performance Analyzer</title>
|
|
7
|
+
<style>
|
|
8
|
+
/* ── ParityFix design tokens ─────────────────────────────────────────
|
|
9
|
+
Brand palette: #ffffff #474554 #aca7cb #ff00e9 #ff00d5 */
|
|
10
|
+
:root {
|
|
11
|
+
--pf-bg: #18171f;
|
|
12
|
+
--pf-surface: #201f2a;
|
|
13
|
+
--pf-surface-2: #2a2836;
|
|
14
|
+
--pf-surface-3: #474554;
|
|
15
|
+
--pf-ink: #474554;
|
|
16
|
+
--pf-border: #3a3848;
|
|
17
|
+
--pf-border-soft: #2d2b38;
|
|
18
|
+
|
|
19
|
+
--pf-text-hi: #ffffff;
|
|
20
|
+
--pf-text: #aca7cb;
|
|
21
|
+
--pf-text-2: #c6c2de;
|
|
22
|
+
--pf-muted: #8b86a6;
|
|
23
|
+
--pf-faint: #615d76;
|
|
24
|
+
|
|
25
|
+
--pf-accent: #ff00e9; /* brand magenta — primary accent */
|
|
26
|
+
--pf-accent-2: #ff00d5; /* brand magenta — hover partner */
|
|
27
|
+
--pf-teal: #ff00e9; /* alias: primary accent */
|
|
28
|
+
--pf-sage: #46d6a0; /* good / success */
|
|
29
|
+
--pf-warn: #f2c14e; /* medium */
|
|
30
|
+
--pf-high: #ff9457; /* high */
|
|
31
|
+
--pf-danger: #ff4d6d; /* critical */
|
|
32
|
+
|
|
33
|
+
--pf-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, sans-serif;
|
|
34
|
+
--pf-mono: 'SF Mono', ui-monospace, 'Fira Code', Menlo, monospace;
|
|
35
|
+
--pf-radius: 10px;
|
|
36
|
+
--pf-shadow: 0 1px 2px rgba(0,0,0,.3), 0 4px 18px rgba(0,0,0,.22);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
40
|
+
html, body, #root { height: 100%; }
|
|
41
|
+
body {
|
|
42
|
+
background: var(--pf-bg);
|
|
43
|
+
color: var(--pf-text);
|
|
44
|
+
font-family: var(--pf-font);
|
|
45
|
+
-webkit-font-smoothing: antialiased;
|
|
46
|
+
font-size: 14px;
|
|
47
|
+
}
|
|
48
|
+
code, pre, .num { font-family: var(--pf-mono); }
|
|
49
|
+
|
|
50
|
+
::-webkit-scrollbar { width: 8px; height: 8px; }
|
|
51
|
+
::-webkit-scrollbar-track { background: transparent; }
|
|
52
|
+
::-webkit-scrollbar-thumb { background: var(--pf-ink); border-radius: 4px; }
|
|
53
|
+
::-webkit-scrollbar-thumb:hover { background: var(--pf-muted); }
|
|
54
|
+
|
|
55
|
+
button { font-family: inherit; }
|
|
56
|
+
button:focus-visible, input:focus-visible { outline: 2px solid var(--pf-teal); outline-offset: 1px; }
|
|
57
|
+
input::placeholder { color: var(--pf-faint); }
|
|
58
|
+
|
|
59
|
+
/* ── Shared component classes ─────────────────────────────────────── */
|
|
60
|
+
.pf-panel {
|
|
61
|
+
background: var(--pf-surface);
|
|
62
|
+
border: 1px solid var(--pf-border-soft);
|
|
63
|
+
border-radius: var(--pf-radius);
|
|
64
|
+
box-shadow: var(--pf-shadow);
|
|
65
|
+
}
|
|
66
|
+
.pf-panel-title {
|
|
67
|
+
font-size: 11px; font-weight: 700; letter-spacing: .08em;
|
|
68
|
+
text-transform: uppercase; color: var(--pf-text-2);
|
|
69
|
+
}
|
|
70
|
+
.pf-badge {
|
|
71
|
+
display: inline-flex; align-items: center; gap: 4px;
|
|
72
|
+
font-size: 10px; font-weight: 700; letter-spacing: .04em;
|
|
73
|
+
padding: 2px 8px; border-radius: 999px; text-transform: uppercase;
|
|
74
|
+
white-space: nowrap;
|
|
75
|
+
}
|
|
76
|
+
.pf-btn {
|
|
77
|
+
border-radius: 8px; border: 1px solid var(--pf-border);
|
|
78
|
+
background: var(--pf-surface-2); color: var(--pf-text);
|
|
79
|
+
font-size: 13px; font-weight: 600; padding: 7px 14px; cursor: pointer;
|
|
80
|
+
transition: background .15s, border-color .15s, color .15s;
|
|
81
|
+
}
|
|
82
|
+
.pf-btn:hover:not(:disabled) { background: var(--pf-surface-3); border-color: var(--pf-ink); }
|
|
83
|
+
.pf-btn:disabled { opacity: .45; cursor: default; }
|
|
84
|
+
.pf-btn-primary {
|
|
85
|
+
background: var(--pf-accent); border-color: var(--pf-accent); color: #ffffff; font-weight: 700;
|
|
86
|
+
}
|
|
87
|
+
.pf-btn-primary:hover:not(:disabled) { background: var(--pf-accent-2); border-color: var(--pf-accent-2); }
|
|
88
|
+
.pf-input {
|
|
89
|
+
width: 100%; background: var(--pf-bg); border: 1px solid var(--pf-border);
|
|
90
|
+
border-radius: 8px; padding: 9px 12px; color: var(--pf-text-hi);
|
|
91
|
+
font-size: 14px; font-family: inherit;
|
|
92
|
+
}
|
|
93
|
+
.pf-input:focus { border-color: var(--pf-teal); outline: none; }
|
|
94
|
+
|
|
95
|
+
.pf-chip {
|
|
96
|
+
border: 1px solid var(--pf-border); background: transparent;
|
|
97
|
+
color: var(--pf-text-2); font-size: 11px; font-weight: 600;
|
|
98
|
+
padding: 4px 11px; border-radius: 999px; cursor: pointer;
|
|
99
|
+
transition: all .15s;
|
|
100
|
+
}
|
|
101
|
+
.pf-chip:hover { border-color: var(--pf-muted); color: var(--pf-text-hi); }
|
|
102
|
+
.pf-chip.on { background: var(--pf-ink); border-color: var(--pf-muted); color: var(--pf-text-hi); }
|
|
103
|
+
|
|
104
|
+
.pf-empty {
|
|
105
|
+
padding: 26px 16px; text-align: center;
|
|
106
|
+
color: var(--pf-faint); font-size: 12.5px;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
@keyframes pf-pulse { 0%,100% { opacity: 1 } 50% { opacity: .45 } }
|
|
110
|
+
@keyframes pf-blink { 0%,100% { opacity: 1 } 50% { opacity: .3 } }
|
|
111
|
+
.pf-pulse { animation: pf-pulse 1.6s ease-in-out infinite; }
|
|
112
|
+
</style>
|
|
113
|
+
<script type="module" crossorigin src="/assets/index-D5TCSsvB.js"></script>
|
|
114
|
+
</head>
|
|
115
|
+
<body>
|
|
116
|
+
<div id="root"></div>
|
|
117
|
+
</body>
|
|
118
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "letfixit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "LetFixIt — AI-based realtime performance analysis & suggestions for Flutter, Android & iOS, powered by the local Ultron n0-beta model.",
|
|
5
|
+
"keywords": ["flutter", "android", "ios", "performance", "profiler", "devtools", "observability", "llm", "local-ai"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "JahidDev24",
|
|
8
|
+
"main": "server/index.js",
|
|
9
|
+
"bin": {
|
|
10
|
+
"letfixit": "server/index.js"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/JahidDev24/ultron-LLM-Coder.git"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/JahidDev24/ultron-LLM-Coder#readme",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/JahidDev24/ultron-LLM-Coder/issues"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"registry": "https://registry.npmjs.org",
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"server/*.js",
|
|
29
|
+
"server/data/**",
|
|
30
|
+
"scripts/*.js",
|
|
31
|
+
"dashboard/dist/**",
|
|
32
|
+
"adapters/android/**/*.kt",
|
|
33
|
+
"adapters/android/**/*.gradle",
|
|
34
|
+
"adapters/flutter/lib/**/*.dart",
|
|
35
|
+
"adapters/flutter/pubspec.yaml",
|
|
36
|
+
"adapters/flutter/pubspec.lock",
|
|
37
|
+
"core/**/*.dart",
|
|
38
|
+
"config/model.manifest.json",
|
|
39
|
+
"config/model.manifest.example.json",
|
|
40
|
+
"setup.sh",
|
|
41
|
+
".env.example",
|
|
42
|
+
"README.md"
|
|
43
|
+
],
|
|
44
|
+
"scripts": {
|
|
45
|
+
"start": "node server/index.js",
|
|
46
|
+
"dev": "concurrently \"node server/index.js\" \"cd dashboard && npm run dev\"",
|
|
47
|
+
"build": "cd dashboard && npm run build",
|
|
48
|
+
"setup": "node scripts/setup-model.js",
|
|
49
|
+
"setup:force": "node scripts/setup-model.js --force",
|
|
50
|
+
"encrypt-model": "node scripts/encrypt-model.js",
|
|
51
|
+
"fill-manifest": "node scripts/fill-manifest.js",
|
|
52
|
+
"postinstall": "node scripts/setup-model.js",
|
|
53
|
+
"prepublishOnly": "npm run build"
|
|
54
|
+
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"dotenv": "^16.3.1",
|
|
57
|
+
"express": "^4.18.2",
|
|
58
|
+
"node-fetch": "^2.7.0",
|
|
59
|
+
"uuid": "^9.0.0",
|
|
60
|
+
"ws": "^8.14.2"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ── Maintainer tool: split + encrypt a model into 3 chunks ────────────────────
|
|
3
|
+
// Produces the encrypted chunks + a manifest for release. You then upload the
|
|
4
|
+
// chunk files (Drive / GitHub Release / any CDN), paste their URLs into the
|
|
5
|
+
// manifest, and ship the manifest — but NEVER the key. The key stays in your
|
|
6
|
+
// environment (and in GitHub Actions secrets for CI releases).
|
|
7
|
+
//
|
|
8
|
+
// PARITYFIX_MODEL_KEY=<passphrase> node scripts/encrypt-model.js <source.gguf> [outDir]
|
|
9
|
+
//
|
|
10
|
+
// Output (default outDir = dist/model-chunks/):
|
|
11
|
+
// chunk.0.enc chunk.1.enc chunk.2.enc ← upload these
|
|
12
|
+
// model.manifest.json ← fill in each chunk's `url`, then copy to config/
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
const { pipeline } = require('stream/promises');
|
|
17
|
+
require('./load-env')(); // pull PARITYFIX_MODEL_KEY etc. from .env (root has no dotenv)
|
|
18
|
+
const mc = require('../server/model_config');
|
|
19
|
+
const { ALGO, deriveKey } = require('./model_crypto');
|
|
20
|
+
|
|
21
|
+
async function sha256File(p) {
|
|
22
|
+
const h = crypto.createHash('sha256');
|
|
23
|
+
await pipeline(fs.createReadStream(p), async function* (src) { for await (const d of src) h.update(d); yield; });
|
|
24
|
+
return h.digest('hex');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function main() {
|
|
28
|
+
const src = process.argv[2];
|
|
29
|
+
const outDir = path.resolve(process.argv[3] || 'dist/model-chunks');
|
|
30
|
+
const key = process.env.PARITYFIX_MODEL_KEY;
|
|
31
|
+
|
|
32
|
+
if (!src || !fs.existsSync(src)) { console.error('usage: PARITYFIX_MODEL_KEY=… node scripts/encrypt-model.js <source.gguf> [outDir]'); process.exit(1); }
|
|
33
|
+
if (!key) { console.error('❌ PARITYFIX_MODEL_KEY is not set (the decryption passphrase). Never commit it.'); process.exit(1); }
|
|
34
|
+
|
|
35
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
36
|
+
const total = fs.statSync(src).size;
|
|
37
|
+
// Size-based: as many chunks as needed so each is ≤ CHUNK_MAX_BYTES, then made
|
|
38
|
+
// equal — base size each with the remainder spread one byte at a time, so all
|
|
39
|
+
// parts differ by at most 1 byte and every part stays under the cap.
|
|
40
|
+
const n = Math.max(1, Math.ceil(total / mc.CHUNK_MAX_BYTES));
|
|
41
|
+
const base = Math.floor(total / n);
|
|
42
|
+
const rem = total % n;
|
|
43
|
+
const salt = crypto.randomBytes(16).toString('hex');
|
|
44
|
+
const derivedKey = deriveKey(key, salt);
|
|
45
|
+
|
|
46
|
+
console.log(`\n Encrypting ${path.basename(src)} (${(total / 1024 ** 3).toFixed(2)} GB) → ${n} chunks of ~${(base / 1048576).toFixed(0)} MB (cap ${(mc.CHUNK_MAX_BYTES / 1048576).toFixed(0)} MB) in ${outDir}\n`);
|
|
47
|
+
const chunks = [];
|
|
48
|
+
let start = 0;
|
|
49
|
+
for (let i = 0; i < n; i++) {
|
|
50
|
+
const size = base + (i < rem ? 1 : 0);
|
|
51
|
+
const end = start + size - 1; // inclusive range for createReadStream
|
|
52
|
+
if (start > end) break;
|
|
53
|
+
const iv = crypto.randomBytes(12);
|
|
54
|
+
const cipher = crypto.createCipheriv(ALGO, derivedKey, iv);
|
|
55
|
+
const encFile = path.join(outDir, `chunk.${String(i).padStart(2, '0')}.enc`);
|
|
56
|
+
await pipeline(fs.createReadStream(src, { start, end }), cipher, fs.createWriteStream(encFile));
|
|
57
|
+
const authTag = cipher.getAuthTag().toString('hex');
|
|
58
|
+
const sha = await sha256File(encFile);
|
|
59
|
+
chunks.push({
|
|
60
|
+
index: i,
|
|
61
|
+
file: path.basename(encFile),
|
|
62
|
+
url: `REPLACE_WITH_DOWNLOAD_URL_FOR_${path.basename(encFile)}`,
|
|
63
|
+
mirrors: [], // optional extra sources for this chunk
|
|
64
|
+
iv: iv.toString('hex'),
|
|
65
|
+
authTag,
|
|
66
|
+
sha256: sha,
|
|
67
|
+
bytes: end - start + 1,
|
|
68
|
+
});
|
|
69
|
+
console.log(` ✓ chunk ${i}: ${((end - start + 1) / 1048576).toFixed(1)} MB → ${encFile}`);
|
|
70
|
+
start = end + 1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const manifest = { name: mc.MODEL_NAME, algorithm: ALGO, salt, sha256: await sha256File(src), chunks };
|
|
74
|
+
const manifestPath = path.join(outDir, 'model.manifest.json');
|
|
75
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
76
|
+
|
|
77
|
+
console.log(`\n ✅ Wrote ${manifestPath} (${chunks.length} chunks)`);
|
|
78
|
+
console.log(' Next:');
|
|
79
|
+
console.log(' 1) Upload the chunk.*.enc files — ideally SPREAD across sources (GitHub Release + a CDN/bucket).');
|
|
80
|
+
console.log(' 2) Put each direct-download URL into the manifest\'s `url`; add extra hosts to `mirrors` for redundancy.');
|
|
81
|
+
console.log(' 3) Prefer hosts that support HTTP range requests so installs resume after a dropped connection.');
|
|
82
|
+
console.log(' 4) Copy the manifest to config/model.manifest.json (git-ignored).');
|
|
83
|
+
console.log(' 5) Store PARITYFIX_MODEL_KEY as a GitHub Actions secret — never commit it.\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
main().catch((e) => { console.error('❌', e.message); process.exit(1); });
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ── Manifest assembler ────────────────────────────────────────────────────────
|
|
3
|
+
// Turns the generated dist/model-chunks/model.manifest.json into a ready-to-ship
|
|
4
|
+
// config/model.manifest.json by filling in each chunk's download `url` from your
|
|
5
|
+
// GitHub Release assets. Chunks below --split go to the part-1 tag, the rest to
|
|
6
|
+
// part-2 (matching the two-release, multi-source layout).
|
|
7
|
+
//
|
|
8
|
+
// node scripts/fill-manifest.js \
|
|
9
|
+
// --owner <you> --repo <repo> \
|
|
10
|
+
// --part1-tag model-v0-part1 --part2-tag model-v0-part2 [--split 5]
|
|
11
|
+
//
|
|
12
|
+
// Single-release variant: pass only --part1-tag (all chunks use it).
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const ROOT = path.join(__dirname, '..');
|
|
17
|
+
const SRC = path.join(ROOT, 'dist', 'model-chunks', 'model.manifest.json');
|
|
18
|
+
const OUT = path.join(ROOT, 'config', 'model.manifest.json');
|
|
19
|
+
|
|
20
|
+
function arg(name, def) {
|
|
21
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
22
|
+
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : def;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const owner = arg('owner');
|
|
26
|
+
const repo = arg('repo');
|
|
27
|
+
const part1 = arg('part1-tag');
|
|
28
|
+
const part2 = arg('part2-tag', part1); // default: one release for all
|
|
29
|
+
const split = parseInt(arg('split', '5'), 10); // chunks < split → part1
|
|
30
|
+
|
|
31
|
+
if (!owner || !repo || !part1) {
|
|
32
|
+
console.error('usage: node scripts/fill-manifest.js --owner <you> --repo <repo> --part1-tag <tag> [--part2-tag <tag>] [--split N]');
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
if (!fs.existsSync(SRC)) {
|
|
36
|
+
console.error(`❌ ${path.relative(ROOT, SRC)} not found. Run \`npm run encrypt-model -- <model.gguf>\` first.`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const src = JSON.parse(fs.readFileSync(SRC, 'utf8'));
|
|
41
|
+
const assetUrl = (tag, file) => `https://github.com/${owner}/${repo}/releases/download/${tag}/${file}`;
|
|
42
|
+
|
|
43
|
+
const chunks = src.chunks
|
|
44
|
+
.slice()
|
|
45
|
+
.sort((a, b) => a.index - b.index)
|
|
46
|
+
.map((ch) => {
|
|
47
|
+
const tag = ch.index < split ? part1 : part2;
|
|
48
|
+
return {
|
|
49
|
+
index: ch.index,
|
|
50
|
+
url: assetUrl(tag, ch.file),
|
|
51
|
+
mirrors: ch.mirrors && ch.mirrors.length ? ch.mirrors : [],
|
|
52
|
+
iv: ch.iv,
|
|
53
|
+
authTag: ch.authTag,
|
|
54
|
+
sha256: ch.sha256,
|
|
55
|
+
bytes: ch.bytes,
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const out = { name: src.name, algorithm: src.algorithm, salt: src.salt, sha256: src.sha256, chunks };
|
|
60
|
+
fs.mkdirSync(path.dirname(OUT), { recursive: true });
|
|
61
|
+
fs.writeFileSync(OUT, JSON.stringify(out, null, 2) + '\n');
|
|
62
|
+
|
|
63
|
+
console.log(`✅ Wrote ${path.relative(ROOT, OUT)} (${chunks.length} chunks)`);
|
|
64
|
+
const p1 = chunks.filter((c) => c.index < split).length;
|
|
65
|
+
console.log(` chunks 0–${split - 1} → ${part1} (${p1})`);
|
|
66
|
+
if (part2 !== part1) console.log(` chunks ${split}–${chunks.length - 1} → ${part2} (${chunks.length - p1})`);
|
|
67
|
+
console.log(' Verify the assets are reachable, then commit is NOT needed — this file is git-ignored.');
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Minimal, dependency-free .env loader for the root scripts (root has no
|
|
2
|
+
// node_modules, so we can't rely on the `dotenv` package here). Real environment
|
|
3
|
+
// variables and CI secrets always win — we only fill in what isn't already set.
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
module.exports = function loadEnv() {
|
|
8
|
+
const p = path.join(__dirname, '..', '.env');
|
|
9
|
+
if (!fs.existsSync(p)) return;
|
|
10
|
+
for (const raw of fs.readFileSync(p, 'utf8').split('\n')) {
|
|
11
|
+
const line = raw.trim();
|
|
12
|
+
if (!line || line.startsWith('#')) continue;
|
|
13
|
+
const eq = line.indexOf('=');
|
|
14
|
+
if (eq < 0) continue;
|
|
15
|
+
const key = line.slice(0, eq).trim();
|
|
16
|
+
let val = line.slice(eq + 1).trim();
|
|
17
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1);
|
|
18
|
+
if (process.env[key] === undefined) process.env[key] = val;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Shared crypto for the model chunk pipeline. AES-256-GCM with a scrypt-derived
|
|
2
|
+
// key. The passphrase (PARITYFIX_MODEL_KEY) is NEVER committed — it lives in an
|
|
3
|
+
// env var locally and in GitHub Actions secrets at release time. The salt is not
|
|
4
|
+
// secret and travels in the manifest.
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
|
|
7
|
+
const ALGO = 'aes-256-gcm';
|
|
8
|
+
|
|
9
|
+
function deriveKey(passphrase, saltHex) {
|
|
10
|
+
if (!passphrase) throw new Error('PARITYFIX_MODEL_KEY is not set');
|
|
11
|
+
return crypto.scryptSync(String(passphrase), Buffer.from(saltHex, 'hex'), 32);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
module.exports = { ALGO, deriveKey };
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ── Ultron n0-beta installer ──────────────────────────────────────────────────
|
|
3
|
+
// Downloads the 3 encrypted model chunks (interactive progress) → verifies →
|
|
4
|
+
// AES-GCM decrypts → merges → finalizes as bin/ultron/ultron-n0-beta.gguf →
|
|
5
|
+
// prints a friendly success + model disclaimer.
|
|
6
|
+
//
|
|
7
|
+
// Runs from `npm run setup` or as a postinstall step. It is NON-FATAL: if the
|
|
8
|
+
// chunk manifest or decryption key isn't configured, it prints instructions and
|
|
9
|
+
// exits cleanly so `npm install` never breaks.
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const crypto = require('crypto');
|
|
13
|
+
const https = require('https');
|
|
14
|
+
const http = require('http');
|
|
15
|
+
const { fileURLToPath } = require('url');
|
|
16
|
+
require('./load-env')(); // pull PARITYFIX_MODEL_KEY etc. from .env (root has no dotenv)
|
|
17
|
+
const mc = require('../server/model_config');
|
|
18
|
+
const { ALGO, deriveKey } = require('./model_crypto');
|
|
19
|
+
|
|
20
|
+
const C = { reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', mag: '\x1b[95m', grn: '\x1b[92m', yel: '\x1b[93m', red: '\x1b[91m', cyan: '\x1b[96m' };
|
|
21
|
+
const c = (col, s) => `${col}${s}${C.reset}`;
|
|
22
|
+
const isPostinstall = process.env.npm_lifecycle_event === 'postinstall';
|
|
23
|
+
const FORCE = process.argv.includes('--force');
|
|
24
|
+
|
|
25
|
+
const TMP = path.join(mc.MODEL_DIR, '.tmp');
|
|
26
|
+
const mb = (n) => (n / 1048576).toFixed(1);
|
|
27
|
+
|
|
28
|
+
// ── Interactive progress bar ────────────────────────────────────────────────
|
|
29
|
+
function drawBar(label, done, total) {
|
|
30
|
+
const width = 26;
|
|
31
|
+
const pct = total ? Math.min(1, done / total) : 0;
|
|
32
|
+
const filled = Math.round(pct * width);
|
|
33
|
+
const bar = c(C.mag, '█'.repeat(filled)) + c(C.dim, '░'.repeat(width - filled));
|
|
34
|
+
const size = total ? `${mb(done)}/${mb(total)} MB` : `${mb(done)} MB`;
|
|
35
|
+
process.stdout.write(`\r ${label} [${bar}] ${String(Math.round(pct * 100)).padStart(3)}% ${size} `);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
39
|
+
|
|
40
|
+
// ── Resumable download ────────────────────────────────────────────────────────
|
|
41
|
+
// Streams `urlStr` into `dest.part`, resuming via an HTTP Range request if a
|
|
42
|
+
// partial file already exists, then renames to `dest`. On mid-stream failure the
|
|
43
|
+
// `.part` is left in place so the next attempt continues from where it stopped.
|
|
44
|
+
// Supports http(s) (with redirects + Drive confirm) and file:// (for testing).
|
|
45
|
+
function download(urlStr, dest, label) {
|
|
46
|
+
const partPath = dest + '.part';
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
if (urlStr.startsWith('file://')) {
|
|
49
|
+
const src = fileURLToPath(urlStr);
|
|
50
|
+
const total = fs.statSync(src).size;
|
|
51
|
+
let done = 0;
|
|
52
|
+
const rs = fs.createReadStream(src);
|
|
53
|
+
const ws = fs.createWriteStream(partPath);
|
|
54
|
+
rs.on('data', (ch) => { done += ch.length; drawBar(label, done, total); });
|
|
55
|
+
rs.on('error', reject); ws.on('error', reject);
|
|
56
|
+
ws.on('finish', () => { process.stdout.write('\n'); fs.renameSync(partPath, dest); resolve(); });
|
|
57
|
+
rs.pipe(ws);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const startAt = fs.existsSync(partPath) ? fs.statSync(partPath).size : 0;
|
|
62
|
+
|
|
63
|
+
const go = (u, redirects, cookie) => {
|
|
64
|
+
if (redirects > 6) return reject(new Error('too many redirects'));
|
|
65
|
+
const lib = u.startsWith('https') ? https : http;
|
|
66
|
+
const headers = {};
|
|
67
|
+
if (cookie) headers.cookie = cookie;
|
|
68
|
+
if (startAt > 0) headers.Range = `bytes=${startAt}-`; // resume
|
|
69
|
+
const req = lib.get(u, { headers }, (res) => {
|
|
70
|
+
const setCookie = res.headers['set-cookie']?.join('; ') || cookie;
|
|
71
|
+
if ([301, 302, 303, 307, 308].includes(res.statusCode)) {
|
|
72
|
+
res.resume();
|
|
73
|
+
return go(new URL(res.headers.location, u).toString(), redirects + 1, setCookie);
|
|
74
|
+
}
|
|
75
|
+
// Google Drive large-file interstitial: HTML page with a confirm token.
|
|
76
|
+
if ((res.headers['content-type'] || '').includes('text/html') && /google\.com|googleusercontent/.test(u)) {
|
|
77
|
+
let body = '';
|
|
78
|
+
res.on('data', (d) => (body += d));
|
|
79
|
+
res.on('end', () => {
|
|
80
|
+
const action = (body.match(/action="([^"]+download[^"]*)"/) || [])[1];
|
|
81
|
+
const tok = (body.match(/confirm=([0-9A-Za-z_-]+)/) || [])[1];
|
|
82
|
+
if (action) return go(action.replace(/&/g, '&'), redirects + 1, setCookie);
|
|
83
|
+
if (tok) { const sep = u.includes('?') ? '&' : '?'; return go(`${u}${sep}confirm=${tok}`, redirects + 1, setCookie); }
|
|
84
|
+
reject(new Error('Google Drive returned HTML (quota/private?). Use a GitHub Release/CDN URL that supports range requests.'));
|
|
85
|
+
});
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (![200, 206].includes(res.statusCode)) { res.resume(); return reject(new Error(`HTTP ${res.statusCode}`)); }
|
|
89
|
+
|
|
90
|
+
// 206 → server honored resume (append). 200 → full body (restart).
|
|
91
|
+
const resuming = res.statusCode === 206;
|
|
92
|
+
let done = resuming ? startAt : 0;
|
|
93
|
+
const len = parseInt(res.headers['content-length'] || '0', 10);
|
|
94
|
+
const total = resuming ? startAt + len : len;
|
|
95
|
+
const ws = fs.createWriteStream(partPath, { flags: resuming ? 'a' : 'w' });
|
|
96
|
+
res.on('data', (ch) => { done += ch.length; drawBar(label, done, total); });
|
|
97
|
+
res.on('error', reject); ws.on('error', reject);
|
|
98
|
+
ws.on('finish', () => { process.stdout.write('\n'); fs.renameSync(partPath, dest); resolve(); });
|
|
99
|
+
res.pipe(ws);
|
|
100
|
+
});
|
|
101
|
+
req.on('error', reject);
|
|
102
|
+
};
|
|
103
|
+
go(urlStr, 0, null);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Fetch one chunk, trying its url then each mirror, retrying (and resuming) on
|
|
108
|
+
// failure, and verifying the checksum before accepting it.
|
|
109
|
+
async function fetchChunk(ch, encPath, baseLabel) {
|
|
110
|
+
const sources = [ch.url, ...(ch.mirrors || [])].filter((u) => u && !/^REPLACE/.test(u));
|
|
111
|
+
if (!sources.length) throw new Error(`chunk ${ch.index} has no valid download URL in the manifest`);
|
|
112
|
+
|
|
113
|
+
let lastErr;
|
|
114
|
+
for (let s = 0; s < sources.length; s++) {
|
|
115
|
+
const label = sources.length > 1 ? `${baseLabel} ${c(C.dim, `(src ${s + 1}/${sources.length})`)}` : baseLabel;
|
|
116
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
117
|
+
try {
|
|
118
|
+
await download(sources[s], encPath, label);
|
|
119
|
+
if (ch.sha256) {
|
|
120
|
+
const got = await sha256File(encPath);
|
|
121
|
+
if (got !== ch.sha256) { fs.rmSync(encPath, { force: true }); throw new Error('checksum mismatch (corrupt download)'); }
|
|
122
|
+
}
|
|
123
|
+
return; // success
|
|
124
|
+
} catch (e) {
|
|
125
|
+
lastErr = e;
|
|
126
|
+
const resumable = fs.existsSync(encPath + '.part');
|
|
127
|
+
console.log('\n ' + c(C.yel, `⚠ ${baseLabel} attempt ${attempt} failed: ${e.message}`) + c(C.dim, resumable ? ' — resuming…' : ' — retrying…'));
|
|
128
|
+
await sleep(1000 * attempt);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
throw lastErr || new Error(`chunk ${ch.index} download failed`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function sha256File(p) {
|
|
136
|
+
return new Promise((resolve, reject) => {
|
|
137
|
+
const h = crypto.createHash('sha256');
|
|
138
|
+
fs.createReadStream(p).on('data', (d) => h.update(d)).on('error', reject).on('end', () => resolve(h.digest('hex')));
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function decryptChunk(encPath, outPath, key, ivHex, authTagHex) {
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
const decipher = crypto.createDecipheriv(ALGO, key, Buffer.from(ivHex, 'hex'));
|
|
145
|
+
if (authTagHex) decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
|
|
146
|
+
const rs = fs.createReadStream(encPath);
|
|
147
|
+
const ws = fs.createWriteStream(outPath);
|
|
148
|
+
rs.on('error', reject); ws.on('error', reject); decipher.on('error', reject);
|
|
149
|
+
ws.on('finish', resolve);
|
|
150
|
+
rs.pipe(decipher).pipe(ws);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function mergeInto(parts, dest) {
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
156
|
+
const ws = fs.createWriteStream(dest);
|
|
157
|
+
ws.on('error', reject);
|
|
158
|
+
const next = (i) => {
|
|
159
|
+
if (i >= parts.length) return ws.end(resolve);
|
|
160
|
+
const rs = fs.createReadStream(parts[i]);
|
|
161
|
+
rs.on('error', reject);
|
|
162
|
+
rs.on('end', () => next(i + 1));
|
|
163
|
+
rs.pipe(ws, { end: false });
|
|
164
|
+
};
|
|
165
|
+
next(0);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function box(lines) {
|
|
170
|
+
const w = Math.max(...lines.map((l) => stripAnsi(l).length));
|
|
171
|
+
const top = ' ╔' + '═'.repeat(w + 2) + '╗';
|
|
172
|
+
const bot = ' ╚' + '═'.repeat(w + 2) + '╝';
|
|
173
|
+
console.log(c(C.mag, top));
|
|
174
|
+
for (const l of lines) console.log(c(C.mag, ' ║ ') + l + ' '.repeat(w - stripAnsi(l).length) + c(C.mag, ' ║'));
|
|
175
|
+
console.log(c(C.mag, bot));
|
|
176
|
+
}
|
|
177
|
+
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
178
|
+
|
|
179
|
+
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
180
|
+
async function setup() {
|
|
181
|
+
console.log(`\n ${c(C.bold + C.mag, '✦ Ultron n0-beta')} — local model setup\n`);
|
|
182
|
+
|
|
183
|
+
if (mc.isReady() && !FORCE) {
|
|
184
|
+
console.log(c(C.grn, ` ✅ ${mc.MODEL_NAME} already installed (${mc.sizeGB()} GB). Use --force to reinstall.\n`));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const manifest = mc.loadManifest();
|
|
189
|
+
const key = process.env.PARITYFIX_MODEL_KEY;
|
|
190
|
+
if (!manifest || !key) {
|
|
191
|
+
console.log(c(C.yel, ' ⚠️ Model chunks not configured — skipping automatic download.\n'));
|
|
192
|
+
if (!manifest) console.log(` • No chunk manifest found. Copy ${c(C.cyan, 'config/model.manifest.example.json')} → ${c(C.cyan, 'config/model.manifest.json')} and fill in the chunk URLs.`);
|
|
193
|
+
if (!key) console.log(` • Set ${c(C.cyan, 'PARITYFIX_MODEL_KEY')} (the decryption passphrase) in your environment.`);
|
|
194
|
+
console.log(`\n Then run ${c(C.cyan, 'npm run setup')} again. (You can also use the legacy ${c(C.cyan, './setup.sh')} to pull from HuggingFace.)\n`);
|
|
195
|
+
return; // non-fatal
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const chunks = [...manifest.chunks].sort((a, b) => a.index - b.index);
|
|
199
|
+
console.log(c(C.dim, ` ${chunks.length} chunk(s) to fetch — resumable, mirror-aware.\n`));
|
|
200
|
+
|
|
201
|
+
fs.mkdirSync(TMP, { recursive: true });
|
|
202
|
+
const derivedKey = deriveKey(key, manifest.salt);
|
|
203
|
+
const decParts = [];
|
|
204
|
+
|
|
205
|
+
for (const ch of chunks) {
|
|
206
|
+
const label = c(C.cyan, `Chunk ${ch.index + 1}/${chunks.length}`);
|
|
207
|
+
const encPath = path.join(TMP, `chunk.${String(ch.index).padStart(2, '0')}.enc`);
|
|
208
|
+
const decPath = path.join(TMP, `chunk.${String(ch.index).padStart(2, '0')}.dec`);
|
|
209
|
+
|
|
210
|
+
// Skip already-fetched-and-verified chunks (idempotent re-runs).
|
|
211
|
+
let haveValid = false;
|
|
212
|
+
if (fs.existsSync(encPath) && ch.sha256) haveValid = (await sha256File(encPath)) === ch.sha256;
|
|
213
|
+
if (!haveValid) await fetchChunk(ch, encPath, label);
|
|
214
|
+
|
|
215
|
+
process.stdout.write(` ${c(C.dim, `↳ decrypting chunk ${ch.index + 1}…`)}\r`);
|
|
216
|
+
await decryptChunk(encPath, decPath, derivedKey, ch.iv, ch.authTag);
|
|
217
|
+
process.stdout.write(' '.repeat(40) + '\r');
|
|
218
|
+
decParts.push(decPath);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
console.log(c(C.dim, ' ↳ merging chunks…'));
|
|
222
|
+
fs.mkdirSync(mc.MODEL_DIR, { recursive: true });
|
|
223
|
+
await mergeInto(decParts, mc.MODEL_PATH);
|
|
224
|
+
|
|
225
|
+
const size = fs.statSync(mc.MODEL_PATH).size;
|
|
226
|
+
if (size < mc.MIN_BYTES) throw new Error(`merged model is only ${mb(size)}MB — expected the full weights`);
|
|
227
|
+
if (manifest.sha256) {
|
|
228
|
+
const got = await sha256File(mc.MODEL_PATH);
|
|
229
|
+
if (got !== manifest.sha256) throw new Error('merged model checksum mismatch');
|
|
230
|
+
}
|
|
231
|
+
try { fs.chmodSync(mc.MODEL_PATH, 0o755); } catch (_) {}
|
|
232
|
+
fs.rmSync(TMP, { recursive: true, force: true });
|
|
233
|
+
|
|
234
|
+
console.log('');
|
|
235
|
+
box([
|
|
236
|
+
c(C.bold + C.grn, `✅ ${mc.MODEL_NAME} is ready`),
|
|
237
|
+
'',
|
|
238
|
+
c(C.reset, `Local model ${c(C.cyan, path.relative(process.cwd(), mc.MODEL_PATH))}`),
|
|
239
|
+
c(C.reset, `Size ${c(C.cyan, mc.sizeGB() + ' GB')}`),
|
|
240
|
+
c(C.reset, `Runs ${c(C.cyan, '100% on-device — private, no cloud needed')}`),
|
|
241
|
+
]);
|
|
242
|
+
console.log('');
|
|
243
|
+
console.log(' ' + c(C.dim, wrap(mc.DISCLAIMER, 76, ' ')));
|
|
244
|
+
console.log('');
|
|
245
|
+
console.log(` ${c(C.mag, '▸')} Start it with ${c(C.cyan, 'npm start')}\n`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Wrap long text to a width, indenting continuation lines.
|
|
249
|
+
function wrap(text, width, indent) {
|
|
250
|
+
const words = text.split(' ');
|
|
251
|
+
const lines = [];
|
|
252
|
+
let line = '';
|
|
253
|
+
for (const w of words) {
|
|
254
|
+
if ((line + ' ' + w).trim().length > width) { lines.push(line.trim()); line = w; }
|
|
255
|
+
else line += ' ' + w;
|
|
256
|
+
}
|
|
257
|
+
if (line.trim()) lines.push(line.trim());
|
|
258
|
+
return lines.join('\n' + indent);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (require.main === module) {
|
|
262
|
+
setup().catch((err) => {
|
|
263
|
+
console.log('\n' + c(C.red, ` ❌ Model setup failed: ${err.message}`));
|
|
264
|
+
console.log(c(C.dim, ' You can retry with `npm run setup`, or use ./setup.sh for the HuggingFace source.\n'));
|
|
265
|
+
process.exit(isPostinstall ? 0 : 1); // never break `npm install`
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
module.exports = { download, fetchChunk, decryptChunk, mergeInto, sha256File, setup };
|