ollanet 0.5.0 → 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
@@ -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.** 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).
@@ -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.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
+ }>;
@@ -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>;