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.
@@ -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>;
@@ -0,0 +1,288 @@
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 { configFromPartial, loadConfig, machineSettingsForHost, mergeSettings, settingsFromEnv, } from "./config.js";
8
+ import { envInt, ollamaBaseUrl, shortName } from "./hosts.js";
9
+ import { consumeSettingsFlag, takeFlag, takeValue } from "./argv.js";
10
+ import { resolveTarget } from "./target.js";
11
+ import { ollamaChat, ollamaTags, ollamaUnload, waitUntilUnloaded, } from "./ollama-chat.js";
12
+ import { assemblePrompt, readPromptFile } from "./prompt-input.js";
13
+ import { comparesDir, newCompareId, saveCompare } from "./compare-store.js";
14
+ const MIN_MODELS = 2;
15
+ const MAX_MODELS = 5;
16
+ /** Used when --prompt, --file, and stdin are all omitted. */
17
+ export const DEFAULT_COMPARE_PROMPT = "In 6–10 sentences, explain how you’d pick an Ollama host on a mixed LAN/Tailscale mesh " +
18
+ "for a 12B chat model. Then give one failure mode (wrong host, cold load, or VRAM spill) " +
19
+ "and how you’d confirm it. Be specific.";
20
+ function compareTimeoutMs() {
21
+ return envInt("OLLAMA_COMPARE_TIMEOUT_MS", envInt("OLLAMA_PROMPT_TIMEOUT_MS", 600_000));
22
+ }
23
+ function unloadWaitMs() {
24
+ return envInt("OLLAMA_COMPARE_UNLOAD_WAIT_MS", 60_000);
25
+ }
26
+ function usage() {
27
+ console.error(`Usage:
28
+ ollanet compare <machine> <model> <model> [model...]
29
+ ollanet compare <machine> <model> <model> [model...] --prompt <text>
30
+ ollanet compare <machine> <model> <model> [model...] --file <path.txt|.md>
31
+
32
+ Examples:
33
+ ollanet compare studio gemma3:12b llama3.2:3b
34
+ ollanet compare studio gemma3:12b llama3.2:3b --prompt "Explain MagicDNS"
35
+ ollanet compare studio gemma3:12b qwen2.5:7b llama3.2:3b --file ./task.md
36
+
37
+ Runs the same prompt on 2–5 models on one host. Prints a summary table and
38
+ writes compares/<id>.md plus .json (prompt, replies, stats). Not a bench suite.
39
+ Omit --prompt/--file to use the built-in mesh-host tasting prompt.
40
+
41
+ Options:
42
+ --prompt <text> Prompt text (default: built-in mesh-host prompt)
43
+ --file <path> Prompt from a .txt or .md file
44
+ --system <text> System prompt
45
+ --temperature <n>
46
+ --num-predict <n>
47
+ --num-ctx <n>
48
+ --keep-alive <value>
49
+ --think / --no-think
50
+ --unload Unload each model before the next (fairer tok/s)
51
+ --no-save Do not write compares/*
52
+ --json Emit the full record on stdout`);
53
+ process.exit(1);
54
+ }
55
+ function parseArgs(argv) {
56
+ const args = [...argv];
57
+ let prompt;
58
+ let file;
59
+ let json = false;
60
+ let save = true;
61
+ let unload = false;
62
+ const settings = {};
63
+ const positional = [];
64
+ while (args.length > 0) {
65
+ const arg = args.shift();
66
+ if (arg === "--")
67
+ continue;
68
+ if (arg === "--help" || arg === "-h")
69
+ usage();
70
+ if (arg === "--json") {
71
+ json = true;
72
+ continue;
73
+ }
74
+ if (arg === "--no-save") {
75
+ save = false;
76
+ continue;
77
+ }
78
+ if (arg === "--unload") {
79
+ unload = true;
80
+ continue;
81
+ }
82
+ if (consumeSettingsFlag(arg, args, settings, usage, {
83
+ temperature: true,
84
+ numPredict: true,
85
+ system: true,
86
+ })) {
87
+ continue;
88
+ }
89
+ if (arg === "-p" || arg === "--prompt" || arg.startsWith("--prompt=")) {
90
+ prompt = arg === "-p" ? takeValue(args, "--prompt", usage) : takeFlag(arg, "--prompt", args, usage);
91
+ continue;
92
+ }
93
+ if (arg === "-f" || arg === "--file" || arg.startsWith("--file=")) {
94
+ file = arg === "-f" ? takeValue(args, "--file", usage) : takeFlag(arg, "--file", args, usage);
95
+ continue;
96
+ }
97
+ if (arg.startsWith("-")) {
98
+ console.error(`Unknown flag: ${arg}`);
99
+ usage();
100
+ }
101
+ positional.push(arg);
102
+ }
103
+ const machine = positional[0];
104
+ const models = positional.slice(1);
105
+ return { machine, models, prompt, file, json, save, unload, settings };
106
+ }
107
+ function tokPerSec(chunk) {
108
+ if (chunk.eval_count == null || chunk.eval_duration == null || chunk.eval_duration <= 0) {
109
+ return undefined;
110
+ }
111
+ return chunk.eval_count / (chunk.eval_duration / 1e9);
112
+ }
113
+ function resolveModelName(available, query) {
114
+ const q = query.toLowerCase();
115
+ const exact = available.find((n) => n.toLowerCase() === q);
116
+ if (exact)
117
+ return exact;
118
+ const prefix = available.filter((n) => n.toLowerCase().startsWith(`${q}:`) || q.startsWith(`${n.toLowerCase()}:`));
119
+ return prefix.length === 1 ? prefix[0] : undefined;
120
+ }
121
+ function printSummary(record) {
122
+ const nameWidth = Math.max(8, ...record.results.map((r) => r.model.length));
123
+ process.stderr.write(`Compare on ${record.machine} ${record.results.length} models ${record.prompt.length} char prompt\n`);
124
+ process.stderr.write(`${"model".padEnd(nameWidth)} ${"tok/s".padStart(7)} ${"tokens".padStart(6)} ${"wall".padStart(7)} done\n`);
125
+ for (const r of record.results) {
126
+ if (r.error) {
127
+ process.stderr.write(`${r.model.padEnd(nameWidth)} ERROR ${r.error}\n`);
128
+ continue;
129
+ }
130
+ const toks = r.tok_s != null ? r.tok_s.toFixed(1) : "—";
131
+ const tokens = r.eval_count != null ? String(r.eval_count) : "—";
132
+ const wall = r.wall_ms != null ? `${(r.wall_ms / 1000).toFixed(2)}s` : "—";
133
+ process.stderr.write(`${r.model.padEnd(nameWidth)} ${toks.padStart(7)} ${tokens.padStart(6)} ${wall.padStart(7)} ${r.done_reason ?? "—"}\n`);
134
+ }
135
+ }
136
+ export async function runCompare(opts) {
137
+ const models = opts.models.map((m) => m.trim()).filter(Boolean);
138
+ if (models.length < MIN_MODELS || models.length > MAX_MODELS) {
139
+ throw new Error(`Compare needs ${MIN_MODELS}–${MAX_MODELS} models (got ${models.length}).`);
140
+ }
141
+ let fileText;
142
+ if (opts.file) {
143
+ fileText = await readPromptFile(opts.file);
144
+ }
145
+ const assembled = assemblePrompt({ argv: opts.prompt, file: fileText });
146
+ const defaulted = !assembled;
147
+ const prompt = assembled || DEFAULT_COMPARE_PROMPT;
148
+ const config = opts.config ? configFromPartial(opts.config) : await loadConfig();
149
+ const host = await resolveTarget(opts.machine, config);
150
+ const baseUrl = ollamaBaseUrl(host);
151
+ const timeoutMs = opts.timeoutMs ?? compareTimeoutMs();
152
+ let available = null;
153
+ try {
154
+ available = (await ollamaTags(baseUrl, Math.min(timeoutMs || 5000, 10_000))).map((t) => t.name);
155
+ }
156
+ catch {
157
+ available = null;
158
+ }
159
+ const resolved = [];
160
+ for (const name of models) {
161
+ if (available) {
162
+ const hit = resolveModelName(available, name);
163
+ if (!hit) {
164
+ throw new Error(`Unknown model "${name}" on ${shortName(host)}. Available: ${available.join(", ")}`);
165
+ }
166
+ resolved.push(hit);
167
+ }
168
+ else {
169
+ resolved.push(name);
170
+ }
171
+ }
172
+ const settings = mergeSettings({ think: false }, config.defaults, machineSettingsForHost(config, host), settingsFromEnv(), opts.settings);
173
+ const quiet = opts.quiet === true || opts.writeStdout === false;
174
+ const results = [];
175
+ for (let i = 0; i < resolved.length; i += 1) {
176
+ const model = resolved[i];
177
+ if (!quiet) {
178
+ process.stderr.write(`→ ${model} (${i + 1}/${resolved.length})\n`);
179
+ }
180
+ const started = Date.now();
181
+ try {
182
+ const { content, thinking, chunk } = await ollamaChat({
183
+ baseUrl,
184
+ model,
185
+ messages: [
186
+ ...(settings.system ? [{ role: "system", content: settings.system }] : []),
187
+ { role: "user", content: prompt },
188
+ ],
189
+ stream: false,
190
+ writeStdout: false,
191
+ timeoutMs,
192
+ settings,
193
+ });
194
+ results.push({
195
+ model,
196
+ content,
197
+ thinking: thinking || undefined,
198
+ tok_s: tokPerSec(chunk),
199
+ eval_count: chunk.eval_count,
200
+ eval_duration: chunk.eval_duration,
201
+ total_duration: chunk.total_duration,
202
+ load_duration: chunk.load_duration,
203
+ wall_ms: Date.now() - started,
204
+ done_reason: chunk.done_reason,
205
+ });
206
+ }
207
+ catch (err) {
208
+ results.push({
209
+ model,
210
+ wall_ms: Date.now() - started,
211
+ error: err instanceof Error ? err.message : String(err),
212
+ });
213
+ }
214
+ if (opts.unload && i < resolved.length - 1) {
215
+ try {
216
+ await ollamaUnload(baseUrl, model, Math.min(timeoutMs || 30_000, 30_000));
217
+ await waitUntilUnloaded(baseUrl, model, unloadWaitMs());
218
+ }
219
+ catch {
220
+ // Fairness hint only — do not fail the compare.
221
+ }
222
+ }
223
+ }
224
+ const record = {
225
+ id: newCompareId(),
226
+ created_at: new Date().toISOString(),
227
+ machine: shortName(host),
228
+ endpoint: baseUrl,
229
+ prompt,
230
+ default_prompt: defaulted,
231
+ system: settings.system,
232
+ results,
233
+ };
234
+ if (opts.save !== false) {
235
+ record.files = await saveCompare(record);
236
+ }
237
+ return record;
238
+ }
239
+ export async function main() {
240
+ const parsed = parseArgs(process.argv.slice(2));
241
+ if (!parsed.machine) {
242
+ console.error("Machine is required.");
243
+ usage();
244
+ }
245
+ const { stdin: stdinStream } = await import("node:process");
246
+ let fromStdin = "";
247
+ if (!stdinStream.isTTY) {
248
+ const chunks = [];
249
+ for await (const chunk of stdinStream)
250
+ chunks.push(chunk);
251
+ fromStdin = Buffer.concat(chunks).toString("utf8").trim();
252
+ }
253
+ let fileText;
254
+ if (parsed.file) {
255
+ try {
256
+ fileText = await readPromptFile(parsed.file);
257
+ }
258
+ catch (err) {
259
+ console.error(err instanceof Error ? err.message : err);
260
+ process.exitCode = 1;
261
+ return;
262
+ }
263
+ }
264
+ const prompt = assemblePrompt({ argv: parsed.prompt, file: fileText, stdin: fromStdin });
265
+ const record = await runCompare({
266
+ machine: parsed.machine,
267
+ models: parsed.models,
268
+ prompt: prompt || undefined,
269
+ settings: parsed.settings,
270
+ save: parsed.save,
271
+ unload: parsed.unload,
272
+ writeStdout: !parsed.json,
273
+ quiet: parsed.json,
274
+ });
275
+ if (record.default_prompt) {
276
+ process.stderr.write("Using default compare prompt (pass --prompt or --file to override).\n");
277
+ }
278
+ if (parsed.json) {
279
+ process.stdout.write(`${JSON.stringify(record, null, 2)}\n`);
280
+ return;
281
+ }
282
+ printSummary(record);
283
+ if (record.files) {
284
+ process.stderr.write(`saved ${record.files.md}\n`);
285
+ process.stderr.write(` ${record.files.json}\n`);
286
+ process.stderr.write(`compares dir: ${comparesDir()}\n`);
287
+ }
288
+ }
package/dist/index.d.ts CHANGED
@@ -8,13 +8,18 @@
8
8
  * const { servers } = await scanNetwork({ lanScan: false });
