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 +41 -7
- package/dist/bench-suite.d.ts +2 -0
- package/dist/bench-suite.js +1 -0
- package/dist/bench.js +54 -18
- package/dist/cli.js +16 -2
- package/dist/compare-store.d.ts +8 -0
- package/dist/compare-store.js +55 -0
- package/dist/compare.d.ts +52 -0
- package/dist/compare.js +338 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +3 -0
- package/dist/mcp.js +118 -3
- package/dist/paths.d.ts +4 -0
- package/dist/paths.js +8 -0
- package/dist/prompt-input.d.ts +7 -0
- package/dist/prompt-input.js +36 -0
- package/dist/prompt.js +28 -5
- package/dist/scan-store.d.ts +15 -0
- package/dist/scan-store.js +41 -0
- package/dist/scan.d.ts +12 -1
- package/dist/scan.js +73 -42
- package/package.json +2 -1
package/dist/compare.js
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
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 { discoverHosts, envInt, ollamaBaseUrl, resolveHost, shortName } from "./hosts.js";
|
|
9
|
+
import { ollamaChat, ollamaTags, ollamaUnload, waitUntilUnloaded, } from "./ollama-chat.js";
|
|
10
|
+
import { assemblePrompt, readPromptFile } from "./prompt-input.js";
|
|
11
|
+
import { comparesDir, newCompareId, saveCompare } from "./compare-store.js";
|
|
12
|
+
const MIN_MODELS = 2;
|
|
13
|
+
const MAX_MODELS = 5;
|
|
14
|
+
/** Used when --prompt, --file, and stdin are all omitted. */
|
|
15
|
+
export const DEFAULT_COMPARE_PROMPT = "In 6–10 sentences, explain how you’d pick an Ollama host on a mixed LAN/Tailscale mesh " +
|
|
16
|
+
"for a 12B chat model. Then give one failure mode (wrong host, cold load, or VRAM spill) " +
|
|
17
|
+
"and how you’d confirm it. Be specific.";
|
|
18
|
+
function compareTimeoutMs() {
|
|
19
|
+
return envInt("OLLAMA_COMPARE_TIMEOUT_MS", envInt("OLLAMA_PROMPT_TIMEOUT_MS", 600_000));
|
|
20
|
+
}
|
|
21
|
+
function unloadWaitMs() {
|
|
22
|
+
return envInt("OLLAMA_COMPARE_UNLOAD_WAIT_MS", 60_000);
|
|
23
|
+
}
|
|
24
|
+
function usage() {
|
|
25
|
+
console.error(`Usage:
|
|
26
|
+
ollanet compare <machine> <model> <model> [model...]
|
|
27
|
+
ollanet compare <machine> <model> <model> [model...] --prompt <text>
|
|
28
|
+
ollanet compare <machine> <model> <model> [model...] --file <path.txt|.md>
|
|
29
|
+
|
|
30
|
+
Examples:
|
|
31
|
+
ollanet compare studio gemma3:12b llama3.2:3b
|
|
32
|
+
ollanet compare studio gemma3:12b llama3.2:3b --prompt "Explain MagicDNS"
|
|
33
|
+
ollanet compare studio gemma3:12b qwen2.5:7b llama3.2:3b --file ./task.md
|
|
34
|
+
|
|
35
|
+
Runs the same prompt on 2–5 models on one host. Prints a summary table and
|
|
36
|
+
writes compares/<id>.md plus .json (prompt, replies, stats). Not a bench suite.
|
|
37
|
+
Omit --prompt/--file to use the built-in mesh-host tasting prompt.
|
|
38
|
+
|
|
39
|
+
Options:
|
|
40
|
+
--prompt <text> Prompt text (default: built-in mesh-host prompt)
|
|
41
|
+
--file <path> Prompt from a .txt or .md file
|
|
42
|
+
--system <text> System prompt
|
|
43
|
+
--temperature <n>
|
|
44
|
+
--num-predict <n>
|
|
45
|
+
--num-ctx <n>
|
|
46
|
+
--keep-alive <value>
|
|
47
|
+
--think / --no-think
|
|
48
|
+
--unload Unload each model before the next (fairer tok/s)
|
|
49
|
+
--no-save Do not write compares/*
|
|
50
|
+
--json Emit the full record on stdout`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
function takeValue(args, flag) {
|
|
54
|
+
const value = args.shift();
|
|
55
|
+
if (!value) {
|
|
56
|
+
console.error(`${flag} requires a value`);
|
|
57
|
+
usage();
|
|
58
|
+
}
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
function parseNumberFlag(raw, flag) {
|
|
62
|
+
const n = Number(raw);
|
|
63
|
+
if (!Number.isFinite(n)) {
|
|
64
|
+
console.error(`${flag} must be a number (got "${raw}")`);
|
|
65
|
+
usage();
|
|
66
|
+
}
|
|
67
|
+
return n;
|
|
68
|
+
}
|
|
69
|
+
function parseArgs(argv) {
|
|
70
|
+
const args = [...argv];
|
|
71
|
+
let prompt;
|
|
72
|
+
let file;
|
|
73
|
+
let json = false;
|
|
74
|
+
let save = true;
|
|
75
|
+
let unload = false;
|
|
76
|
+
const settings = {};
|
|
77
|
+
const positional = [];
|
|
78
|
+
while (args.length > 0) {
|
|
79
|
+
const arg = args.shift();
|
|
80
|
+
if (arg === "--")
|
|
81
|
+
continue;
|
|
82
|
+
if (arg === "--help" || arg === "-h")
|
|
83
|
+
usage();
|
|
84
|
+
if (arg === "--json") {
|
|
85
|
+
json = true;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (arg === "--no-save") {
|
|
89
|
+
save = false;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (arg === "--unload") {
|
|
93
|
+
unload = true;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (arg === "--think") {
|
|
97
|
+
settings.think = true;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (arg === "--no-think") {
|
|
101
|
+
settings.think = false;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (arg === "--prompt" || arg === "-p" || arg.startsWith("--prompt=")) {
|
|
105
|
+
prompt = arg.includes("=") ? arg.slice("--prompt=".length) : takeValue(args, "--prompt");
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (arg === "--file" || arg === "-f" || arg.startsWith("--file=")) {
|
|
109
|
+
file = arg.includes("=") ? arg.slice("--file=".length) : takeValue(args, "--file");
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (arg === "--system" || arg.startsWith("--system=")) {
|
|
113
|
+
settings.system = arg.includes("=")
|
|
114
|
+
? arg.slice("--system=".length)
|
|
115
|
+
: takeValue(args, "--system");
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (arg === "--temperature" || arg === "-t" || arg.startsWith("--temperature=")) {
|
|
119
|
+
const raw = arg.includes("=") ? arg.slice("--temperature=".length) : takeValue(args, "--temperature");
|
|
120
|
+
settings.temperature = parseNumberFlag(raw, "--temperature");
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (arg === "--num-predict" || arg.startsWith("--num-predict=")) {
|
|
124
|
+
const raw = arg.includes("=") ? arg.slice("--num-predict=".length) : takeValue(args, "--num-predict");
|
|
125
|
+
settings.num_predict = Math.trunc(parseNumberFlag(raw, "--num-predict"));
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (arg === "--num-ctx" || arg.startsWith("--num-ctx=")) {
|
|
129
|
+
const raw = arg.includes("=") ? arg.slice("--num-ctx=".length) : takeValue(args, "--num-ctx");
|
|
130
|
+
settings.num_ctx = Math.trunc(parseNumberFlag(raw, "--num-ctx"));
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (arg === "--keep-alive" || arg.startsWith("--keep-alive=")) {
|
|
134
|
+
const raw = arg.includes("=") ? arg.slice("--keep-alive=".length) : takeValue(args, "--keep-alive");
|
|
135
|
+
const asNum = Number(raw);
|
|
136
|
+
settings.keep_alive =
|
|
137
|
+
raw.trim() !== "" && Number.isFinite(asNum) && String(asNum) === raw ? asNum : raw;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (arg.startsWith("-")) {
|
|
141
|
+
console.error(`Unknown flag: ${arg}`);
|
|
142
|
+
usage();
|
|
143
|
+
}
|
|
144
|
+
positional.push(arg);
|
|
145
|
+
}
|
|
146
|
+
const machine = positional[0];
|
|
147
|
+
const models = positional.slice(1);
|
|
148
|
+
return { machine, models, prompt, file, json, save, unload, settings };
|
|
149
|
+
}
|
|
150
|
+
function tokPerSec(chunk) {
|
|
151
|
+
if (chunk.eval_count == null || chunk.eval_duration == null || chunk.eval_duration <= 0) {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
return chunk.eval_count / (chunk.eval_duration / 1e9);
|
|
155
|
+
}
|
|
156
|
+
function resolveModelName(available, query) {
|
|
157
|
+
const q = query.toLowerCase();
|
|
158
|
+
const exact = available.find((n) => n.toLowerCase() === q);
|
|
159
|
+
if (exact)
|
|
160
|
+
return exact;
|
|
161
|
+
const prefix = available.filter((n) => n.toLowerCase().startsWith(`${q}:`) || q.startsWith(`${n.toLowerCase()}:`));
|
|
162
|
+
return prefix.length === 1 ? prefix[0] : undefined;
|
|
163
|
+
}
|
|
164
|
+
function printSummary(record) {
|
|
165
|
+
const nameWidth = Math.max(8, ...record.results.map((r) => r.model.length));
|
|
166
|
+
process.stderr.write(`Compare on ${record.machine} ${record.results.length} models ${record.prompt.length} char prompt\n`);
|
|
167
|
+
process.stderr.write(`${"model".padEnd(nameWidth)} ${"tok/s".padStart(7)} ${"tokens".padStart(6)} ${"wall".padStart(7)} done\n`);
|
|
168
|
+
for (const r of record.results) {
|
|
169
|
+
if (r.error) {
|
|
170
|
+
process.stderr.write(`${r.model.padEnd(nameWidth)} ERROR ${r.error}\n`);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const toks = r.tok_s != null ? r.tok_s.toFixed(1) : "—";
|
|
174
|
+
const tokens = r.eval_count != null ? String(r.eval_count) : "—";
|
|
175
|
+
const wall = r.wall_ms != null ? `${(r.wall_ms / 1000).toFixed(2)}s` : "—";
|
|
176
|
+
process.stderr.write(`${r.model.padEnd(nameWidth)} ${toks.padStart(7)} ${tokens.padStart(6)} ${wall.padStart(7)} ${r.done_reason ?? "—"}\n`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
export async function runCompare(opts) {
|
|
180
|
+
const models = opts.models.map((m) => m.trim()).filter(Boolean);
|
|
181
|
+
if (models.length < MIN_MODELS || models.length > MAX_MODELS) {
|
|
182
|
+
throw new Error(`Compare needs ${MIN_MODELS}–${MAX_MODELS} models (got ${models.length}).`);
|
|
183
|
+
}
|
|
184
|
+
let fileText;
|
|
185
|
+
if (opts.file) {
|
|
186
|
+
fileText = await readPromptFile(opts.file);
|
|
187
|
+
}
|
|
188
|
+
const assembled = assemblePrompt({ argv: opts.prompt, file: fileText });
|
|
189
|
+
const defaulted = !assembled;
|
|
190
|
+
const prompt = assembled || DEFAULT_COMPARE_PROMPT;
|
|
191
|
+
const config = opts.config ? configFromPartial(opts.config) : await loadConfig();
|
|
192
|
+
const { hosts } = await discoverHosts({
|
|
193
|
+
hosts: config.hosts,
|
|
194
|
+
discovery: config.discovery,
|
|
195
|
+
});
|
|
196
|
+
const host = resolveHost(hosts, opts.machine);
|
|
197
|
+
if (!host.online && !host.isSelf && host.source === "tailscale") {
|
|
198
|
+
throw new Error(`Machine "${shortName(host)}" appears offline.`);
|
|
199
|
+
}
|
|
200
|
+
const baseUrl = ollamaBaseUrl(host);
|
|
201
|
+
const timeoutMs = opts.timeoutMs ?? compareTimeoutMs();
|
|
202
|
+
let available = null;
|
|
203
|
+
try {
|
|
204
|
+
available = (await ollamaTags(baseUrl, Math.min(timeoutMs || 5000, 10_000))).map((t) => t.name);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
available = null;
|
|
208
|
+
}
|
|
209
|
+
const resolved = [];
|
|
210
|
+
for (const name of models) {
|
|
211
|
+
if (available) {
|
|
212
|
+
const hit = resolveModelName(available, name);
|
|
213
|
+
if (!hit) {
|
|
214
|
+
throw new Error(`Unknown model "${name}" on ${shortName(host)}. Available: ${available.join(", ")}`);
|
|
215
|
+
}
|
|
216
|
+
resolved.push(hit);
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
resolved.push(name);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const settings = mergeSettings({ think: false }, config.defaults, machineSettingsForHost(config, host), settingsFromEnv(), opts.settings);
|
|
223
|
+
const quiet = opts.quiet === true || opts.writeStdout === false;
|
|
224
|
+
const results = [];
|
|
225
|
+
for (let i = 0; i < resolved.length; i += 1) {
|
|
226
|
+
const model = resolved[i];
|
|
227
|
+
if (!quiet) {
|
|
228
|
+
process.stderr.write(`→ ${model} (${i + 1}/${resolved.length})\n`);
|
|
229
|
+
}
|
|
230
|
+
const started = Date.now();
|
|
231
|
+
try {
|
|
232
|
+
const { content, thinking, chunk } = await ollamaChat({
|
|
233
|
+
baseUrl,
|
|
234
|
+
model,
|
|
235
|
+
messages: [
|
|
236
|
+
...(settings.system ? [{ role: "system", content: settings.system }] : []),
|
|
237
|
+
{ role: "user", content: prompt },
|
|
238
|
+
],
|
|
239
|
+
stream: false,
|
|
240
|
+
writeStdout: false,
|
|
241
|
+
timeoutMs,
|
|
242
|
+
settings,
|
|
243
|
+
});
|
|
244
|
+
results.push({
|
|
245
|
+
model,
|
|
246
|
+
content,
|
|
247
|
+
thinking: thinking || undefined,
|
|
248
|
+
tok_s: tokPerSec(chunk),
|
|
249
|
+
eval_count: chunk.eval_count,
|
|
250
|
+
eval_duration: chunk.eval_duration,
|
|
251
|
+
total_duration: chunk.total_duration,
|
|
252
|
+
load_duration: chunk.load_duration,
|
|
253
|
+
wall_ms: Date.now() - started,
|
|
254
|
+
done_reason: chunk.done_reason,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
results.push({
|
|
259
|
+
model,
|
|
260
|
+
wall_ms: Date.now() - started,
|
|
261
|
+
error: err instanceof Error ? err.message : String(err),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
if (opts.unload && i < resolved.length - 1) {
|
|
265
|
+
try {
|
|
266
|
+
await ollamaUnload(baseUrl, model, Math.min(timeoutMs || 30_000, 30_000));
|
|
267
|
+
await waitUntilUnloaded(baseUrl, model, unloadWaitMs());
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// Fairness hint only — do not fail the compare.
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const record = {
|
|
275
|
+
id: newCompareId(),
|
|
276
|
+
created_at: new Date().toISOString(),
|
|
277
|
+
machine: shortName(host),
|
|
278
|
+
endpoint: baseUrl,
|
|
279
|
+
prompt,
|
|
280
|
+
default_prompt: defaulted,
|
|
281
|
+
system: settings.system,
|
|
282
|
+
results,
|
|
283
|
+
};
|
|
284
|
+
if (opts.save !== false) {
|
|
285
|
+
record.files = await saveCompare(record);
|
|
286
|
+
}
|
|
287
|
+
return record;
|
|
288
|
+
}
|
|
289
|
+
export async function main() {
|
|
290
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
291
|
+
if (!parsed.machine) {
|
|
292
|
+
console.error("Machine is required.");
|
|
293
|
+
usage();
|
|
294
|
+
}
|
|
295
|
+
const { stdin: stdinStream } = await import("node:process");
|
|
296
|
+
let fromStdin = "";
|
|
297
|
+
if (!stdinStream.isTTY) {
|
|
298
|
+
const chunks = [];
|
|
299
|
+
for await (const chunk of stdinStream)
|
|
300
|
+
chunks.push(chunk);
|
|
301
|
+
fromStdin = Buffer.concat(chunks).toString("utf8").trim();
|
|
302
|
+
}
|
|
303
|
+
let fileText;
|
|
304
|
+
if (parsed.file) {
|
|
305
|
+
try {
|
|
306
|
+
fileText = await readPromptFile(parsed.file);
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
console.error(err instanceof Error ? err.message : err);
|
|
310
|
+
process.exitCode = 1;
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const prompt = assemblePrompt({ argv: parsed.prompt, file: fileText, stdin: fromStdin });
|
|
315
|
+
const record = await runCompare({
|
|
316
|
+
machine: parsed.machine,
|
|
317
|
+
models: parsed.models,
|
|
318
|
+
prompt: prompt || undefined,
|
|
319
|
+
settings: parsed.settings,
|
|
320
|
+
save: parsed.save,
|
|
321
|
+
unload: parsed.unload,
|
|
322
|
+
writeStdout: !parsed.json,
|
|
323
|
+
quiet: parsed.json,
|
|
324
|
+
});
|
|
325
|
+
if (record.default_prompt) {
|
|
326
|
+
process.stderr.write("Using default compare prompt (pass --prompt or --file to override).\n");
|
|
327
|
+
}
|
|
328
|
+
if (parsed.json) {
|
|
329
|
+
process.stdout.write(`${JSON.stringify(record, null, 2)}\n`);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
printSummary(record);
|
|
333
|
+
if (record.files) {
|
|
334
|
+
process.stderr.write(`saved ${record.files.md}\n`);
|
|
335
|
+
process.stderr.write(` ${record.files.json}\n`);
|
|
336
|
+
process.stderr.write(`compares dir: ${comparesDir()}\n`);
|
|
337
|
+
}
|
|
338
|
+
}
|
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";
|
package/dist/index.js
CHANGED
|
@@ -8,9 +8,12 @@
|
|
|
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";
|
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
|
-
|
|
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
|
-
|
|
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,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
|
+
}
|