ollanet 0.4.1 → 0.6.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/README.md CHANGED
@@ -18,7 +18,7 @@ This started as a personal little script. I had Ollama running on a laptop on my
18
18
 
19
19
  ## Related: Finetuna
20
20
 
21
- **ollanet** is the client-side / network tool. On the machine that *runs* Ollama, use **[Finetuna](https://github.com/Catalyst-Forge-LLC/finetuna)** to turn a stock model into a GPU-tuned, named variant (context, batch, Modelfile, auto-tune).
21
+ **ollanet** is the client-side / network tool. On the machine that *runs* Ollama, use **[Finetuna](https://finetuna.net)** to turn a stock model into a GPU-tuned, named variant (context, batch, Modelfile, auto-tune).
22
22
 
23
23
  Typical loop:
24
24
 
@@ -30,11 +30,14 @@ They share the same Ollama API; Finetuna shapes the models, ollanet finds and ch
30
30
  ## Features
31
31
 
32
32
  - **Discover** Ollama hosts from localhost, `config.hosts`, `OLLANET_HOSTS`, optional Tailscale, and optional LAN scan (`--lan`, off by default)
33
- - **Prompt** any hostname/IP/model with streaming replies
33
+ - **Pull** a library model onto a named host (`ollanet pull studio gemma3:12b`) — the server downloads; re-pull updates
34
+ - **Show / rm / ps** — inspect a Modelfile, delete a model (`--yes`), see what’s in VRAM. Scan marks Finetuna-style `[tuned]` names
35
+ - **Prompt** any hostname/IP/model with streaming replies (`--file` for `.txt` / `.md`)
36
+ - **Compare** 2–5 models on one host with the same prompt; summary table + `compares/<id>.md`
34
37
  - **Persist** chats as `responses/<hash>.json` with topic, machine, model, timestamps
35
38
  - **Continue** any thread with `--chat <hash>`
36
39
  - **Configure** per-machine defaults (model, temperature, context, …)
37
- - **MCP** — `ollanet mcp` exposes scan/prompt/chats as Model Context Protocol tools for agents
40
+ - **MCP** — `ollanet mcp` exposes scan/prompt/compare/pull/show/rm/ps/chats as Model Context Protocol tools for agents
38
41
  - **Library** — `import { scanNetwork } from "ollanet"` for apps (Node 20+, not the browser)
39
42
 
40
43
  ## Use cases
@@ -44,11 +47,12 @@ The core idea: turn “I have to remember which IP has which models loaded” in
44
47
  **For agents and automation** — prefer `ollanet mcp` (stdio MCP). The same flows also work via CLI flags:
45
48
 
46
49
  - **Live model inventory / routing** — `ollanet_scan` (or `scan --json`) lists reachable hosts and models. Route each sub-task to the best host: the big-VRAM box for reasoning, the laptop for quick lookups.
50
+ - **Put a new model on a box** — `ollanet_pull` (or `pull studio gemma3:12b`) asks that host to download from the registry. Re-pull updates. Then `show` / `ps` / `rm` manage what’s on that disk and in VRAM.
47
51
  - **Conversations that survive machines** — chats are stored by short hash with full history. Start a long thread on the GPU box, continue it later with `ollanet_prompt` + `chat_id` (or `--chat <hash>`).
48
52
  - **A “talk to any Ollama on my network” tool** — `ollanet_prompt` / `prompt … --json --no-stream`; no hard-coded endpoints.
49
53
  - **Embed in an app** — same scan + pick + call as a library. The app does not own the GPUs; it discovers them (FilePress, CLIs, Electron, a local companion for a hosted product).
50
54
  - **Fleet health and speed checks** — periodic `ollanet bench <host> --json` builds a record of which model on which machine is currently fastest (or has silently gone offline). Saved results live in `benchmarks/`.
51
- - **The Finetuna loop** — [Finetuna](https://github.com/Catalyst-Forge-LLC/finetuna) shapes a GPU-tuned named variant on the host; ollanet discovers and uses it from anywhere on the network.
55
+ - **The Finetuna loop** — [Finetuna](https://finetuna.net) shapes a GPU-tuned named variant on the host; ollanet `scan` / `show` / `prompt` it from anywhere on the network. After `pull`, ollanet prints the next step (`finetuna --model …`).
52
56
 
53
57
  **For humans:**
54
58
 
@@ -115,8 +119,15 @@ ollanet scan
115
119
  # Also TCP-scan your local /24s for open Ollama ports
116
120
  ollanet scan --lan
117
121
 
122
+ # Put a library model on a named machine (that host downloads it)
123
+ ollanet pull studio gemma3:12b
124
+ ollanet show studio gemma3:12b
125
+ ollanet ps studio
126
+
118
127
  # Talk to a machine by name, MagicDNS name, or IP
119
128
  ollanet prompt localhost "What is MagicDNS?"
129
+ ollanet prompt localhost --file ./notes.md
130
+ ollanet compare studio gemma3:12b llama3.2:3b --prompt "Explain MagicDNS"
120
131
  ollanet prompt 192.168.1.50 gemma4:12b "Hello from the LAN"
121
132
 
122
133
  # Continue later
@@ -146,6 +157,7 @@ for (const s of servers) {
146
157
  |---|---|---|
147
158
  | `scanNetwork()` | localhost + config + `OLLANET_HOSTS` + Tailscale | Button / first “find servers” |
148
159
  | `scanNetwork({ lanScan: true })` | plus TCP sweep of local `/24`s on port 11434 | Checkbox; never the default scan, never on every health check |
160
+ | `lastScan()` | last saved snapshot, no network | UI “show last inventory” |
149
161
  | `discoverHosts()` then probe yourself | candidates only, no `/api/tags` | If the app already has its own Ollama client |
150
162
 
151
163
  Pass `config` to skip the file (useful when the app already has its own config):
@@ -162,7 +174,9 @@ await scanNetwork({
162
174
 
163
175
  `runPrompt({ save: false, writeStdout: false })` is a one-shot call that does not write `~/.ollanet/responses/`.
164
176
 
165
- Requires **ollanet ≥ 0.4.0**. Bundlers (Vite / SvelteKit SSR):
177
+ `pullModel({ machine, model })` asks that host to download (or update) a library model. The bits never transit ollanet. `showModel`, `removeModel({ yes: true })`, and `listLoaded` are the inspect / delete / VRAM trio.
178
+
179
+ Requires **ollanet ≥ 0.4.0** (library). **Model management (`pull` / `show` / `rm` / `ps`) needs ≥ 0.5.0.** **Compare, `lastScan`, and bench `--hot` need ≥ 0.6.0.** Bundlers (Vite / SvelteKit SSR):
166
180
 
167
181
  ```ts
168
182
  // vite.config.ts
@@ -175,7 +189,12 @@ optimizeDeps: { exclude: ["ollanet"] },
175
189
  | Command | Description |
176
190
  |---|---|
177
191
  | `ollanet scan` | Probe known/discovered hosts for Ollama + list models |
178
- | `ollanet prompt …` | Send a prompt / continue a chat |
192
+ | `ollanet pull <machine> <model>` | Ask that host to download / update a library model |
193
+ | `ollanet show <machine> <model>` | Inspect Modelfile / params / capabilities (`[tuned]` when it looks like Finetuna) |
194
+ | `ollanet rm <machine> <model> --yes` | Delete a model from that host’s disk |
195
+ | `ollanet ps [machine]` | Models loaded in VRAM (omit machine = every discovered host) |
196
+ | `ollanet prompt …` | Send a prompt / continue a chat (`--file` for `.txt` / `.md`) |
197
+ | `ollanet compare …` | Same prompt on 2–5 models; summary + `compares/<id>.md` |
179
198
  | `ollanet chats` | List or inspect saved transcripts |
180
199
  | `ollanet bench …` | Benchmark models for tok/s + lightweight quality checks |
181
200
  | `ollanet mcp` | Stdio [MCP](https://modelcontextprotocol.io) server for agents |
@@ -185,6 +204,7 @@ optimizeDeps: { exclude: ["ollanet"] },
185
204
  - `--json` — machine-readable output
186
205
  - `--all` — also probe offline Tailscale peers
187
206
  - `--lan` — scan local LAN CIDRs for open Ollama ports
207
+ - `--last` / `--cached` — print the last saved scan (no network). Written to `~/.ollanet/last-scan.json` when installed, or `./last-scan.json` in a checkout (`OLLANET_LAST_SCAN` overrides).
188
208
 
189
209
  ### `prompt` options
190
210
 
@@ -205,12 +225,70 @@ ollanet prompt --chat <hash> <prompt...>
205
225
  | `--keep-alive <value>` | Keep model loaded (`5m`, `0`, `-1`, …) |
206
226
  | `--format json\|<schema>` | JSON mode / schema |
207
227
  | `--think` / `--no-think` | Enable/disable model thinking (default **off** so replies aren’t empty on qwen3-style models) |
228
+ | `--file <path>` | Prompt from a `.txt` or `.md` file (joins argv / stdin) |
208
229
  | `--no-stream` | Buffer the full reply |
209
230
  | `--no-save` | Do not write a transcript |
210
231
  | `--json` | Emit JSON (includes chat id when saved) |
211
232
 
212
233
  Machine can be a discovered name, hostname, FQDN, or IP (`192.168.1.50`, `host:11434`). Direct addresses work even if they were never scanned.
213
234
 
235
+ ### `compare` options
236
+
237
+ ```text
238
+ ollanet compare <machine> <model> <model> [model...]
239
+ ollanet compare <machine> <model> <model> [model...] --prompt <text>
240
+ ollanet compare <machine> <model> <model> [model...] --file <path.txt|.md>
241
+ ```
242
+
243
+ Same prompt on **2–5 models** on one host. Prints a tok/s table and writes `compares/<id>.md` (readable) plus `.json`. This is not `bench` (no fixed suite / repeats). Omit `--prompt` / `--file` to use the built-in mesh-host tasting prompt (printed on stderr when used).
244
+
245
+ | Flag | Meaning |
246
+ |---|---|
247
+ | `--prompt` / `-p` | Prompt text (default: built-in) |
248
+ | `--file` / `-f` | Prompt from `.txt` or `.md` |
249
+ | `--system` | System prompt |
250
+ | `--temperature` / `--num-predict` / `--num-ctx` / `--keep-alive` | Same as `prompt` |
251
+ | `--think` / `--no-think` | Thinking (default off) |
252
+ | `--unload` | Unload each model before the next (fairer tok/s) |
253
+ | `--no-save` | Do not write `compares/*` |
254
+ | `--json` | Full record on stdout |
255
+
256
+ Override dir with `OLLANET_COMPARES_DIR`.
257
+
258
+ ### `pull` options
259
+
260
+ ```text
261
+ ollanet pull <machine|ip> <model>
262
+ ```
263
+
264
+ The named machine downloads from the Ollama registry onto **its** disk. Re-pulling the same name updates it. This is not a file upload and not a Finetuna runtime tune.
265
+
266
+ | Flag | Meaning |
267
+ |---|---|
268
+ | `--machine <name>` | Host (same resolution as `prompt`) |
269
+ | `--model <name>` | Model to pull |
270
+ | `--insecure` | Allow HTTP / self-signed registries |
271
+ | `--no-stream` | Single final response (no progress chunks) |
272
+ | `--json` | Result JSON on stdout |
273
+
274
+ Timeout default is none (large pulls can take hours). Override with `OLLAMA_PULL_TIMEOUT_MS`. After a successful pull, stderr points at [Finetuna](https://finetuna.net) for a host-side named variant.
275
+
276
+ ### `show` / `rm` / `ps`
277
+
278
+ ```text
279
+ ollanet show <machine|ip> <model>
280
+ ollanet rm <machine|ip> <model> --yes
281
+ ollanet ps [machine]
282
+ ```
283
+
284
+ | Flag | Meaning |
285
+ |---|---|
286
+ | `--yes` / `-y` | Required for `rm` unless you confirm on a TTY |
287
+ | `--json` | Result JSON on stdout |
288
+ | `--machine` / `--model` | Same host resolution as `prompt` |
289
+
290
+ `scan` is on-disk inventory and marks `*-ctx32k` / `*-flash` / `*finetuna*` as `[tuned]`. `ps` is what’s resident. `show` reads the Modelfile so you can see a Finetuna bake from another machine.
291
+
214
292
  ### `chats` options
215
293
 
216
294
  - `--json` — list summary JSON
@@ -226,8 +304,13 @@ Runs a **stdio MCP server** (no extra npm deps). Cursor / Claude Desktop / any M
226
304
 
227
305
  | Tool | What it does |
228
306
  |---|---|
229
- | `ollanet_scan` | Discover hosts + models (`lan`, `all` optional) |
230
- | `ollanet_prompt` | Prompt a host or continue with `chat_id` |
307
+ | `ollanet_scan` | Discover hosts + models (`lan`, `all`, `last` optional) |
308
+ | `ollanet_prompt` | Prompt a host or continue with `chat_id` (`file` optional) |
309
+ | `ollanet_compare` | Same prompt on 2–5 models (`machine`, `models`; `prompt` / `file` optional) |
310
+ | `ollanet_pull` | Pull / update a model on a host (`machine`, `model`) |
311
+ | `ollanet_show` | Inspect a model (`machine`, `model`) |
312
+ | `ollanet_rm` | Delete a model (`machine`, `model`, `confirm: true`) |
313
+ | `ollanet_ps` | Loaded models (`machine` optional) |
231
314
  | `ollanet_list_chats` | Summarize saved transcripts |
232
315
  | `ollanet_get_chat` | Load one chat (full history) |
233
316
 
@@ -252,11 +335,14 @@ Or, from a checkout: `"command": "pnpm"`, `"args": ["ollanet", "--", "mcp"]` (wi
252
335
  ollanet bench <machine|ip> [model...]
253
336
  ollanet bench <machine|ip> --all
254
337
  ollanet bench localhost gemma4:12b --runs 5 --cold-load --json
338
+ ollanet bench localhost gemma4:12b --hot --runs 5
255
339
  ```
256
340
 
257
341
  Runs a built-in suite (`quick` default, or `--suite full`) against one or more **completion**
258
342
  models: instruction checks once, throughput case repeated (`--runs`, default 3) with
259
- pinned `num_predict` / `seed` / `temperature`. Reports median tok/s + spread. Under `--all`,
343
+ pinned `num_predict` / `seed` / `temperature`. Reports median tok/s + spread.
344
+ `--hot` discards the first throughput run and leaves models loaded so the counted
345
+ repeats are already warm (Finetuna steady-state). Under `--all`,
260
346
  skips non-completion models (embeddings); multimodal `completion`+`vision` models stay in
261
347
  (early-stop / pass_rate handle text-suite failures). Use `--exclude-vision` to drop them.
262
348
  Saves `benchmarks/<id>.json` (or `~/.ollanet/benchmarks/` when installed).
@@ -21,5 +21,7 @@ export declare function comparabilityKey(opts: {
21
21
  temperature: number;
22
22
  think: boolean;
23
23
  numCtx: number | null;
24
+ /** Steady-state tok/s: first throughput run discarded, no inter-model unload. */
25
+ hot?: boolean;
24
26
  }): string;
25
27
  export declare function median(values: number[]): number | undefined;
@@ -134,6 +134,7 @@ export function comparabilityKey(opts) {
134
134
  `temperature=${opts.temperature}`,
135
135
  `think=${opts.think ? 1 : 0}`,
136
136
  `num_ctx=${opts.numCtx ?? ""}`,
137
+ ...(opts.hot ? ["hot=1"] : []),
137
138
  ].join("|");
138
139
  return createHash("sha256").update(payload).digest("hex").slice(0, 16);
139
140
  }
package/dist/bench.js CHANGED
@@ -16,11 +16,16 @@ function usage() {
16
16
  ollanet bench <machine|ip> [model...] [options]
17
17
  ollanet bench <machine|ip> --all [options]
18
18
 
19
+ Examples:
20
+ ollanet bench studio gemma3:12b --runs 5
21
+ ollanet bench studio gemma3:12b --hot --runs 5
22
+
19
23
  Options:
20
24
  --all Every completion-capable model from /api/tags
21
25
  --exclude-vision With --all, skip models that advertise vision
22
26
  --suite quick|full Prompt suite (default quick)
23
27
  --runs <n> Throughput repeats (default 3)
28
+ --hot Discard first throughput run; keep models loaded
24
29
  --warmup / --no-warmup
25
30
  --cold-load Measure cold load via unload + /api/ps
26
31
  --num-predict <n> Pin throughput length (default 256)
@@ -85,6 +90,8 @@ function parseArgs(argv) {
85
90
  let all = false;
86
91
  let excludeVision = false;
87
92
  let warmup = true;
93
+ let warmupSet = false;
94
+ let hot = false;
88
95
  let coldLoad = false;
89
96
  let json = false;
90
97
  let save = true;
@@ -126,10 +133,16 @@ function parseArgs(argv) {
126
133
  }
127
134
  if (arg === "--warmup") {
128
135
  warmup = true;
136
+ warmupSet = true;
129
137
  continue;
130
138
  }
131
139
  if (arg === "--no-warmup") {
132
140
  warmup = false;
141
+ warmupSet = true;
142
+ continue;
143
+ }
144
+ if (arg === "--hot") {
145
+ hot = true;
133
146
  continue;
134
147
  }
135
148
  if (arg === "--cold-load") {
@@ -221,7 +234,8 @@ function parseArgs(argv) {
221
234
  excludeVision,
222
235
  suite,
223
236
  runs,
224
- warmup,
237
+ warmup: hot && !warmupSet ? false : warmup,
238
+ hot,
225
239
  coldLoad,
226
240
  json,
227
241
  save,
@@ -382,12 +396,15 @@ async function runCaseAttempt(opts) {
382
396
  };
383
397
  }
384
398
  }
399
+ function countedAttempts(attempts) {
400
+ return attempts.filter((a) => !a.discarded);
401
+ }
385
402
  function summarizeModel(cases, cold, selfJudge) {
386
403
  const throughput = cases.find((c) => c.id === "throughput");
387
- const fullLen = throughput?.attempts.filter((a) => !a.error && !a.early_stop && a.tok_s != null) ?? [];
404
+ const counted = countedAttempts(throughput?.attempts ?? []);
405
+ const fullLen = counted.filter((a) => !a.error && !a.early_stop && a.tok_s != null);
388
406
  const toks = fullLen.map((a) => a.tok_s);
389
- const earlyStopCount = throughput?.attempts.filter((a) => a.early_stop || (a.done_reason && a.done_reason !== "length"))
390
- .length ?? 0;
407
+ const earlyStopCount = counted.filter((a) => a.early_stop || (a.done_reason && a.done_reason !== "length")).length;
391
408
  const checkAttempts = cases
392
409
  .filter((c) => c.role === "check")
393
410
  .flatMap((c) => c.attempts)
@@ -404,12 +421,15 @@ function summarizeModel(cases, cold, selfJudge) {
404
421
  self_judge: selfJudge,
405
422
  };
406
423
  }
407
- function printTable(machine, suite, runs, revision, compKey, models, skipped, coldLoad) {
424
+ function printTable(machine, suite, runs, revision, compKey, models, skipped, coldLoad, hot) {
408
425
  const lines = [];
409
- lines.push(`Bench: ${models.length} completion model(s) × suite=${suite} × runs=${runs}`);
426
+ lines.push(`Bench: ${models.length} completion model(s) × suite=${suite} × runs=${runs}` +
427
+ (hot ? " (hot: first discarded)" : ""));
410
428
  lines.push(`comparability_key=${compKey} suite_revision=${revision} timeout: ${BENCH_TIMEOUT_MS}ms/case`);
411
429
  if (coldLoad)
412
430
  lines.push("Cold-load: on");
431
+ if (hot)
432
+ lines.push("Hot: first throughput run discarded; models stay loaded");
413
433
  lines.push("");
414
434
  const header = coldLoad
415
435
  ? pad("Model", MODEL_COL) +
@@ -462,6 +482,8 @@ function printTable(machine, suite, runs, revision, compKey, models, skipped, co
462
482
  notes.push(m.error.slice(0, 60));
463
483
  if (m.summary.early_stop_count > 0)
464
484
  notes.push(`${m.summary.early_stop_count}× early-stop`);
485
+ if (hot)
486
+ notes.push("hot");
465
487
  if (m.summary.self_judge)
466
488
  notes.push("self-judge");
467
489
  const load = m.summary.load_ms != null && m.summary.load_ms > 0
@@ -590,11 +612,13 @@ export async function main() {
590
612
  temperature: 0,
591
613
  think: baseSettings.think === true,
592
614
  numCtx: baseSettings.num_ctx ?? null,
615
+ hot: parsed.hot,
593
616
  });
594
617
  const suiteCases = getSuiteCases(parsed.suite);
618
+ const throughputReps = parsed.runs + (parsed.hot ? 1 : 0);
595
619
  const caseCount = selected.length *
596
620
  (suiteCases.filter((c) => c.role !== "throughput").length +
597
- suiteCases.filter((c) => c.role === "throughput").length * parsed.runs);
621
+ suiteCases.filter((c) => c.role === "throughput").length * throughputReps);
598
622
  if (!parsed.json) {
599
623
  const skipBits = [
600
624
  skipped.filter((s) => s.reason === "non-completion").length
@@ -606,16 +630,22 @@ export async function main() {
606
630
  ].filter(Boolean);
607
631
  console.error(`Bench: ${selected.length} completion model(s)` +
608
632
  (skipBits.length ? ` (skipped ${skipBits.join(", ")})` : "") +
609
- ` × suite=${parsed.suite} × runs=${parsed.runs}`);
633
+ ` × suite=${parsed.suite} × runs=${parsed.runs}` +
634
+ (parsed.hot ? " (hot: +1 discarded)" : ""));
610
635
  console.error(`Cases: ~${caseCount} chat calls timeout: ${BENCH_TIMEOUT_MS}ms/case` +
611
636
  ` worst-case ceiling: ~${Math.ceil((caseCount * BENCH_TIMEOUT_MS) / 60000)}m if every call times out`);
612
- if (!isLoopback(host)) {
613
- console.error(`Note: benchmarking unloads models on ${machineLabel} between runs` +
614
- (parsed.coldLoad ? " (and for --cold-load)" : "") +
615
- ".");
637
+ if (!parsed.hot && !isLoopback(host) && (selected.length >= 2 || parsed.coldLoad)) {
638
+ const bits = [
639
+ selected.length >= 2 ? "unloads each model before the next" : "",
640
+ parsed.coldLoad ? "unloads for --cold-load" : "",
641
+ ].filter(Boolean);
642
+ console.error(`Note: benchmarking ${bits.join(" and ")} on ${machineLabel}.`);
616
643
  }
617
644
  console.error("Proceeding…");
618
645
  }
646
+ if (parsed.hot) {
647
+ console.error("Hot: discarding the first throughput run; models stay loaded (no unload between models).");
648
+ }
619
649
  if (parsed.settings.think === true) {
620
650
  for (const name of selected) {
621
651
  const caps = await fetchCapabilities(baseUrl, name);
@@ -665,18 +695,22 @@ export async function main() {
665
695
  let modelError;
666
696
  for (const caseDef of suiteCases) {
667
697
  const attempts = [];
668
- const reps = caseDef.role === "throughput" ? parsed.runs : 1;
669
- for (let r = 1; r <= reps; r += 1) {
698
+ const discardFirst = parsed.hot && caseDef.role === "throughput";
699
+ const reps = caseDef.role === "throughput" ? parsed.runs + (discardFirst ? 1 : 0) : 1;
700
+ for (let i = 0; i < reps; i += 1) {
701
+ const discarded = discardFirst && i === 0;
670
702
  const attempt = await runCaseAttempt({
671
703
  baseUrl,
672
704
  model,
673
705
  caseDef,
674
- run: r,
706
+ run: discarded ? 0 : discardFirst ? i : i + 1,
675
707
  settings: baseSettings,
676
708
  throughputNumPredict: parsed.throughputNumPredict,
677
709
  judgeModel,
678
710
  selfJudge,
679
711
  });
712
+ if (discarded)
713
+ attempt.discarded = true;
680
714
  attempts.push(attempt);
681
715
  if (attempt.error) {
682
716
  anyError = true;
@@ -717,8 +751,9 @@ export async function main() {
717
751
  });
718
752
  if (parsed.failFast && modelError)
719
753
  break;
720
- // Unload before next model when ≥2 models remain after this one / multi-model run
721
- if (selected.length >= 2 && mi < selected.length - 1) {
754
+ // Unload before next model when ≥2 models remain after this one / multi-model run.
755
+ // --hot keeps weights resident so the discarded first shot warms the rest.
756
+ if (!parsed.hot && selected.length >= 2 && mi < selected.length - 1) {
722
757
  try {
723
758
  await ollamaUnload(baseUrl, model, BENCH_TIMEOUT_MS);
724
759
  await waitUntilUnloaded(baseUrl, model, UNLOAD_WAIT_MS);
@@ -755,6 +790,7 @@ export async function main() {
755
790
  throughput_num_predict: parsed.throughputNumPredict,
756
791
  num_ctx: baseSettings.num_ctx ?? null,
757
792
  warmup: parsed.warmup,
793
+ hot: parsed.hot,
758
794
  cold_load: parsed.coldLoad,
759
795
  think: baseSettings.think === true,
760
796
  bench_timeout_ms: BENCH_TIMEOUT_MS,
@@ -778,7 +814,7 @@ export async function main() {
778
814
  }
779
815
  else {
780
816
  process.stdout.write("\n");
781
- printTable(machineLabel, parsed.suite, parsed.runs, revision, compKey, modelResults, skipped, parsed.coldLoad);
817
+ printTable(machineLabel, parsed.suite, parsed.runs, revision, compKey, modelResults, skipped, parsed.coldLoad, parsed.hot);
782
818
  if (parsed.save)
783
819
  console.error(`benchmarks dir: ${benchmarksDir()}`);
784
820
  }
package/dist/cli.d.ts CHANGED
@@ -4,6 +4,10 @@
4
4
  *
5
5
  * ollanet scan
6
6
  * ollanet prompt <machine-or-ip> "hello"
7
+ * ollanet pull <machine> <model>
8
+ * ollanet show <machine> <model>
9
+ * ollanet rm <machine> <model> --yes
10
+ * ollanet ps [machine]
7
11
  * ollanet chats
8
12
  */
9
13
  export {};
package/dist/cli.js CHANGED
@@ -4,6 +4,10 @@
4
4
  *
5
5
  * ollanet scan
6
6
  * ollanet prompt <machine-or-ip> "hello"
7
+ * ollanet pull <machine> <model>
8
+ * ollanet show <machine> <model>
9
+ * ollanet rm <machine> <model> --yes
10
+ * ollanet ps [machine]
7
11
  * ollanet chats
8
12
  */
9
13
  const HELP = `ollanet — Ollama over your network
@@ -12,9 +16,15 @@ Discover Ollama hosts on localhost, your LAN, Tailscale, or any IP/hostname
12
16
  you can reach — then chat with hash-addressed transcripts.
13
17
 
14
18
  Usage:
15
- ollanet scan [--json] [--all] [--lan]
19
+ ollanet scan [--json] [--all] [--lan] [--last]
16
20
  ollanet prompt <machine|ip> [model] <prompt...>
17
21
  ollanet prompt --chat <hash> <prompt...>
22
+ ollanet prompt <machine|ip> --file <path.txt|.md>
23
+ ollanet compare <machine> <model> <model> [model...] [--prompt <text> | --file <path>]
24
+ ollanet pull <machine|ip> <model>
25
+ ollanet show <machine|ip> <model>
26
+ ollanet rm <machine|ip> <model> --yes
27
+ ollanet ps [machine]
18
28
  ollanet chats [--json] [--id <hash>]
19
29
  ollanet bench <machine|ip> [model...] [--all] [options]
20
30
  ollanet mcp
@@ -22,18 +32,27 @@ Usage:
22
32
  Examples:
23
33
  ollanet scan
24
34
  ollanet scan --lan
35
+ ollanet scan --last # print the last saved scan (no network)
25
36
  ollanet prompt localhost "What is MagicDNS?"
26
- ollanet prompt 192.168.1.50 gemma4:12b "Hello"
37
+ ollanet prompt localhost --file ./notes.md
38
+ ollanet compare studio gemma3:12b llama3.2:3b
39
+ ollanet compare studio gemma3:12b llama3.2:3b --prompt "Explain MagicDNS"
27
40
  ollanet prompt --chat a1b2c3d4e5f6 "Tell me more"
41
+ ollanet pull studio gemma3:12b
42
+ ollanet show studio gemma4-ctx32k
43
+ ollanet rm studio llama3.2:1b --yes
44
+ ollanet ps studio
28
45
  ollanet chats
29
46
  ollanet bench localhost --all
30
47
  ollanet bench localhost llama3.2:1b --runs 3
48
+ ollanet bench localhost gemma3:12b --hot --runs 5
31
49
  ollanet mcp # stdio MCP server for agents
32
50
 
33
51
  Config: ~/.ollanet/config.json when installed, ./config.json in a checkout
34
52
  (override with OLLANET_CONFIG)
35
53
  Hosts: config.hosts, OLLANET_HOSTS, optional Tailscale + --lan
36
54
  Chats: ~/.ollanet/responses or ./responses (override with OLLANET_RESPONSES_DIR)
55
+ Scan: ~/.ollanet/last-scan.json or ./last-scan.json (override with OLLANET_LAST_SCAN)
37
56
  `;
38
57
  async function main() {
39
58
  // pnpm/npm often forward a bare `--`; ignore it.
@@ -73,6 +92,37 @@ async function main() {
73
92
  await run();
74
93
  return;
75
94
  }
95
+ case "pull":
96
+ case "download": {
97
+ const { main: run } = await import("./pull.js");
98
+ await run();
99
+ return;
100
+ }
101
+ case "show":
102
+ case "inspect": {
103
+ const { main: run } = await import("./show.js");
104
+ await run();
105
+ return;
106
+ }
107
+ case "rm":
108
+ case "delete": {
109
+ const { main: run } = await import("./rm.js");
110
+ await run();
111
+ return;
112
+ }
113
+ case "ps":
114
+ case "loaded": {
115
+ const { main: run } = await import("./ps.js");
116
+ await run();
117
+ return;
118
+ }
119
+ case "compare":
120
+ case "vs":
121
+ case "h2h": {
122
+ const { main: run } = await import("./compare.js");
123
+ await run();
124
+ return;
125
+ }
76
126
  case "bench":
77
127
  case "benchmark": {
78
128
  const { main: run } = await import("./bench.js");
@@ -0,0 +1,8 @@
1
+ import type { CompareRecord } from "./compare.js";
2
+ export declare function comparesDir(): string;
3
+ export declare function newCompareId(): string;
4
+ export declare function renderCompareMarkdown(record: CompareRecord): string;
5
+ export declare function saveCompare(record: CompareRecord): Promise<{
6
+ json: string;
7
+ md: string;
8
+ }>;
@@ -0,0 +1,55 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { defaultComparesDir } from "./paths.js";
5
+ export function comparesDir() {
6
+ return process.env.OLLANET_COMPARES_DIR ?? defaultComparesDir();
7
+ }
8
+ export function newCompareId() {
9
+ return randomBytes(6).toString("hex");
10
+ }
11
+ function tokLabel(n) {
12
+ return n == null || !Number.isFinite(n) ? "—" : n.toFixed(1);
13
+ }
14
+ export function renderCompareMarkdown(record) {
15
+ const lines = [
16
+ `# Compare ${record.id}`,
17
+ "",
18
+ `- **host:** ${record.machine} (${record.endpoint})`,
19
+ `- **when:** ${record.created_at}`,
20
+ `- **models:** ${record.results.map((r) => r.model).join(", ")}`,
21
+ ...(record.default_prompt
22
+ ? ["- **prompt:** default (override with --prompt / --file)"]
23
+ : []),
24
+ "",
25
+ "## Prompt",
26
+ "",
27
+ record.prompt.trimEnd(),
28
+ "",
29
+ ];
30
+ if (record.system) {
31
+ lines.push("## System", "", record.system.trimEnd(), "");
32
+ }
33
+ for (const r of record.results) {
34
+ lines.push(`## ${r.model}`, "");
35
+ if (r.error) {
36
+ lines.push(`- **error:** ${r.error}`, "");
37
+ continue;
38
+ }
39
+ lines.push(`- **tok/s:** ${tokLabel(r.tok_s)}`, `- **tokens:** ${r.eval_count ?? "—"}`, `- **wall:** ${r.wall_ms != null ? `${(r.wall_ms / 1000).toFixed(2)}s` : "—"}`, `- **done:** ${r.done_reason ?? "—"}`, "");
40
+ if (r.thinking) {
41
+ lines.push("### Thinking", "", r.thinking.trimEnd(), "");
42
+ }
43
+ lines.push(r.content?.trimEnd() || "_(empty reply)_", "");
44
+ }
45
+ return `${lines.join("\n")}\n`;
46
+ }
47
+ export async function saveCompare(record) {
48
+ const dir = comparesDir();
49
+ await mkdir(dir, { recursive: true });
50
+ const json = path.join(dir, `${record.id}.json`);
51
+ const md = path.join(dir, `${record.id}.md`);
52
+ await writeFile(json, `${JSON.stringify(record, null, 2)}\n`, "utf8");
53
+ await writeFile(md, renderCompareMarkdown(record), "utf8");
54
+ return { json, md };
55
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Head-to-head: one prompt, 2–5 models on the same host.
3
+ *
4
+ * ollanet compare studio gemma3:12b llama3.2:3b --prompt "Explain MagicDNS"
5
+ * ollanet compare studio m1 m2 --file task.md
6
+ */
7
+ import { type AppConfig, type GenerateSettings } from "./config.js";
8
+ /** Used when --prompt, --file, and stdin are all omitted. */
9
+ export declare const DEFAULT_COMPARE_PROMPT: string;
10
+ export interface CompareModelResult {
11
+ model: string;
12
+ content?: string;
13
+ thinking?: string;
14
+ tok_s?: number;
15
+ eval_count?: number;
16
+ eval_duration?: number;
17
+ total_duration?: number;
18
+ load_duration?: number;
19
+ wall_ms?: number;
20
+ done_reason?: string;
21
+ error?: string;
22
+ }
23
+ export interface CompareRecord {
24
+ id: string;
25
+ created_at: string;
26
+ machine: string;
27
+ endpoint: string;
28
+ prompt: string;
29
+ /** True when the built-in default prompt was used. */
30
+ default_prompt: boolean;
31
+ system?: string;
32
+ results: CompareModelResult[];
33
+ files?: {
34
+ json: string;
35
+ md: string;
36
+ };
37
+ }
38
+ export interface CompareOptions {
39
+ machine: string;
40
+ models: string[];
41
+ prompt?: string;
42
+ file?: string;
43
+ settings?: GenerateSettings;
44
+ save?: boolean;
45
+ unload?: boolean;
46
+ writeStdout?: boolean;
47
+ quiet?: boolean;
48
+ config?: Partial<AppConfig>;
49
+ timeoutMs?: number;
50
+ }
51
+ export declare function runCompare(opts: CompareOptions): Promise<CompareRecord>;
52
+ export declare function main(): Promise<void>;