ollanet 0.1.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/dist/prompt.js ADDED
@@ -0,0 +1,451 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Run a prompt / continue a chat against an Ollama server on the network.
4
+ *
5
+ * New chat (saved by default):
6
+ * ollanet prompt mycroftone "What is Tailscale?"
7
+ * ollanet prompt 192.168.1.50 "Hello"
8
+ *
9
+ * Continue:
10
+ * ollanet prompt --chat a1b2c3d4e5f6 "Tell me more"
11
+ *
12
+ * List chats:
13
+ * ollanet chats
14
+ */
15
+ import { stdin as stdinStream } from "node:process";
16
+ import { appendMessage, cleanTopic, createChat, loadChat, saveChat, toApiMessages, topicFromPrompt, } from "./chat-store.js";
17
+ import { configPath, defaultModelForHost, loadConfig, machineSettingsForHost, mergeSettings, parseFormat, settingsFromEnv, settingsSummary, } from "./config.js";
18
+ import { discoverHosts, ollamaBaseUrl, resolveHost, shortName, } from "./hosts.js";
19
+ function usage() {
20
+ console.error(`Usage:
21
+ ollanet prompt <machine> [model] <prompt...>
22
+ ollanet prompt --chat <hash> <prompt...>
23
+ ollanet prompt <machine> --chat <hash> <prompt...>
24
+
25
+ Examples:
26
+ ollanet prompt mycroftone "Explain MagicDNS"
27
+ ollanet prompt --chat a1b2c3d4e5f6 "Give an example"
28
+ ollanet prompt mycroftone --temperature 0.2 --num-predict 64 "2+2?"
29
+ ollanet chats
30
+
31
+ Each reply is saved under responses/<hash>.json and the hash is printed so you
32
+ can continue later with --chat. Use --no-save to skip persistence.
33
+
34
+ Config: ${configPath()}
35
+ Precedence: CLI > env > machineDefaults > defaults
36
+
37
+ Options:
38
+ --chat <hash> Continue a saved chat
39
+ --machine <name> Machine (new chat positional, or override when using --chat)
40
+ --no-save Do not write/update a responses/*.json transcript
41
+ --model <name> Model (overrides config / chat default)
42
+ --system <text> System prompt (new chats only unless chat has none)
43
+ --temperature <n> Sampling temperature
44
+ --num-predict <n> Max tokens to generate
45
+ --num-ctx <n> Context window size
46
+ --keep-alive <value> Keep model loaded (e.g. 5m, 0, -1)
47
+ --format <json|schema> Force JSON mode or a JSON schema string
48
+ --no-stream Wait for the full response
49
+ --json Emit final response JSON (implies --no-stream)`);
50
+ process.exit(1);
51
+ }
52
+ function looksLikeModel(value) {
53
+ return value.includes(":") || value.includes("/");
54
+ }
55
+ function takeValue(args, flag) {
56
+ const value = args.shift();
57
+ if (!value) {
58
+ console.error(`${flag} requires a value`);
59
+ usage();
60
+ }
61
+ return value;
62
+ }
63
+ function parseNumberFlag(raw, flag) {
64
+ const n = Number(raw);
65
+ if (!Number.isFinite(n)) {
66
+ console.error(`${flag} must be a number (got "${raw}")`);
67
+ usage();
68
+ }
69
+ return n;
70
+ }
71
+ function parseArgs(argv) {
72
+ const args = [...argv];
73
+ let machineFlag;
74
+ let modelFlag;
75
+ let chatId;
76
+ let stream = true;
77
+ let json = false;
78
+ let save = true;
79
+ const settings = {};
80
+ const positional = [];
81
+ while (args.length > 0) {
82
+ const arg = args.shift();
83
+ if (arg === "--")
84
+ continue;
85
+ if (arg === "--help" || arg === "-h")
86
+ usage();
87
+ if (arg === "--no-stream") {
88
+ stream = false;
89
+ continue;
90
+ }
91
+ if (arg === "--json") {
92
+ json = true;
93
+ stream = false;
94
+ continue;
95
+ }
96
+ if (arg === "--no-save") {
97
+ save = false;
98
+ continue;
99
+ }
100
+ if (arg === "--chat" || arg.startsWith("--chat=")) {
101
+ chatId = arg.includes("=") ? arg.slice("--chat=".length) : takeValue(args, "--chat");
102
+ continue;
103
+ }
104
+ if (arg === "--machine" || arg.startsWith("--machine=")) {
105
+ machineFlag = arg.includes("=") ? arg.slice("--machine=".length) : takeValue(args, "--machine");
106
+ continue;
107
+ }
108
+ if (arg === "--model" || arg.startsWith("--model=")) {
109
+ modelFlag = arg.includes("=") ? arg.slice("--model=".length) : takeValue(args, "--model");
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 = raw.trim() !== "" && Number.isFinite(asNum) && String(asNum) === raw ? asNum : raw;
137
+ continue;
138
+ }
139
+ if (arg === "--format" || arg.startsWith("--format=")) {
140
+ const raw = arg.includes("=") ? arg.slice("--format=".length) : takeValue(args, "--format");
141
+ const format = parseFormat(raw);
142
+ if (format !== undefined)
143
+ settings.format = format;
144
+ continue;
145
+ }
146
+ if (arg.startsWith("-")) {
147
+ console.error(`Unknown flag: ${arg}`);
148
+ usage();
149
+ }
150
+ positional.push(arg);
151
+ }
152
+ let machine = machineFlag;
153
+ let model = modelFlag;
154
+ let promptParts = positional;
155
+ if (!chatId) {
156
+ // New chat: <machine> [model] <prompt...>
157
+ if (!machine) {
158
+ if (positional.length < 1)
159
+ usage();
160
+ machine = positional[0];
161
+ promptParts = positional.slice(1);
162
+ }
163
+ if (!model && promptParts.length > 0 && looksLikeModel(promptParts[0])) {
164
+ model = promptParts[0];
165
+ promptParts = promptParts.slice(1);
166
+ }
167
+ }
168
+ // Continue (--chat): all positionals are the prompt. Optional machine override
169
+ // via --machine, or a leading discovered-host name peeled in main().
170
+ return { machine, model, promptParts, stream, json, save, chatId, settings };
171
+ }
172
+ async function readStdinIfPiped() {
173
+ if (stdinStream.isTTY)
174
+ return "";
175
+ const chunks = [];
176
+ for await (const chunk of stdinStream) {
177
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
178
+ }
179
+ return Buffer.concat(chunks).toString("utf8").trim();
180
+ }
181
+ function buildOptions(settings) {
182
+ const options = {};
183
+ if (settings.temperature != null)
184
+ options.temperature = settings.temperature;
185
+ if (settings.num_predict != null)
186
+ options.num_predict = settings.num_predict;
187
+ if (settings.num_ctx != null)
188
+ options.num_ctx = settings.num_ctx;
189
+ return options;
190
+ }
191
+ async function runChat(opts) {
192
+ const url = `${opts.baseUrl}/api/chat`;
193
+ const body = {
194
+ model: opts.model,
195
+ messages: opts.messages,
196
+ stream: opts.stream,
197
+ };
198
+ if (opts.settings.keep_alive != null)
199
+ body.keep_alive = opts.settings.keep_alive;
200
+ if (opts.settings.format !== undefined)
201
+ body.format = opts.settings.format;
202
+ const options = buildOptions(opts.settings);
203
+ if (Object.keys(options).length > 0)
204
+ body.options = options;
205
+ const res = await fetch(url, {
206
+ method: "POST",
207
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
208
+ body: JSON.stringify(body),
209
+ });
210
+ if (!res.ok) {
211
+ const text = await res.text().catch(() => "");
212
+ throw new Error(`Ollama HTTP ${res.status} ${res.statusText}${text ? `: ${text}` : ""}`);
213
+ }
214
+ if (!opts.stream) {
215
+ const chunk = (await res.json());
216
+ if (chunk.error)
217
+ throw new Error(chunk.error);
218
+ const content = chunk.message?.content ?? "";
219
+ if (opts.writeStdout)
220
+ process.stdout.write(`${content}\n`);
221
+ return { content, chunk };
222
+ }
223
+ if (!res.body)
224
+ throw new Error("No response body from Ollama");
225
+ const reader = res.body.getReader();
226
+ const decoder = new TextDecoder();
227
+ let buffer = "";
228
+ let last = {};
229
+ let content = "";
230
+ while (true) {
231
+ const { done, value } = await reader.read();
232
+ if (done)
233
+ break;
234
+ buffer += decoder.decode(value, { stream: true });
235
+ const lines = buffer.split("\n");
236
+ buffer = lines.pop() ?? "";
237
+ for (const line of lines) {
238
+ const trimmed = line.trim();
239
+ if (!trimmed)
240
+ continue;
241
+ const chunk = JSON.parse(trimmed);
242
+ if (chunk.error)
243
+ throw new Error(chunk.error);
244
+ const piece = chunk.message?.content ?? "";
245
+ if (piece) {
246
+ content += piece;
247
+ if (opts.writeStdout)
248
+ process.stdout.write(piece);
249
+ }
250
+ last = chunk;
251
+ }
252
+ }
253
+ const trailing = buffer.trim();
254
+ if (trailing) {
255
+ const chunk = JSON.parse(trailing);
256
+ if (chunk.error)
257
+ throw new Error(chunk.error);
258
+ const piece = chunk.message?.content ?? "";
259
+ if (piece) {
260
+ content += piece;
261
+ if (opts.writeStdout)
262
+ process.stdout.write(piece);
263
+ }
264
+ last = chunk;
265
+ }
266
+ if (opts.writeStdout && content.length > 0 && !content.endsWith("\n")) {
267
+ process.stdout.write("\n");
268
+ }
269
+ return { content, chunk: last };
270
+ }
271
+ async function generateTopic(opts) {
272
+ const fallback = topicFromPrompt(opts.prompt);
273
+ try {
274
+ const { content } = await runChat({
275
+ baseUrl: opts.baseUrl,
276
+ model: opts.model,
277
+ stream: false,
278
+ writeStdout: false,
279
+ settings: mergeSettings(opts.settings, {
280
+ temperature: 0.2,
281
+ num_predict: 24,
282
+ // keep_alive 0 so topic pass doesn't pin the model longer than needed
283
+ keep_alive: 0,
284
+ }),
285
+ messages: [
286
+ {
287
+ role: "user",
288
+ content: "Write a concise 3-8 word topic title for the following user message. " +
289
+ "Reply with only the title, no quotes or punctuation wrapping.\n\n" +
290
+ opts.prompt,
291
+ },
292
+ ],
293
+ });
294
+ return cleanTopic(content, fallback);
295
+ }
296
+ catch {
297
+ return fallback;
298
+ }
299
+ }
300
+ function formatDuration(ns) {
301
+ if (ns == null)
302
+ return "";
303
+ const ms = ns / 1e6;
304
+ if (ms < 1000)
305
+ return `${ms.toFixed(0)}ms`;
306
+ return `${(ms / 1000).toFixed(1)}s`;
307
+ }
308
+ export async function main() {
309
+ const parsed = parseArgs(process.argv.slice(2));
310
+ const { promptParts, stream, json, save } = parsed;
311
+ const fromArgs = promptParts.join(" ").trim();
312
+ const fromStdin = await readStdinIfPiped();
313
+ const prompt = [fromArgs, fromStdin].filter(Boolean).join("\n\n").trim();
314
+ if (!prompt) {
315
+ console.error("No prompt provided (pass args and/or pipe stdin).");
316
+ usage();
317
+ }
318
+ const config = await loadConfig();
319
+ const { hosts: targets } = await discoverHosts({
320
+ hosts: config.hosts,
321
+ discovery: config.discovery,
322
+ });
323
+ let chat;
324
+ let isNewChat = false;
325
+ let machineQuery = parsed.machine;
326
+ let promptText = prompt;
327
+ if (parsed.chatId) {
328
+ chat = await loadChat(parsed.chatId);
329
+ // Allow `ollanet prompt mycroftone --chat HASH "follow-up"` by peeling a
330
+ // leading positional that matches a discovered host.
331
+ if (!machineQuery && promptParts.length >= 2) {
332
+ try {
333
+ resolveHost(targets, promptParts[0]);
334
+ machineQuery = promptParts[0];
335
+ promptText = [...promptParts.slice(1), fromStdin].filter(Boolean).join("\n\n").trim();
336
+ }
337
+ catch {
338
+ // first token is part of the prompt
339
+ }
340
+ }
341
+ }
342
+ machineQuery = machineQuery ?? chat?.machine;
343
+ if (!machineQuery) {
344
+ console.error("Machine is required for a new chat (or use --chat <hash>).");
345
+ usage();
346
+ }
347
+ if (!promptText) {
348
+ console.error("No prompt provided (pass args and/or pipe stdin).");
349
+ usage();
350
+ }
351
+ const host = resolveHost(targets, machineQuery);
352
+ if (!host.online && !host.isSelf && host.source === "tailscale") {
353
+ throw new Error(`Machine "${shortName(host)}" appears offline.`);
354
+ }
355
+ const model = parsed.model ??
356
+ chat?.model ??
357
+ defaultModelForHost(config, host);
358
+ if (!model) {
359
+ throw new Error(`No model specified for "${shortName(host)}" and no default in ${configPath()}.\n` +
360
+ `Pass a model, use --model <name>, or add it under defaultModels.`);
361
+ }
362
+ const settings = mergeSettings(config.defaults, machineSettingsForHost(config, host), settingsFromEnv(), chat?.system ? { system: chat.system } : undefined, parsed.settings);
363
+ if (!chat && save) {
364
+ chat = createChat({
365
+ machine: shortName(host),
366
+ model,
367
+ system: settings.system,
368
+ topic: topicFromPrompt(promptText),
369
+ });
370
+ isNewChat = true;
371
+ }
372
+ else if (!chat) {
373
+ // --no-save one-shot: ephemeral transcript for the API call only
374
+ chat = createChat({
375
+ machine: shortName(host),
376
+ model,
377
+ system: settings.system,
378
+ });
379
+ isNewChat = true;
380
+ }
381
+ else {
382
+ if (parsed.settings.system && !chat.system) {
383
+ chat.system = parsed.settings.system;
384
+ }
385
+ chat.model = model;
386
+ chat.machine = shortName(host);
387
+ }
388
+ appendMessage(chat, { role: "user", content: promptText });
389
+ const baseUrl = ollamaBaseUrl(host);
390
+ if (!json) {
391
+ const src = parsed.model ? "explicit" : chat && !isNewChat ? "chat" : "default";
392
+ const extras = settingsSummary(settings);
393
+ const chatTag = parsed.chatId ? ` chat=${chat.id}` : "";
394
+ console.error(`→ ${shortName(host)} (${host.ip}) model=${model} [${src}]${chatTag}${extras ? ` ${extras}` : ""}`);
395
+ }
396
+ const { content, chunk } = await runChat({
397
+ baseUrl,
398
+ model,
399
+ messages: toApiMessages(chat),
400
+ stream: json ? false : stream,
401
+ writeStdout: !json,
402
+ settings,
403
+ });
404
+ appendMessage(chat, {
405
+ role: "assistant",
406
+ content,
407
+ machine: shortName(host),
408
+ model,
409
+ stats: {
410
+ eval_count: chunk.eval_count,
411
+ eval_duration: chunk.eval_duration,
412
+ total_duration: chunk.total_duration,
413
+ prompt_eval_count: chunk.prompt_eval_count,
414
+ },
415
+ });
416
+ if (isNewChat && save) {
417
+ chat.topic = await generateTopic({
418
+ baseUrl,
419
+ model,
420
+ prompt: promptText,
421
+ settings,
422
+ });
423
+ }
424
+ if (save) {
425
+ const file = await saveChat(chat);
426
+ if (!json) {
427
+ console.error(`chat ${chat.id} topic: ${chat.topic}`);
428
+ console.error(`saved ${file}`);
429
+ console.error(`continue: ollanet prompt --chat ${chat.id} "your follow-up"`);
430
+ }
431
+ }
432
+ if (json) {
433
+ console.log(JSON.stringify({
434
+ chat_id: save ? chat.id : null,
435
+ topic: chat.topic,
436
+ content,
437
+ ollama: chunk,
438
+ }, null, 2));
439
+ return;
440
+ }
441
+ const stats = [
442
+ chunk.eval_count != null ? `${chunk.eval_count} tokens` : "",
443
+ chunk.eval_duration
444
+ ? `${((chunk.eval_count ?? 0) / (chunk.eval_duration / 1e9)).toFixed(1)} tok/s`
445
+ : "",
446
+ chunk.total_duration ? formatDuration(chunk.total_duration) : "",
447
+ ].filter(Boolean);
448
+ if (stats.length > 0) {
449
+ console.error(`(${stats.join(" · ")})`);
450
+ }
451
+ }
package/dist/scan.js ADDED
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Scan reachable networks for Ollama servers and list available models.
4
+ *
5
+ * Usage: ollanet scan [--lan] [--json] [--all]
6
+ */
7
+ import { loadConfig } from "./config.js";
8
+ import { OLLAMA_PORT, discoverHosts, shortName, } from "./hosts.js";
9
+ const TIMEOUT_MS = Number(process.env.OLLAMA_TIMEOUT_MS ?? 2500);
10
+ const CONCURRENCY = Number(process.env.OLLAMA_CONCURRENCY ?? 16);
11
+ function formatBytes(bytes) {
12
+ if (bytes == null || !Number.isFinite(bytes))
13
+ return "";
14
+ const units = ["B", "KB", "MB", "GB", "TB"];
15
+ let value = bytes;
16
+ let unit = 0;
17
+ while (value >= 1024 && unit < units.length - 1) {
18
+ value /= 1024;
19
+ unit += 1;
20
+ }
21
+ return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
22
+ }
23
+ async function fetchModels(host) {
24
+ const url = `http://${host.ip.includes(":") ? `[${host.ip}]` : host.ip}:${host.port}/api/tags`;
25
+ const controller = new AbortController();
26
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
27
+ try {
28
+ const res = await fetch(url, {
29
+ signal: controller.signal,
30
+ headers: { Accept: "application/json" },
31
+ });
32
+ if (!res.ok) {
33
+ throw new Error(`HTTP ${res.status} ${res.statusText}`);
34
+ }
35
+ const body = (await res.json());
36
+ return { models: body.models ?? [], url };
37
+ }
38
+ finally {
39
+ clearTimeout(timer);
40
+ }
41
+ }
42
+ async function mapPool(items, concurrency, worker) {
43
+ const results = new Array(items.length);
44
+ let next = 0;
45
+ async function run() {
46
+ while (next < items.length) {
47
+ const index = next;
48
+ next += 1;
49
+ results[index] = await worker(items[index]);
50
+ }
51
+ }
52
+ const runners = Array.from({ length: Math.min(concurrency, items.length) }, () => run());
53
+ await Promise.all(runners);
54
+ return results;
55
+ }
56
+ async function scanHost(host) {
57
+ const url = `http://${host.ip}:${host.port}/api/tags`;
58
+ if (!host.online && !host.isSelf && host.source === "tailscale") {
59
+ return { host, ok: false, models: [], error: "offline", url };
60
+ }
61
+ try {
62
+ const { models } = await fetchModels(host);
63
+ return { host, ok: true, models, url };
64
+ }
65
+ catch (err) {
66
+ const name = err instanceof Error ? err.name : "";
67
+ const message = err instanceof Error ? err.message : String(err);
68
+ const error = name === "AbortError" || message.toLowerCase().includes("abort")
69
+ ? `timeout after ${TIMEOUT_MS}ms`
70
+ : message;
71
+ return { host, ok: false, models: [], error, url };
72
+ }
73
+ }
74
+ function printResults(results, networkLabel, sources) {
75
+ const found = results.filter((r) => r.ok);
76
+ const offline = results.filter((r) => r.error === "offline").length;
77
+ const probed = results.length - offline;
78
+ console.log(`Network: ${networkLabel}`);
79
+ console.log(`Discovery: ${sources.join(", ") || "none"}`);
80
+ console.log(`Probing Ollama on ${results.length} host(s) (${probed} online/reachable candidates)\n`);
81
+ if (found.length === 0) {
82
+ console.log("No Ollama servers found.");
83
+ console.log("Tip: add hosts to config.json, set OLLANET_HOSTS, pass --lan, or prompt an IP directly.");
84
+ const interesting = results.filter((r) => r.error && r.error !== "offline");
85
+ if (interesting.length > 0) {
86
+ console.log("\nNon-timeout failures (first few):");
87
+ for (const r of interesting.slice(0, 5)) {
88
+ console.log(` - ${shortName(r.host)} (${r.host.ip}): ${r.error}`);
89
+ }
90
+ }
91
+ return;
92
+ }
93
+ for (const result of found) {
94
+ const label = result.host.dnsName || result.host.hostname;
95
+ const selfTag = result.host.isSelf ? " [this device]" : "";
96
+ console.log(`${label}${selfTag}`);
97
+ console.log(` IP: ${result.host.ip} source: ${result.host.source} OS: ${result.host.os}`);
98
+ console.log(` Endpoint: http://${result.host.ip}:${result.host.port}`);
99
+ if (result.models.length === 0) {
100
+ console.log(" Models: (none)");
101
+ }
102
+ else {
103
+ console.log(` Models (${result.models.length}):`);
104
+ for (const model of result.models) {
105
+ const size = formatBytes(model.size);
106
+ const params = model.details?.parameter_size;
107
+ const quant = model.details?.quantization_level;
108
+ const meta = [params, quant, size].filter(Boolean).join(" · ");
109
+ console.log(meta ? ` - ${model.name} (${meta})` : ` - ${model.name}`);
110
+ }
111
+ }
112
+ console.log("");
113
+ }
114
+ console.log(`Found ${found.length} Ollama server(s) with ${found.reduce((n, r) => n + r.models.length, 0)} model entrie(s).`);
115
+ }
116
+ export async function main() {
117
+ const includeOffline = process.argv.includes("--all");
118
+ const jsonOut = process.argv.includes("--json");
119
+ const lanScan = process.argv.includes("--lan");
120
+ const config = await loadConfig();
121
+ const { hosts, sources, networkLabel } = await discoverHosts({
122
+ hosts: config.hosts,
123
+ discovery: config.discovery,
124
+ includeOffline,
125
+ lanScan,
126
+ });
127
+ if (hosts.length === 0) {
128
+ console.error("No hosts to scan. Add config.hosts, set OLLANET_HOSTS, or enable Tailscale/LAN discovery.");
129
+ process.exitCode = 1;
130
+ return;
131
+ }
132
+ const results = await mapPool(hosts, CONCURRENCY, scanHost);
133
+ if (jsonOut) {
134
+ console.log(JSON.stringify({
135
+ network: networkLabel,
136
+ sources,
137
+ port: OLLAMA_PORT,
138
+ scanned: results.length,
139
+ servers: results
140
+ .filter((r) => r.ok)
141
+ .map((r) => ({
142
+ hostname: r.host.hostname,
143
+ dnsName: r.host.dnsName,
144
+ ip: r.host.ip,
145
+ port: r.host.port,
146
+ os: r.host.os,
147
+ source: r.host.source,
148
+ self: r.host.isSelf,
149
+ endpoint: `http://${r.host.ip}:${r.host.port}`,
150
+ models: r.models.map((m) => ({
151
+ name: m.name,
152
+ size: m.size,
153
+ modified_at: m.modified_at,
154
+ parameter_size: m.details?.parameter_size,
155
+ quantization_level: m.details?.quantization_level,
156
+ family: m.details?.family,
157
+ })),
158
+ })),
159
+ }, null, 2));
160
+ return;
161
+ }
162
+ printResults(results, networkLabel, sources);
163
+ if (!results.some((r) => r.ok)) {
164
+ process.exitCode = 2;
165
+ }
166
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "ollanet",
3
+ "version": "0.1.0",
4
+ "description": "Chat with Ollama servers on any reachable network — scan hosts, prompt models, continue chats by hash",
5
+ "type": "module",
6
+ "bin": {
7
+ "ollanet": "./dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc -p tsconfig.build.json",
16
+ "prepare": "tsc -p tsconfig.build.json",
17
+ "ollanet": "tsx src/cli.ts",
18
+ "scan": "tsx src/cli.ts scan",
19
+ "prompt": "tsx src/cli.ts prompt",
20
+ "chats": "tsx src/cli.ts chats",
21
+ "start": "tsx src/cli.ts scan",
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "keywords": [
25
+ "ollama",
26
+ "cli",
27
+ "llm",
28
+ "chat",
29
+ "lan",
30
+ "tailscale",
31
+ "network"
32
+ ],
33
+ "author": "Catalyst Forge LLC",
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/Catalyst-Forge-LLC/ollanet.git"
38
+ },
39
+ "homepage": "https://github.com/Catalyst-Forge-LLC/ollanet#readme",
40
+ "bugs": {
41
+ "url": "https://github.com/Catalyst-Forge-LLC/ollanet/issues"
42
+ },
43
+ "engines": {
44
+ "node": ">=20"
45
+ },
46
+ "packageManager": "pnpm@8.14.1",
47
+ "devDependencies": {
48
+ "@types/node": "^26.2.0",
49
+ "tsx": "^4.23.12",
50
+ "typescript": "^5.8.3"
51
+ }
52
+ }