clementine-agent 1.11.3 → 1.12.1

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.
@@ -4402,10 +4402,22 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
4402
4402
  res.json({ enabled: false, toolkits: [] });
4403
4403
  return;
4404
4404
  }
4405
- const [connected, configured, meta] = await Promise.all([
4405
+ // Fetch live: full catalog + user's connections + auth-config slugs.
4406
+ // This replaces the hardcoded CURATED_TOOLKITS — slug typos are now
4407
+ // impossible because we render directly from Composio's data, and
4408
+ // newly-added services appear automatically.
4409
+ let catalog = [];
4410
+ let catalogError = null;
4411
+ try {
4412
+ catalog = await c.listAllToolkits();
4413
+ }
4414
+ catch (err) {
4415
+ catalogError = err?.message ?? String(err);
4416
+ console.error('[composio] catalog fetch failed:', catalogError);
4417
+ }
4418
+ const [connected, configured] = await Promise.all([
4406
4419
  c.listConnectedToolkits(),
4407
4420
  c.listToolkitSlugsWithAuthConfig(),
4408
- c.listToolkitMeta(),
4409
4421
  ]);
4410
4422
  const connectionsBySlug = new Map();
4411
4423
  for (const conn of connected) {
@@ -4426,39 +4438,53 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
4426
4438
  accountAvatarUrl: conn.accountAvatarUrl ?? null,
4427
4439
  createdAt: conn.createdAt ?? null,
4428
4440
  });
4429
- const curated = c.CURATED_TOOLKITS.map(t => {
4430
- const m = meta.get(t.slug);
4431
- const conns = connectionsBySlug.get(t.slug) ?? [];
4432
- return {
4441
+ // Merge: every catalog entry, plus any connection whose slug isn't in
4442
+ // the catalog (defensive — catalog dedupes are rare but possible).
4443
+ const catalogBySlug = new Map(catalog.map(t => [t.slug, t]));
4444
+ const orphanSlugs = [...connectionsBySlug.keys()].filter(s => !catalogBySlug.has(s));
4445
+ const toolkits = [
4446
+ ...catalog.map(t => ({
4433
4447
  slug: t.slug,
4434
- displayName: t.displayName,
4448
+ displayName: t.name,
4435
4449
  authMode: t.authMode,
4436
4450
  hasAuthConfig: configured.has(t.slug),
4437
- logoUrl: m?.logo ?? null,
4438
- description: m?.description ?? null,
4439
- toolCount: m?.toolsCount ?? null,
4440
- connections: conns.map(toView),
4441
- };
4442
- });
4443
- const extras = [...connectionsBySlug.entries()]
4444
- .filter(([slug]) => !c.CURATED_TOOLKITS.some(t => t.slug === slug))
4445
- .map(([slug, conns]) => {
4446
- const m = meta.get(slug);
4447
- // Non-curated: infer auth mode. If a custom auth config exists,
4448
- // user set it up themselves (BYO). Otherwise it must be managed.
4449
- const authMode = configured.has(slug) ? 'byo' : 'managed';
4450
- return {
4451
+ logoUrl: t.logoUrl ?? null,
4452
+ description: t.description ?? null,
4453
+ toolCount: t.toolsCount ?? null,
4454
+ categories: t.categories,
4455
+ connections: (connectionsBySlug.get(t.slug) ?? []).map(toView),
4456
+ })),
4457
+ ...orphanSlugs.map(slug => ({
4451
4458
  slug,
4452
- displayName: m?.name ?? c.displayNameFor(slug),
4453
- authMode,
4459
+ displayName: c.displayNameFor(slug),
4460
+ authMode: 'managed',
4454
4461
  hasAuthConfig: configured.has(slug),
4455
- logoUrl: m?.logo ?? null,
4456
- description: m?.description ?? null,
4457
- toolCount: m?.toolsCount ?? null,
4458
- connections: conns.map(toView),
4459
- };
4462
+ logoUrl: null,
4463
+ description: null,
4464
+ toolCount: null,
4465
+ categories: [],
4466
+ connections: (connectionsBySlug.get(slug) ?? []).map(toView),
4467
+ })),
4468
+ ];
4469
+ // Featured set = toolkits with active connections (highest signal),
4470
+ // then top toolkits by tool count. Cap at 30 to keep the default
4471
+ // grid focused; the search box covers the rest.
4472
+ const FEATURED_LIMIT = 30;
4473
+ const connectedSlugs = new Set(connectionsBySlug.keys());
4474
+ const featured = [
4475
+ ...toolkits.filter(t => connectedSlugs.has(t.slug)),
4476
+ ...toolkits
4477
+ .filter(t => !connectedSlugs.has(t.slug))
4478
+ .sort((a, b) => (b.toolCount ?? 0) - (a.toolCount ?? 0))
4479
+ .slice(0, Math.max(0, FEATURED_LIMIT - connectedSlugs.size)),
4480
+ ];
4481
+ res.json({
4482
+ enabled: true,
4483
+ toolkits,
4484
+ featured: featured.map(t => t.slug),
4485
+ totalCount: toolkits.length,
4486
+ catalogError,
4460
4487
  });
4461
- res.json({ enabled: true, toolkits: [...curated, ...extras] });
4462
4488
  }
4463
4489
  catch (err) {
4464
4490
  res.status(500).json({ error: String(err) });
@@ -25777,56 +25803,118 @@ async function refreshComposioConnections() {
25777
25803
  }
25778
25804
  var toolkits = d.toolkits || [];
25779
25805
  if (toolkits.length === 0) {
25780
- container.innerHTML = '<div class="empty-state" style="padding:16px">No toolkits available. Check that your Composio API key is valid.</div>';
25806
+ // Distinguish "catalog fetch failed" from "user has nothing connected".
25807
+ // Surfacing d.catalogError tells us if Composio rejected the catalog
25808
+ // call (different permission than connectedAccounts.list, etc.).
25809
+ var msg = d.catalogError
25810
+ ? 'Could not load toolkits from Composio: <code style="color:var(--red);font-size:11px">' + esc(d.catalogError) + '</code>'
25811
+ : 'Composio returned an empty toolkit catalog. This is unusual — try refreshing.';
25812
+ container.innerHTML =
25813
+ '<div style="padding:16px">' +
25814
+ '<div style="margin-bottom:12px;font-size:13px">' + msg + '</div>' +
25815
+ '<div style="font-size:11px;color:var(--text-muted);margin-bottom:12px">If your existing connections (e.g. Outlook) work via the agent, your API key is valid — the catalog endpoint may require a different permission tier or there\\'s a transient API issue.</div>' +
25816
+ '<button class="btn btn-sm btn-primary" onclick="refreshComposioConnections()">Retry</button>' +
25817
+ '</div>';
25781
25818
  return;
25782
25819
  }
25820
+ // Stash full catalog in a closure so the search input can re-render
25821
+ // without refetching. Source of truth lives on the window object so
25822
+ // the input's oninput handler can find it.
25823
+ window._composioCatalog = {
25824
+ toolkits: toolkits,
25825
+ featured: new Set(d.featured || []),
25826
+ total: d.totalCount || toolkits.length,
25827
+ };
25828
+ renderComposioCatalog('');
25829
+ } catch (e) {
25830
+ container.innerHTML = '<div class="empty-state" style="color:var(--red);padding:16px">Failed to load Composio toolkits: ' + esc(String(e)) + '</div>';
25831
+ }
25832
+ }
25783
25833
 
25784
- var html = '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px">';
25785
- toolkits.forEach(function(t) {
25786
- var connected = (t.connections || []).filter(function(c) { return c.status === 'ACTIVE'; });
25787
- var pending = (t.connections || []).filter(function(c) { return c.status !== 'ACTIVE'; });
25788
- var isConnected = connected.length > 0;
25789
- var statusColor = isConnected ? 'var(--green)' : (pending.length > 0 ? 'var(--yellow,#f59e0b)' : 'var(--text-muted)');
25790
- var byo = t.authMode === 'byo' && !t.hasAuthConfig;
25834
+ function renderComposioCatalog(query) {
25835
+ var container = document.getElementById('composio-connections');
25836
+ if (!container) return;
25837
+ var cat = window._composioCatalog;
25838
+ if (!cat) return;
25839
+ var q = (query || '').trim().toLowerCase();
25840
+ var visible;
25841
+ if (q.length === 0) {
25842
+ // Default view: featured set (connected toolkits + most-popular ones).
25843
+ visible = cat.toolkits.filter(function(t) { return cat.featured.has(t.slug); });
25844
+ } else {
25845
+ // Search across slug, name, and description. Cap to 80 to keep DOM
25846
+ // size reasonable when the query matches a generic word.
25847
+ visible = cat.toolkits.filter(function(t) {
25848
+ return t.slug.toLowerCase().indexOf(q) !== -1
25849
+ || (t.displayName||'').toLowerCase().indexOf(q) !== -1
25850
+ || (t.description||'').toLowerCase().indexOf(q) !== -1;
25851
+ }).slice(0, 80);
25852
+ }
25853
+ // Always show connected toolkits first within the visible set.
25854
+ visible.sort(function(a, b) {
25855
+ var aConn = (a.connections||[]).some(function(c){ return c.status==='ACTIVE'; }) ? 0 : 1;
25856
+ var bConn = (b.connections||[]).some(function(c){ return c.status==='ACTIVE'; }) ? 0 : 1;
25857
+ if (aConn !== bConn) return aConn - bConn;
25858
+ return (b.toolCount||0) - (a.toolCount||0);
25859
+ });
25791
25860
 
25792
- html += '<div style="border:1px solid var(--border);border-radius:8px;padding:12px;background:var(--bg-secondary);display:flex;flex-direction:column;gap:8px">';
25793
- html += '<div style="display:flex;align-items:center;gap:10px">';
25794
- if (t.logoUrl) {
25795
- 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">';
25796
- } else {
25797
- html += '<div style="width:24px;height:24px;border-radius:4px;background:var(--bg-tertiary);flex-shrink:0"></div>';
25798
- }
25799
- html += '<div style="flex:1;min-width:0">';
25800
- 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>';
25801
- html += '<div style="font-size:10px;color:var(--text-muted)">' + (isConnected ? connected.length + ' account' + (connected.length !== 1 ? 's' : '') : 'Not connected') + '</div>';
25802
- html += '</div>';
25803
- html += '<span style="width:8px;height:8px;border-radius:50%;background:' + statusColor + ';flex-shrink:0"></span>';
25804
- html += '</div>';
25861
+ var header = '<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px">'
25862
+ + '<input type="search" id="composio-search" value="' + esc(q) + '" placeholder="Search ' + cat.total + ' toolkits — Gmail, Slack, Notion, …" oninput="renderComposioCatalog(this.value)"'
25863
+ + ' style="flex:1;padding:8px 12px;border:1px solid var(--border);border-radius:6px;background:var(--bg-input);color:var(--text-primary);font-size:13px">'
25864
+ + '<span style="font-size:11px;color:var(--text-muted);white-space:nowrap">' + visible.length + (q.length === 0 ? ' featured' : ' match' + (visible.length !== 1 ? 'es' : '')) + ' / ' + cat.total + ' total</span>'
25865
+ + '</div>';
25805
25866
 
25806
- if (connected.length > 0) {
25807
- html += '<div style="display:flex;flex-direction:column;gap:4px">';
25808
- connected.forEach(function(c) {
25809
- var label = c.alias || c.accountLabel || c.accountEmail || c.accountName || 'connected';
25810
- html += '<div style="display:flex;align-items:center;gap:6px;font-size:11px">';
25811
- html += '<span style="flex:1;color:var(--text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + esc(label) + '</span>';
25812
- 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>';
25813
- html += '</div>';
25814
- });
25815
- html += '</div>';
25816
- }
25867
+ if (visible.length === 0) {
25868
+ container.innerHTML = header + '<div class="empty-state" style="padding:16px">No toolkits match "' + esc(q) + '". Try a different search.</div>';
25869
+ setTimeout(function() { var s = document.getElementById('composio-search'); if (s) { s.focus(); s.setSelectionRange(s.value.length, s.value.length); } }, 0);
25870
+ return;
25871
+ }
25817
25872
 
25818
- if (byo) {
25819
- 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>';
25820
- }
25873
+ var html = header + '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px">';
25874
+ visible.forEach(function(t) {
25875
+ var connected = (t.connections || []).filter(function(c) { return c.status === 'ACTIVE'; });
25876
+ var pending = (t.connections || []).filter(function(c) { return c.status !== 'ACTIVE'; });
25877
+ var isConnected = connected.length > 0;
25878
+ var statusColor = isConnected ? 'var(--green)' : (pending.length > 0 ? 'var(--yellow,#f59e0b)' : 'var(--text-muted)');
25879
+ var byo = t.authMode === 'byo' && !t.hasAuthConfig;
25880
+
25881
+ html += '<div style="border:1px solid var(--border);border-radius:8px;padding:12px;background:var(--bg-secondary);display:flex;flex-direction:column;gap:8px">';
25882
+ html += '<div style="display:flex;align-items:center;gap:10px">';
25883
+ if (t.logoUrl) {
25884
+ 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">';
25885
+ } else {
25886
+ html += '<div style="width:24px;height:24px;border-radius:4px;background:var(--bg-tertiary);flex-shrink:0"></div>';
25887
+ }
25888
+ html += '<div style="flex:1;min-width:0">';
25889
+ 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>';
25890
+ html += '<div style="font-size:10px;color:var(--text-muted)">' + (isConnected ? connected.length + ' account' + (connected.length !== 1 ? 's' : '') : ((t.toolCount||0) + ' tools')) + '</div>';
25891
+ html += '</div>';
25892
+ html += '<span style="width:8px;height:8px;border-radius:50%;background:' + statusColor + ';flex-shrink:0"></span>';
25893
+ html += '</div>';
25821
25894
 
25822
- html += '<button class="btn btn-sm btn-primary" onclick="connectComposio(\\'' + esc(t.slug) + '\\')" style="font-size:11px">' + (isConnected ? '+ Add another account' : 'Connect') + '</button>';
25895
+ if (connected.length > 0) {
25896
+ html += '<div style="display:flex;flex-direction:column;gap:4px">';
25897
+ connected.forEach(function(c) {
25898
+ var label = c.alias || c.accountLabel || c.accountEmail || c.accountName || 'connected';
25899
+ html += '<div style="display:flex;align-items:center;gap:6px;font-size:11px">';
25900
+ html += '<span style="flex:1;color:var(--text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + esc(label) + '</span>';
25901
+ 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>';
25902
+ html += '</div>';
25903
+ });
25823
25904
  html += '</div>';
25824
- });
25905
+ }
25906
+
25907
+ if (byo) {
25908
+ 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>';
25909
+ }
25910
+
25911
+ html += '<button class="btn btn-sm btn-primary" onclick="connectComposio(\\'' + esc(t.slug) + '\\')" style="font-size:11px">' + (isConnected ? '+ Add account' : 'Connect') + '</button>';
25825
25912
  html += '</div>';
25826
- container.innerHTML = html;
25827
- } catch (e) {
25828
- container.innerHTML = '<div class="empty-state" style="color:var(--red);padding:16px">Failed to load Composio toolkits: ' + esc(String(e)) + '</div>';
25829
- }
25913
+ });
25914
+ html += '</div>';
25915
+ container.innerHTML = html;
25916
+ // Restore focus to the search input after re-render so users can keep typing.
25917
+ setTimeout(function() { var s = document.getElementById('composio-search'); if (s) { s.focus(); s.setSelectionRange(s.value.length, s.value.length); } }, 0);
25830
25918
  }