9
9
  */
10
10
  export { scanNetwork } from "./scan.js";
11
- export type { ScanOptions, ScanPayload, ScannedServer } from "./scan.js";
11
+ export type { ScanOptions, ScanPayload, ScannedServer, ScanFailure } from "./scan.js";
12
+ export { lastScan, lastScanPath, loadLastScan } from "./scan-store.js";
13
+ export type { StoredScan } from "./scan-store.js";
12
14
  export { discoverHosts, resolveHost, ollamaBaseUrl, shortName, ollamaPort, } from "./hosts.js";
13
15
  export type { DiscoverOptions, DiscoveryResult, HostTarget, HostSource, HostConfigEntry, } from "./hosts.js";
14
16
  export { loadConfig, defaultModelForHost, mergeSettings, configFromPartial, } from "./config.js";
15
17
  export type { AppConfig, DiscoveryConfig, GenerateSettings } from "./config.js";
16
18
  export { runPrompt } from "./prompt.js";
17
19
  export type { PromptRunOptions, PromptRunResult } from "./prompt.js";
20
+ export { runCompare, DEFAULT_COMPARE_PROMPT } from "./compare.js";
21
+ export type { CompareOptions, CompareRecord, CompareModelResult } from "./compare.js";
22
+ export { assemblePrompt, readPromptFile } from "./prompt-input.js";
18
23
  export { pullModel } from "./pull.js";
