@cachly-dev/mcp-server 0.10.48 → 0.10.55

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.
@@ -6,16 +6,57 @@ import { ckgSlug, extractProblemConcept, ckgUpsertNode, ckgUpdateEdge } from '..
6
6
  import { safeJsonParse } from '../utils.js';
7
7
  import type { CKGEdge, CKGNode } from '../ckg.js';
8
8
  import { keywordSearch, tokenize, splitMultiQuery, levenshtein,
9
- ZERO_RESULTS_LOG, zeroResultsTotal, indexVocab as _indexVocab } from '../search.js';
9
+ indexVocab as _indexVocab } from '../search.js';
10
10
  import type { KeywordMatch } from '../search.js';
11
- import { computeEmbedding, hasEmbedProvider, embedProviderHint, EMBED_PROVIDER } from '../embeddings.js';
12
- import { detectNamespace } from '../namespace.js';
11
+ import { computeEmbedding, hasEmbedProvider } from '../embeddings.js';
13
12
 
14
13
  // ── Changelog (shown once per version in session_start) ──────────────────────
15
- const MCP_VERSION = '0.10.0';
14
+ const MCP_VERSION = '0.10.55';
16
15
  const WHATS_NEW: Record<string, string[]> = {
16
+ '0.10.55': [
17
+ `🆕 **What's new in cachly MCP v0.10.55:**`,
18
+ ` ⚡ UX: API failure now shows a clear error instead of silently blocking for 30s`,
19
+ ` ⚡ UX: provisioning wait reduced from 25s → 8s (Brain is usually ready sooner)`,
20
+ ` 🔧 VS Code: setup spinner is now cancellable — dismiss and MCP auto-connects later`,
21
+ ` 🧠 VS Code: Brain Health panel shows offline-queued lessons with pending sync count`,
22
+ ],
23
+ '0.10.54': [
24
+ `🆕 **What's new in cachly MCP v${MCP_VERSION}:**`,
25
+ ` 👀 Perf: first tool call no longer blocked by auto-session (was freezing for 30-120s on large repos)`,
26
+ ` 👀 Perf: git history bootstrap runs in background — session_start responds instantly`,
27
+ ` 🐞 Fixed: VS Code lessons now include author attribution (broken since launch)`,
28
+ ],
29
+ '0.10.53': [
30
+ `🆕 **What's new in cachly MCP v${MCP_VERSION}:**`,
31
+ ` 🐛 Fixed: Brain bootstrap summary now visible in session_start (was silently discarded)`,
32
+ ` 🐛 Fixed: team lessons now indexed correctly (\`ts\` field was stored as \`timestamp\`)`,
33
+ ` ✨ New: proactive follow-up hints after \`learn_from_attempts\` (CKG co-occurrence patterns)`,
34
+ ` ✨ New: author name attribution for VS Code lessons (set \`cachly.authorName\` in VS Code settings)`,
35
+ ],
36
+ '0.10.52': [
37
+ `🆕 **What's new in cachly MCP v0.10.52:**`,
38
+ ` 🚀 Zero-click setup: Brain activates automatically on first call (no auth prompt needed)`,
39
+ ` 🐛 Fixed: all Brain connections now use decrypted passwords (resolves AUTH errors for all users)`,
40
+ ],
41
+ '0.10.50': [
42
+ `🆕 **What's new in cachly MCP v0.10.50:**`,
43
+ ` 🐛 Fixed: Brain connection now works out of the box (was failing with AUTH error)`,
44
+ ` 🐛 Fixed: TLS defaults to off for free-tier instances (no more TLS handshake failure)`,
45
+ ` ✅ \`get_api_status\` correctly shows API key type instead of "invalid JWT format"`,
46
+ ],
47
+ '0.10.46': [
48
+ `🆕 **What's new in v0.10.46:**`,
49
+ ` ✅ \`brain_from_git\` — auto-learns from your full git history on setup`,
50
+ ` ✅ \`smart_recall\` — semantic + keyword hybrid search, 3× more relevant results`,
51
+ ` ✅ VS Code Extension — Brain persists across all projects, zero config`,
52
+ ` ✅ Device-flow auth — zero-config login, no JWT needed in editor config`,
53
+ ` ✅ Auto-provision — Brain instance created automatically after first login`,
54
+ ` ✅ JWT now persisted to ~/.claude/mcp.json — survives editor restarts`,
55
+ ` ✅ Confidence scoring — every recalled fact shows staleness + reliability`,
56
+ ` ✅ CKG (Causal Knowledge Graph) — root-cause chains between bugs + fixes`,
57
+ ],
17
58
  '0.10.0': [
18
- `🆕 **What's new in v${MCP_VERSION}:**`,
59
+ `🆕 **What's new in v0.10.0:**`,
19
60
  ` ✅ Zero-friction setup — auto-provisions your Brain, no manual config needed`,
20
61
  ` ✅ 10× faster briefings — batch Redis fetches replace per-key loops`,
21
62
  ` ✅ Crash-proof data reads — corrupted cache entries no longer break tools`,
@@ -183,7 +224,7 @@ export async function handleBrainTool(
183
224
  const existing = await redis.get(depKey);
184
225
  const depTopics: string[] = safeJsonParse<string[]>(existing, []);
185
226
  if (!depTopics.includes(topic)) depTopics.push(topic);
186
- await redis.set(depKey, JSON.stringify(depTopics));
227
+ await redis.set(depKey, JSON.stringify(depTopics), 'EX', 365 * 86400); // 1-year TTL
187
228
  }
188
229
 
189
230
  const lessonObj = {
@@ -207,9 +248,11 @@ export async function handleBrainTool(
207
248
  };
208
249
  const lesson = JSON.stringify(lessonObj);
209
250
 
210
- // Always append to the history list (audit log)
251
+ // Always append to the history list (audit log); cap at 200 entries to prevent unbounded growth
211
252
  const listKey = `cachly:lessons:${topic}`;
212
253
  await redis.rpush(listKey, lesson);
254
+ await redis.ltrim(listKey, -200, -1);
255
+ await redis.expire(listKey, 365 * 86400); // 1-year TTL
213
256
 
214
257
  // Update best key for success/partial; for failure only update if no success exists
215
258
  if (outcome === 'success' || outcome === 'partial') {
@@ -342,6 +385,44 @@ export async function handleBrainTool(
342
385
  }
343
386
  } catch { /* CKG updates are non-critical */ }
344
387
 
388
+ // ── Proactive follow-up suggestion (CKG co-occurrence patterns) ─────────
389
+ // After a successful fix, check if related concepts commonly follow it.
390
+ // Uses CKG co-occurs edges with confidence > 0.4 and at least 2 trials.
391
+ const proactiveSuggestions: string[] = [];
392
+ if (outcome === 'success' || outcome === 'partial') {
393
+ try {
394
+ const conceptId = ckgSlug(topic);
395
+ const fromEdgeKeys = await redis.smembers(`cachly:ckg:idx:from:${conceptId}`);
396
+ for (const ek of fromEdgeKeys.slice(0, 20)) {
397
+ const er = await redis.get(ek);
398
+ if (!er) continue;
399
+ const edge = safeJsonParse<CKGEdge | null>(er, null);
400
+ if (!edge || edge.confidence < 0.4 || (edge.trials ?? 0) < 2) continue;
401
+ if (edge.edgeType !== 'co-occurs' && edge.edgeType !== 'causes') continue;
402
+ // Resolve related concept to a lesson key
403
+ const relTopic = edge.to
404
+ .replace(/^cause:/, '')
405
+ .replace(/^tag:/, '')
406
+ .replace(/^problem:/, '')
407
+ .replace(/-/g, ':')
408
+ .replace(/_/g, '-');
409
+ const relRaw = await redis.get(`cachly:lesson:best:${relTopic}`);
410
+ if (!relRaw) continue;
411
+ const relLesson = safeJsonParse<{ topic?: string; what_worked?: string } | null>(relRaw, null);
412
+ const displayTopic = relLesson?.topic ?? relTopic;
413
+ if (!relLesson || !relLesson.what_worked || displayTopic === topic) continue;
414
+ const pct = Math.round(edge.confidence * 100);
415
+ if (proactiveSuggestions.length === 0) proactiveSuggestions.push('');
416
+ proactiveSuggestions.push(
417
+ `🔮 **Pattern detected:** \`${displayTopic}\` tends to follow this (${pct}% co-occurrence across ${edge.trials} sessions)`,
418
+ ` → ${relLesson.what_worked.slice(0, 100)}${relLesson.what_worked.length > 100 ? '…' : ''}`,
419
+ ` → Pre-empt it now: \`recall_best_solution(topic="${displayTopic}")\``,
420
+ );
421
+ break; // one proactive hint is enough
422
+ }
423
+ } catch { /* non-critical */ }
424
+ }
425
+
345
426
  const emoji = outcome === 'success' ? '✅' : outcome === 'partial' ? '⚠️' : '❌';
346
427
  const sevEmoji = severity === 'critical' ? '🔴' : severity === 'major' ? '🟡' : '🟢';
347
428
  const action = isUpdate ? 'updated' : 'stored';
@@ -364,6 +445,7 @@ export async function handleBrainTool(
364
445
  : '',
365
446
  ...templateWarnings,
366
447
  ...iWasWrongWarning,
448
+ ...proactiveSuggestions,
367
449
  ].filter(l => l !== '').join('\n');
368
450
  }
369
451
 
@@ -109,7 +109,9 @@ export async function handleTeamTool(
109
109
  file_paths: file_paths ?? [],
110
110
  commands: commands ?? [],
111
111
  tags: [...(tags ?? []), 'team'],
112
- timestamp: new Date().toISOString(),
112
+ ts: new Date().toISOString(),
113
+ verified_at: outcome === 'success' ? new Date().toISOString() : undefined,
114
+ confidence: 1.0,
113
115
  recall_count: 0,
114
116
  version: 2,
115
117
  };
package/src/index.ts CHANGED
@@ -1,8 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { jwtExpiryMs, checkJwt, handleApiError } from './auth.js';
3
3
  import { readFile } from 'node:fs/promises';
4
- import { execSync } from 'node:child_process';
5
- import { join } from 'node:path';
6
4
  import { fileURLToPath } from 'node:url';
7
5
 
8
6
  // True only when this file is the entry point (not imported by tests or other modules).
@@ -60,8 +58,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
60
58
 
61
59
  const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
62
60
  let JWT = process.env.CACHLY_JWT ?? '';
63
- const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
64
- const CURRENT_VERSION = '0.10.47';
61
+ const CURRENT_VERSION = '0.10.55';
65
62
 
66
63
  // ── Per-request context (multi-tenant HTTP / Smithery) ───────────────────────
67
64
  // In stdio mode the module-level JWT/instance globals apply.
@@ -86,8 +83,18 @@ async function resolveDefaultInstanceId(): Promise<string> {
86
83
  if (_defaultInstanceId) return _defaultInstanceId;
87
84
  if (!getJWT()) return '';
88
85
  // Cooldown: don't hammer the API on every tool call after a transient failure.
86
+ // 10 s is enough to avoid rate-limit spam while not blocking the user for too long.
89
87
  const now = Date.now();
90
- if (_defaultInstanceLastAttempt > 0 && now - _defaultInstanceLastAttempt < 30_000) return '';
88
+ if (_defaultInstanceLastAttempt > 0 && now - _defaultInstanceLastAttempt < 10_000) {
89
+ throw new McpError(
90
+ ErrorCode.InvalidRequest,
91
+ '⚠️ Cachly Brain is temporarily unreachable (API error a few seconds ago).\n\n' +
92
+ 'The server will retry automatically in a moment. If this persists:\n' +
93
+ '• Run `get_api_status` for a full diagnostic\n' +
94
+ '• Check that CACHLY_JWT is set and valid\n' +
95
+ '• Visit https://cachly.dev/instances to verify your Brain is running'
96
+ );
97
+ };
91
98
  _defaultInstanceLastAttempt = now;
92
99
  try {
93
100
  const res = await fetch(`${API_URL}/api/v1/instances`, {
@@ -178,6 +185,26 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
178
185
  } catch { /* use raw JWT as fallback */ }
179
186
  JWT = apiKey;
180
187
  setEmbedJwt(apiKey); // keep embeddings.ts in sync
188
+ // Persist API key to ~/.claude/mcp.json so the next editor restart
189
+ // doesn't start over from a missing CACHLY_JWT (fixes BrainRecallCount=0 bug).
190
+ try {
191
+ const { resolve: _res, dirname: _dir } = await import('node:path');
192
+ const { homedir: _hd } = await import('node:os');
193
+ const { writeFile: _wf, mkdir: _mk, readFile: _rf } = await import('node:fs/promises');
194
+ const { existsSync: _ex } = await import('node:fs');
195
+ const globalJwtPath = _res(_hd(), '.claude', 'mcp.json');
196
+ await _mk(_dir(globalJwtPath), { recursive: true });
197
+ let cfg: { mcpServers?: Record<string, { env?: Record<string, string> }> } = {};
198
+ if (_ex(globalJwtPath)) {
199
+ try { cfg = JSON.parse(await _rf(globalJwtPath, 'utf-8')) as typeof cfg; } catch { /* corrupt — start fresh */ }
200
+ }
201
+ cfg.mcpServers ??= {};
202
+ cfg.mcpServers['cachly'] ??= {};
203
+ cfg.mcpServers['cachly'].env ??= {};
204
+ cfg.mcpServers['cachly'].env['CACHLY_JWT'] = apiKey;
205
+ if (_defaultInstanceId) cfg.mcpServers['cachly'].env['CACHLY_BRAIN_INSTANCE_ID'] = _defaultInstanceId;
206
+ await _wf(globalJwtPath, JSON.stringify(cfg, null, 2), 'utf-8');
207
+ } catch { /* non-fatal — editor will just prompt again on next restart */ }
181
208
  _deviceFlow = null;
182
209
  sendFunnelEvent('device_flow_completed');
183
210
  // Auto-provision: find or create the user's brain instance.
@@ -243,22 +270,61 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
243
270
  return 'pending';
244
271
  } catch { return 'pending'; }
245
272
  }
246
- // ── Confidence utils (imported from confidence.ts) ──────────────────────────
247
- import { calculateConfidence, confidenceBadge, STRUCTURED_TEMPLATES,
248
- CONFIDENCE_WARN_VALUE, CONFIDENCE_STALE_VALUE, CONFIDENCE_WARN_DAYS,
249
- CONFIDENCE_STALE_DAYS,
250
- simpleHash } from './confidence.js';
273
+ // ── Silent Auto-Setup (instant-trial, zero-click) ─────────────────────────
274
+ // Attempts to provision a free trial silently on the first tool call when no
275
+ // JWT is set. Returns true if credentials were written and the tool can
276
+ // proceed transparently; false if the API is unreachable or trials are full.
277
+ // Only runs once per process (subsequent failures short-circuit immediately).
278
+ let _silentSetupAttempted = false;
279
+ async function silentAutoSetup(): Promise<boolean> {
280
+ if (_silentSetupAttempted) return false;
281
+ _silentSetupAttempted = true;
282
+ try {
283
+ type TrialResp = { api_key: string; expires_at?: string; instance_id?: string; trial?: boolean };
284
+ const res = await fetch(`${API_URL}/auth/instant-trial`, {
285
+ method: 'POST',
286
+ headers: { 'Content-Type': 'application/json' },
287
+ body: JSON.stringify({}),
288
+ signal: AbortSignal.timeout(8000),
289
+ });
290
+ if (!res.ok) return false;
291
+ const data = await res.json() as TrialResp;
292
+ if (!data.api_key) return false;
251
293
 
294
+ JWT = data.api_key;
295
+ setEmbedJwt(data.api_key);
296
+ if (data.instance_id) _defaultInstanceId = data.instance_id;
297
+ sendFunnelEvent('trial_provisioned', { version: CURRENT_VERSION });
252
298
 
299
+ // Persist credentials so the next editor restart doesn't re-provision.
300
+ try {
301
+ const { resolve: _rp, dirname: _dn } = await import('node:path');
302
+ const { homedir: _hd } = await import('node:os');
303
+ const { writeFile: _wf, mkdir: _mk, readFile: _rf } = await import('node:fs/promises');
304
+ const { existsSync: _ex } = await import('node:fs');
305
+ const cfgPath = _rp(_hd(), '.claude', 'mcp.json');
306
+ await _mk(_dn(cfgPath), { recursive: true });
307
+ let cfg: { mcpServers?: Record<string, { env?: Record<string, string> }> } = {};
308
+ if (_ex(cfgPath)) {
309
+ try { cfg = JSON.parse(await _rf(cfgPath, 'utf-8')) as typeof cfg; } catch { /* corrupt — overwrite */ }
310
+ }
311
+ cfg.mcpServers ??= {};
312
+ cfg.mcpServers['cachly'] ??= {};
313
+ cfg.mcpServers['cachly'].env ??= {};
314
+ cfg.mcpServers['cachly'].env['CACHLY_JWT'] = data.api_key;
315
+ if (_defaultInstanceId) cfg.mcpServers['cachly'].env['CACHLY_BRAIN_INSTANCE_ID'] = _defaultInstanceId;
316
+ await _wf(cfgPath, JSON.stringify(cfg, null, 2), 'utf-8');
317
+ } catch { /* non-fatal — trial is active for this session even if persist fails */ }
318
+ return true;
319
+ } catch { return false; }
320
+ }
253
321
  // ── Embeddings (imported from embeddings.ts) ────────────────────────────────
254
- import { embedConfig, setEmbedJwt, EMBED_PROVIDER, computeEmbedding,
255
- hasEmbedProvider, embedProviderHint } from './embeddings.js';
322
+ import { setEmbedJwt } from './embeddings.js';
256
323
 
257
324
  // ── Search Engine (imported from search.ts) ─────────────────────────────────
258
325
  import { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS,
259
326
  katakanaToRomaji, arabicLightStem, expandCrossLingual, CROSS_LINGUAL_MAP,
260
- keywordSearch, ZERO_RESULTS_LOG, zeroResultsTotal, indexVocab as _indexVocab } from './search.js';
261
- import type { KeywordMatch } from './search.js';
327
+ ZERO_RESULTS_LOG, zeroResultsTotal } from './search.js';
262
328
 
263
329
  // ── Exported for testing (re-export from search.ts) ─────────────────────────
264
330
  export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS,
@@ -267,19 +333,6 @@ export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp,
267
333
  // ── Types ─────────────────────────────────────────────────────────────────────
268
334
  // Instance is imported from handlers/brain.ts
269
335
 
270
- interface CreateResponse {
271
- instance_id: string;
272
- checkout_url?: string;
273
- status: string;
274
- }
275
-
276
- interface SemanticSearchResponse {
277
- found: boolean;
278
- id?: string;
279
- similarity?: number;
280
- prompt?: string;
281
- }
282
-
283
336
  // ── Connection pool ───────────────────────────────────────────────────────────
284
337
 
285
338
  /** Reuse Redis connections across tool calls (keyed by instance_id). */
@@ -301,7 +354,7 @@ async function getConnection(instance_id: string): Promise<Redis> {
301
354
  // Verify the pooled connection is still alive before reusing it.
302
355
  // ioredis 'error' eviction is async — a connection can be in the pool
303
356
  // but already internally broken (status 'reconnecting' or 'end').
304
- const status = (cached as any).status as string | undefined;
357
+ const status = (cached as { status?: string }).status;
305
358
  if (status === 'ready' || status === 'connect') {
306
359
  return cached;
307
360
  }
@@ -310,14 +363,13 @@ async function getConnection(instance_id: string): Promise<Redis> {
310
363
  cached.disconnect();
311
364
  }
312
365
 
313
- // Fetch instance, waiting up to 25 s if it is still provisioning.
314
- // This covers the zero-friction path: device-flow auth → auto-provision → first tool call
315
- // all happen in quick succession and the instance isn't running yet.
366
+ // Fetch instance, waiting up to 8 s if it is still provisioning.
367
+ // (autoStartSession already gave the instance a head-start; 8 s covers any remaining gap.)
316
368
  let inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
317
369
  if (inst.status === 'provisioning') {
318
- const deadline = Date.now() + 25_000;
370
+ const deadline = Date.now() + 8_000;
319
371
  while (inst.status === 'provisioning' && Date.now() < deadline) {
320
- await new Promise(r => setTimeout(r, 3000));
372
+ await new Promise(r => setTimeout(r, 2000));
321
373
  inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
322
374
  }
323
375
  }
@@ -357,7 +409,9 @@ async function getConnection(instance_id: string): Promise<Redis> {
357
409
 
358
410
  // Fetch connection details (includes password) from the dedicated endpoint.
359
411
  let password: string | undefined;
360
- let tlsEnabled = inst.tls_enabled !== false; // default true
412
+ // Default to false — most free/dev instances run without TLS.
413
+ // Only enable TLS when the API explicitly returns tls_enabled: true.
414
+ let tlsEnabled = inst.tls_enabled === true;
361
415
  try {
362
416
  const conn = await apiFetch<{ password?: string; tls_enabled?: boolean }>(
363
417
  `/api/v1/instances/${instance_id}/connection`
@@ -383,6 +437,7 @@ async function getConnection(instance_id: string): Promise<Redis> {
383
437
  lazyConnect: true,
384
438
  enableReadyCheck: true,
385
439
  connectTimeout: 8000,
440
+ commandTimeout: 10000,
386
441
  retryStrategy: () => null, // fail fast, no reconnect loops in MCP context
387
442
  });
388
443
 
@@ -407,6 +462,7 @@ async function getConnection(instance_id: string): Promise<Redis> {
407
462
  lazyConnect: true,
408
463
  enableReadyCheck: true,
409
464
  connectTimeout: 8000,
465
+ commandTimeout: 10000,
410
466
  retryStrategy: () => null,
411
467
  });
412
468
  retryClient.on('error', () => pool.delete(instance_id));
@@ -468,11 +524,6 @@ async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T>
468
524
  return res.json() as Promise<T>;
469
525
  }
470
526
 
471
- import { detectNamespace } from './namespace.js';
472
-
473
- // ── CKG (imported from ckg.ts) ─────────────────────────────────────────────
474
- import type { CKGEdge, CKGNode } from './ckg.js';
475
- import { ckgSlug, extractProblemConcept, ckgUpsertNode, ckgUpdateEdge } from './ckg.js';
476
527
  import { handleBrainTool } from './handlers/brain.js';
477
528
  import { handleContextTool } from './handlers/context.js';
478
529
  import { handleInstanceTool } from './handlers/instances.js';
@@ -510,7 +561,10 @@ function sendFunnelEvent(event: string, extra?: Record<string, unknown>): void {
510
561
  headers: { 'Content-Type': 'application/json' },
511
562
  body: JSON.stringify({
512
563
  event, version: CURRENT_VERSION, editor: detectEditor(),
513
- ...(getJWT() ? { jwt: getJWT() } : {}),
564
+ // Send Cachly API keys (cky_*) for server-side tenant resolution (recall counting, emails).
565
+ // Raw Keycloak JWTs (eyJ...) are intentionally excluded — they are short-lived bearer tokens
566
+ // that should never leave the MCP process boundary.
567
+ ...(getJWT().startsWith('cky_') ? { api_key: getJWT() } : {}),
514
568
  ...extra,
515
569
  }),
516
570
  signal: AbortSignal.timeout(3000),
@@ -532,12 +586,20 @@ async function sendAnonymousTelemetry(toolName: string): Promise<void> {
532
586
  }
533
587
 
534
588
  async function handleTool(name: string, args: Record<string, unknown>): Promise<string> {
535
- // Guard: if no JWT, return actionable onboarding message instead of HTTP 401
589
+ // Guard: if no JWT, try silent auto-setup first, then fall back to device flow.
536
590
  if (!getJWT()) {
591
+ // ── Step 1: Silent instant-trial (zero-click) ────────────────────────
592
+ // On the very first tool call, attempt to silently provision a free trial.
593
+ // If it succeeds the user never sees an auth prompt — Brain is just active.
594
+ const provisioned = await silentAutoSetup();
595
+ if (provisioned) {
596
+ return handleTool(name, args); // Transparent re-entry with active credentials
597
+ }
598
+
537
599
  void sendAnonymousTelemetry(name);
538
600
 
539
- // ── Zero-credential device flow ─────────────────────────────────────
540
- // 1st call: start device flow, return code + URL
601
+ // ── Step 2: Zero-credential device flow ─────────────────────────────
602
+ // 1st call after silent setup fails: start device flow, return code + URL
541
603
  // 2nd+ calls: poll for token; once authenticated, proceed transparently
542
604
  if (_deviceFlow) {
543
605
  const result = await pollDeviceFlow(_deviceFlow);
@@ -611,7 +673,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
611
673
  }
612
674
 
613
675
  // Delegate brain tools (learn, recall, session, etc.)
614
- const brainResult = await handleBrainTool(name, args, getConnection, apiFetch);
676
+ let brainResult = await handleBrainTool(name, args, getConnection, apiFetch);
615
677
  if (brainResult !== null) {
616
678
  if (!_firstCallSuccessSent && getJWT()) {
617
679
  _firstCallSuccessSent = true;
@@ -636,28 +698,40 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
636
698
  if (!isFirstSession && resultText.length > 100) {
637
699
  // Existing brain — session_start acts as implicit recall → increment counter.
638
700
  sendFunnelEvent('recall_best_solution', telemetryExtra);
639
- } else if (isFirstSession && args.workspace_path) {
640
- // First session with a known workspace: auto-bootstrap from git history.
641
- // Runs synchronously so the user sees the result in the same response.
701
+ }
702
+
703
+ // Auto-bootstrap brain_from_git when workspace_path is provided and it hasn't run yet.
704
+ // We check a sentinel key in Valkey so this only fires once per workspace path,
705
+ // not just on the very first session (handles cases where brain was empty but
706
+ // session data existed, or where the user re-installed).
707
+ if (args.workspace_path) {
642
708
  try {
643
- const gitBootstrap = await handleFedbrainTool('brain_from_git', {
644
- instance_id: instanceId,
645
- repo_path: args.workspace_path as string,
646
- limit: 50,
647
- }, getConnection, apiFetch);
648
- if (gitBootstrap !== null) {
649
- sendFunnelEvent('brain_from_git', telemetryExtra);
650
- sendFunnelEvent('recall_best_solution', telemetryExtra);
651
- // Append bootstrap summary to the session_start briefing.
652
- const bootstrapText = typeof gitBootstrap === 'object' && gitBootstrap !== null && 'content' in gitBootstrap
653
- ? (gitBootstrap as { content: { text?: string }[] }).content.map((c) => c.text ?? '').join('\n')
654
- : String(gitBootstrap);
709
+ const workspacePath = args.workspace_path as string;
710
+ const autoKey = `cachly:brain_from_git:auto_done:${Buffer.from(workspacePath).toString('base64').slice(0, 40)}`;
711
+ const redis = await getConnection(instanceId);
712
+ const alreadyDone = await redis.get(autoKey);
713
+ if (!alreadyDone) {
714
+ // Mark immediately to prevent concurrent runs across multiple MCP tool calls
715
+ await redis.set(autoKey, '1', 'EX', 365 * 86400);
716
+ // Append a placeholder so the user gets immediate feedback, then bootstrap in background.
717
+ // Running brain_from_git synchronously would block session_start for 30-120s on large repos.
718
+ const bgMsg = '\n\n---\n\n🚀 **Auto-indexing your git history in the background** — your Brain will learn from recent commits automatically. No action needed.';
655
719
  if (typeof brainResult === 'object' && brainResult !== null && 'content' in brainResult) {
656
- (brainResult as { content: { type: string; text: string }[] }).content.push({
657
- type: 'text',
658
- text: '\n---\n' + bootstrapText,
659
- });
720
+ (brainResult as { content: { type: string; text: string }[] }).content.push({ type: 'text', text: bgMsg });
721
+ } else if (typeof brainResult === 'string') {
722
+ brainResult = brainResult + bgMsg;
660
723
  }
724
+ // Fire-and-forget — never block the session_start response
725
+ void handleFedbrainTool('brain_from_git', {
726
+ instance_id: instanceId,
727
+ repo_path: workspacePath,
728
+ limit: 50,
729
+ }, getConnection, apiFetch).then((gitBootstrap) => {
730
+ if (gitBootstrap !== null) {
731
+ sendFunnelEvent('brain_from_git', telemetryExtra);
732
+ sendFunnelEvent('recall_best_solution', telemetryExtra);
733
+ }
734
+ }).catch(() => { /* non-critical */ });
661
735
  }
662
736
  } catch { /* git bootstrap errors must never break session_start */ }
663
737
  }
@@ -760,28 +834,38 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
760
834
  }
761
835
 
762
836
  // Decode JWT claims (inspection only, no verification)
763
- let authInfo = '❌ invalid JWT format';
764
- try {
765
- const parts = getJWT().split('.');
766
- if (parts.length === 3) {
767
- const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')) as {
768
- sub?: string; exp?: number; iss?: string;
769
- };
770
- const sub = payload.sub ?? '(unknown)';
771
- const iss = payload.iss ?? '(unknown)';
772
- const provider = iss.includes('keycloak') ? 'Keycloak' : 'OIDC';
773
- const expTs = payload.exp ? new Date(payload.exp * 1000) : null;
774
- const expired = expTs ? expTs < new Date() : false;
775
- authInfo = [
776
- `✅ JWT decoded`,
777
- ` Sub (user ID): ${sub}`,
778
- ` Provider: ${provider}`,
779
- ` Issuer: ${iss}`,
780
- ` Expires: ${expTs ? expTs.toISOString() : 'never'} ${expired ? '⚠️ EXPIRED – get a new token!' : '✅'}`,
781
- ].join('\n');
837
+ const jwt = getJWT();
838
+ let authInfo: string;
839
+ // API keys (cky_live_... / cky_trial_...) are valid long-lived credentials.
840
+ // Only try to decode as JWT (3-part base64url) if it looks like one.
841
+ if (jwt.startsWith('cky_')) {
842
+ const keyType = jwt.startsWith('cky_trial_') ? 'Trial' : 'Live';
843
+ const keyId = jwt.slice(0, 20) + '…';
844
+ authInfo = `✅ API key (${keyType})\n Key prefix: ${keyId}`;
845
+ } else {
846
+ authInfo = '❌ invalid credential format';
847
+ try {
848
+ const parts = jwt.split('.');
849
+ if (parts.length === 3) {
850
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')) as {
851
+ sub?: string; exp?: number; iss?: string;
852
+ };
853
+ const sub = payload.sub ?? '(unknown)';
854
+ const iss = payload.iss ?? '(unknown)';
855
+ const provider = iss.includes('keycloak') ? 'Keycloak' : 'OIDC';
856
+ const expTs = payload.exp ? new Date(payload.exp * 1000) : null;
857
+ const expired = expTs ? expTs < new Date() : false;
858
+ authInfo = [
859
+ `✅ JWT decoded`,
860
+ ` Sub (user ID): ${sub}`,
861
+ ` Provider: ${provider}`,
862
+ ` Issuer: ${iss}`,
863
+ ` Expires: ${expTs ? expTs.toISOString() : 'never'} ${expired ? '⚠️ EXPIRED – get a new token!' : '✅'}`,
864
+ ].join('\n');
865
+ }
866
+ } catch {
867
+ authInfo = '❌ JWT decode failed – check CACHLY_JWT format';
782
868
  }
783
- } catch {
784
- authInfo = '❌ JWT decode failed – check CACHLY_JWT format';
785
869
  }
786
870
 
787
871
  // List instances + connectivity
@@ -923,12 +1007,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
923
1007
  || _defaultInstanceId
924
1008
  || process.env.CACHLY_BRAIN_INSTANCE_ID;
925
1009
  if (instanceId) {
926
- await autoStartSession(instanceId).catch(() => undefined);
1010
+ // Fire-and-forget — session management is non-critical and must never block tool calls.
1011
+ // The session_start briefing (which includes brain_from_git) can take 30-120s on large
1012
+ // repos; awaiting it here would freeze the user's first MCP tool call entirely.
1013
+ void autoStartSession(instanceId);
927
1014
  }
928
- } else if (_autoSessionStarting) {
929
- // Another concurrent call is mid-start — wait for it before proceeding.
930
- await _autoSessionStarting.catch(() => undefined);
931
1015
  }
1016
+ // Note: we intentionally do NOT await _autoSessionStarting for any call.
1017
+ // The session tracker runs in background — tool calls must always proceed immediately.
932
1018
  if (!sessionTools.has(name)) _autoSessionToolCount++;
933
1019
 
934
1020
  try {
@@ -1998,7 +2084,7 @@ if (process.argv[2] === 'status') {
1998
2084
  if (process.argv[2] === 'health') {
1999
2085
  const { existsSync } = await import('node:fs');
2000
2086
  const { readFile } = await import('node:fs/promises');
2001
- const { resolve, join: pJoin } = await import('node:path');
2087
+ const { resolve } = await import('node:path');
2002
2088
 
2003
2089
  const cwd = process.cwd();
2004
2090
  let passed = 0;
@@ -2015,6 +2101,9 @@ if (process.argv[2] === 'health') {
2015
2101
  const jwt = process.env.CACHLY_JWT ?? '';
2016
2102
  if (!jwt) {
2017
2103
  fail('CACHLY_JWT not set — run: npx @cachly-dev/mcp-server@latest setup');
2104
+ } else if (jwt.startsWith('cky_')) {
2105
+ // Opaque cachly API key — no JWT expiry to check, format is valid by prefix
2106
+ ok('API key set (cky_*** opaque key)');
2018
2107
  } else {
2019
2108
  try {
2020
2109
  const parts = jwt.split('.');
@@ -2032,7 +2121,7 @@ if (process.argv[2] === 'health') {
2032
2121
  ok(`JWT valid${minsLeft !== null ? ` (expires in ${Math.floor(minsLeft / 60)}h ${minsLeft % 60}m)` : ''}`);
2033
2122
  }
2034
2123
  } catch {
2035
- fail('CACHLY_JWT format invalid (expected JWT with 3 parts)');
2124
+ fail('CACHLY_JWT format invalid (expected a cky_*** API key or a JWT with 3 parts)');
2036
2125
  }
2037
2126
  }
2038
2127
 
@@ -2080,11 +2169,23 @@ if (process.argv[2] === 'health') {
2080
2169
  signal: AbortSignal.timeout(5000),
2081
2170
  });
2082
2171
  if (connRes.ok) {
2083
- const conn = await connRes.json() as { password?: string; tls_enabled?: boolean };
2084
- pingPassword = conn.password;
2085
- pingTls = conn.tls_enabled !== false;
2172
+ const conn = await connRes.json() as { password?: string; connection_string?: string; tls_enabled?: boolean };
2173
+ // password may be a direct field, or embedded in connection_string as redis://:pass@host:port
2174
+ if (conn.password) {
2175
+ pingPassword = conn.password;
2176
+ } else if (conn.connection_string) {
2177
+ try {
2178
+ const u = new URL(conn.connection_string);
2179
+ if (u.password) pingPassword = decodeURIComponent(u.password);
2180
+ } catch { /* leave pingPassword undefined */ }
2181
+ }
2182
+ pingTls = conn.tls_enabled === true;
2183
+ } else {
2184
+ warn(`Could not fetch Redis credentials (HTTP ${connRes.status}) — connectivity test may fail`);
2086
2185
  }
2087
- } catch { /* proceed without password */ }
2186
+ } catch (connErr) {
2187
+ warn(`Could not fetch Redis credentials — ${(connErr as Error).message}`);
2188
+ }
2088
2189
  const pingClient = new Redis({
2089
2190
  host: inst.host, port: inst.port,
2090
2191
  password: pingPassword,
@@ -2299,7 +2400,7 @@ if (process.argv[2] === 'setup') {
2299
2400
  if (!keyBody.key) throw new Error('no key in response');
2300
2401
  token = keyBody.key; // swap JWT → cky_live_...
2301
2402
  console.log(' ✓\n');
2302
- } catch (e) {
2403
+ } catch {
2303
2404
  console.log(' (skipped)\n');
2304
2405
  // Non-fatal: fall back to using the Keycloak JWT directly.
2305
2406
  // It will expire but setup still works for now.
@@ -2689,7 +2790,7 @@ if (process.argv[2] === 'learn-git') {
2689
2790
  limit: maxCommits,
2690
2791
  });
2691
2792
  console.log(result);
2692
- sendFunnelEvent('brain_from_git', { api_key: JWT, instance_id: instanceId });
2793
+ sendFunnelEvent('brain_from_git', { instance_id: instanceId });
2693
2794
  console.log('\n✅ Brain learned from your commits.\n');
2694
2795
  // Let fire-and-forget telemetry flush before exit.
2695
2796
  await new Promise(r => setTimeout(r, 300));