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.
- package/dist/agent/assistant.js +53 -8
- package/dist/cli/dashboard.js +316 -4
- package/dist/cli/setup.js +13 -0
- package/dist/config/integrations-registry.js +13 -0
- package/dist/integrations/composio/client.d.ts +72 -0
- package/dist/integrations/composio/client.js +387 -0
- package/dist/integrations/composio/mcp-bridge.d.ts +24 -0
- package/dist/integrations/composio/mcp-bridge.js +75 -0
- package/package.json +3 -1
package/dist/agent/assistant.js
CHANGED
|
@@ -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
|
-
-
|
|
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,
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|