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 +6 -2
- package/dist/config.js +17 -0
- package/dist/hosts.js +53 -15
- package/dist/prompt.js +246 -87
- package/dist/scan.js +3 -3
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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 =
|
|
246
|
-
const concurrency =
|
|
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}
|
|
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 (
|
|
271
|
-
return true;
|
|
272
|
-
if (/^[a-z0-9][a-z0-9
|
|
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
|
|
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
|
|
28
|
+
ollanet prompt localhost "Explain MagicDNS"
|
|
27
29
|
ollanet prompt --chat a1b2c3d4e5f6 "Give an example"
|
|
28
|
-
ollanet prompt
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
420
|
+
throw err;
|
|
265
421
|
}
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
283
|
-
keep_alive
|
|
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 {
|
|
311
|
-
|
|
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
|
|
330
|
-
// leading
|
|
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
|
-
|
|
333
|
-
|
|
482
|
+
const hit = findDiscoveredHost(targets, promptParts[0]);
|
|
483
|
+
if (hit) {
|
|
334
484
|
machineQuery = promptParts[0];
|
|
335
|
-
|
|
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(
|
|
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 =
|
|
10
|
-
const CONCURRENCY =
|
|
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 "";
|