@harbinger-ai/harbinger 0.1.2 → 0.1.3
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/lib/chat/actions.js +397 -0
- package/lib/chat/components/agents-page.js +545 -0
- package/lib/chat/components/agents-page.jsx +571 -0
- package/lib/chat/components/app-sidebar.js +17 -1
- package/lib/chat/components/app-sidebar.jsx +19 -1
- package/lib/chat/components/icons.js +40 -0
- package/lib/chat/components/icons.jsx +42 -0
- package/lib/chat/components/index.js +2 -0
- package/lib/chat/components/mcp-page.js +383 -55
- package/lib/chat/components/mcp-page.jsx +404 -101
- package/lib/chat/components/settings-layout.js +3 -2
- package/lib/chat/components/settings-layout.jsx +2 -1
- package/lib/chat/components/settings-providers-page.js +337 -0
- package/lib/chat/components/settings-providers-page.jsx +410 -0
- package/lib/chat/components/settings-secrets-page.js +91 -66
- package/lib/chat/components/settings-secrets-page.jsx +83 -72
- package/lib/mcp/actions.js +120 -0
- package/lib/mcp/registry.js +164 -0
- package/package.json +1 -1
package/lib/chat/actions.js
CHANGED
|
@@ -271,6 +271,403 @@ export async function deleteApiKey() {
|
|
|
271
271
|
}
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
275
|
+
// LLM Provider actions
|
|
276
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Get all configured LLM providers from settings table.
|
|
280
|
+
* @returns {Promise<object>} — { anthropic: { apiKey, model, maxTokens }, ... }
|
|
281
|
+
*/
|
|
282
|
+
export async function getLlmProviders() {
|
|
283
|
+
await requireAuth();
|
|
284
|
+
try {
|
|
285
|
+
const { getDb } = await import('../db/index.js');
|
|
286
|
+
const { settings } = await import('../db/schema.js');
|
|
287
|
+
const { eq } = await import('drizzle-orm');
|
|
288
|
+
const db = getDb();
|
|
289
|
+
const rows = db.select().from(settings).where(eq(settings.type, 'llm_provider')).all();
|
|
290
|
+
const result = {};
|
|
291
|
+
for (const row of rows) {
|
|
292
|
+
try {
|
|
293
|
+
const val = JSON.parse(row.value);
|
|
294
|
+
// Mask the API key for client display
|
|
295
|
+
result[row.key] = { ...val, apiKey: val.apiKey ? val.apiKey : '' };
|
|
296
|
+
} catch {}
|
|
297
|
+
}
|
|
298
|
+
return result;
|
|
299
|
+
} catch (err) {
|
|
300
|
+
console.error('Failed to get LLM providers:', err);
|
|
301
|
+
return {};
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Save/update an LLM provider config.
|
|
307
|
+
*/
|
|
308
|
+
export async function saveLlmProvider(provider, config) {
|
|
309
|
+
await requireAuth();
|
|
310
|
+
try {
|
|
311
|
+
const { getDb } = await import('../db/index.js');
|
|
312
|
+
const { settings } = await import('../db/schema.js');
|
|
313
|
+
const { eq, and } = await import('drizzle-orm');
|
|
314
|
+
const fs = await import('fs');
|
|
315
|
+
const path = await import('path');
|
|
316
|
+
const db = getDb();
|
|
317
|
+
|
|
318
|
+
// If existing row, read current to preserve apiKey if not changing
|
|
319
|
+
const existing = db.select().from(settings)
|
|
320
|
+
.where(and(eq(settings.type, 'llm_provider'), eq(settings.key, provider)))
|
|
321
|
+
.get();
|
|
322
|
+
|
|
323
|
+
let merged = { ...config };
|
|
324
|
+
if (existing) {
|
|
325
|
+
const prev = JSON.parse(existing.value);
|
|
326
|
+
// If apiKey looks masked or empty, keep existing
|
|
327
|
+
if (!config.apiKey && prev.apiKey) merged.apiKey = prev.apiKey;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const value = JSON.stringify(merged);
|
|
331
|
+
if (existing) {
|
|
332
|
+
db.update(settings).set({ value }).where(and(eq(settings.type, 'llm_provider'), eq(settings.key, provider))).run();
|
|
333
|
+
} else {
|
|
334
|
+
db.insert(settings).values({ type: 'llm_provider', key: provider, value }).run();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Also write to .env for persistence across container restarts
|
|
338
|
+
const envMap = {
|
|
339
|
+
anthropic: { key: 'ANTHROPIC_API_KEY', model: 'LLM_MODEL', provider: 'anthropic' },
|
|
340
|
+
openai: { key: 'OPENAI_API_KEY', model: 'LLM_MODEL', provider: 'openai' },
|
|
341
|
+
google: { key: 'GOOGLE_API_KEY', model: 'LLM_MODEL', provider: 'google' },
|
|
342
|
+
custom: { key: 'CUSTOM_API_KEY', model: 'LLM_MODEL', provider: 'custom' },
|
|
343
|
+
};
|
|
344
|
+
const mapping = envMap[provider];
|
|
345
|
+
if (mapping && merged.apiKey) {
|
|
346
|
+
try {
|
|
347
|
+
const envPath = path.join(process.cwd(), '.env');
|
|
348
|
+
let envContent = '';
|
|
349
|
+
try { envContent = fs.readFileSync(envPath, 'utf8'); } catch {}
|
|
350
|
+
// Update or append the API key line
|
|
351
|
+
const keyPattern = new RegExp(`^${mapping.key}=.*$`, 'm');
|
|
352
|
+
if (keyPattern.test(envContent)) {
|
|
353
|
+
envContent = envContent.replace(keyPattern, `${mapping.key}=${merged.apiKey}`);
|
|
354
|
+
} else {
|
|
355
|
+
envContent += `\n${mapping.key}=${merged.apiKey}`;
|
|
356
|
+
}
|
|
357
|
+
fs.writeFileSync(envPath, envContent);
|
|
358
|
+
} catch (envErr) {
|
|
359
|
+
console.error('Failed to write .env:', envErr);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return { success: true };
|
|
364
|
+
} catch (err) {
|
|
365
|
+
console.error('Failed to save LLM provider:', err);
|
|
366
|
+
return { error: err.message };
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Set the active provider (writes to .env + settings).
|
|
372
|
+
*/
|
|
373
|
+
export async function setActiveProvider(provider, model) {
|
|
374
|
+
await requireAuth();
|
|
375
|
+
try {
|
|
376
|
+
const { getDb } = await import('../db/index.js');
|
|
377
|
+
const { settings } = await import('../db/schema.js');
|
|
378
|
+
const { eq, and } = await import('drizzle-orm');
|
|
379
|
+
const fs = await import('fs');
|
|
380
|
+
const path = await import('path');
|
|
381
|
+
const db = getDb();
|
|
382
|
+
|
|
383
|
+
const value = JSON.stringify({ provider, model });
|
|
384
|
+
const existing = db.select().from(settings)
|
|
385
|
+
.where(and(eq(settings.type, 'llm_config'), eq(settings.key, 'active_provider')))
|
|
386
|
+
.get();
|
|
387
|
+
|
|
388
|
+
if (existing) {
|
|
389
|
+
db.update(settings).set({ value }).where(and(eq(settings.type, 'llm_config'), eq(settings.key, 'active_provider'))).run();
|
|
390
|
+
} else {
|
|
391
|
+
db.insert(settings).values({ type: 'llm_config', key: 'active_provider', value }).run();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Write to .env
|
|
395
|
+
try {
|
|
396
|
+
const envPath = path.join(process.cwd(), '.env');
|
|
397
|
+
let envContent = '';
|
|
398
|
+
try { envContent = fs.readFileSync(envPath, 'utf8'); } catch {}
|
|
399
|
+
|
|
400
|
+
const updates = { LLM_PROVIDER: provider, LLM_MODEL: model || '' };
|
|
401
|
+
for (const [envKey, envVal] of Object.entries(updates)) {
|
|
402
|
+
const pat = new RegExp(`^${envKey}=.*$`, 'm');
|
|
403
|
+
if (pat.test(envContent)) {
|
|
404
|
+
envContent = envContent.replace(pat, `${envKey}=${envVal}`);
|
|
405
|
+
} else {
|
|
406
|
+
envContent += `\n${envKey}=${envVal}`;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
fs.writeFileSync(envPath, envContent);
|
|
410
|
+
} catch {}
|
|
411
|
+
|
|
412
|
+
// Update process.env for immediate effect
|
|
413
|
+
process.env.LLM_PROVIDER = provider;
|
|
414
|
+
if (model) process.env.LLM_MODEL = model;
|
|
415
|
+
|
|
416
|
+
return { success: true };
|
|
417
|
+
} catch (err) {
|
|
418
|
+
console.error('Failed to set active provider:', err);
|
|
419
|
+
return { error: err.message };
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Test an LLM provider connection by sending a simple message.
|
|
425
|
+
*/
|
|
426
|
+
export async function testLlmConnection(provider) {
|
|
427
|
+
await requireAuth();
|
|
428
|
+
try {
|
|
429
|
+
const { getDb } = await import('../db/index.js');
|
|
430
|
+
const { settings } = await import('../db/schema.js');
|
|
431
|
+
const { eq, and } = await import('drizzle-orm');
|
|
432
|
+
const db = getDb();
|
|
433
|
+
|
|
434
|
+
const row = db.select().from(settings)
|
|
435
|
+
.where(and(eq(settings.type, 'llm_provider'), eq(settings.key, provider)))
|
|
436
|
+
.get();
|
|
437
|
+
|
|
438
|
+
if (!row) return { error: 'Provider not configured' };
|
|
439
|
+
const config = JSON.parse(row.value);
|
|
440
|
+
if (!config.apiKey) return { error: 'No API key configured' };
|
|
441
|
+
|
|
442
|
+
// Simple connectivity test using fetch
|
|
443
|
+
const endpoints = {
|
|
444
|
+
anthropic: 'https://api.anthropic.com/v1/messages',
|
|
445
|
+
openai: 'https://api.openai.com/v1/chat/completions',
|
|
446
|
+
google: `https://generativelanguage.googleapis.com/v1beta/models/${config.model || 'gemini-2.0-flash'}:generateContent?key=${config.apiKey}`,
|
|
447
|
+
custom: (config.baseUrl || 'https://api.openai.com/v1') + '/chat/completions',
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
if (provider === 'anthropic') {
|
|
451
|
+
const res = await fetch(endpoints.anthropic, {
|
|
452
|
+
method: 'POST',
|
|
453
|
+
headers: {
|
|
454
|
+
'Content-Type': 'application/json',
|
|
455
|
+
'x-api-key': config.apiKey,
|
|
456
|
+
'anthropic-version': '2023-06-01',
|
|
457
|
+
},
|
|
458
|
+
body: JSON.stringify({
|
|
459
|
+
model: config.model || 'claude-sonnet-4-20250514',
|
|
460
|
+
max_tokens: 32,
|
|
461
|
+
messages: [{ role: 'user', content: 'Reply with only the word "connected"' }],
|
|
462
|
+
}),
|
|
463
|
+
});
|
|
464
|
+
if (!res.ok) {
|
|
465
|
+
const body = await res.text();
|
|
466
|
+
return { error: `HTTP ${res.status}: ${body.slice(0, 200)}` };
|
|
467
|
+
}
|
|
468
|
+
const data = await res.json();
|
|
469
|
+
return { success: true, response: data.content?.[0]?.text || 'OK' };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (provider === 'openai' || provider === 'custom') {
|
|
473
|
+
const res = await fetch(endpoints[provider], {
|
|
474
|
+
method: 'POST',
|
|
475
|
+
headers: {
|
|
476
|
+
'Content-Type': 'application/json',
|
|
477
|
+
'Authorization': `Bearer ${config.apiKey}`,
|
|
478
|
+
},
|
|
479
|
+
body: JSON.stringify({
|
|
480
|
+
model: config.model || 'gpt-4o-mini',
|
|
481
|
+
max_tokens: 32,
|
|
482
|
+
messages: [{ role: 'user', content: 'Reply with only the word "connected"' }],
|
|
483
|
+
}),
|
|
484
|
+
});
|
|
485
|
+
if (!res.ok) {
|
|
486
|
+
const body = await res.text();
|
|
487
|
+
return { error: `HTTP ${res.status}: ${body.slice(0, 200)}` };
|
|
488
|
+
}
|
|
489
|
+
const data = await res.json();
|
|
490
|
+
return { success: true, response: data.choices?.[0]?.message?.content || 'OK' };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (provider === 'google') {
|
|
494
|
+
const res = await fetch(endpoints.google, {
|
|
495
|
+
method: 'POST',
|
|
496
|
+
headers: { 'Content-Type': 'application/json' },
|
|
497
|
+
body: JSON.stringify({
|
|
498
|
+
contents: [{ parts: [{ text: 'Reply with only the word "connected"' }] }],
|
|
499
|
+
generationConfig: { maxOutputTokens: 32 },
|
|
500
|
+
}),
|
|
501
|
+
});
|
|
502
|
+
if (!res.ok) {
|
|
503
|
+
const body = await res.text();
|
|
504
|
+
return { error: `HTTP ${res.status}: ${body.slice(0, 200)}` };
|
|
505
|
+
}
|
|
506
|
+
const data = await res.json();
|
|
507
|
+
return { success: true, response: data.candidates?.[0]?.content?.parts?.[0]?.text || 'OK' };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return { error: 'Unknown provider' };
|
|
511
|
+
} catch (err) {
|
|
512
|
+
return { error: err.message };
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Get the current active provider info.
|
|
518
|
+
*/
|
|
519
|
+
export async function getActiveProvider() {
|
|
520
|
+
await requireAuth();
|
|
521
|
+
try {
|
|
522
|
+
const { getDb } = await import('../db/index.js');
|
|
523
|
+
const { settings } = await import('../db/schema.js');
|
|
524
|
+
const { eq, and } = await import('drizzle-orm');
|
|
525
|
+
const db = getDb();
|
|
526
|
+
|
|
527
|
+
const row = db.select().from(settings)
|
|
528
|
+
.where(and(eq(settings.type, 'llm_config'), eq(settings.key, 'active_provider')))
|
|
529
|
+
.get();
|
|
530
|
+
|
|
531
|
+
if (row) return JSON.parse(row.value);
|
|
532
|
+
|
|
533
|
+
// Fallback: read from env
|
|
534
|
+
return {
|
|
535
|
+
provider: process.env.LLM_PROVIDER || 'anthropic',
|
|
536
|
+
model: process.env.LLM_MODEL || '',
|
|
537
|
+
};
|
|
538
|
+
} catch (err) {
|
|
539
|
+
return { provider: process.env.LLM_PROVIDER || 'anthropic', model: process.env.LLM_MODEL || '' };
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
544
|
+
// Agent management actions
|
|
545
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Get all agent profiles with their activity status from swarm.
|
|
549
|
+
*/
|
|
550
|
+
export async function getAgentProfilesWithStatus() {
|
|
551
|
+
await requireAuth();
|
|
552
|
+
try {
|
|
553
|
+
const { discoverAgents } = await import('../agents.js');
|
|
554
|
+
const agents = discoverAgents();
|
|
555
|
+
|
|
556
|
+
// Cross-reference with swarm
|
|
557
|
+
let runs = [];
|
|
558
|
+
try {
|
|
559
|
+
const { getSwarmStatus: fetchStatus } = await import('../tools/github.js');
|
|
560
|
+
const swarm = await fetchStatus(1);
|
|
561
|
+
runs = swarm.runs || [];
|
|
562
|
+
} catch {}
|
|
563
|
+
|
|
564
|
+
return agents.map((a) => {
|
|
565
|
+
const codename = (a.codename || a.name || a.id || '').toLowerCase();
|
|
566
|
+
const activeRuns = runs.filter(
|
|
567
|
+
(r) =>
|
|
568
|
+
(r.status === 'in_progress' || r.status === 'queued') &&
|
|
569
|
+
(r.branch || '').toLowerCase().includes(codename)
|
|
570
|
+
);
|
|
571
|
+
return {
|
|
572
|
+
...a,
|
|
573
|
+
activeJobs: activeRuns.length,
|
|
574
|
+
status: activeRuns.length > 0 ? 'active' : 'idle',
|
|
575
|
+
};
|
|
576
|
+
});
|
|
577
|
+
} catch (err) {
|
|
578
|
+
console.error('Failed to get agent profiles with status:', err);
|
|
579
|
+
return [];
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Get a single agent's full profile (including markdown file content).
|
|
585
|
+
*/
|
|
586
|
+
export async function getAgentProfile(agentId) {
|
|
587
|
+
await requireAuth();
|
|
588
|
+
try {
|
|
589
|
+
const { loadAgentProfile } = await import('../agents.js');
|
|
590
|
+
return loadAgentProfile(agentId);
|
|
591
|
+
} catch (err) {
|
|
592
|
+
console.error('Failed to get agent profile:', err);
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Update an agent's file content (SOUL.md, SKILLS.md, TOOLS.md, IDENTITY.md, CONFIG.yaml).
|
|
599
|
+
*/
|
|
600
|
+
export async function updateAgentFile(agentId, filename, content) {
|
|
601
|
+
await requireAuth();
|
|
602
|
+
try {
|
|
603
|
+
const fs = await import('fs');
|
|
604
|
+
const path = await import('path');
|
|
605
|
+
const { agentsDir } = await import('../paths.js');
|
|
606
|
+
const allowed = ['SOUL.md', 'SKILLS.md', 'TOOLS.md', 'IDENTITY.md', 'CONFIG.yaml', 'HEARTBEAT.md'];
|
|
607
|
+
if (!allowed.includes(filename)) return { error: 'File not allowed' };
|
|
608
|
+
const filePath = path.join(agentsDir, agentId, filename);
|
|
609
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
610
|
+
return { success: true };
|
|
611
|
+
} catch (err) {
|
|
612
|
+
console.error('Failed to update agent file:', err);
|
|
613
|
+
return { error: err.message };
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Create a new agent with identity files.
|
|
619
|
+
*/
|
|
620
|
+
export async function createAgent(identity) {
|
|
621
|
+
await requireAuth();
|
|
622
|
+
try {
|
|
623
|
+
const fs = await import('fs');
|
|
624
|
+
const path = await import('path');
|
|
625
|
+
const { agentsDir } = await import('../paths.js');
|
|
626
|
+
const dirName = (identity.name || identity.codename || 'new-agent').toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
627
|
+
const agentPath = path.join(agentsDir, dirName);
|
|
628
|
+
|
|
629
|
+
if (fs.existsSync(agentPath)) return { error: 'Agent directory already exists' };
|
|
630
|
+
fs.mkdirSync(agentPath, { recursive: true });
|
|
631
|
+
|
|
632
|
+
// IDENTITY.md
|
|
633
|
+
const identityContent = `Name: ${identity.name || dirName}\nCodename: ${identity.codename || dirName.toUpperCase()}\nRole: ${identity.role || 'General Agent'}\nSpecialization: ${identity.specialization || 'General'}`;
|
|
634
|
+
fs.writeFileSync(path.join(agentPath, 'IDENTITY.md'), identityContent, 'utf8');
|
|
635
|
+
|
|
636
|
+
// SOUL.md
|
|
637
|
+
const soulContent = identity.soul || `# ${identity.codename || dirName.toUpperCase()}\n\nYou are ${identity.name || dirName}, a specialized AI agent.\n\n## Role\n${identity.role || 'General Agent'}\n\n## Specialization\n${identity.specialization || 'General purpose tasks'}`;
|
|
638
|
+
fs.writeFileSync(path.join(agentPath, 'SOUL.md'), soulContent, 'utf8');
|
|
639
|
+
|
|
640
|
+
// CONFIG.yaml
|
|
641
|
+
const configContent = `codename: ${identity.codename || dirName.toUpperCase()}\nrole: ${identity.role || 'General Agent'}\nactive: true`;
|
|
642
|
+
fs.writeFileSync(path.join(agentPath, 'CONFIG.yaml'), configContent, 'utf8');
|
|
643
|
+
|
|
644
|
+
return { success: true, id: dirName };
|
|
645
|
+
} catch (err) {
|
|
646
|
+
console.error('Failed to create agent:', err);
|
|
647
|
+
return { error: err.message };
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Create a job for a specific agent.
|
|
653
|
+
*/
|
|
654
|
+
export async function createAgentJob(agentId, prompt) {
|
|
655
|
+
await requireAuth();
|
|
656
|
+
try {
|
|
657
|
+
const { createJob } = await import('../tools/github.js');
|
|
658
|
+
// Prefix the prompt with agent mention
|
|
659
|
+
const { loadAgentProfile } = await import('../agents.js');
|
|
660
|
+
const profile = loadAgentProfile(agentId);
|
|
661
|
+
const codename = profile?.codename || agentId;
|
|
662
|
+
const fullPrompt = `@${codename.toUpperCase()} ${prompt}`;
|
|
663
|
+
const result = await createJob(fullPrompt);
|
|
664
|
+
return { success: true, ...result };
|
|
665
|
+
} catch (err) {
|
|
666
|
+
console.error('Failed to create agent job:', err);
|
|
667
|
+
return { error: err.message };
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
274
671
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
275
672
|
// Swarm actions
|
|
276
673
|
// ─────────────────────────────────────────────────────────────────────────────
|