19
24
  export type { PullOptions, PullResult } from "./pull.js";
20
25
  export { showModel } from "./show.js";
@@ -24,5 +29,6 @@ export type { RemoveOptions, RemoveResult } from "./rm.js";
24
29
  export { listLoaded } from "./ps.js";
25
30
  export type { PsOptions, PsResult, LoadedHost, LoadedModel } from "./ps.js";
26
31
  export { looksTuned } from "./tuned.js";
32
+ export { resolveTarget, listTargets } from "./target.js";
27
33
  export { ollamaChat, ollamaTags, ollamaShow, ollamaPs, ollamaPsRequired, ollamaPull, ollamaDelete, } from "./ollama-chat.js";
28
34
  export type { PullChunk, OllamaPullOptions, OllamaPullResult, OllamaShowInfo, } from "./ollama-chat.js";
package/dist/index.js CHANGED
@@ -8,12 +8,16 @@
8
8
  * const { servers } = await scanNetwork({ lanScan: false });
9
9
  */
10
10
  export { scanNetwork } from "./scan.js";
11
+ export { lastScan, lastScanPath, loadLastScan } from "./scan-store.js";
11
12
  export { discoverHosts, resolveHost, ollamaBaseUrl, shortName, ollamaPort, } from "./hosts.js";
