ollanet 0.1.0 → 0.2.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
@@ -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,10 @@
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
+ import { ollamaChat } from "./ollama-chat.js";
20
+ /** 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);
19
22
  function usage() {
20
23
  console.error(`Usage:
21
24
  ollanet prompt <machine> [model] <prompt...>
@@ -23,9 +26,9 @@ function usage() {
23
26
  ollanet prompt <machine> --chat <hash> <prompt...>
24
27
 
25
28
  Examples:
26
- ollanet prompt mycroftone "Explain MagicDNS"
29
+ ollanet prompt localhost "Explain MagicDNS"
27
30
  ollanet prompt --chat a1b2c3d4e5f6 "Give an example"
28
- ollanet prompt mycroftone --temperature 0.2 --num-predict 64 "2+2?"
31
+ ollanet prompt localhost --temperature 0.2 --num-predict 64 "2+2?"
29
32
  ollanet chats
30
33
 
31
34
  Each reply is saved under responses/<hash>.json and the hash is printed so you
@@ -45,12 +48,72 @@ Options:
45
48
  --num-ctx <n> Context window size
46
49
  --keep-alive <value> Keep model loaded (e.g. 5m, 0, -1)
47
50
  --format <json|schema> Force JSON mode or a JSON schema string
51
+ --think Enable model thinking (qwen3 etc.); streams to stderr
52
+ --no-think Disable thinking (default) so tokens go to the reply
48
53
  --no-stream Wait for the full response
49
54
  --json Emit final response JSON (implies --no-stream)`);
50
55
  process.exit(1);
51
56
  }
57
+ /**
58
+ * Heuristic for an optional positional model token.
59
+ * Rejects whitespace (so quoted prompts with ":" stay prompts) and URL schemes.
60
+ * Confirmed against the host's /api/tags before peeling.
61
+ */
52
62
  function looksLikeModel(value) {
53
- return value.includes(":") || value.includes("/");
63
+ const v = value.trim();
64
+ if (!v || /\s/.test(v))
65
+ return false;
66
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(v))
67
+ return false;
68
+ return /^[a-z0-9][a-z0-9._-]*([/:@][a-z0-9._-]+)+$/i.test(v);
69
+ }
70
+ function modelNameMatches(available, candidate) {
71
+ const c = candidate.toLowerCase();
72
+ return available.some((name) => {
73
+ const n = name.toLowerCase();
74
+ return n === c || n.startsWith(`${c}:`) || c.startsWith(`${n}:`);
75
+ });
76
+ }
77
+ async function listModelNames(host) {
78
+ const url = `${ollamaBaseUrl(host)}/api/tags`;
79
+ const controller = new AbortController();
80
+ const timer = setTimeout(() => controller.abort(), Math.min(PROMPT_TIMEOUT_MS || 2500, 5000));
81
+ try {
82
+ const res = await fetch(url, {
83
+ signal: controller.signal,
84
+ headers: { Accept: "application/json" },
85
+ });
86
+ if (!res.ok)
87
+ return null;
88
+ const body = (await res.json());
89
+ return (body.models ?? []).map((m) => m.name).filter((n) => Boolean(n));
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ finally {
95
+ clearTimeout(timer);
96
+ }
97
+ }
98
+ /** Peel a leading model token only when it looks like one and exists on the host. */
99
+ async function maybePeelModel(host, promptParts, fromStdin) {
100
+ if (promptParts.length === 0)
101
+ return { promptParts };
102
+ const candidate = promptParts[0];
103
+ if (!looksLikeModel(candidate))
104
+ return { promptParts };
105
+ const names = await listModelNames(host);
106
+ if (names) {
107
+ if (modelNameMatches(names, candidate)) {
108
+ return { model: candidate, promptParts: promptParts.slice(1) };
109
+ }
110
+ return { promptParts };
111
+ }
112
+ // Tags unavailable: only peel when something remains for the prompt body.
113
+ if (promptParts.length > 1 || fromStdin) {
114
+ return { model: candidate, promptParts: promptParts.slice(1) };
115
+ }
116
+ return { promptParts };
54
117
  }
55
118
  function takeValue(args, flag) {
56
119
  const value = args.shift();
@@ -97,6 +160,14 @@ function parseArgs(argv) {
97
160
  save = false;
98
161
  continue;
99
162
  }
163
+ if (arg === "--think") {
164
+ settings.think = true;
165
+ continue;
166
+ }
167
+ if (arg === "--no-think") {
168
+ settings.think = false;
169
+ continue;
170
+ }
100
171
  if (arg === "--chat" || arg.startsWith("--chat=")) {
101
172
  chatId = arg.includes("=") ? arg.slice("--chat=".length) : takeValue(args, "--chat");
102
173
  continue;
@@ -150,20 +221,16 @@ function parseArgs(argv) {
150
221
  positional.push(arg);
151
222
  }
152
223
  let machine = machineFlag;
153
- let model = modelFlag;
224
+ const model = modelFlag;
154
225
  let promptParts = positional;
155
226
  if (!chatId) {
156
- // New chat: <machine> [model] <prompt...>
227
+ // New chat: <machine> [model] <prompt...> — model is peeled later against /api/tags.
157
228
  if (!machine) {
158
229
  if (positional.length < 1)
159
230
  usage();
160
231
  machine = positional[0];
161
232
  promptParts = positional.slice(1);
162
233
  }
163
- if (!model && promptParts.length > 0 && looksLikeModel(promptParts[0])) {
164
- model = promptParts[0];
165
- promptParts = promptParts.slice(1);
166
- }
167
234
  }
168
235
  // Continue (--chat): all positionals are the prompt. Optional machine override
169
236
  // via --machine, or a leading discovered-host name peeled in main().
@@ -172,115 +239,51 @@ function parseArgs(argv) {
172
239
  async function readStdinIfPiped() {
173
240
  if (stdinStream.isTTY)
174
241
  return "";
242
+ // Under npm/pnpm/CI, stdin is often a pipe that never gets EOF. Only drain it
243
+ // when data arrives promptly; otherwise treat as "no stdin prompt".
244
+ const hasData = await new Promise((resolve) => {
245
+ if (stdinStream.readableLength > 0 || stdinStream.readableEnded) {
246
+ resolve(true);
247
+ return;
248
+ }
249
+ let settled = false;
250
+ const done = (value) => {
251
+ if (settled)
252
+ return;
253
+ settled = true;
254
+ clearTimeout(timer);
255
+ stdinStream.off("readable", onReadable);
256
+ stdinStream.off("end", onEnd);
257
+ resolve(value);
258
+ };
259
+ const onReadable = () => done(true);
260
+ const onEnd = () => done(true);
261
+ stdinStream.once("readable", onReadable);
262
+ stdinStream.once("end", onEnd);
263
+ const timer = setTimeout(() => done(stdinStream.readableLength > 0), 25);
264
+ });
265
+ if (!hasData)
266
+ return "";
175
267
  const chunks = [];
176
268
  for await (const chunk of stdinStream) {
177
269
  chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
178
270
  }
179
271
  return Buffer.concat(chunks).toString("utf8").trim();
180
272
  }
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
273
  async function generateTopic(opts) {
272
274
  const fallback = topicFromPrompt(opts.prompt);
273
275
  try {
274
- const { content } = await runChat({
276
+ const { content } = await ollamaChat({
275
277
  baseUrl: opts.baseUrl,
276
278
  model: opts.model,
277
279
  stream: false,
278
280
  writeStdout: false,
281
+ timeoutMs: PROMPT_TIMEOUT_MS,
279
282
  settings: mergeSettings(opts.settings, {
280
283
  temperature: 0.2,
281
284
  num_predict: 24,
282
- // keep_alive 0 so topic pass doesn't pin the model longer than needed
283
- keep_alive: 0,
285
+ think: false,
286
+ // Inherit caller's keep_alive — do not force-unload the model after topic gen.
284
287
  }),
285
288
  messages: [
286
289
  {
@@ -307,14 +310,9 @@ function formatDuration(ns) {
307
310
  }
308
311
  export async function main() {
309
312
  const parsed = parseArgs(process.argv.slice(2));
310
- const { promptParts, stream, json, save } = parsed;
311
- const fromArgs = promptParts.join(" ").trim();
313
+ const { stream, json, save } = parsed;
314
+ let promptParts = parsed.promptParts;
312
315
  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
316
  const config = await loadConfig();
319
317
  const { hosts: targets } = await discoverHosts({
320
318
  hosts: config.hosts,
@@ -323,19 +321,15 @@ export async function main() {
323
321
  let chat;
324
322
  let isNewChat = false;
325
323
  let machineQuery = parsed.machine;
326
- let promptText = prompt;
327
324
  if (parsed.chatId) {
328
325
  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.
326
+ // Allow `ollanet prompt <host> --chat HASH "follow-up"` only when the
327
+ // leading token matches an already-discovered host (not direct-address fallback).
331
328
  if (!machineQuery && promptParts.length >= 2) {
332
- try {
333
- resolveHost(targets, promptParts[0]);
329
+ const hit = findDiscoveredHost(targets, promptParts[0]);
330
+ if (hit) {
334
331
  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
332
+ promptParts = promptParts.slice(1);
339
333
  }
340
334
  }
341
335
  }
@@ -344,22 +338,32 @@ export async function main() {
344
338
  console.error("Machine is required for a new chat (or use --chat <hash>).");
345
339
  usage();
346
340
  }
347
- if (!promptText) {
348
- console.error("No prompt provided (pass args and/or pipe stdin).");
349
- usage();
350
- }
351
341
  const host = resolveHost(targets, machineQuery);
352
342
  if (!host.online && !host.isSelf && host.source === "tailscale") {
353
343
  throw new Error(`Machine "${shortName(host)}" appears offline.`);
354
344
  }
345
+ let peeledModel;
346
+ if (!parsed.chatId && !parsed.model) {
347
+ const peeled = await maybePeelModel(host, promptParts, fromStdin);
348
+ peeledModel = peeled.model;
349
+ promptParts = peeled.promptParts;
350
+ }
351
+ const promptText = [promptParts.join(" ").trim(), fromStdin].filter(Boolean).join("\n\n").trim();
352
+ if (!promptText) {
353
+ console.error("No prompt provided (pass args and/or pipe stdin).");
354
+ usage();
355
+ }
355
356
  const model = parsed.model ??
357
+ peeledModel ??
356
358
  chat?.model ??
357
359
  defaultModelForHost(config, host);
358
360
  if (!model) {
359
361
  throw new Error(`No model specified for "${shortName(host)}" and no default in ${configPath()}.\n` +
360
362
  `Pass a model, use --model <name>, or add it under defaultModels.`);
361
363
  }
362
- const settings = mergeSettings(config.defaults, machineSettingsForHost(config, host), settingsFromEnv(), chat?.system ? { system: chat.system } : undefined, parsed.settings);
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);
363
367
  if (!chat && save) {
364
368
  chat = createChat({
365
369
  machine: shortName(host),
@@ -393,17 +397,19 @@ export async function main() {
393
397
  const chatTag = parsed.chatId ? ` chat=${chat.id}` : "";
394
398
  console.error(`→ ${shortName(host)} (${host.ip}) model=${model} [${src}]${chatTag}${extras ? ` ${extras}` : ""}`);
395
399
  }
396
- const { content, chunk } = await runChat({
400
+ const { content, thinking, chunk } = await ollamaChat({
397
401
  baseUrl,
398
402
  model,
399
403
  messages: toApiMessages(chat),
400
404
  stream: json ? false : stream,
401
405
  writeStdout: !json,
406
+ timeoutMs: PROMPT_TIMEOUT_MS,
402
407
  settings,
403
408
  });
404
409
  appendMessage(chat, {
405
410
  role: "assistant",
406
411
  content,
412
+ ...(thinking ? { thinking } : {}),
407
413
  machine: shortName(host),
408
414
  model,
409
415
  stats: {
@@ -434,6 +440,7 @@ export async function main() {
434
440
  chat_id: save ? chat.id : null,
435
441
  topic: chat.topic,
436
442
  content,
443
+ thinking: thinking || null,
437
444
  ollama: chunk,
438
445
  }, null, 2));
439
446
  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.2.0",
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": {
@@ -18,8 +18,11 @@
18
18
  "scan": "tsx src/cli.ts scan",
19
19
  "prompt": "tsx src/cli.ts prompt",
20
20
  "chats": "tsx src/cli.ts chats",
21
+ "bench": "tsx src/cli.ts bench",
21
22
  "start": "tsx src/cli.ts scan",
22
- "typecheck": "tsc --noEmit"
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "tsc -p tsconfig.build.json && node --test test/*.test.mjs",
25
+ "test:only": "node --test test/*.test.mjs"
23
26
  },
24
27
  "keywords": [
25
28
  "ollama",