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/README.md +38 -2
- package/dist/bench-store.js +20 -0
- package/dist/bench-suite.js +149 -0
- package/dist/bench.js +781 -0
- package/dist/cli.js +9 -0
- package/dist/config.js +22 -0
- package/dist/hosts.js +56 -16
- package/dist/ollama-chat.js +307 -0
- package/dist/paths.js +4 -0
- package/dist/prompt.js +133 -126
- package/dist/scan.js +3 -3
- package/package.json +5 -2
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,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
|
|
29
|
+
ollanet prompt localhost "Explain MagicDNS"
|
|
27
30
|
ollanet prompt --chat a1b2c3d4e5f6 "Give an example"
|
|
28
|
-
ollanet prompt
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
283
|
-
keep_alive
|
|
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 {
|
|
311
|
-
|
|
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
|
|
330
|
-
// leading
|
|
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
|
-
|
|
333
|
-
|
|
329
|
+
const hit = findDiscoveredHost(targets, promptParts[0]);
|
|
330
|
+
if (hit) {
|
|
334
331
|
machineQuery = promptParts[0];
|
|
335
|
-
|
|
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(
|
|
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
|
|
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 =
|
|
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 "";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ollanet",
|
|
3
|
-
"version": "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",
|