12
13
  export { loadConfig, defaultModelForHost, mergeSettings, configFromPartial, } from "./config.js";
13
14
  export { runPrompt } from "./prompt.js";
15
+ export { runCompare, DEFAULT_COMPARE_PROMPT } from "./compare.js";
16
+ export { assemblePrompt, readPromptFile } from "./prompt-input.js";
14
17
  export { pullModel } from "./pull.js";
15
18
  export { showModel } from "./show.js";
16
19
  export { removeModel } from "./rm.js";
17
20
  export { listLoaded } from "./ps.js";
18
21
  export { looksTuned } from "./tuned.js";
22
+ export { resolveTarget, listTargets } from "./target.js";
19
23
  export { ollamaChat, ollamaTags, ollamaShow, ollamaPs, ollamaPsRequired, ollamaPull, ollamaDelete, } from "./ollama-chat.js";
package/dist/mcp.js CHANGED
@@ -12,10 +12,13 @@ import { createInterface } from "node:readline";
12
12
  import { readFile } from "node:fs/promises";
13
13
  import { listChats, loadChat } from "./chat-store.js";
14
14
  import { projectPath } from "./paths.js";
15
+ import { runCompare } from "./compare.js";
15
16
  import { listLoaded } from "./ps.js";
16
17
  import { pullModel } from "./pull.js";
