termux-vision 1.0.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/lib/cache.js ADDED
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Model Cache Management and Artifact Discovery for Node.js.
3
+ * Open-Source under Apache License 2.0.
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const os = require('os');
11
+ const https = require('https');
12
+ const http = require('http');
13
+ const crypto = require('crypto');
14
+
15
+ const {
16
+ ModelNotFoundError,
17
+ NoInstalledModelsError,
18
+ ModelDownloadError
19
+ } = require('./errors');
20
+
21
+ const CATALOG = {
22
+ 'smolvlm-500m-q4': {
23
+ modelId: 'smolvlm-500m-q4',
24
+ adapter: 'smolvlm',
25
+ tier: 'M',
26
+ estimatedMemoryMb: 750,
27
+ contextLimit: 1024,
28
+ preferredResolution: 384,
29
+ artifacts: [
30
+ {
31
+ role: 'language_model',
32
+ filename: 'smolvlm-500m-instruct-q4_k_m.gguf',
33
+ sizeBytes: 350000000,
34
+ downloadUrl: 'https://huggingface.co/HuggingFaceTB/SmolVLM-500M-Instruct-GGUF/resolve/main/smolvlm-500m-instruct-q4_k_m.gguf'
35
+ },
36
+ {
37
+ role: 'vision_projector',
38
+ filename: 'mmproj-smolvlm-500m-instruct-f16.gguf',
39
+ sizeBytes: 200000000,
40
+ downloadUrl: 'https://huggingface.co/HuggingFaceTB/SmolVLM-500M-Instruct-GGUF/resolve/main/mmproj-smolvlm-500m-instruct-f16.gguf'
41
+ }
42
+ ]
43
+ },
44
+ 'qwen2-vl-2b-q4': {
45
+ modelId: 'qwen2-vl-2b-q4',
46
+ adapter: 'qwen2vl',
47
+ tier: 'L',
48
+ estimatedMemoryMb: 2100,
49
+ contextLimit: 1024,
50
+ preferredResolution: 384,
51
+ artifacts: [
52
+ {
53
+ role: 'language_model',
54
+ filename: 'Qwen2-VL-2B-Instruct-Q4_K_M.gguf',
55
+ sizeBytes: 986046944,
56
+ downloadUrl: 'https://huggingface.co/second-state/Qwen2-VL-2B-Instruct-GGUF/resolve/main/Qwen2-VL-2B-Instruct-Q4_K_M.gguf'
57
+ },
58
+ {
59
+ role: 'vision_projector',
60
+ filename: 'Qwen2-VL-2B-Instruct-vision-encoder.gguf',
61
+ sizeBytes: 2600000000,
62
+ downloadUrl: 'https://huggingface.co/second-state/Qwen2-VL-2B-Instruct-GGUF/resolve/main/Qwen2-VL-2B-Instruct-vision-encoder.gguf'
63
+ }
64
+ ]
65
+ }
66
+ };
67
+
68
+ class ModelCacheManager {
69
+ constructor(customCacheRoot = null) {
70
+ this.cacheRoot = customCacheRoot || path.join(os.homedir(), '.cache', 'termux-vision');
71
+ this.legacyCache = path.join(os.homedir(), '.cache', 'vlm_models');
72
+ this.modelsDir = path.join(this.cacheRoot, 'models');
73
+ this.downloadsDir = path.join(this.cacheRoot, 'downloads');
74
+
75
+ fs.mkdirSync(this.modelsDir, { recursive: true });
76
+ fs.mkdirSync(this.downloadsDir, { recursive: true });
77
+ }
78
+
79
+ getAvailableCatalogModels() {
80
+ return Object.keys(CATALOG).sort();
81
+ }
82
+
83
+ isModelInstalled(modelId) {
84
+ const mdir = path.join(this.modelsDir, modelId);
85
+ if (!fs.existsSync(mdir)) return false;
86
+
87
+ // Check ready marker or custom gguf + mmproj
88
+ const readyMarker = path.join(mdir, 'READY');
89
+ if (fs.existsSync(readyMarker)) return true;
90
+
91
+ if (fs.statSync(mdir).isDirectory()) {
92
+ const files = fs.readdirSync(mdir);
93
+ const ggufs = files.filter(f => f.endsWith('.gguf'));
94
+ const visionGgufs = ggufs.filter(f => /mmproj|encoder|projector/i.test(f));
95
+ const textGgufs = ggufs.filter(f => !/mmproj|encoder|projector/i.test(f));
96
+ if ((textGgufs.length > 0 && visionGgufs.length > 0) || ggufs.length >= 2) {
97
+ return true;
98
+ }
99
+ }
100
+ return false;
101
+ }
102
+
103
+ listInstalled() {
104
+ const results = [];
105
+ if (fs.existsSync(this.modelsDir)) {
106
+ const entries = fs.readdirSync(this.modelsDir);
107
+ for (const name of entries) {
108
+ const mdir = path.join(this.modelsDir, name);
109
+ if (fs.statSync(mdir).isDirectory() && this.isModelInstalled(name)) {
110
+ let totalBytes = 0;
111
+ const files = fs.readdirSync(mdir);
112
+ for (const f of files) {
113
+ try {
114
+ totalBytes += fs.statSync(path.join(mdir, f)).size;
115
+ } catch (e) {}
116
+ }
117
+ const tier = CATALOG[name] ? CATALOG[name].tier : 'CUSTOM';
118
+ results.push({
119
+ modelId: name,
120
+ tier: tier,
121
+ state: 'READY',
122
+ sizeMb: Math.round(totalBytes / (1024 * 1024)),
123
+ path: mdir
124
+ });
125
+ }
126
+ }
127
+ }
128
+
129
+ if (fs.existsSync(this.legacyCache)) {
130
+ for (const [key, val] of Object.entries(CATALOG)) {
131
+ if (!results.find(r => r.modelId === key)) {
132
+ const allFound = val.artifacts.every(a => fs.existsSync(path.join(this.legacyCache, a.filename)));
133
+ if (allFound) {
134
+ results.push({
135
+ modelId: key,
136
+ tier: val.tier,
137
+ state: 'READY',
138
+ sizeMb: Math.round(val.artifacts.reduce((acc, a) => acc + (a.sizeBytes || 0), 0) / (1024 * 1024)),
139
+ path: this.legacyCache
140
+ });
141
+ }
142
+ }
143
+ }
144
+ }
145
+ return results;
146
+ }
147
+
148
+ requireInstalledModel(modelId, mmprojPath = null) {
149
+ // 1. Direct file path check (Custom 싸제 GGUF)
150
+ const expanded = path.resolve(modelId.replace(/^~(?=$|\/|\\)/, os.homedir()));
151
+ if (fs.existsSync(expanded) && fs.statSync(expanded).isFile()) {
152
+ const dir = path.dirname(expanded);
153
+ const filename = path.basename(expanded);
154
+ let visionPath = mmprojPath ? path.resolve(mmprojPath.replace(/^~(?=$|\/|\\)/, os.homedir())) : null;
155
+ if (!visionPath) {
156
+ const files = fs.readdirSync(dir);
157
+ const candidate = files.find(f => f.endsWith('.gguf') && /mmproj|encoder|projector/i.test(f));
158
+ if (candidate) visionPath = path.join(dir, candidate);
159
+ }
160
+ return {
161
+ manifest: {
162
+ modelId: path.parse(filename).name,
163
+ adapter: /smol/i.test(filename) ? 'smolvlm' : 'qwen2vl',
164
+ tier: 'CUSTOM',
165
+ contextLimit: 1024
166
+ },
167
+ textModelPath: expanded,
168
+ visionModelPath: visionPath
169
+ };
170
+ }
171
+
172
+ // 2. Installed model search
173
+ const installed = this.listInstalled();
174
+ const installedIds = installed.map(m => m.modelId);
175
+
176
+ if (installedIds.length === 0) {
177
+ throw new NoInstalledModelsError(this.getAvailableCatalogModels());
178
+ }
179
+
180
+ if (!installedIds.includes(modelId)) {
181
+ throw new ModelNotFoundError(modelId, installedIds, this.getAvailableCatalogModels());
182
+ }
183
+
184
+ const mdir = path.join(this.modelsDir, modelId);
185
+ let textPath = null;
186
+ let visionPath = mmprojPath ? path.resolve(mmprojPath) : null;
187
+
188
+ if (CATALOG[modelId]) {
189
+ const cat = CATALOG[modelId];
190
+ for (const a of cat.artifacts) {
191
+ const p = path.join(mdir, a.filename);
192
+ if (fs.existsSync(p)) {
193
+ if (a.role === 'language_model') textPath = p;
194
+ if (a.role === 'vision_projector') visionPath = p;
195
+ }
196
+ }
197
+ return {
198
+ manifest: cat,
199
+ textModelPath: textPath,
200
+ visionModelPath: visionPath
201
+ };
202
+ }
203
+
204
+ // Custom directory
205
+ const files = fs.readdirSync(mdir);
206
+ const ggufs = files.filter(f => f.endsWith('.gguf'));
207
+ const visionFiles = ggufs.filter(f => /mmproj|encoder|projector/i.test(f));
208
+ const textFiles = ggufs.filter(f => !/mmproj|encoder|projector/i.test(f));
209
+
210
+ textPath = textFiles.length > 0 ? path.join(mdir, textFiles[0]) : (ggufs.length > 0 ? path.join(mdir, ggufs[0]) : null);
211
+ if (!visionPath) {
212
+ visionPath = visionFiles.length > 0 ? path.join(mdir, visionFiles[0]) : (ggufs.length > 1 ? path.join(mdir, ggufs[1]) : null);
213
+ }
214
+
215
+ return {
216
+ manifest: {
217
+ modelId: modelId,
218
+ adapter: /smol/i.test(modelId) ? 'smolvlm' : 'qwen2vl',
219
+ tier: 'CUSTOM',
220
+ contextLimit: 1024
221
+ },
222
+ textModelPath: textPath,
223
+ visionModelPath: visionPath
224
+ };
225
+ }
226
+
227
+ async downloadFile(url, destPath, onProgress = null) {
228
+ const partialPath = destPath + '.partial';
229
+ if (fs.existsSync(partialPath)) fs.unlinkSync(partialPath);
230
+
231
+ return new Promise((resolve, reject) => {
232
+ const getFollow = (currentUrl) => {
233
+ const client = currentUrl.startsWith('https') ? https : http;
234
+ const req = client.get(currentUrl, { headers: { 'User-Agent': 'Mozilla/5.0 (termux-vision node)' } }, (res) => {
235
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
236
+ return getFollow(res.headers.location);
237
+ }
238
+ if (res.statusCode !== 200) {
239
+ return reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
240
+ }
241
+ const total = parseInt(res.headers['content-length'] || '0', 10);
242
+ let downloaded = 0;
243
+ const out = fs.createWriteStream(partialPath);
244
+
245
+ res.on('data', (chunk) => {
246
+ downloaded += chunk.length;
247
+ if (onProgress) onProgress(path.basename(destPath), downloaded, total);
248
+ });
249
+
250
+ res.pipe(out);
251
+ out.on('finish', () => {
252
+ out.close(() => {
253
+ if (fs.existsSync(destPath)) fs.unlinkSync(destPath);
254
+ fs.renameSync(partialPath, destPath);
255
+ resolve();
256
+ });
257
+ });
258
+ });
259
+ req.on('error', (err) => {
260
+ if (fs.existsSync(partialPath)) try { fs.unlinkSync(partialPath); } catch (e) {}
261
+ reject(err);
262
+ });
263
+ };
264
+ getFollow(url);
265
+ });
266
+ }
267
+
268
+ async install(modelId, onProgress = null) {
269
+ if (!CATALOG[modelId]) {
270
+ const installed = this.listInstalled().map(m => m.modelId);
271
+ throw new ModelDownloadError(modelId, `Model '${modelId}' is not in catalog.`, installed);
272
+ }
273
+ const manifest = CATALOG[modelId];
274
+ const targetDir = path.join(this.modelsDir, modelId);
275
+ fs.mkdirSync(targetDir, { recursive: true });
276
+
277
+ for (const art of manifest.artifacts) {
278
+ const dest = path.join(targetDir, art.filename);
279
+ if (fs.existsSync(dest) && fs.statSync(dest).size > 0) continue;
280
+ await this.downloadFile(art.downloadUrl, dest, onProgress);
281
+ }
282
+
283
+ fs.writeFileSync(path.join(targetDir, 'READY'), 'OK\n', 'utf-8');
284
+ fs.writeFileSync(path.join(targetDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf-8');
285
+ }
286
+
287
+ remove(modelId) {
288
+ const mdir = path.join(this.modelsDir, modelId);
289
+ if (fs.existsSync(mdir)) {
290
+ fs.rmSync(mdir, { recursive: true, force: true });
291
+ return true;
292
+ }
293
+ return false;
294
+ }
295
+ }
296
+
297
+ module.exports = {
298
+ CATALOG,
299
+ ModelCacheManager
300
+ };
package/lib/cv.js ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Fast Pure-JS Spatial Filters & Classical Computer Vision for Node.js.
3
+ * Open-Source under Apache License 2.0.
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ function sobel(grayPixels, width, height) {
9
+ const gradX = new Float32Array(width * height);
10
+ const gradY = new Float32Array(width * height);
11
+ const magnitude = new Float32Array(width * height);
12
+
13
+ for (let y = 1; y < height - 1; y++) {
14
+ for (let x = 1; x < width - 1; x++) {
15
+ const idx = y * width + x;
16
+ const gx =
17
+ -grayPixels[(y - 1) * width + (x - 1)] + grayPixels[(y - 1) * width + (x + 1)] +
18
+ -2 * grayPixels[y * width + (x - 1)] + 2 * grayPixels[y * width + (x + 1)] +
19
+ -grayPixels[(y + 1) * width + (x - 1)] + grayPixels[(y + 1) * width + (x + 1)];
20
+
21
+ const gy =
22
+ -grayPixels[(y - 1) * width + (x - 1)] - 2 * grayPixels[(y - 1) * width + x] - grayPixels[(y - 1) * width + (x + 1)] +
23
+ grayPixels[(y + 1) * width + (x - 1)] + 2 * grayPixels[(y + 1) * width + x] + grayPixels[(y + 1) * width + (x + 1)];
24
+
25
+ gradX[idx] = gx;
26
+ gradY[idx] = gy;
27
+ magnitude[idx] = Math.sqrt(gx * gx + gy * gy);
28
+ }
29
+ }
30
+
31
+ return { gradX, gradY, magnitude };
32
+ }
33
+
34
+ function canny(grayPixels, width, height, lowThreshold = 40.0, highThreshold = 120.0) {
35
+ const { magnitude } = sobel(grayPixels, width, height);
36
+ const edges = new Uint8Array(width * height);
37
+
38
+ for (let i = 0; i < magnitude.length; i++) {
39
+ if (magnitude[i] >= highThreshold) {
40
+ edges[i] = 255;
41
+ } else if (magnitude[i] >= lowThreshold) {
42
+ edges[i] = 128;
43
+ } else {
44
+ edges[i] = 0;
45
+ }
46
+ }
47
+
48
+ return edges;
49
+ }
50
+
51
+ module.exports = {
52
+ sobel,
53
+ canny
54
+ };
package/lib/detect.js ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * BoundingBox Geometry & Non-Maximum Suppression (NMS) for Node.js.
3
+ * Open-Source under Apache License 2.0.
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ function computeIoU(boxA, boxB) {
9
+ const x1 = Math.max(boxA.x, boxB.x);
10
+ const y1 = Math.max(boxA.y, boxB.y);
11
+ const x2 = Math.min(boxA.x + boxA.width, boxB.x + boxB.width);
12
+ const y2 = Math.min(boxA.y + boxA.height, boxB.y + boxB.height);
13
+
14
+ const interArea = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
15
+ const areaA = boxA.width * boxA.height;
16
+ const areaB = boxB.width * boxB.height;
17
+ const unionArea = areaA + areaB - interArea;
18
+
19
+ return unionArea <= 0 ? 0 : interArea / unionArea;
20
+ }
21
+
22
+ function nms(boxes, iouThreshold = 0.45) {
23
+ if (!boxes || boxes.length === 0) return [];
24
+ const sorted = [...boxes].sort((a, b) => (b.score || 0) - (a.score || 0));
25
+ const keep = [];
26
+
27
+ for (let i = 0; i < sorted.length; i++) {
28
+ const current = sorted[i];
29
+ let suppressed = false;
30
+ for (let j = 0; j < keep.length; j++) {
31
+ if (computeIoU(current, keep[j]) > iouThreshold) {
32
+ suppressed = true;
33
+ break;
34
+ }
35
+ }
36
+ if (!suppressed) {
37
+ keep.push(current);
38
+ }
39
+ }
40
+
41
+ return keep;
42
+ }
43
+
44
+ module.exports = {
45
+ computeIoU,
46
+ nms
47
+ };
package/lib/doctor.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Hardware, Memory, Vulkan, and Environment Diagnostic Doctor for Node.js.
3
+ * Open-Source under Apache License 2.0.
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const { ModelCacheManager } = require('./cache');
12
+
13
+ function runDoctor(probeVulkan = false) {
14
+ const cache = new ModelCacheManager();
15
+ const isAndroid = fs.existsSync('/system/build.prop') || Boolean(process.env.ANDROID_ROOT);
16
+
17
+ let totalRamMb = null;
18
+ let availableRamMb = null;
19
+
20
+ try {
21
+ if (fs.existsSync('/proc/meminfo')) {
22
+ const content = fs.readFileSync('/proc/meminfo', 'utf-8');
23
+ for (const line of content.split('\n')) {
24
+ if (line.startsWith('MemTotal:')) {
25
+ totalRamMb = Math.round(parseInt(line.split(/\s+/)[1], 10) / 1024);
26
+ } else if (line.startsWith('MemAvailable:')) {
27
+ availableRamMb = Math.round(parseInt(line.split(/\s+/)[1], 10) / 1024);
28
+ }
29
+ }
30
+ }
31
+ } catch (e) {}
32
+
33
+ if (!totalRamMb) {
34
+ totalRamMb = Math.round(os.totalmem() / (1024 * 1024));
35
+ availableRamMb = Math.round(os.freemem() / (1024 * 1024));
36
+ }
37
+
38
+ const loaderDetected = fs.existsSync('/system/lib64/libvulkan.so') || fs.existsSync('/system/lib/libvulkan.so');
39
+ const driverDetected = fs.existsSync('/vendor/lib64/hw/vulkan.adreno.so') || fs.existsSync('/vendor/lib64/hw/vulkan.mali.so');
40
+
41
+ let vulkanStatus = 'unverified';
42
+ if (probeVulkan) {
43
+ vulkanStatus = (loaderDetected && driverDetected) ? 'driver_detected_experimental' : 'disabled';
44
+ }
45
+
46
+ const installed = cache.listInstalled();
47
+
48
+ return {
49
+ schemaVersion: 1,
50
+ clientVersion: '0.2.0-alpha.1',
51
+ platform: {
52
+ system: os.type(),
53
+ machine: os.arch(),
54
+ nodeVersion: process.version,
55
+ isAndroid: isAndroid
56
+ },
57
+ hardware: {
58
+ cpuCores: os.cpus().length,
59
+ totalRamMb: totalRamMb,
60
+ availableRamMb: availableRamMb
61
+ },
62
+ vulkan: {
63
+ loaderDetected: loaderDetected,
64
+ driverDetected: driverDetected,
65
+ status: vulkanStatus
66
+ },
67
+ vlmRuntime: {
68
+ installedModelsCount: installed.length,
69
+ cacheDir: cache.cacheRoot
70
+ },
71
+ recommendedPreset: 'Tier M (smolvlm-500m / 4-Threads CPU Reference)'
72
+ };
73
+ }
74
+
75
+ module.exports = {
76
+ runDoctor
77
+ };
package/lib/errors.js ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Structured Exception Classes and Dynamic User Error Contracts for Node.js.
3
+ * Open-Source under Apache License 2.0.
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ class TermuxVisionError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = this.constructor.name;
12
+ }
13
+ }
14
+
15
+ class ModelNotFoundError extends TermuxVisionError {
16
+ constructor(modelId, availableLocal = [], catalogModels = []) {
17
+ let msg = `Model '${modelId}' was not found locally or on remote registry.`;
18
+ if (availableLocal && availableLocal.length > 0) {
19
+ msg += `\n\nCurrently installed local models (${availableLocal.length}):\n` +
20
+ availableLocal.map(m => ` - ${m}`).join('\n') +
21
+ `\n\nTo run with an installed model:\n termux-vision vlm <IMAGE> --model ${availableLocal[0]}`;
22
+ } else if (catalogModels && catalogModels.length > 0) {
23
+ msg += `\n\nNo models installed locally. Available catalog presets:\n` +
24
+ catalogModels.map(m => ` - ${m}`).join('\n') +
25
+ `\n\nInstall an official model:\n termux-vision model install ${catalogModels[0]}`;
26
+ }
27
+ super(msg);
28
+ this.modelId = modelId;
29
+ this.availableLocal = availableLocal;
30
+ this.catalogModels = catalogModels;
31
+ }
32
+ }
33
+
34
+ class NoInstalledModelsError extends ModelNotFoundError {
35
+ constructor(catalogModels = []) {
36
+ const catText = catalogModels.length > 0 ? catalogModels.map(m => ` - ${m}`).join('\n') : ' (none)';
37
+ const msg =
38
+ "No installed VLM models were found in local cache (~/.cache/termux-vision/models).\n\n" +
39
+ "How to use VLM models:\n" +
40
+ "1. Install an official catalog model:\n" +
41
+ `${catText}\n` +
42
+ " Example: termux-vision model install smolvlm-500m-q4\n\n" +
43
+ "2. Use custom/external ('싸제') models:\n" +
44
+ " - Place your text model GGUF and vision projector mmproj GGUF in:\n" +
45
+ " ~/.cache/termux-vision/models/<custom_model_name>/\n" +
46
+ " - Or pass direct file paths:\n" +
47
+ " termux-vision vlm <IMAGE> --model /path/to/model.gguf --mmproj /path/to/mmproj.gguf\n" +
48
+ " (Note: VLM inference requires both a language model .gguf and a vision projector mmproj-*.gguf)";
49
+ super('all', [], catalogModels);
50
+ this.message = msg;
51
+ }
52
+ }
53
+
54
+ class ModelSelectionRequiredError extends ModelNotFoundError {
55
+ constructor(installedModels = []) {
56
+ const modelsText = installedModels.map(m => ` - ${m}`).join('\n');
57
+ const exampleModel = installedModels[0] || 'MODEL_ID';
58
+ const msg =
59
+ `Multiple models are installed. Please specify one with --model:\n` +
60
+ `${modelsText}\n\n` +
61
+ `Example:\n` +
62
+ ` termux-vision vlm <IMAGE> --model ${exampleModel} -p "Describe this image"`;
63
+ super('multiple', installedModels, []);
64
+ this.message = msg;
65
+ }
66
+ }
67
+
68
+ class VulkanNotAvailableError extends TermuxVisionError {
69
+ constructor(reason = '') {
70
+ const detail = reason ? `\nFailure detail: ${reason}` : '';
71
+ const msg =
72
+ `Vulkan GPU acceleration is unavailable or failed on this device.${detail}\n\n` +
73
+ `[Action Required] Explicit GPU mode cannot proceed. Please switch to CPU mode:\n` +
74
+ ` CLI: --device cpu\n` +
75
+ ` Node.js API: device: 'cpu'\n` +
76
+ `Or use automatic detection: --device auto (device: 'auto')`;
77
+ super(msg);
78
+ this.reason = reason;
79
+ }
80
+ }
81
+
82
+ class RuntimeNotFoundError extends TermuxVisionError {
83
+ constructor(searchedPaths = []) {
84
+ let searched = '';
85
+ if (searchedPaths.length > 0) {
86
+ searched = '\n\nSearched paths:\n' + searchedPaths.map(p => ` - ${p}`).join('\n');
87
+ }
88
+ const msg =
89
+ `Required runtime 'llama-cli' was not found.${searched}\n\n` +
90
+ `Please ensure llama.cpp is installed on Termux:\n` +
91
+ ` pkg install termux-llamacpp (or place llama-cli in PATH / $PREFIX/bin)`;
92
+ super(msg);
93
+ this.searchedPaths = searchedPaths;
94
+ }
95
+ }
96
+
97
+ class ModelDownloadError extends TermuxVisionError {
98
+ constructor(source, reason = '', availableLocal = []) {
99
+ let msg = `Failed to download model from '${source}': ${reason}`;
100
+ if (availableLocal && availableLocal.length > 0) {
101
+ msg += `\n\nInstalled local models available:\n` +
102
+ availableLocal.map(m => ` - ${m}`).join('\n') +
103
+ `\nRun with local model: termux-vision vlm <IMAGE> --model ${availableLocal[0]}`;
104
+ }
105
+ super(msg);
106
+ this.source = source;
107
+ this.reason = reason;
108
+ }
109
+ }
110
+
111
+ module.exports = {
112
+ TermuxVisionError,
113
+ ModelNotFoundError,
114
+ NoInstalledModelsError,
115
+ ModelSelectionRequiredError,
116
+ VulkanNotAvailableError,
117
+ RuntimeNotFoundError,
118
+ ModelDownloadError
119
+ };