@stevederico/dotbot 0.26.0 → 0.28.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/CHANGELOG.md +19 -0
- package/bin/dotbot.js +469 -40
- package/dotbot.db +0 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
0.28
|
|
2
|
+
|
|
3
|
+
Add --sandbox mode
|
|
4
|
+
Add --allow domain presets
|
|
5
|
+
Add domain-gated web_fetch
|
|
6
|
+
Add domain-gated browser_navigate
|
|
7
|
+
Add preset-unlocked tools
|
|
8
|
+
Add API key validation
|
|
9
|
+
Add models CLI command
|
|
10
|
+
Add /models REPL command
|
|
11
|
+
Add /load model command
|
|
12
|
+
|
|
13
|
+
0.27
|
|
14
|
+
|
|
15
|
+
Add interactive API key prompt
|
|
16
|
+
Add key saving to dotbotrc
|
|
17
|
+
Add provider signup URLs
|
|
18
|
+
Update thinking spinner display
|
|
19
|
+
|
|
1
20
|
0.26
|
|
2
21
|
|
|
3
22
|
Update REPL prompt style
|
package/bin/dotbot.js
CHANGED
|
@@ -22,7 +22,7 @@ process.emit = function (event, error) {
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { parseArgs } from 'node:util';
|
|
25
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
25
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
26
26
|
import { fileURLToPath } from 'node:url';
|
|
27
27
|
import { dirname, join } from 'node:path';
|
|
28
28
|
import { homedir } from 'node:os';
|
|
@@ -100,6 +100,7 @@ Usage:
|
|
|
100
100
|
echo "msg" | dotbot Pipe input from stdin
|
|
101
101
|
|
|
102
102
|
Commands:
|
|
103
|
+
models List available models from provider
|
|
103
104
|
doctor Check environment and configuration
|
|
104
105
|
tools List all available tools
|
|
105
106
|
stats Show database statistics
|
|
@@ -121,6 +122,8 @@ Options:
|
|
|
121
122
|
--db SQLite database path (default: ${DEFAULT_DB})
|
|
122
123
|
--port Server port for 'serve' command (default: ${DEFAULT_PORT})
|
|
123
124
|
--openai Enable OpenAI-compatible API endpoints (/v1/chat/completions, /v1/models)
|
|
125
|
+
--sandbox Restrict tools to safe subset (no files, code, browser, messages)
|
|
126
|
+
--allow Allow domain/preset in sandbox (github, slack, discord, npm, etc.)
|
|
124
127
|
--json Output as JSON (for inspection commands)
|
|
125
128
|
--verbose Show initialization logs
|
|
126
129
|
--help, -h Show this help
|
|
@@ -138,16 +141,20 @@ Config File:
|
|
|
138
141
|
Examples:
|
|
139
142
|
dotbot "What's the weather in SF?"
|
|
140
143
|
dotbot
|
|
144
|
+
dotbot -p anthropic -m claude-sonnet-4-5 "Hello"
|
|
145
|
+
dotbot -p ollama -m llama3 "Summarize this"
|
|
146
|
+
dotbot -p openai -m gpt-4o
|
|
147
|
+
dotbot models
|
|
141
148
|
dotbot serve --port 8080
|
|
142
149
|
dotbot doctor
|
|
143
150
|
dotbot tools
|
|
144
151
|
dotbot memory search "preferences"
|
|
145
|
-
dotbot memory delete user_pref
|
|
146
152
|
dotbot stats --json
|
|
153
|
+
dotbot --sandbox "What is 2+2?"
|
|
154
|
+
dotbot --sandbox --allow github "Check my repo"
|
|
147
155
|
dotbot --system "You are a pirate" "Hello"
|
|
148
156
|
dotbot --session abc-123 "follow up question"
|
|
149
157
|
echo "What is 2+2?" | dotbot
|
|
150
|
-
cat question.txt | dotbot
|
|
151
158
|
`);
|
|
152
159
|
}
|
|
153
160
|
|
|
@@ -162,7 +169,18 @@ function loadConfig() {
|
|
|
162
169
|
}
|
|
163
170
|
try {
|
|
164
171
|
const content = readFileSync(CONFIG_PATH, 'utf8');
|
|
165
|
-
|
|
172
|
+
const config = JSON.parse(content);
|
|
173
|
+
|
|
174
|
+
// Inject saved API keys into process.env (don't override existing)
|
|
175
|
+
if (config.env && typeof config.env === 'object') {
|
|
176
|
+
for (const [key, value] of Object.entries(config.env)) {
|
|
177
|
+
if (!process.env[key]) {
|
|
178
|
+
process.env[key] = value;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return config;
|
|
166
184
|
} catch (err) {
|
|
167
185
|
console.error(`Warning: Invalid config file at ${CONFIG_PATH}: ${err.message}`);
|
|
168
186
|
return {};
|
|
@@ -193,10 +211,17 @@ function parseCliArgs() {
|
|
|
193
211
|
port: { type: 'string' },
|
|
194
212
|
openai: { type: 'boolean', default: false },
|
|
195
213
|
session: { type: 'string', default: '' },
|
|
214
|
+
sandbox: { type: 'boolean', default: false },
|
|
215
|
+
allow: { type: 'string', multiple: true },
|
|
196
216
|
},
|
|
197
217
|
});
|
|
198
218
|
|
|
199
219
|
// Merge: CLI args > config file > hardcoded defaults
|
|
220
|
+
// Build sandbox domain allowlist from --allow flags, presets, and config
|
|
221
|
+
const allowFlags = values.allow || [];
|
|
222
|
+
const configAllow = config.sandboxAllow || [];
|
|
223
|
+
const allAllow = [...allowFlags, ...configAllow];
|
|
224
|
+
|
|
200
225
|
return {
|
|
201
226
|
...values,
|
|
202
227
|
provider: values.provider ?? config.provider ?? 'xai',
|
|
@@ -205,6 +230,8 @@ function parseCliArgs() {
|
|
|
205
230
|
db: values.db ?? config.db ?? DEFAULT_DB,
|
|
206
231
|
port: values.port ?? config.port ?? String(DEFAULT_PORT),
|
|
207
232
|
session: values.session ?? '',
|
|
233
|
+
sandbox: values.sandbox || config.sandbox || false,
|
|
234
|
+
sandboxAllow: allAllow,
|
|
208
235
|
positionals,
|
|
209
236
|
};
|
|
210
237
|
} catch (err) {
|
|
@@ -214,7 +241,42 @@ function parseCliArgs() {
|
|
|
214
241
|
}
|
|
215
242
|
|
|
216
243
|
/**
|
|
217
|
-
*
|
|
244
|
+
* Prompt user for input via readline.
|
|
245
|
+
*
|
|
246
|
+
* @param {string} question - Prompt text
|
|
247
|
+
* @returns {Promise<string>} User input
|
|
248
|
+
*/
|
|
249
|
+
function askUser(question) {
|
|
250
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
251
|
+
return new Promise((resolve) => {
|
|
252
|
+
rl.question(question, (answer) => {
|
|
253
|
+
rl.close();
|
|
254
|
+
resolve(answer.trim());
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Save or update a key in ~/.dotbotrc config file.
|
|
261
|
+
*
|
|
262
|
+
* @param {string} key - Config key
|
|
263
|
+
* @param {*} value - Config value
|
|
264
|
+
*/
|
|
265
|
+
function saveToConfig(key, value) {
|
|
266
|
+
let config = {};
|
|
267
|
+
if (existsSync(CONFIG_PATH)) {
|
|
268
|
+
try {
|
|
269
|
+
config = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
|
270
|
+
} catch {
|
|
271
|
+
config = {};
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
config[key] = value;
|
|
275
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Get provider config with API key from environment or interactive prompt.
|
|
218
280
|
*
|
|
219
281
|
* @param {string} providerId - Provider ID
|
|
220
282
|
* @returns {Object} Provider config with headers
|
|
@@ -228,25 +290,267 @@ async function getProviderConfig(providerId) {
|
|
|
228
290
|
process.exit(1);
|
|
229
291
|
}
|
|
230
292
|
|
|
293
|
+
if (providerId === 'ollama') {
|
|
294
|
+
const baseUrl = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
|
|
295
|
+
return { ...base, apiUrl: `${baseUrl}/api/chat` };
|
|
296
|
+
}
|
|
297
|
+
|
|
231
298
|
const envKey = base.envKey;
|
|
232
|
-
|
|
299
|
+
let apiKey = process.env[envKey];
|
|
233
300
|
|
|
234
|
-
if (!apiKey
|
|
235
|
-
|
|
236
|
-
|
|
301
|
+
if (!apiKey) {
|
|
302
|
+
if (!process.stdin.isTTY) {
|
|
303
|
+
console.error(`Missing ${envKey} environment variable.`);
|
|
304
|
+
console.error(`Get one at: ${getProviderSignupUrl(providerId)}`);
|
|
305
|
+
process.exit(1);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
console.log(`\n${base.name} API key not found (${envKey}). Get one at: ${getProviderSignupUrl(providerId)}\n`);
|
|
309
|
+
|
|
310
|
+
apiKey = await askUser(`Paste your ${base.name} API key: `);
|
|
311
|
+
if (!apiKey) {
|
|
312
|
+
console.error('No API key provided.');
|
|
313
|
+
process.exit(1);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Validate key by fetching models
|
|
317
|
+
process.stdout.write('Validating');
|
|
318
|
+
startSpinner();
|
|
319
|
+
const { ok } = await fetchProviderModels(providerId, apiKey);
|
|
320
|
+
if (ok) {
|
|
321
|
+
stopSpinner('valid');
|
|
322
|
+
} else {
|
|
323
|
+
stopSpinner('failed');
|
|
324
|
+
console.error(`Could not authenticate with ${base.name}. Check your API key and try again.`);
|
|
325
|
+
process.exit(1);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const save = await askUser('Save to ~/.dotbotrc for next time? (Y/n) ');
|
|
329
|
+
if (save.toLowerCase() !== 'n') {
|
|
330
|
+
saveToConfig('env', { ...loadConfig().env, [envKey]: apiKey });
|
|
331
|
+
console.log(`Saved to ${CONFIG_PATH}\n`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
process.env[envKey] = apiKey;
|
|
237
335
|
}
|
|
238
336
|
|
|
337
|
+
return {
|
|
338
|
+
...base,
|
|
339
|
+
headers: () => base.headers(apiKey),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Get signup/API key URL for a provider.
|
|
345
|
+
*
|
|
346
|
+
* @param {string} providerId - Provider ID
|
|
347
|
+
* @returns {string} URL to get an API key
|
|
348
|
+
*/
|
|
349
|
+
function getProviderSignupUrl(providerId) {
|
|
350
|
+
const urls = {
|
|
351
|
+
xai: 'https://console.x.ai',
|
|
352
|
+
openai: 'https://platform.openai.com/api-keys',
|
|
353
|
+
anthropic: 'https://console.anthropic.com/settings/keys',
|
|
354
|
+
cerebras: 'https://cloud.cerebras.ai',
|
|
355
|
+
};
|
|
356
|
+
return urls[providerId] || 'the provider\'s website';
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Fetch available models from a provider's API.
|
|
361
|
+
*
|
|
362
|
+
* @param {string} providerId - Provider ID
|
|
363
|
+
* @param {string} apiKey - API key for authentication
|
|
364
|
+
* @returns {Promise<{ok: boolean, models: Array<{id: string, name: string}>}>} Validation result with model list
|
|
365
|
+
*/
|
|
366
|
+
async function fetchProviderModels(providerId, apiKey) {
|
|
367
|
+
await loadModules();
|
|
368
|
+
const base = AI_PROVIDERS[providerId];
|
|
369
|
+
if (!base) return { ok: false, models: [] };
|
|
370
|
+
|
|
371
|
+
let url;
|
|
372
|
+
let headers;
|
|
373
|
+
|
|
239
374
|
if (providerId === 'ollama') {
|
|
240
375
|
const baseUrl = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
|
|
241
|
-
|
|
376
|
+
url = `${baseUrl}/api/tags`;
|
|
377
|
+
headers = { 'Content-Type': 'application/json' };
|
|
378
|
+
} else {
|
|
379
|
+
url = `${base.apiUrl}/models`;
|
|
380
|
+
headers = base.headers(apiKey);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(10000) });
|
|
385
|
+
if (!res.ok) return { ok: false, models: [] };
|
|
386
|
+
|
|
387
|
+
const data = await res.json();
|
|
388
|
+
|
|
389
|
+
let models = [];
|
|
390
|
+
if (providerId === 'ollama') {
|
|
391
|
+
models = (data.models || []).map((m) => ({ id: m.name, name: m.name }));
|
|
392
|
+
} else if (providerId === 'anthropic') {
|
|
393
|
+
models = (data.data || []).map((m) => ({ id: m.id, name: m.display_name || m.id }));
|
|
394
|
+
} else {
|
|
395
|
+
models = (data.data || []).map((m) => ({ id: m.id, name: m.id }));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return { ok: true, models };
|
|
399
|
+
} catch {
|
|
400
|
+
return { ok: false, models: [] };
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Tools always allowed in sandbox mode (safe, internal-only).
|
|
406
|
+
*/
|
|
407
|
+
const SANDBOX_ALLOWED_TOOLS = new Set([
|
|
408
|
+
'memory_save', 'memory_search', 'memory_delete', 'memory_list', 'memory_read', 'memory_update',
|
|
409
|
+
'web_search', 'grokipedia_search',
|
|
410
|
+
'file_read', 'file_list',
|
|
411
|
+
'run_code',
|
|
412
|
+
'weather_get',
|
|
413
|
+
'event_query', 'events_summary',
|
|
414
|
+
'task_create', 'task_list', 'task_plan', 'task_work', 'task_step_done', 'task_complete',
|
|
415
|
+
'task_delete', 'task_search', 'task_stats',
|
|
416
|
+
'trigger_create', 'trigger_list', 'trigger_toggle', 'trigger_delete',
|
|
417
|
+
'schedule_job', 'list_jobs', 'toggle_job', 'cancel_job',
|
|
418
|
+
]);
|
|
419
|
+
|
|
420
|
+
/** Tools that are allowed in sandbox but domain-gated via allowlist. */
|
|
421
|
+
const SANDBOX_GATED_TOOLS = new Set(['web_fetch', 'browser_navigate']);
|
|
422
|
+
|
|
423
|
+
/** Tools unlocked in sandbox when their preset is in the --allow list. */
|
|
424
|
+
const SANDBOX_PRESET_TOOLS = {
|
|
425
|
+
messages: ['message_list', 'message_send', 'message_delete', 'message_read'],
|
|
426
|
+
images: ['image_generate', 'image_list', 'image_search'],
|
|
427
|
+
notifications: ['notify_user'],
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Domain presets matching NemoClaw's policy preset pattern.
|
|
432
|
+
* Each preset maps to a list of allowed domains.
|
|
433
|
+
*/
|
|
434
|
+
const DOMAIN_PRESETS = {
|
|
435
|
+
github: ['github.com', 'api.github.com', 'raw.githubusercontent.com'],
|
|
436
|
+
slack: ['slack.com', 'api.slack.com'],
|
|
437
|
+
discord: ['discord.com', 'api.discord.com'],
|
|
438
|
+
npm: ['registry.npmjs.org', 'www.npmjs.com'],
|
|
439
|
+
pypi: ['pypi.org', 'files.pythonhosted.org'],
|
|
440
|
+
jira: ['atlassian.net', 'jira.atlassian.com'],
|
|
441
|
+
huggingface: ['huggingface.co', 'api-inference.huggingface.co'],
|
|
442
|
+
docker: ['hub.docker.com', 'registry-1.docker.io'],
|
|
443
|
+
telegram: ['api.telegram.org'],
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Resolve --allow values into a Set of allowed domains.
|
|
448
|
+
* Accepts preset names (e.g., "github") or raw domains (e.g., "api.example.com").
|
|
449
|
+
*
|
|
450
|
+
* @param {Array<string>} allowList - Preset names or domain names
|
|
451
|
+
* @returns {Set<string>} Resolved domain set
|
|
452
|
+
*/
|
|
453
|
+
function resolveSandboxDomains(allowList = []) {
|
|
454
|
+
const domains = new Set();
|
|
455
|
+
for (const entry of allowList) {
|
|
456
|
+
const lower = entry.toLowerCase();
|
|
457
|
+
if (DOMAIN_PRESETS[lower]) {
|
|
458
|
+
for (const d of DOMAIN_PRESETS[lower]) domains.add(d);
|
|
459
|
+
} else {
|
|
460
|
+
domains.add(lower);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return domains;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Check if a URL's hostname matches the sandbox domain allowlist.
|
|
468
|
+
*
|
|
469
|
+
* @param {string} urlStr - URL to check
|
|
470
|
+
* @param {Set<string>} allowedDomains - Set of allowed domain names
|
|
471
|
+
* @returns {boolean} Whether the domain is allowed
|
|
472
|
+
*/
|
|
473
|
+
function isDomainAllowed(urlStr, allowedDomains) {
|
|
474
|
+
try {
|
|
475
|
+
const { hostname } = new URL(urlStr);
|
|
476
|
+
if (allowedDomains.has(hostname)) return true;
|
|
477
|
+
// Support wildcard subdomains (e.g., "atlassian.net" matches "myteam.atlassian.net")
|
|
478
|
+
for (const domain of allowedDomains) {
|
|
479
|
+
if (hostname.endsWith(`.${domain}`)) return true;
|
|
480
|
+
}
|
|
481
|
+
return false;
|
|
482
|
+
} catch {
|
|
483
|
+
return false;
|
|
242
484
|
}
|
|
485
|
+
}
|
|
243
486
|
|
|
487
|
+
/**
|
|
488
|
+
* Wrap a tool's execute function with domain enforcement.
|
|
489
|
+
*
|
|
490
|
+
* @param {Object} tool - Tool definition
|
|
491
|
+
* @param {Set<string>} allowedDomains - Allowed domains
|
|
492
|
+
* @returns {Object} Tool with domain-gated execute
|
|
493
|
+
*/
|
|
494
|
+
function wrapWithDomainGate(tool, allowedDomains) {
|
|
495
|
+
const original = tool.execute;
|
|
244
496
|
return {
|
|
245
|
-
...
|
|
246
|
-
|
|
497
|
+
...tool,
|
|
498
|
+
description: `${tool.description} [SANDBOX: restricted to allowed domains]`,
|
|
499
|
+
execute: async (input, signal, context) => {
|
|
500
|
+
const url = input.url;
|
|
501
|
+
if (!url || !isDomainAllowed(url, allowedDomains)) {
|
|
502
|
+
const allowed = [...allowedDomains].join(', ') || 'none';
|
|
503
|
+
return `Blocked by sandbox policy. Domain not in allowlist.\nAllowed domains: ${allowed}`;
|
|
504
|
+
}
|
|
505
|
+
return original(input, signal, context);
|
|
506
|
+
},
|
|
247
507
|
};
|
|
248
508
|
}
|
|
249
509
|
|
|
510
|
+
/**
|
|
511
|
+
* Get tools filtered by sandbox mode with domain-gated network access.
|
|
512
|
+
*
|
|
513
|
+
* Mirrors NemoClaw's deny-by-default policy:
|
|
514
|
+
* - No filesystem access (file_*)
|
|
515
|
+
* - No code execution (run_code)
|
|
516
|
+
* - No outbound messaging (message_*)
|
|
517
|
+
* - No image generation, notifications, or app scaffolding
|
|
518
|
+
* - Network tools (web_fetch, browser_navigate) restricted to domain allowlist
|
|
519
|
+
* - Curated search APIs (web_search, grokipedia_search) always allowed
|
|
520
|
+
*
|
|
521
|
+
* @param {boolean} sandbox - Whether sandbox mode is active
|
|
522
|
+
* @param {Array<string>} allowList - Domain presets or raw domains to allow
|
|
523
|
+
* @returns {Array} Filtered and gated tools
|
|
524
|
+
*/
|
|
525
|
+
function getActiveTools(sandbox = false, allowList = []) {
|
|
526
|
+
if (!sandbox) return coreTools;
|
|
527
|
+
|
|
528
|
+
const allowedDomains = resolveSandboxDomains(allowList);
|
|
529
|
+
const allowLower = new Set(allowList.map((a) => a.toLowerCase()));
|
|
530
|
+
|
|
531
|
+
// Build set of preset-unlocked tool names
|
|
532
|
+
const presetUnlocked = new Set();
|
|
533
|
+
for (const [preset, toolNames] of Object.entries(SANDBOX_PRESET_TOOLS)) {
|
|
534
|
+
if (allowLower.has(preset)) {
|
|
535
|
+
for (const name of toolNames) presetUnlocked.add(name);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const tools = [];
|
|
540
|
+
|
|
541
|
+
for (const tool of coreTools) {
|
|
542
|
+
if (SANDBOX_ALLOWED_TOOLS.has(tool.name)) {
|
|
543
|
+
tools.push(tool);
|
|
544
|
+
} else if (SANDBOX_GATED_TOOLS.has(tool.name) && allowedDomains.size > 0) {
|
|
545
|
+
tools.push(wrapWithDomainGate(tool, allowedDomains));
|
|
546
|
+
} else if (presetUnlocked.has(tool.name)) {
|
|
547
|
+
tools.push(tool);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return tools;
|
|
552
|
+
}
|
|
553
|
+
|
|
250
554
|
/**
|
|
251
555
|
* Initialize stores.
|
|
252
556
|
*
|
|
@@ -331,38 +635,48 @@ async function runChat(message, options) {
|
|
|
331
635
|
...storesObj,
|
|
332
636
|
};
|
|
333
637
|
|
|
334
|
-
let
|
|
638
|
+
let hasThinkingText = false;
|
|
335
639
|
let thinkingDone = false;
|
|
336
640
|
|
|
641
|
+
process.stdout.write('Thinking');
|
|
642
|
+
startSpinner();
|
|
643
|
+
|
|
337
644
|
for await (const event of agentLoop({
|
|
338
645
|
model: options.model,
|
|
339
646
|
messages,
|
|
340
|
-
tools:
|
|
647
|
+
tools: getActiveTools(options.sandbox, options.sandboxAllow),
|
|
341
648
|
provider,
|
|
342
649
|
context,
|
|
343
650
|
})) {
|
|
344
651
|
switch (event.type) {
|
|
345
652
|
case 'thinking':
|
|
346
|
-
if (!isThinking) {
|
|
347
|
-
process.stdout.write('Thinking...\n');
|
|
348
|
-
isThinking = true;
|
|
349
|
-
}
|
|
350
653
|
if (event.text) {
|
|
654
|
+
if (!hasThinkingText) {
|
|
655
|
+
stopSpinner('');
|
|
656
|
+
process.stdout.write('\n');
|
|
657
|
+
hasThinkingText = true;
|
|
658
|
+
}
|
|
351
659
|
process.stdout.write(event.text);
|
|
352
660
|
}
|
|
353
661
|
break;
|
|
354
662
|
case 'text_delta':
|
|
355
|
-
if (
|
|
356
|
-
|
|
357
|
-
|
|
663
|
+
if (!thinkingDone) {
|
|
664
|
+
if (hasThinkingText) {
|
|
665
|
+
process.stdout.write('\n...done thinking.\n\n');
|
|
666
|
+
} else {
|
|
667
|
+
stopSpinner('');
|
|
668
|
+
}
|
|
358
669
|
thinkingDone = true;
|
|
359
670
|
}
|
|
360
671
|
process.stdout.write(event.text);
|
|
361
672
|
break;
|
|
362
673
|
case 'tool_start':
|
|
363
|
-
if (
|
|
364
|
-
|
|
365
|
-
|
|
674
|
+
if (!thinkingDone) {
|
|
675
|
+
if (hasThinkingText) {
|
|
676
|
+
process.stdout.write('\n...done thinking.\n\n');
|
|
677
|
+
} else {
|
|
678
|
+
stopSpinner('');
|
|
679
|
+
}
|
|
366
680
|
thinkingDone = true;
|
|
367
681
|
}
|
|
368
682
|
process.stdout.write(`[${event.name}] `);
|
|
@@ -420,7 +734,7 @@ async function runRepl(options) {
|
|
|
420
734
|
output: process.stdout,
|
|
421
735
|
});
|
|
422
736
|
|
|
423
|
-
console.log(`\ndotbot v${VERSION} — ${options.provider}/${options.model}`);
|
|
737
|
+
console.log(`\ndotbot v${VERSION} — ${options.provider}/${options.model}${options.sandbox ? ' (sandbox)' : ''}`);
|
|
424
738
|
if (options.session) {
|
|
425
739
|
console.log(`Resuming session: ${session.id}`);
|
|
426
740
|
}
|
|
@@ -428,6 +742,8 @@ async function runRepl(options) {
|
|
|
428
742
|
|
|
429
743
|
const showHelp = () => {
|
|
430
744
|
console.log('Available Commands:');
|
|
745
|
+
console.log(' /models List available models from provider');
|
|
746
|
+
console.log(' /load <model> Switch to a different model');
|
|
431
747
|
console.log(' /show Show model information');
|
|
432
748
|
console.log(' /clear Clear session context');
|
|
433
749
|
console.log(' /bye Exit');
|
|
@@ -497,6 +813,50 @@ async function runRepl(options) {
|
|
|
497
813
|
return;
|
|
498
814
|
}
|
|
499
815
|
|
|
816
|
+
if (trimmed === '/models') {
|
|
817
|
+
const apiKey = process.env[AI_PROVIDERS[options.provider]?.envKey];
|
|
818
|
+
process.stdout.write('Fetching models');
|
|
819
|
+
startSpinner();
|
|
820
|
+
const { ok, models } = await fetchProviderModels(options.provider, apiKey);
|
|
821
|
+
if (ok && models.length) {
|
|
822
|
+
stopSpinner('');
|
|
823
|
+
console.log('');
|
|
824
|
+
for (const m of models) {
|
|
825
|
+
const active = m.id === options.model ? ' (active)' : '';
|
|
826
|
+
console.log(` ${m.id}${active}`);
|
|
827
|
+
}
|
|
828
|
+
console.log('');
|
|
829
|
+
} else {
|
|
830
|
+
stopSpinner('');
|
|
831
|
+
// Fall back to static list
|
|
832
|
+
const base = AI_PROVIDERS[options.provider];
|
|
833
|
+
if (base.models?.length) {
|
|
834
|
+
console.log('');
|
|
835
|
+
for (const m of base.models) {
|
|
836
|
+
const active = m.id === options.model ? ' (active)' : '';
|
|
837
|
+
console.log(` ${m.id}${active}`);
|
|
838
|
+
}
|
|
839
|
+
console.log('');
|
|
840
|
+
} else {
|
|
841
|
+
console.log('\nNo models found.\n');
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
promptUser();
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
if (trimmed.startsWith('/load ')) {
|
|
849
|
+
const newModel = trimmed.slice(6).trim();
|
|
850
|
+
if (!newModel) {
|
|
851
|
+
console.log('Usage: /load <model-name>\n');
|
|
852
|
+
} else {
|
|
853
|
+
options.model = newModel;
|
|
854
|
+
console.log(`Switched to ${newModel}\n`);
|
|
855
|
+
}
|
|
856
|
+
promptUser();
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
500
860
|
await handleMessage(trimmed);
|
|
501
861
|
});
|
|
502
862
|
};
|
|
@@ -504,41 +864,51 @@ async function runRepl(options) {
|
|
|
504
864
|
const handleMessage = async (text) => {
|
|
505
865
|
messages.push({ role: 'user', content: text });
|
|
506
866
|
|
|
507
|
-
let
|
|
867
|
+
let hasThinkingText = false;
|
|
508
868
|
let thinkingDone = false;
|
|
509
869
|
let assistantContent = '';
|
|
510
870
|
|
|
871
|
+
process.stdout.write('Thinking');
|
|
872
|
+
startSpinner();
|
|
873
|
+
|
|
511
874
|
try {
|
|
512
875
|
for await (const event of agentLoop({
|
|
513
876
|
model: options.model,
|
|
514
877
|
messages: [...messages],
|
|
515
|
-
tools:
|
|
878
|
+
tools: getActiveTools(options.sandbox, options.sandboxAllow),
|
|
516
879
|
provider,
|
|
517
880
|
context,
|
|
518
881
|
})) {
|
|
519
882
|
switch (event.type) {
|
|
520
883
|
case 'thinking':
|
|
521
|
-
if (!isThinking) {
|
|
522
|
-
process.stdout.write('Thinking...\n');
|
|
523
|
-
isThinking = true;
|
|
524
|
-
}
|
|
525
884
|
if (event.text) {
|
|
885
|
+
if (!hasThinkingText) {
|
|
886
|
+
stopSpinner('');
|
|
887
|
+
process.stdout.write('\n');
|
|
888
|
+
hasThinkingText = true;
|
|
889
|
+
}
|
|
526
890
|
process.stdout.write(event.text);
|
|
527
891
|
}
|
|
528
892
|
break;
|
|
529
893
|
case 'text_delta':
|
|
530
|
-
if (
|
|
531
|
-
|
|
532
|
-
|
|
894
|
+
if (!thinkingDone) {
|
|
895
|
+
if (hasThinkingText) {
|
|
896
|
+
process.stdout.write('\n...done thinking.\n\n');
|
|
897
|
+
} else {
|
|
898
|
+
stopSpinner('');
|
|
899
|
+
}
|
|
533
900
|
thinkingDone = true;
|
|
534
901
|
}
|
|
535
902
|
process.stdout.write(event.text);
|
|
536
903
|
assistantContent += event.text;
|
|
537
904
|
break;
|
|
538
905
|
case 'tool_start':
|
|
539
|
-
if (
|
|
540
|
-
|
|
541
|
-
|
|
906
|
+
if (!thinkingDone) {
|
|
907
|
+
if (hasThinkingText) {
|
|
908
|
+
process.stdout.write('\n...done thinking.\n\n');
|
|
909
|
+
} else {
|
|
910
|
+
stopSpinner('');
|
|
911
|
+
}
|
|
542
912
|
thinkingDone = true;
|
|
543
913
|
}
|
|
544
914
|
process.stdout.write(`[${event.name}] `);
|
|
@@ -670,7 +1040,7 @@ async function runServer(options) {
|
|
|
670
1040
|
for await (const event of agentLoop({
|
|
671
1041
|
model,
|
|
672
1042
|
messages: reqMessages,
|
|
673
|
-
tools:
|
|
1043
|
+
tools: getActiveTools(options.sandbox, options.sandboxAllow),
|
|
674
1044
|
provider,
|
|
675
1045
|
context,
|
|
676
1046
|
})) {
|
|
@@ -703,7 +1073,7 @@ async function runServer(options) {
|
|
|
703
1073
|
for await (const event of agentLoop({
|
|
704
1074
|
model,
|
|
705
1075
|
messages: reqMessages,
|
|
706
|
-
tools:
|
|
1076
|
+
tools: getActiveTools(options.sandbox, options.sandboxAllow),
|
|
707
1077
|
provider,
|
|
708
1078
|
context,
|
|
709
1079
|
})) {
|
|
@@ -777,7 +1147,7 @@ async function runServer(options) {
|
|
|
777
1147
|
for await (const event of agentLoop({
|
|
778
1148
|
model,
|
|
779
1149
|
messages,
|
|
780
|
-
tools:
|
|
1150
|
+
tools: getActiveTools(options.sandbox, options.sandboxAllow),
|
|
781
1151
|
provider,
|
|
782
1152
|
context,
|
|
783
1153
|
})) {
|
|
@@ -1164,6 +1534,62 @@ async function runDoctor(options) {
|
|
|
1164
1534
|
console.log();
|
|
1165
1535
|
}
|
|
1166
1536
|
|
|
1537
|
+
/**
|
|
1538
|
+
* List available models from the provider's API.
|
|
1539
|
+
*
|
|
1540
|
+
* @param {Object} options - CLI options
|
|
1541
|
+
*/
|
|
1542
|
+
async function runModels(options) {
|
|
1543
|
+
await loadModules();
|
|
1544
|
+
const base = AI_PROVIDERS[options.provider];
|
|
1545
|
+
if (!base) {
|
|
1546
|
+
console.error(`Unknown provider: ${options.provider}`);
|
|
1547
|
+
process.exit(1);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
const apiKey = process.env[base.envKey];
|
|
1551
|
+
if (!apiKey && options.provider !== 'ollama') {
|
|
1552
|
+
console.error(`Missing ${base.envKey} — set it or run dotbot to configure interactively.`);
|
|
1553
|
+
process.exit(1);
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
process.stdout.write('Fetching models');
|
|
1557
|
+
startSpinner();
|
|
1558
|
+
|
|
1559
|
+
const { ok, models } = await fetchProviderModels(options.provider, apiKey);
|
|
1560
|
+
|
|
1561
|
+
if (!ok) {
|
|
1562
|
+
stopSpinner('failed');
|
|
1563
|
+
console.error(`Could not fetch models from ${base.name}. Check your API key.`);
|
|
1564
|
+
|
|
1565
|
+
// Fall back to static list
|
|
1566
|
+
if (base.models?.length) {
|
|
1567
|
+
console.log(`\nLocal model list (${base.name}):\n`);
|
|
1568
|
+
for (const m of base.models) {
|
|
1569
|
+
const active = m.id === options.model ? ' (active)' : '';
|
|
1570
|
+
console.log(` ${m.id}${active}`);
|
|
1571
|
+
}
|
|
1572
|
+
console.log();
|
|
1573
|
+
}
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
stopSpinner('');
|
|
1578
|
+
|
|
1579
|
+
if (options.json) {
|
|
1580
|
+
console.log(JSON.stringify(models));
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
console.log(`\n${base.name} models (${models.length})\n`);
|
|
1585
|
+
for (const m of models) {
|
|
1586
|
+
const active = m.id === options.model ? ' (active)' : '';
|
|
1587
|
+
const label = m.name !== m.id ? ` — ${m.name}` : '';
|
|
1588
|
+
console.log(` ${m.id}${label}${active}`);
|
|
1589
|
+
}
|
|
1590
|
+
console.log();
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1167
1593
|
/**
|
|
1168
1594
|
* Main entry point.
|
|
1169
1595
|
*/
|
|
@@ -1196,6 +1622,9 @@ async function main() {
|
|
|
1196
1622
|
}
|
|
1197
1623
|
|
|
1198
1624
|
switch (command) {
|
|
1625
|
+
case 'models':
|
|
1626
|
+
await runModels(args);
|
|
1627
|
+
break;
|
|
1199
1628
|
case 'doctor':
|
|
1200
1629
|
await runDoctor(args);
|
|
1201
1630
|
break;
|
package/dotbot.db
CHANGED
|
Binary file
|
package/package.json
CHANGED