18
+ import { assemblePrompt, readPromptFile } from "./prompt-input.js";
17
19
  import { runPrompt } from "./prompt.js";
18
20
  import { removeModel } from "./rm.js";
21
+ import { lastScan } from "./scan-store.js";
19
22
  import { scanNetwork } from "./scan.js";
20
23
  import { showModel } from "./show.js";
21
24
  // Pin to the oldest widely-supported MCP revision. Clients may request a newer
@@ -38,6 +41,10 @@ const TOOLS = [
38
41
  type: "boolean",
39
42
  description: "Also probe offline Tailscale peers (default false)",
40
43
  },
44
+ last: {
45
+ type: "boolean",
46
+ description: "Return the last saved scan without probing the network (default false)",
47
+ },
41
48
  },
42
49
  additionalProperties: false,
43
50
  },
@@ -84,8 +91,51 @@ const TOOLS = [
84
91
  type: "boolean",
85
92
  description: "Persist the transcript (default true)",
86
93
  },
94
+ file: {
95
+ type: "string",
96
+ description: "Read prompt body from a local .txt or .md file (joined with prompt if both set)",
97
+ },
87
98
  },
88
- required: ["prompt"],
99
+ additionalProperties: false,
100
+ },
101
+ },
102
+ {
103
+ name: "ollanet_compare",
104
+ description: "Head-to-head: run the same prompt on 2–5 models on one Ollama host. " +
105
+ "Returns a summary plus saved markdown/JSON paths. " +
106
+ "Omit prompt/file to use the built-in mesh-host tasting prompt. " +
107
+ "Machine is a discovered name, MagicDNS name, hostname, or IP[:port].",
108
+ inputSchema: {
109
+ type: "object",
110
+ properties: {
111
+ machine: { type: "string", description: "Host name/IP" },
112
+ models: {
113
+ type: "array",
114
+ items: { type: "string" },
115
+ minItems: 2,
116
+ maxItems: 5,
117
+ description: "2–5 model names on that host",
118
+ },
119
+ prompt: {
120
+ type: "string",
121
+ description: "Prompt text (optional; default is the built-in mesh-host prompt)",
122
+ },
123
+ file: {
124
+ type: "string",
125
+ description: "Read prompt from a local .txt or .md file",
126
+ },
127
+ system: { type: "string" },
128
+ temperature: { type: "number" },
129
+ num_predict: { type: "integer" },
130
+ num_ctx: { type: "integer" },
131
+ think: { type: "boolean" },
132
+ unload: {
133
+ type: "boolean",
134
+ description: "Unload each model before the next (fairer tok/s)",
135
+ },
136
+ save: { type: "boolean", description: "Write compares/*.md and .json (default true)" },
137
+ },
138
+ required: ["machine", "models"],
89
139
  additionalProperties: false,
90
140
  },
91
141
  },
