clementine-agent 1.12.4 → 1.13.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.
@@ -1566,24 +1566,30 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1566
1566
  }
1567
1567
  catch { /* non-fatal */ }
1568
1568
  }
1569
- // Composio tool preferencewhen a Composio toolkit has an active
1570
- // connection AND a Claude Desktop equivalent exists, the agent tends to
1571
- // default to the Claude Desktop tool (mcp__claude_ai_*) because those
1572
- // names appear in Claude's training and feel familiar. The Composio
1573
- // versions (mcp__<slug>__*) usually have broader scope (full inbox vs.
1574
- // limited preview, write access vs. read-only, etc.), so we explicitly
1575
- // steer toward them when present. Only emitted when at least one
1576
- // connection is live otherwise it's noise.
1577
- if (composioConnectedSlugs.length > 0) {
1578
- const slugs = composioConnectedSlugs.slice().sort().join(', ');
1579
- volatileParts.push(`## Composio Tools (Preferred)\n\n` +
1580
- `Connected Composio toolkits: ${slugs}.\n\n` +
1581
- `**Always prefer the Composio tool over the Claude Desktop equivalent** when both are available for the same service:\n` +
1582
- `- For Outlook/email: use \`mcp__outlook__*\` (NOT \`mcp__claude_ai_Microsoft_365__*\`)\n` +
1583
- `- For Gmail: use \`mcp__gmail__*\` (NOT \`mcp__claude_ai_Gmail__*\`)\n` +
1584
- `- For Google Drive: use \`mcp__googledrive__*\` (NOT \`mcp__claude_ai_Google_Drive__*\`)\n` +
1585
- `- For Google Calendar/Sheets/Docs/Slack/Notion/etc.: same pattern Composio first.\n\n` +
1586
- `Why: Composio tools have broader scope (full inbox/file access, write capabilities) and are tied to OAuth tokens you control directly. Use Claude Desktop tools only as fallback when no Composio equivalent is connected.`);
1569
+ // Tool source preferencesonly emit a prompt instruction when:
1570
+ // 1. A service has BOTH Composio AND Claude Desktop sources connected
1571
+ // (a real conflict the agent could disambiguate the wrong way), AND
1572
+ // 2. The user has explicitly picked a preference for that service.
1573
+ //
1574
+ // No conflict 0 chars. Conflict but no user preference → silent
1575
+ // default (Composio), still 0 chars. Only configured preferences cost
1576
+ // tokens, and only the affected services are listed (~50 chars each).
1577
+ // Compare to the previous hardcoded block which was ~700 chars on
1578
+ // every turn regardless.
1579
+ if (!isAutonomous) {
1580
+ try {
1581
+ const { loadToolPreferences, computeAvailability, buildPromptInstruction } = require('../integrations/tool-preferences.js');
1582
+ const { loadClaudeIntegrations } = require('./mcp-bridge.js');
1583
+ const composioSet = new Set(composioConnectedSlugs);
1584
+ const cdIntegrations = loadClaudeIntegrations();
1585
+ const cdActive = new Set(Object.values(cdIntegrations).filter(i => i.connected).map(i => i.name));
1586
+ const prefs = loadToolPreferences();
1587
+ const availability = computeAvailability(composioSet, cdActive, prefs.preferences);
1588
+ const instruction = buildPromptInstruction(availability, prefs.preferences);
1589
+ if (instruction)
1590
+ volatileParts.push(instruction);
1591
+ }
1592
+ catch { /* non-fatal — agent runs without the preference rule */ }
1587
1593
  }
1588
1594
  // Conversational context — same signals the insight engine surfaces
1589
1595
  // proactively (Phase 10), but injected directly into the agent's prompt
@@ -4570,6 +4570,66 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
4570
4570
  res.status(500).json({ error: String(err) });
4571
4571
  }
4572
4572
  });
