clementine-agent 1.10.0 → 1.10.2

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.
@@ -4478,7 +4478,19 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
4478
4478
  });
4479
4479
  return;
4480
4480
  }
4481
- res.status(500).json({ error: String(err) });
4481
+ // Composio errors carry useful detail in `.error`, `.message`, and
4482
+ // `.status`; serializing with String() drops everything except the name.
4483
+ // Pull whatever's there and log server-side so we have a paper trail.
4484
+ const e = err;
4485
+ const detail = e?.error
4486
+ ? (typeof e.error === 'string' ? e.error : (e.error.message ?? JSON.stringify(e.error)))
4487
+ : (e?.message ?? String(err));
4488
+ console.error(`[composio] authorize ${slug} failed:`, detail, e?.stack ?? '');
4489
+ res.status(500).json({
4490
+ error: detail,
4491
+ toolkit: slug,
4492
+ statusCode: e?.status,
4493
+ });
4482
4494
  }
4483
4495
  });
4484
4496
  app.post('/api/composio/toolkits/:slug/disconnect', async (req, res) => {
@@ -25839,7 +25851,12 @@ async function connectComposio(slug) {
25839
25851
  window.open(d.setupUrl, '_blank');
25840
25852
  return;
25841
25853
  }
25842
- if (!res.ok) { toast('Connect failed: ' + (d.error || res.status), 'error'); return; }
25854
+ if (!res.ok) {
25855
+ var reason = d.error || ('HTTP ' + res.status);
25856
+ toast('Connect failed: ' + reason, 'error');
25857
+ console.error('[composio] connect failed', { slug: slug, status: res.status, body: d });
25858
+ return;
25859
+ }
25843
25860
  if (d.redirectUrl) {
25844
25861
  window.open(d.redirectUrl, '_blank');
25845
25862
  toast('Opened ' + slug + ' authorization in a new tab. Refresh after approving.', 'info');
package/dist/config.d.ts CHANGED
@@ -11,6 +11,12 @@ export declare const PKG_DIR: string;
11
11
  /** Data home — user data, vault, .env, logs, sessions. */
12
12
  export declare const BASE_DIR: string;
13
13
  import { shellEscape as _shellEscape } from './config/env-parser.js';
14
+ /**
15
+ * Look up a config value: local .env first, then process.env fallback.
16
+ * Keychain refs in either source are resolved lazily; failed resolution
17
+ * falls through to the fallback rather than returning the literal stub.
18
+ */
19
+ export declare function getEnv(key: string, fallback?: string): string;
14
20
  /** Merged view of process.env overlaid with .env. Use for classifyIntegrations / summarizeIntegrationStatus. */
15
21
  export declare function envSnapshot(): Record<string, string | undefined>;
16
22
  /** Test-only: clear the keychain ref cache so re-resolution can be tested. */
package/dist/config.js CHANGED
@@ -84,7 +84,7 @@ function maybeResolveRef(value) {
84
84
  * Keychain refs in either source are resolved lazily; failed resolution
85
85
  * falls through to the fallback rather than returning the literal stub.
86
86
  */
87
- function getEnv(key, fallback = '') {
87
+ export function getEnv(key, fallback = '') {
88
88
  const fromLocal = maybeResolveRef(env[key]);
89
89
  if (fromLocal !== undefined && fromLocal !== '')
90
90
  return fromLocal;
@@ -32,6 +32,7 @@ export declare function isComposioEnabled(): boolean;
32
32
  * the dashboard PUT /api/settings/COMPOSIO_API_KEY handler.
33
33
  */
34
34
  export declare function resetComposioClient(): void;
35
+ export declare function getPreferredUserId(): Promise<string>;
35
36
  export declare function clementineUserId(): string;
36
37
  export declare function displayNameFor(slug: string): string;
37
38
  export interface ConnectedToolkit {
@@ -15,7 +15,18 @@
15
15
  import { Composio } from '@composio/core';
16
16
  import { ClaudeAgentSDKProvider } from '@composio/claude-agent-sdk';
17
17
  import pino from 'pino';
18
+ import { getEnv } from '../../config.js';
18
19
  const logger = pino({ name: 'clementine.composio' });
20
+ // `process.env` is intentionally NOT populated from .env (config.ts keeps
21
+ // secrets out of the SDK subprocess). Reading process.env.COMPOSIO_API_KEY
22
+ // directly works during the dashboard's hot-reload (PUT handler mutates
23
+ // process.env), but is empty after a fresh daemon restart even if the key
24
+ // is in .env. Use this helper everywhere we read Composio env vars: it
25
+ // prefers process.env (hot-reload from dashboard) and falls back to the
26
+ // .env file via getEnv (survives restarts).
27
+ function readComposioEnv(key) {
28
+ return process.env[key] || getEnv(key, '');
29
+ }
19
30
  // Curated set surfaced in the dashboard. Composio exposes 1000+ — rendering
20
31
  // them all is noisy. Users can still connect anything by editing this list.
21
32
  export const CURATED_TOOLKITS = [
@@ -40,6 +51,9 @@ export const CURATED_TOOLKITS = [
40
51
  { slug: 'stripe', displayName: 'Stripe', authMode: 'managed' },
41
52
  { slug: 'supabase', displayName: 'Supabase', authMode: 'managed' },
42
53
  { slug: 'linkedin', displayName: 'LinkedIn', authMode: 'managed' },
54
+ { slug: 'outlook', displayName: 'Outlook', authMode: 'managed' },
55
+ { slug: 'onedrive', displayName: 'OneDrive', authMode: 'managed' },
56
+ { slug: 'zoom', displayName: 'Zoom', authMode: 'managed' },
43
57
  { slug: 'twitter', displayName: 'Twitter / X', authMode: 'byo' },
44
58
  ];
45
59
  const DISPLAY_NAME_BY_SLUG = new Map(CURATED_TOOLKITS.map(t => [t.slug, t.displayName]));
@@ -47,7 +61,7 @@ let singleton = null;
47
61
  export function getComposio() {
48
62
  if (singleton)
49
63
  return singleton;
50
- const apiKey = process.env.COMPOSIO_API_KEY;
64
+ const apiKey = readComposioEnv('COMPOSIO_API_KEY');
51
65
  if (!apiKey)
52
66
  return null;
53
67
  singleton = new Composio({
@@ -57,7 +71,7 @@ export function getComposio() {
57
71
  return singleton;
58
72
  }
59
73
  export function isComposioEnabled() {
60
- return Boolean(process.env.COMPOSIO_API_KEY);
74
+ return Boolean(readComposioEnv('COMPOSIO_API_KEY'));
61
75
  }
62
76
  /**
63
77
  * Discard the cached client + identity cache so the next call to getComposio()
@@ -68,11 +82,54 @@ export function resetComposioClient() {
68
82
  singleton = null;
69
83
  identityCache.clear();
70
84
  toolkitMetaCache = null;
85
+ detectedPreferredUserId = null;
86
+ }
87
+ // Public: same logic as the internal detector, exposed for the MCP bridge so
88
+ // agent sessions land on the right user_id.
89
+ export async function getPreferredUserId() {
90
+ const composio = getComposio();
91
+ if (!composio)
92
+ return clementineUserId();
93
+ return detectPreferredUserId(composio);
71
94
  }
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.
95
+ // Default user_id for *new* connections. We list connections without filtering
96
+ // so existing accounts (set up in Composio's web UI under the platform default
97
+ // "default" user_id, or any other label) still surface — but new authorize()
98
+ // calls have to pass *some* user_id, and we want it to match whatever the
99
+ // user already has if possible. detectPreferredUserId() picks the user_id
100
+ // with the most existing connections, falling back to this constant.
101
+ const DEFAULT_NEW_CONNECTION_USER_ID = 'default';
74
102
  export function clementineUserId() {
75
- return process.env.COMPOSIO_USER_ID ?? 'clementine-default';
103
+ return readComposioEnv('COMPOSIO_USER_ID') || DEFAULT_NEW_CONNECTION_USER_ID;
104
+ }
105
+ // Cached after first detection — avoids extra API calls per authorize.
106
+ let detectedPreferredUserId = null;
107
+ async function detectPreferredUserId(composio) {
108
+ const explicit = readComposioEnv('COMPOSIO_USER_ID');
109
+ if (explicit)
110
+ return explicit;
111
+ if (detectedPreferredUserId)
112
+ return detectedPreferredUserId;
113
+ try {
114
+ const resp = await composio.connectedAccounts.list({ limit: 100 });
115
+ const counts = new Map();
116
+ for (const it of resp.items) {
117
+ const uid = it.userId ?? it.user_id;
118
+ if (typeof uid === 'string' && uid.length > 0) {
119
+ counts.set(uid, (counts.get(uid) ?? 0) + 1);
120
+ }
121
+ }
122
+ if (counts.size > 0) {
123
+ const top = [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0];
124
+ detectedPreferredUserId = top;
125
+ return top;
126
+ }
127
+ }
128
+ catch (err) {
129
+ logger.debug({ err }, 'detectPreferredUserId failed — using default');
130
+ }
131
+ detectedPreferredUserId = DEFAULT_NEW_CONNECTION_USER_ID;
132
+ return DEFAULT_NEW_CONNECTION_USER_ID;
76
133
  }
77
134
  export function displayNameFor(slug) {
78
135
  return DISPLAY_NAME_BY_SLUG.get(slug) ?? humanize(slug);
@@ -226,7 +283,11 @@ export async function listConnectedToolkits() {
226
283
  if (!composio)
227
284
  return [];
228
285
  try {
229
- const resp = await composio.connectedAccounts.list({ userIds: [clementineUserId()] });
286
+ // No userIds filter: a Composio API key is account-scoped, and a personal
287
+ // agent should see every connection on the account regardless of which
288
+ // user_id label it was created under. This is the fix for "I connected X
289
+ // in Composio but it doesn't show up in Clementine."
290
+ const resp = await composio.connectedAccounts.list({ limit: 100 });
230
291
  const enriched = await Promise.all(resp.items.map(async (it) => {
231
292
  const seed = extractAccountIdentity(it.state, it.data);
232
293
  const identity = it.status === 'ACTIVE'
@@ -359,7 +420,11 @@ export async function authorizeToolkit(slug, opts) {
359
420
  // 2. Initiate the connection. allowMultiple if there's already an active
360
421
  // connection so we add another account instead of replacing.
361
422
  const existing = (await listConnectedToolkits()).filter(c => c.slug === slug && c.status === 'ACTIVE');
362
- const conn = await composio.connectedAccounts.initiate(clementineUserId(), authConfigId, {
423
+ // Reuse whichever user_id already owns connections in this account, so a
424
+ // freshly authorized Gmail lands next to the existing Outlook (etc.) under
425
+ // the same user_id. Falls back to env override or "default".
426
+ const userId = await detectPreferredUserId(composio);
427
+ const conn = await composio.connectedAccounts.initiate(userId, authConfigId, {
363
428
  ...(existing.length > 0 ? { allowMultiple: true } : {}),
364
429
  ...(opts?.callbackUrl ? { callbackUrl: opts.callbackUrl } : {}),
365
430
  ...(opts?.alias ? { alias: opts.alias } : {}),
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk';
19
19
  import pino from 'pino';
20
- import { clementineUserId, getComposio, listConnectedToolkits, } from './client.js';
20
+ import { getComposio, getPreferredUserId, listConnectedToolkits, } from './client.js';
21
21
  const logger = pino({ name: 'clementine.composio.mcp' });
22
22
  /**
23
23
  * Build SDK MCP server configs for the given toolkit slugs (or all active
@@ -57,7 +57,8 @@ async function buildOne(composio, slug, connected) {
57
57
  // auto-create one and 400s for BYO toolkits (Twitter etc.) that don't have
58
58
  // a managed OAuth app available.
59
59
  const authConfig = (await composio.authConfigs.list({ toolkit: slug })).items[0];
60
- const session = await composio.create(clementineUserId(), {
60
+ const userId = await getPreferredUserId();
61
+ const session = await composio.create(userId, {
61
62
  toolkits: [slug],
62
63
  manageConnections: false,
63
64
  ...(authConfig ? { authConfigs: { [slug]: authConfig.id } } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.10.0",
3
+ "version": "1.10.2",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",