ollanet 0.2.2 → 0.4.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 CHANGED
@@ -1,4 +1,3 @@
1
- #!/usr/bin/env node
2
1
  /**
3
2
  * Run a prompt / continue a chat against an Ollama server on the network.
4
3
  *
@@ -14,11 +13,13 @@
14
13
  */
15
14
  import { stdin as stdinStream } from "node:process";
16
15
  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";
16
+ import { configPath, defaultModelForHost, loadConfig, machineSettingsForHost, mergeSettings, parseFormat, settingsFromEnv, settingsSummary, configFromPartial, } from "./config.js";
18
17
  import { discoverHosts, envInt, findDiscoveredHost, ollamaBaseUrl, resolveHost, shortName, } from "./hosts.js";
19
18
  import { ollamaChat } from "./ollama-chat.js";
20
19
  /** Prompt/chat HTTP timeout (ms). 0 = no timeout. Separate from scan's OLLAMA_TIMEOUT_MS. */
21
- const PROMPT_TIMEOUT_MS = envInt("OLLAMA_PROMPT_TIMEOUT_MS", 600_000);
20
+ function promptTimeoutMs() {
21
+ return envInt("OLLAMA_PROMPT_TIMEOUT_MS", 600_000);
22
+ }
22
23
  function usage() {
23
24
  console.error(`Usage:
24
25
  ollanet prompt <machine> [model] <prompt...>
@@ -77,7 +78,7 @@ function modelNameMatches(available, candidate) {
77
78
  async function listModelNames(host) {
78
79
  const url = `${ollamaBaseUrl(host)}/api/tags`;
79
80
  const controller = new AbortController();
80
- const timer = setTimeout(() => controller.abort(), Math.min(PROMPT_TIMEOUT_MS || 2500, 5000));
81
+ const timer = setTimeout(() => controller.abort(), Math.min(promptTimeoutMs() || 2500, 5000));
81
82
  try {
82
83
  const res = await fetch(url, {
83
84
  signal: controller.signal,
@@ -278,7 +279,7 @@ async function generateTopic(opts) {
278
279
  model: opts.model,
279
280
  stream: false,
280
281
  writeStdout: false,
281
- timeoutMs: PROMPT_TIMEOUT_MS,
282
+ timeoutMs: promptTimeoutMs(),
282
283
  settings: mergeSettings(opts.settings, {
283
284
  temperature: 0.2,
284
285
  num_predict: 24,
@@ -308,62 +309,48 @@ function formatDuration(ns) {
308
309
  return `${ms.toFixed(0)}ms`;
309
310
  return `${(ms / 1000).toFixed(1)}s`;
310
311
  }
311
- export async function main() {
312
- const parsed = parseArgs(process.argv.slice(2));
313
- const { stream, json, save } = parsed;
314
- let promptParts = parsed.promptParts;
315
- const fromStdin = await readStdinIfPiped();
316
- const config = await loadConfig();
312
+ /** Programmatic prompt used by CLI `--json` and the MCP server. */
313
+ export async function runPrompt(opts) {
314
+ const save = opts.save !== false;
315
+ const stream = opts.stream !== false && opts.writeStdout !== false;
316
+ const quiet = opts.quiet === true || opts.writeStdout === false;
317
+ const writeStdout = opts.writeStdout !== false;
318
+ let promptParts = opts.prompt ? [opts.prompt] : [];
319
+ const config = opts.config ? configFromPartial(opts.config) : await loadConfig();
317
320
  const { hosts: targets } = await discoverHosts({
318
321
  hosts: config.hosts,
319
322
  discovery: config.discovery,
320
323
  });
321
324
  let chat;
322
325
  let isNewChat = false;
323
- let machineQuery = parsed.machine;
324
- if (parsed.chatId) {
325
- chat = await loadChat(parsed.chatId);
326
- // Allow `ollanet prompt <host> --chat HASH "follow-up"` only when the
327
- // leading token matches an already-discovered host (not direct-address fallback).
328
- if (!machineQuery && promptParts.length >= 2) {
329
- const hit = findDiscoveredHost(targets, promptParts[0]);
330
- if (hit) {
331
- machineQuery = promptParts[0];
332
- promptParts = promptParts.slice(1);
333
- }
334
- }
326
+ let machineQuery = opts.machine;
327
+ if (opts.chatId) {
328
+ chat = await loadChat(opts.chatId);
335
329
  }
336
330
  machineQuery = machineQuery ?? chat?.machine;
337
331
  if (!machineQuery) {
338
- console.error("Machine is required for a new chat (or use --chat <hash>).");
339
- usage();
332
+ throw new Error("Machine is required for a new chat (or pass chatId).");
340
333
  }
341
334
  const host = resolveHost(targets, machineQuery);
342
335
  if (!host.online && !host.isSelf && host.source === "tailscale") {
343
336
  throw new Error(`Machine "${shortName(host)}" appears offline.`);
344
337
  }
345
338
  let peeledModel;
346
- if (!parsed.chatId && !parsed.model) {
347
- const peeled = await maybePeelModel(host, promptParts, fromStdin);
339
+ if (!opts.chatId && !opts.model) {
340
+ const peeled = await maybePeelModel(host, promptParts, "");
348
341
  peeledModel = peeled.model;
349
342
  promptParts = peeled.promptParts;
350
343
  }
351
- const promptText = [promptParts.join(" ").trim(), fromStdin].filter(Boolean).join("\n\n").trim();
344
+ const promptText = promptParts.join(" ").trim();
352
345
  if (!promptText) {
353
- console.error("No prompt provided (pass args and/or pipe stdin).");
354
- usage();
346
+ throw new Error("No prompt provided.");
355
347
  }
356
- const model = parsed.model ??
357
- peeledModel ??
358
- chat?.model ??
359
- defaultModelForHost(config, host);
348
+ const model = opts.model ?? peeledModel ?? chat?.model ?? defaultModelForHost(config, host);
360
349
  if (!model) {
361
350
  throw new Error(`No model specified for "${shortName(host)}" and no default in ${configPath()}.\n` +
362
- `Pass a model, use --model <name>, or add it under defaultModels.`);
351
+ `Pass a model or add it under defaultModels.`);
363
352
  }
364
- const settings = mergeSettings(
365
- // Thinking models otherwise spend the whole num_predict budget on hidden reasoning.
366
- { think: false }, config.defaults, machineSettingsForHost(config, host), settingsFromEnv(), chat?.system ? { system: chat.system } : undefined, parsed.settings);
353
+ const settings = mergeSettings({ think: false }, config.defaults, machineSettingsForHost(config, host), settingsFromEnv(), chat?.system ? { system: chat.system } : undefined, opts.settings);
367
354
  if (!chat && save) {
368
355
  chat = createChat({
369
356
  machine: shortName(host),
@@ -374,7 +361,6 @@ export async function main() {
374
361
  isNewChat = true;
375
362
  }
376
363
  else if (!chat) {
377
- // --no-save one-shot: ephemeral transcript for the API call only
378
364
  chat = createChat({
379
365
  machine: shortName(host),
380
366
  model,
@@ -383,27 +369,27 @@ export async function main() {
383
369
  isNewChat = true;
384
370
  }
385
371
  else {
386
- if (parsed.settings.system && !chat.system) {
387
- chat.system = parsed.settings.system;
372
+ if (opts.settings?.system && !chat.system) {
373
+ chat.system = opts.settings.system;
388
374
  }
389
375
  chat.model = model;
390
376
  chat.machine = shortName(host);
391
377
  }
392
378
  appendMessage(chat, { role: "user", content: promptText });
393
379
  const baseUrl = ollamaBaseUrl(host);
394
- if (!json) {
395
- const src = parsed.model ? "explicit" : chat && !isNewChat ? "chat" : "default";
380
+ if (!quiet) {
381
+ const src = opts.model ? "explicit" : chat && !isNewChat ? "chat" : "default";
396
382
  const extras = settingsSummary(settings);
397
- const chatTag = parsed.chatId ? ` chat=${chat.id}` : "";
383
+ const chatTag = opts.chatId ? ` chat=${chat.id}` : "";
398
384
  console.error(`→ ${shortName(host)} (${host.ip}) model=${model} [${src}]${chatTag}${extras ? ` ${extras}` : ""}`);
399
385
  }
400
386
  const { content, thinking, chunk } = await ollamaChat({
401
387
  baseUrl,
402
388
  model,
403
389
  messages: toApiMessages(chat),
404
- stream: json ? false : stream,
405
- writeStdout: !json,
406
- timeoutMs: PROMPT_TIMEOUT_MS,
390
+ stream: writeStdout ? stream : false,
391
+ writeStdout,
392
+ timeoutMs: promptTimeoutMs(),
407
393
  settings,
408
394
  });
409
395
  appendMessage(chat, {
@@ -429,30 +415,100 @@ export async function main() {
429
415
  }
430
416
  if (save) {
431
417
  const file = await saveChat(chat);
432
- if (!json) {
418
+ if (!quiet) {
433
419
  console.error(`chat ${chat.id} topic: ${chat.topic}`);
434
420
  console.error(`saved ${file}`);
435
421
  console.error(`continue: ollanet prompt --chat ${chat.id} "your follow-up"`);
436
422
  }
437
423
  }
438
- if (json) {
439
- console.log(JSON.stringify({
440
- chat_id: save ? chat.id : null,
441
- topic: chat.topic,
442
- content,
443
- thinking: thinking || null,
444
- ollama: chunk,
445
- }, null, 2));
446
- return;
424
+ return {
425
+ chat_id: save ? chat.id : null,
426
+ topic: chat.topic,
427
+ content,
428
+ thinking: thinking || null,
429
+ machine: shortName(host),
430
+ model,
431
+ ollama: chunk,
432
+ };
433
+ }
434
+ export async function main() {
435
+ const parsed = parseArgs(process.argv.slice(2));
436
+ const { stream, json, save } = parsed;
437
+ let promptParts = parsed.promptParts;
438
+ const fromStdin = await readStdinIfPiped();
439
+ const config = await loadConfig();
440
+ const { hosts: targets } = await discoverHosts({
441
+ hosts: config.hosts,
442
+ discovery: config.discovery,
443
+ });
444
+ let machineQuery = parsed.machine;
445
+ if (parsed.chatId) {
446
+ // Allow `ollanet prompt <host> --chat HASH "follow-up"` only when the
447
+ // leading token matches an already-discovered host (not direct-address fallback).
448
+ if (!machineQuery && promptParts.length >= 2) {
449
+ const hit = findDiscoveredHost(targets, promptParts[0]);
450
+ if (hit) {
451
+ machineQuery = promptParts[0];
452
+ promptParts = promptParts.slice(1);
453
+ }
454
+ }
447
455
  }
448
- const stats = [
449
- chunk.eval_count != null ? `${chunk.eval_count} tokens` : "",
450
- chunk.eval_duration
451
- ? `${((chunk.eval_count ?? 0) / (chunk.eval_duration / 1e9)).toFixed(1)} tok/s`
452
- : "",
453
- chunk.total_duration ? formatDuration(chunk.total_duration) : "",
454
- ].filter(Boolean);
455
- if (stats.length > 0) {
456
- console.error(`(${stats.join(" · ")})`);
456
+ let peeledModel;
457
+ if (!parsed.chatId && !parsed.model) {
458
+ if (!machineQuery) {
459
+ console.error("Machine is required for a new chat (or use --chat <hash>).");
460
+ usage();
461
+ }
462
+ const host = resolveHost(targets, machineQuery);
463
+ const peeled = await maybePeelModel(host, promptParts, fromStdin);
464
+ peeledModel = peeled.model;
465
+ promptParts = peeled.promptParts;
466
+ }
467
+ const promptText = [promptParts.join(" ").trim(), fromStdin].filter(Boolean).join("\n\n").trim();
468
+ if (!promptText) {
469
+ console.error("No prompt provided (pass args and/or pipe stdin).");
470
+ usage();
471
+ }
472
+ try {
473
+ const result = await runPrompt({
474
+ machine: machineQuery,
475
+ model: parsed.model ?? peeledModel,
476
+ prompt: promptText,
477
+ chatId: parsed.chatId,
478
+ save,
479
+ settings: parsed.settings,
480
+ writeStdout: !json,
481
+ stream: json ? false : stream,
482
+ quiet: json,
483
+ });
484
+ if (json) {
485
+ console.log(JSON.stringify({
486
+ chat_id: result.chat_id,
487
+ topic: result.topic,
488
+ content: result.content,
489
+ thinking: result.thinking,
490
+ ollama: result.ollama,
491
+ }, null, 2));
492
+ return;
493
+ }
494
+ const chunk = result.ollama;
495
+ const stats = [
496
+ chunk.eval_count != null ? `${chunk.eval_count} tokens` : "",
497
+ chunk.eval_duration
498
+ ? `${((Number(chunk.eval_count) || 0) / (Number(chunk.eval_duration) / 1e9)).toFixed(1)} tok/s`
499
+ : "",
500
+ chunk.total_duration ? formatDuration(Number(chunk.total_duration)) : "",
501
+ ].filter(Boolean);
502
+ if (stats.length > 0) {
503
+ console.error(`(${stats.join(" · ")})`);
504
+ }
505
+ }
506
+ catch (err) {
507
+ const message = err instanceof Error ? err.message : String(err);
508
+ if (message.startsWith("Machine is required") || message.startsWith("No prompt")) {
509
+ console.error(message);
510
+ usage();
511
+ }
512
+ throw err;
457
513
  }
458
514
  }
package/dist/scan.d.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Scan reachable networks for Ollama servers and list available models.
3
+ *
4
+ * Usage: ollanet scan [--lan] [--json] [--all]
5
+ */
6
+ import { type AppConfig } from "./config.js";
7
+ export interface ScanOptions {
8
+ includeOffline?: boolean;
9
+ /** TCP-scan local /24s. Opt-in; never the default. */
10
+ lanScan?: boolean;
11
+ /**
12
+ * In-memory config. When set, the config file (`~/.ollanet` / `OLLANET_CONFIG`)
13
+ * is not read. Omit to keep CLI/MCP file behavior.
14
+ */
15
+ config?: Partial<AppConfig>;
16
+ }
17
+ export interface ScannedServer {
18
+ hostname: string;
19
+ dnsName: string;
20
+ ip: string;
21
+ port: number;
22
+ os: string;
23
+ source: string;
24
+ self: boolean;
25
+ endpoint: string;
26
+ models: Array<{
27
+ name: string;
28
+ size?: number;
29
+ modified_at?: string;
30
+ parameter_size?: string;
31
+ quantization_level?: string;
32
+ family?: string;
33
+ }>;
34
+ }
35
+ export interface ScanPayload {
36
+ network: string;
37
+ sources: string[];
38
+ port: number;
39
+ scanned: number;
40
+ servers: ScannedServer[];
41
+ }
42
+ /** Programmatic scan used by CLI `--json`, MCP, and `import { scanNetwork } from "ollanet"`. */
43
+ export declare function scanNetwork(options?: ScanOptions): Promise<ScanPayload>;
44
+ export declare function main(): Promise<void>;
package/dist/scan.js CHANGED
@@ -1,13 +1,16 @@
1
- #!/usr/bin/env node
2
1
  /**
3
2
  * Scan reachable networks for Ollama servers and list available models.
4
3
  *
5
4
  * Usage: ollanet scan [--lan] [--json] [--all]
6
5
  */
7
- import { loadConfig } from "./config.js";
8
- import { OLLAMA_PORT, discoverHosts, envInt, shortName, } from "./hosts.js";
9
- const TIMEOUT_MS = envInt("OLLAMA_TIMEOUT_MS", 2500);
10
- const CONCURRENCY = Math.max(1, envInt("OLLAMA_CONCURRENCY", 16));
6
+ import { configFromPartial, loadConfig } from "./config.js";
7
+ import { discoverHosts, envInt, ollamaPort, shortName, } from "./hosts.js";
8
+ function scanTimeoutMs() {
9
+ return envInt("OLLAMA_TIMEOUT_MS", 2500);
10
+ }
11
+ function scanConcurrency() {
12
+ return Math.max(1, envInt("OLLAMA_CONCURRENCY", 16));
13
+ }
11
14
  function formatBytes(bytes) {
12
15
  if (bytes == null || !Number.isFinite(bytes))
13
16
  return "";
@@ -22,8 +25,9 @@ function formatBytes(bytes) {
22
25
  }
23
26
  async function fetchModels(host) {
24
27
  const url = `http://${host.ip.includes(":") ? `[${host.ip}]` : host.ip}:${host.port}/api/tags`;
28
+ const timeoutMs = scanTimeoutMs();
25
29
  const controller = new AbortController();
26
- const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
30
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
27
31
  try {
28
32
  const res = await fetch(url, {
29
33
  signal: controller.signal,
@@ -66,7 +70,7 @@ async function scanHost(host) {
66
70
  const name = err instanceof Error ? err.name : "";
67
71
  const message = err instanceof Error ? err.message : String(err);
68
72
  const error = name === "AbortError" || message.toLowerCase().includes("abort")
69
- ? `timeout after ${TIMEOUT_MS}ms`
73
+ ? `timeout after ${scanTimeoutMs()}ms`
70
74
  : message;
71
75
  return { host, ok: false, models: [], error, url };
72
76
  }
@@ -113,6 +117,49 @@ function printResults(results, networkLabel, sources) {
113
117
  }
114
118
  console.log(`Found ${found.length} Ollama server(s) with ${found.reduce((n, r) => n + r.models.length, 0)} model entrie(s).`);
115
119
  }
120
+ function toPayload(results, networkLabel, sources) {
121
+ return {
122
+ network: networkLabel,
123
+ sources,
124
+ port: ollamaPort(),
125
+ scanned: results.length,
126
+ servers: results
127
+ .filter((r) => r.ok)
128
+ .map((r) => ({
129
+ hostname: r.host.hostname,
130
+ dnsName: r.host.dnsName,
131
+ ip: r.host.ip,
132
+ port: r.host.port,
133
+ os: r.host.os,
134
+ source: r.host.source,
135
+ self: r.host.isSelf,
136
+ endpoint: `http://${r.host.ip}:${r.host.port}`,
137
+ models: r.models.map((m) => ({
138
+ name: m.name,
139
+ size: m.size,
140
+ modified_at: m.modified_at,
141
+ parameter_size: m.details?.parameter_size,
142
+ quantization_level: m.details?.quantization_level,
143
+ family: m.details?.family,
144
+ })),
145
+ })),
146
+ };
147
+ }
148
+ /** Programmatic scan used by CLI `--json`, MCP, and `import { scanNetwork } from "ollanet"`. */
149
+ export async function scanNetwork(options = {}) {
150
+ const config = options.config ? configFromPartial(options.config) : await loadConfig();
151
+ const { hosts, sources, networkLabel } = await discoverHosts({
152
+ hosts: config.hosts,
153
+ discovery: config.discovery,
154
+ includeOffline: options.includeOffline ?? false,
155
+ lanScan: options.lanScan ?? false,
156
+ });
157
+ if (hosts.length === 0) {
158
+ throw new Error("No hosts to scan. Add config.hosts, set OLLANET_HOSTS, or enable Tailscale/LAN discovery.");
159
+ }
160
+ const results = await mapPool(hosts, scanConcurrency(), scanHost);
161
+ return toPayload(results, networkLabel, sources);
162
+ }
116
163
  export async function main() {
117
164
  const includeOffline = process.argv.includes("--all");
118
165
  const jsonOut = process.argv.includes("--json");
@@ -129,34 +176,9 @@ export async function main() {
129
176
  process.exitCode = 1;
130
177
  return;
131
178
  }
132
- const results = await mapPool(hosts, CONCURRENCY, scanHost);
179
+ const results = await mapPool(hosts, scanConcurrency(), scanHost);
133
180
  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));
181
+ console.log(JSON.stringify(toPayload(results, networkLabel, sources), null, 2));
160
182
  return;
161
183
  }
162
184
  printResults(results, networkLabel, sources);
package/package.json CHANGED
@@ -1,28 +1,44 @@
1
1
  {
2
2
  "name": "ollanet",
3
- "version": "0.2.2",
4
- "description": "Chat with Ollama servers on any reachable network — scan hosts, prompt models, continue chats by hash",
3
+ "version": "0.4.0",
4
+ "description": "Discover and talk to Ollama on any reachable network — CLI, MCP, and a Node library",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "ollanet": "./dist/cli.js"
8
8
  },
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ },
14
+ "./scan": {
15
+ "types": "./dist/scan.d.ts",
16
+ "import": "./dist/scan.js"
17
+ },
18
+ "./package.json": "./package.json"
19
+ },
20
+ "types": "./dist/index.d.ts",
9
21
  "files": [
10
22
  "dist",
11
23
  "README.md",
12
24
  "LICENSE"
13
25
  ],