4573
+ // ── Tool source preferences ─────────────────────────────────
4574
+ // When a service is reachable from multiple MCP sources (Composio + Claude
4575
+ // Desktop), let the user pick which one. See src/integrations/tool-preferences.ts
4576
+ // for design notes.
4577
+ app.get('/api/tool-preferences', async (_req, res) => {
4578
+ try {
4579
+ const tp = await import('../integrations/tool-preferences.js');
4580
+ const composio = await import('../integrations/composio/client.js');
4581
+ const mcp = await import('../agent/mcp-bridge.js');
4582
+ const prefs = tp.loadToolPreferences();
4583
+ const composioSlugs = composio.isComposioEnabled()
4584
+ ? new Set((await composio.listConnectedToolkits()).filter(c => c.status === 'ACTIVE').map(c => c.slug))
4585
+ : new Set();
4586
+ const cdActive = new Set(Object.values(mcp.loadClaudeIntegrations()).filter(i => i.connected).map(i => i.name));
4587
+ const availability = tp.computeAvailability(composioSlugs, cdActive, prefs.preferences);
4588
+ res.json({
4589
+ preferences: prefs.preferences,
4590
+ services: availability.map(a => ({
4591
+ id: a.service.id,
4592
+ label: a.service.label,
4593
+ composio: a.service.composioSlug
4594
+ ? { slug: a.service.composioSlug, available: a.composioAvailable }
4595
+ : null,
4596
+ claudeDesktop: a.service.claudeDesktopName
4597
+ ? { name: a.service.claudeDesktopName, available: a.claudeDesktopAvailable }
4598
+ : null,
4599
+ hasConflict: a.hasConflict,
4600
+ effective: a.effective,
4601
+ })),
4602
+ });
4603
+ }
4604
+ catch (err) {
4605
+ res.status(500).json({ error: String(err) });
4606
+ }
4607
+ });
4608
+ app.put('/api/tool-preferences', async (req, res) => {
4609
+ try {
4610
+ const body = req.body;
4611
+ const incoming = body?.preferences;
4612
+ if (!incoming || typeof incoming !== 'object') {
4613
+ res.status(400).json({ error: 'preferences (object) required in body' });
4614
+ return;
4615
+ }
4616
+ const tp = await import('../integrations/tool-preferences.js');
4617
+ const valid = {};
4618
+ const knownIds = new Set(tp.KNOWN_SERVICES.map(s => s.id));
4619
+ for (const [id, source] of Object.entries(incoming)) {
4620
+ if (!knownIds.has(id))
4621
+ continue;
4622
+ if (source === 'composio' || source === 'claude-desktop' || source === 'off') {
4623
+ valid[id] = source;
4624
+ }
4625
+ }
4626
+ tp.saveToolPreferences({ preferences: valid });
4627
+ res.json({ ok: true, preferences: valid });
4628
+ }
4629
+ catch (err) {
4630
+ res.status(500).json({ error: String(err) });
4631
+ }
4632
+ });
4573
4633
  // ── CRON CRUD routes ──────────────────────────────────────────