25831
25919
 
25832
25920
  async function saveComposioApiKey() {
@@ -54,6 +54,35 @@ export interface ToolkitMeta {
54
54
  description?: string;
55
55
  toolsCount?: number;
56
56
  }
57
+ /**
58
+ * Full catalog entry — derived directly from Composio's API. Replaces the
59
+ * hardcoded CURATED_TOOLKITS for UI rendering. The dashboard uses this so
60
+ * users can browse/search the entire catalog (1000+ services) instead of
61
+ * being limited to whatever slugs are pinned in code.
62
+ */
63
+ export interface CatalogToolkit {
64
+ slug: string;
65
+ name: string;
66
+ logoUrl?: string;
67
+ description?: string;
68
+ toolsCount?: number;
69
+ /** managed = Composio hosts the OAuth app; byo = user must register their
70
+ * own; none = no auth required. Derived from composioManagedAuthSchemes. */
71
+ authMode: 'managed' | 'byo' | 'none';
72
+ categories: {
73
+ slug: string;
74
+ name: string;
75
+ }[];
76
+ }
77
+ /**
78
+ * Fetch the full Composio toolkit catalog (1000+ services). Replaces the
79
+ * static CURATED_TOOLKITS array as the source of truth for the dashboard
80
+ * Connections panel — slug typos are now impossible because we render from
81
+ * Composio's own data, and new services appear automatically as they're
82
+ * added. Cached at module level for 1 hour on success; failures don't
83
+ * cache (so the next request retries instead of returning stale empty).
84
+ */
85
+ export declare function listAllToolkits(): Promise<CatalogToolkit[]>;
57
86
  export declare function listToolkitMeta(): Promise<Map<string, ToolkitMeta>>;
58
87
  export declare function listToolkitSlugsWithAuthConfig(): Promise<Set<string>>;
59
88
  export declare class ComposioNeedsAuthConfigError extends Error {
@@ -82,6 +82,7 @@ export function resetComposioClient() {
82
82
  singleton = null;
83
83
  identityCache.clear();
84
84
  toolkitMetaCache = null;
85
+ catalogCache = null;
85
86
  detectedPreferredUserId = null;
86
87
  }
87
88
  // Public: same logic as the internal detector, exposed for the MCP bridge so
@@ -334,6 +335,95 @@ export async function listConnectedToolkits() {
334
335
  }
335
336
  }
336
337
  let toolkitMetaCache = null;
338
+ let catalogCache = null;
339
+ const CATALOG_TTL_MS = 60 * 60 * 1000; // 1h — catalog drifts very slowly
340
+ /**
341
+ * Fetch the full Composio toolkit catalog (1000+ services). Replaces the
342
+ * static CURATED_TOOLKITS array as the source of truth for the dashboard
343
+ * Connections panel — slug typos are now impossible because we render from
344
+ * Composio's own data, and new services appear automatically as they're
345
+ * added. Cached at module level for 1 hour on success; failures don't
346
+ * cache (so the next request retries instead of returning stale empty).
347
+ */
348
+ export async function listAllToolkits() {
349
+ const now = Date.now();
350
+ if (catalogCache && catalogCache.data.length > 0 && now - catalogCache.at < CATALOG_TTL_MS) {
351
+ return catalogCache.data;
352
+ }
353
+ const composio = getComposio();
354
+ if (!composio)
355
+ return [];
356
+ // Try the raw client first — supports cursor pagination so we get the full
357
+ // 1000+ catalog. If that errors (some API keys / plans restrict it), fall
358
+ // back to the high-level wrapper which returns up to 500 in one shot.
359
+ let result = [];
360
+ let lastError = null;
361
+ try {
362
+ result = await fetchCatalogViaRawClient(composio);
363
+ }
364
+ catch (err) {
365
+ lastError = err;
366
+ logger.warn({ err }, 'Raw-client toolkit list failed — falling back to high-level wrapper');
367
+ }
368
+ if (result.length === 0) {
369
+ try {
370
+ result = await fetchCatalogViaWrapper(composio);
371
+ }
372
+ catch (err) {
373
+ lastError = err;
374
+ logger.error({ err }, 'High-level toolkit list also failed');
375
+ }
376
+ }
377
+ if (result.length === 0) {
378
+ // Surface whichever error we hit so the dashboard can render something
379
+ // actionable instead of "no toolkits available".
380
+ const detail = lastError?.message ?? 'Unknown error';
381
+ throw new Error(`Composio catalog fetch failed: ${detail}`);
382
+ }
383
+ catalogCache = { at: now, data: result };
384
+ logger.info({ count: result.length }, 'Composio catalog fetched');
385
+ return result;
386
+ }
387
+ function normalizeCatalogItem(it) {
388
+ const managed = (it.composioManagedAuthSchemes ?? it.composio_managed_auth_schemes ?? []);
389
+ const schemes = (it.authSchemes ?? it.auth_schemes ?? []);
390
+ const noAuth = it.noAuth ?? it.no_auth ?? false;
391
+ return {
392
+ slug: it.slug,
393
+ name: it.name,
394
+ logoUrl: it.meta?.logo,
395
+ description: it.meta?.description,
396
+ toolsCount: it.meta?.toolsCount ?? it.meta?.tools_count,
397
+ authMode: noAuth ? 'none' : (managed.length > 0 ? 'managed' : (schemes.length > 0 ? 'byo' : 'none')),
398
+ categories: it.meta?.categories ?? [],
399
+ };
400
+ }
401
+ async function fetchCatalogViaRawClient(composio) {
402
+ const out = [];
403
+ let cursor;
404
+ // Bounded loop — 30 pages × 500 items = 15K ceiling.
405
+ for (let page = 0; page < 30; page++) {
406
+ const rawClient = composio.client;
407
+ const resp = await rawClient.toolkits.list({
408
+ limit: 500,
409
+ ...(cursor ? { cursor } : {}),
410
+ });
411
+ const items = (resp?.items ?? []);
412
+ for (const it of items)
413
+ out.push(normalizeCatalogItem(it));
414
+ cursor = resp?.next_cursor ?? resp?.nextCursor;
415
+ if (!cursor || items.length === 0)
416
+ break;
417
+ }
418
+ return out;
419
+ }
420
+ async function fetchCatalogViaWrapper(composio) {
421
+ // High-level wrapper returns an array (up to limit). No cursor support
422
+ // through this path, so we cap at the documented max. Better than zero.
423
+ const resp = await composio.toolkits.get({ limit: 500 });
424
+ const items = (Array.isArray(resp) ? resp : (resp?.items ?? []));
425
+ return items.map(normalizeCatalogItem);
426
+ }
337
427
  async function fetchAllToolkitMeta() {
338
428
  const composio = getComposio();
339
429
  if (!composio)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.11.3",
3
+ "version": "1.12.1",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",