ollanet 0.1.0 → 0.1.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.
package/README.md CHANGED
@@ -6,7 +6,7 @@ Scan for models, fire prompts, and continue conversations later by a short hash.
6
6
 
7
7
  ## Origin
8
8
 
9
- This started as a selfish little script. I had Ollama running on a laptop on my Tailscale network and wanted an easy way to discover it, poke at the models, and actually talk to them from another machine — without babysitting IPs or opening a browser. One scan/prompt tool later, chats by hash showed up, LAN discovery crept in, and the thing grew into **ollanet**: a tiny CLI for using Ollama over whatever network you already have.
9
+ This started as a personal little script. I had Ollama running on a laptop on my Tailscale network and wanted an easy way to discover it, poke at the models, and actually talk to them from another machine — without babysitting IPs or opening a browser. One scan/prompt tool later, chats by hash showed up, LAN discovery crept in, and the thing grew into **ollanet**: a tiny CLI for using Ollama over whatever network you already have.
10
10
 
11
11
  ## Related: Finetuna
12
12
 
@@ -127,6 +127,7 @@ ollanet prompt --chat <hash> <prompt...>
127
127
  | `--num-ctx <n>` | Context window |
128
128
  | `--keep-alive <value>` | Keep model loaded (`5m`, `0`, `-1`, …) |
129
129
  | `--format json\|<schema>` | JSON mode / schema |
130
+ | `--think` / `--no-think` | Enable/disable model thinking (default **off** so replies aren’t empty on qwen3-style models) |
130
131
  | `--no-stream` | Buffer the full reply |
131
132
  | `--no-save` | Do not write a transcript |
132
133
  | `--json` | Emit JSON (includes chat id when saved) |
@@ -174,7 +175,8 @@ Edit `~/.ollanet/config.json` (installed) or `config.json` (checkout) — or poi
174
175
  "defaults": {
175
176
  "temperature": 0.7,
176
177
  "num_predict": 512,
177
- "keep_alive": "5m"
178
+ "keep_alive": "5m",
179
+ "think": false
178
180
  },
179
181
  "machineDefaults": {
180
182
  "studio": { "num_ctx": 16384 }
@@ -200,7 +202,9 @@ Edit `~/.ollanet/config.json` (installed) or `config.json` (checkout) — or poi
200
202
  | `OLLAMA_KEEP_ALIVE` | Default keep-alive |
201
203
  | `OLLAMA_FORMAT` | Default format (`json` or schema JSON) |
202
204
  | `OLLAMA_SYSTEM` | Default system prompt |
205
+ | `OLLANET_THINK` / `OLLAMA_THINK` | Enable thinking (`true`/`false`; default off) |
203
206
  | `OLLAMA_TIMEOUT_MS` | HTTP scan/probe timeout |
207
+ | `OLLAMA_PROMPT_TIMEOUT_MS` | Prompt/chat HTTP timeout (default `600000`; `0` = none) |
204
208
  | `OLLAMA_CONCURRENCY` | Scan concurrency |
205
209
 
206
210
  ## Chat transcripts
package/dist/config.js CHANGED
@@ -107,6 +107,16 @@ export function normalizeSettings(input) {
107
107
  if (typeof src.system === "string" && src.system.trim()) {
108
108
  out.system = src.system;
109
109
  }
110
+ const think = src.think;
111
+ if (typeof think === "boolean")
112
+ out.think = think;
113
+ else if (typeof think === "string") {
114
+ const v = think.trim().toLowerCase();
115
+ if (["1", "true", "yes", "on"].includes(v))
116
+ out.think = true;
117
+ if (["0", "false", "no", "off"].includes(v))
118
+ out.think = false;
119
+ }
110
120
  return out;
111
121
  }
112
122
  function asFiniteNumber(value) {
@@ -192,6 +202,8 @@ export function mergeSettings(...layers) {
192
202
  out.format = layer.format;
193
203
  if (layer.system != null)
194
204
  out.system = layer.system;
205
+ if (layer.think != null)
206
+ out.think = layer.think;
195
207
  }
196
208
  return out;
197
209
  }
@@ -203,6 +215,7 @@ export function settingsFromEnv() {
203
215
  keep_alive: process.env.OLLAMA_KEEP_ALIVE,
204
216
  format: process.env.OLLAMA_FORMAT,
205
217
  system: process.env.OLLAMA_SYSTEM,
218
+ think: process.env.OLLANET_THINK ?? process.env.OLLAMA_THINK,
206
219
  });