4574
4634
  app.get('/api/projects', (_req, res) => {
4575
4635
  try {
@@ -14546,6 +14606,15 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
14546
14606
  <div class="empty-state">Loading...</div>
14547
14607
  </div>
14548
14608
  </div>
14609
+ <div class="card" style="margin-bottom:20px">
14610
+ <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
14611
+ <span>Tool Source Preferences</span>
14612
+ <span style="font-size:11px;color:var(--text-muted)">Pick which source the agent uses when a service has multiple</span>
14613
+ </div>
14614
+ <div class="card-body" style="padding:16px" id="tool-preferences">
14615
+ <div class="empty-state">Loading...</div>
14616
+ </div>
14617
+ </div>
14549
14618
  <div class="card" style="margin-bottom:20px">
14550
14619
  <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
14551
14620
  <span>Claude Desktop Integrations</span>
@@ -15869,7 +15938,7 @@ function switchTab(group, tab) {
15869
15938
  }
15870
15939
  if (group === 'settings') {
15871
15940
  if (tab === 'general' && typeof refreshSettings === 'function') refreshSettings();
15872
- if (tab === 'integrations') { refreshSalesforce(); refreshComposioConnections(); }
15941
+ if (tab === 'integrations') { refreshSalesforce(); refreshComposioConnections(); refreshToolPreferences(); }
15873
15942
  if (tab === 'remote') refreshRemoteAccess();
15874
15943
  if (tab === 'security') refreshAuthSessions();
15875
15944
  if (tab === 'projects' && typeof refreshProjects === 'function') refreshProjects();
@@ -25940,6 +26009,89 @@ function renderComposioCatalog(query) {
25940
26009
  setTimeout(function() { var s = document.getElementById('composio-search'); if (s) { s.focus(); s.setSelectionRange(s.value.length, s.value.length); } }, 0);
25941
26010
  }
25942
26011
 
26012
+ async function refreshToolPreferences() {
26013
+ var container = document.getElementById('tool-preferences');
26014
+ if (!container) return;
26015
+ try {
26016
+ var r = await apiFetch('/api/tool-preferences');
26017
+ var d = await r.json();
26018
+ var services = d.services || [];
26019
+ var prefs = d.preferences || {};
26020
+ // Seed the click-handler's working copy with what's currently saved,
26021
+ // so single-click edits merge instead of replacing the whole object.
26022
+ window._toolPrefs = Object.assign({}, prefs);
26023
+
26024
+ var conflicts = services.filter(function(s) { return s.hasConflict; });
26025
+ var single = services.filter(function(s) { return !s.hasConflict && s.effective; });
26026
+ var none = services.filter(function(s) { return !s.effective; });
26027
+
26028
+ var html = '';
26029
+ if (conflicts.length === 0 && single.length === 0) {
26030
+ html += '<div style="font-size:13px;color:var(--text-muted);line-height:1.6">No services connected yet from either Composio or Claude Desktop. Once you connect something, it\\'ll appear here.</div>';
26031
+ container.innerHTML = html;
26032
+ return;
26033
+ }
26034
+
26035
+ if (conflicts.length > 0) {
26036
+ html += '<div style="font-size:12px;color:var(--text-secondary);margin-bottom:12px;line-height:1.5">'
26037
+ + '<strong>' + conflicts.length + ' service' + (conflicts.length === 1 ? ' has' : 's have') + ' multiple sources connected.</strong> '
26038
+ + 'Pick which one the agent should use. Default (no selection) = Composio.'
26039
+ + '</div>';
26040
+ html += '<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:14px">';
26041
+ conflicts.forEach(function(s) {
26042
+ var picked = prefs[s.id] || 'composio';
26043
+ html += '<div style="display:flex;align-items:center;gap:12px;padding:10px 12px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary)">';
26044
+ html += '<div style="flex:1;font-size:13px;font-weight:500">' + esc(s.label) + '</div>';
26045
+ html += '<div style="display:flex;gap:6px">';
26046
+ ['composio','claude-desktop','off'].forEach(function(opt) {
26047
+ var selected = picked === opt;
26048
+ var label = opt === 'composio' ? 'Composio' : (opt === 'claude-desktop' ? 'Claude Desktop' : 'Off');
26049
+ html += '<button onclick="setToolPreference(\\'' + esc(s.id) + '\\',\\'' + opt + '\\')" '
26050
+ + 'style="padding:4px 10px;font-size:11px;border-radius:4px;cursor:pointer;border:1px solid '
26051
+ + (selected ? 'var(--accent)' : 'var(--border)') + ';'
26052
+ + 'background:' + (selected ? 'var(--accent)' : 'transparent') + ';'
26053
+ + 'color:' + (selected ? '#fff' : 'var(--text-secondary)') + '">' + esc(label) + '</button>';
26054
+ });
26055
+ html += '</div>';
26056
+ html += '</div>';
26057
+ });
26058
+ html += '</div>';
26059
+ }
26060
+
26061
+ if (single.length > 0) {
26062
+ html += '<div style="font-size:11px;color:var(--text-muted);margin-bottom:6px;text-transform:uppercase;letter-spacing:0.5px">Single source — using automatically</div>';
26063
+ html += '<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px">';
26064
+ single.forEach(function(s) {
26065
+ var srcLabel = s.effective === 'composio' ? 'Composio' : (s.effective === 'claude-desktop' ? 'Claude Desktop' : 'Off');
26066
+ html += '<span style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;font-size:11px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:12px">'
26067
+ + esc(s.label) + ' <span style="color:var(--text-muted);font-size:10px">via ' + esc(srcLabel) + '</span>'
26068
+ + '</span>';
26069
+ });
26070
+ html += '</div>';
26071
+ }
26072
+
26073
+ container.innerHTML = html;
26074
+ } catch (e) {
26075
+ container.innerHTML = '<div class="empty-state" style="color:var(--red);padding:8px">Failed to load tool preferences: ' + esc(String(e)) + '</div>';
26076
+ }
26077
+ }
26078
+
26079
+ async function setToolPreference(serviceId, source) {
26080
+ try {
26081
+ // Get current prefs, merge in the change, send back. Server validates
26082
+ // each entry, so we don't need to pre-filter unknown IDs.
26083
+ var current = window._toolPrefs || {};
26084
+ current[serviceId] = source;
26085
+ window._toolPrefs = current;
26086
+ await apiFetch('/api/tool-preferences', {
26087
+ method: 'PUT',
26088
+ headers: { 'content-type': 'application/json' },
26089
+ body: JSON.stringify({ preferences: current }),
26090
+ });
26091
+ refreshToolPreferences();
26092
+ } catch (e) { toast('Failed to save preference: ' + e, 'error'); }
26093
+ }
26094
+
25943
26095
  async function saveComposioApiKey() {
25944
26096
  var input = document.getElementById('composio-key-input');
25945
26097
  var status = document.getElementById('composio-key-status');
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Tool source preferences — when a service has tools available from
3
+ * multiple MCP sources (e.g., Composio's outlook AND Claude Desktop's
4
+ * Microsoft 365), let the user pick which one the agent should use.
5
+ *
6
+ * Storage: ~/.clementine/tool-preferences.json
7
+ *
8
+ * Design decisions:
9
+ * - Only services with multiple available sources show up. No noise.
10
+ * - When a conflict exists but the user hasn't picked, silently default
11
+ * to Composio (broader scope, OAuth tokens you control).
12
+ * - When no conflict exists (one or zero sources), no preference is
13
+ * needed and no system-prompt clutter is emitted.
14
+ * - The mapping between Composio slugs and Claude Desktop integration
15
+ * names lives here (small, bounded — only services where Claude
16
+ * Desktop has a connector).
17
+ */
18
+ export type ToolSource = 'composio' | 'claude-desktop' | 'off';
19
+ /**
20
+ * Canonical service registry. Each entry maps a stable service ID to its
21
+ * Composio toolkit slug (if any) and Claude Desktop integration name (if
22
+ * any). Adding a new service = one line here.
23
+ */
24
+ export interface ServiceDefinition {
25
+ /** Stable canonical ID — what the user sees and what we key prefs by. */
26
+ id: string;
27
+ /** Friendly name for the dashboard. */
28
+ label: string;
29
+ /** Composio toolkit slug, if Composio offers this service. */
30
+ composioSlug?: string;
31
+ /** Claude Desktop integration name (matches mcp__claude_ai_<name>__*). */
32
+ claudeDesktopName?: string;
33
+ }
34
+ export declare const KNOWN_SERVICES: ServiceDefinition[];
35
+ export interface ToolPreferences {
36
+ version: 1;
37
+ /** id → chosen source. Missing = use silent default (composio when conflict). */
38
+ preferences: Record<string, ToolSource>;
39
+ updatedAt?: string;
40
+ }
41
+ export declare function loadToolPreferences(): ToolPreferences;
42
+ export declare function saveToolPreferences(prefs: Omit<ToolPreferences, 'version' | 'updatedAt'>): void;
43
+ export interface ServiceAvailability {
44
+ service: ServiceDefinition;
45
+ composioAvailable: boolean;
46
+ claudeDesktopAvailable: boolean;
47
+ /** True when both sources are connected — preference matters. */
48
+ hasConflict: boolean;
49
+ /** Effective source: user pref if set, else "composio" when conflict, else
50
+ * whichever single source is connected, else null. */
51
+ effective: ToolSource | null;
52
+ }
53
+ /**
54
+ * Walk every known service and compute availability + effective preference.
55
+ * Pure function — caller passes in what's connected.
56
+ */
57
+ export declare function computeAvailability(composioConnectedSlugs: Set<string>, claudeDesktopActiveNames: Set<string>, preferences: Record<string, ToolSource>): ServiceAvailability[];
58
+ /**
59
+ * Build the system-prompt instruction listing which tool source the agent
60
+ * should use for each service. Only includes services where:
61
+ * - There IS a conflict (both sources connected), AND
62
+ * - The user has explicitly picked a non-default preference, OR
63
+ * - The user picked 'off' (so we tell the agent NOT to use it)
64
+ *
65
+ * Returns empty string when no instruction is needed — that's the goal:
66
+ * silent default, zero prompt overhead, until the user actually configures.
67
+ */
68
+ export declare function buildPromptInstruction(availability: ServiceAvailability[], preferences: Record<string, ToolSource>): string;
69
+ //# sourceMappingURL=tool-preferences.d.ts.map
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Tool source preferences — when a service has tools available from
3
+ * multiple MCP sources (e.g., Composio's outlook AND Claude Desktop's
4
+ * Microsoft 365), let the user pick which one the agent should use.
5
+ *
6
+ * Storage: ~/.clementine/tool-preferences.json
7
+ *
8
+ * Design decisions:
9
+ * - Only services with multiple available sources show up. No noise.
10
+ * - When a conflict exists but the user hasn't picked, silently default
11
+ * to Composio (broader scope, OAuth tokens you control).
12
+ * - When no conflict exists (one or zero sources), no preference is
13
+ * needed and no system-prompt clutter is emitted.
14
+ * - The mapping between Composio slugs and Claude Desktop integration
15
+ * names lives here (small, bounded — only services where Claude
16
+ * Desktop has a connector).
17
+ */
18
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
19
+ import path from 'node:path';
20
+ import { BASE_DIR } from '../config.js';
21
+ const PREFS_FILE = path.join(BASE_DIR, 'tool-preferences.json');
22
+ export const KNOWN_SERVICES = [
23
+ { id: 'outlook', label: 'Outlook / Microsoft 365', composioSlug: 'outlook', claudeDesktopName: 'Microsoft_365' },
24
+ { id: 'gmail', label: 'Gmail', composioSlug: 'gmail', claudeDesktopName: 'Gmail' },
25
+ { id: 'googledrive', label: 'Google Drive', composioSlug: 'googledrive', claudeDesktopName: 'Google_Drive' },
26
+ { id: 'googlecalendar', label: 'Google Calendar', composioSlug: 'googlecalendar', claudeDesktopName: 'Google_Calendar' },
27
+ { id: 'googlesheets', label: 'Google Sheets', composioSlug: 'googlesheets', claudeDesktopName: 'Google_Workspace' },
28
+ { id: 'slack', label: 'Slack', composioSlug: 'slack', claudeDesktopName: 'Slack' },
29
+ { id: 'notion', label: 'Notion', composioSlug: 'notion', claudeDesktopName: 'Notion' },
30
+ { id: 'github', label: 'GitHub', composioSlug: 'github', claudeDesktopName: 'GitHub' },
31
+ { id: 'linear', label: 'Linear', composioSlug: 'linear', claudeDesktopName: 'Linear' },
32
+ ];
33
+ const EMPTY_PREFS = { version: 1, preferences: {} };
34
+ export function loadToolPreferences() {
35
+ try {
36
+ if (!existsSync(PREFS_FILE))
37
+ return { ...EMPTY_PREFS, preferences: {} };
38
+ const data = JSON.parse(readFileSync(PREFS_FILE, 'utf-8'));
39
+ if (data?.version !== 1 || typeof data.preferences !== 'object') {
40
+ return { ...EMPTY_PREFS, preferences: {} };
41
+ }
42
+ return data;
43
+ }
44
+ catch {
45
+ return { ...EMPTY_PREFS, preferences: {} };
46
+ }
47
+ }
48
+ export function saveToolPreferences(prefs) {
49
+ const out = {
50
+ version: 1,
51
+ preferences: prefs.preferences,
52
+ updatedAt: new Date().toISOString(),
53
+ };
54
+ writeFileSync(PREFS_FILE, JSON.stringify(out, null, 2), { mode: 0o600 });
55
+ }
56
+ /**
57
+ * Walk every known service and compute availability + effective preference.
58
+ * Pure function — caller passes in what's connected.
59
+ */
60
+ export function computeAvailability(composioConnectedSlugs, claudeDesktopActiveNames, preferences) {
61
+ return KNOWN_SERVICES.map(service => {
62
+ const composioAvailable = !!service.composioSlug && composioConnectedSlugs.has(service.composioSlug);
63
+ const claudeDesktopAvailable = !!service.claudeDesktopName && claudeDesktopActiveNames.has(service.claudeDesktopName);
64
+ const hasConflict = composioAvailable && claudeDesktopAvailable;
65
+ let effective = null;
66
+ const userPref = preferences[service.id];
67
+ if (userPref === 'off') {
68
+ effective = 'off';
69
+ }
70
+ else if (hasConflict) {
71
+ effective = userPref ?? 'composio'; // default to composio when conflict + no pref
72
+ }
73
+ else if (composioAvailable) {
74
+ effective = 'composio';
75
+ }
76
+ else if (claudeDesktopAvailable) {
77
+ effective = 'claude-desktop';
78
+ }
79
+ return { service, composioAvailable, claudeDesktopAvailable, hasConflict, effective };
80
+ });
81
+ }
82
+ /**
83
+ * Build the system-prompt instruction listing which tool source the agent
84
+ * should use for each service. Only includes services where:
85
+ * - There IS a conflict (both sources connected), AND
86
+ * - The user has explicitly picked a non-default preference, OR
87
+ * - The user picked 'off' (so we tell the agent NOT to use it)
88
+ *
89
+ * Returns empty string when no instruction is needed — that's the goal:
90
+ * silent default, zero prompt overhead, until the user actually configures.
91
+ */
92
+ export function buildPromptInstruction(availability, preferences) {
93
+ const lines = [];
94
+ for (const a of availability) {
95
+ if (!a.hasConflict)
96
+ continue;
97
+ const userPref = preferences[a.service.id];
98
+ if (!userPref)
99
+ continue; // no explicit pref → silent default, no prompt cost
100
+ if (userPref === 'off') {
101
+ lines.push(`- ${a.service.label}: do NOT use any of its tools (user disabled)`);
102
+ }
103
+ else if (userPref === 'composio' && a.service.composioSlug) {
104
+ lines.push(`- ${a.service.label}: use \`mcp__${a.service.composioSlug}__*\` (NOT \`mcp__claude_ai_${a.service.claudeDesktopName}__*\`)`);
105
+ }
106
+ else if (userPref === 'claude-desktop' && a.service.claudeDesktopName) {
107
+ lines.push(`- ${a.service.label}: use \`mcp__claude_ai_${a.service.claudeDesktopName}__*\` (NOT \`mcp__${a.service.composioSlug}__*\`)`);
108
+ }
109
+ }
110
+ if (lines.length === 0)
111
+ return '';
112
+ return `## Tool Source Preferences\n\n${lines.join('\n')}`;
113
+ }
114
+ //# sourceMappingURL=tool-preferences.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.12.4",
3
+ "version": "1.13.0",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",