ollanet 0.5.0 → 0.6.1

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
@@ -32,11 +32,12 @@ They share the same Ollama API; Finetuna shapes the models, ollanet finds and ch
32
32
  - **Discover** Ollama hosts from localhost, `config.hosts`, `OLLANET_HOSTS`, optional Tailscale, and optional LAN scan (`--lan`, off by default)
33
33
  - **Pull** a library model onto a named host (`ollanet pull studio gemma3:12b`) — the server downloads; re-pull updates
34
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
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`
36
37
  - **Persist** chats as `responses/<hash>.json` with topic, machine, model, timestamps
37
38
  - **Continue** any thread with `--chat <hash>`
38
39
  - **Configure** per-machine defaults (model, temperature, context, …)
39
- - **MCP** — `ollanet mcp` exposes scan/prompt/pull/show/rm/ps/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
40
41
  - **Library** — `import { scanNetwork } from "ollanet"` for apps (Node 20+, not the browser)
41
42
 
42
43
  ## Use cases
@@ -125,6 +126,8 @@ ollanet ps studio
125
126
 
126
127
  # Talk to a machine by name, MagicDNS name, or IP
127
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"
128
131
  ollanet prompt 192.168.1.50 gemma4:12b "Hello from the LAN"
129
132
 
130
133
  # Continue later
@@ -154,6 +157,7 @@ for (const s of servers) {
154
157
  |---|---|---|
155
158
  | `scanNetwork()` | localhost + config + `OLLANET_HOSTS` + Tailscale | Button / first “find servers” |
156
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” |
157
161
  | `discoverHosts()` then probe yourself | candidates only, no `/api/tags` | If the app already has its own Ollama client |
158
162
 
159
163
  Pass `config` to skip the file (useful when the app already has its own config):
@@ -172,7 +176,7 @@ await scanNetwork({
172
176
 
173
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.
174
178
 
175
- Requires **ollanet ≥ 0.4.0** (library). **Model management (`pull` / `show` / `rm` / `ps`) needs ≥ 0.5.0.** Bundlers (Vite / SvelteKit SSR):
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.** **`resolveTarget` / `listTargets` need ≥ 0.6.1.** Bundlers (Vite / SvelteKit SSR):
176
180
 
177
181
  ```ts
178
182
  // vite.config.ts
@@ -189,7 +193,8 @@ optimizeDeps: { exclude: ["ollanet"] },
189
193
  | `ollanet show <machine> <model>` | Inspect Modelfile / params / capabilities (`[tuned]` when it looks like Finetuna) |
190
194
  | `ollanet rm <machine> <model> --yes` | Delete a model from that host’s disk |
191
195
  | `ollanet ps [machine]` | Models loaded in VRAM (omit machine = every discovered host) |
192
- | `ollanet prompt …` | Send a prompt / continue a chat |
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` |
193
198
  | `ollanet chats` | List or inspect saved transcripts |
194
199
  | `ollanet bench …` | Benchmark models for tok/s + lightweight quality checks |
195
200
  | `ollanet mcp` | Stdio [MCP](https://modelcontextprotocol.io) server for agents |
@@ -199,6 +204,7 @@ optimizeDeps: { exclude: ["ollanet"] },
199
204
  - `--json` — machine-readable output
200
205
  - `--all` — also probe offline Tailscale peers
201
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).
202
208
 
203
209
  ### `prompt` options
204
210
 
@@ -219,12 +225,36 @@ ollanet prompt --chat <hash> <prompt...>
219
225
  | `--keep-alive <value>` | Keep model loaded (`5m`, `0`, `-1`, …) |
220
226
  | `--format json\|<schema>` | JSON mode / schema |
221
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) |
222
229
  | `--no-stream` | Buffer the full reply |
223
230
  | `--no-save` | Do not write a transcript |
224
231
  | `--json` | Emit JSON (includes chat id when saved) |
225
232
 
226
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.
227
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
+
228
258
  ### `pull` options
229
259
 
230
260
  ```text
@@ -274,8 +304,9 @@ Runs a **stdio MCP server** (no extra npm deps). Cursor / Claude Desktop / any M
274
304
 
275
305
  | Tool | What it does |
