@stevederico/dotbot 0.27.0 → 0.29.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 +22 -0
- package/README.md +64 -12
- package/bin/dotbot.js +389 -99
- package/core/agent.js +1 -1
- package/core/cdp.js +5 -58
- package/dotbot.db +0 -0
- package/index.js +0 -7
- package/package.json +1 -1
- package/storage/SQLiteCronAdapter.js +8 -92
- package/storage/index.js +0 -3
- package/tools/appgen.js +1 -10
- package/tools/browser.js +0 -15
- package/tools/code.js +0 -28
- package/tools/images.js +0 -10
- package/tools/index.js +2 -4
- package/tools/jobs.js +0 -2
- package/tools/tasks.js +0 -2
- package/tools/web.js +0 -36
- package/examples/sqlite-session-example.js +0 -69
- package/observer/index.js +0 -164
package/bin/dotbot.js
CHANGED
|
@@ -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
|
|
|
@@ -204,10 +211,17 @@ function parseCliArgs() {
|
|
|
204
211
|
port: { type: 'string' },
|
|
205
212
|
openai: { type: 'boolean', default: false },
|
|
206
213
|
session: { type: 'string', default: '' },
|
|
214
|
+
sandbox: { type: 'boolean', default: false },
|
|
215
|
+
allow: { type: 'string', multiple: true },
|
|
207
216
|
},
|
|
208
217
|
});
|
|
209
218
|
|
|
210
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
|
+
|
|
211
225
|
return {
|
|
212
226
|
...values,
|
|
213
227
|
provider: values.provider ?? config.provider ?? 'xai',
|
|
@@ -216,6 +230,8 @@ function parseCliArgs() {
|
|
|
216
230
|
db: values.db ?? config.db ?? DEFAULT_DB,
|
|
217
231
|
port: values.port ?? config.port ?? String(DEFAULT_PORT),
|
|
218
232
|
session: values.session ?? '',
|
|
233
|
+
sandbox: values.sandbox || config.sandbox || false,
|
|
234
|
+
sandboxAllow: allAllow,
|
|
219
235
|
positionals,
|
|
220
236
|
};
|
|
221
237
|
} catch (err) {
|
|
@@ -297,6 +313,18 @@ async function getProviderConfig(providerId) {
|
|
|
297
313
|
process.exit(1);
|
|
298
314
|
}
|
|
299
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
|
+
|
|
300
328
|
const save = await askUser('Save to ~/.dotbotrc for next time? (Y/n) ');
|
|
301
329
|
if (save.toLowerCase() !== 'n') {
|
|
302
330
|
saveToConfig('env', { ...loadConfig().env, [envKey]: apiKey });
|
|
@@ -328,6 +356,201 @@ function getProviderSignupUrl(providerId) {
|
|
|
328
356
|
return urls[providerId] || 'the provider\'s website';
|
|
329
357
|
}
|
|
330
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
|
+
|
|
374
|
+
if (providerId === 'ollama') {
|
|
375
|
+
const baseUrl = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
|
|
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;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
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;
|
|
496
|
+
return {
|
|
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
|
+
},
|
|
507
|
+
};
|
|
508
|
+
}
|
|
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
|
+
|
|
331
554
|
/**
|
|
332
555
|
* Initialize stores.
|
|
333
556
|
*
|
|
@@ -381,50 +604,21 @@ async function initStores(dbPath, verbose = false, customSystemPrompt = '') {
|
|
|
381
604
|
}
|
|
382
605
|
|
|
383
606
|
/**
|
|
384
|
-
*
|
|
607
|
+
* Stream events from an agentLoop iterable to stdout.
|
|
608
|
+
* Handles thinking markers, text deltas, tool status, and errors.
|
|
385
609
|
*
|
|
386
|
-
* @param {
|
|
387
|
-
* @
|
|
610
|
+
* @param {AsyncIterable<Object>} events - Async iterable of agentLoop events
|
|
611
|
+
* @returns {Promise<string>} Accumulated assistant text content
|
|
388
612
|
*/
|
|
389
|
-
async function
|
|
390
|
-
const storesObj = await initStores(options.db, options.verbose, options.system);
|
|
391
|
-
const provider = await getProviderConfig(options.provider);
|
|
392
|
-
|
|
393
|
-
let session;
|
|
394
|
-
let messages;
|
|
395
|
-
|
|
396
|
-
if (options.session) {
|
|
397
|
-
session = await storesObj.sessionStore.getSession(options.session, 'cli-user');
|
|
398
|
-
if (!session) {
|
|
399
|
-
console.error(`Error: Session not found: ${options.session}`);
|
|
400
|
-
process.exit(1);
|
|
401
|
-
}
|
|
402
|
-
messages = [...(session.messages || []), { role: 'user', content: message }];
|
|
403
|
-
} else {
|
|
404
|
-
session = await storesObj.sessionStore.createSession('cli-user', options.model, options.provider);
|
|
405
|
-
messages = [{ role: 'user', content: message }];
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
const context = {
|
|
409
|
-
userID: 'cli-user',
|
|
410
|
-
sessionId: session.id,
|
|
411
|
-
providers: { [options.provider]: { apiKey: process.env[AI_PROVIDERS[options.provider]?.envKey] } },
|
|
412
|
-
...storesObj,
|
|
413
|
-
};
|
|
414
|
-
|
|
613
|
+
async function streamEvents(events) {
|
|
415
614
|
let hasThinkingText = false;
|
|
416
615
|
let thinkingDone = false;
|
|
616
|
+
let assistantContent = '';
|
|
417
617
|
|
|
418
618
|
process.stdout.write('Thinking');
|
|
419
619
|
startSpinner();
|
|
420
620
|
|
|
421
|
-
for await (const event of
|
|
422
|
-
model: options.model,
|
|
423
|
-
messages,
|
|
424
|
-
tools: coreTools,
|
|
425
|
-
provider,
|
|
426
|
-
context,
|
|
427
|
-
})) {
|
|
621
|
+
for await (const event of events) {
|
|
428
622
|
switch (event.type) {
|
|
429
623
|
case 'thinking':
|
|
430
624
|
if (event.text) {
|
|
@@ -446,6 +640,7 @@ async function runChat(message, options) {
|
|
|
446
640
|
thinkingDone = true;
|
|
447
641
|
}
|
|
448
642
|
process.stdout.write(event.text);
|
|
643
|
+
assistantContent += event.text;
|
|
449
644
|
break;
|
|
450
645
|
case 'tool_start':
|
|
451
646
|
if (!thinkingDone) {
|
|
@@ -466,11 +661,55 @@ async function runChat(message, options) {
|
|
|
466
661
|
stopSpinner('error');
|
|
467
662
|
break;
|
|
468
663
|
case 'error':
|
|
664
|
+
stopSpinner();
|
|
469
665
|
console.error(`\nError: ${event.error}`);
|
|
470
666
|
break;
|
|
471
667
|
}
|
|
472
668
|
}
|
|
473
669
|
|
|
670
|
+
return assistantContent;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Run a single chat message and stream output.
|
|
675
|
+
*
|
|
676
|
+
* @param {string} message - User message
|
|
677
|
+
* @param {Object} options - CLI options
|
|
678
|
+
*/
|
|
679
|
+
async function runChat(message, options) {
|
|
680
|
+
const storesObj = await initStores(options.db, options.verbose, options.system);
|
|
681
|
+
const provider = await getProviderConfig(options.provider);
|
|
682
|
+
|
|
683
|
+
let session;
|
|
684
|
+
let messages;
|
|
685
|
+
|
|
686
|
+
if (options.session) {
|
|
687
|
+
session = await storesObj.sessionStore.getSession(options.session, 'cli-user');
|
|
688
|
+
if (!session) {
|
|
689
|
+
console.error(`Error: Session not found: ${options.session}`);
|
|
690
|
+
process.exit(1);
|
|
691
|
+
}
|
|
692
|
+
messages = [...(session.messages || []), { role: 'user', content: message }];
|
|
693
|
+
} else {
|
|
694
|
+
session = await storesObj.sessionStore.createSession('cli-user', options.model, options.provider);
|
|
695
|
+
messages = [{ role: 'user', content: message }];
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const context = {
|
|
699
|
+
userID: 'cli-user',
|
|
700
|
+
sessionId: session.id,
|
|
701
|
+
providers: { [options.provider]: { apiKey: process.env[AI_PROVIDERS[options.provider]?.envKey] } },
|
|
702
|
+
...storesObj,
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
await streamEvents(agentLoop({
|
|
706
|
+
model: options.model,
|
|
707
|
+
messages,
|
|
708
|
+
tools: getActiveTools(options.sandbox, options.sandboxAllow),
|
|
709
|
+
provider,
|
|
710
|
+
context,
|
|
711
|
+
}));
|
|
712
|
+
|
|
474
713
|
process.stdout.write('\n\n');
|
|
475
714
|
process.exit(0);
|
|
476
715
|
}
|
|
@@ -511,7 +750,7 @@ async function runRepl(options) {
|
|
|
511
750
|
output: process.stdout,
|
|
512
751
|
});
|
|
513
752
|
|
|
514
|
-
console.log(`\ndotbot v${VERSION} — ${options.provider}/${options.model}`);
|
|
753
|
+
console.log(`\ndotbot v${VERSION} — ${options.provider}/${options.model}${options.sandbox ? ' (sandbox)' : ''}`);
|
|
515
754
|
if (options.session) {
|
|
516
755
|
console.log(`Resuming session: ${session.id}`);
|
|
517
756
|
}
|
|
@@ -519,6 +758,8 @@ async function runRepl(options) {
|
|
|
519
758
|
|
|
520
759
|
const showHelp = () => {
|
|
521
760
|
console.log('Available Commands:');
|
|
761
|
+
console.log(' /models List available models from provider');
|
|
762
|
+
console.log(' /load <model> Switch to a different model');
|
|
522
763
|
console.log(' /show Show model information');
|
|
523
764
|
console.log(' /clear Clear session context');
|
|
524
765
|
console.log(' /bye Exit');
|
|
@@ -588,6 +829,50 @@ async function runRepl(options) {
|
|
|
588
829
|
return;
|
|
589
830
|
}
|
|
590
831
|
|
|
832
|
+
if (trimmed === '/models') {
|
|
833
|
+
const apiKey = process.env[AI_PROVIDERS[options.provider]?.envKey];
|
|
834
|
+
process.stdout.write('Fetching models');
|
|
835
|
+
startSpinner();
|
|
836
|
+
const { ok, models } = await fetchProviderModels(options.provider, apiKey);
|
|
837
|
+
if (ok && models.length) {
|
|
838
|
+
stopSpinner('');
|
|
839
|
+
console.log('');
|
|
840
|
+
for (const m of models) {
|
|
841
|
+
const active = m.id === options.model ? ' (active)' : '';
|
|
842
|
+
console.log(` ${m.id}${active}`);
|
|
843
|
+
}
|
|
844
|
+
console.log('');
|
|
845
|
+
} else {
|
|
846
|
+
stopSpinner('');
|
|
847
|
+
// Fall back to static list
|
|
848
|
+
const base = AI_PROVIDERS[options.provider];
|
|
849
|
+
if (base.models?.length) {
|
|
850
|
+
console.log('');
|
|
851
|
+
for (const m of base.models) {
|
|
852
|
+
const active = m.id === options.model ? ' (active)' : '';
|
|
853
|
+
console.log(` ${m.id}${active}`);
|
|
854
|
+
}
|
|
855
|
+
console.log('');
|
|
856
|
+
} else {
|
|
857
|
+
console.log('\nNo models found.\n');
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
promptUser();
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if (trimmed.startsWith('/load ')) {
|
|
865
|
+
const newModel = trimmed.slice(6).trim();
|
|
866
|
+
if (!newModel) {
|
|
867
|
+
console.log('Usage: /load <model-name>\n');
|
|
868
|
+
} else {
|
|
869
|
+
options.model = newModel;
|
|
870
|
+
console.log(`Switched to ${newModel}\n`);
|
|
871
|
+
}
|
|
872
|
+
promptUser();
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
|
|
591
876
|
await handleMessage(trimmed);
|
|
592
877
|
});
|
|
593
878
|
};
|
|
@@ -595,68 +880,14 @@ async function runRepl(options) {
|
|
|
595
880
|
const handleMessage = async (text) => {
|
|
596
881
|
messages.push({ role: 'user', content: text });
|
|
597
882
|
|
|
598
|
-
let hasThinkingText = false;
|
|
599
|
-
let thinkingDone = false;
|
|
600
|
-
let assistantContent = '';
|
|
601
|
-
|
|
602
|
-
process.stdout.write('Thinking');
|
|
603
|
-
startSpinner();
|
|
604
|
-
|
|
605
883
|
try {
|
|
606
|
-
|
|
884
|
+
const assistantContent = await streamEvents(agentLoop({
|
|
607
885
|
model: options.model,
|
|
608
886
|
messages: [...messages],
|
|
609
|
-
tools:
|
|
887
|
+
tools: getActiveTools(options.sandbox, options.sandboxAllow),
|
|
610
888
|
provider,
|
|
611
889
|
context,
|
|
612
|
-
}))
|
|
613
|
-
switch (event.type) {
|
|
614
|
-
case 'thinking':
|
|
615
|
-
if (event.text) {
|
|
616
|
-
if (!hasThinkingText) {
|
|
617
|
-
stopSpinner('');
|
|
618
|
-
process.stdout.write('\n');
|
|
619
|
-
hasThinkingText = true;
|
|
620
|
-
}
|
|
621
|
-
process.stdout.write(event.text);
|
|
622
|
-
}
|
|
623
|
-
break;
|
|
624
|
-
case 'text_delta':
|
|
625
|
-
if (!thinkingDone) {
|
|
626
|
-
if (hasThinkingText) {
|
|
627
|
-
process.stdout.write('\n...done thinking.\n\n');
|
|
628
|
-
} else {
|
|
629
|
-
stopSpinner('');
|
|
630
|
-
}
|
|
631
|
-
thinkingDone = true;
|
|
632
|
-
}
|
|
633
|
-
process.stdout.write(event.text);
|
|
634
|
-
assistantContent += event.text;
|
|
635
|
-
break;
|
|
636
|
-
case 'tool_start':
|
|
637
|
-
if (!thinkingDone) {
|
|
638
|
-
if (hasThinkingText) {
|
|
639
|
-
process.stdout.write('\n...done thinking.\n\n');
|
|
640
|
-
} else {
|
|
641
|
-
stopSpinner('');
|
|
642
|
-
}
|
|
643
|
-
thinkingDone = true;
|
|
644
|
-
}
|
|
645
|
-
process.stdout.write(`[${event.name}] `);
|
|
646
|
-
startSpinner();
|
|
647
|
-
break;
|
|
648
|
-
case 'tool_result':
|
|
649
|
-
stopSpinner('done');
|
|
650
|
-
break;
|
|
651
|
-
case 'tool_error':
|
|
652
|
-
stopSpinner('error');
|
|
653
|
-
break;
|
|
654
|
-
case 'error':
|
|
655
|
-
stopSpinner();
|
|
656
|
-
console.error(`\nError: ${event.error}`);
|
|
657
|
-
break;
|
|
658
|
-
}
|
|
659
|
-
}
|
|
890
|
+
}));
|
|
660
891
|
|
|
661
892
|
if (assistantContent) {
|
|
662
893
|
messages.push({ role: 'assistant', content: assistantContent });
|
|
@@ -771,7 +1002,7 @@ async function runServer(options) {
|
|
|
771
1002
|
for await (const event of agentLoop({
|
|
772
1003
|
model,
|
|
773
1004
|
messages: reqMessages,
|
|
774
|
-
tools:
|
|
1005
|
+
tools: getActiveTools(options.sandbox, options.sandboxAllow),
|
|
775
1006
|
provider,
|
|
776
1007
|
context,
|
|
777
1008
|
})) {
|
|
@@ -804,7 +1035,7 @@ async function runServer(options) {
|
|
|
804
1035
|
for await (const event of agentLoop({
|
|
805
1036
|
model,
|
|
806
1037
|
messages: reqMessages,
|
|
807
|
-
tools:
|
|
1038
|
+
tools: getActiveTools(options.sandbox, options.sandboxAllow),
|
|
808
1039
|
provider,
|
|
809
1040
|
context,
|
|
810
1041
|
})) {
|
|
@@ -878,7 +1109,7 @@ async function runServer(options) {
|
|
|
878
1109
|
for await (const event of agentLoop({
|
|
879
1110
|
model,
|
|
880
1111
|
messages,
|
|
881
|
-
tools:
|
|
1112
|
+
tools: getActiveTools(options.sandbox, options.sandboxAllow),
|
|
882
1113
|
provider,
|
|
883
1114
|
context,
|
|
884
1115
|
})) {
|
|
@@ -1265,6 +1496,62 @@ async function runDoctor(options) {
|
|
|
1265
1496
|
console.log();
|
|
1266
1497
|
}
|
|
1267
1498
|
|
|
1499
|
+
/**
|
|
1500
|
+
* List available models from the provider's API.
|
|
1501
|
+
*
|
|
1502
|
+
* @param {Object} options - CLI options
|
|
1503
|
+
*/
|
|
1504
|
+
async function runModels(options) {
|
|
1505
|
+
await loadModules();
|
|
1506
|
+
const base = AI_PROVIDERS[options.provider];
|
|
1507
|
+
if (!base) {
|
|
1508
|
+
console.error(`Unknown provider: ${options.provider}`);
|
|
1509
|
+
process.exit(1);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
const apiKey = process.env[base.envKey];
|
|
1513
|
+
if (!apiKey && options.provider !== 'ollama') {
|
|
1514
|
+
console.error(`Missing ${base.envKey} — set it or run dotbot to configure interactively.`);
|
|
1515
|
+
process.exit(1);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
process.stdout.write('Fetching models');
|
|
1519
|
+
startSpinner();
|
|
1520
|
+
|
|
1521
|
+
const { ok, models } = await fetchProviderModels(options.provider, apiKey);
|
|
1522
|
+
|
|
1523
|
+
if (!ok) {
|
|
1524
|
+
stopSpinner('failed');
|
|
1525
|
+
console.error(`Could not fetch models from ${base.name}. Check your API key.`);
|
|
1526
|
+
|
|
1527
|
+
// Fall back to static list
|
|
1528
|
+
if (base.models?.length) {
|
|
1529
|
+
console.log(`\nLocal model list (${base.name}):\n`);
|
|
1530
|
+
for (const m of base.models) {
|
|
1531
|
+
const active = m.id === options.model ? ' (active)' : '';
|
|
1532
|
+
console.log(` ${m.id}${active}`);
|
|
1533
|
+
}
|
|
1534
|
+
console.log();
|
|
1535
|
+
}
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
stopSpinner('');
|
|
1540
|
+
|
|
1541
|
+
if (options.json) {
|
|
1542
|
+
console.log(JSON.stringify(models));
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
console.log(`\n${base.name} models (${models.length})\n`);
|
|
1547
|
+
for (const m of models) {
|
|
1548
|
+
const active = m.id === options.model ? ' (active)' : '';
|
|
1549
|
+
const label = m.name !== m.id ? ` — ${m.name}` : '';
|
|
1550
|
+
console.log(` ${m.id}${label}${active}`);
|
|
1551
|
+
}
|
|
1552
|
+
console.log();
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1268
1555
|
/**
|
|
1269
1556
|
* Main entry point.
|
|
1270
1557
|
*/
|
|
@@ -1297,6 +1584,9 @@ async function main() {
|
|
|
1297
1584
|
}
|
|
1298
1585
|
|
|
1299
1586
|
switch (command) {
|
|
1587
|
+
case 'models':
|
|
1588
|
+
await runModels(args);
|
|
1589
|
+
break;
|
|
1300
1590
|
case 'doctor':
|
|
1301
1591
|
await runDoctor(args);
|
|
1302
1592
|
break;
|
package/core/agent.js
CHANGED
|
@@ -31,7 +31,7 @@ const OLLAMA_BASE = "http://localhost:11434";
|
|
|
31
31
|
* @param {Array} options.tools - Tool definitions from tools.js
|
|
32
32
|
* @param {AbortSignal} [options.signal] - Optional abort signal
|
|
33
33
|
* @param {Object} [options.provider] - Provider config from AI_PROVIDERS. Defaults to Ollama.
|
|
34
|
-
* @param {Object} [options.context] - Execution context passed to tool execute functions (e.g.
|
|
34
|
+
* @param {Object} [options.context] - Execution context passed to tool execute functions (e.g. providers, userID).
|
|
35
35
|
* @yields {Object} Stream events for the frontend
|
|
36
36
|
*/
|
|
37
37
|
export async function* agentLoop({ model, messages, tools, signal, provider, context, maxTurns }) {
|