offgrid-ai 0.4.1 → 0.4.3
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/package.json +1 -1
- package/src/autodetect.mjs +1 -1
- package/src/backends.mjs +2 -2
- package/src/cli.mjs +104 -56
- package/src/profile-setup.mjs +12 -6
- package/src/profiles.mjs +13 -6
- package/src/scan.mjs +104 -15
package/package.json
CHANGED
package/src/autodetect.mjs
CHANGED
|
@@ -109,7 +109,7 @@ export function computeFlags(capabilities, modelPath, mmprojPath, draftModelPath
|
|
|
109
109
|
|
|
110
110
|
// MTP flags
|
|
111
111
|
if (mtp) {
|
|
112
|
-
argv.push("--spec-type", "draft-mtp", "--spec-draft-n-max", "
|
|
112
|
+
argv.push("--spec-type", "draft-mtp", "--spec-draft-n-max", "4");
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
return { flags, argv };
|
package/src/backends.mjs
CHANGED
|
@@ -27,7 +27,7 @@ export const BACKENDS = {
|
|
|
27
27
|
defaultPort: LLAMA_CPP_PORT,
|
|
28
28
|
defaultBaseUrl: baseUrlFor({ port: LLAMA_CPP_PORT }),
|
|
29
29
|
needsCommandFile: true,
|
|
30
|
-
scanModels: () => scanGgufModels(),
|
|
30
|
+
scanModels: async () => (await scanGgufModels()).models,
|
|
31
31
|
},
|
|
32
32
|
"llama-cpp-mtp": {
|
|
33
33
|
id: "llama-cpp-mtp",
|
|
@@ -38,7 +38,7 @@ export const BACKENDS = {
|
|
|
38
38
|
defaultPort: LLAMA_CPP_MTP_PORT,
|
|
39
39
|
defaultBaseUrl: baseUrlFor({ port: LLAMA_CPP_MTP_PORT }),
|
|
40
40
|
needsCommandFile: true,
|
|
41
|
-
scanModels: () => scanGgufModels(),
|
|
41
|
+
scanModels: async () => (await scanGgufModels()).models,
|
|
42
42
|
},
|
|
43
43
|
"ollama": {
|
|
44
44
|
id: "ollama",
|
package/src/cli.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { totalmem } from "node:os";
|
|
2
2
|
import { existsSync, statSync, rmSync } from "node:fs";
|
|
3
|
+
import { basename } from "node:path";
|
|
3
4
|
import { ensureDirs, findLlamaServer, hasHomebrew, DATA_DIR } from "./config.mjs";
|
|
4
|
-
import { scanGgufModels } from "./scan.mjs";
|
|
5
|
+
import { scanGgufModels, matchDrafter } from "./scan.mjs";
|
|
5
6
|
import { createProfileFromModel, normalizeProfile, sanitizeProfileId } from "./profiles.mjs";
|
|
6
7
|
import { readProfile, saveProfile, deleteProfile, loadProfiles, readCommandArgv } from "./profiles.mjs";
|
|
7
8
|
import { backendFor, BACKENDS } from "./backends.mjs";
|
|
@@ -9,7 +10,7 @@ import { startServer, stopProfile, waitForReady, serverReady, serverMatchesProfi
|
|
|
9
10
|
import { syncPiConfig, removeFromPiConfig, hasPiModel, launchPi, hasPi } from "./harness-pi.mjs";
|
|
10
11
|
import { tailFriendly } from "./logs.mjs";
|
|
11
12
|
import { estimateMemory } from "./estimate.mjs";
|
|
12
|
-
import { pc, formatBytes, renderRows, renderSection, renderCard, humanCapabilitySummary,
|
|
13
|
+
import { pc, formatBytes, renderRows, renderSection, renderCard, humanCapabilitySummary, startInteractive, createPrompt, parseOptions } from "./ui.mjs";
|
|
13
14
|
import { checkForUpdate, currentPackageVersion, detectInvocation, updateCommand, runUpdateCommand } from "./updates.mjs";
|
|
14
15
|
import { removeInstallerPathEntries } from "./shell-path.mjs";
|
|
15
16
|
import { configureLocalProfile } from "./profile-setup.mjs";
|
|
@@ -78,7 +79,7 @@ export async function mainFlow() {
|
|
|
78
79
|
|
|
79
80
|
// 1. Check what backends are available
|
|
80
81
|
const llamaBinary = await findLlamaServer();
|
|
81
|
-
const ggufModels = await scanGgufModels();
|
|
82
|
+
const { models: ggufModels } = await scanGgufModels();
|
|
82
83
|
const managedModels = await scanManagedModels();
|
|
83
84
|
const profiles = await loadProfiles();
|
|
84
85
|
const hasAnyBackend = llamaBinary || managedModels.some((m) => m.models.length > 0);
|
|
@@ -206,17 +207,17 @@ async function runCommand(argv) {
|
|
|
206
207
|
}
|
|
207
208
|
|
|
208
209
|
async function loadModelCatalog() {
|
|
209
|
-
const [profiles, ggufModels, managedModels] = await Promise.all([
|
|
210
|
+
const [profiles, { models: ggufModels, drafters }, managedModels] = await Promise.all([
|
|
210
211
|
loadProfiles(),
|
|
211
212
|
scanGgufModels(),
|
|
212
213
|
scanManagedModels(),
|
|
213
214
|
]);
|
|
214
|
-
return normalizeCatalog({ profiles, ggufModels, managedModels });
|
|
215
|
+
return normalizeCatalog({ profiles, ggufModels, drafters, managedModels });
|
|
215
216
|
}
|
|
216
217
|
|
|
217
218
|
function normalizeCatalog(catalog) {
|
|
218
219
|
if (catalog.newModels && catalog.managedItems) return catalog;
|
|
219
|
-
const { profiles, ggufModels, managedModels } = catalog;
|
|
220
|
+
const { profiles, ggufModels, drafters, managedModels } = catalog;
|
|
220
221
|
const profiledPaths = new Set(profiles.map((p) => p.modelPath).filter(Boolean));
|
|
221
222
|
const newModels = ggufModels.filter((m) => !profiledPaths.has(m.path));
|
|
222
223
|
const managedItems = [];
|
|
@@ -228,10 +229,10 @@ function normalizeCatalog(catalog) {
|
|
|
228
229
|
if (!profiledAliases.has(`${backendId}:${model.id}`)) managedItems.push({ model, backendId });
|
|
229
230
|
}
|
|
230
231
|
}
|
|
231
|
-
return { profiles, ggufModels, managedModels, newModels, managedItems };
|
|
232
|
+
return { profiles, ggufModels, drafters, managedModels, newModels, managedItems };
|
|
232
233
|
}
|
|
233
234
|
|
|
234
|
-
async function printModelCatalog({ profiles, newModels, managedItems }, items = modelCatalogItems({ profiles, newModels, managedItems })) {
|
|
235
|
+
async function printModelCatalog({ profiles, newModels, managedItems, drafters }, items = modelCatalogItems({ profiles, newModels, managedItems })) {
|
|
235
236
|
const itemNumber = (predicate) => {
|
|
236
237
|
const index = items.findIndex(predicate);
|
|
237
238
|
return index === -1 ? " " : String(index + 1).padStart(2, " ");
|
|
@@ -243,13 +244,14 @@ async function printModelCatalog({ profiles, newModels, managedItems }, items =
|
|
|
243
244
|
|
|
244
245
|
const fileMissingCount = profiles.filter((p) => isProfileFileMissing(p)).length;
|
|
245
246
|
|
|
247
|
+
const summaryBorder = fileMissingCount > 0 ? pc.red : newModels.length > 0 ? pc.yellow : pc.dim;
|
|
246
248
|
console.log("\n" + renderCard("Your local AI workspace", renderRows([
|
|
247
|
-
["
|
|
248
|
-
["Need setup", newModels.length > 0 ? pc.yellow(`${newModels.length}
|
|
249
|
-
["Running
|
|
250
|
-
["File missing", fileMissingCount > 0 ? pc.red(`${fileMissingCount} setup${fileMissingCount === 1 ? "" : "s"}`) : pc.
|
|
251
|
-
["Next step", fileMissingCount > 0 ? pc.red("Remove or fix missing setups") : profiles.length > 0 ? "Start chatting" : newModels.length > 0 ? "Set up a downloaded model" : "Download a model"],
|
|
252
|
-
]), { formatBorder:
|
|
249
|
+
["Setups", `${profiles.length} saved`],
|
|
250
|
+
["Need setup", newModels.length > 0 ? pc.yellow(`${newModels.length} model${newModels.length === 1 ? "" : "s"}`) : pc.dim("none")],
|
|
251
|
+
["Running", runningProfilesNow.length > 0 ? pc.green(String(runningProfilesNow.length)) : pc.dim("none")],
|
|
252
|
+
["File missing", fileMissingCount > 0 ? pc.red(`${fileMissingCount} setup${fileMissingCount === 1 ? "" : "s"}`) : pc.dim("none")],
|
|
253
|
+
["Next step", fileMissingCount > 0 ? pc.red("Remove or fix missing setups") : profiles.length > 0 ? "Start chatting" : newModels.length > 0 ? pc.yellow("Set up a downloaded model") : "Download a model"],
|
|
254
|
+
]), { formatBorder: summaryBorder }));
|
|
253
255
|
|
|
254
256
|
console.log("\n" + pc.bold("Ready to chat"));
|
|
255
257
|
if (profiles.length === 0) {
|
|
@@ -260,18 +262,19 @@ async function printModelCatalog({ profiles, newModels, managedItems }, items =
|
|
|
260
262
|
const piConfigured = await hasPiModel(profile);
|
|
261
263
|
const fileMissing = isProfileFileMissing(profile);
|
|
262
264
|
const num = itemNumber((item) => item.type === "profile" && item.profile.id === profile.id);
|
|
263
|
-
console.log(profileCatalogCard(num, profile, { running, piConfigured, fileMissing }));
|
|
265
|
+
console.log(profileCatalogCard(num, profile, { running, piConfigured, fileMissing, drafters }));
|
|
264
266
|
}
|
|
265
267
|
}
|
|
266
268
|
|
|
267
|
-
console.log("\n" + pc.bold("
|
|
269
|
+
console.log("\n" + pc.bold("Needs one-time setup"));
|
|
268
270
|
if (newModels.length === 0) {
|
|
269
|
-
console.log(renderCard("All set", "Every downloaded local model already has a saved setup.", { formatBorder: pc.
|
|
271
|
+
console.log(renderCard("All set", "Every downloaded local model already has a saved setup.", { formatBorder: pc.dim }));
|
|
270
272
|
} else {
|
|
271
273
|
for (const model of newModels.slice(0, 20)) {
|
|
272
274
|
const caps = detectCapabilities(model.path, model.mmprojPath);
|
|
275
|
+
const drafter = matchDrafter(model.path, drafters);
|
|
273
276
|
const num = itemNumber((item) => item.type === "new" && item.model.path === model.path);
|
|
274
|
-
console.log(downloadedModelCard(num, model, caps));
|
|
277
|
+
console.log(downloadedModelCard(num, model, caps, { mtpAvailable: caps.mtp || Boolean(drafter), drafter }));
|
|
275
278
|
}
|
|
276
279
|
if (newModels.length > 20) console.log(pc.dim(` ... and ${newModels.length - 20} more`));
|
|
277
280
|
}
|
|
@@ -296,46 +299,65 @@ function isProfileFileMissing(profile) {
|
|
|
296
299
|
return !existsSync(profile.modelPath);
|
|
297
300
|
}
|
|
298
301
|
|
|
299
|
-
function profileCatalogCard(num, profile, { running, piConfigured, fileMissing }) {
|
|
302
|
+
function profileCatalogCard(num, profile, { running, piConfigured, fileMissing, drafters }) {
|
|
300
303
|
const backend = backendFor(profile.backend);
|
|
301
304
|
const caps = profile.capabilities ?? {};
|
|
302
305
|
let status;
|
|
303
306
|
if (fileMissing) {
|
|
304
307
|
status = pc.red("File missing");
|
|
305
308
|
} else if (running) {
|
|
306
|
-
status =
|
|
309
|
+
status = pc.green("Running now");
|
|
307
310
|
} else {
|
|
308
|
-
status =
|
|
311
|
+
status = "Ready";
|
|
309
312
|
}
|
|
310
|
-
|
|
313
|
+
const border = fileMissing ? pc.red : running ? pc.green : pc.dim;
|
|
314
|
+
const mtpDrafter = profile.drafterPath
|
|
315
|
+
? pc.green("enabled")
|
|
316
|
+
: (drafters ? matchDrafter(profile.modelPath, drafters) : null)
|
|
317
|
+
? pc.yellow("available — re-setup to enable")
|
|
318
|
+
: (caps.architecture === "gemma4")
|
|
319
|
+
? pc.yellow("drafter not found")
|
|
320
|
+
: null;
|
|
321
|
+
const rows = [
|
|
311
322
|
["Status", status],
|
|
312
323
|
["Good for", fileMissing ? pc.red("Model file not found") : humanCapabilitySummary(caps)],
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
324
|
+
];
|
|
325
|
+
if (mtpDrafter) rows.push(["MTP", mtpDrafter]);
|
|
326
|
+
rows.push(["Pi", piConfigured ? pc.dim("synced") : pc.yellow("Needs sync")]);
|
|
327
|
+
rows.push(["Runs with", backend.label]);
|
|
328
|
+
return renderCard(`${num}. ${profile.label}`, renderRows(rows), { formatBorder: border });
|
|
316
329
|
}
|
|
317
330
|
|
|
318
|
-
function downloadedModelCard(num, model, caps) {
|
|
331
|
+
function downloadedModelCard(num, model, caps, { mtpAvailable } = {}) {
|
|
332
|
+
const mtpRow = mtpAvailable
|
|
333
|
+
? [["MTP", pc.green("Drafter found — setup will enable 2× speedup")]]
|
|
334
|
+
: (caps.architecture === "gemma4" || caps.architecture === "gemma4")
|
|
335
|
+
? [["MTP", pc.yellow("Drafter not found — download MTP model for 2× speedup")]]
|
|
336
|
+
: [];
|
|
319
337
|
return renderCard(`${num}. ${model.label}`, renderRows([
|
|
320
|
-
["Status",
|
|
338
|
+
["Status", pc.yellow("Needs setup")],
|
|
321
339
|
["Good for", humanCapabilitySummary(caps)],
|
|
340
|
+
...mtpRow,
|
|
322
341
|
["Size", formatBytes(model.sizeBytes)],
|
|
323
342
|
]), { formatBorder: pc.yellow });
|
|
324
343
|
}
|
|
325
344
|
|
|
326
345
|
function managedModelCard(num, model, backend) {
|
|
327
346
|
return renderCard(`${num}. ${model.label}`, renderRows([
|
|
328
|
-
["Status",
|
|
347
|
+
["Status", pc.dim(`Via ${backend.label}`)],
|
|
329
348
|
["Runs with", backend.label],
|
|
330
|
-
["Model ID",
|
|
349
|
+
["Model ID", model.id],
|
|
331
350
|
...(model.quant ? [["Size/type", model.quant]] : []),
|
|
332
|
-
]), { formatBorder: pc.
|
|
351
|
+
]), { formatBorder: pc.dim });
|
|
333
352
|
}
|
|
334
353
|
|
|
335
|
-
function modelCatalogItems({ profiles, newModels, managedItems }) {
|
|
354
|
+
function modelCatalogItems({ profiles, newModels, managedItems, drafters }) {
|
|
336
355
|
return [
|
|
337
356
|
...profiles.map((profile) => ({ type: "profile", profile, label: profile.label, hint: `${profile.modelAlias} · ${profile.baseUrl}`, fileMissing: isProfileFileMissing(profile) })),
|
|
338
|
-
...newModels.map((model) =>
|
|
357
|
+
...newModels.map((model) => {
|
|
358
|
+
const drafter = matchDrafter(model.path, drafters);
|
|
359
|
+
return { type: "new", model, label: model.label, hint: `${model.quant ?? "GGUF"} · ${formatBytes(model.sizeBytes)}`, drafter };
|
|
360
|
+
}),
|
|
339
361
|
...managedItems.map(({ model, backendId }) => ({ type: "managed", model, backendId, label: model.label, hint: BACKENDS[backendId].label })),
|
|
340
362
|
];
|
|
341
363
|
}
|
|
@@ -366,7 +388,7 @@ async function handleCatalogAction(prompt, action, item) {
|
|
|
366
388
|
if (action === "inspect") {
|
|
367
389
|
if (item.type === "profile") return await printProfileDetails(await readProfile(item.profile.id));
|
|
368
390
|
if (item.type === "managed") return printManagedModelDetails(item.model, BACKENDS[item.backendId]);
|
|
369
|
-
return printGgufModelDetails(item.model);
|
|
391
|
+
return printGgufModelDetails(item.model, item.drafter);
|
|
370
392
|
}
|
|
371
393
|
|
|
372
394
|
if (action === "setup") {
|
|
@@ -376,7 +398,7 @@ async function handleCatalogAction(prompt, action, item) {
|
|
|
376
398
|
await saveProfile(profile);
|
|
377
399
|
return await syncPiConfig(profile);
|
|
378
400
|
}
|
|
379
|
-
const profile = await createProfileFromModel(item.model);
|
|
401
|
+
const profile = await createProfileFromModel(item.model, null, item.drafter?.path);
|
|
380
402
|
const configured = await configureLocalProfile(prompt, profile);
|
|
381
403
|
if (!configured) return;
|
|
382
404
|
await saveProfile(configured);
|
|
@@ -391,7 +413,7 @@ async function handleCatalogAction(prompt, action, item) {
|
|
|
391
413
|
await syncPiConfig(profile);
|
|
392
414
|
return await runProfile(profile);
|
|
393
415
|
}
|
|
394
|
-
const profile = await createProfileFromModel(item.model);
|
|
416
|
+
const profile = await createProfileFromModel(item.model, null, item.drafter?.path);
|
|
395
417
|
const configured = await configureLocalProfile(prompt, profile);
|
|
396
418
|
if (!configured) return;
|
|
397
419
|
await saveProfile(configured);
|
|
@@ -410,26 +432,39 @@ async function printProfileDetails(profile) {
|
|
|
410
432
|
const fileMissing = !isManaged && isProfileFileMissing(profile);
|
|
411
433
|
const statusRow = fileMissing
|
|
412
434
|
? pc.red("File missing")
|
|
413
|
-
: running ? pc.green("Running now") :
|
|
414
|
-
|
|
435
|
+
: running ? pc.green("Running now") : "Ready";
|
|
436
|
+
const mtpStatus = profile.drafterPath
|
|
437
|
+
? pc.green(`MTP enabled (drafter: ${basename(profile.drafterPath)})`)
|
|
438
|
+
: (profile.capabilities?.architecture === "gemma4")
|
|
439
|
+
? pc.yellow("MTP available — download a drafter model to enable 2× speedup")
|
|
440
|
+
: null;
|
|
441
|
+
const overviewRows = [
|
|
415
442
|
["Name", pc.bold(profile.label)],
|
|
416
443
|
["Status", statusRow],
|
|
417
444
|
["Good for", fileMissing ? pc.red("Model file not found — remove or fix this setup") : humanCapabilitySummary(profile.capabilities ?? {})],
|
|
418
|
-
["Pi", piConfigured ? pc.
|
|
419
|
-
["Server", fileMissing ? pc.red(profile.baseUrl) :
|
|
420
|
-
]
|
|
445
|
+
["Pi", piConfigured ? pc.dim("synced") : pc.yellow("Needs sync")],
|
|
446
|
+
["Server", fileMissing ? pc.red(profile.baseUrl) : profile.baseUrl],
|
|
447
|
+
];
|
|
448
|
+
if (mtpStatus) overviewRows.push(["MTP", mtpStatus]);
|
|
449
|
+
console.log("\n" + renderSection("Model overview", renderRows(overviewRows)));
|
|
421
450
|
|
|
422
|
-
|
|
423
|
-
["Setup ID",
|
|
451
|
+
const detailRows = [
|
|
452
|
+
["Setup ID", profile.id],
|
|
424
453
|
["Runs with", backend.label],
|
|
425
|
-
["Model alias",
|
|
454
|
+
["Model alias", profile.modelAlias],
|
|
426
455
|
...(profile.capabilities ? [["Detected", capabilitySummary(profile.capabilities)]] : []),
|
|
427
|
-
|
|
456
|
+
];
|
|
457
|
+
if (!isManaged) {
|
|
458
|
+
detailRows.push(
|
|
428
459
|
["Local file", fileMissing ? pc.red(`${profile.modelPath} (not found)`) : profile.modelPath ?? "unknown"],
|
|
429
460
|
["Vision file", profile.mmprojPath ? (existsSync(profile.mmprojPath) ? profile.mmprojPath : pc.red(`${profile.mmprojPath} (not found)`)) : "none"],
|
|
430
461
|
["Model size", profile.modelPath && existsSync(profile.modelPath) ? formatBytes(statSync(profile.modelPath).size) : "unknown"],
|
|
431
|
-
|
|
432
|
-
|
|
462
|
+
);
|
|
463
|
+
if (profile.drafterPath) {
|
|
464
|
+
detailRows.push(["Drafter", existsSync(profile.drafterPath) ? profile.drafterPath : pc.red(`${profile.drafterPath} (not found)`)]);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
console.log("\n" + renderSection("Model details", renderRows(detailRows), { columns: 110 }));
|
|
433
468
|
|
|
434
469
|
if (fileMissing) {
|
|
435
470
|
console.log("\n" + pc.red("⚠ This model's file is no longer on disk. Remove this setup or move the file back."));
|
|
@@ -441,20 +476,33 @@ async function printProfileDetails(profile) {
|
|
|
441
476
|
}
|
|
442
477
|
}
|
|
443
478
|
|
|
444
|
-
function printGgufModelDetails(model) {
|
|
479
|
+
function printGgufModelDetails(model, drafter) {
|
|
445
480
|
const caps = detectCapabilities(model.path, model.mmprojPath);
|
|
446
|
-
|
|
481
|
+
const mtpAvailable = caps.mtp || Boolean(drafter);
|
|
482
|
+
const mtpRow = mtpAvailable
|
|
483
|
+
? [["MTP", pc.green("Drafter found — setup will enable 2× speedup")]]
|
|
484
|
+
: (caps.architecture === "gemma4")
|
|
485
|
+
? [["MTP", pc.yellow("Drafter not found — download MTP model for 2× speedup")]]
|
|
486
|
+
: [];
|
|
487
|
+
const overviewRows = [
|
|
447
488
|
["Name", pc.bold(model.label)],
|
|
448
489
|
["Status", pc.yellow("Needs one-time setup")],
|
|
449
490
|
["Good for", humanCapabilitySummary(caps)],
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
491
|
+
];
|
|
492
|
+
if (mtpRow.length) overviewRows.push(...mtpRow);
|
|
493
|
+
overviewRows.push(["Size", formatBytes(model.sizeBytes)]);
|
|
494
|
+
console.log("\n" + renderSection("Downloaded model", renderRows(overviewRows)));
|
|
495
|
+
const detailRows = [
|
|
453
496
|
["Local file", model.path],
|
|
454
497
|
["Vision file", model.mmprojPath ?? "none"],
|
|
455
498
|
["Detected", capabilitySummary(caps)],
|
|
456
499
|
["Quant", model.quant ?? "unknown"],
|
|
457
|
-
]
|
|
500
|
+
];
|
|
501
|
+
if (drafter) {
|
|
502
|
+
detailRows.push(["Drafter", drafter.path]);
|
|
503
|
+
detailRows.push(["Drafter size", formatBytes(drafter.sizeBytes)]);
|
|
504
|
+
}
|
|
505
|
+
console.log("\n" + renderSection("Model details", renderRows(detailRows), { columns: 110 }));
|
|
458
506
|
}
|
|
459
507
|
|
|
460
508
|
function printManagedModelDetails(model, backend) {
|
|
@@ -659,9 +707,9 @@ async function statusCommand() {
|
|
|
659
707
|
if (running.length === 0) {
|
|
660
708
|
console.log(renderCard("Status", renderRows([
|
|
661
709
|
["Running now", pc.dim("none")],
|
|
662
|
-
["Ready setups", profiles.length > 0 ?
|
|
663
|
-
["Next step", profiles.length > 0 ? "Run offgrid-ai to start chatting" : "Run offgrid-ai to set up a model"],
|
|
664
|
-
]), { formatBorder:
|
|
710
|
+
["Ready setups", profiles.length > 0 ? String(profiles.length) : pc.dim("none")],
|
|
711
|
+
["Next step", profiles.length > 0 ? "Run offgrid-ai to start chatting" : pc.yellow("Run offgrid-ai to set up a model")],
|
|
712
|
+
]), { formatBorder: pc.dim }));
|
|
665
713
|
return;
|
|
666
714
|
}
|
|
667
715
|
|
|
@@ -870,7 +918,7 @@ async function onboardFlow() {
|
|
|
870
918
|
console.log(pc.green("✓ Pi found"));
|
|
871
919
|
|
|
872
920
|
// 4. Model backends — at least one is mandatory
|
|
873
|
-
const ggufModels = await scanGgufModels();
|
|
921
|
+
const { models: ggufModels } = await scanGgufModels();
|
|
874
922
|
const managedModels = await scanManagedModels();
|
|
875
923
|
const totalManaged = managedModels.reduce((sum, m) => sum + m.models.length, 0);
|
|
876
924
|
const hasModels = ggufModels.length > 0 || totalManaged > 0;
|
package/src/profile-setup.mjs
CHANGED
|
@@ -43,12 +43,14 @@ export async function configureLocalProfile(prompt, profile) {
|
|
|
43
43
|
console.log(pc.dim("Sampling defaults are shown for transparency; you can edit command.json later if needed.\n"));
|
|
44
44
|
|
|
45
45
|
if (caps.mtp) {
|
|
46
|
+
const drafterInfo = configured.drafterPath ? `\n Drafter: ${configured.drafterPath}` : "";
|
|
46
47
|
console.log(renderSection("MTP detected", renderRows([
|
|
47
48
|
["Backend", "llama.cpp MTP"],
|
|
48
49
|
["Port", String(LLAMA_CPP_MTP_PORT)],
|
|
49
|
-
["Flags",
|
|
50
|
+
["Flags", `--spec-type draft-mtp --spec-draft-n-max 4${configured.drafterPath ? " --spec-draft-model <drafter>" : ""}`],
|
|
50
51
|
])));
|
|
51
|
-
|
|
52
|
+
if (drafterInfo) console.log(pc.dim(drafterInfo));
|
|
53
|
+
const useMtp = await prompt.yesNo("Use MTP speculative decoding?", true);
|
|
52
54
|
configured = useMtp ? applyMtpDefaults(configured) : removeMtpDefaults(configured);
|
|
53
55
|
}
|
|
54
56
|
|
|
@@ -113,15 +115,19 @@ export function applyRuntimeFlagOverrides(profile, overrides) {
|
|
|
113
115
|
|
|
114
116
|
function applyMtpDefaults(profile) {
|
|
115
117
|
const flags = { ...profile.flags, port: LLAMA_CPP_MTP_PORT };
|
|
116
|
-
|
|
117
|
-
values: { "--spec-type": "draft-mtp", "--spec-draft-n-max":
|
|
118
|
-
}
|
|
118
|
+
const edits = {
|
|
119
|
+
values: { "--spec-type": "draft-mtp", "--spec-draft-n-max": 4 },
|
|
120
|
+
};
|
|
121
|
+
if (profile.drafterPath) {
|
|
122
|
+
edits.values["--spec-draft-model"] = profile.drafterPath;
|
|
123
|
+
}
|
|
124
|
+
return applyProfileFlags({ ...profile, backend: "llama-cpp-mtp", providerId: "llama-cpp-mtp" }, flags, edits);
|
|
119
125
|
}
|
|
120
126
|
|
|
121
127
|
function removeMtpDefaults(profile) {
|
|
122
128
|
const flags = { ...profile.flags, port: LLAMA_CPP_PORT };
|
|
123
129
|
return applyProfileFlags({ ...profile, backend: "llama-cpp", providerId: "llama-cpp" }, flags, {
|
|
124
|
-
remove: ["--spec-type", "--spec-draft-n-max"],
|
|
130
|
+
remove: ["--spec-type", "--spec-draft-n-max", "--spec-draft-model"],
|
|
125
131
|
});
|
|
126
132
|
}
|
|
127
133
|
|
package/src/profiles.mjs
CHANGED
|
@@ -131,22 +131,29 @@ export function normalizeProfile(profile) {
|
|
|
131
131
|
|
|
132
132
|
// ── Auto-create profile from a discovered model ────────────────────────────
|
|
133
133
|
|
|
134
|
-
export async function createProfileFromModel(model, backendId) {
|
|
134
|
+
export async function createProfileFromModel(model, backendId, drafterPath) {
|
|
135
135
|
const { detectCapabilities } = await import("./autodetect.mjs");
|
|
136
136
|
const caps = detectCapabilities(model.path, model.mmprojPath);
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
const
|
|
137
|
+
// If a drafter is provided, this model supports MTP regardless of filename
|
|
138
|
+
const hasMtp = caps.mtp || Boolean(drafterPath);
|
|
139
|
+
const backend = backendId ?? (hasMtp ? "llama-cpp-mtp" : "llama-cpp");
|
|
140
|
+
const { flags, argv } = computeFlags(
|
|
141
|
+
{ ...caps, mtp: hasMtp },
|
|
142
|
+
model.path,
|
|
143
|
+
model.mmprojPath,
|
|
144
|
+
drafterPath ?? null,
|
|
145
|
+
);
|
|
140
146
|
|
|
141
147
|
return normalizeProfile({
|
|
142
|
-
id,
|
|
148
|
+
id: slugFromLabel(model.label),
|
|
143
149
|
label: model.label,
|
|
144
150
|
backend,
|
|
145
151
|
providerId: backend,
|
|
146
152
|
modelAlias: model.aliasSuggestion,
|
|
147
153
|
modelPath: model.path,
|
|
148
154
|
mmprojPath: model.mmprojPath,
|
|
149
|
-
|
|
155
|
+
drafterPath: drafterPath ?? null,
|
|
156
|
+
capabilities: summarizeCapabilities({ ...caps, mtp: hasMtp }),
|
|
150
157
|
preset: null, // no presets — auto-detected
|
|
151
158
|
flags,
|
|
152
159
|
commandArgv: argv,
|
package/src/scan.mjs
CHANGED
|
@@ -2,47 +2,128 @@ import { statSync } from "node:fs";
|
|
|
2
2
|
import { readdir } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, join } from "node:path";
|
|
4
4
|
import { getModelScanDirs } from "./config.mjs";
|
|
5
|
+
import { readGgufMetadata } from "./gguf.mjs";
|
|
6
|
+
|
|
7
|
+
// ── Scan for GGUF models and MTP drafters ────────────────────────────────
|
|
5
8
|
|
|
6
9
|
export async function scanGgufModels(dirs) {
|
|
7
10
|
const scanDirs = dirs ?? await getModelScanDirs();
|
|
8
11
|
const allModels = [];
|
|
12
|
+
const allDrafters = [];
|
|
9
13
|
|
|
10
14
|
for (const root of scanDirs) {
|
|
11
|
-
const models = await scanOneDir(root);
|
|
15
|
+
const { models, drafters } = await scanOneDir(root);
|
|
12
16
|
allModels.push(...models);
|
|
17
|
+
allDrafters.push(...drafters);
|
|
13
18
|
}
|
|
14
19
|
|
|
15
20
|
// Deduplicate by path
|
|
16
21
|
const seen = new Set();
|
|
17
|
-
|
|
22
|
+
const models = allModels.filter((m) => {
|
|
18
23
|
if (seen.has(m.path)) return false;
|
|
19
24
|
seen.add(m.path);
|
|
20
25
|
return true;
|
|
21
26
|
}).sort((a, b) => a.label.localeCompare(b.label));
|
|
27
|
+
|
|
28
|
+
const drafterSeen = new Set();
|
|
29
|
+
const drafters = allDrafters.filter((d) => {
|
|
30
|
+
if (drafterSeen.has(d.path)) return false;
|
|
31
|
+
drafterSeen.add(d.path);
|
|
32
|
+
return true;
|
|
33
|
+
}).sort((a, b) => a.label.localeCompare(b.label));
|
|
34
|
+
|
|
35
|
+
return { models, drafters };
|
|
22
36
|
}
|
|
23
37
|
|
|
24
38
|
async function scanOneDir(root) {
|
|
25
39
|
const files = await findFiles(root, (path) => path.toLowerCase().endsWith(".gguf"));
|
|
26
40
|
const mmprojs = files.filter((path) => basename(path).toLowerCase().includes("mmproj"));
|
|
27
|
-
const
|
|
41
|
+
const candidates = files.filter((path) => !basename(path).toLowerCase().includes("mmproj"));
|
|
42
|
+
|
|
43
|
+
const models = [];
|
|
44
|
+
const drafters = [];
|
|
28
45
|
|
|
29
|
-
|
|
46
|
+
for (const path of candidates) {
|
|
30
47
|
const dir = dirname(path);
|
|
31
48
|
const mmprojPath = mmprojs.find((candidate) => dirname(candidate) === dir) ?? null;
|
|
32
49
|
const name = basename(path).replace(/\.gguf$/i, "");
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
50
|
+
const sizeBytes = statSync(path).size;
|
|
51
|
+
|
|
52
|
+
// Read GGUF metadata to detect drafter architecture
|
|
53
|
+
const meta = safeReadGgufMetadata(path);
|
|
54
|
+
const architecture = typeof meta["general.architecture"] === "string" ? meta["general.architecture"] : null;
|
|
55
|
+
|
|
56
|
+
if (architecture === "gemma4_assistant") {
|
|
57
|
+
// This is an MTP drafter model, not a main model
|
|
58
|
+
drafters.push({
|
|
59
|
+
path,
|
|
60
|
+
label: labelFromName(name),
|
|
61
|
+
aliasSuggestion: aliasFromName(name),
|
|
62
|
+
quant: quantFromName(name),
|
|
63
|
+
sizeBytes,
|
|
64
|
+
architecture,
|
|
65
|
+
targetHint: drafterTargetHint(name),
|
|
66
|
+
backend: "llama-cpp",
|
|
67
|
+
source: "local-gguf",
|
|
68
|
+
});
|
|
69
|
+
} else {
|
|
70
|
+
models.push({
|
|
71
|
+
path,
|
|
72
|
+
mmprojPath,
|
|
73
|
+
label: labelFromName(name),
|
|
74
|
+
aliasSuggestion: aliasFromName(name),
|
|
75
|
+
quant: quantFromName(name),
|
|
76
|
+
sizeBytes,
|
|
77
|
+
backend: "llama-cpp",
|
|
78
|
+
source: "local-gguf",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { models, drafters };
|
|
44
84
|
}
|
|
45
85
|
|
|
86
|
+
// ── Match drafters to target models ────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
// Map a drafter filename to a regex that matches its target model filenames.
|
|
89
|
+
// e.g. "mtp-gemma-4-31B-it" matches "gemma-4-31B-it*"
|
|
90
|
+
// "gemma-4-31B-it-assistant-Q8_0" matches "gemma-4-31B-it*"
|
|
91
|
+
// "gemma-4-12b-it-MTP-Q8_0" matches "gemma-4-12b-it*"
|
|
92
|
+
function drafterTargetHint(name) {
|
|
93
|
+
const lower = name.toLowerCase();
|
|
94
|
+
// Strip common prefixes/suffixes to get the base model identifier
|
|
95
|
+
let base = lower
|
|
96
|
+
.replace(/^mtp[-_]/i, "")
|
|
97
|
+
.replace(/[-_]mtp$/i, "")
|
|
98
|
+
.replace(/[-_]assistant([-_].*)?$/i, "")
|
|
99
|
+
.replace(/[-_]draft([-_].*)?$/i, "");
|
|
100
|
+
// Remove quant suffix for matching (Q4_K_M, Q8_0, UD-Q4_K_XL, etc.)
|
|
101
|
+
base = base.replace(/[-_]q\d_k_[a-z]+$/i, "").replace(/[-_]q\d_[01]$/i, "").replace(/[-_]ud-[a-z0-9_]+$/i, "");
|
|
102
|
+
return base;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Find a drafter that matches a given target model path/name.
|
|
106
|
+
// Returns the drafter object or null.
|
|
107
|
+
export function matchDrafter(targetPath, drafters) {
|
|
108
|
+
if (!drafters || drafters.length === 0) return null;
|
|
109
|
+
const targetName = basename(targetPath).replace(/\.gguf$/i, "").toLowerCase();
|
|
110
|
+
// Remove quant suffix from target for matching
|
|
111
|
+
const targetBase = targetName
|
|
112
|
+
.replace(/[-_]q\d_k_[a-z]+$/i, "")
|
|
113
|
+
.replace(/[-_]q\d_[01]$/i, "")
|
|
114
|
+
.replace(/[-_]ud-[a-z0-9_]+$/i, "");
|
|
115
|
+
|
|
116
|
+
for (const drafter of drafters) {
|
|
117
|
+
const hint = drafter.targetHint;
|
|
118
|
+
if (targetBase.startsWith(hint) || targetBase === hint) {
|
|
119
|
+
return drafter;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
46
127
|
async function findFiles(root, predicate) {
|
|
47
128
|
const result = [];
|
|
48
129
|
async function walk(dir) {
|
|
@@ -75,4 +156,12 @@ function aliasFromName(name) {
|
|
|
75
156
|
|
|
76
157
|
function quantFromName(name) {
|
|
77
158
|
return name.match(/(Q\d_K_[A-Z]+|Q\d_[01]|UD-[A-Z0-9_]+)/)?.[1];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function safeReadGgufMetadata(path) {
|
|
162
|
+
try {
|
|
163
|
+
return readGgufMetadata(path);
|
|
164
|
+
} catch {
|
|
165
|
+
return {};
|
|
166
|
+
}
|
|
78
167
|
}
|