207
220
  }
208
221
  export function settingsSummary(settings) {
@@ -218,6 +231,10 @@ export function settingsSummary(settings) {
218
231
  if (settings.format !== undefined) {
219
232
  parts.push(settings.format === "json" ? "format=json" : "format=<schema>");
220
233
  }
234
+ if (settings.think === true)
235
+ parts.push("think=on");
236
+ if (settings.think === false)
237
+ parts.push("think=off");
221
238
  return parts.join(" ");
222
239
  }
223
240
  export function configPath() {
package/dist/hosts.js CHANGED
@@ -3,7 +3,18 @@ import net from "node:net";
3
3
  import os from "node:os";
4
4
  import { promisify } from "node:util";
5
5
  const execFileAsync = promisify(execFile);
6
- export const OLLAMA_PORT = Number(process.env.OLLAMA_PORT ?? 11434);
6
+ /** Parse a required numeric env var; invalid values throw instead of becoming NaN. */
7
+ export function envInt(name, fallback) {
8
+ const raw = process.env[name];
9
+ if (raw == null || String(raw).trim() === "")
10
+ return fallback;
11
+ const n = Number(raw);
12
+ if (!Number.isFinite(n)) {
13
+ throw new Error(`Invalid ${name}="${raw}" (expected a number)`);
14
+ }
15
+ return Math.trunc(n);
16
+ }
17
+ export const OLLAMA_PORT = envInt("OLLAMA_PORT", 11434);
7
18
  function cleanDnsName(dnsName) {
8
19
  if (!dnsName)
9
20
  return "";
@@ -242,8 +253,8 @@ async function tcpOpen(ip, port, timeoutMs) {
242
253
  });
243
254
  }
244
255
  async function lanScanTargets(cidrs, port) {
245
- const timeoutMs = Number(process.env.OLLANET_LAN_TIMEOUT_MS ?? 200);
246
- const concurrency = Number(process.env.OLLANET_LAN_CONCURRENCY ?? 64);
256
+ const timeoutMs = envInt("OLLANET_LAN_TIMEOUT_MS", 200);
257
+ const concurrency = Math.max(1, envInt("OLLANET_LAN_CONCURRENCY", 64));
247
258
  const ips = [...new Set(cidrs.flatMap((cidr) => cidrToIps(cidr)))];
248
259
  if (ips.length === 0)
249
260
  return [];
@@ -261,15 +272,21 @@ async function lanScanTargets(cidrs, port) {
261
272
  port,
262
273
  }));
263
274
  }
275
+ /**
276
+ * True for IPs, FQDNs, and host:port — not bare words.
277
+ * Bare words must match a discovered host so typos get a helpful error.
278
+ */
264
279
  function looksLikeAddress(query) {
265
280
  const q = query.trim();
266
- if (!q)
281
+ if (!q || /\s/.test(q))
267
282
  return false;
268
- if (/^\d{1,3}(\.\d{1,3}){3}$/.test(q))
283
+ if (/^\d{1,3}(\.\d{1,3}){3}(:\d+)?$/.test(q))
284
+ return true;
285
+ if (/^\[[0-9a-f:]+\](:\d+)?$/i.test(q))
269
286
  return true;
270
- if (q.includes(":") && !q.includes(" "))
271
- return true; // hostname:port or ipv6-ish
272
- if (/^[a-z0-9][a-z0-9._-]*$/i.test(q))
287
+ if (/^[a-z0-9][a-z0-9._-]*:\d+$/i.test(q))
288
+ return true;
289
+ if (q.includes(".") && /^[a-z0-9][a-z0-9.-]+$/i.test(q))
273
290
  return true;
274
291
  return false;
275
292
  }
