clementine-agent 1.9.0 → 1.10.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.
@@ -379,13 +379,20 @@ const AUTO_MEMORY_PROMPT = `You are a memory extraction agent. Your ONLY job is
379
379
 
380
380
  {current_memory}
381
381
 
382
+ ## Current User Model (already known — DO NOT re-extract these)
383
+
384
+ {current_user_model}
385
+
382
386
  ## Where to save what (memory routing):
383
387
 
384
388
  **Always-in-context core memory** (use the user_model tool — these stay top-of-mind in every future session):
385
389
  - **Lasting facts about ${OWNER}** (role, location, identifiers, durable preferences, communication style) → user_model(action="append", slot="user_facts", content=...)
386
390
  - **Active goals/intents** (what ${OWNER} is trying to accomplish right now) → user_model(action="append", slot="goals", content=...)
387
391
  - **Key people/projects** (recurring relationships) → user_model(action="append", slot="relationships", content=...)
388
- - Use action="replace" instead of "append" if you're updating an existing fact rather than adding a new one. Slots are capped at 2000 chars — older content rolls off on append.
392
+ - **DEFAULT to action="append"** it adds the new fact alongside what's already there.
393
+ - Only use action="replace" when CORRECTING an existing fact, and you MUST include the FULL slot content (everything from "Current User Model" above, with the correction applied). \`replace\` overwrites the entire slot — passing only the new fact wipes everything else.
394
+ - Never use action="clear" from this extractor. Clearing is a deliberate user action, not a memory-extraction outcome.
395
+ - Slots are capped at 2000 chars — older content rolls off on append automatically.
389
396
 
390
397
  **Vault notes** (use memory_write/note_create — durable but retrieved on demand):
391
398
  - **People mentioned** — names, relationships, context → create or update person notes in 02-People/
@@ -1632,12 +1639,13 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1632
1639
  };
1633
1640
  }
1634
1641
  // ── Build SDK Options ─────────────────────────────────────────────
1635
- buildOptions(opts = {}) {
1642
+ async buildOptions(opts = {}) {
1636
1643
  const { isHeartbeat = false, cronTier = null, maxTurns = null, model = null, enableTeams = true, retrievalContext = '', profile = null, sessionKey = null, streaming = false, isPlanStep = false, isUnleashed = false, sourceOverride, disableAllTools = false, verboseLevel, abortController, effort, maxBudgetUsd, thinking, outputFormat, stallGuard, intentClassification, } = opts;
1637
1644
  let allowedTools = [
1638
1645
  'Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep',
1639
1646
  'WebSearch', 'WebFetch',
1640
1647
  mcpTool('working_memory'),
1648
+ mcpTool('user_model'),
1641
1649
  mcpTool('memory_read'),
1642
1650
  mcpTool('memory_write'),
1643
1651
  mcpTool('memory_search'),
@@ -1920,6 +1928,22 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1920
1928
  }
1921
1929
  }
1922
1930
  catch { /* non-fatal — run with just Clementine's own server */ }
1931
+ // Composio toolkits — each connected toolkit becomes an in-process MCP
1932
+ // server (mcp__gmail__*, mcp__slack__*, …). Profile-level allowlist
1933
+ // (profile.allowedComposioToolkits) constrains which toolkits this agent
1934
+ // sees; omit it to surface every active connection.
1935
+ let composioMcpServers = {};
1936
+ if (!disableAllTools && !isPlanStep) {
1937
+ try {
1938
+ const { buildComposioMcpServers } = await import('../integrations/composio/mcp-bridge.js');
1939
+ const allowList = profile?.allowedComposioToolkits;
1940
+ composioMcpServers = await buildComposioMcpServers(allowList);
1941
+ }
1942
+ catch (err) {
1943
+ // Composio is purely additive — never block the agent if it fails.
1944
+ logger.debug({ err }, 'Composio MCP servers unavailable');
1945
+ }
1946
+ }
1923
1947
  // Permission mode: always 'bypassPermissions' — this is a daemon/harness with no interactive
1924
1948
  // terminal, so 'auto' mode (which requires plan support + human approval) doesn't apply.
1925
1949
  const effectivePermissionMode = 'bypassPermissions';
@@ -1965,6 +1989,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1965
1989
  },
1966
1990
  },
1967
1991
  ...externalMcpServers,
1992
+ ...composioMcpServers,
1968
1993
  },
1969
1994
  ...(abortController ? { abortController } : {}),
1970
1995
  maxTurns: effectiveMaxTurns,
@@ -2594,7 +2619,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2594
2619
  let contradictionRetried = false;
2595
2620
  try {
2596
2621
  for (let attempt = 0; attempt <= PersonalAssistant.RATE_LIMIT_MAX_RETRIES; attempt++) {
2597
- const sdkOptions = this.buildOptions({ model, maxTurns: maxTurnsOverride ?? null, retrievalContext, profile, sessionKey, streaming: !!onText, verboseLevel, abortController, stallGuard, intentClassification, effort: intentClassification?.suggestedEffort });
2622
+ const sdkOptions = await this.buildOptions({ model, maxTurns: maxTurnsOverride ?? null, retrievalContext, profile, sessionKey, streaming: !!onText, verboseLevel, abortController, stallGuard, intentClassification, effort: intentClassification?.suggestedEffort });
2598
2623
  // If a project matched, switch cwd so the agent gets its tools/CLAUDE.md
2599
2624
  if (matchedProject) {
2600
2625
  sdkOptions.cwd = matchedProject.path;
@@ -3683,10 +3708,23 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3683
3708
  // Non-fatal — proceed without corrections
3684
3709
  }
3685
3710
  }
3711
+ // Render current user_model state so the extractor can: (a) skip
3712
+ // re-extracting facts already there, (b) safely use action="replace"
3713
+ // by passing the full slot content with a correction applied. Scoped
3714
+ // to the active agent — Clementine sees global slots, hired agents
3715
+ // see their own per-agent slots.
3716
+ let currentUserModel = '(empty — no slots populated yet)';
3717
+ try {
3718
+ const rendered = this.memoryStore?.renderUserModel?.(profile?.slug ?? null);
3719
+ if (rendered && rendered.trim())
3720
+ currentUserModel = rendered;
3721
+ }
3722
+ catch { /* non-fatal */ }
3686
3723
  const memPrompt = AUTO_MEMORY_PROMPT
3687
3724
  .replace('{user_message}', userMessage)
3688
3725
  .replace('{assistant_response}', truncatedResponse)
3689
3726
  .replace('{current_memory}', currentMemory || '(empty — no existing memory yet)')
3727
+ .replace('{current_user_model}', currentUserModel)
3690
3728
  .replace('{recent_corrections}', correctionsText);
3691
3729
  const userMessageSnippet = userMessage.slice(0, 500);
3692
3730
  const stream = query({
@@ -3706,6 +3744,13 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3706
3744
  mcpTool('task_add'),
3707
3745
  mcpTool('note_take'),
3708
3746
  mcpTool('memory_read'),
3747
+ // Auto-extractor needs user_model to populate the always-in-context
3748
+ // core slots (user_facts, goals, relationships, agent_persona).
3749
+ // The MCP server boots with CLEMENTINE_TEAM_AGENT=<slug>, so writes
3750
+ // are scoped to the active agent automatically — Clementine's
3751
+ // sessions populate global slots, hired-agent sessions populate
3752
+ // that agent's per-agent slots.
3753
+ mcpTool('user_model'),
3709
3754
  ],
3710
3755
  mcpServers: {
3711
3756
  [TOOLS_SERVER]: {
@@ -3823,7 +3868,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3823
3868
  // ── Heartbeat / Cron ──────────────────────────────────────────────
3824
3869
  async heartbeat(standingInstructions, changesSummary = '', timeContext = '', dedupContext = '', profile) {
3825
3870
  setInteractionSource('autonomous');
3826
- const sdkOptions = this.buildOptions({
3871
+ const sdkOptions = await this.buildOptions({
3827
3872
  isHeartbeat: true,
3828
3873
  enableTeams: false,
3829
3874
  model: MODELS.haiku,
@@ -3893,7 +3938,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3893
3938
  // Don't mutate the global — pass source through the closure instead
3894
3939
  // Per-step stall guard so concurrent steps don't cross-contaminate
3895
3940
  const stepGuard = new StallGuard();
3896
- const sdkOptions = this.buildOptions({
3941
+ const sdkOptions = await this.buildOptions({
3897
3942
  isHeartbeat: false,
3898
3943
  cronTier: tier,
3899
3944
  maxTurns,
@@ -3958,7 +4003,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3958
4003
  // not chat text — pass mode='cron' so high_effort_low_output guard is
3959
4004
  // disabled. Loop detection and circular-reasoning checks stay active.
3960
4005
  const cronGuard = new StallGuard('cron');
3961
- const sdkOptions = this.buildOptions({
4006
+ const sdkOptions = await this.buildOptions({
3962
4007
  isHeartbeat: true,
3963
4008
  cronTier: tier,
3964
4009
  maxTurns: maxTurns ?? (tier >= 2 ? 30 : 15),
@@ -4445,7 +4490,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
4445
4490
  _setActiveQueryContext({ job: jobName, source: 'unleashed', agentSlug });
4446
4491
  // Unleashed phases run side-effect-heavy work; same logic as cron mode.
4447
4492
  const phaseGuard = new StallGuard('unleashed');
4448
- const sdkOptions = this.buildOptions({
4493
+ const sdkOptions = await this.buildOptions({
4449
4494
  isHeartbeat: true,
4450
4495
  cronTier: tier,
4451
4496
  maxTurns: turnsPerPhase,
@@ -4802,7 +4847,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
4802
4847
  logger.info({ taskName, phase }, `Team task: starting phase ${phase}`);
4803
4848
  setInteractionSource('autonomous');
4804
4849
  const teamGuard = new StallGuard();
4805
- const sdkOptions = this.buildOptions({
4850
+ const sdkOptions = await this.buildOptions({
4806
4851
  isHeartbeat: true,
4807
4852
  cronTier: 2, // Give full tool access (Bash, Write, Edit)
4808
4853
  maxTurns: turnsPerPhase,
@@ -4386,6 +4386,132 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
4386
4386
  res.status(500).json({ error: String(err) });
4387
4387
  }
4388
4388
  });
4389
+ // ── Composio (1000+ third-party services via OAuth broker) ────
4390
+ app.get('/api/composio/status', (_req, res) => {
4391
+ res.json({ enabled: Boolean(process.env.COMPOSIO_API_KEY) });
4392
+ });
4393
+ app.get('/api/composio/toolkits', async (_req, res) => {
4394
+ try {
4395
+ if (!process.env.COMPOSIO_API_KEY) {
4396
+ res.json({ enabled: false, toolkits: [] });
4397
+ return;
4398
+ }
4399
+ const c = await import('../integrations/composio/client.js');
4400
+ const [connected, configured, meta] = await Promise.all([
4401
+ c.listConnectedToolkits(),
4402
+ c.listToolkitSlugsWithAuthConfig(),
4403
+ c.listToolkitMeta(),
4404
+ ]);
4405
+ const connectionsBySlug = new Map();
4406
+ for (const conn of connected) {
4407
+ const arr = connectionsBySlug.get(conn.slug) ?? [];
4408
+ arr.push(conn);
4409
+ connectionsBySlug.set(conn.slug, arr);
4410
+ }
4411
+ for (const arr of connectionsBySlug.values()) {
4412
+ arr.sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''));
4413
+ }
4414
+ const toView = (conn) => ({
4415
+ id: conn.connectionId,
4416
+ status: conn.status,
4417
+ alias: conn.alias ?? null,
4418
+ accountLabel: conn.accountLabel ?? null,
4419
+ accountEmail: conn.accountEmail ?? null,
4420
+ accountName: conn.accountName ?? null,
4421
+ accountAvatarUrl: conn.accountAvatarUrl ?? null,
4422
+ createdAt: conn.createdAt ?? null,
4423
+ });
4424
+ const curated = c.CURATED_TOOLKITS.map(t => {
4425
+ const m = meta.get(t.slug);
4426
+ const conns = connectionsBySlug.get(t.slug) ?? [];
4427
+ return {
4428
+ slug: t.slug,
4429
+ displayName: t.displayName,
4430
+ authMode: t.authMode,
4431
+ hasAuthConfig: configured.has(t.slug),
4432
+ logoUrl: m?.logo ?? null,
4433
+ description: m?.description ?? null,
4434
+ toolCount: m?.toolsCount ?? null,
4435
+ connections: conns.map(toView),
4436
+ };
4437
+ });
4438
+ const extras = [...connectionsBySlug.entries()]
4439
+ .filter(([slug]) => !c.CURATED_TOOLKITS.some(t => t.slug === slug))
4440
+ .map(([slug, conns]) => {
4441
+ const m = meta.get(slug);
4442
+ // Non-curated: infer auth mode. If a custom auth config exists,
4443
+ // user set it up themselves (BYO). Otherwise it must be managed.
4444
+ const authMode = configured.has(slug) ? 'byo' : 'managed';
4445
+ return {
4446
+ slug,
4447
+ displayName: m?.name ?? c.displayNameFor(slug),
4448
+ authMode,
4449
+ hasAuthConfig: configured.has(slug),
4450
+ logoUrl: m?.logo ?? null,
4451
+ description: m?.description ?? null,
4452
+ toolCount: m?.toolsCount ?? null,
4453
+ connections: conns.map(toView),
4454
+ };
4455
+ });
4456
+ res.json({ enabled: true, toolkits: [...curated, ...extras] });
4457
+ }
4458
+ catch (err) {
4459
+ res.status(500).json({ error: String(err) });
4460
+ }
4461
+ });
4462
+ app.post('/api/composio/toolkits/:slug/authorize', async (req, res) => {
4463
+ const slug = req.params.slug;
4464
+ const alias = typeof req.body?.alias === 'string' ? req.body.alias : undefined;
4465
+ try {
4466
+ const c = await import('../integrations/composio/client.js');
4467
+ const result = await c.authorizeToolkit(slug, alias ? { alias } : undefined);
4468
+ res.json(result);
4469
+ }
4470
+ catch (err) {
4471
+ const c = await import('../integrations/composio/client.js');
4472
+ if (err instanceof c.ComposioNeedsAuthConfigError) {
4473
+ res.status(409).json({
4474
+ error: err.message,
4475
+ needsAuthConfig: true,
4476
+ toolkit: slug,
4477
+ setupUrl: 'https://platform.composio.dev/auth-configs',
4478
+ });
4479
+ return;
4480
+ }
4481
+ res.status(500).json({ error: String(err) });
4482
+ }
4483
+ });
4484
+ app.post('/api/composio/toolkits/:slug/disconnect', async (req, res) => {
4485
+ const connectionId = typeof req.body?.connectionId === 'string' ? req.body.connectionId : '';
4486
+ if (!connectionId) {
4487
+ res.status(400).json({ error: 'connectionId required in body' });
4488
+ return;
4489
+ }
4490
+ try {
4491
+ const c = await import('../integrations/composio/client.js');
4492
+ await c.disconnectToolkit(connectionId);
4493
+ res.json({ ok: true });
4494
+ }
4495
+ catch (err) {
4496
+ res.status(500).json({ error: String(err) });
4497
+ }
4498
+ });
4499
+ app.post('/api/composio/connections/:id/rename', async (req, res) => {
4500
+ const id = req.params.id;
4501
+ const alias = typeof req.body?.alias === 'string' ? req.body.alias.trim() : '';
4502
+ if (!alias) {
4503
+ res.status(400).json({ error: 'alias required in body' });
4504
+ return;
4505
+ }
4506
+ try {
4507
+ const c = await import('../integrations/composio/client.js');
4508
+ await c.renameConnection(id, alias);
4509
+ res.json({ ok: true });
4510
+ }
4511
+ catch (err) {
4512
+ res.status(500).json({ error: String(err) });
4513
+ }
4514
+ });
4389
4515
  // ── CRON CRUD routes ──────────────────────────────────────────
4390
4516
  app.get('/api/projects', (_req, res) => {
4391
4517
  try {
@@ -4968,6 +5094,13 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
4968
5094
  { key: 'MS_USER_EMAIL', label: 'User Email', hint: 'Email address for mail/calendar access' },
4969
5095
  ],
4970
5096
  },
5097
+ {
5098
+ label: 'Composio',
5099
+ keys: [
5100
+ { key: 'COMPOSIO_API_KEY', label: 'API Key', hint: 'Get one at https://app.composio.dev → Developers. Unlocks 1000+ services (Gmail, Slack, Notion, …) for the agent.', type: 'password' },
5101
+ { key: 'COMPOSIO_USER_ID', label: 'User ID', hint: 'Optional — keys all connections under this Composio user. Defaults to "clementine-default".' },
5102
+ ],
5103
+ },
4971
5104
  {
4972
5105
  label: 'Salesforce',
4973
5106
  keys: [
@@ -5088,13 +5221,22 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
5088
5221
  const { setEnable1MContext } = await import('../config.js');
5089
5222
  setEnable1MContext(value.toLowerCase() === 'true');
5090
5223
  }
5224
+ // Composio: mutate process.env in-place + drop singleton so the very
5225
+ // next /api/composio/* call picks up the new key without a daemon
5226
+ // restart. Without this, "Save key → Connect Gmail" would 503 until
5227
+ // the user restarted, which defeats the dashboard-config UX.
5228
+ if (key === 'COMPOSIO_API_KEY' || key === 'COMPOSIO_USER_ID') {
5229
+ process.env[key] = value;
5230
+ const { resetComposioClient } = await import('../integrations/composio/client.js');
5231
+ resetComposioClient();
5232
+ }
5091
5233
  res.json({ ok: true, message: `Updated ${key}` });
5092
5234
  }
5093
5235
  catch (err) {
5094
5236
  res.status(500).json({ error: String(err) });
5095
5237
  }
5096
5238
  });
5097
- app.delete('/api/settings/:key', (req, res) => {
5239
+ app.delete('/api/settings/:key', async (req, res) => {
5098
5240
  try {
5099
5241
  const { key } = req.params;
5100
5242
  if (!existsSync(ENV_PATH)) {
@@ -5105,6 +5247,13 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
5105
5247
  const re = new RegExp(`^${key}=.*\n?`, 'm');
5106
5248
  content = content.replace(re, '');
5107
5249
  writeFileSync(ENV_PATH, content);
5250
+ // Hot-reload mirror of the PUT handler — drop process.env entry +
5251
+ // reset Composio client so removal takes effect without a restart.
5252
+ if (key === 'COMPOSIO_API_KEY' || key === 'COMPOSIO_USER_ID') {
5253
+ delete process.env[key];
5254
+ const { resetComposioClient } = await import('../integrations/composio/client.js');
5255
+ resetComposioClient();
5256
+ }
5108
5257
  res.json({ ok: true, message: `Removed ${key}` });
5109
5258
  }
5110
5259
  catch (err) {
@@ -12698,8 +12847,8 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
12698
12847
 
12699
12848
  <!-- User Model — MemGPT-style core memory blocks always loaded into context -->
12700
12849
  <div class="tab-pane" id="tab-intelligence-user-model">
12701
- <div style="color:var(--muted,#888);margin-bottom:12px;font-size:13px">
12702
- What the agent always knows about you. These slots load into every conversation's context (above retrieved memory). Edit directly to correct or steer.
12850
+ <div style="color:var(--muted,#888);margin-bottom:12px;font-size:13px;max-width:760px">
12851
+ <strong style="color:var(--text)">Always-in-context core memory.</strong> Four small slots (capped at 2000 chars each) that load into <em>every</em> conversation distinct from MEMORY.md and the chunk store. The agent appends here automatically as you talk; you can also edit directly to correct or steer. Use the Scope dropdown to view per-agent slots (each hired agent maintains their own).
12703
12852
  </div>
12704
12853
  <div style="display:flex;gap:8px;margin-bottom:12px;align-items:center;flex-wrap:wrap">
12705
12854
  <label style="font-size:13px;color:var(--text-secondary)">Scope:</label>
@@ -14325,6 +14474,15 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
14325
14474
  </p>
14326
14475
  </div>
14327
14476
  <div class="tab-pane" id="tab-settings-integrations">
14477
+ <div class="card" style="margin-bottom:20px">
14478
+ <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
14479
+ <span>Connections (via Composio)</span>
14480
+ <span style="font-size:11px;color:var(--text-muted)">1000+ services — OAuth handled by Composio</span>
14481
+ </div>
14482
+ <div class="card-body" style="padding:16px" id="composio-connections">
14483
+ <div class="empty-state">Loading...</div>
14484
+ </div>
14485
+ </div>
14328
14486
  <div class="card" style="margin-bottom:20px">
14329
14487
  <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
14330
14488
  <span>Claude Desktop Integrations</span>
@@ -15640,7 +15798,7 @@ function switchTab(group, tab) {
15640
15798
  if (tab === 'learning' && typeof refreshSelfImprove === 'function') refreshSelfImprove();
15641
15799
  }
15642
15800
  if (group === 'settings') {
15643
- if (tab === 'integrations') refreshSalesforce();
15801
+ if (tab === 'integrations') { refreshSalesforce(); refreshComposioConnections(); }
15644
15802
  if (tab === 'remote') refreshRemoteAccess();
15645
15803
  if (tab === 'security') refreshAuthSessions();
15646
15804
  if (tab === 'projects' && typeof refreshProjects === 'function') refreshProjects();
@@ -19160,7 +19318,20 @@ async function loadUserModel() {
19160
19318
  relationships: 'People, projects, channels they regularly interact with.',
19161
19319
  agent_persona: 'For multi-agent: this agent\\'s self-identity in its working relationship with the user.',
19162
19320
  };
19321
+ // First-run hint: when every slot is empty, show a single explainer
19322
+ // banner above the (still editable) textareas so the user understands
19323
+ // both what this is and how to populate it. Suppressed once anything
19324
+ // is in place — at that point the metadata on each card is enough.
19325
+ var allEmpty = d.blocks.every(function(b) { return !(b.content || '').trim(); });
19163
19326
  var html = '<div style="display:flex;flex-direction:column;gap:14px">';
19327
+ if (allEmpty) {
19328
+ html += '<div class="card" style="padding:14px;border-left:3px solid var(--accent,#f59e0b);background:var(--bg-input,#1a1a1a)">' +
19329
+ '<div style="font-weight:600;margin-bottom:6px">No core memory yet for this scope</div>' +
19330
+ '<div style="font-size:12px;color:var(--text-secondary);line-height:1.5">' +
19331
+ 'These slots auto-populate as you chat — the agent extracts durable facts about you (your role, active goals, recurring people/projects) and appends them after each exchange. ' +
19332
+ 'You can also seed from existing memory in one click, or type directly into any slot below and click Save.' +
19333
+ '</div></div>';
19334
+ }
19164
19335
  for (var i = 0; i < d.blocks.length; i++) {
19165
19336
  var b = d.blocks[i];
19166
19337
  var label = labelMap[b.slot] || b.slot;
@@ -25561,6 +25732,147 @@ async function refreshClaudeIntegrations(preloaded) {
25561
25732
  }
25562
25733
  }
25563
25734
 
25735
+ async function refreshComposioConnections() {
25736
+ var container = document.getElementById('composio-connections');
25737
+ if (!container) return;
25738
+ try {
25739
+ var r = await apiFetch('/api/composio/toolkits');
25740
+ var d = await r.json();
25741
+ if (!d.enabled) {
25742
+ container.innerHTML =
25743
+ '<div style="padding:8px 4px">' +
25744
+ '<div style="font-size:13px;margin-bottom:12px">Composio gives Clementine OAuth access to 1000+ services (Gmail, Slack, Notion, GitHub, …) from any channel — Telegram, Discord, dashboard chat. The agent never sees the credentials.</div>' +
25745
+ '<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">Get your free API key at <a href="https://app.composio.dev/developers" target="_blank" style="color:var(--accent)">app.composio.dev/developers</a>, then paste it here:</div>' +
25746
+ '<div style="display:flex;gap:8px;align-items:center">' +
25747
+ '<input type="password" id="composio-key-input" placeholder="cak_..." style="flex:1;padding:8px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-input);color:var(--text-primary);font-size:13px;font-family:monospace">' +
25748
+ '<button class="btn btn-sm btn-primary" onclick="saveComposioApiKey()" style="font-size:12px">Save & Activate</button>' +
25749
+ '</div>' +
25750
+ '<div id="composio-key-status" style="font-size:11px;color:var(--text-muted);margin-top:8px"></div>' +
25751
+ '</div>';
25752
+ var input = document.getElementById('composio-key-input');
25753
+ if (input) input.addEventListener('keydown', function(e) { if (e.key === 'Enter') saveComposioApiKey(); });
25754
+ return;
25755
+ }
25756
+ var toolkits = d.toolkits || [];
25757
+ if (toolkits.length === 0) {
25758
+ container.innerHTML = '<div class="empty-state" style="padding:16px">No toolkits available. Check that your Composio API key is valid.</div>';
25759
+ return;
25760
+ }
25761
+
25762
+ var html = '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px">';
25763
+ toolkits.forEach(function(t) {
25764
+ var connected = (t.connections || []).filter(function(c) { return c.status === 'ACTIVE'; });
25765
+ var pending = (t.connections || []).filter(function(c) { return c.status !== 'ACTIVE'; });
25766
+ var isConnected = connected.length > 0;
25767
+ var statusColor = isConnected ? 'var(--green)' : (pending.length > 0 ? 'var(--yellow,#f59e0b)' : 'var(--text-muted)');
25768
+ var byo = t.authMode === 'byo' && !t.hasAuthConfig;
25769
+
25770
+ html += '<div style="border:1px solid var(--border);border-radius:8px;padding:12px;background:var(--bg-secondary);display:flex;flex-direction:column;gap:8px">';
25771
+ html += '<div style="display:flex;align-items:center;gap:10px">';
25772
+ if (t.logoUrl) {
25773
+ html += '<img src="' + esc(t.logoUrl) + '" alt="" style="width:24px;height:24px;border-radius:4px;background:#fff;padding:2px;flex-shrink:0;object-fit:contain">';
25774
+ } else {
25775
+ html += '<div style="width:24px;height:24px;border-radius:4px;background:var(--bg-tertiary);flex-shrink:0"></div>';
25776
+ }
25777
+ html += '<div style="flex:1;min-width:0">';
25778
+ html += '<div style="font-size:13px;font-weight:600;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + esc(t.displayName) + '</div>';
25779
+ html += '<div style="font-size:10px;color:var(--text-muted)">' + (isConnected ? connected.length + ' account' + (connected.length !== 1 ? 's' : '') : 'Not connected') + '</div>';
25780
+ html += '</div>';
25781
+ html += '<span style="width:8px;height:8px;border-radius:50%;background:' + statusColor + ';flex-shrink:0"></span>';
25782
+ html += '</div>';
25783
+
25784
+ if (connected.length > 0) {
25785
+ html += '<div style="display:flex;flex-direction:column;gap:4px">';
25786
+ connected.forEach(function(c) {
25787
+ var label = c.alias || c.accountLabel || c.accountEmail || c.accountName || 'connected';
25788
+ html += '<div style="display:flex;align-items:center;gap:6px;font-size:11px">';
25789
+ html += '<span style="flex:1;color:var(--text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + esc(label) + '</span>';
25790
+ html += '<button class="btn-sm" onclick="disconnectComposio(\\'' + esc(t.slug) + '\\',\\'' + esc(c.id) + '\\')" style="font-size:10px;padding:2px 6px;background:transparent;border:1px solid var(--border);color:var(--text-muted);border-radius:4px;cursor:pointer">Disconnect</button>';
25791
+ html += '</div>';
25792
+ });
25793
+ html += '</div>';
25794
+ }
25795
+
25796
+ if (byo) {
25797
+ html += '<div style="font-size:10px;color:var(--yellow,#f59e0b);background:rgba(245,158,11,0.08);padding:6px 8px;border-radius:4px;line-height:1.4">Needs a BYO OAuth app — <a href="https://platform.composio.dev/auth-configs" target="_blank" style="color:inherit;text-decoration:underline">set up in Composio</a> first.</div>';
25798
+ }
25799
+
25800
+ html += '<button class="btn btn-sm btn-primary" onclick="connectComposio(\\'' + esc(t.slug) + '\\')" style="font-size:11px">' + (isConnected ? '+ Add another account' : 'Connect') + '</button>';
25801
+ html += '</div>';
25802
+ });
25803
+ html += '</div>';
25804
+ container.innerHTML = html;
25805
+ } catch (e) {
25806
+ container.innerHTML = '<div class="empty-state" style="color:var(--red);padding:16px">Failed to load Composio toolkits: ' + esc(String(e)) + '</div>';
25807
+ }
25808
+ }
25809
+
25810
+ async function saveComposioApiKey() {
25811
+ var input = document.getElementById('composio-key-input');
25812
+ var status = document.getElementById('composio-key-status');
25813
+ if (!input) return;
25814
+ var key = (input.value || '').trim();
25815
+ if (!key) { if (status) { status.textContent = 'Enter a key first.'; status.style.color = 'var(--red)'; } return; }
25816
+ if (status) { status.textContent = 'Saving…'; status.style.color = 'var(--text-muted)'; }
25817
+ try {
25818
+ await apiJson('PUT', '/api/settings/COMPOSIO_API_KEY', { value: key });
25819
+ if (status) { status.textContent = 'Saved. Loading toolkits…'; status.style.color = 'var(--green)'; }
25820
+ toast('Composio API key saved', 'success');
25821
+ setTimeout(refreshComposioConnections, 400);
25822
+ } catch (e) {
25823
+ if (status) { status.textContent = 'Failed to save: ' + e; status.style.color = 'var(--red)'; }
25824
+ toast('Failed to save key: ' + e, 'error');
25825
+ }
25826
+ }
25827
+
25828
+ async function connectComposio(slug) {
25829
+ try {
25830
+ var res = await fetch('/api/composio/toolkits/' + encodeURIComponent(slug) + '/authorize', {
25831
+ method: 'POST',
25832
+ credentials: 'same-origin',
25833
+ headers: { 'content-type': 'application/json' },
25834
+ body: JSON.stringify({}),
25835
+ });
25836
+ var d = await res.json();
25837
+ if (res.status === 409 && d.needsAuthConfig) {
25838
+ toast('This toolkit needs a BYO OAuth app — opening Composio dashboard.', 'warn');
25839
+ window.open(d.setupUrl, '_blank');
25840
+ return;
25841
+ }
25842
+ if (!res.ok) { toast('Connect failed: ' + (d.error || res.status), 'error'); return; }
25843
+ if (d.redirectUrl) {
25844
+ window.open(d.redirectUrl, '_blank');
25845
+ toast('Opened ' + slug + ' authorization in a new tab. Refresh after approving.', 'info');
25846
+ // Soft poll to catch the new connection without forcing a manual refresh.
25847
+ setTimeout(refreshComposioConnections, 5000);
25848
+ setTimeout(refreshComposioConnections, 15000);
25849
+ setTimeout(refreshComposioConnections, 30000);
25850
+ } else {
25851
+ toast('Connected ' + slug, 'success');
25852
+ refreshComposioConnections();
25853
+ }
25854
+ } catch (e) { toast('Connect failed: ' + e, 'error'); }
25855
+ }
25856
+
25857
+ async function disconnectComposio(slug, connectionId) {
25858
+ if (!confirm('Disconnect this ' + slug + ' account?')) return;
25859
+ try {
25860
+ var res = await fetch('/api/composio/toolkits/' + encodeURIComponent(slug) + '/disconnect', {
25861
+ method: 'POST',
25862
+ credentials: 'same-origin',
25863
+ headers: { 'content-type': 'application/json' },
25864
+ body: JSON.stringify({ connectionId: connectionId }),
25865
+ });
25866
+ if (!res.ok) {
25867
+ var d = await res.json().catch(function() { return {}; });
25868
+ toast('Disconnect failed: ' + (d.error || res.status), 'error');
25869
+ return;
25870
+ }
25871
+ toast('Disconnected', 'success');
25872
+ refreshComposioConnections();
25873
+ } catch (e) { toast('Disconnect failed: ' + e, 'error'); }
25874
+ }
25875
+
25564
25876
  async function refreshMcpServers() {
25565
25877
  var container = document.getElementById('mcp-servers-list');
25566
25878
  if (!container) return;
package/dist/cli/setup.js CHANGED
@@ -176,6 +176,18 @@ const FEATURES = [
176
176
  },
177
177
  ],
178
178
  },
179
+ {
180
+ value: 'composio',
181
+ name: 'Composio (1000+ services — Gmail, Slack, Notion, GitHub, …)',
182
+ credentials: [
183
+ {
184
+ key: 'COMPOSIO_API_KEY',
185
+ label: 'Composio API key',
186
+ help: `Sign up at ${CYAN}https://app.composio.dev${DIM} (free tier) and copy your key from Developers > API Keys.\n After setup, manage connections in the dashboard: Settings > Integrations.`,
187
+ masked: true,
188
+ },
189
+ ],
190
+ },
179
191
  {
180
192
  value: 'outlook',
181
193
  name: 'Outlook (Microsoft Graph — email + calendar)',
@@ -598,6 +610,7 @@ export async function runSetup() {
598
610
  { header: 'Voice', keys: ['GROQ_API_KEY', 'ELEVENLABS_API_KEY', 'ELEVENLABS_VOICE_ID'] },
599
611
  { header: 'Video', keys: ['GOOGLE_API_KEY'] },
600
612
  { header: 'Outlook (Microsoft Graph)', keys: ['MS_TENANT_ID', 'MS_CLIENT_ID', 'MS_CLIENT_SECRET', 'MS_USER_EMAIL'] },
613
+ { header: 'Composio (1000+ services broker)', keys: ['COMPOSIO_API_KEY', 'COMPOSIO_USER_ID'] },
601
614
  { header: 'Workspace', keys: ['WORKSPACE_DIRS'] },
602
615
  { header: 'Security', keys: ['ALLOW_ALL_USERS'] },
603
616
  ];
@@ -189,6 +189,19 @@ export const INTEGRATIONS = [
189
189
  { envVar: 'GOOGLE_API_KEY', label: 'Gemini API key', required: true, docUrl: 'https://aistudio.google.com/apikey', persistent: true },
190
190
  ],
191
191
  },
