@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.
@@ -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
  // ─────────────────────────────────────────────────────────────────────────────