276
306
  |---|---|
277
- | `ollanet_scan` | Discover hosts + models (`lan`, `all` optional) |
278
- | `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) |
279
310
  | `ollanet_pull` | Pull / update a model on a host (`machine`, `model`) |
280
311
  | `ollanet_show` | Inspect a model (`machine`, `model`) |
281
312
  | `ollanet_rm` | Delete a model (`machine`, `model`, `confirm: true`) |
@@ -304,11 +335,14 @@ Or, from a checkout: `"command": "pnpm"`, `"args": ["ollanet", "--", "mcp"]` (wi
304
335
  ollanet bench <machine|ip> [model...]
305
336
  ollanet bench <machine|ip> --all
306
337
  ollanet bench localhost gemma4:12b --runs 5 --cold-load --json
338
+ ollanet bench localhost gemma4:12b --hot --runs 5
307
339
  ```
308
340
 
309
341
  Runs a built-in suite (`quick` default, or `--suite full`) against one or more **completion**
310
342
  models: instruction checks once, throughput case repeated (`--runs`, default 3) with
311
- 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`,
312
346
  skips non-completion models (embeddings); multimodal `completion`+`vision` models stay in
313
347
  (early-stop / pass_rate handle text-suite failures). Use `--exclude-vision` to drop them.
314
348
  Saves `benchmarks/<id>.json` (or `~/.ollanet/benchmarks/` when installed).
package/dist/argv.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Shared CLI flag helpers. Commands keep their own usage() and unique flags.
3
+ */
4
+ import type { GenerateSettings } from "./config.js";
5
+ export type UsageFn = () => never;
6
+ export declare function takeValue(args: string[], flag: string, usage: UsageFn): string;
7
+ /** `--name value` or `--name=value`. Returns undefined if `arg` is not this flag. */
8
+ export declare function takeFlag(arg: string, name: string, args: string[], usage: UsageFn): string | undefined;
9
+ export declare function parseNumberFlag(raw: string, flag: string, usage: UsageFn): number;
10
+ export declare function parseKeepAlive(raw: string): string | number;
11
+ export interface SettingsFlagOpts {
12
+ temperature?: boolean;
13
+ numPredict?: boolean;
14
+ system?: boolean;
15
+ }
16
+ /** Shared generate-settings flags. Returns true when `arg` was consumed. */
17
+ export declare function consumeSettingsFlag(arg: string, args: string[], settings: GenerateSettings, usage: UsageFn, opts?: SettingsFlagOpts): boolean;
package/dist/argv.js ADDED
@@ -0,0 +1,73 @@
1
+ export function takeValue(args, flag, usage) {
2
+ const value = args.shift();
3
+ if (!value) {
4
+ console.error(`${flag} requires a value`);
5
+ usage();
6
+ }
7
+ return value;
8
+ }
9
+ /** `--name value` or `--name=value`. Returns undefined if `arg` is not this flag. */
10
+ export function takeFlag(arg, name, args, usage) {
11
+ if (arg === name)
12
+ return takeValue(args, name, usage);
13
+ if (arg.startsWith(`${name}=`))
14
+ return arg.slice(name.length + 1);
15
+ return undefined;
16
+ }
17
+ export function parseNumberFlag(raw, flag, usage) {
18
+ const n = Number(raw);
19
+ if (!Number.isFinite(n)) {
20
+ console.error(`${flag} must be a number (got "${raw}")`);
21
+ usage();
22
+ }
23
+ return n;
24
+ }
25
+ export function parseKeepAlive(raw) {
26
+ const asNum = Number(raw);
27
+ return raw.trim() !== "" && Number.isFinite(asNum) && String(asNum) === raw ? asNum : raw;
28
+ }
29
+ /** Shared generate-settings flags. Returns true when `arg` was consumed. */
30
+ export function consumeSettingsFlag(arg, args, settings, usage, opts = {}) {
31
+ if (arg === "--think") {
32
+ settings.think = true;
33
+ return true;
34
+ }
35
+ if (arg === "--no-think") {
36
+ settings.think = false;
37
+ return true;
38
+ }
39
+ if (opts.system) {
40
+ const system = takeFlag(arg, "--system", args, usage);
41
+ if (system !== undefined) {
42
+ settings.system = system;
43
+ return true;
44
+ }
45
+ }
46
+ if (opts.temperature) {
47
+ if (arg === "-t" || arg === "--temperature" || arg.startsWith("--temperature=")) {
48
+ const raw = arg === "-t" || arg === "--temperature"
49
+ ? takeValue(args, "--temperature", usage)
50
+ : arg.slice("--temperature=".length);
51
+ settings.temperature = parseNumberFlag(raw, "--temperature", usage);
52
+ return true;
53
+ }
54
+ }
55
+ if (opts.numPredict) {
56
+ const raw = takeFlag(arg, "--num-predict", args, usage);
57
+ if (raw !== undefined) {
58
+ settings.num_predict = Math.trunc(parseNumberFlag(raw, "--num-predict", usage));
59
+ return true;
60
+ }
61
+ }
62
+ const numCtx = takeFlag(arg, "--num-ctx", args, usage);
63
+ if (numCtx !== undefined) {
64
+ settings.num_ctx = Math.trunc(parseNumberFlag(numCtx, "--num-ctx", usage));
65
+ return true;
66
+ }
67
+ const keepAlive = takeFlag(arg, "--keep-alive", args, usage);
68
+ if (keepAlive !== undefined) {
69
+ settings.keep_alive = parseKeepAlive(keepAlive);
70
+ return true;
71
+ }
72
+ return false;
73
+ }
@@ -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
@@ -5,7 +5,9 @@ import { defaultModelForHost, loadConfig, machineSettingsForHost, mergeSettings,
5
5
  import { comparabilityKey, getSuiteCases, 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
- import { discoverHosts, envInt, ollamaBaseUrl, resolveHost, shortName, } from "./hosts.js";
8
+ import { envInt, ollamaBaseUrl, shortName } from "./hosts.js";
9
+ import { consumeSettingsFlag, parseNumberFlag, takeFlag } from "./argv.js";
10
+ import { resolveTarget } from "./target.js";
9
11
  import { projectPath } from "./paths.js";
10
12
  import { contextLengthForModel, isCompletionCapable, isVisionCapable, ollamaChat, ollamaShow, ollamaTags, ollamaUnload, ollamaVersion, ollamaPs, psHasModel, shouldWarnNoThinking, vramForModel, waitUntilUnloaded, } from "./ollama-chat.js";
11
13
  const MODEL_COL = 34;
@@ -16,11 +18,16 @@ function usage() {
16
18
  ollanet bench <machine|ip> [model...] [options]
17
19
  ollanet bench <machine|ip> --all [options]
18
20
 
21
+ Examples:
22
+ ollanet bench studio gemma3:12b --runs 5
23
+ ollanet bench studio gemma3:12b --hot --runs 5
24
+
19
25
  Options:
20
26
  --all Every completion-capable model from /api/tags
21
27
  --exclude-vision With --all, skip models that advertise vision
22
28
  --suite quick|full Prompt suite (default quick)
23
29
  --runs <n> Throughput repeats (default 3)
30
+ --hot Discard first throughput run; keep models loaded
24
31
  --warmup / --no-warmup
25
32
  --cold-load Measure cold load via unload + /api/ps
26
33
  --num-predict <n> Pin throughput length (default 256)
@@ -36,22 +43,6 @@ Options:
36
43
  See docs/bench-spec.md for measurement rules.`);