192
+ // ── Multi-service broker ──────────────────────────────────────────
193
+ {
194
+ slug: 'composio',
195
+ label: 'Composio',
196
+ description: 'OAuth + tool execution for 1000+ services (Gmail, Slack, Notion, GitHub, …). Connect toolkits in Settings → Integrations.',
197
+ kind: 'api-key',
198
+ docUrl: 'https://app.composio.dev/developers',
199
+ capabilities: ['Gmail', 'Google Calendar/Drive/Docs', 'Slack', 'Notion', 'GitHub', 'Linear', 'HubSpot', 'Salesforce', 'and 1000+ more'],
200
+ requirements: [
201
+ { envVar: 'COMPOSIO_API_KEY', label: 'Composio API key', required: true, docUrl: 'https://app.composio.dev/developers', persistent: true },
202
+ { envVar: 'COMPOSIO_USER_ID', label: 'User ID for Composio connections (defaults to clementine-default)', required: false, persistent: true },
203
+ ],
204
+ },
192
205
  // ── Payments ──────────────────────────────────────────────────────
193
206
  {
194
207
  slug: 'stripe',
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Composio integration — client + connection management.
3
+ *
4
+ * Composio brokers OAuth and tool execution for 1000+ third-party services
5
+ * (Gmail, Slack, Notion, Linear, GitHub, …). Users authenticate once via
6
+ * Composio's hosted OAuth flow; Composio holds the tokens and refreshes them
7
+ * automatically. Clementine never sees the credentials.
8
+ *
9
+ * This module owns everything except the per-toolkit MCP server construction
10
+ * (see ./mcp-bridge.ts) and the dashboard endpoints (see cli/dashboard.ts).
11
+ *
12
+ * Disabled gracefully when COMPOSIO_API_KEY is not set — every public function
13
+ * returns an empty result, never throws.
14
+ */
15
+ import { Composio } from '@composio/core';
16
+ import { ClaudeAgentSDKProvider } from '@composio/claude-agent-sdk';
17
+ export type ToolkitAuthMode = 'managed' | 'byo';
18
+ export interface CuratedToolkit {
19
+ slug: string;
20
+ displayName: string;
21
+ /** "managed" = Composio hosts the OAuth app — click Connect, it works.
22
+ * "byo" = User must register their own OAuth app on the toolkit's
23
+ * dev portal and add it as an Auth Config in Composio first. */
24
+ authMode: ToolkitAuthMode;
25
+ }
26
+ export declare const CURATED_TOOLKITS: CuratedToolkit[];
27
+ export declare function getComposio(): Composio<ClaudeAgentSDKProvider> | null;
28
+ export declare function isComposioEnabled(): boolean;
29
+ /**
30
+ * Discard the cached client + identity cache so the next call to getComposio()
31
+ * picks up a freshly-set COMPOSIO_API_KEY without a daemon restart. Called by
32
+ * the dashboard PUT /api/settings/COMPOSIO_API_KEY handler.
33
+ */
34
+ export declare function resetComposioClient(): void;
35
+ export declare function clementineUserId(): string;
36
+ export declare function displayNameFor(slug: string): string;
37
+ export interface ConnectedToolkit {
38
+ slug: string;
39
+ connectionId: string;
40
+ status: string;
41
+ alias?: string;
42
+ accountLabel?: string;
43
+ accountEmail?: string;
44
+ accountName?: string;
45
+ accountAvatarUrl?: string;
46
+ createdAt?: string;
47
+ }
48
+ export declare function listConnectedToolkits(): Promise<ConnectedToolkit[]>;
49
+ export interface ToolkitMeta {
50
+ slug: string;
51
+ name: string;
52
+ logo?: string;
53
+ description?: string;
54
+ toolsCount?: number;
55
+ }
56
+ export declare function listToolkitMeta(): Promise<Map<string, ToolkitMeta>>;
57
+ export declare function listToolkitSlugsWithAuthConfig(): Promise<Set<string>>;
58
+ export declare class ComposioNeedsAuthConfigError extends Error {
59
+ readonly slug: string;
60
+ readonly underlying: string;
61
+ constructor(slug: string, underlying: string);
62
+ }
63
+ export declare function authorizeToolkit(slug: string, opts?: {
64
+ callbackUrl?: string;
65
+ alias?: string;
66
+ }): Promise<{
67
+ redirectUrl: string | null;
68
+ connectionId: string;
69
+ }>;
70
+ export declare function disconnectToolkit(connectionId: string): Promise<void>;
71
+ export declare function renameConnection(connectionId: string, alias: string): Promise<void>;
72
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1,387 @@
1
+ /**
2
+ * Composio integration — client + connection management.
3
+ *
4
+ * Composio brokers OAuth and tool execution for 1000+ third-party services
5
+ * (Gmail, Slack, Notion, Linear, GitHub, …). Users authenticate once via
6
+ * Composio's hosted OAuth flow; Composio holds the tokens and refreshes them
7
+ * automatically. Clementine never sees the credentials.
8
+ *
9
+ * This module owns everything except the per-toolkit MCP server construction
10
+ * (see ./mcp-bridge.ts) and the dashboard endpoints (see cli/dashboard.ts).
11
+ *
12
+ * Disabled gracefully when COMPOSIO_API_KEY is not set — every public function
13
+ * returns an empty result, never throws.
14
+ */
15
+ import { Composio } from '@composio/core';
16
+ import { ClaudeAgentSDKProvider } from '@composio/claude-agent-sdk';
17
+ import pino from 'pino';
18
+ const logger = pino({ name: 'clementine.composio' });
19
+ // Curated set surfaced in the dashboard. Composio exposes 1000+ — rendering
20
+ // them all is noisy. Users can still connect anything by editing this list.
21
+ export const CURATED_TOOLKITS = [
22
+ { slug: 'gmail', displayName: 'Gmail', authMode: 'managed' },
23
+ { slug: 'googlecalendar', displayName: 'Google Calendar', authMode: 'managed' },
24
+ { slug: 'googledrive', displayName: 'Google Drive', authMode: 'managed' },
25
+ { slug: 'googlesheets', displayName: 'Google Sheets', authMode: 'managed' },
26
+ { slug: 'googledocs', displayName: 'Google Docs', authMode: 'managed' },
27
+ { slug: 'slack', displayName: 'Slack', authMode: 'managed' },
28
+ { slug: 'github', displayName: 'GitHub', authMode: 'managed' },
29
+ { slug: 'linear', displayName: 'Linear', authMode: 'managed' },
30
+ { slug: 'notion', displayName: 'Notion', authMode: 'managed' },
31
+ { slug: 'hubspot', displayName: 'HubSpot', authMode: 'managed' },
32
+ { slug: 'salesforce', displayName: 'Salesforce', authMode: 'managed' },
33
+ { slug: 'discord', displayName: 'Discord', authMode: 'managed' },
34
+ { slug: 'trello', displayName: 'Trello', authMode: 'managed' },
35
+ { slug: 'asana', displayName: 'Asana', authMode: 'managed' },
36
+ { slug: 'jira', displayName: 'Jira', authMode: 'managed' },
37
+ { slug: 'airtable', displayName: 'Airtable', authMode: 'managed' },
38
+ { slug: 'figma', displayName: 'Figma', authMode: 'managed' },
39
+ { slug: 'dropbox', displayName: 'Dropbox', authMode: 'managed' },
40
+ { slug: 'stripe', displayName: 'Stripe', authMode: 'managed' },
41
+ { slug: 'supabase', displayName: 'Supabase', authMode: 'managed' },
42
+ { slug: 'linkedin', displayName: 'LinkedIn', authMode: 'managed' },
43
+ { slug: 'twitter', displayName: 'Twitter / X', authMode: 'byo' },
44
+ ];
45
+ const DISPLAY_NAME_BY_SLUG = new Map(CURATED_TOOLKITS.map(t => [t.slug, t.displayName]));
46
+ let singleton = null;
47
+ export function getComposio() {
48
+ if (singleton)
49
+ return singleton;
50
+ const apiKey = process.env.COMPOSIO_API_KEY;
51
+ if (!apiKey)
52
+ return null;
53
+ singleton = new Composio({
54
+ apiKey,
55
+ provider: new ClaudeAgentSDKProvider(),
56
+ });
57
+ return singleton;
58
+ }
59
+ export function isComposioEnabled() {
60
+ return Boolean(process.env.COMPOSIO_API_KEY);
61
+ }
62
+ /**
63
+ * Discard the cached client + identity cache so the next call to getComposio()
64
+ * picks up a freshly-set COMPOSIO_API_KEY without a daemon restart. Called by
65
+ * the dashboard PUT /api/settings/COMPOSIO_API_KEY handler.
66
+ */
67
+ export function resetComposioClient() {
68
+ singleton = null;
69
+ identityCache.clear();
70
+ toolkitMetaCache = null;
71
+ }
72
+ // Single-tenant by default. All connections are keyed under COMPOSIO_USER_ID;
73
+ // override if the same Composio account is shared with another tool.
74
+ export function clementineUserId() {
75
+ return process.env.COMPOSIO_USER_ID ?? 'clementine-default';
76
+ }
77
+ export function displayNameFor(slug) {
78
+ return DISPLAY_NAME_BY_SLUG.get(slug) ?? humanize(slug);
79
+ }
80
+ function humanize(slug) {
81
+ return slug
82
+ .split(/[-_]/g)
83
+ .filter(Boolean)
84
+ .map(p => p.charAt(0).toUpperCase() + p.slice(1))
85
+ .join(' ');
86
+ }
87
+ function str(v) {
88
+ return typeof v === 'string' && v.trim() ? v.trim() : undefined;
89
+ }
90
+ function decodeJwtPayload(jwt) {
91
+ try {
92
+ const parts = jwt.split('.');
93
+ if (parts.length < 2)
94
+ return null;
95
+ const payload = parts[1];
96
+ const padded = payload + '==='.slice((payload.length + 3) % 4);
97
+ const b64 = padded.replace(/-/g, '+').replace(/_/g, '/');
98
+ return JSON.parse(Buffer.from(b64, 'base64').toString('utf-8'));
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ }
104
+ // Composio's connected-account state is shaped per toolkit. Pull whatever
105
+ // human identity we can find — OAuth id_tokens (Google et al.) carry
106
+ // email/name/picture; other toolkits stash shop, subdomain, account_id.
107
+ function extractAccountIdentity(state, data) {
108
+ const s = (state && typeof state === 'object' ? state : {}) ?? {};
109
+ const d = (data && typeof data === 'object' ? data : {}) ?? {};
110
+ const out = {};
111
+ const idToken = str(s.id_token) ?? str(d.id_token);
112
+ if (idToken) {
113
+ const payload = decodeJwtPayload(idToken);
114
+ if (payload) {
115
+ out.email = str(payload.email);
116
+ out.name = str(payload.name) ?? str(payload.given_name);
117
+ out.avatarUrl = str(payload.picture);
118
+ }
119
+ }
120
+ for (const src of [d, s]) {
121
+ const profile = (src.user_info && typeof src.user_info === 'object' ? src.user_info : null) ??
122
+ (src.profile && typeof src.profile === 'object' ? src.profile : null);
123
+ if (profile) {
124
+ out.email = out.email ?? str(profile.email);
125
+ out.name = out.name ?? str(profile.name) ?? str(profile.display_name);
126
+ out.avatarUrl = out.avatarUrl ?? str(profile.picture) ?? str(profile.avatar_url);
127
+ }
128
+ out.email = out.email ?? str(src.email);
129
+ out.name = out.name ?? str(src.name) ?? str(src.display_name);
130
+ out.avatarUrl = out.avatarUrl ?? str(src.avatar_url) ?? str(src.picture);
131
+ }
132
+ const fallback = str(s.shop) ??
133
+ str(s.subdomain) ??
134
+ str(s.domain) ??
135
+ str(s.account_id) ??
136
+ str(s.site_name) ??
137
+ str(d.shop) ??
138
+ str(d.subdomain);
139
+ out.label = out.email ?? out.name ?? fallback;
140
+ return out;
141
+ }
142
+ function genericProfileParse(d) {
143
+ const first = (...candidates) => {
144
+ for (const c of candidates)
145
+ if (typeof c === 'string' && c.trim())
146
+ return c.trim();
147
+ return undefined;
148
+ };
149
+ const nested = (key) => (d[key] && typeof d[key] === 'object' ? d[key] : {});
150
+ const viewer = nested('viewer');
151
+ const user = nested('user');
152
+ const team = nested('team');
153
+ const profile = nested('profile');
154
+ const email = first(d.email, user.email, viewer.email, profile.email);
155
+ const name = first(d.name, d.login, d.display_name, user.name, viewer.name, profile.name, team.name);
156
+ const avatar = first(d.avatar_url, d.avatarUrl, d.picture, user.avatar_url, viewer.avatarUrl, profile.image);
157
+ return { email, name, avatarUrl: avatar, label: email ?? name };
158
+ }
159
+ const WHOAMI_BY_TOOLKIT = {
160
+ gmail: {
161
+ tool: 'GMAIL_GET_PROFILE',
162
+ arguments: { user_id: 'me' },
163
+ parse: d => {
164
+ const email = typeof d.emailAddress === 'string' ? d.emailAddress : undefined;
165
+ return { email, label: email };
166
+ },
167
+ },
168
+ github: { tool: 'GITHUB_GET_THE_AUTHENTICATED_USER', arguments: {}, parse: genericProfileParse },
169
+ linear: { tool: 'LINEAR_GET_CURRENT_USER', arguments: {}, parse: genericProfileParse },
170
+ notion: { tool: 'NOTION_GET_ABOUT_ME', arguments: {}, parse: genericProfileParse },
171
+ slack: { tool: 'SLACK_FETCH_TEAM_INFO', arguments: {}, parse: genericProfileParse },
172
+ hubspot: { tool: 'HUBSPOT_GET_ACCOUNT_INFO', arguments: {}, parse: genericProfileParse },
173
+ stripe: { tool: 'STRIPE_GET_ACCOUNT', arguments: {}, parse: genericProfileParse },
174
+ };
175
+ const identityCache = new Map();
176
+ const IDENTITY_TTL_MS = 15 * 60 * 1000;
177
+ async function fetchToolkitIdentity(composio, slug, connectedAccountId) {
178
+ const spec = WHOAMI_BY_TOOLKIT[slug];
179
+ if (!spec)
180
+ return {};
181
+ try {
182
+ const result = await composio.tools.execute(spec.tool, {
183
+ userId: clementineUserId(),
184
+ // Without this, Composio picks the user's *default* connection for the
185
+ // toolkit, so multiple Gmail accounts end up labeled with the same email.
186
+ ...(connectedAccountId ? { connectedAccountId } : {}),
187
+ arguments: spec.arguments,
188
+ // Composio requires either a pinned toolkit version or this flag.
189
+ // Skipping pin so a toolkit bump doesn't break identity silently —
190
+ // misses fall through to fallback chain below.
191
+ dangerouslySkipVersionCheck: true,
192
+ });
193
+ if (!result.successful || !result.data)
194
+ return {};
195
+ return spec.parse(result.data);
196
+ }
197
+ catch (err) {
198
+ logger.warn({ err, slug }, 'whoami fetch failed');
199
+ return {};
200
+ }
201
+ }
202
+ async function getIdentityFor(composio, id, slug, seed) {
203
+ if (seed.label)
204
+ return seed;
205
+ const cached = identityCache.get(id);
206
+ if (cached && Date.now() - cached.at < IDENTITY_TTL_MS)
207
+ return cached.identity;
208
+ let identity = {};
209
+ try {
210
+ const full = await composio.connectedAccounts.get(id);
211
+ identity = extractAccountIdentity(full.state, full.data);
212
+ }
213
+ catch (err) {
214
+ logger.warn({ err, id }, 'failed to fetch full connection for identity');
215
+ }
216
+ if (!identity.label) {
217
+ const whoami = await fetchToolkitIdentity(composio, slug, id);
218
+ if (whoami.label)
219
+ identity = { ...identity, ...whoami };
220
+ }
221
+ identityCache.set(id, { at: Date.now(), identity });
222
+ return identity;
223
+ }
224
+ export async function listConnectedToolkits() {
225
+ const composio = getComposio();
226
+ if (!composio)
227
+ return [];
228
+ try {
229
+ const resp = await composio.connectedAccounts.list({ userIds: [clementineUserId()] });
230
+ const enriched = await Promise.all(resp.items.map(async (it) => {
231
+ const seed = extractAccountIdentity(it.state, it.data);
232
+ const identity = it.status === 'ACTIVE'
233
+ ? await getIdentityFor(composio, it.id, it.toolkit.slug, seed)
234
+ : seed;
235
+ return {
236
+ slug: it.toolkit.slug,
237
+ connectionId: it.id,
238
+ status: it.status,
239
+ alias: it.alias ?? undefined,
240
+ accountLabel: identity.label,
241
+ accountEmail: identity.email,
242
+ accountName: identity.name,
243
+ accountAvatarUrl: identity.avatarUrl,
244
+ createdAt: it.createdAt,
245
+ };
246
+ }));
247
+ return enriched;
248
+ }
249
+ catch (err) {
250
+ logger.error({ err }, 'listConnectedToolkits failed');
251
+ return [];
252
+ }
253
+ }
254
+ let toolkitMetaCache = null;
255
+ async function fetchAllToolkitMeta() {
256
+ const composio = getComposio();
257
+ if (!composio)
258
+ return new Map();
259
+ const out = new Map();
260
+ try {
261
+ const resp = await composio.toolkits.get({ limit: 500 });
262
+ const items = Array.isArray(resp) ? resp : (resp.items ?? []);
263
+ for (const it of items) {
264
+ out.set(it.slug, {
265
+ slug: it.slug,
266
+ name: it.name,
267
+ logo: it.meta?.logo,
268
+ description: it.meta?.description,
269
+ toolsCount: it.meta?.toolsCount,
270
+ });
271
+ }
272
+ // Backfill curated toolkits the list endpoint omitted (e.g. MCP-only ones).
273
+ await Promise.all(CURATED_TOOLKITS.filter(t => !out.has(t.slug)).map(async (t) => {
274
+ try {
275
+ const full = (await composio.toolkits.get(t.slug));
276
+ out.set(full.slug, {
277
+ slug: full.slug,
278
+ name: full.name,
279
+ logo: full.meta?.logo,
280
+ description: full.meta?.description,
281
+ toolsCount: full.meta?.toolsCount,
282
+ });
283
+ }
284
+ catch (err) {
285
+ logger.debug({ err, slug: t.slug }, 'meta backfill failed');
286
+ }
287
+ }));
288
+ }
289
+ catch (err) {
290
+ logger.error({ err }, 'fetchAllToolkitMeta failed');
291
+ }
292
+ return out;
293
+ }
294
+ export async function listToolkitMeta() {
295
+ if (!toolkitMetaCache) {
296
+ toolkitMetaCache = fetchAllToolkitMeta().catch(err => {
297
+ logger.error({ err }, 'listToolkitMeta failed');
298
+ toolkitMetaCache = null;
299
+ return new Map();
300
+ });
301
+ }
302
+ return toolkitMetaCache;
303
+ }
304
+ export async function listToolkitSlugsWithAuthConfig() {
305
+ const composio = getComposio();
306
+ if (!composio)
307
+ return new Set();
308
+ try {
309
+ const resp = await composio.authConfigs.list({ limit: 200 });
310
+ return new Set(resp.items.map(it => it.toolkit.slug));
311
+ }
312
+ catch (err) {
313
+ logger.error({ err }, 'listToolkitSlugsWithAuthConfig failed');
314
+ return new Set();
315
+ }
316
+ }
317
+ // ── Authorize / disconnect ─────────────────────────────────────────────
318
+ export class ComposioNeedsAuthConfigError extends Error {
319
+ slug;
320
+ underlying;
321
+ constructor(slug, underlying) {
322
+ super(`Toolkit "${slug}" needs an auth config — Composio doesn't host a managed OAuth app for it. ` +
323
+ `Add it via the Composio Dashboard: Toolkits → search ${slug} → Add to project → paste your OAuth credentials. ` +
324
+ `https://platform.composio.dev/auth-configs`);
325
+ this.slug = slug;
326
+ this.underlying = underlying;
327
+ this.name = 'ComposioNeedsAuthConfigError';
328
+ }
329
+ }
330
+ export async function authorizeToolkit(slug, opts) {
331
+ const composio = getComposio();
332
+ if (!composio)
333
+ throw new Error('COMPOSIO_API_KEY not set');
334
+ // 1. Find or create an auth config. session.authorize() doesn't auto-create
335
+ // so we have to pass authConfigId explicitly to connectedAccounts.initiate.
336
+ let authConfigId;
337
+ const existingConfig = (await composio.authConfigs.list({ toolkit: slug })).items[0];
338
+ if (existingConfig) {
339
+ authConfigId = existingConfig.id;
340
+ }
341
+ else {
342
+ try {
343
+ const created = await composio.authConfigs.create(slug, {
344
+ type: 'use_composio_managed_auth',
345
+ name: `${displayNameFor(slug)} Auth Config`,
346
+ });
347
+ authConfigId = created.id;
348
+ }
349
+ catch (err) {
350
+ // 400 → Composio doesn't host a managed OAuth app for this toolkit;
351
+ // user must register their own via the Composio dashboard.
352
+ const status = err?.status;
353
+ if (status === 400) {
354
+ throw new ComposioNeedsAuthConfigError(slug, String(err));
355
+ }
356
+ throw err;
357
+ }
358
+ }
359
+ // 2. Initiate the connection. allowMultiple if there's already an active
360
+ // connection so we add another account instead of replacing.
361
+ const existing = (await listConnectedToolkits()).filter(c => c.slug === slug && c.status === 'ACTIVE');
362
+ const conn = await composio.connectedAccounts.initiate(clementineUserId(), authConfigId, {
363
+ ...(existing.length > 0 ? { allowMultiple: true } : {}),
364
+ ...(opts?.callbackUrl ? { callbackUrl: opts.callbackUrl } : {}),
365
+ ...(opts?.alias ? { alias: opts.alias } : {}),
366
+ });
367
+ return { redirectUrl: conn.redirectUrl ?? null, connectionId: conn.id };
368
+ }
369
+ export async function disconnectToolkit(connectionId) {
370
+ const composio = getComposio();
371
+ if (!composio)
372
+ throw new Error('COMPOSIO_API_KEY not set');
373
+ await composio.connectedAccounts.delete(connectionId);
374
+ identityCache.delete(connectionId);
375
+ }
376
+ export async function renameConnection(connectionId, alias) {
377
+ const composio = getComposio();
378
+ if (!composio)
379
+ throw new Error('COMPOSIO_API_KEY not set');
380
+ // The Composio high-level wrapper only exposes status updates; alias
381
+ // rename lives on the raw client's PATCH endpoint, which is marked
382
+ // protected. Bridge through `as any` — this is a small, well-scoped escape
383
+ // hatch and the alternative (bypassing the wrapper entirely) loses retry
384
+ // and auth handling.
385
+ await composio.client.connectedAccounts.patch(connectionId, { alias });
386
+ }
387
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Composio MCP bridge — converts Composio toolkits into in-process SDK MCP
3
+ * servers that the Claude Agent SDK's `query()` can spawn directly.
4
+ *
5
+ * Why per-toolkit servers (instead of one big "composio" server)?
6
+ * - Per-agent scoping. A scheduler-bot doesn't need GitHub tools; spawning
7
+ * a Calendar-only sub-agent shouldn't drag in 200 tools across 20
8
+ * toolkits.
9
+ * - Tool-name namespacing. Each MCP server registers its tools under its
10
+ * own name, so we get `mcp__gmail__GMAIL_SEND_EMAIL` not
11
+ * `mcp__composio__GMAIL_SEND_EMAIL` collisions across toolkits.
12
+ * - Concurrency. Composio's TS provider opens one session per server, so
13
+ * having one server per toolkit lets unrelated toolkits load in parallel.
14
+ *
15
+ * Returns an empty map when COMPOSIO_API_KEY is unset, so the agent path
16
+ * always works — Composio is purely additive.
17
+ */
18
+ import type { McpSdkServerConfigWithInstance } from '@anthropic-ai/claude-agent-sdk';
19
+ /**
20
+ * Build SDK MCP server configs for the given toolkit slugs (or all active
21
+ * connected toolkits when omitted). Each toolkit becomes one MCP server.
22
+ */
23
+ export declare function buildComposioMcpServers(slugs?: string[]): Promise<Record<string, McpSdkServerConfigWithInstance>>;
24
+ //# sourceMappingURL=mcp-bridge.d.ts.map
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Composio MCP bridge — converts Composio toolkits into in-process SDK MCP
3
+ * servers that the Claude Agent SDK's `query()` can spawn directly.
4
+ *
5
+ * Why per-toolkit servers (instead of one big "composio" server)?
6
+ * - Per-agent scoping. A scheduler-bot doesn't need GitHub tools; spawning
7
+ * a Calendar-only sub-agent shouldn't drag in 200 tools across 20
8
+ * toolkits.
9
+ * - Tool-name namespacing. Each MCP server registers its tools under its
10
+ * own name, so we get `mcp__gmail__GMAIL_SEND_EMAIL` not
11
+ * `mcp__composio__GMAIL_SEND_EMAIL` collisions across toolkits.
12
+ * - Concurrency. Composio's TS provider opens one session per server, so
13
+ * having one server per toolkit lets unrelated toolkits load in parallel.
14
+ *
15
+ * Returns an empty map when COMPOSIO_API_KEY is unset, so the agent path
16
+ * always works — Composio is purely additive.
17
+ */
18
+ import { createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk';
19
+ import pino from 'pino';
20
+ import { clementineUserId, getComposio, listConnectedToolkits, } from './client.js';
21
+ const logger = pino({ name: 'clementine.composio.mcp' });
22
+ /**
23
+ * Build SDK MCP server configs for the given toolkit slugs (or all active
24
+ * connected toolkits when omitted). Each toolkit becomes one MCP server.
25
+ */
26
+ export async function buildComposioMcpServers(slugs) {
27
+ const composio = getComposio();
28
+ if (!composio)
29
+ return {};
30
+ const connected = await listConnectedToolkits();
31
+ const activeSlugs = new Set(connected.filter(c => c.status === 'ACTIVE').map(c => c.slug));
32
+ const targetSlugs = slugs?.length
33
+ ? slugs.filter(s => activeSlugs.has(s))
34
+ : [...activeSlugs];
35
+ if (targetSlugs.length === 0)
36
+ return {};
37
+ const out = {};
38
+ // Build serially to avoid hammering Composio with parallel session opens
39
+ // on every agent query. Sessions are cached upstream by Composio's SDK,
40
+ // so repeat calls within a process are cheap after the first hit.
41
+ for (const slug of targetSlugs) {
42
+ try {
43
+ out[slug] = await buildOne(composio, slug, connected);
44
+ }
45
+ catch (err) {
46
+ logger.warn({ err, slug }, 'failed to build Composio MCP server — skipping');
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+ async function buildOne(composio, slug, connected) {
52
+ // If 2+ active connections for this toolkit, force Composio to require
53
+ // explicit account selection per tool call — otherwise it silently picks
54
+ // the default (newest) account.
55
+ const activeCount = connected.filter(c => c.slug === slug && c.status === 'ACTIVE').length;
56
+ // Look up auth config explicitly. Without this, composio.create() tries to
57
+ // auto-create one and 400s for BYO toolkits (Twitter etc.) that don't have
58
+ // a managed OAuth app available.
59
+ const authConfig = (await composio.authConfigs.list({ toolkit: slug })).items[0];
60
+ const session = await composio.create(clementineUserId(), {
61
+ toolkits: [slug],
62
+ manageConnections: false,
63
+ ...(authConfig ? { authConfigs: { [slug]: authConfig.id } } : {}),
64
+ ...(activeCount >= 2
65
+ ? { multiAccount: { enable: true, requireExplicitSelection: true } }
66
+ : {}),
67
+ });
68
+ const tools = await session.tools();
69
+ return createSdkMcpServer({
70
+ name: slug,
71
+ version: '0.1.0',
72
+ tools,
73
+ });
74
+ }
75
+ //# sourceMappingURL=mcp-bridge.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -23,6 +23,8 @@
23
23
  "dependencies": {
24
24
  "@anthropic-ai/claude-agent-sdk": "^0.2.119",
25
25
  "@anthropic-ai/sdk": "^0.91.0",
26
+ "@composio/claude-agent-sdk": "^0.8.1",
27
+ "@composio/core": "^0.8.1",
26
28
  "@inquirer/prompts": "^7.0.0",
27
29
  "@modelcontextprotocol/sdk": "^1.29.0",
28
30
  "@slack/bolt": "^4.2.0",