agent-nuvira 1.75.2 → 1.76.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.
@@ -1854,7 +1854,7 @@ let chatConsole = new ChatConsole({ persistPath: join(MEMORY_DIR, 'chat-sessions
1854
1854
  // the picker; both are in-memory (the workspace store records repo ids, not
1855
1855
  // local paths — this is the dashboard's own recency).
1856
1856
  const projectBundleCache = new Map();
1857
- const recentProjects = new Set([process.cwd()]);
1857
+ const recentProjects = new Set();
1858
1858
  /** Get (or build) the cached bundle for a project path; null when invalid. */
1859
1859
  function getProjectBundle(path) {
1860
1860
  try {
@@ -2654,8 +2654,10 @@ function handleRequest(req, res) {
2654
2654
  // WhatsApp contacts are synced into the bridge contacts file for
2655
2655
  // send-by-name parity with `buff whatsapp contact add <Name> <no>`.
2656
2656
  let savedContacts;
2657
+ const contactErrors = [];
2657
2658
  if (Array.isArray(body?.contacts)) {
2658
2659
  const { writeGatewayContacts, syncWhatsAppContactName } = await import('../gateway/contacts.js');
2660
+ const { validateContactId } = await import('../gateway/contacts.js');
2659
2661
  const valid = body.contacts
2660
2662
  .filter((c) => !!c && typeof c === 'object' &&
2661
2663
  typeof c.name === 'string' &&
@@ -2667,7 +2669,16 @@ function handleRequest(req, res) {
2667
2669
  id: (c.id ?? '').trim(),
2668
2670
  addedAt: typeof c.addedAt === 'number' ? c.addedAt : Date.now(),
2669
2671
  }))
2670
- .filter((c) => c.name && c.id && c.platform in PLATFORM_ENV_VARS);
2672
+ .filter((c) => {
2673
+ if (!c.name || !c.id || !(c.platform in PLATFORM_ENV_VARS))
2674
+ return false;
2675
+ const err = validateContactId(c.platform, c.id);
2676
+ if (err) {
2677
+ contactErrors.push(`${c.name || c.id} (${c.platform}): ${err}`);
2678
+ return false;
2679
+ }
2680
+ return true;
2681
+ });
2671
2682
  savedContacts = valid;
2672
2683
  writeGatewayContacts(savedContacts);
2673
2684
  // Send-by-name parity: named whatsapp contacts land in the bridge
@@ -2677,7 +2688,13 @@ function handleRequest(req, res) {
2677
2688
  syncWhatsAppContactName(c.name, c.id);
2678
2689
  }
2679
2690
  }
2680
- writeJson(res, 200, { ok: true, policies: merged, statusRecipients: gatewayPatch.statusRecipients ?? [], contacts: savedContacts });
2691
+ writeJson(res, 200, {
2692
+ ok: true,
2693
+ policies: merged,
2694
+ statusRecipients: gatewayPatch.statusRecipients ?? [],
2695
+ contacts: savedContacts,
2696
+ ...(contactErrors.length > 0 ? { contactErrors } : {}),
2697
+ });
2681
2698
  return;
2682
2699
  }
2683
2700
  writeJson(res, 405, { ok: false, error: 'Method not allowed — use GET or PUT.' });
@@ -3362,9 +3379,34 @@ function handleRequest(req, res) {
3362
3379
  return;
3363
3380
  }
3364
3381
  const canWrite = roleCan(session.role, 'routing.operate');
3382
+ // Per-platform setup documentation links and one-line hints.
3383
+ const SETUP_META = {
3384
+ telegram: { url: 'https://core.telegram.org/bots#how-do-i-create-a-bot', hint: 'Create a bot via @BotFather on Telegram, copy the token, paste it here, then run `buff gateway start`. Note: contacts need the Telegram chat ID (numeric), not a phone number — message the bot first to get the ID.' },
3385
+ discord: { url: 'https://discord.com/developers/applications', hint: 'Create an application + bot in the Discord Developer Portal, copy the bot token, paste it here, then run `buff gateway start`.' },
3386
+ slack: { url: 'https://api.slack.com/apps', hint: 'Create a Slack app with Bot Token Scopes, install to workspace, copy the Bot Token, paste it here, then run `buff gateway start`.' },
3387
+ whatsapp_cloud: { url: 'https://developers.facebook.com/docs/whatsapp/cloud-api/get-started', hint: 'Set up a Meta Cloud API phone number, paste the token + Phone ID here, then run `buff gateway start`.' },
3388
+ email: { url: '', hint: 'Enter your SMTP relay host and auth credentials. The agent replies to incoming emails.' },
3389
+ signal: { url: 'https://bbernhard.github.io/signal-cli-rest-api/', hint: 'Run signal-cli-rest-api, register an account, paste the account number here, then run `buff gateway start`.' },
3390
+ dingtalk: { url: 'https://open.dingtalk.com/', hint: 'Create a custom robot in a DingTalk group, copy the webhook URL, paste it here, then run `buff gateway start`.' },
3391
+ feishu: { url: 'https://open.feishu.cn/', hint: 'Create a bot in Feishu, copy the webhook URL, paste it here, then run `buff gateway start`.' },
3392
+ wecom: { url: 'https://open.work.weixin.qq.com/', hint: 'Create a group bot in WeCom, copy the webhook URL, paste it here, then run `buff gateway start`.' },
3393
+ mattermost: { url: 'https://developers.mattermost.com/', hint: 'Create an incoming webhook in Mattermost, copy the URL, paste it here, then run `buff gateway start`.' },
3394
+ matrix: { url: 'https://spec.matrix.org/', hint: 'Enter your Matrix homeserver URL and an access token (from a bot user), then run `buff gateway start`.' },
3395
+ webhook: { url: '', hint: 'Enter a generic webhook URL. The agent sends outbound webhooks to this URL.' },
3396
+ bluebubbles: { url: 'https://docs.bluebubbles.io/', hint: 'Set up BlueBubbles server, paste the URL + password here, then run `buff gateway start`.' },
3397
+ ntfy: { url: 'https://ntfy.sh/', hint: 'Enter an ntfy topic. The agent publishes to this topic (default server: ntfy.sh).' },
3398
+ teams: { url: 'https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook', hint: 'Create an incoming webhook in Microsoft Teams, paste the URL here, then run `buff gateway start`.' },
3399
+ google_chat: { url: 'https://developers.google.com/workspace/chat/quickstart/webhooks', hint: 'Create a webhook in a Google Chat space, paste the URL here, then run `buff gateway start`.' },
3400
+ weixin: { url: '', hint: 'Enter your Weixin iLink bot token, then run `buff gateway start`.' },
3401
+ sms: { url: 'https://www.twilio.com/docs/messaging/quickstart/node', hint: 'Enter your Twilio Account SID, Auth Token, and sender phone number, then run `buff gateway start`.' },
3402
+ irc: { url: '', hint: 'Enter the IRC server host and optional port/nickname/channel settings, then run `buff gateway start`.' },
3403
+ simplex: { url: '', hint: 'Run the local simplex-chat daemon (ws://127.0.0.1:5225), paste the WebSocket URL here, then run `buff gateway start`.' },
3404
+ homeassistant: { url: 'https://developers.home-assistant.io/docs/auth_api/', hint: 'Create a long-lived access token in Home Assistant, paste it here, then run `buff gateway start`.' },
3405
+ };
3365
3406
  const platforms = configurablePlatforms().map((p) => {
3366
3407
  const st = platformConfigStatus(p);
3367
3408
  const meta = platformEnvVarMeta(p);
3409
+ const setup = SETUP_META[p];
3368
3410
  return {
3369
3411
  platform: p,
3370
3412
  label: st.label,
@@ -3379,6 +3421,7 @@ function handleRequest(req, res) {
3379
3421
  secret: m?.secret ?? false,
3380
3422
  };
3381
3423
  }),
3424
+ ...(setup ? { setupUrl: setup.url, setupHint: setup.hint } : {}),
3382
3425
  };
3383
3426
  });
3384
3427
  writeJson(res, 200, { ok: true, platforms });
@@ -3471,7 +3514,7 @@ function handleRequest(req, res) {
3471
3514
  return;
3472
3515
  }
3473
3516
  const projects = [...recentProjects]
3474
- .map((p) => ({ path: p, name: basename(p) || p, kind: p === process.cwd() ? 'cwd' : 'recent' }))
3517
+ .map((p) => ({ path: p, name: basename(p) || p, kind: 'recent' }))
3475
3518
  .filter((p) => existsSync(p.path) && statSync(p.path).isDirectory());
3476
3519
  writeJson(res, 200, { ok: true, projects });
3477
3520
  return;
@@ -3516,9 +3559,8 @@ function handleRequest(req, res) {
3516
3559
  })();
3517
3560
  return;
3518
3561
  }