@@ -218,6 +268,13 @@ async function packageVersion() {
218
268
  async function callTool(name, args) {
219
269
  switch (name) {
220
270
  case "ollanet_scan": {
271
+ if (args.last === true) {
272
+ const stored = await lastScan();
273
+ if (!stored) {
274
+ return textResult({ error: "No saved scan. Run ollanet_scan without last=true first." }, true);
275
+ }
276
+ return textResult(stored);
277
+ }
221
278
  const payload = await scanNetwork({
222
279
  lanScan: Boolean(args.lan),
223
280
  includeOffline: Boolean(args.all),
@@ -225,9 +282,21 @@ async function callTool(name, args) {
225
282
  return textResult(payload);
226
283
  }
227
284
  case "ollanet_prompt": {
228
- const prompt = typeof args.prompt === "string" ? args.prompt : "";
285
+ let fileText = "";
286
+ if (typeof args.file === "string" && args.file.trim()) {
287
+ try {
288
+ fileText = await readPromptFile(args.file);
289
+ }
290
+ catch (err) {
291
+ return textResult({ error: err instanceof Error ? err.message : String(err) }, true);
292
+ }
293
+ }
294
+ const prompt = assemblePrompt({
295
+ argv: typeof args.prompt === "string" ? args.prompt : "",
296
+ file: fileText,
297
+ });
229
298
  if (!prompt.trim()) {
230
- return textResult({ error: "prompt is required" }, true);
299
+ return textResult({ error: "prompt or file is required" }, true);
231
300
  }
232
301
  const settings = {};
233
302
  if (typeof args.system === "string")
@@ -263,6 +332,52 @@ async function callTool(name, args) {
263
332
  });
264
333
  return textResult(result);
265
334
  }
335
+ case "ollanet_compare": {
336
+ const machine = typeof args.machine === "string" ? args.machine : "";
337
+ const models = Array.isArray(args.models)
338
+ ? args.models.filter((m) => typeof m === "string")
339
+ : [];
340
+ if (!machine.trim())
341
+ return textResult({ error: "machine is required" }, true);
342
+ if (models.length < 2 || models.length > 5) {
343
+ return textResult({ error: "models must be an array of 2–5 names" }, true);
344
+ }
345
+ let fileText = "";
346
+ if (typeof args.file === "string" && args.file.trim()) {
347
+ try {
348
+ fileText = await readPromptFile(args.file);
349
+ }
350
+ catch (err) {
351
+ return textResult({ error: err instanceof Error ? err.message : String(err) }, true);
352
+ }
353
+ }
354
+ const prompt = assemblePrompt({
355
+ argv: typeof args.prompt === "string" ? args.prompt : "",
356
+ file: fileText,
357
+ });
358
+ const settings = {};
359
+ if (typeof args.system === "string")
360
+ settings.system = args.system;
361
+ if (typeof args.temperature === "number")
362
+ settings.temperature = args.temperature;
363
+ if (typeof args.num_predict === "number")
364
+ settings.num_predict = Math.trunc(args.num_predict);
365
+ if (typeof args.num_ctx === "number")
366
+ settings.num_ctx = Math.trunc(args.num_ctx);
367
+ if (typeof args.think === "boolean")
368
+ settings.think = args.think;
369
+ const result = await runCompare({
370
+ machine,
371
+ models,
372
+ prompt: prompt || undefined,
373
+ settings,
374
+ save: args.save === undefined ? true : Boolean(args.save),
375
+ unload: args.unload === true,
376
+ writeStdout: false,
377
+ quiet: true,
378
+ });
379
+ return textResult(result);
380
+ }
266
381
  case "ollanet_pull": {
267
382
  const machine = typeof args.machine === "string" ? args.machine : "";
268
383
  const model = typeof args.model === "string" ? args.model : "";
package/dist/paths.d.ts CHANGED
@@ -15,3 +15,7 @@ export declare function defaultConfigPath(): string;
15
15
  export declare function defaultResponsesDir(): string;
16
16
  /** Default benchmark results dir (mirrors responses layout). */
17
17
  export declare function defaultBenchmarksDir(): string;
18
+ /** Last successful scan snapshot (single file). */
19
+ export declare function defaultLastScanPath(): string;
20
+ /** Head-to-head compare records (markdown + JSON). */
21
+ export declare function defaultComparesDir(): string;
package/dist/paths.js CHANGED
@@ -26,3 +26,11 @@ export function defaultResponsesDir() {
26
26
  export function defaultBenchmarksDir() {
27
27
  return IS_INSTALLED ? path.join(USER_DIR, "benchmarks") : projectPath("benchmarks");
28
28
  }
29
+ /** Last successful scan snapshot (single file). */
30
+ export function defaultLastScanPath() {
31
+ return IS_INSTALLED ? path.join(USER_DIR, "last-scan.json") : projectPath("last-scan.json");
32
+ }
33
+ /** Head-to-head compare records (markdown + JSON). */
34
+ export function defaultComparesDir() {
35
+ return IS_INSTALLED ? path.join(USER_DIR, "compares") : projectPath("compares");
36
+ }
@@ -0,0 +1,7 @@
1
+ export declare function assertPromptFilename(filePath: string): string;
2
+ export declare function readPromptFile(filePath: string): Promise<string>;
3
+ export declare function assemblePrompt(parts: {
4
+ argv?: string;
5
+ file?: string;
6
+ stdin?: string;
7
+ }): string;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Shared prompt assembly: argv, --file (.txt / .md), and piped stdin.
3
+ */
4
+ import { readFile, stat } from "node:fs/promises";
5
+ import path from "node:path";
6
+ const ALLOWED = new Set([".txt", ".md"]);
7
+ const MAX_BYTES = 1_000_000;
8
+ export function assertPromptFilename(filePath) {
9
+ const ext = path.extname(filePath).toLowerCase();
10
+ if (!ALLOWED.has(ext)) {
11
+ throw new Error(`Prompt file must be .txt or .md (got "${ext || "no extension"}": ${filePath})`);
12
+ }
13
+ return path.resolve(filePath);
14
+ }
15
+ export async function readPromptFile(filePath) {
16
+ const resolved = assertPromptFilename(filePath);
17
+ const info = await stat(resolved);
18
+ if (!info.isFile()) {
19
+ throw new Error(`Prompt path is not a file: ${resolved}`);
20
+ }
21
+ if (info.size > MAX_BYTES) {
22
+ throw new Error(`Prompt file is too large (${info.size} bytes; max ${MAX_BYTES}): ${resolved}`);
23
+ }
24
+ const text = (await readFile(resolved, "utf8")).replace(/^\uFEFF/, "");
25
+ if (!text.trim()) {
26
+ throw new Error(`Prompt file is empty: ${resolved}`);
27
+ }
28
+ return text;
29
+ }
30
+ export function assemblePrompt(parts) {
31
+ return [parts.argv, parts.file, parts.stdin]
32
+ .map((s) => (s ?? "").trim())
33
+ .filter(Boolean)
34
+ .join("\n\n")
35
+ .trim();
36
+ }