@@ -286,16 +303,23 @@ function parseDirectAddress(query) {
286
303
  hostname = ip;
287
304
  port = Number(ipv4WithPort[2]);
288
305
  }
289
- else if (!q.includes("://") && q.includes(".") === false && q.includes(":")) {
290
- // host:port without dots (rare) — skip to avoid mangling IPv6
291
- }
292
306
  else {
307
+ // host:port with or without dots. Requires a single colon + numeric port so
308
+ // bare IPv6 (multiple colons) is left alone.
293
309
  const hostPort = q.match(/^([a-z0-9][a-z0-9._-]*):(\d+)$/i);
294
310
  if (hostPort) {
295
311
  hostname = hostPort[1];
296
312
  ip = hostname;
297
313
  port = Number(hostPort[2]);
298
314
  }
315
+ else {
316
+ const bracketed = q.match(/^\[([0-9a-f:]+)\]:(\d+)$/i);
317
+ if (bracketed) {
318
+ ip = bracketed[1];
319
+ hostname = ip;
320
+ port = Number(bracketed[2]);
321
+ }
322
+ }
299
323
  }
300
324
  return makeHost({
301
325
  hostname,
@@ -358,16 +382,30 @@ export async function discoverHosts(opts = {}) {
358
382
  : sources.join("+") || "local");
359
383
  return { hosts, sources, networkLabel };
360
384
  }
385
+ function hostNameMatches(host, q) {
386
+ const names = [host.hostname, host.dnsName, shortName(host), host.ip].map((n) => n.toLowerCase());
387
+ return names.includes(q) || names.some((n) => n.startsWith(`${q}.`));
388
+ }
389
+ /**
390
+ * Match a query against already-discovered hosts only (no direct-address fallback).
391
+ * Used when peeling an optional leading hostname before a prompt.
392
+ */
393
+ export function findDiscoveredHost(targets, query) {
394
+ const q = query.trim().toLowerCase().replace(/\.$/, "");
395
+ if (!q)
396
+ return undefined;
397
+ const matches = targets.filter((host) => hostNameMatches(host, q));
398
+ if (matches.length === 1)
399
+ return matches[0];
400
+ return undefined;
401
+ }
361
402
  /** Resolve a machine name against discovered hosts, or treat it as a direct address. */
362
403
  export function resolveHost(targets, query) {
363
404
  const q = query.trim().toLowerCase().replace(/\.$/, "");
364
405
  if (!q) {
365
406
  throw new Error("Machine name is empty.");
366
407
  }
367
- const matches = targets.filter((host) => {
368
- const names = [host.hostname, host.dnsName, shortName(host), host.ip].map((n) => n.toLowerCase());
369
- return names.includes(q) || names.some((n) => n.startsWith(`${q}.`));
370
- });
408
+ const matches = targets.filter((host) => hostNameMatches(host, q));
371
409
  if (matches.length === 1)
372
410
  return matches[0];
373
411
  if (matches.length > 1) {
package/dist/prompt.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * Run a prompt / continue a chat against an Ollama server on the network.
4
4
  *
5
5
  * New chat (saved by default):
6
- * ollanet prompt mycroftone "What is Tailscale?"
6
+ * ollanet prompt localhost "What is Tailscale?"
7
7
  * ollanet prompt 192.168.1.50 "Hello"
8
8
  *
9
9
  * Continue:
@@ -15,7 +15,9 @@
15
15
  import { stdin as stdinStream } from "node:process";
16
16
  import { appendMessage, cleanTopic, createChat, loadChat, saveChat, toApiMessages, topicFromPrompt, } from "./chat-store.js";
17
17
  import { configPath, defaultModelForHost, loadConfig, machineSettingsForHost, mergeSettings, parseFormat, settingsFromEnv, settingsSummary, } from "./config.js";
18
- import { discoverHosts, ollamaBaseUrl, resolveHost, shortName, } from "./hosts.js";
18
+ import { discoverHosts, envInt, findDiscoveredHost, ollamaBaseUrl, resolveHost, shortName, } from "./hosts.js";
19
+ /** Prompt/chat HTTP timeout (ms). 0 = no timeout. Separate from scan's OLLAMA_TIMEOUT_MS. */
20
+ const PROMPT_TIMEOUT_MS = envInt("OLLAMA_PROMPT_TIMEOUT_MS", 600_000);
19
21
  function usage() {
20
22
  console.error(`Usage:
21
23
  ollanet prompt <machine> [model] <prompt...>
@@ -23,9 +25,9 @@ function usage() {
23
25
  ollanet prompt <machine> --chat <hash> <prompt...>
24
26
 
25
27
  Examples:
26
- ollanet prompt mycroftone "Explain MagicDNS"
28
+ ollanet prompt localhost "Explain MagicDNS"
27
29
  ollanet prompt --chat a1b2c3d4e5f6 "Give an example"
28
- ollanet prompt mycroftone --temperature 0.2 --num-predict 64 "2+2?"
30
+ ollanet prompt localhost --temperature 0.2 --num-predict 64 "2+2?"
29
31
  ollanet chats
30
32
 
31
33
  Each reply is saved under responses/<hash>.json and the hash is printed so you
@@ -45,12 +47,72 @@ Options:
45
47
  --num-ctx <n> Context window size
46
48
  --keep-alive <value> Keep model loaded (e.g. 5m, 0, -1)
47
49
  --format <json|schema> Force JSON mode or a JSON schema string
50
+ --think Enable model thinking (qwen3 etc.); streams to stderr
51
+ --no-think Disable thinking (default) so tokens go to the reply
48
52
  --no-stream Wait for the full response
49
53
  --json Emit final response JSON (implies --no-stream)`);
50
54
  process.exit(1);
51
55
  }
56
+ /**
57
+ * Heuristic for an optional positional model token.
58
+ * Rejects whitespace (so quoted prompts with ":" stay prompts) and URL schemes.
59
+ * Confirmed against the host's /api/tags before peeling.
60
+ */
52
61
  function looksLikeModel(value) {
53
- return value.includes(":") || value.includes("/");
62
+ const v = value.trim();
63
+ if (!v || /\s/.test(v))
64
+ return false;
65
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(v))
66
+ return false;
67
+ return /^[a-z0-9][a-z0-9._-]*([/:@][a-z0-9._-]+)+$/i.test(v);
68
+ }
69
+ function modelNameMatches(available, candidate) {
70
+ const c = candidate.toLowerCase();
71
+ return available.some((name) => {
72
+ const n = name.toLowerCase();
73
+ return n === c || n.startsWith(`${c}:`) || c.startsWith(`${n}:`);
74
+ });
75
+ }
76
+ async function listModelNames(host) {
77
+ const url = `${ollamaBaseUrl(host)}/api/tags`;
78
+ const controller = new AbortController();
79
+ const timer = setTimeout(() => controller.abort(), Math.min(PROMPT_TIMEOUT_MS || 2500, 5000));
80
+ try {
81
+ const res = await fetch(url, {
82
+ signal: controller.signal,
83
+ headers: { Accept: "application/json" },
84
+ });
85
+ if (!res.ok)
86
+ return null;
87
+ const body = (await res.json());
88
+ return (body.models ?? []).map((m) => m.name).filter((n) => Boolean(n));
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ finally {
94
+ clearTimeout(timer);
95
+ }
96
+ }
97
+ /** Peel a leading model token only when it looks like one and exists on the host. */
98
+ async function maybePeelModel(host, promptParts, fromStdin) {
99
+ if (promptParts.length === 0)
100
+ return { promptParts };
101
+ const candidate = promptParts[0];
102
+ if (!looksLikeModel(candidate))
103
+ return { promptParts };
104
+ const names = await listModelNames(host);
105
+ if (names) {
106
+ if (modelNameMatches(names, candidate)) {
107
+ return { model: candidate, promptParts: promptParts.slice(1) };
108
+ }
109
+ return { promptParts };
110
+ }
111
+ // Tags unavailable: only peel when something remains for the prompt body.
112
+ if (promptParts.length > 1 || fromStdin) {
113
+ return { model: candidate, promptParts: promptParts.slice(1) };
114
+ }
115
+ return { promptParts };
54
116
  }
55
117
  function takeValue(args, flag) {
56
118
  const value = args.shift();
@@ -97,6 +159,14 @@ function parseArgs(argv) {
97
159
  save = false;
98
160
  continue;
99
161
  }
162
+ if (arg === "--think") {
163
+ settings.think = true;
164
+ continue;
165
+ }
166
+ if (arg === "--no-think") {
167
+ settings.think = false;
168
+ continue;
169
+ }
100
170
  if (arg === "--chat" || arg.startsWith("--chat=")) {
101
171
  chatId = arg.includes("=") ? arg.slice("--chat=".length) : takeValue(args, "--chat");
102
172
  continue;
@@ -150,20 +220,16 @@ function parseArgs(argv) {
150
220
  positional.push(arg);
151
221
  }
152
222
  let machine = machineFlag;
153
- let model = modelFlag;
223
+ const model = modelFlag;
154
224
  let promptParts = positional;
155
225
  if (!chatId) {
156
- // New chat: <machine> [model] <prompt...>
226
+ // New chat: <machine> [model] <prompt...> — model is peeled later against /api/tags.
157
227
  if (!machine) {
158
228
  if (positional.length < 1)
159
229
  usage();
160
230
  machine = positional[0];
161
231
  promptParts = positional.slice(1);
162
232
  }
163
- if (!model && promptParts.length > 0 && looksLikeModel(promptParts[0])) {
164
- model = promptParts[0];
165
- promptParts = promptParts.slice(1);
166
- }
167
233
  }
168
234
  // Continue (--chat): all positionals are the prompt. Optional machine override
169
235
  // via --machine, or a leading discovered-host name peeled in main().
@@ -172,6 +238,31 @@ function parseArgs(argv) {
172
238
  async function readStdinIfPiped() {
173
239
  if (stdinStream.isTTY)
174
240
  return "";
241
+ // Under npm/pnpm/CI, stdin is often a pipe that never gets EOF. Only drain it
242
+ // when data arrives promptly; otherwise treat as "no stdin prompt".
243
+ const hasData = await new Promise((resolve) => {
244
+ if (stdinStream.readableLength > 0 || stdinStream.readableEnded) {
245
+ resolve(true);
246
+ return;
247
+ }
248
+ let settled = false;
249
+ const done = (value) => {
250
+ if (settled)
251
+ return;
252
+ settled = true;
253
+ clearTimeout(timer);
254
+ stdinStream.off("readable", onReadable);
255
+ stdinStream.off("end", onEnd);
256
+ resolve(value);
257
+ };
258
+ const onReadable = () => done(true);
259
+ const onEnd = () => done(true);
260
+ stdinStream.once("readable", onReadable);
261
+ stdinStream.once("end", onEnd);
262
+ const timer = setTimeout(() => done(stdinStream.readableLength > 0), 25);
263
+ });
264
+ if (!hasData)
265
+ return "";
175
266
  const chunks = [];
176
267
  for await (const chunk of stdinStream) {
177
268
  chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
@@ -190,10 +281,13 @@ function buildOptions(settings) {
190
281
  }
191
282
  async function runChat(opts) {
192
283
  const url = `${opts.baseUrl}/api/chat`;
284
+ // Default think:false — thinking models otherwise burn num_predict on hidden reasoning.
285
+ const think = opts.settings.think === true;
193
286
  const body = {
194
287
  model: opts.model,
195
288
  messages: opts.messages,
196
289
  stream: opts.stream,
290
+ think,
197
291
  };
198
292
  if (opts.settings.keep_alive != null)
199
293
  body.keep_alive = opts.settings.keep_alive;
@@ -202,71 +296,133 @@ async function runChat(opts) {
202
296
  const options = buildOptions(opts.settings);
203
297
  if (Object.keys(options).length > 0)
204
298
  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
- });
299
+ const controller = new AbortController();
300
+ const timer = PROMPT_TIMEOUT_MS > 0 ? setTimeout(() => controller.abort(), PROMPT_TIMEOUT_MS) : undefined;
301
+ let res;
302
+ try {
303
+ res = await fetch(url, {
304
+ method: "POST",
305
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
306
+ body: JSON.stringify(body),
307
+ signal: controller.signal,
308
+ });
309
+ }
310
+ catch (err) {
311
+ if (timer)
312
+ clearTimeout(timer);
313
+ const name = err instanceof Error ? err.name : "";
314
+ if (name === "AbortError") {
315
+ throw new Error(`Prompt timed out after ${PROMPT_TIMEOUT_MS}ms (${url})`);
316
+ }
317
+ throw err;
318
+ }
210
319
  if (!res.ok) {
320
+ if (timer)
321
+ clearTimeout(timer);
211
322
  const text = await res.text().catch(() => "");
212
323
  throw new Error(`Ollama HTTP ${res.status} ${res.statusText}${text ? `: ${text}` : ""}`);
213
324
  }
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);
325
+ const parseChunk = (raw) => {
326
+ try {
327
+ return JSON.parse(raw);
328
+ }
329
+ catch {
330
+ throw new Error(`Non-JSON response from ${url} (is something else on this port?): ${raw.slice(0, 120)}`);
331
+ }
332
+ };
333
+ try {
334
+ if (!opts.stream) {
335
+ const chunk = parseChunk(await res.text());
336
+ if (chunk.error)
337
+ throw new Error(chunk.error);
338
+ const content = chunk.message?.content ?? "";
339
+ const thinking = chunk.message?.thinking ?? "";
340
+ if (opts.writeStdout) {
341
+ if (think && thinking) {
342
+ process.stderr.write(`${thinking}\n---\n`);
343
+ }
344
+ const visible = content || thinking;
345
+ if (visible)
346
+ process.stdout.write(`${visible}\n`);
347
+ }
348
+ return { content: content || thinking, thinking, chunk };
349
+ }
350
+ if (!res.body)
351
+ throw new Error("No response body from Ollama");
352
+ const reader = res.body.getReader();
353
+ const decoder = new TextDecoder();
354
+ let buffer = "";
355
+ let last = {};
356
+ let content = "";
357
+ let thinking = "";
358
+ let thinkingHeader = false;
359
+ const handleChunk = (chunk) => {
242
360
  if (chunk.error)
243
361
  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);
362
+ const thinkPiece = chunk.message?.thinking ?? "";
363
+ const contentPiece = chunk.message?.content ?? "";
364
+ if (thinkPiece) {
365
+ thinking += thinkPiece;
366
+ if (opts.writeStdout && think) {
367
+ if (!thinkingHeader) {
368
+ process.stderr.write("[thinking]\n");
369
+ thinkingHeader = true;
370
+ }
371
+ process.stderr.write(thinkPiece);
372
+ }
373
+ }
374
+ if (contentPiece) {
375
+ content += contentPiece;
376
+ if (opts.writeStdout) {
377
+ if (thinkingHeader) {
378
+ process.stderr.write("\n---\n");
379
+ thinkingHeader = false;
380
+ }
381
+ process.stdout.write(contentPiece);
382
+ }
249
383
  }
250
384
  last = chunk;
385
+ };
386
+ while (true) {
387
+ const { done, value } = await reader.read();
388
+ if (done)
389
+ break;
390
+ buffer += decoder.decode(value, { stream: true });
391
+ const lines = buffer.split("\n");
392
+ buffer = lines.pop() ?? "";
393
+ for (const line of lines) {
394
+ const trimmed = line.trim();
395
+ if (!trimmed)
396
+ continue;
397
+ handleChunk(parseChunk(trimmed));
398
+ }
399
+ }
400
+ const trailing = buffer.trim();
401
+ if (trailing) {
402
+ handleChunk(parseChunk(trailing));
403
+ }
404
+ // If the model only emitted thinking (budget exhausted), surface it on stdout.
405
+ if (opts.writeStdout && !content && thinking) {
406
+ if (think)
407
+ process.stderr.write("\n---\n");
408
+ process.stdout.write(`${thinking}\n`);
409
+ }
410
+ else if (opts.writeStdout && content.length > 0 && !content.endsWith("\n")) {
411
+ process.stdout.write("\n");
251
412
  }
413
+ return { content: content || thinking, thinking, chunk: last };
252
414
  }
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);
415
+ catch (err) {
416
+ const name = err instanceof Error ? err.name : "";
417
+ if (name === "AbortError") {
418
+ throw new Error(`Prompt timed out after ${PROMPT_TIMEOUT_MS}ms (${url})`);
263
419
  }
264
- last = chunk;
420
+ throw err;
265
421
  }
266
- if (opts.writeStdout && content.length > 0 && !content.endsWith("\n")) {
267
- process.stdout.write("\n");
422
+ finally {
423
+ if (timer)
424
+ clearTimeout(timer);
268
425
  }
269
- return { content, chunk: last };
270
426
  }
271
427
  async function generateTopic(opts) {
272
428
  const fallback = topicFromPrompt(opts.prompt);
@@ -279,8 +435,8 @@ async function generateTopic(opts) {
279
435
  settings: mergeSettings(opts.settings, {
280
436
  temperature: 0.2,
281
437
  num_predict: 24,
282
- // keep_alive 0 so topic pass doesn't pin the model longer than needed
283
- keep_alive: 0,
438
+ think: false,
439
+ // Inherit caller's keep_alive — do not force-unload the model after topic gen.
284
440
  }),
285
441
  messages: [
286
442
  {
@@ -307,14 +463,9 @@ function formatDuration(ns) {
307
463
  }
308
464
  export async function main() {
309
465
  const parsed = parseArgs(process.argv.slice(2));
310
- const { promptParts, stream, json, save } = parsed;
311
- const fromArgs = promptParts.join(" ").trim();
466
+ const { stream, json, save } = parsed;
467
+ let promptParts = parsed.promptParts;
312
468
  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
469
  const config = await loadConfig();
319
470
  const { hosts: targets } = await discoverHosts({
320
471
  hosts: config.hosts,
@@ -323,19 +474,15 @@ export async function main() {
323
474
  let chat;
324
475
  let isNewChat = false;
325
476
  let machineQuery = parsed.machine;
326
- let promptText = prompt;
327
477
  if (parsed.chatId) {
328
478
  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.
479
+ // Allow `ollanet prompt <host> --chat HASH "follow-up"` only when the
480
+ // leading token matches an already-discovered host (not direct-address fallback).
331
481
  if (!machineQuery && promptParts.length >= 2) {
332
- try {
333
- resolveHost(targets, promptParts[0]);
482
+ const hit = findDiscoveredHost(targets, promptParts[0]);
483
+ if (hit) {
334
484
  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
485
+ promptParts = promptParts.slice(1);
339
486
  }
340
487
  }
341
488
  }
@@ -344,22 +491,32 @@ export async function main() {
344
491
  console.error("Machine is required for a new chat (or use --chat <hash>).");
345
492
  usage();
346
493
  }
347
- if (!promptText) {
348
- console.error("No prompt provided (pass args and/or pipe stdin).");
349
- usage();
350
- }
351
494
  const host = resolveHost(targets, machineQuery);
352
495
  if (!host.online && !host.isSelf && host.source === "tailscale") {
353
496
  throw new Error(`Machine "${shortName(host)}" appears offline.`);
354
497
  }
498
+ let peeledModel;
499
+ if (!parsed.chatId && !parsed.model) {
500
+ const peeled = await maybePeelModel(host, promptParts, fromStdin);
501
+ peeledModel = peeled.model;
502
+ promptParts = peeled.promptParts;
503
+ }
504
+ const promptText = [promptParts.join(" ").trim(), fromStdin].filter(Boolean).join("\n\n").trim();
505
+ if (!promptText) {
506
+ console.error("No prompt provided (pass args and/or pipe stdin).");
507
+ usage();
508
+ }
355
509
  const model = parsed.model ??
510
+ peeledModel ??
356
511
  chat?.model ??
357
512
  defaultModelForHost(config, host);
358
513
  if (!model) {
359
514
  throw new Error(`No model specified for "${shortName(host)}" and no default in ${configPath()}.\n` +
360
515
  `Pass a model, use --model <name>, or add it under defaultModels.`);
361
516
  }
362
- const settings = mergeSettings(config.defaults, machineSettingsForHost(config, host), settingsFromEnv(), chat?.system ? { system: chat.system } : undefined, parsed.settings);
517
+ const settings = mergeSettings(
518
+ // Thinking models otherwise spend the whole num_predict budget on hidden reasoning.
519
+ { think: false }, config.defaults, machineSettingsForHost(config, host), settingsFromEnv(), chat?.system ? { system: chat.system } : undefined, parsed.settings);
363
520
  if (!chat && save) {
364
521
  chat = createChat({
365
522
  machine: shortName(host),
@@ -393,7 +550,7 @@ export async function main() {
393
550
  const chatTag = parsed.chatId ? ` chat=${chat.id}` : "";
394
551
  console.error(`→ ${shortName(host)} (${host.ip}) model=${model} [${src}]${chatTag}${extras ? ` ${extras}` : ""}`);
395
552
  }
396
- const { content, chunk } = await runChat({
553
+ const { content, thinking, chunk } = await runChat({
397
554
  baseUrl,
398
555
  model,
399
556
  messages: toApiMessages(chat),
@@ -404,6 +561,7 @@ export async function main() {
404
561
  appendMessage(chat, {
405
562
  role: "assistant",
406
563
  content,
564
+ ...(thinking ? { thinking } : {}),
407
565
  machine: shortName(host),
408
566
  model,
409
567
  stats: {
@@ -434,6 +592,7 @@ export async function main() {
434
592
  chat_id: save ? chat.id : null,
435
593
  topic: chat.topic,
436
594
  content,
595
+ thinking: thinking || null,
437
596
  ollama: chunk,
438
597
  }, null, 2));
439
598
  return;
package/dist/scan.js CHANGED
@@ -5,9 +5,9 @@
5
5
  * Usage: ollanet scan [--lan] [--json] [--all]
6
6
  */
7
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);
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));
11
11
  function formatBytes(bytes) {
12
12
  if (bytes == null || !Number.isFinite(bytes))
13
13
  return "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ollanet",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Chat with Ollama servers on any reachable network — scan hosts, prompt models, continue chats by hash",
5
5
  "type": "module",
6
6
  "bin": {