3519
- // GET /api/browse?path=<dir> — list subdirectories of a path for the
3520
- // frontend folder browser (project picker). Returns directories + a flag
3521
- // indicating whether the path itself can be attached as a project.
3562
+ // GET /api/browse?path=<dir>&showDrives=1 — list subdirectories for the
3563
+ // frontend folder browser (project picker). showDrives=1 returns drive roots.
3522
3564
  if (pathname === '/api/browse' && req.method === 'GET') {
3523
3565
  const session = adminSessions.validate(bearerToken(req));
3524
3566
  if (!session) {
@@ -3529,22 +3571,68 @@ function handleRequest(req, res) {
3529
3571
  writeJson(res, 403, { ok: false, error: `Access denied — role '${session.role}' cannot browse directories.` });
3530
3572
  return;
3531
3573
  }
3532
- // Extract ?path= from the URL query string.
3533
3574
  const urlObj = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
3534
3575
  const rawPath = urlObj.searchParams.get('path') || '';
3576
+ const showDrives = urlObj.searchParams.get('showDrives') === '1';
3535
3577
  const dirPath = rawPath.trim();
3578
+ // Collect drive roots for the platform.
3579
+ const homeDir = homedir();
3580
+ const collectDrives = () => {
3581
+ const drives = [];
3582
+ const plat = process.platform;
3583
+ if (plat === 'win32') {
3584
+ drives.push({ name: '🏠 Home', path: homeDir, type: 'home' });
3585
+ for (const ch of 'CDEFGH'.split('')) {
3586
+ const dp = `${ch}:\\`;
3587
+ try {
3588
+ if (existsSync(dp))
3589
+ drives.push({ name: `${ch}:`, path: dp, type: 'drive' });
3590
+ }
3591
+ catch { /* skip */ }
3592
+ }
3593
+ }
3594
+ else if (plat === 'darwin') {
3595
+ drives.push({ name: '🏠 Home', path: homeDir, type: 'home' });
3596
+ try {
3597
+ const vols = readdirSync('/Volumes', { withFileTypes: true })
3598
+ .filter((e) => e.isDirectory())
3599
+ .map((e) => ({ name: `💾 ${e.name}`, path: `/Volumes/${e.name}`, type: 'drive' }));
3600
+ drives.push(...vols);
3601
+ }
3602
+ catch { /* /Volumes may not exist */ }
3603
+ drives.push({ name: ' Root /', path: '/', type: 'system' });
3604
+ }
3605
+ else {
3606
+ drives.push({ name: '🏠 Home', path: homeDir, type: 'home' });
3607
+ drives.push({ name: ' Root /', path: '/', type: 'system' });
3608
+ }
3609
+ return drives;
3610
+ };
3611
+ // No path → show home directory contents + drive bar.
3536
3612
  if (!dirPath) {
3537
- // No path given — start from the user's home directory.
3538
- const homeDir = homedir();
3539
3613
  try {
3540
3614
  const entries = readdirSync(homeDir, { withFileTypes: true })
3541
- .filter((e) => e.isDirectory())
3542
- .map((e) => ({ name: e.name, path: join(homeDir, e.name) }))
3543
- .slice(0, 50);
3544
- writeJson(res, 200, { ok: true, path: homeDir, entries, parent: null, isProject: existsSync(homeDir) && statSync(homeDir).isDirectory() });
3615
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
3616
+ .map((e) => {
3617
+ const fp = join(homeDir, e.name);
3618
+ let modified = 0;
3619
+ try {
3620
+ modified = statSync(fp).mtimeMs;
3621
+ }
3622
+ catch { /* skip */ }
3623
+ return { name: e.name, path: fp, isDir: true, modified };
3624
+ })
3625
+ .sort((a, b) => a.name.localeCompare(b.name))
3626
+ .slice(0, 100);
3627
+ writeJson(res, 200, {
3628
+ ok: true, path: homeDir, entries, parent: null,
3629
+ isProject: existsSync(homeDir) && statSync(homeDir).isDirectory(),
3630
+ drives: collectDrives(),
3631
+ breadcrumbs: [{ name: 'Home', path: homeDir }],
3632
+ });
3545
3633
  }
3546
3634
  catch {
3547
- writeJson(res, 200, { ok: true, path: homeDir, entries: [], parent: null, isProject: false });
3635
+ writeJson(res, 200, { ok: true, path: homeDir, entries: [], parent: null, isProject: false, drives: collectDrives(), breadcrumbs: [] });
3548
3636
  }
3549
3637
  return;
3550
3638
  }
@@ -3556,17 +3644,96 @@ function handleRequest(req, res) {
3556
3644
  try {
3557
3645
  const entries = readdirSync(target, { withFileTypes: true })
3558
3646
  .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
3559
- .map((e) => ({ name: e.name, path: join(target, e.name) }))
3647
+ .map((e) => {
3648
+ const fp = join(target, e.name);
3649
+ let modified = 0;
3650
+ try {
3651
+ modified = statSync(fp).mtimeMs;
3652
+ }
3653
+ catch { /* skip */ }
3654
+ return { name: e.name, path: fp, isDir: true, modified };
3655
+ })
3560
3656
  .sort((a, b) => a.name.localeCompare(b.name))
3561
3657
  .slice(0, 100);
3562
3658
  const parent = dirname(target);
3563
- writeJson(res, 200, { ok: true, path: target, entries, parent: parent !== target ? parent : null, isProject: true });
3659
+ const parts = target.split(/[\/]/).filter(Boolean);
3660
+ const breadcrumbs = [];
3661
+ let cum = target.startsWith('/') ? '/' : '';
3662
+ for (const part of parts) {
3663
+ cum = cum === '/' ? `/${part}` : `${cum}/${part}`;
3664
+ breadcrumbs.push({ name: part, path: cum });
3665
+ }
3666
+ writeJson(res, 200, {
3667
+ ok: true, path: target, entries,
3668
+ parent: parent !== target ? parent : null,
3669
+ isProject: true, breadcrumbs,
3670
+ });
3564
3671
  }
3565
3672
  catch {
3566
- writeJson(res, 200, { ok: true, path: target, entries: [], parent: dirname(target), isProject: true });
3673
+ writeJson(res, 200, { ok: true, path: target, entries: [], parent: dirname(target), isProject: true, breadcrumbs: [] });
3567
3674
  }
3568
3675
  return;
3569
3676
  }
3677
+ // POST /api/config/platforms/:platform/verify — verify a platform's token
3678
+ // by calling the platform's API (e.g. Telegram getMe, Discord /users/@me).
3679
+ const verifyMatch = /^\/api\/config\/platforms\/([^/]+)\/verify$/.exec(pathname);
3680
+ if (verifyMatch && req.method === 'POST') {
3681
+ void (async () => {
3682
+ const session = adminSessions.validate(bearerToken(req));
3683
+ if (!session) {
3684
+ writeJson(res, 401, { ok: false, error: 'Not authenticated — log in first.' });
3685
+ return;
3686
+ }
3687
+ if (!roleCan(session.role, 'routing.operate')) {
3688
+ writeJson(res, 403, { ok: false, error: 'Access denied.' });
3689
+ return;
3690
+ }
3691
+ const platform = verifyMatch[1];
3692
+ const body = await readJsonBody(req);
3693
+ const values = (body?.values ?? {});
3694
+ try {
3695
+ let result;
3696
+ if (platform === 'telegram') {
3697
+ const token = values.BUFF_TELEGRAM_TOKEN ?? '';
3698
+ if (!token) {
3699
+ writeJson(res, 400, { ok: false, error: 'No token provided.' });
3700
+ return;
3701
+ }
3702
+ const r = await fetch(`https://api.telegram.org/bot${token}/getMe`, { signal: AbortSignal.timeout(10_000) });
3703
+ const d = await r.json();
3704
+ result = d.ok && d.result ? { ok: true, info: `@${d.result.username} (${d.result.first_name})` } : { ok: false, error: `Telegram rejected the token: ${JSON.stringify(d)}` };
3705
+ }
3706
+ else if (platform === 'discord') {
3707
+ const token = values.BUFF_DISCORD_BOT_TOKEN ?? '';
3708
+ if (!token) {
3709
+ writeJson(res, 400, { ok: false, error: 'No token provided.' });
3710
+ return;
3711
+ }
3712
+ const r = await fetch('https://discord.com/api/v10/users/@me', { headers: { Authorization: `Bot ${token}` }, signal: AbortSignal.timeout(10_000) });
3713
+ const d = await r.json();
3714
+ result = d.username ? { ok: true, info: `@${d.username} (${d.id})` } : { ok: false, error: `Discord rejected the token: ${JSON.stringify(d)}` };
3715
+ }
3716
+ else if (platform === 'slack') {
3717
+ const token = values.BUFF_SLACK_BOT_TOKEN ?? '';
3718
+ if (!token) {
3719
+ writeJson(res, 400, { ok: false, error: 'No token provided.' });
3720
+ return;
3721
+ }
3722
+ const r = await fetch('https://slack.com/api/auth.test', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(10_000) });
3723
+ const d = await r.json();
3724
+ result = d.ok ? { ok: true, info: `${d.user} @ ${d.team}` } : { ok: false, error: `Slack rejected the token: ${JSON.stringify(d)}` };
3725
+ }
3726
+ else {
3727
+ result = { ok: false, error: `Verification not available for '${platform}' — save the token and test via the Gateway tab.` };
3728
+ }
3729
+ writeJson(res, 200, result);
3730
+ }
3731
+ catch (err) {
3732
+ writeJson(res, 200, { ok: false, error: `Verification failed: ${err instanceof Error ? err.message : String(err)}` });
3733
+ }
3734
+ })();
3735
+ return;
3736
+ }
3570
3737
  // GET /api/sessions — P4 session sidebar: past conversations (most recent
3571
3738
  // first) with title/preview/turnCount. Same auth as /api/chat (the sidebar
3572
3739
  // is part of the chat surface).