37
44
  process.exit(1);
38
45
  }
39
- function takeValue(args, flag) {
40
- const value = args.shift();
41
- if (!value) {
42
- console.error(`${flag} requires a value`);
43
- usage();
44
- }
45
- return value;
46
- }
47
- function parseNumberFlag(raw, flag) {
48
- const n = Number(raw);
49
- if (!Number.isFinite(n)) {
50
- console.error(`${flag} must be a number (got "${raw}")`);
51
- usage();
52
- }
53
- return n;
54
- }
55
46
  function isLoopback(host) {
56
47
  return (host.source === "localhost" ||
57
48
  host.ip === "127.0.0.1" ||
@@ -85,6 +76,8 @@ function parseArgs(argv) {
85
76
  let all = false;
86
77
  let excludeVision = false;
87
78
  let warmup = true;
79
+ let warmupSet = false;
80
+ let hot = false;
88
81
  let coldLoad = false;
89
82
  let json = false;
90
83
  let save = true;
@@ -126,10 +119,16 @@ function parseArgs(argv) {
126
119
  }
127
120
  if (arg === "--warmup") {
128
121
  warmup = true;
122
+ warmupSet = true;
129
123
  continue;
130
124
  }
131
125
  if (arg === "--no-warmup") {
132
126
  warmup = false;
127
+ warmupSet = true;
128
+ continue;
129
+ }
130
+ if (arg === "--hot") {
131
+ hot = true;
133
132
  continue;
134
133
  }
135
134
  if (arg === "--cold-load") {
@@ -144,57 +143,35 @@ function parseArgs(argv) {
144
143
  failOnError = true;
145
144
  continue;
146
145
  }
147
- if (arg === "--think") {
148
- settings.think = true;
149
- continue;
150
- }
151
- if (arg === "--no-think") {
152
- settings.think = false;
153
- continue;
154
- }
155
146
  if (arg === "--judge") {
156
147
  judge = true;
157
148
  continue;
158
149
  }
159
- if (arg === "--suite" || arg.startsWith("--suite=")) {
160
- const raw = arg.includes("=") ? arg.slice("--suite=".length) : takeValue(args, "--suite");
161
- if (raw !== "quick" && raw !== "full") {
162
- console.error(`Unknown suite "${raw}" (use quick|full)`);
163
- usage();
164
- }
165
- suite = raw;
166
- continue;
167
- }
168
- if (arg === "--runs" || arg.startsWith("--runs=")) {
169
- const raw = arg.includes("=") ? arg.slice("--runs=".length) : takeValue(args, "--runs");
170
- runs = Math.max(1, Math.trunc(parseNumberFlag(raw, "--runs")));
150
+ if (consumeSettingsFlag(arg, args, settings, usage)) {
171
151
  continue;
172
152
  }
173
- if (arg === "--num-predict" || arg.startsWith("--num-predict=")) {
174
- const raw = arg.includes("=")
175
- ? arg.slice("--num-predict=".length)
176
- : takeValue(args, "--num-predict");
177
- throughputNumPredict = Math.trunc(parseNumberFlag(raw, "--num-predict"));
153
+ const suiteRaw = takeFlag(arg, "--suite", args, usage);
154
+ if (suiteRaw !== undefined) {
155
+ if (suiteRaw !== "quick" && suiteRaw !== "full") {
156
+ console.error(`Unknown suite "${suiteRaw}" (use quick|full)`);
157
+ usage();
158
+ }
159
+ suite = suiteRaw;
178
160
  continue;
179
161
  }
180
- if (arg === "--num-ctx" || arg.startsWith("--num-ctx=")) {
181
- const raw = arg.includes("=") ? arg.slice("--num-ctx=".length) : takeValue(args, "--num-ctx");
182
- settings.num_ctx = Math.trunc(parseNumberFlag(raw, "--num-ctx"));
162
+ const runsRaw = takeFlag(arg, "--runs", args, usage);
163
+ if (runsRaw !== undefined) {
164
+ runs = Math.max(1, Math.trunc(parseNumberFlag(runsRaw, "--runs", usage)));
183
165
  continue;
184
166
  }
185
- if (arg === "--keep-alive" || arg.startsWith("--keep-alive=")) {
186
- const raw = arg.includes("=")
187
- ? arg.slice("--keep-alive=".length)
188
- : takeValue(args, "--keep-alive");
189
- const asNum = Number(raw);
190
- settings.keep_alive =
191
- raw.trim() !== "" && Number.isFinite(asNum) && String(asNum) === raw ? asNum : raw;
167
+ const predictRaw = takeFlag(arg, "--num-predict", args, usage);
168
+ if (predictRaw !== undefined) {
169
+ throughputNumPredict = Math.trunc(parseNumberFlag(predictRaw, "--num-predict", usage));
192
170
  continue;
193
171
  }
194
- if (arg === "--judge-model" || arg.startsWith("--judge-model=")) {
195
- judgeModel = arg.includes("=")
196
- ? arg.slice("--judge-model=".length)
197
- : takeValue(args, "--judge-model");
172
+ const judgeRaw = takeFlag(arg, "--judge-model", args, usage);
173
+ if (judgeRaw !== undefined) {
174
+ judgeModel = judgeRaw;
198
175
  continue;
199
176
  }
200
177
  if (arg.startsWith("-")) {
@@ -221,7 +198,8 @@ function parseArgs(argv) {
221
198
  excludeVision,
222
199
  suite,
223
200
  runs,
224
- warmup,
201
+ warmup: hot && !warmupSet ? false : warmup,
202
+ hot,
225
203
  coldLoad,
226
204
  json,
227
205
  save,
@@ -382,12 +360,15 @@ async function runCaseAttempt(opts) {
382
360
  };
383
361
  }
384
362
  }
363
+ function countedAttempts(attempts) {
364
+ return attempts.filter((a) => !a.discarded);
365
+ }
385
366
  function summarizeModel(cases, cold, selfJudge) {
386
367
  const throughput = cases.find((c) => c.id === "throughput");
387
- const fullLen = throughput?.attempts.filter((a) => !a.error && !a.early_stop && a.tok_s != null) ?? [];
368
+ const counted = countedAttempts(throughput?.attempts ?? []);
369
+ const fullLen = counted.filter((a) => !a.error && !a.early_stop && a.tok_s != null);
388
370
  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;
371
+ const earlyStopCount = counted.filter((a) => a.early_stop || (a.done_reason && a.done_reason !== "length")).length;
391
372
  const checkAttempts = cases
392
373
  .filter((c) => c.role === "check")
393
374
  .flatMap((c) => c.attempts)
@@ -404,12 +385,15 @@ function summarizeModel(cases, cold, selfJudge) {
404
385
  self_judge: selfJudge,
405
386
  };
406
387
  }
407
- function printTable(machine, suite, runs, revision, compKey, models, skipped, coldLoad) {
388
+ function printTable(machine, suite, runs, revision, compKey, models, skipped, coldLoad, hot) {
408
389
  const lines = [];
409
- lines.push(`Bench: ${models.length} completion model(s) × suite=${suite} × runs=${runs}`);
390
+ lines.push(`Bench: ${models.length} completion model(s) × suite=${suite} × runs=${runs}` +
391
+ (hot ? " (hot: first discarded)" : ""));
410
392
  lines.push(`comparability_key=${compKey} suite_revision=${revision} timeout: ${BENCH_TIMEOUT_MS}ms/case`);
411
393
  if (coldLoad)
412
394
  lines.push("Cold-load: on");
395
+ if (hot)
396
+ lines.push("Hot: first throughput run discarded; models stay loaded");
413
397
  lines.push("");
414
398
  const header = coldLoad
415
399
  ? pad("Model", MODEL_COL) +
@@ -462,6 +446,8 @@ function printTable(machine, suite, runs, revision, compKey, models, skipped, co
462
446
  notes.push(m.error.slice(0, 60));
463
447
  if (m.summary.early_stop_count > 0)
464
448
  notes.push(`${m.summary.early_stop_count}× early-stop`);
449
+ if (hot)
450
+ notes.push("hot");
465
451
  if (m.summary.self_judge)
466
452
  notes.push("self-judge");
467
453
  const load = m.summary.load_ms != null && m.summary.load_ms > 0
@@ -505,11 +491,7 @@ function pad(s, n) {
505
491
  export async function main() {
506
492
  const parsed = parseArgs(process.argv.slice(2));
507
493
  const config = await loadConfig();
508
- const { hosts: targets } = await discoverHosts({
509
- hosts: config.hosts,
510
- discovery: config.discovery,
511
- });
512
- const host = resolveHost(targets, parsed.machine);
494
+ const host = await resolveTarget(parsed.machine, config);
513
495
  const baseUrl = ollamaBaseUrl(host);
514
496
  const machineLabel = shortName(host);
515
497
  const tags = await ollamaTags(baseUrl, Math.min(BENCH_TIMEOUT_MS, 15_000));
@@ -590,11 +572,13 @@ export async function main() {
590
572
  temperature: 0,
591
573
  think: baseSettings.think === true,
592
574
  numCtx: baseSettings.num_ctx ?? null,
575
+ hot: parsed.hot,
593
576
  });
594
577
  const suiteCases = getSuiteCases(parsed.suite);
578
+ const throughputReps = parsed.runs + (parsed.hot ? 1 : 0);
595
579
  const caseCount = selected.length *
596
580
  (suiteCases.filter((c) => c.role !== "throughput").length +
597
- suiteCases.filter((c) => c.role === "throughput").length * parsed.runs);
581
+ suiteCases.filter((c) => c.role === "throughput").length * throughputReps);
598
582
  if (!parsed.json) {
599
583
  const skipBits = [
600
584
  skipped.filter((s) => s.reason === "non-completion").length
@@ -606,16 +590,22 @@ export async function main() {
606
590
  ].filter(Boolean);
607
591
  console.error(`Bench: ${selected.length} completion model(s)` +
608
592
  (skipBits.length ? ` (skipped ${skipBits.join(", ")})` : "") +
609
- ` × suite=${parsed.suite} × runs=${parsed.runs}`);
593
+ ` × suite=${parsed.suite} × runs=${parsed.runs}` +
594
+ (parsed.hot ? " (hot: +1 discarded)" : ""));
610
595
  console.error(`Cases: ~${caseCount} chat calls timeout: ${BENCH_TIMEOUT_MS}ms/case` +
611
596
  ` 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
- ".");
597
+ if (!parsed.hot && !isLoopback(host) && (selected.length >= 2 || parsed.coldLoad)) {
598
+ const bits = [
599
+ selected.length >= 2 ? "unloads each model before the next" : "",
600
+ parsed.coldLoad ? "unloads for --cold-load" : "",
601
+ ].filter(Boolean);
602
+ console.error(`Note: benchmarking ${bits.join(" and ")} on ${machineLabel}.`);
616
603
  }
617
604
  console.error("Proceeding…");
618
605
  }
606
+ if (parsed.hot) {
607
+ console.error("Hot: discarding the first throughput run; models stay loaded (no unload between models).");
608
+ }
619
609
  if (parsed.settings.think === true) {
620
610
  for (const name of selected) {
621
611
  const caps = await fetchCapabilities(baseUrl, name);
@@ -665,18 +655,22 @@ export async function main() {
665
655
  let modelError;
666
656
  for (const caseDef of suiteCases) {
667
657
  const attempts = [];
668
- const reps = caseDef.role === "throughput" ? parsed.runs : 1;
669
- for (let r = 1; r <= reps; r += 1) {
658
+ const discardFirst = parsed.hot && caseDef.role === "throughput";
659
+ const reps = caseDef.role === "throughput" ? parsed.runs + (discardFirst ? 1 : 0) : 1;
660
+ for (let i = 0; i < reps; i += 1) {
661
+ const discarded = discardFirst && i === 0;
670
662
  const attempt = await runCaseAttempt({
671
663
  baseUrl,
672
664
  model,
673
665
  caseDef,
674
- run: r,
666
+ run: discarded ? 0 : discardFirst ? i : i + 1,
675
667
  settings: baseSettings,
676
668
  throughputNumPredict: parsed.throughputNumPredict,
677
669
  judgeModel,
678
670
  selfJudge,
679
671
  });
672
+ if (discarded)
673
+ attempt.discarded = true;
680
674
  attempts.push(attempt);
681
675
  if (attempt.error) {
682
676
  anyError = true;
@@ -717,8 +711,9 @@ export async function main() {
717
711
  });
718
712
  if (parsed.failFast && modelError)
719
713
  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) {
714
+ // Unload before next model when ≥2 models remain after this one / multi-model run.
715
+ // --hot keeps weights resident so the discarded first shot warms the rest.
716
+ if (!parsed.hot && selected.length >= 2 && mi < selected.length - 1) {
722
717
  try {
723
718
  await ollamaUnload(baseUrl, model, BENCH_TIMEOUT_MS);
724
719
  await waitUntilUnloaded(baseUrl, model, UNLOAD_WAIT_MS);
@@ -755,6 +750,7 @@ export async function main() {
755
750
  throughput_num_predict: parsed.throughputNumPredict,
756
751
  num_ctx: baseSettings.num_ctx ?? null,
757
752
  warmup: parsed.warmup,
753
+ hot: parsed.hot,
758
754
  cold_load: parsed.coldLoad,
759
755
  think: baseSettings.think === true,
760
756
  bench_timeout_ms: BENCH_TIMEOUT_MS,
@@ -778,7 +774,7 @@ export async function main() {
778
774
  }
779
775
  else {
780
776
  process.stdout.write("\n");
781
- printTable(machineLabel, parsed.suite, parsed.runs, revision, compKey, modelResults, skipped, parsed.coldLoad);
777
+ printTable(machineLabel, parsed.suite, parsed.runs, revision, compKey, modelResults, skipped, parsed.coldLoad, parsed.hot);
782
778
  if (parsed.save)
783
779
  console.error(`benchmarks dir: ${benchmarksDir()}`);
784
780
  }
package/dist/chats.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * ollanet chats --id a1b2c3d4e5f6
8
8
  */
9
9
  import { loadChat, listChats, responsesDir, } from "./chat-store.js";
10
+ import { takeFlag } from "./argv.js";
10
11
  function usage() {
11
12
  console.error(`Usage: ollanet chats [--json] [--id <hash>]
12
13
 
@@ -31,12 +32,9 @@ function parseArgs(argv) {
31
32
  json = true;
32
33
  continue;
33
34
  }
34
- if (arg === "--id" || arg.startsWith("--id=")) {
35
- id = arg.includes("=") ? arg.slice("--id=".length) : args.shift();
36
- if (!id) {
37
- console.error("--id requires a value");
38
- usage();
39
- }
35
+ const idFlag = takeFlag(arg, "--id", args, usage);
36
+ if (idFlag !== undefined) {
37
+ id = idFlag;
40
38
  continue;
41
39
  }
42
40
  console.error(`Unknown flag: ${arg}`);
package/dist/cli.js CHANGED
@@ -16,9 +16,11 @@ Discover Ollama hosts on localhost, your LAN, Tailscale, or any IP/hostname
16
16
  you can reach — then chat with hash-addressed transcripts.
17
17
 
18
18
  Usage:
19
- ollanet scan [--json] [--all] [--lan]
19
+ ollanet scan [--json] [--all] [--lan] [--last]
20
20
  ollanet prompt <machine|ip> [model] <prompt...>
21
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>]
22
24
  ollanet pull <machine|ip> <model>
23
25
  ollanet show <machine|ip> <model>
24
26
  ollanet rm <machine|ip> <model> --yes
@@ -30,8 +32,11 @@ Usage:
30
32
  Examples:
31
33
  ollanet scan
32
34
  ollanet scan --lan
35
+ ollanet scan --last # print the last saved scan (no network)
33
36
  ollanet prompt localhost "What is MagicDNS?"
34
- 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"
35
40
  ollanet prompt --chat a1b2c3d4e5f6 "Tell me more"
36
41
  ollanet pull studio gemma3:12b
37
42
  ollanet show studio gemma4-ctx32k
@@ -40,12 +45,14 @@ Examples:
40
45
  ollanet chats
41
46
  ollanet bench localhost --all
42
47
  ollanet bench localhost llama3.2:1b --runs 3
48
+ ollanet bench localhost gemma3:12b --hot --runs 5
43
49
  ollanet mcp # stdio MCP server for agents
44
50
 
45
51
  Config: ~/.ollanet/config.json when installed, ./config.json in a checkout
46
52
  (override with OLLANET_CONFIG)
47
53
  Hosts: config.hosts, OLLANET_HOSTS, optional Tailscale + --lan
48
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)
49
56
  `;
50
57
  async function main() {
51
58
  // pnpm/npm often forward a bare `--`; ignore it.
@@ -109,6 +116,13 @@ async function main() {
109
116
  await run();
110
117
  return;
111
118
  }
119
+ case "compare":
120
+ case "vs":
121
+ case "h2h": {
122
+ const { main: run } = await import("./compare.js");
123
+ await run();
124
+ return;
125
+ }
112
126
  case "bench":
113
127
  case "benchmark": {
114
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
+ }>;