14
26
  "scripts": {
15
- "build": "tsc -p tsconfig.build.json",
16
- "prepare": "tsc -p tsconfig.build.json",
27
+ "build": "tsc -p tsconfig.build.json && node scripts/fix-dts-extensions.mjs",
28
+ "prepare": "tsc -p tsconfig.build.json && node scripts/fix-dts-extensions.mjs",
17
29
  "ollanet": "tsx src/cli.ts",
18
30
  "scan": "tsx src/cli.ts scan",
19
31
  "prompt": "tsx src/cli.ts prompt",
20
32
  "chats": "tsx src/cli.ts chats",
21
33
  "bench": "tsx src/cli.ts bench",
34
+ "mcp": "tsx src/cli.ts mcp",
22
35
  "start": "tsx src/cli.ts scan",
23
36
  "typecheck": "tsc --noEmit",
24
- "test": "tsc -p tsconfig.build.json && node --test test/*.test.mjs",
25
- "test:only": "node --test test/*.test.mjs"
37
+ "test": "tsc -p tsconfig.build.json && node scripts/fix-dts-extensions.mjs && node --test test/*.test.mjs",
38
+ "test:only": "node --test test/*.test.mjs",
39
+ "site:dev": "pnpm --dir site dev",
40
+ "site:build": "pnpm --dir site build",
41
+ "site:deploy": "pnpm --dir site deploy"
26
42
  },
27
43
  "keywords": [
28
44
  "ollama",
@@ -31,7 +47,10 @@
31
47
  "chat",
32
48
  "lan",
33
49
  "tailscale",
34
- "network"
50
+ "network",
51
+ "mcp",
52
+ "model-context-protocol",
53
+ "library"
35
54
  ],
36
55
  "author": "Catalyst Forge LLC",
37
56
  "license": "MIT",
@@ -39,7 +58,7 @@
39
58
  "type": "git",
40
59
  "url": "git+https://github.com/Catalyst-Forge-LLC/ollanet.git"
41
60
  },
42
- "homepage": "https://github.com/Catalyst-Forge-LLC/ollanet#readme",
61
+ "homepage": "https://ollanet.dev",
43
62
  "bugs": {
44
63
  "url": "https://github.com/Catalyst-Forge-LLC/ollanet/issues"
45
64
  },