ollanet 0.2.2 → 0.3.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/README.md +57 -0
- package/dist/cli.js +7 -0
- package/dist/mcp.js +285 -0
- package/dist/prompt.js +114 -59
- package/dist/scan.js +44 -26
- package/package.json +11 -5
package/README.md
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/Catalyst-Forge-LLC/ollanet/main/assets/ollanet-logo.png" alt="ollanet logo" width="360">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
1
5
|
# ollanet
|
|
2
6
|
|
|
3
7
|
Chat with **Ollama** servers on **any network you can reach** — LAN, localhost, Tailscale, VPN, or a raw IP.
|
|
4
8
|
|
|
5
9
|
Scan for models, fire prompts, and continue conversations later by a short hash. No browser UI required.
|
|
6
10
|
|
|
11
|
+
**Site:** [ollanet.dev](https://ollanet.dev) — Downpress + Cloudflare Pages (`site/`).
|
|
12
|
+
|
|
7
13
|
## Origin
|
|
8
14
|
|
|
9
15
|
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.
|
|
@@ -26,6 +32,26 @@ They share the same Ollama API; Finetuna shapes the models, ollanet finds and ch
|
|
|
26
32
|
- **Persist** chats as `responses/<hash>.json` with topic, machine, model, timestamps
|
|
27
33
|
- **Continue** any thread with `--chat <hash>`
|
|
28
34
|
- **Configure** per-machine defaults (model, temperature, context, …)
|
|
35
|
+
- **MCP** — `ollanet mcp` exposes scan/prompt/chats as Model Context Protocol tools for agents
|
|
36
|
+
|
|
37
|
+
## Use cases
|
|
38
|
+
|
|
39
|
+
The core idea: turn “I have to remember which IP has which models loaded” into a discoverable, stateful, network-native primitive. That unlocks more than interactive chat:
|
|
40
|
+
|
|
41
|
+
**For agents and automation** — prefer `ollanet mcp` (stdio MCP). The same flows also work via CLI flags:
|
|
42
|
+
|
|
43
|
+
- **Live model inventory / routing** — `ollanet_scan` (or `scan --json`) lists reachable hosts and models. Route each sub-task to the best host: the big-VRAM box for reasoning, the laptop for quick lookups.
|
|
44
|
+
- **Conversations that survive machines** — chats are stored by short hash with full history. Start a long thread on the GPU box, continue it later with `ollanet_prompt` + `chat_id` (or `--chat <hash>`).
|
|
45
|
+
- **A “talk to any Ollama on my network” tool** — `ollanet_prompt` / `prompt … --json --no-stream`; no hard-coded endpoints.
|
|
46
|
+
- **Fleet health and speed checks** — periodic `ollanet bench <host> --json` builds a record of which model on which machine is currently fastest (or has silently gone offline). Saved results live in `benchmarks/`.
|
|
47
|
+
- **The Finetuna loop** — [Finetuna](https://github.com/Catalyst-Forge-LLC/finetuna) shapes a GPU-tuned named variant on the host; ollanet discovers and uses it from anywhere on the network.
|
|
48
|
+
|
|
49
|
+
**For humans:**
|
|
50
|
+
|
|
51
|
+
- **Tailscale-native access** — prompt any machine by MagicDNS name from a laptop or phone (Termux) without babysitting IPs or opening a browser.
|
|
52
|
+
- **A private model mesh** — everyone on a shared tailnet or LAN can discover and use the same Ollama hosts, no public exposure, no central UI.
|
|
53
|
+
- **Multi-GPU lab** — `scan --lan` treats several machines as a pool of inference endpoints; `bench` compares the same model across hardware.
|
|
54
|
+
- **Audit trail / notes** — persisted chat JSON doubles as a research log you can resume from any device.
|
|
29
55
|
|
|
30
56
|
## Requirements
|
|
31
57
|
|
|
@@ -105,6 +131,7 @@ ollanet chats --id a1b2c3d4e5f6
|
|
|
105
131
|
| `ollanet prompt …` | Send a prompt / continue a chat |
|
|
106
132
|
| `ollanet chats` | List or inspect saved transcripts |
|
|
107
133
|
| `ollanet bench …` | Benchmark models for tok/s + lightweight quality checks |
|
|
134
|
+
| `ollanet mcp` | Stdio [MCP](https://modelcontextprotocol.io) server for agents |
|
|
108
135
|
|
|
109
136
|
### `scan` options
|
|
110
137
|
|
|
@@ -142,6 +169,36 @@ Machine can be a discovered name, hostname, FQDN, or IP (`192.168.1.50`, `host:1
|
|
|
142
169
|
- `--json` — list summary JSON
|
|
143
170
|
- `--id <hash>` — show one chat (full JSON with `--json`)
|
|
144
171
|
|
|
172
|
+
### `mcp`
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
ollanet mcp
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Runs a **stdio MCP server** (no extra npm deps). Cursor / Claude Desktop / any MCP host can spawn it and call:
|
|
179
|
+
|
|
180
|
+
| Tool | What it does |
|
|
181
|
+
|---|---|
|
|
182
|
+
| `ollanet_scan` | Discover hosts + models (`lan`, `all` optional) |
|
|
183
|
+
| `ollanet_prompt` | Prompt a host or continue with `chat_id` |
|
|
184
|
+
| `ollanet_list_chats` | Summarize saved transcripts |
|
|
185
|
+
| `ollanet_get_chat` | Load one chat (full history) |
|
|
186
|
+
|
|
187
|
+
Example Cursor MCP config (`~/.cursor/mcp.json` or project `.cursor/mcp.json`):
|
|
188
|
+
|
|
189
|
+
```json
|
|
190
|
+
{
|
|
191
|
+
"mcpServers": {
|
|
192
|
+
"ollanet": {
|
|
193
|
+
"command": "npx",
|
|
194
|
+
"args": ["-y", "ollanet", "mcp"]
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Or, from a checkout: `"command": "pnpm"`, `"args": ["ollanet", "--", "mcp"]` (with `cwd` set to the repo). Uses the same config/chats dirs as the CLI (`~/.ollanet/` when installed).
|
|
201
|
+
|
|
145
202
|
### `bench`
|
|
146
203
|
|
|
147
204
|
```bash
|
package/dist/cli.js
CHANGED
|
@@ -17,6 +17,7 @@ Usage:
|
|
|
17
17
|
ollanet prompt --chat <hash> <prompt...>
|
|
18
18
|
ollanet chats [--json] [--id <hash>]
|
|
19
19
|
ollanet bench <machine|ip> [model...] [--all] [options]
|
|
20
|
+
ollanet mcp
|
|
20
21
|
|
|
21
22
|
Examples:
|
|
22
23
|
ollanet scan
|
|
@@ -27,6 +28,7 @@ Examples:
|
|
|
27
28
|
ollanet chats
|
|
28
29
|
ollanet bench localhost --all
|
|
29
30
|
ollanet bench localhost llama3.2:1b --runs 3
|
|
31
|
+
ollanet mcp # stdio MCP server for agents
|
|
30
32
|
|
|
31
33
|
Config: ~/.ollanet/config.json when installed, ./config.json in a checkout
|
|
32
34
|
(override with OLLANET_CONFIG)
|
|
@@ -77,6 +79,11 @@ async function main() {
|
|
|
77
79
|
await run();
|
|
78
80
|
return;
|
|
79
81
|
}
|
|
82
|
+
case "mcp": {
|
|
83
|
+
const { main: run } = await import("./mcp.js");
|
|
84
|
+
await run();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
80
87
|
default:
|
|
81
88
|
console.error(`Unknown command: ${cmd}\n`);
|
|
82
89
|
process.stderr.write(HELP);
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ollanet mcp — stdio MCP server (zero deps).
|
|
3
|
+
*
|
|
4
|
+
* Speaks the Model Context Protocol over stdin/stdout so agents (Cursor,
|
|
5
|
+
* Claude Desktop, etc.) can discover hosts, prompt models, and continue chats.
|
|
6
|
+
*
|
|
7
|
+
* ollanet mcp
|
|
8
|
+
*
|
|
9
|
+
* stdout is reserved for JSON-RPC; log only to stderr.
|
|
10
|
+
*/
|
|
11
|
+
import { createInterface } from "node:readline";
|
|
12
|
+
import { readFile } from "node:fs/promises";
|
|
13
|
+
import { listChats, loadChat } from "./chat-store.js";
|
|
14
|
+
import { projectPath } from "./paths.js";
|
|
15
|
+
import { runPrompt } from "./prompt.js";
|
|
16
|
+
import { scanNetwork } from "./scan.js";
|
|
17
|
+
const PROTOCOL_VERSION = "2024-11-05";
|
|
18
|
+
const TOOLS = [
|
|
19
|
+
{
|
|
20
|
+
name: "ollanet_scan",
|
|
21
|
+
description: "Discover reachable Ollama hosts and list their models. " +
|
|
22
|
+
"Use this before routing work to pick a host/model. " +
|
|
23
|
+
"Optional LAN TCP scan (--lan equivalent) and include offline Tailscale peers.",
|
|
24
|
+
inputSchema: {
|
|
25
|
+
type: "object",
|
|
26
|
+
properties: {
|
|
27
|
+
lan: {
|
|
28
|
+
type: "boolean",
|
|
29
|
+
description: "TCP-scan local LAN CIDRs for open Ollama ports (default false)",
|
|
30
|
+
},
|
|
31
|
+
all: {
|
|
32
|
+
type: "boolean",
|
|
33
|
+
description: "Also probe offline Tailscale peers (default false)",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "ollanet_prompt",
|
|
41
|
+
description: "Send a prompt to an Ollama host on the network (or continue a saved chat by id). " +
|
|
42
|
+
"Returns the assistant reply and chat_id for follow-ups. " +
|
|
43
|
+
"Machine can be a discovered name, MagicDNS name, hostname, or IP[:port].",
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: "object",
|
|
46
|
+
properties: {
|
|
47
|
+
prompt: {
|
|
48
|
+
type: "string",
|
|
49
|
+
description: "User message to send",
|
|
50
|
+
},
|
|
51
|
+
machine: {
|
|
52
|
+
type: "string",
|
|
53
|
+
description: "Host name/IP (required for new chats; optional override when continuing)",
|
|
54
|
+
},
|
|
55
|
+
model: {
|
|
56
|
+
type: "string",
|
|
57
|
+
description: "Model name (optional; uses config default or chat model)",
|
|
58
|
+
},
|
|
59
|
+
chat_id: {
|
|
60
|
+
type: "string",
|
|
61
|
+
description: "Continue a saved chat by its hex hash id",
|
|
62
|
+
},
|
|
63
|
+
system: {
|
|
64
|
+
type: "string",
|
|
65
|
+
description: "System prompt (new chats, or if the chat has none yet)",
|
|
66
|
+
},
|
|
67
|
+
temperature: { type: "number" },
|
|
68
|
+
num_predict: { type: "integer" },
|
|
69
|
+
num_ctx: { type: "integer" },
|
|
70
|
+
keep_alive: {
|
|
71
|
+
description: 'Keep model loaded (e.g. "5m", 0, -1)',
|
|
72
|
+
},
|
|
73
|
+
think: {
|
|
74
|
+
type: "boolean",
|
|
75
|
+
description: "Enable model thinking (default false)",
|
|
76
|
+
},
|
|
77
|
+
save: {
|
|
78
|
+
type: "boolean",
|
|
79
|
+
description: "Persist the transcript (default true)",
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
required: ["prompt"],
|
|
83
|
+
additionalProperties: false,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "ollanet_list_chats",
|
|
88
|
+
description: "List saved ollanet chat transcripts (id, topic, machine, model, message count).",
|
|
89
|
+
inputSchema: {
|
|
90
|
+
type: "object",
|
|
91
|
+
properties: {},
|
|
92
|
+
additionalProperties: false,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: "ollanet_get_chat",
|
|
97
|
+
description: "Load one saved chat by id, including full message history.",
|
|
98
|
+
inputSchema: {
|
|
99
|
+
type: "object",
|
|
100
|
+
properties: {
|
|
101
|
+
chat_id: {
|
|
102
|
+
type: "string",
|
|
103
|
+
description: "Hex chat hash from a prior prompt",
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
required: ["chat_id"],
|
|
107
|
+
additionalProperties: false,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
];
|
|
111
|
+
function writeMessage(msg) {
|
|
112
|
+
process.stdout.write(`${JSON.stringify(msg)}\n`);
|
|
113
|
+
}
|
|
114
|
+
function ok(id, result) {
|
|
115
|
+
if (id === undefined)
|
|
116
|
+
return;
|
|
117
|
+
writeMessage({ jsonrpc: "2.0", id, result });
|
|
118
|
+
}
|
|
119
|
+
function fail(id, code, message) {
|
|
120
|
+
if (id === undefined)
|
|
121
|
+
return;
|
|
122
|
+
writeMessage({ jsonrpc: "2.0", id, error: { code, message } });
|
|
123
|
+
}
|
|
124
|
+
function textResult(data, isError = false) {
|
|
125
|
+
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
|
|
126
|
+
return {
|
|
127
|
+
content: [{ type: "text", text }],
|
|
128
|
+
...(isError ? { isError: true } : {}),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
async function packageVersion() {
|
|
132
|
+
try {
|
|
133
|
+
const pkg = JSON.parse(await readFile(projectPath("package.json"), "utf8"));
|
|
134
|
+
return pkg.version ?? "0.0.0";
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return "0.0.0";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async function callTool(name, args) {
|
|
141
|
+
switch (name) {
|
|
142
|
+
case "ollanet_scan": {
|
|
143
|
+
const payload = await scanNetwork({
|
|
144
|
+
lanScan: Boolean(args.lan),
|
|
145
|
+
includeOffline: Boolean(args.all),
|
|
146
|
+
});
|
|
147
|
+
return textResult(payload);
|
|
148
|
+
}
|
|
149
|
+
case "ollanet_prompt": {
|
|
150
|
+
const prompt = typeof args.prompt === "string" ? args.prompt : "";
|
|
151
|
+
if (!prompt.trim()) {
|
|
152
|
+
return textResult({ error: "prompt is required" }, true);
|
|
153
|
+
}
|
|
154
|
+
const settings = {};
|
|
155
|
+
if (typeof args.system === "string")
|
|
156
|
+
settings.system = args.system;
|
|
157
|
+
if (typeof args.temperature === "number")
|
|
158
|
+
settings.temperature = args.temperature;
|
|
159
|
+
if (typeof args.num_predict === "number")
|
|
160
|
+
settings.num_predict = Math.trunc(args.num_predict);
|
|
161
|
+
if (typeof args.num_ctx === "number")
|
|
162
|
+
settings.num_ctx = Math.trunc(args.num_ctx);
|
|
163
|
+
if (typeof args.think === "boolean")
|
|
164
|
+
settings.think = args.think;
|
|
165
|
+
if (args.keep_alive !== undefined) {
|
|
166
|
+
const raw = args.keep_alive;
|
|
167
|
+
if (typeof raw === "number")
|
|
168
|
+
settings.keep_alive = raw;
|
|
169
|
+
else if (typeof raw === "string") {
|
|
170
|
+
const asNum = Number(raw);
|
|
171
|
+
settings.keep_alive =
|
|
172
|
+
raw.trim() !== "" && Number.isFinite(asNum) && String(asNum) === raw ? asNum : raw;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const result = await runPrompt({
|
|
176
|
+
prompt,
|
|
177
|
+
machine: typeof args.machine === "string" ? args.machine : undefined,
|
|
178
|
+
model: typeof args.model === "string" ? args.model : undefined,
|
|
179
|
+
chatId: typeof args.chat_id === "string" ? args.chat_id : undefined,
|
|
180
|
+
save: args.save === undefined ? true : Boolean(args.save),
|
|
181
|
+
settings,
|
|
182
|
+
writeStdout: false,
|
|
183
|
+
stream: false,
|
|
184
|
+
quiet: true,
|
|
185
|
+
});
|
|
186
|
+
return textResult(result);
|
|
187
|
+
}
|
|
188
|
+
case "ollanet_list_chats": {
|
|
189
|
+
const chats = await listChats();
|
|
190
|
+
return textResult(chats.map((c) => ({
|
|
191
|
+
id: c.id,
|
|
192
|
+
topic: c.topic,
|
|
193
|
+
machine: c.machine,
|
|
194
|
+
model: c.model,
|
|
195
|
+
messages: c.messages.length,
|
|
196
|
+
created_at: c.created_at,
|
|
197
|
+
updated_at: c.updated_at,
|
|
198
|
+
})));
|
|
199
|
+
}
|
|
200
|
+
case "ollanet_get_chat": {
|
|
201
|
+
const id = typeof args.chat_id === "string" ? args.chat_id : "";
|
|
202
|
+
if (!id)
|
|
203
|
+
return textResult({ error: "chat_id is required" }, true);
|
|
204
|
+
const chat = await loadChat(id);
|
|
205
|
+
return textResult(chat);
|
|
206
|
+
}
|
|
207
|
+
default:
|
|
208
|
+
return textResult({ error: `Unknown tool: ${name}` }, true);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async function handleRequest(msg, version) {
|
|
212
|
+
const { id, method } = msg;
|
|
213
|
+
if (!method) {
|
|
214
|
+
fail(id, -32600, "Invalid Request: missing method");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
// Notifications (no id) — acknowledge silently.
|
|
218
|
+
if (method === "notifications/initialized" || method.startsWith("notifications/")) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
switch (method) {
|
|
222
|
+
case "initialize":
|
|
223
|
+
ok(id, {
|
|
224
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
225
|
+
capabilities: { tools: {} },
|
|
226
|
+
serverInfo: { name: "ollanet", version },
|
|
227
|
+
});
|
|
228
|
+
return;
|
|
229
|
+
case "ping":
|
|
230
|
+
ok(id, {});
|
|
231
|
+
return;
|
|
232
|
+
case "tools/list":
|
|
233
|
+
ok(id, { tools: TOOLS });
|
|
234
|
+
return;
|
|
235
|
+
case "tools/call": {
|
|
236
|
+
const params = (msg.params ?? {});
|
|
237
|
+
const name = params.name;
|
|
238
|
+
if (!name) {
|
|
239
|
+
fail(id, -32602, "tools/call requires params.name");
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
const result = await callTool(name, params.arguments ?? {});
|
|
244
|
+
ok(id, result);
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
248
|
+
ok(id, textResult({ error: message }, true));
|
|
249
|
+
}
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
default:
|
|
253
|
+
fail(id, -32601, `Method not found: ${method}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
export async function main() {
|
|
257
|
+
const version = await packageVersion();
|
|
258
|
+
console.error(`ollanet mcp ${version} (stdio) — tools: ${TOOLS.map((t) => t.name).join(", ")}`);
|
|
259
|
+
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
260
|
+
for await (const line of rl) {
|
|
261
|
+
const trimmed = line.trim();
|
|
262
|
+
if (!trimmed)
|
|
263
|
+
continue;
|
|
264
|
+
let msg;
|
|
265
|
+
try {
|
|
266
|
+
msg = JSON.parse(trimmed);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
writeMessage({
|
|
270
|
+
jsonrpc: "2.0",
|
|
271
|
+
id: null,
|
|
272
|
+
error: { code: -32700, message: "Parse error" },
|
|
273
|
+
});
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
// JSON-RPC batch
|
|
277
|
+
if (Array.isArray(msg)) {
|
|
278
|
+
for (const item of msg) {
|
|
279
|
+
await handleRequest(item, version);
|
|
280
|
+
}
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
await handleRequest(msg, version);
|
|
284
|
+
}
|
|
285
|
+
}
|
package/dist/prompt.js
CHANGED
|
@@ -308,11 +308,13 @@ function formatDuration(ns) {
|
|
|
308
308
|
return `${ms.toFixed(0)}ms`;
|
|
309
309
|
return `${(ms / 1000).toFixed(1)}s`;
|
|
310
310
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
const
|
|
311
|
+
/** Programmatic prompt used by CLI `--json` and the MCP server. */
|
|
312
|
+
export async function runPrompt(opts) {
|
|
313
|
+
const save = opts.save !== false;
|
|
314
|
+
const stream = opts.stream !== false && opts.writeStdout !== false;
|
|
315
|
+
const quiet = opts.quiet === true || opts.writeStdout === false;
|
|
316
|
+
const writeStdout = opts.writeStdout !== false;
|
|
317
|
+
let promptParts = opts.prompt ? [opts.prompt] : [];
|
|
316
318
|
const config = await loadConfig();
|
|
317
319
|
const { hosts: targets } = await discoverHosts({
|
|
318
320
|
hosts: config.hosts,
|
|
@@ -320,50 +322,34 @@ export async function main() {
|
|
|
320
322
|
});
|
|
321
323
|
let chat;
|
|
322
324
|
let isNewChat = false;
|
|
323
|
-
let machineQuery =
|
|
324
|
-
if (
|
|
325
|
-
chat = await loadChat(
|
|
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
|
-
}
|
|
325
|
+
let machineQuery = opts.machine;
|
|
326
|
+
if (opts.chatId) {
|
|
327
|
+
chat = await loadChat(opts.chatId);
|
|
335
328
|
}
|
|
336
329
|
machineQuery = machineQuery ?? chat?.machine;
|
|
337
330
|
if (!machineQuery) {
|
|
338
|
-
|
|
339
|
-
usage();
|
|
331
|
+
throw new Error("Machine is required for a new chat (or pass chatId).");
|
|
340
332
|
}
|
|
341
333
|
const host = resolveHost(targets, machineQuery);
|
|
342
334
|
if (!host.online && !host.isSelf && host.source === "tailscale") {
|
|
343
335
|
throw new Error(`Machine "${shortName(host)}" appears offline.`);
|
|
344
336
|
}
|
|
345
337
|
let peeledModel;
|
|
346
|
-
if (!
|
|
347
|
-
const peeled = await maybePeelModel(host, promptParts,
|
|
338
|
+
if (!opts.chatId && !opts.model) {
|
|
339
|
+
const peeled = await maybePeelModel(host, promptParts, "");
|
|
348
340
|
peeledModel = peeled.model;
|
|
349
341
|
promptParts = peeled.promptParts;
|
|
350
342
|
}
|
|
351
|
-
const promptText =
|
|
343
|
+
const promptText = promptParts.join(" ").trim();
|
|
352
344
|
if (!promptText) {
|
|
353
|
-
|
|
354
|
-
usage();
|
|
345
|
+
throw new Error("No prompt provided.");
|
|
355
346
|
}
|
|
356
|
-
const model =
|
|
357
|
-
peeledModel ??
|
|
358
|
-
chat?.model ??
|
|
359
|
-
defaultModelForHost(config, host);
|
|
347
|
+
const model = opts.model ?? peeledModel ?? chat?.model ?? defaultModelForHost(config, host);
|
|
360
348
|
if (!model) {
|
|
361
349
|
throw new Error(`No model specified for "${shortName(host)}" and no default in ${configPath()}.\n` +
|
|
362
|
-
`Pass a model
|
|
350
|
+
`Pass a model or add it under defaultModels.`);
|
|
363
351
|
}
|
|
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);
|
|
352
|
+
const settings = mergeSettings({ think: false }, config.defaults, machineSettingsForHost(config, host), settingsFromEnv(), chat?.system ? { system: chat.system } : undefined, opts.settings);
|
|
367
353
|
if (!chat && save) {
|
|
368
354
|
chat = createChat({
|
|
369
355
|
machine: shortName(host),
|
|
@@ -374,7 +360,6 @@ export async function main() {
|
|
|
374
360
|
isNewChat = true;
|
|
375
361
|
}
|
|
376
362
|
else if (!chat) {
|
|
377
|
-
// --no-save one-shot: ephemeral transcript for the API call only
|
|
378
363
|
chat = createChat({
|
|
379
364
|
machine: shortName(host),
|
|
380
365
|
model,
|
|
@@ -383,26 +368,26 @@ export async function main() {
|
|
|
383
368
|
isNewChat = true;
|
|
384
369
|
}
|
|
385
370
|
else {
|
|
386
|
-
if (
|
|
387
|
-
chat.system =
|
|
371
|
+
if (opts.settings?.system && !chat.system) {
|
|
372
|
+
chat.system = opts.settings.system;
|
|
388
373
|
}
|
|
389
374
|
chat.model = model;
|
|
390
375
|
chat.machine = shortName(host);
|
|
391
376
|
}
|
|
392
377
|
appendMessage(chat, { role: "user", content: promptText });
|
|
393
378
|
const baseUrl = ollamaBaseUrl(host);
|
|
394
|
-
if (!
|
|
395
|
-
const src =
|
|
379
|
+
if (!quiet) {
|
|
380
|
+
const src = opts.model ? "explicit" : chat && !isNewChat ? "chat" : "default";
|
|
396
381
|
const extras = settingsSummary(settings);
|
|
397
|
-
const chatTag =
|
|
382
|
+
const chatTag = opts.chatId ? ` chat=${chat.id}` : "";
|
|
398
383
|
console.error(`→ ${shortName(host)} (${host.ip}) model=${model} [${src}]${chatTag}${extras ? ` ${extras}` : ""}`);
|
|
399
384
|
}
|
|
400
385
|
const { content, thinking, chunk } = await ollamaChat({
|
|
401
386
|
baseUrl,
|
|
402
387
|
model,
|
|
403
388
|
messages: toApiMessages(chat),
|
|
404
|
-
stream:
|
|
405
|
-
writeStdout
|
|
389
|
+
stream: writeStdout ? stream : false,
|
|
390
|
+
writeStdout,
|
|
406
391
|
timeoutMs: PROMPT_TIMEOUT_MS,
|
|
407
392
|
settings,
|
|
408
393
|
});
|
|
@@ -429,30 +414,100 @@ export async function main() {
|
|
|
429
414
|
}
|
|
430
415
|
if (save) {
|
|
431
416
|
const file = await saveChat(chat);
|
|
432
|
-
if (!
|
|
417
|
+
if (!quiet) {
|
|
433
418
|
console.error(`chat ${chat.id} topic: ${chat.topic}`);
|
|
434
419
|
console.error(`saved ${file}`);
|
|
435
420
|
console.error(`continue: ollanet prompt --chat ${chat.id} "your follow-up"`);
|
|
436
421
|
}
|
|
437
422
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
423
|
+
return {
|
|
424
|
+
chat_id: save ? chat.id : null,
|
|
425
|
+
topic: chat.topic,
|
|
426
|
+
content,
|
|
427
|
+
thinking: thinking || null,
|
|
428
|
+
machine: shortName(host),
|
|
429
|
+
model,
|
|
430
|
+
ollama: chunk,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
export async function main() {
|
|
434
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
435
|
+
const { stream, json, save } = parsed;
|
|
436
|
+
let promptParts = parsed.promptParts;
|
|
437
|
+
const fromStdin = await readStdinIfPiped();
|
|
438
|
+
const config = await loadConfig();
|
|
439
|
+
const { hosts: targets } = await discoverHosts({
|
|
440
|
+
hosts: config.hosts,
|
|
441
|
+
discovery: config.discovery,
|
|
442
|
+
});
|
|
443
|
+
let machineQuery = parsed.machine;
|
|
444
|
+
if (parsed.chatId) {
|
|
445
|
+
// Allow `ollanet prompt <host> --chat HASH "follow-up"` only when the
|
|
446
|
+
// leading token matches an already-discovered host (not direct-address fallback).
|
|
447
|
+
if (!machineQuery && promptParts.length >= 2) {
|
|
448
|
+
const hit = findDiscoveredHost(targets, promptParts[0]);
|
|
449
|
+
if (hit) {
|
|
450
|
+
machineQuery = promptParts[0];
|
|
451
|
+
promptParts = promptParts.slice(1);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
let peeledModel;
|
|
456
|
+
if (!parsed.chatId && !parsed.model) {
|
|
457
|
+
if (!machineQuery) {
|
|
458
|
+
console.error("Machine is required for a new chat (or use --chat <hash>).");
|
|
459
|
+
usage();
|
|
460
|
+
}
|
|
461
|
+
const host = resolveHost(targets, machineQuery);
|
|
462
|
+
const peeled = await maybePeelModel(host, promptParts, fromStdin);
|
|
463
|
+
peeledModel = peeled.model;
|
|
464
|
+
promptParts = peeled.promptParts;
|
|
447
465
|
}
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
466
|
+
const promptText = [promptParts.join(" ").trim(), fromStdin].filter(Boolean).join("\n\n").trim();
|
|
467
|
+
if (!promptText) {
|
|
468
|
+
console.error("No prompt provided (pass args and/or pipe stdin).");
|
|
469
|
+
usage();
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
const result = await runPrompt({
|
|
473
|
+
machine: machineQuery,
|
|
474
|
+
model: parsed.model ?? peeledModel,
|
|
475
|
+
prompt: promptText,
|
|
476
|
+
chatId: parsed.chatId,
|
|
477
|
+
save,
|
|
478
|
+
settings: parsed.settings,
|
|
479
|
+
writeStdout: !json,
|
|
480
|
+
stream: json ? false : stream,
|
|
481
|
+
quiet: json,
|
|
482
|
+
});
|
|
483
|
+
if (json) {
|
|
484
|
+
console.log(JSON.stringify({
|
|
485
|
+
chat_id: result.chat_id,
|
|
486
|
+
topic: result.topic,
|
|
487
|
+
content: result.content,
|
|
488
|
+
thinking: result.thinking,
|
|
489
|
+
ollama: result.ollama,
|
|
490
|
+
}, null, 2));
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const chunk = result.ollama;
|
|
494
|
+
const stats = [
|
|
495
|
+
chunk.eval_count != null ? `${chunk.eval_count} tokens` : "",
|
|
496
|
+
chunk.eval_duration
|
|
497
|
+
? `${((Number(chunk.eval_count) || 0) / (Number(chunk.eval_duration) / 1e9)).toFixed(1)} tok/s`
|
|
498
|
+
: "",
|
|
499
|
+
chunk.total_duration ? formatDuration(Number(chunk.total_duration)) : "",
|
|
500
|
+
].filter(Boolean);
|
|
501
|
+
if (stats.length > 0) {
|
|
502
|
+
console.error(`(${stats.join(" · ")})`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
catch (err) {
|
|
506
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
507
|
+
if (message.startsWith("Machine is required") || message.startsWith("No prompt")) {
|
|
508
|
+
console.error(message);
|
|
509
|
+
usage();
|
|
510
|
+
}
|
|
511
|
+
throw err;
|
|
457
512
|
}
|
|
458
513
|
}
|
package/dist/scan.js
CHANGED
|
@@ -113,6 +113,49 @@ function printResults(results, networkLabel, sources) {
|
|
|
113
113
|
}
|
|
114
114
|
console.log(`Found ${found.length} Ollama server(s) with ${found.reduce((n, r) => n + r.models.length, 0)} model entrie(s).`);
|
|
115
115
|
}
|
|
116
|
+
function toPayload(results, networkLabel, sources) {
|
|
117
|
+
return {
|
|
118
|
+
network: networkLabel,
|
|
119
|
+
sources,
|
|
120
|
+
port: OLLAMA_PORT,
|
|
121
|
+
scanned: results.length,
|
|
122
|
+
servers: results
|
|
123
|
+
.filter((r) => r.ok)
|
|
124
|
+
.map((r) => ({
|
|
125
|
+
hostname: r.host.hostname,
|
|
126
|
+
dnsName: r.host.dnsName,
|
|
127
|
+
ip: r.host.ip,
|
|
128
|
+
port: r.host.port,
|
|
129
|
+
os: r.host.os,
|
|
130
|
+
source: r.host.source,
|
|
131
|
+
self: r.host.isSelf,
|
|
132
|
+
endpoint: `http://${r.host.ip}:${r.host.port}`,
|
|
133
|
+
models: r.models.map((m) => ({
|
|
134
|
+
name: m.name,
|
|
135
|
+
size: m.size,
|
|
136
|
+
modified_at: m.modified_at,
|
|
137
|
+
parameter_size: m.details?.parameter_size,
|
|
138
|
+
quantization_level: m.details?.quantization_level,
|
|
139
|
+
family: m.details?.family,
|
|
140
|
+
})),
|
|
141
|
+
})),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/** Programmatic scan used by CLI `--json` and the MCP server. */
|
|
145
|
+
export async function scanNetwork(options = {}) {
|
|
146
|
+
const config = await loadConfig();
|
|
147
|
+
const { hosts, sources, networkLabel } = await discoverHosts({
|
|
148
|
+
hosts: config.hosts,
|
|
149
|
+
discovery: config.discovery,
|
|
150
|
+
includeOffline: options.includeOffline ?? false,
|
|
151
|
+
lanScan: options.lanScan ?? false,
|
|
152
|
+
});
|
|
153
|
+
if (hosts.length === 0) {
|
|
154
|
+
throw new Error("No hosts to scan. Add config.hosts, set OLLANET_HOSTS, or enable Tailscale/LAN discovery.");
|
|
155
|
+
}
|
|
156
|
+
const results = await mapPool(hosts, CONCURRENCY, scanHost);
|
|
157
|
+
return toPayload(results, networkLabel, sources);
|
|
158
|
+
}
|
|
116
159
|
export async function main() {
|
|
117
160
|
const includeOffline = process.argv.includes("--all");
|
|
118
161
|
const jsonOut = process.argv.includes("--json");
|
|
@@ -131,32 +174,7 @@ export async function main() {
|
|
|
131
174
|
}
|
|
132
175
|
const results = await mapPool(hosts, CONCURRENCY, scanHost);
|
|
133
176
|
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));
|
|
177
|
+
console.log(JSON.stringify(toPayload(results, networkLabel, sources), null, 2));
|
|
160
178
|
return;
|
|
161
179
|
}
|
|
162
180
|
printResults(results, networkLabel, sources);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ollanet",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Chat with Ollama servers on any reachable network — scan hosts, prompt models, continue chats by hash",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Chat with Ollama servers on any reachable network — scan hosts, prompt models, continue chats by hash, MCP for agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"ollanet": "./dist/cli.js"
|
|
@@ -19,10 +19,14 @@
|
|
|
19
19
|
"prompt": "tsx src/cli.ts prompt",
|
|
20
20
|
"chats": "tsx src/cli.ts chats",
|
|
21
21
|
"bench": "tsx src/cli.ts bench",
|
|
22
|
+
"mcp": "tsx src/cli.ts mcp",
|
|
22
23
|
"start": "tsx src/cli.ts scan",
|
|
23
24
|
"typecheck": "tsc --noEmit",
|
|
24
25
|
"test": "tsc -p tsconfig.build.json && node --test test/*.test.mjs",
|
|
25
|
-
"test:only": "node --test test/*.test.mjs"
|
|
26
|
+
"test:only": "node --test test/*.test.mjs",
|
|
27
|
+
"site:dev": "pnpm --dir site dev",
|
|
28
|
+
"site:build": "pnpm --dir site build",
|
|
29
|
+
"site:deploy": "pnpm --dir site deploy"
|
|
26
30
|
},
|
|
27
31
|
"keywords": [
|
|
28
32
|
"ollama",
|
|
@@ -31,7 +35,9 @@
|
|
|
31
35
|
"chat",
|
|
32
36
|
"lan",
|
|
33
37
|
"tailscale",
|
|
34
|
-
"network"
|
|
38
|
+
"network",
|
|
39
|
+
"mcp",
|
|
40
|
+
"model-context-protocol"
|
|
35
41
|
],
|
|
36
42
|
"author": "Catalyst Forge LLC",
|
|
37
43
|
"license": "MIT",
|
|
@@ -39,7 +45,7 @@
|
|
|
39
45
|
"type": "git",
|
|
40
46
|
"url": "git+https://github.com/Catalyst-Forge-LLC/ollanet.git"
|
|
41
47
|
},
|
|
42
|
-
"homepage": "https://
|
|
48
|
+
"homepage": "https://ollanet.dev",
|
|
43
49
|
"bugs": {
|
|
44
50
|
"url": "https://github.com/Catalyst-Forge-LLC/ollanet/issues"
|
|
45
51
|
},
|