ollanet 0.6.2 → 0.6.4

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.
@@ -2,6 +2,8 @@
2
2
  * Built-in ollanet bench suites, checkers, and revision hashes.
3
3
  */
4
4
  import { createHash } from "node:crypto";
5
+ /** Pinned length for `full`'s prose throughput case (`throughput_long`). */
6
+ export const LONG_THROUGHPUT_NUM_PREDICT = 1024;
5
7
  const MATH_ANSWER = 323;
6
8
  const REASON_ANSWER = 42;
7
9
  function lastTokenNormalized(content) {
@@ -104,18 +106,23 @@ const FULL_EXTRA = [
104
106
  {
105
107
  id: "reason",
106
108
  role: "check",
107
- prompt: "A box has 10 apples. You eat 3, then buy 5 more, then give away 2 times as many as you ate. " +
108
- "How many apples are left? Reply with only the final number.",
109
+ prompt: "Start with 40. Add 7. Subtract 5. Reply with only the final number.",
109
110
  num_predict: 48,
110
- // 10 - 3 + 5 - 6 = 6... wait: gave away 2*3=6, so 10-3+5-6=6. Spec said 42 as placeholder.
111
- // Use a problem that clearly ends at 42:
112
- // Actually rewrite for 42: "Start with 40. Add 7. Subtract 5. Reply with only the number." => 42
113
111
  check: checkExpectedInt(REASON_ANSWER),
114
112
  },
113
+ {
114
+ id: "throughput_long",
115
+ role: "throughput",
116
+ // Chat-shaped tokens (markdown, lists, a table) at a long pin. Anti-EOS
117
+ // so done_reason=length; one shot (not --runs) so full stays usable.
118
+ prompt: "Write a detailed trade-off briefing for a product team choosing a web UI stack. " +
119
+ "Compare at least two options. Use markdown headings, bullet lists, and at least one table. " +
120
+ "Keep adding sections (testing, hiring, migration, failure modes, cost) without wrapping up. " +
121
+ "Do not write a closing recommendation that ends the piece. Do not stop early.",
122
+ num_predict: LONG_THROUGHPUT_NUM_PREDICT,
123
+ repeats: 1,
124
+ },
115
125
  ];
116
- // Fix reason prompt to match REASON_ANSWER
117
- FULL_EXTRA[FULL_EXTRA.length - 1].prompt =
118
- "Start with 40. Add 7. Subtract 5. Reply with only the final number.";
119
126
  export function getSuiteCases(suite) {
120
127
  if (suite === "full")
121
128
  return [...QUICK_CASES, ...FULL_EXTRA];
package/dist/bench.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * ollanet bench — speed + lightweight quality suite against Ollama models.
3
3
  */
4
- import { defaultModelForHost, loadConfig, machineSettingsForHost, mergeSettings, settingsFromEnv, } from "./config.js";
5
- import { comparabilityKey, getSuiteCases, median, suiteRevision, } from "./bench-suite.js";
4
+ import { defaultModelForHost, loadConfig, lookupAlias, machineSettingsForHost, mergeSettings, settingsFromEnv, } from "./config.js";
5
+ import { comparabilityKey, getSuiteCases, LONG_THROUGHPUT_NUM_PREDICT, median, suiteRevision, } from "./bench-suite.js";
6
6
  import { benchmarksDir, newBenchId, saveBenchmark } from "./bench-store.js";
7
7
  import { readFile } from "node:fs/promises";
8
8
  import { envInt, ollamaBaseUrl, shortName } from "./hosts.js";
@@ -23,19 +23,20 @@ Examples:
23
23
  ollanet bench studio gemma3:12b --hot --runs 5
24
24
  ollanet help bench
25
25
 
26
- Fixed suite (not compare). Checks run once; throughput repeats --runs times.
26
+ Fixed suite (not compare). Checks run once; 256-token throughput repeats --runs times.
27
27
  Median tok/s excludes early-stops. Unload is between models, not between repeats.
28
+ full adds json/reason checks plus one 1024-token prose generation (tok/s long).
28
29
 
29
30
  Options:
30
31
  --all Every completion-capable model from /api/tags
31
32
  --exclude-vision With --all, skip models that advertise vision
32
33
  --suite quick|full Prompt suite (default quick)
33
- --runs <n> Counted throughput repeats (default 3)
34
- --hot Discard first throughput run; keep models loaded
34
+ --runs <n> Counted 256-token throughput repeats (default 3)
35
+ --hot Discard first 256-token throughput run; keep models loaded
35
36
  --warmup Short discarded call before timed cases (default on)
36
37
  --no-warmup Skip the short warmup (--hot implies this)
37
38
  --cold-load Measure cold load via unload + /api/ps
38
- --num-predict <n> Pin throughput length (default 256)
39
+ --num-predict <n> Pin 256-token throughput length (default 256; not the long case)
39
40
  --num-ctx <n> Context window override
40
41
  --keep-alive <value> Keep model loaded (e.g. 5m, 0, -1)
41
42
  --think / --no-think Thinking tokens (default off)
@@ -65,6 +66,26 @@ function resolveModelName(tags, query) {
65
66
  const prefix = tags.find((t) => t.name.toLowerCase().startsWith(`${q}:`) || q.startsWith(`${t.name.toLowerCase()}:`));
66
67
  return prefix?.name;
67
68
  }
69
+ function pinnedNumPredict(caseDef, throughputNumPredict) {
70
+ if (caseDef.role === "throughput") {
71
+ return caseDef.num_predict ?? throughputNumPredict;
72
+ }
73
+ return caseDef.num_predict ?? 32;
74
+ }
75
+ /** At least the global timeout; longer pins need room to finish at ~8 tok/s. */
76
+ function attemptTimeoutMs(numPredict) {
77
+ return Math.max(BENCH_TIMEOUT_MS, Math.ceil(numPredict / 8) * 1000);
78
+ }
79
+ function caseReps(caseDef, runs, hot) {
80
+ if (caseDef.role !== "throughput")
81
+ return 1;
82
+ if (caseDef.repeats != null)
83
+ return caseDef.repeats;
84
+ return runs + (hot ? 1 : 0);
85
+ }
86
+ function discardFirstThroughput(caseDef, hot) {
87
+ return hot && caseDef.role === "throughput" && caseDef.repeats == null;
88
+ }
68
89
  function tokPerSec(chunk) {
69
90
  if (chunk.eval_count == null || chunk.eval_duration == null || chunk.eval_duration <= 0) {
70
91
  return undefined;
@@ -285,9 +306,7 @@ async function runColdLoad(baseUrl, model, keepAlive) {
285
306
  }
286
307
  }
287
308
  async function runCaseAttempt(opts) {
288
- const numPredict = opts.caseDef.role === "throughput"
289
- ? opts.throughputNumPredict
290
- : (opts.caseDef.num_predict ?? 32);
309
+ const numPredict = pinnedNumPredict(opts.caseDef, opts.throughputNumPredict);
291
310
  const settings = mergeSettings(opts.settings, {
292
311
  temperature: 0,
293
312
  seed: 0,
@@ -303,7 +322,7 @@ async function runCaseAttempt(opts) {
303
322
  messages: [{ role: "user", content: opts.caseDef.prompt }],
304
323
  stream: false,
305
324
  writeStdout: false,
306
- timeoutMs: BENCH_TIMEOUT_MS,
325
+ timeoutMs: attemptTimeoutMs(numPredict),
307
326
  settings,
308
327
  });
309
328
  const wallMs = Date.now() - wall0;
@@ -373,12 +392,23 @@ async function runCaseAttempt(opts) {
373
392
  function countedAttempts(attempts) {
374
393
  return attempts.filter((a) => !a.discarded);
375
394
  }
376
- function summarizeModel(cases, cold, selfJudge) {
377
- const throughput = cases.find((c) => c.id === "throughput");
378
- const counted = countedAttempts(throughput?.attempts ?? []);
395
+ function speedFrom(cases, id) {
396
+ const counted = countedAttempts(cases.find((c) => c.id === id)?.attempts ?? []);
379
397
  const fullLen = counted.filter((a) => !a.error && !a.early_stop && a.tok_s != null);
380
398
  const toks = fullLen.map((a) => a.tok_s);
381
- const earlyStopCount = counted.filter((a) => a.early_stop || (a.done_reason && a.done_reason !== "length")).length;
399
+ return {
400
+ median: median(toks) ?? null,
401
+ min: toks.length ? Math.min(...toks) : null,
402
+ max: toks.length ? Math.max(...toks) : null,
403
+ };
404
+ }
405
+ function summarizeModel(cases, cold, selfJudge) {
406
+ const peak = speedFrom(cases, "throughput");
407
+ const long = speedFrom(cases, "throughput_long");
408
+ const throughputAttempts = cases
409
+ .filter((c) => c.role === "throughput")
410
+ .flatMap((c) => countedAttempts(c.attempts));
411
+ const earlyStopCount = throughputAttempts.filter((a) => a.early_stop || (a.done_reason && a.done_reason !== "length")).length;
382
412
  const checkAttempts = cases
383
413
  .filter((c) => c.role === "check")
384
414
  .flatMap((c) => c.attempts)
@@ -386,9 +416,12 @@ function summarizeModel(cases, cold, selfJudge) {
386
416
  const passed = checkAttempts.filter((a) => a.quality?.pass).length;
387
417
  const passRate = checkAttempts.length > 0 ? passed / checkAttempts.length : null;
388
418
  return {
389
- tok_s_median: median(toks) ?? null,
390
- tok_s_min: toks.length ? Math.min(...toks) : null,
391
- tok_s_max: toks.length ? Math.max(...toks) : null,
419
+ tok_s_median: peak.median,
420
+ tok_s_min: peak.min,
421
+ tok_s_max: peak.max,
422
+ tok_s_long_median: long.median,
423
+ tok_s_long_min: long.min,
424
+ tok_s_long_max: long.max,
392
425
  early_stop_count: earlyStopCount,
393
426
  pass_rate: passRate,
394
427
  load_ms: cold && cold.error == null && cold.load_ms > 0 ? cold.load_ms : cold?.error ? null : cold?.load_ms ?? null,
@@ -404,17 +437,22 @@ function printTable(machine, suite, runs, revision, compKey, models, skipped, co
404
437
  lines.push("Cold-load: on");
405
438
  if (hot)
406
439
  lines.push("Hot: first throughput run discarded; models stay loaded");
440
+ if (suite === "full")
441
+ lines.push("full: tok/s long is one 1024-token prose generation");
407
442
  lines.push("");
443
+ const showLong = suite === "full";
408
444
  const header = coldLoad
409
445
  ? pad("Model", MODEL_COL) +
410
446
  pad("tok/s (med)", 12) +
411
447
  pad("spread", 12) +
448
+ (showLong ? pad("tok/s long", 12) : "") +
412
449
  pad("load", 8) +
413
450
  pad("pass", 8) +
414
451
  "notes"
415
452
  : pad("Model", MODEL_COL) +
416
453
  pad("tok/s (med)", 12) +
417
454
  pad("spread", 12) +
455
+ (showLong ? pad("tok/s long", 12) : "") +
418
456
  pad("pass", 8) +
419
457
  "notes";
420
458
  lines.push(header);
@@ -448,6 +486,7 @@ function printTable(machine, suite, runs, revision, compKey, models, skipped, co
448
486
  const spread = m.summary.tok_s_min != null && m.summary.tok_s_max != null
449
487
  ? `${m.summary.tok_s_min.toFixed(1)}–${m.summary.tok_s_max.toFixed(1)}`
450
488
  : "—";
489
+ const tokLong = m.summary.tok_s_long_median != null ? m.summary.tok_s_long_median.toFixed(1) : "—";
451
490
  const pass = m.summary.pass_rate != null
452
491
  ? `${Math.round(m.summary.pass_rate * checkDenom(m))}/${checkDenom(m)}`
453
492
  : "—";
@@ -467,12 +506,14 @@ function printTable(machine, suite, runs, revision, compKey, models, skipped, co
467
506
  ? pad(m.name, MODEL_COL) +
468
507
  pad(tok, 12) +
469
508
  pad(spread, 12) +
509
+ (showLong ? pad(tokLong, 12) : "") +
470
510
  pad(load, 8) +
471
511
  pad(pass, 8) +
472
512
  notes.join("; ")
473
513
  : pad(m.name, MODEL_COL) +
474
514
  pad(tok, 12) +
475
515
  pad(spread, 12) +
516
+ (showLong ? pad(tokLong, 12) : "") +
476
517
  pad(pass, 8) +
477
518
  notes.join("; ");
478
519
  lines.push(row);
@@ -501,7 +542,16 @@ function pad(s, n) {
501
542
  export async function main() {
502
543
  const parsed = parseArgs(process.argv.slice(2));
503
544
  const config = await loadConfig();
504
- const host = await resolveTarget(parsed.machine, config);
545
+ const alias = lookupAlias(config, parsed.machine);
546
+ let machineQuery = parsed.machine;
547
+ let models = [...parsed.models];
548
+ if (alias) {
549
+ machineQuery = alias.machine;
550
+ if (models.length === 0 && !parsed.all) {
551
+ models = [alias.model];
552
+ }
553
+ }
554
+ const host = await resolveTarget(machineQuery, config);
505
555
  const baseUrl = ollamaBaseUrl(host);
506
556
  const machineLabel = shortName(host);
507
557
  const tags = await ollamaTags(baseUrl, Math.min(BENCH_TIMEOUT_MS, 15_000));
@@ -528,8 +578,8 @@ export async function main() {
528
578
  selected.push(t.name);
529
579
  }
530
580
  }
531
- else if (parsed.models.length > 0) {
532
- for (const q of parsed.models) {
581
+ else if (models.length > 0) {
582
+ for (const q of models) {
533
583
  const resolved = resolveModelName(tags, q);
534
584
  if (!resolved) {
535
585
  console.error(`Unknown model "${q}". Available: ${tagNames.join(", ")}`);
@@ -546,7 +596,7 @@ export async function main() {
546
596
  else {
547
597
  const def = defaultModelForHost(config, host);
548
598
  if (!def) {
549
- console.error(`No model specified for "${machineLabel}". Pass model names, use --all, or set defaultModels.`);
599
+ console.error(`No model specified for "${machineLabel}". Pass model names, use --all, set defaultModels, or use an alias.`);
550
600
  process.exit(1);
551
601
  }
552
602
  const name = resolveModelName(tags, def);
@@ -585,10 +635,13 @@ export async function main() {
585
635
  hot: parsed.hot,
586
636
  });
587
637
  const suiteCases = getSuiteCases(parsed.suite);
588
- const throughputReps = parsed.runs + (parsed.hot ? 1 : 0);
589
638
  const caseCount = selected.length *
590
- (suiteCases.filter((c) => c.role !== "throughput").length +
591
- suiteCases.filter((c) => c.role === "throughput").length * throughputReps);
639
+ suiteCases.reduce((n, c) => n + caseReps(c, parsed.runs, parsed.hot), 0);
640
+ const worstCaseMs = selected.length *
641
+ suiteCases.reduce((n, c) => {
642
+ const pin = pinnedNumPredict(c, parsed.throughputNumPredict);
643
+ return n + caseReps(c, parsed.runs, parsed.hot) * attemptTimeoutMs(pin);
644
+ }, 0);
592
645
  if (!parsed.json) {
593
646
  const skipBits = [
594
647
  skipped.filter((s) => s.reason === "non-completion").length
@@ -603,7 +656,10 @@ export async function main() {
603
656
  ` × suite=${parsed.suite} × runs=${parsed.runs}` +
604
657
  (parsed.hot ? " (hot: +1 discarded)" : ""));
605
658
  console.error(`Cases: ~${caseCount} chat calls timeout: ${BENCH_TIMEOUT_MS}ms/case` +
606
- ` worst-case ceiling: ~${Math.ceil((caseCount * BENCH_TIMEOUT_MS) / 60000)}m if every call times out`);
659
+ (parsed.suite === "full"
660
+ ? ` (long case ≥${attemptTimeoutMs(LONG_THROUGHPUT_NUM_PREDICT)}ms)`
661
+ : "") +
662
+ ` worst-case ceiling: ~${Math.ceil(worstCaseMs / 60000)}m if every call times out`);
607
663
  if (!parsed.hot && !isLoopback(host) && (selected.length >= 2 || parsed.coldLoad)) {
608
664
  const bits = [
609
665
  selected.length >= 2 ? "unloads each model before the next" : "",
@@ -665,8 +721,8 @@ export async function main() {
665
721
  let modelError;
666
722
  for (const caseDef of suiteCases) {
667
723
  const attempts = [];
668
- const discardFirst = parsed.hot && caseDef.role === "throughput";
669
- const reps = caseDef.role === "throughput" ? parsed.runs + (discardFirst ? 1 : 0) : 1;
724
+ const discardFirst = discardFirstThroughput(caseDef, parsed.hot);
725
+ const reps = caseReps(caseDef, parsed.runs, parsed.hot);
670
726
  for (let i = 0; i < reps; i += 1) {
671
727
  const discarded = discardFirst && i === 0;
672
728
  const attempt = await runCaseAttempt({
@@ -758,6 +814,7 @@ export async function main() {
758
814
  temperature: 0,
759
815
  seed: 0,
760
816
  throughput_num_predict: parsed.throughputNumPredict,
817
+ throughput_long_num_predict: parsed.suite === "full" ? LONG_THROUGHPUT_NUM_PREDICT : null,
761
818
  num_ctx: baseSettings.num_ctx ?? null,
762
819
  warmup: parsed.warmup,
763
820
  hot: parsed.hot,
package/dist/cli.d.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * ollanet — chat with Ollama servers on any reachable network.
3
+ * ollanet — Ollama over any reachable network (CLI, MCP, library).
4
4
  *
5
5
  * ollanet scan
6
6
  * ollanet prompt <machine-or-ip> "hello"
7
+ * ollanet compare <machine> <model> <model>
7
8
  * ollanet pull <machine> <model>
8
9
  * ollanet show <machine> <model>
9
10
  * ollanet rm <machine> <model> --yes
10
11
  * ollanet ps [machine]
12
+ * ollanet bench <machine> [model...]
11
13
  * ollanet chats
14
+ * ollanet mcp
12
15
  */
13
16
  export {};
package/dist/cli.js CHANGED
@@ -1,19 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * ollanet — chat with Ollama servers on any reachable network.
3
+ * ollanet — Ollama over any reachable network (CLI, MCP, library).
4
4
  *
5
5
  * ollanet scan
6
6
  * ollanet prompt <machine-or-ip> "hello"
7
+ * ollanet compare <machine> <model> <model>
7
8
  * ollanet pull <machine> <model>
8
9
  * ollanet show <machine> <model>
9
10
  * ollanet rm <machine> <model> --yes
10
11
  * ollanet ps [machine]
12
+ * ollanet bench <machine> [model...]
11
13
  * ollanet chats
14
+ * ollanet mcp
12
15
  */
13
16
  const HELP = `ollanet — Ollama over your network
14
17
 
15
- Discover Ollama hosts on localhost, your LAN, Tailscale, or any IP/hostname
16
- you can reach — then chat with hash-addressed transcripts.
18
+ Discover hosts, manage models, prompt and compare, bench tok/s, and continue
19
+ chats by hash. CLI, MCP, and a Node library — same mesh.
17
20
 
18
21
  Usage:
19
22
  ollanet scan [--json] [--all] [--lan] [--last]
@@ -27,6 +30,7 @@ Usage:
27
30
  ollanet ps [machine]
28
31
  ollanet chats [--json] [--id <hash>]
29
32
  ollanet bench <machine|ip> [model...] [--all] [options]
33
+ ollanet alias list|add|rm …
30
34
  ollanet mcp
31
35
  ollanet help [command]
32
36
 
@@ -44,6 +48,8 @@ Examples:
44
48
  ollanet rm studio llama3.2:1b --yes
45
49
  ollanet ps studio
46
50
  ollanet chats
51
+ ollanet alias add desk studio gemma3:12b
52
+ ollanet prompt desk "hello" # expands machine + model
47
53
  ollanet bench localhost --all
48
54
  ollanet bench localhost llama3.2:1b --runs 3
49
55
  ollanet bench localhost gemma3:12b --hot --runs 5
@@ -53,6 +59,7 @@ Examples:
53
59
  Config: ~/.ollanet/config.json when installed, ./config.json in a checkout
54
60
  (override with OLLANET_CONFIG)
55
61
  Hosts: config.hosts, OLLANET_HOSTS, optional Tailscale + --lan
62
+ Aliases: config.aliases — ollanet alias add|list|rm
56
63
  Chats: ~/.ollanet/responses or ./responses (override with OLLANET_RESPONSES_DIR)
57
64
  Scan: ~/.ollanet/last-scan.json or ./last-scan.json (override with OLLANET_LAST_SCAN)
58
65
  `;
@@ -162,6 +169,12 @@ async function main() {
162
169
  await run();
163
170
  return;
164
171
  }
172
+ case "alias":
173
+ case "aliases": {
174
+ const { main: run } = await import("./alias.js");
175
+ await run();
176
+ return;
177
+ }
165
178
  default:
166
179
  console.error(`Unknown command: ${cmd}\n`);
167
180
  process.stderr.write(HELP);
package/dist/compare.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * ollanet compare studio gemma3:12b llama3.2:3b --prompt "Explain MagicDNS"
5
5
  * ollanet compare studio m1 m2 --file task.md
6
6
  */
7
- import { configFromPartial, loadConfig, machineSettingsForHost, mergeSettings, settingsFromEnv, } from "./config.js";
7
+ import { configFromPartial, loadConfig, lookupAlias, machineSettingsForHost, mergeSettings, settingsFromEnv, } from "./config.js";
8
8
  import { envInt, ollamaBaseUrl, shortName } from "./hosts.js";
9
9
  import { consumeSettingsFlag, failUsage, isHelpFlag, printHelp, takeFlag, takeValue } from "./argv.js";
10
10
  import { resolveTarget } from "./target.js";
@@ -244,6 +244,14 @@ export async function main() {
244
244
  console.error("Machine is required.");
245
245
  usage();
246
246
  }
247
+ const config = await loadConfig();
248
+ const alias = lookupAlias(config, parsed.machine);
249
+ const machine = alias ? alias.machine : parsed.machine;
250
+ let models = [...parsed.models];
251
+ if (alias && models.length === 1) {
252
+ // `compare desk other:7b` → alias model vs other on alias host
253
+ models = [alias.model, models[0]];
254
+ }
247
255
  const { stdin: stdinStream } = await import("node:process");
248
256
  let fromStdin = "";
249
257
  if (!stdinStream.isTTY) {
@@ -265,14 +273,15 @@ export async function main() {
265
273
  }
266
274
  const prompt = assemblePrompt({ argv: parsed.prompt, file: fileText, stdin: fromStdin });
267
275
  const record = await runCompare({
268
- machine: parsed.machine,
269
- models: parsed.models,
276
+ machine,
277
+ models,
270
278
  prompt: prompt || undefined,
271
279
  settings: parsed.settings,
272
280
  save: parsed.save,
273
281
  unload: parsed.unload,
274
282
  writeStdout: !parsed.json,
275
283
  quiet: parsed.json,
284
+ config,
276
285
  });
277
286
  if (record.default_prompt) {
278
287
  process.stderr.write("Using default compare prompt (pass --prompt or --file to override).\n");
package/dist/config.d.ts CHANGED
@@ -17,6 +17,11 @@ export interface GenerateSettings {
17
17
  */
18
18
  think?: boolean;
19
19
  }
20
+ /** Named shortcut for a frequent machine + model pair. */
21
+ export interface ModelAlias {
22
+ machine: string;
23
+ model: string;
24
+ }
20
25
  export interface AppConfig {
21
26
  /** Explicit hosts/IPs to probe (LAN, VPN, MagicDNS, etc.). */
22
27
  hosts: HostConfigEntry[];
@@ -28,10 +33,28 @@ export interface AppConfig {
28
33
  machineDefaults: Record<string, GenerateSettings & {
29
34
  model?: string;
30
35
  }>;
36
+ /** Shortcuts: `ollanet prompt <alias> "…"` expands to machine + model. */
37
+ aliases: Record<string, ModelAlias>;
31
38
  }
32
39
  /** Build an AppConfig from an in-memory partial (no file I/O). */
33
40
  export declare function configFromPartial(partial?: Partial<AppConfig>): AppConfig;
34
41
  export declare function loadConfig(): Promise<AppConfig>;
42
+ /**
43
+ * Read-modify-write the config JSON on disk. Preserves unknown keys.
44
+ * Creates the parent directory when missing (installed `~/.ollanet/`).
45
+ */
46
+ export declare function mutateConfigFile(mutate: (file: Record<string, unknown>) => void): Promise<void>;
47
+ /** Alias names: letter start, then letters/digits/_/- (no colons — those look like models). */
48
+ export declare function isValidAliasName(name: string): boolean;
49
+ export declare function lookupAlias(config: AppConfig, name: string | undefined): ModelAlias | undefined;
50
+ /**
51
+ * If `machine` is an alias name, expand to that alias's host.
52
+ * When `model` is omitted, fill it from the alias too.
53
+ */
54
+ export declare function expandMachineModel(config: AppConfig, machine: string | undefined, model: string | undefined): {
55
+ machine?: string;
56
+ model?: string;
57
+ };
35
58
  export declare function normalizeSettings(input: unknown): GenerateSettings;
36
59
  export declare function parseFormat(value: unknown): GenerateSettings["format"] | undefined;
37
60
  export declare function hostLookupKeys(host: HostTarget): string[];
package/dist/config.js CHANGED
@@ -1,4 +1,5 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
2
3
  import { shortName, } from "./hosts.js";
3
4
  import { defaultConfigPath } from "./paths.js";
4
5
  function emptyConfig() {
@@ -8,6 +9,7 @@ function emptyConfig() {
8
9
  defaultModels: {},
9
10
  defaults: {},
10
11
  machineDefaults: {},
12
+ aliases: {},
11
13
  };
12
14
  }
13
15
  /** Build an AppConfig from an in-memory partial (no file I/O). */
@@ -19,6 +21,7 @@ export function configFromPartial(partial = {}) {
19
21
  defaultModels: partial.defaultModels ?? empty.defaultModels,
20
22
  defaults: partial.defaults ?? empty.defaults,
21
23
  machineDefaults: partial.machineDefaults ?? empty.machineDefaults,
24
+ aliases: partial.aliases ?? empty.aliases,
22
25
  };
23
26
  }
24
27
  const CONFIG_PATH = process.env.OLLANET_CONFIG ??
@@ -42,6 +45,7 @@ export async function loadConfig() {
42
45
  defaultModels,
43
46
  defaults: normalizeSettings(parsed.defaults),
44
47
  machineDefaults,
48
+ aliases: normalizeAliases(parsed.aliases),
45
49
  };
46
50
  }
47
51
  catch (err) {
@@ -52,6 +56,29 @@ export async function loadConfig() {
52
56
  throw new Error(`Failed to read config at ${CONFIG_PATH}: ${err instanceof Error ? err.message : err}`);
53
57
  }
54
58
  }
59
+ /**
60
+ * Read-modify-write the config JSON on disk. Preserves unknown keys.
61
+ * Creates the parent directory when missing (installed `~/.ollanet/`).
62
+ */
63
+ export async function mutateConfigFile(mutate) {
64
+ let file = {};
65
+ try {
66
+ const raw = await readFile(CONFIG_PATH, "utf8");
67
+ const parsed = JSON.parse(raw);
68
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
69
+ file = parsed;
70
+ }
71
+ }
72
+ catch (err) {
73
+ const code = err.code;
74
+ if (code !== "ENOENT") {
75
+ throw new Error(`Failed to read config at ${CONFIG_PATH}: ${err instanceof Error ? err.message : err}`);
76
+ }
77
+ }
78
+ mutate(file);
79
+ await mkdir(path.dirname(CONFIG_PATH), { recursive: true });
80
+ await writeFile(CONFIG_PATH, `${JSON.stringify(file, null, 2)}\n`, "utf8");
81
+ }
55
82
  function normalizeDiscovery(input) {
56
83
  if (!input || typeof input !== "object")
57
84
  return {};
@@ -77,6 +104,51 @@ function normalizeStringMap(map) {
77
104
  }
78
105
  return out;
79
106
  }
107
+ function normalizeAliases(map) {
108
+ if (!map || typeof map !== "object" || Array.isArray(map))
109
+ return {};
110
+ const out = {};
111
+ for (const [key, value] of Object.entries(map)) {
112
+ const name = key.trim().toLowerCase();
113
+ if (!name || !value || typeof value !== "object" || Array.isArray(value))
114
+ continue;
115
+ const row = value;
116
+ const machine = typeof row.machine === "string" ? row.machine.trim() : "";
117
+ const model = typeof row.model === "string" ? row.model.trim() : "";
118
+ if (!machine || !model)
119
+ continue;
120
+ out[name] = { machine, model };
121
+ }
122
+ return out;
123
+ }
124
+ /** Alias names: letter start, then letters/digits/_/- (no colons — those look like models). */
125
+ export function isValidAliasName(name) {
126
+ return /^[a-z][a-z0-9_-]*$/i.test(name.trim());
127
+ }
128
+ export function lookupAlias(config, name) {
129
+ if (!name?.trim())
130
+ return undefined;
131
+ return config.aliases[name.trim().toLowerCase()];
132
+ }
133
+ /**
134
+ * If `machine` is an alias name, expand to that alias's host.
135
+ * When `model` is omitted, fill it from the alias too.
136
+ */
137
+ export function expandMachineModel(config, machine, model) {
138
+ if (!machine?.trim())
139
+ return { machine, model };
140
+ const alias = lookupAlias(config, machine);
141
+ if (!alias) {
142
+ return {
143
+ machine: machine.trim(),
144
+ model: model?.trim() ? model.trim() : undefined,
145
+ };
146
+ }
147
+ return {
148
+ machine: alias.machine,
149
+ model: model?.trim() ? model.trim() : alias.model,
150
+ };
151
+ }
80
152
  function normalizeMachineDefaults(map) {
81
153
  const out = {};
82
154
  for (const [key, value] of Object.entries(map ?? {})) {
package/dist/help.js CHANGED
@@ -11,6 +11,7 @@ const LOADERS = {
11
11
  ps: () => import("./ps.js"),
12
12
  compare: () => import("./compare.js"),
13
13
  bench: () => import("./bench.js"),
14
+ alias: () => import("./alias.js"),
14
15
  mcp: () => import("./mcp.js"),
15
16
  };
16
17
  const ALIASES = {
@@ -26,6 +27,7 @@ const ALIASES = {
26
27
  vs: "compare",
27
28
  h2h: "compare",
28
29
  benchmark: "bench",
30
+ aliases: "alias",
29
31
  };
30
32
  export function canonicalCommand(name) {
31
33
  const key = name.trim().toLowerCase();
package/dist/index.d.ts CHANGED
@@ -13,8 +13,8 @@ export { lastScan, lastScanPath, loadLastScan } from "./scan-store.js";
13
13
  export type { StoredScan } from "./scan-store.js";
14
14
  export { discoverHosts, resolveHost, ollamaBaseUrl, shortName, ollamaPort, } from "./hosts.js";
15
15
  export type { DiscoverOptions, DiscoveryResult, HostTarget, HostSource, HostConfigEntry, } from "./hosts.js";
16
- export { loadConfig, defaultModelForHost, mergeSettings, configFromPartial, } from "./config.js";
17
- export type { AppConfig, DiscoveryConfig, GenerateSettings } from "./config.js";
16
+ export { loadConfig, defaultModelForHost, mergeSettings, configFromPartial, lookupAlias, expandMachineModel, } from "./config.js";
17
+ export type { AppConfig, DiscoveryConfig, GenerateSettings, ModelAlias } from "./config.js";
18
18
  export { runPrompt } from "./prompt.js";
19
19
  export type { PromptRunOptions, PromptRunResult } from "./prompt.js";
20
20
  export { runCompare, DEFAULT_COMPARE_PROMPT } from "./compare.js";
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@
10
10
  export { scanNetwork } from "./scan.js";
11
11
  export { lastScan, lastScanPath, loadLastScan } from "./scan-store.js";
12
12
  export { discoverHosts, resolveHost, ollamaBaseUrl, shortName, ollamaPort, } from "./hosts.js";
13
- export { loadConfig, defaultModelForHost, mergeSettings, configFromPartial, } from "./config.js";
13
+ export { loadConfig, defaultModelForHost, mergeSettings, configFromPartial, lookupAlias, expandMachineModel, } from "./config.js";
14
14
  export { runPrompt } from "./prompt.js";
15
15
  export { runCompare, DEFAULT_COMPARE_PROMPT } from "./compare.js";
16
16
  export { assemblePrompt, readPromptFile } from "./prompt-input.js";
package/dist/mcp.d.ts CHANGED
@@ -2,7 +2,8 @@
2
2
  * ollanet mcp — stdio MCP server (zero deps).
3
3
  *
4
4
  * Speaks the Model Context Protocol over stdin/stdout so agents (Cursor,
5
- * Claude Desktop, etc.) can discover hosts, prompt models, and continue chats.
5
+ * Claude Desktop, etc.) can discover hosts, manage models, prompt, compare,
6
+ * and continue chats.
6
7
  *
7
8
  * ollanet mcp
8
9
  *
package/dist/mcp.js CHANGED
@@ -2,7 +2,8 @@
2
2
  * ollanet mcp — stdio MCP server (zero deps).
3
3
  *
4
4
  * Speaks the Model Context Protocol over stdin/stdout so agents (Cursor,
5
- * Claude Desktop, etc.) can discover hosts, prompt models, and continue chats.
5
+ * Claude Desktop, etc.) can discover hosts, manage models, prompt, compare,
6
+ * and continue chats.
6
7
  *
7
8
  * ollanet mcp
8
9
  *
@@ -200,7 +201,7 @@ const TOOLS = [
200
201
  {
201
202
  name: "ollanet_ps",
202
203
  description: "List models currently loaded in VRAM. Optional machine; omit to probe every discovered host. " +
203
- "scan is on-disk inventory; ps is what is resident right now.",
204
+ "scan is on-disk inventory; ps is what is resident right now, with CPU/GPU %.",
204
205
  inputSchema: {
205
206
  type: "object",
206
207
  properties: {
@@ -74,6 +74,8 @@ export declare function ollamaVersion(baseUrl: string, timeoutMs: number): Promi
74
74
  export interface PsModel {
75
75
  name?: string;
76
76
  model?: string;
77
+ /** Total resident bytes (VRAM + CPU/RAM). */
78
+ size?: number;
77
79
  size_vram?: number;
78
80
  context_length?: number;
79
81
  digest?: string;