ollanet 0.1.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/LICENSE +21 -0
- package/README.md +248 -0
- package/dist/chat-store.js +131 -0
- package/dist/chats.js +127 -0
- package/dist/cli.js +81 -0
- package/dist/config.js +225 -0
- package/dist/hosts.js +383 -0
- package/dist/paths.js +24 -0
- package/dist/prompt.js +451 -0
- package/dist/scan.js +166 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# ollanet
|
|
2
|
+
|
|
3
|
+
Chat with **Ollama** servers on **any network you can reach** — LAN, localhost, Tailscale, VPN, or a raw IP.
|
|
4
|
+
|
|
5
|
+
Scan for models, fire prompts, and continue conversations later by a short hash. No browser UI required.
|
|
6
|
+
|
|
7
|
+
## Origin
|
|
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.
|
|
10
|
+
|
|
11
|
+
## Related: Finetuna
|
|
12
|
+
|
|
13
|
+
**ollanet** is the client-side / network tool. On the machine that *runs* Ollama, use **[Finetuna](https://github.com/Catalyst-Forge-LLC/finetuna)** to turn a stock model into a GPU-tuned, named variant (context, batch, Modelfile, auto-tune).
|
|
14
|
+
|
|
15
|
+
Typical loop:
|
|
16
|
+
|
|
17
|
+
1. On the host: `finetuna` → create something like `gemma4-ctx32k`
|
|
18
|
+
2. From anywhere on the network: `ollanet scan` → `ollanet prompt that-host gemma4-ctx32k "…"`
|
|
19
|
+
|
|
20
|
+
They share the same Ollama API; Finetuna shapes the models, ollanet finds and chats with them.
|
|
21
|
+
|
|
22
|
+
## Features
|
|
23
|
+
|
|
24
|
+
- **Discover** Ollama hosts from localhost, `config.hosts`, `OLLANET_HOSTS`, optional Tailscale, and optional LAN scan
|
|
25
|
+
- **Prompt** any hostname/IP/model with streaming replies
|
|
26
|
+
- **Persist** chats as `responses/<hash>.json` with topic, machine, model, timestamps
|
|
27
|
+
- **Continue** any thread with `--chat <hash>`
|
|
28
|
+
- **Configure** per-machine defaults (model, temperature, context, …)
|
|
29
|
+
|
|
30
|
+
## Requirements
|
|
31
|
+
|
|
32
|
+
- Node.js 20+
|
|
33
|
+
- One or more [Ollama](https://ollama.com) servers reachable on port `11434` (or a custom port)
|
|
34
|
+
|
|
35
|
+
Tailscale is optional. If the `tailscale` CLI is present, peers are included automatically.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
Run without installing:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npx ollanet scan # from npm (once published)
|
|
43
|
+
npx --allow-git=all github:Catalyst-Forge-LLC/ollanet scan # straight from GitHub
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
> npm 12+ blocks git dependencies by default, hence `--allow-git=all` for the
|
|
47
|
+
> GitHub form (npm ≤11 doesn't need it). Note that npx runs a one-off command —
|
|
48
|
+
> it does not put `ollanet` on your PATH.
|
|
49
|
+
|
|
50
|
+
Or install globally, which puts the `ollanet` command on your PATH:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npm install -g ollanet # from npm (once published)
|
|
54
|
+
npm install -g --allow-git=all github:Catalyst-Forge-LLC/ollanet # from GitHub
|
|
55
|
+
ollanet scan
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
When installed this way, config lives at `~/.ollanet/config.json` and chats are
|
|
59
|
+
saved to `~/.ollanet/responses/`.
|
|
60
|
+
|
|
61
|
+
### From a checkout (development)
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
cd ollanet
|
|
65
|
+
pnpm install # also builds dist/ via the prepare hook
|
|
66
|
+
pnpm link --global # optional: put `ollanet` on your PATH
|
|
67
|
+
|
|
68
|
+
# Or without a global link:
|
|
69
|
+
pnpm ollanet -- help
|
|
70
|
+
pnpm scan
|
|
71
|
+
pnpm prompt -- localhost "hello"
|
|
72
|
+
pnpm chats
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
A checkout uses the repo-local `config.json` and `responses/` instead of `~/.ollanet/`.
|
|
76
|
+
|
|
77
|
+
## Quick start
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# Find Ollama servers (localhost + configured hosts + Tailscale if available)
|
|
81
|
+
ollanet scan
|
|
82
|
+
|
|
83
|
+
# Also TCP-scan your local /24s for open Ollama ports
|
|
84
|
+
ollanet scan --lan
|
|
85
|
+
|
|
86
|
+
# Talk to a machine by name, MagicDNS name, or IP
|
|
87
|
+
ollanet prompt localhost "What is MagicDNS?"
|
|
88
|
+
ollanet prompt 192.168.1.50 gemma4:12b "Hello from the LAN"
|
|
89
|
+
|
|
90
|
+
# Continue later
|
|
91
|
+
ollanet prompt --chat a1b2c3d4e5f6 "Give a concrete example"
|
|
92
|
+
|
|
93
|
+
# Browse saved chats
|
|
94
|
+
ollanet chats
|
|
95
|
+
ollanet chats --id a1b2c3d4e5f6
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Commands
|
|
99
|
+
|
|
100
|
+
| Command | Description |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `ollanet scan` | Probe known/discovered hosts for Ollama + list models |
|
|
103
|
+
| `ollanet prompt …` | Send a prompt / continue a chat |
|
|
104
|
+
| `ollanet chats` | List or inspect saved transcripts |
|
|
105
|
+
|
|
106
|
+
### `scan` options
|
|
107
|
+
|
|
108
|
+
- `--json` — machine-readable output
|
|
109
|
+
- `--all` — also probe offline Tailscale peers
|
|
110
|
+
- `--lan` — scan local LAN CIDRs for open Ollama ports
|
|
111
|
+
|
|
112
|
+
### `prompt` options
|
|
113
|
+
|
|
114
|
+
```text
|
|
115
|
+
ollanet prompt <machine|ip> [model] <prompt...>
|
|
116
|
+
ollanet prompt --chat <hash> <prompt...>
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
| Flag | Meaning |
|
|
120
|
+
|---|---|
|
|
121
|
+
| `--chat <hash>` | Continue a saved chat |
|
|
122
|
+
| `--machine <name>` | Explicit machine (or override when continuing) |
|
|
123
|
+
| `--model <name>` | Model override |
|
|
124
|
+
| `--system <text>` | System prompt |
|
|
125
|
+
| `--temperature <n>` | Sampling temperature |
|
|
126
|
+
| `--num-predict <n>` | Max tokens to generate |
|
|
127
|
+
| `--num-ctx <n>` | Context window |
|
|
128
|
+
| `--keep-alive <value>` | Keep model loaded (`5m`, `0`, `-1`, …) |
|
|
129
|
+
| `--format json\|<schema>` | JSON mode / schema |
|
|
130
|
+
| `--no-stream` | Buffer the full reply |
|
|
131
|
+
| `--no-save` | Do not write a transcript |
|
|
132
|
+
| `--json` | Emit JSON (includes chat id when saved) |
|
|
133
|
+
|
|
134
|
+
Machine can be a discovered name, hostname, FQDN, or IP (`192.168.1.50`, `host:11434`). Direct addresses work even if they were never scanned.
|
|
135
|
+
|
|
136
|
+
### `chats` options
|
|
137
|
+
|
|
138
|
+
- `--json` — list summary JSON
|
|
139
|
+
- `--id <hash>` — show one chat (full JSON with `--json`)
|
|
140
|
+
|
|
141
|
+
## Discovery
|
|
142
|
+
|
|
143
|
+
ollanet combines several sources (deduped by `ip:port`):
|
|
144
|
+
|
|
145
|
+
| Source | When |
|
|
146
|
+
|---|---|
|
|
147
|
+
| localhost | Always (unless disabled) |
|
|
148
|
+
| `config.hosts` | Always when configured |
|
|
149
|
+
| `OLLANET_HOSTS` | Env comma/space list of hosts/IPs |
|
|
150
|
+
| Tailscale | If `tailscale status --json` works |
|
|
151
|
+
| LAN scan | `ollanet scan --lan` or `discovery.lan: true` |
|
|
152
|
+
|
|
153
|
+
## Configuration
|
|
154
|
+
|
|
155
|
+
Edit `~/.ollanet/config.json` (installed) or `config.json` (checkout) — or point
|
|
156
|
+
`OLLANET_CONFIG` at another file:
|
|
157
|
+
|
|
158
|
+
```json
|
|
159
|
+
{
|
|
160
|
+
"hosts": [
|
|
161
|
+
"192.168.1.50",
|
|
162
|
+
{ "name": "studio", "host": "studio.local", "port": 11434 }
|
|
163
|
+
],
|
|
164
|
+
"discovery": {
|
|
165
|
+
"localhost": true,
|
|
166
|
+
"tailscale": true,
|
|
167
|
+
"lan": false,
|
|
168
|
+
"cidrs": ["192.168.1.0/24"]
|
|
169
|
+
},
|
|
170
|
+
"defaultModels": {
|
|
171
|
+
"localhost": "llama3.2:1b",
|
|
172
|
+
"studio": "gemma4:12b"
|
|
173
|
+
},
|
|
174
|
+
"defaults": {
|
|
175
|
+
"temperature": 0.7,
|
|
176
|
+
"num_predict": 512,
|
|
177
|
+
"keep_alive": "5m"
|
|
178
|
+
},
|
|
179
|
+
"machineDefaults": {
|
|
180
|
+
"studio": { "num_ctx": 16384 }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**Precedence:** CLI flags → env vars → `machineDefaults` → `defaults`.
|
|
186
|
+
|
|
187
|
+
### Environment
|
|
188
|
+
|
|
189
|
+
| Variable | Purpose |
|
|
190
|
+
|---|---|
|
|
191
|
+
| `OLLANET_CONFIG` | Path to config JSON |
|
|
192
|
+
| `OLLANET_RESPONSES_DIR` | Chat transcript directory |
|
|
193
|
+
| `OLLANET_HOSTS` | Extra hosts/IPs to include |
|
|
194
|
+
| `OLLANET_LAN_TIMEOUT_MS` | Per-IP LAN probe timeout (default `200`) |
|
|
195
|
+
| `OLLANET_LAN_CONCURRENCY` | LAN probe concurrency (default `64`) |
|
|
196
|
+
| `OLLAMA_PORT` | Default Ollama port (`11434`) |
|
|
197
|
+
| `OLLAMA_TEMPERATURE` | Default temperature |
|
|
198
|
+
| `OLLAMA_NUM_PREDICT` | Default max tokens |
|
|
199
|
+
| `OLLAMA_NUM_CTX` | Default context size |
|
|
200
|
+
| `OLLAMA_KEEP_ALIVE` | Default keep-alive |
|
|
201
|
+
| `OLLAMA_FORMAT` | Default format (`json` or schema JSON) |
|
|
202
|
+
| `OLLAMA_SYSTEM` | Default system prompt |
|
|
203
|
+
| `OLLAMA_TIMEOUT_MS` | HTTP scan/probe timeout |
|
|
204
|
+
| `OLLAMA_CONCURRENCY` | Scan concurrency |
|
|
205
|
+
|
|
206
|
+
## Chat transcripts
|
|
207
|
+
|
|
208
|
+
Each saved chat is a JSON file:
|
|
209
|
+
|
|
210
|
+
```text
|
|
211
|
+
~/.ollanet/responses/<hash>.json # installed
|
|
212
|
+
responses/<hash>.json # checkout
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Fields include `id`, `topic`, `machine`, `model`, `system`, `created_at`, `updated_at`, and a `messages[]` array (`role`, `content`, `timestamp`, plus assistant `stats`).
|
|
216
|
+
|
|
217
|
+
The first turn asks the model for a short topic title (falls back to the prompt text).
|
|
218
|
+
|
|
219
|
+
## Project layout
|
|
220
|
+
|
|
221
|
+
```text
|
|
222
|
+
ollanet/
|
|
223
|
+
src/
|
|
224
|
+
cli.ts # subcommand router (CLI entry)
|
|
225
|
+
hosts.ts # discovery + host resolution
|
|
226
|
+
scan.ts
|
|
227
|
+
prompt.ts
|
|
228
|
+
chats.ts
|
|
229
|
+
chat-store.ts
|
|
230
|
+
config.ts
|
|
231
|
+
paths.ts # checkout vs installed path resolution
|
|
232
|
+
dist/ # compiled output (published to npm)
|
|
233
|
+
config.json
|
|
234
|
+
responses/ # gitignored chat history
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Development
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
pnpm install # builds dist/ via prepare
|
|
241
|
+
pnpm typecheck
|
|
242
|
+
pnpm ollanet -- scan # runs from source via tsx
|
|
243
|
+
pnpm build # compile dist/ manually
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## License
|
|
247
|
+
|
|
248
|
+
MIT
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { defaultResponsesDir } from "./paths.js";
|
|
5
|
+
const RESPONSES_DIR = process.env.OLLANET_RESPONSES_DIR ??
|
|
6
|
+
process.env.OLLAMA_RESPONSES_DIR ??
|
|
7
|
+
defaultResponsesDir();
|
|
8
|
+
export function responsesDir() {
|
|
9
|
+
return RESPONSES_DIR;
|
|
10
|
+
}
|
|
11
|
+
export function newChatId() {
|
|
12
|
+
return randomBytes(6).toString("hex");
|
|
13
|
+
}
|
|
14
|
+
export function chatPath(id) {
|
|
15
|
+
const safe = normalizeChatId(id);
|
|
16
|
+
return path.join(RESPONSES_DIR, `${safe}.json`);
|
|
17
|
+
}
|
|
18
|
+
export function normalizeChatId(id) {
|
|
19
|
+
const cleaned = id.trim().toLowerCase().replace(/\.json$/i, "");
|
|
20
|
+
if (!/^[a-f0-9]{8,32}$/.test(cleaned)) {
|
|
21
|
+
throw new Error(`Invalid chat id "${id}". Expected a hex hash from a prior prompt.`);
|
|
22
|
+
}
|
|
23
|
+
return cleaned;
|
|
24
|
+
}
|
|
25
|
+
export async function ensureResponsesDir() {
|
|
26
|
+
await mkdir(RESPONSES_DIR, { recursive: true });
|
|
27
|
+
return RESPONSES_DIR;
|
|
28
|
+
}
|
|
29
|
+
export async function loadChat(id) {
|
|
30
|
+
const safe = normalizeChatId(id);
|
|
31
|
+
try {
|
|
32
|
+
const raw = await readFile(chatPath(safe), "utf8");
|
|
33
|
+
const parsed = JSON.parse(raw);
|
|
34
|
+
if (!parsed.id || !Array.isArray(parsed.messages)) {
|
|
35
|
+
throw new Error("missing id/messages");
|
|
36
|
+
}
|
|
37
|
+
return parsed;
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
const code = err.code;
|
|
41
|
+
if (code === "ENOENT") {
|
|
42
|
+
throw new Error(`Chat not found: ${safe} (${chatPath(safe)})`);
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`Failed to load chat ${safe}: ${err instanceof Error ? err.message : err}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export async function saveChat(chat) {
|
|
48
|
+
await ensureResponsesDir();
|
|
49
|
+
const file = chatPath(chat.id);
|
|
50
|
+
await writeFile(file, `${JSON.stringify(chat, null, 2)}\n`, "utf8");
|
|
51
|
+
return file;
|
|
52
|
+
}
|
|
53
|
+
export function createChat(opts) {
|
|
54
|
+
const now = new Date().toISOString();
|
|
55
|
+
return {
|
|
56
|
+
id: newChatId(),
|
|
57
|
+
topic: opts.topic?.trim() || "Untitled chat",
|
|
58
|
+
created_at: now,
|
|
59
|
+
updated_at: now,
|
|
60
|
+
machine: opts.machine,
|
|
61
|
+
model: opts.model,
|
|
62
|
+
...(opts.system ? { system: opts.system } : {}),
|
|
63
|
+
messages: [],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export function appendMessage(chat, message) {
|
|
67
|
+
const entry = {
|
|
68
|
+
...message,
|
|
69
|
+
timestamp: message.timestamp ?? new Date().toISOString(),
|
|
70
|
+
};
|
|
71
|
+
chat.messages.push(entry);
|
|
72
|
+
chat.updated_at = entry.timestamp;
|
|
73
|
+
if (message.machine)
|
|
74
|
+
chat.machine = message.machine;
|
|
75
|
+
if (message.model)
|
|
76
|
+
chat.model = message.model;
|
|
77
|
+
return entry;
|
|
78
|
+
}
|
|
79
|
+
export async function listChats() {
|
|
80
|
+
await ensureResponsesDir();
|
|
81
|
+
const files = (await readdir(RESPONSES_DIR))
|
|
82
|
+
.filter((f) => f.endsWith(".json"))
|
|
83
|
+
.sort();
|
|
84
|
+
const chats = [];
|
|
85
|
+
for (const file of files) {
|
|
86
|
+
try {
|
|
87
|
+
const raw = await readFile(path.join(RESPONSES_DIR, file), "utf8");
|
|
88
|
+
const parsed = JSON.parse(raw);
|
|
89
|
+
if (parsed.id && Array.isArray(parsed.messages))
|
|
90
|
+
chats.push(parsed);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// skip corrupt files
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
chats.sort((a, b) => b.updated_at.localeCompare(a.updated_at));
|
|
97
|
+
return chats;
|
|
98
|
+
}
|
|
99
|
+
/** Fallback topic from the first user message when model summarization fails. */
|
|
100
|
+
export function topicFromPrompt(prompt) {
|
|
101
|
+
const oneLine = prompt.replace(/\s+/g, " ").trim();
|
|
102
|
+
if (!oneLine)
|
|
103
|
+
return "Untitled chat";
|
|
104
|
+
if (oneLine.length <= 72)
|
|
105
|
+
return oneLine;
|
|
106
|
+
return `${oneLine.slice(0, 69).trimEnd()}...`;
|
|
107
|
+
}
|
|
108
|
+
export function cleanTopic(raw, fallback) {
|
|
109
|
+
const cleaned = raw
|
|
110
|
+
.replace(/^["'`]+|["'`]+$/g, "")
|
|
111
|
+
.replace(/^(topic|title)\s*:\s*/i, "")
|
|
112
|
+
.replace(/\s+/g, " ")
|
|
113
|
+
.trim();
|
|
114
|
+
if (!cleaned)
|
|
115
|
+
return fallback;
|
|
116
|
+
if (cleaned.length <= 80)
|
|
117
|
+
return cleaned;
|
|
118
|
+
return `${cleaned.slice(0, 77).trimEnd()}...`;
|
|
119
|
+
}
|
|
120
|
+
export function toApiMessages(chat) {
|
|
121
|
+
const out = [];
|
|
122
|
+
if (chat.system) {
|
|
123
|
+
out.push({ role: "system", content: chat.system });
|
|
124
|
+
}
|
|
125
|
+
for (const msg of chat.messages) {
|
|
126
|
+
if (msg.role === "system")
|
|
127
|
+
continue;
|
|
128
|
+
out.push({ role: msg.role, content: msg.content });
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
package/dist/chats.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* List saved ollanet chats.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* ollanet chats
|
|
7
|
+
* ollanet chats --json
|
|
8
|
+
* ollanet chats --id a1b2c3d4e5f6
|
|
9
|
+
*/
|
|
10
|
+
import { loadChat, listChats, responsesDir, } from "./chat-store.js";
|
|
11
|
+
function usage() {
|
|
12
|
+
console.error(`Usage: ollanet chats [--json] [--id <hash>]
|
|
13
|
+
|
|
14
|
+
Lists transcripts in ${responsesDir()}.
|
|
15
|
+
|
|
16
|
+
Options:
|
|
17
|
+
--json Machine-readable output
|
|
18
|
+
--id <hash> Show one chat (messages included with --json, preview otherwise)`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
function parseArgs(argv) {
|
|
22
|
+
const args = [...argv];
|
|
23
|
+
let json = false;
|
|
24
|
+
let id;
|
|
25
|
+
while (args.length > 0) {
|
|
26
|
+
const arg = args.shift();
|
|
27
|
+
if (arg === "--")
|
|
28
|
+
continue;
|
|
29
|
+
if (arg === "--help" || arg === "-h")
|
|
30
|
+
usage();
|
|
31
|
+
if (arg === "--json") {
|
|
32
|
+
json = true;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (arg === "--id" || arg.startsWith("--id=")) {
|
|
36
|
+
id = arg.includes("=") ? arg.slice("--id=".length) : args.shift();
|
|
37
|
+
if (!id) {
|
|
38
|
+
console.error("--id requires a value");
|
|
39
|
+
usage();
|
|
40
|
+
}
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
console.error(`Unknown flag: ${arg}`);
|
|
44
|
+
usage();
|
|
45
|
+
}
|
|
46
|
+
return { json, id };
|
|
47
|
+
}
|
|
48
|
+
function pad(value, width) {
|
|
49
|
+
if (value.length >= width)
|
|
50
|
+
return `${value.slice(0, width - 1)}…`;
|
|
51
|
+
return value.padEnd(width);
|
|
52
|
+
}
|
|
53
|
+
function formatWhen(iso) {
|
|
54
|
+
const d = new Date(iso);
|
|
55
|
+
if (Number.isNaN(d.getTime()))
|
|
56
|
+
return iso;
|
|
57
|
+
const yyyy = d.getFullYear();
|
|
58
|
+
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
59
|
+
const dd = String(d.getDate()).padStart(2, "0");
|
|
60
|
+
const hh = String(d.getHours()).padStart(2, "0");
|
|
61
|
+
const mi = String(d.getMinutes()).padStart(2, "0");
|
|
62
|
+
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
|
|
63
|
+
}
|
|
64
|
+
function summarize(chat) {
|
|
65
|
+
return {
|
|
66
|
+
id: chat.id,
|
|
67
|
+
topic: chat.topic,
|
|
68
|
+
machine: chat.machine,
|
|
69
|
+
model: chat.model,
|
|
70
|
+
messages: chat.messages.length,
|
|
71
|
+
created_at: chat.created_at,
|
|
72
|
+
updated_at: chat.updated_at,
|
|
73
|
+
system: chat.system ?? null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function printTable(chats) {
|
|
77
|
+
if (chats.length === 0) {
|
|
78
|
+
console.log(`No chats in ${responsesDir()}`);
|
|
79
|
+
console.log(`Start one with: ollanet prompt <machine> "hello"`);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
console.log(`${pad("ID", 14)} ${pad("UPDATED", 17)} ${pad("MACHINE", 18)} ${pad("MODEL", 22)} ${pad("MSGS", 5)} TOPIC`);
|
|
83
|
+
for (const chat of chats) {
|
|
84
|
+
console.log(`${pad(chat.id, 14)} ${pad(formatWhen(chat.updated_at), 17)} ${pad(chat.machine, 18)} ${pad(chat.model, 22)} ${pad(String(chat.messages.length), 5)} ${chat.topic}`);
|
|
85
|
+
}
|
|
86
|
+
console.log(`\n${chats.length} chat(s) in ${responsesDir()}`);
|
|
87
|
+
console.log(`Continue: ollanet prompt --chat <id> "follow-up"`);
|
|
88
|
+
}
|
|
89
|
+
function printPreview(chat) {
|
|
90
|
+
console.log(`id: ${chat.id}`);
|
|
91
|
+
console.log(`topic: ${chat.topic}`);
|
|
92
|
+
console.log(`machine: ${chat.machine}`);
|
|
93
|
+
console.log(`model: ${chat.model}`);
|
|
94
|
+
console.log(`created: ${chat.created_at}`);
|
|
95
|
+
console.log(`updated: ${chat.updated_at}`);
|
|
96
|
+
if (chat.system)
|
|
97
|
+
console.log(`system: ${chat.system}`);
|
|
98
|
+
console.log(`messages: ${chat.messages.length}`);
|
|
99
|
+
console.log("");
|
|
100
|
+
for (const msg of chat.messages) {
|
|
101
|
+
const who = msg.role.padEnd(9);
|
|
102
|
+
const when = formatWhen(msg.timestamp);
|
|
103
|
+
const body = msg.content.replace(/\s+/g, " ").trim();
|
|
104
|
+
const preview = body.length > 160 ? `${body.slice(0, 157)}...` : body;
|
|
105
|
+
console.log(`[${when}] ${who} ${preview}`);
|
|
106
|
+
}
|
|
107
|
+
console.log(`\nContinue: ollanet prompt --chat ${chat.id} "follow-up"`);
|
|
108
|
+
}
|
|
109
|
+
export async function main() {
|
|
110
|
+
const { json, id } = parseArgs(process.argv.slice(2));
|
|
111
|
+
if (id) {
|
|
112
|
+
const chat = await loadChat(id);
|
|
113
|
+
if (json) {
|
|
114
|
+
console.log(JSON.stringify(chat, null, 2));
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
printPreview(chat);
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const chats = await listChats();
|
|
122
|
+
if (json) {
|
|
123
|
+
console.log(JSON.stringify(chats.map(summarize), null, 2));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
printTable(chats);
|
|
127
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ollanet — chat with Ollama servers on any reachable network.
|
|
4
|
+
*
|
|
5
|
+
* ollanet scan
|
|
6
|
+
* ollanet prompt <machine-or-ip> "hello"
|
|
7
|
+
* ollanet chats
|
|
8
|
+
*/
|
|
9
|
+
const HELP = `ollanet — Ollama over your network
|
|
10
|
+
|
|
11
|
+
Discover Ollama hosts on localhost, your LAN, Tailscale, or any IP/hostname
|
|
12
|
+
you can reach — then chat with hash-addressed transcripts.
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
ollanet scan [--json] [--all] [--lan]
|
|
16
|
+
ollanet prompt <machine|ip> [model] <prompt...>
|
|
17
|
+
ollanet prompt --chat <hash> <prompt...>
|
|
18
|
+
ollanet chats [--json] [--id <hash>]
|
|
19
|
+
|
|
20
|
+
Examples:
|
|
21
|
+
ollanet scan
|
|
22
|
+
ollanet scan --lan
|
|
23
|
+
ollanet prompt localhost "What is MagicDNS?"
|
|
24
|
+
ollanet prompt 192.168.1.50 gemma4:12b "Hello"
|
|
25
|
+
ollanet prompt --chat a1b2c3d4e5f6 "Tell me more"
|
|
26
|
+
ollanet chats
|
|
27
|
+
|
|
28
|
+
Config: ~/.ollanet/config.json when installed, ./config.json in a checkout
|
|
29
|
+
(override with OLLANET_CONFIG)
|
|
30
|
+
Hosts: config.hosts, OLLANET_HOSTS, optional Tailscale + --lan
|
|
31
|
+
Chats: ~/.ollanet/responses or ./responses (override with OLLANET_RESPONSES_DIR)
|
|
32
|
+
`;
|
|
33
|
+
async function main() {
|
|
34
|
+
// pnpm/npm often forward a bare `--`; ignore it.
|
|
35
|
+
const argv = process.argv.slice(2).filter((arg) => arg !== "--");
|
|
36
|
+
const cmd = argv[0];
|
|
37
|
+
if (!cmd || cmd === "-h" || cmd === "--help" || cmd === "help") {
|
|
38
|
+
process.stdout.write(HELP);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (cmd === "-v" || cmd === "--version" || cmd === "version") {
|
|
42
|
+
const { readFile } = await import("node:fs/promises");
|
|
43
|
+
const { projectPath } = await import("./paths.js");
|
|
44
|
+
const pkg = JSON.parse(await readFile(projectPath("package.json"), "utf8"));
|
|
45
|
+
console.log(`ollanet ${pkg.version ?? "0.0.0"}`);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// Strip the subcommand so command modules see their own argv.
|
|
49
|
+
process.argv = [process.argv[0] ?? "node", process.argv[1] ?? "ollanet", ...argv.slice(1)];
|
|
50
|
+
switch (cmd) {
|
|
51
|
+
case "scan":
|
|
52
|
+
case "ls-servers": {
|
|
53
|
+
const { main: run } = await import("./scan.js");
|
|
54
|
+
await run();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
case "prompt":
|
|
58
|
+
case "ask":
|
|
59
|
+
case "chat": {
|
|
60
|
+
const { main: run } = await import("./prompt.js");
|
|
61
|
+
await run();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
case "chats":
|
|
65
|
+
case "ls":
|
|
66
|
+
case "history": {
|
|
67
|
+
const { main: run } = await import("./chats.js");
|
|
68
|
+
await run();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
default:
|
|
72
|
+
console.error(`Unknown command: ${cmd}\n`);
|
|
73
|
+
process.stderr.write(HELP);
|
|
74
|
+
process.exitCode = 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
main().catch((err) => {
|
|
78
|
+
console.error(err instanceof Error ? err.message : err);
|
|
79
|
+
process.exitCode = 1;
|
|
80
|
+
});
|
|
81
|
+
export {};
|