@cachly-dev/mcp-server 0.5.40 → 0.5.42

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/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@cachly-dev/mcp-server",
3
- "version": "0.5.40",
3
+ "version": "0.5.42",
4
4
  "mcpName": "io.github.cachly-dev/mcp-server",
5
- "description": "AI memory brain for Claude Code, Cursor, Copilot & Windsurf — persistent sessions, lessons learned, semantic search, team brain. 51 MCP tools.",
5
+ "description": "Claude forgets. cachly doesn't. Persistent AI memory for Claude Code, Cursor & Copilot — one command setup, zero copy-paste.",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "cachly-mcp": "./dist/index.js"
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
3
3
  import { readdir, readFile, stat } from 'node:fs/promises';
4
4
  import { join, relative, extname } from 'node:path';
5
5
  /**
6
- * cachly MCP Server v0.5.40
6
+ * cachly MCP Server v0.5.42
7
7
  *
8
8
  * Exposes cachly.dev as MCP tools so any AI assistant
9
9
  * (GitHub Copilot, Claude, Cursor, Windsurf, Continue.dev …) can:
@@ -1543,6 +1543,55 @@ const TOOLS = [
1543
1543
  required: ['instance_id'],
1544
1544
  },
1545
1545
  },
1546
+ {
1547
+ name: 'brain_stats',
1548
+ description:
1549
+ 'Complete statistics dashboard for your AI Brain — lessons, recalls, time saved, top topics, team authors, memory usage. ' +
1550
+ 'Returns a formatted report designed for sharing (screenshot-worthy). ' +
1551
+ 'Use this when you want a full overview or want to share your brain health with others.',
1552
+ inputSchema: {
1553
+ type: 'object',
1554
+ properties: {
1555
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
1556
+ },
1557
+ required: ['instance_id'],
1558
+ },
1559
+ },
1560
+ {
1561
+ name: 'export_brain',
1562
+ description:
1563
+ 'Export your entire AI Brain as a portable Markdown file. ' +
1564
+ 'Includes all lessons (topic, outcome, what worked, severity), context entries (keys + content), ' +
1565
+ 'session history, and metadata. Perfect for sharing with teammates, archiving, posting to GitHub, ' +
1566
+ 'or importing into a new instance. ' +
1567
+ 'Example: export_brain() → returns full markdown string you can save to a .md file.',
1568
+ inputSchema: {
1569
+ type: 'object',
1570
+ properties: {
1571
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
1572
+ include_context: { type: 'boolean', description: 'Include context entries (default: true)' },
1573
+ max_lessons: { type: 'number', description: 'Max lessons to include (default: all)' },
1574
+ },
1575
+ required: ['instance_id'],
1576
+ },
1577
+ },
1578
+ {
1579
+ name: 'invite_link',
1580
+ description:
1581
+ 'Generate a one-click team invite link for your Brain instance. ' +
1582
+ 'Share the link (or the npx join command) with a teammate — they run one command and are instantly ' +
1583
+ 'connected to the same shared Brain with all your lessons and context. ' +
1584
+ 'No dashboard visit, no copy-paste of instance IDs required. ' +
1585
+ 'Example: invite_link() → returns "npx @cachly-dev/mcp-server@latest join <token>"',
1586
+ inputSchema: {
1587
+ type: 'object',
1588
+ properties: {
1589
+ instance_id: { type: 'string', description: 'UUID of the cache instance to share' },
1590
+ label: { type: 'string', description: 'Optional label shown to the invitee (e.g. "my-api project")' },
1591
+ },
1592
+ required: ['instance_id'],
1593
+ },
1594
+ },
1546
1595
  {
1547
1596
  name: 'brain_doctor',
1548
1597
  description:
@@ -3021,7 +3070,18 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
3021
3070
  lines.push(`After this session, call \`session_end\` to save a summary.`);
3022
3071
  lines.push('');
3023
3072
  } else {
3024
- lines.push(`📊 **Brain:** ${lessons.length} lessons · ${ctxCount} context entries`, '');
3073
+ const totalRecalls = lessons.reduce((s, l) => s + (l.recall_count ?? 0), 0);
3074
+ const savedMin = totalRecalls * 15;
3075
+ const savedStr = savedMin >= 60
3076
+ ? `~${(savedMin / 60).toFixed(1)}h saved`
3077
+ : savedMin > 0 ? `~${savedMin}min saved` : '';
3078
+ const statsLine = [
3079
+ `${lessons.length} lessons`,
3080
+ ctxCount > 0 ? `${ctxCount} context entries` : '',
3081
+ totalRecalls > 0 ? `${totalRecalls} recalls` : '',
3082
+ savedStr,
3083
+ ].filter(Boolean).join(' · ');
3084
+ lines.push(`📊 **Brain:** ${statsLine}`, '');
3025
3085
  }
3026
3086
 
3027
3087
  // Focus-relevant lessons
@@ -3353,6 +3413,303 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
3353
3413
  return lines.join('\n');
3354
3414
  }
3355
3415
 
3416
+ // ── brain_stats ───────────────────────────────────────────────────────────
3417
+ case 'brain_stats': {
3418
+ const { instance_id } = args as { instance_id: string };
3419
+ const redis = await getConnection(instance_id);
3420
+
3421
+ // Scan all lesson keys
3422
+ const lessonKeys: string[] = [];
3423
+ const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
3424
+ await new Promise<void>((resolve, reject) => {
3425
+ lStream.on('data', (batch: string[]) => lessonKeys.push(...batch));
3426
+ lStream.on('end', resolve);
3427
+ lStream.on('error', reject);
3428
+ });
3429
+
3430
+ type Lesson = { topic: string; outcome: string; recall_count?: number; ts: string; severity?: string; what_worked: string; author?: string };
3431
+ const lessons: Lesson[] = [];
3432
+ if (lessonKeys.length > 0) {
3433
+ const vals = await redis.mget(...lessonKeys);
3434
+ for (const raw of vals) {
3435
+ if (!raw) continue;
3436
+ try { lessons.push(JSON.parse(raw) as Lesson); } catch { /* skip */ }
3437
+ }
3438
+ }
3439
+
3440
+ // Context count
3441
+ let ctxCount = 0;
3442
+ const ctxStream = redis.scanStream({ match: 'cachly:ctx:*', count: 200 });
3443
+ await new Promise<void>((resolve, reject) => {
3444
+ ctxStream.on('data', (batch: string[]) => { ctxCount += batch.filter((k: string) => !k.endsWith(':meta')).length; });
3445
+ ctxStream.on('end', resolve);
3446
+ ctxStream.on('error', reject);
3447
+ });
3448
+
3449
+ const lastSessionRaw = await redis.get('cachly:session:last');
3450
+ let lastSession: { ts: string; summary: string; duration_min?: number } | null = null;
3451
+ if (lastSessionRaw) { try { lastSession = JSON.parse(lastSessionRaw); } catch { /* ignore */ } }
3452
+
3453
+ const totalRecalls = lessons.reduce((s, l) => s + (l.recall_count ?? 0), 0);
3454
+ const savedMin = totalRecalls * 15;
3455
+ const savedHours = (savedMin / 60).toFixed(1);
3456
+ const successCount = lessons.filter(l => l.outcome === 'success').length;
3457
+ const failureCount = lessons.filter(l => l.outcome === 'failure' || l.outcome === 'partial').length;
3458
+ const criticalCount = lessons.filter(l => l.severity === 'critical').length;
3459
+ const majorCount = lessons.filter(l => l.severity === 'major').length;
3460
+ const authors = [...new Set(lessons.map(l => l.author).filter(Boolean))];
3461
+ const topByRecall = [...lessons].sort((a, b) => (b.recall_count ?? 0) - (a.recall_count ?? 0)).slice(0, 5);
3462
+ const newestLesson = [...lessons].sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime())[0];
3463
+
3464
+ // Age of last session
3465
+ let lastSessionStr = 'none yet';
3466
+ if (lastSession) {
3467
+ const ageMin = Math.round((Date.now() - new Date(lastSession.ts).getTime()) / 60000);
3468
+ lastSessionStr = ageMin < 60 ? `${ageMin}m ago`
3469
+ : ageMin < 1440 ? `${Math.round(ageMin / 60)}h ago`
3470
+ : `${Math.round(ageMin / 1440)}d ago`;
3471
+ }
3472
+
3473
+ const tokensSaved = (totalRecalls * 1200);
3474
+ const tokenStr = tokensSaved >= 1000 ? `~${(tokensSaved / 1000).toFixed(0)}k tokens` : `~${tokensSaved} tokens`;
3475
+ const costSaved = (tokensSaved * 0.000003).toFixed(2);
3476
+
3477
+ const lines = [
3478
+ `🧠 **Brain Stats — Full Dashboard**`,
3479
+ ``,
3480
+ `╔══════════════════════════════════════╗`,
3481
+ `║ 📚 Lessons: ${String(lessons.length).padEnd(18)}║`,
3482
+ `║ 🔄 Total recalls: ${String(totalRecalls).padEnd(18)}║`,
3483
+ `║ 🕐 Time saved: ~${savedHours}h${' '.repeat(Math.max(0, 16 - savedHours.length))}║`,
3484
+ `║ 💎 Tokens saved: ${tokenStr.padEnd(18)}║`,
3485
+ `║ 💵 Cost saved: ~$${costSaved.padEnd(17)}║`,
3486
+ `║ 📁 Context keys: ${String(ctxCount).padEnd(18)}║`,
3487
+ `╚══════════════════════════════════════╝`,
3488
+ ``,
3489
+ `**Lessons breakdown:**`,
3490
+ ` ✅ Success: ${successCount} ❌ Failures/partial: ${failureCount}`,
3491
+ ` 🔴 Critical: ${criticalCount} 🟡 Major: ${majorCount}`,
3492
+ ``,
3493
+ `**Last session:** ${lastSessionStr}${lastSession?.summary ? ` — ${lastSession.summary.slice(0, 80)}` : ''}`,
3494
+ `**Newest lesson:** ${newestLesson ? `\`${newestLesson.topic}\`` : 'none'}`,
3495
+ ``,
3496
+ ];
3497
+
3498
+ if (topByRecall.length > 0) {
3499
+ lines.push(`**Top recalled lessons (most reused):**`);
3500
+ for (const l of topByRecall) {
3501
+ const emoji = l.outcome === 'success' ? '✅' : '⚠️';
3502
+ lines.push(` ${emoji} \`${l.topic}\` — recalled ${l.recall_count ?? 0}× · ${l.what_worked.slice(0, 60)}`);
3503
+ }
3504
+ lines.push('');
3505
+ }
3506
+
3507
+ if (authors.length > 0) {
3508
+ lines.push(`**Team contributors (${authors.length}):** ${(authors as string[]).join(', ')}`);
3509
+ lines.push('');
3510
+ }
3511
+
3512
+ lines.push(`💡 Share your brain: \`invite_link()\` — generates a one-command team invite.`);
3513
+ lines.push(`📤 Export to Markdown: \`export_brain()\` — portable snapshot you can save or share.`);
3514
+
3515
+ return lines.join('\n');
3516
+ }
3517
+
3518
+ // ── export_brain ──────────────────────────────────────────────────────────
3519
+ case 'export_brain': {
3520
+ const { instance_id, include_context = true, max_lessons } = args as {
3521
+ instance_id: string;
3522
+ include_context?: boolean;
3523
+ max_lessons?: number;
3524
+ };
3525
+ const redis = await getConnection(instance_id);
3526
+
3527
+ // Scan all lessons
3528
+ const lessonKeys: string[] = [];
3529
+ const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
3530
+ await new Promise<void>((resolve, reject) => {
3531
+ lStream.on('data', (batch: string[]) => lessonKeys.push(...batch));
3532
+ lStream.on('end', resolve);
3533
+ lStream.on('error', reject);
3534
+ });
3535
+
3536
+ type FullLesson = {
3537
+ topic: string; outcome: string; what_worked: string; what_failed?: string;
3538
+ severity?: string; ts: string; recall_count?: number; author?: string;
3539
+ file_paths?: string[]; commands?: string[]; tags?: string[];
3540
+ };
3541
+ let lessons: FullLesson[] = [];
3542
+ if (lessonKeys.length > 0) {
3543
+ const vals = await redis.mget(...lessonKeys);
3544
+ for (const raw of vals) {
3545
+ if (!raw) continue;
3546
+ try { lessons.push(JSON.parse(raw) as FullLesson); } catch { /* skip */ }
3547
+ }
3548
+ }
3549
+ lessons.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
3550
+ if (max_lessons && max_lessons > 0) lessons = lessons.slice(0, max_lessons);
3551
+
3552
+ // Last session + crystal
3553
+ const lastSessionRaw = await redis.get('cachly:session:last');
3554
+ let lastSession: { ts: string; summary: string } | null = null;
3555
+ if (lastSessionRaw) { try { lastSession = JSON.parse(lastSessionRaw); } catch { /* ignore */ } }
3556
+
3557
+ const crystalRaw = await redis.get('cachly:crystal');
3558
+ let crystal: { summary: string; created_at: string } | null = null;
3559
+ if (crystalRaw) { try { crystal = JSON.parse(crystalRaw); } catch { /* ignore */ } }
3560
+
3561
+ // Context entries
3562
+ type CtxEntry = { key: string; value: string };
3563
+ const ctxEntries: CtxEntry[] = [];
3564
+ if (include_context) {
3565
+ const ctxKeys: string[] = [];
3566
+ const ctxStream = redis.scanStream({ match: 'cachly:ctx:*', count: 200 });
3567
+ await new Promise<void>((resolve, reject) => {
3568
+ ctxStream.on('data', (batch: string[]) => {
3569
+ ctxKeys.push(...batch.filter((k: string) => !k.endsWith(':meta')));
3570
+ });
3571
+ ctxStream.on('end', resolve);
3572
+ ctxStream.on('error', reject);
3573
+ });
3574
+ if (ctxKeys.length > 0) {
3575
+ const ctxVals = await redis.mget(...ctxKeys);
3576
+ for (let i = 0; i < ctxKeys.length; i++) {
3577
+ const val = ctxVals[i];
3578
+ if (val) ctxEntries.push({ key: ctxKeys[i].replace('cachly:ctx:', ''), value: val });
3579
+ }
3580
+ }
3581
+ }
3582
+
3583
+ const exportedAt = new Date().toISOString();
3584
+ const totalRecalls = lessons.reduce((s, l) => s + (l.recall_count ?? 0), 0);
3585
+ const savedHours = (totalRecalls * 15 / 60).toFixed(1);
3586
+
3587
+ const md: string[] = [
3588
+ `# 🧠 cachly Brain Export`,
3589
+ ``,
3590
+ `> Exported: ${exportedAt} `,
3591
+ `> Instance: \`${instance_id}\` `,
3592
+ `> Lessons: ${lessons.length} · Recalls: ${totalRecalls} · Time saved: ~${savedHours}h`,
3593
+ ``,
3594
+ ];
3595
+
3596
+ if (crystal) {
3597
+ md.push(`## 💎 Memory Crystal`, ``, `> ${crystal.summary}`, ``, `_Generated: ${crystal.created_at}_`, ``);
3598
+ }
3599
+
3600
+ if (lastSession) {
3601
+ md.push(`## 📅 Last Session`, ``, `**${lastSession.ts.slice(0, 10)}** — ${lastSession.summary}`, ``);
3602
+ }
3603
+
3604
+ // Group lessons by outcome
3605
+ const successLessons = lessons.filter(l => l.outcome === 'success');
3606
+ const failureLessons = lessons.filter(l => l.outcome !== 'success');
3607
+
3608
+ const formatLesson = (l: FullLesson) => {
3609
+ const sev = l.severity === 'critical' ? ' 🔴' : l.severity === 'major' ? ' 🟡' : '';
3610
+ const lines: string[] = [
3611
+ `### \`${l.topic}\`${sev}`,
3612
+ ``,
3613
+ `**Outcome:** ${l.outcome} `,
3614
+ `**Recalls:** ${l.recall_count ?? 0}× `,
3615
+ `**Stored:** ${l.ts.slice(0, 10)}${l.author ? ` \n**Author:** ${l.author}` : ''}`,
3616
+ ``,
3617
+ `**What worked:** `,
3618
+ l.what_worked,
3619
+ ];
3620
+ if (l.what_failed) { lines.push(``, `**What failed:** `, l.what_failed); }
3621
+ if ((l.commands ?? []).length > 0) {
3622
+ lines.push(``, `**Commands:**`);
3623
+ for (const cmd of l.commands!) lines.push(`\`\`\`\n${cmd}\n\`\`\``);
3624
+ }
3625
+ if ((l.file_paths ?? []).length > 0) {
3626
+ lines.push(``, `**Files:** ${l.file_paths!.map(f => `\`${f}\``).join(', ')}`);
3627
+ }
3628
+ if ((l.tags ?? []).length > 0) {
3629
+ lines.push(``, `**Tags:** ${l.tags!.map(t => `\`${t}\``).join(', ')}`);
3630
+ }
3631
+ lines.push(``);
3632
+ return lines.join('\n');
3633
+ };
3634
+
3635
+ if (successLessons.length > 0) {
3636
+ md.push(`## ✅ Successful Solutions (${successLessons.length})`, ``);
3637
+ for (const l of successLessons) md.push(formatLesson(l));
3638
+ }
3639
+
3640
+ if (failureLessons.length > 0) {
3641
+ md.push(`## ⚠️ Open / Partial Issues (${failureLessons.length})`, ``);
3642
+ for (const l of failureLessons) md.push(formatLesson(l));
3643
+ }
3644
+
3645
+ if (ctxEntries.length > 0) {
3646
+ md.push(`## 📁 Context Entries (${ctxEntries.length})`, ``);
3647
+ for (const e of ctxEntries) {
3648
+ md.push(`### \`${e.key}\``, ``, e.value.slice(0, 500) + (e.value.length > 500 ? '\n\n_[truncated]_' : ''), ``);
3649
+ }
3650
+ }
3651
+
3652
+ md.push(
3653
+ `---`,
3654
+ ``,
3655
+ `_Exported from [cachly AI Brain](https://cachly.dev) · Re-import with \`npx @cachly-dev/mcp-server@latest setup\`_`,
3656
+ );
3657
+
3658
+ return md.join('\n');
3659
+ }
3660
+
3661
+ // ── invite_link ───────────────────────────────────────────────────────────
3662
+ case 'invite_link': {
3663
+ const { instance_id, label = '' } = args as { instance_id: string; label?: string };
3664
+
3665
+ // Generate a share token via API
3666
+ let shareToken = '';
3667
+ let inviteUrl = '';
3668
+ try {
3669
+ const res = await apiFetch<{ token?: string; url?: string }>(
3670
+ `/api/v1/instances/${instance_id}/invite`,
3671
+ { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label }) }
3672
+ );
3673
+ shareToken = res.token ?? '';
3674
+ inviteUrl = res.url ?? '';
3675
+ } catch {
3676
+ // API doesn't support invite yet — generate a local share snippet instead
3677
+ }
3678
+
3679
+ // Build the join command
3680
+ const joinCmd = shareToken
3681
+ ? `npx @cachly-dev/mcp-server@latest join ${shareToken}`
3682
+ : `npx @cachly-dev/mcp-server@latest setup`;
3683
+
3684
+ const lines = [
3685
+ `🔗 **Team Invite — ${label || instance_id.slice(0, 8) + '…'}**`,
3686
+ ``,
3687
+ `Share this command with your teammate:`,
3688
+ ``,
3689
+ `\`\`\`bash`,
3690
+ joinCmd,
3691
+ `\`\`\``,
3692
+ ``,
3693
+ `**What happens when they run it:**`,
3694
+ ` 1. Browser opens → they sign in (free, 30 seconds)`,
3695
+ ` 2. Their Brain is auto-connected to this shared instance`,
3696
+ ` 3. All ${label ? `"${label}" ` : ''}lessons and context are instantly available`,
3697
+ ` 4. Editor configs written automatically — no copy-paste`,
3698
+ ``,
3699
+ ];
3700
+
3701
+ if (inviteUrl) {
3702
+ lines.push(`**Or share the link directly:** ${inviteUrl}`, ``);
3703
+ }
3704
+
3705
+ lines.push(
3706
+ `💡 Teammates can also run \`session_start\` immediately to see all your lessons.`,
3707
+ ` Use \`team_learn\` with an \`author\` param so everyone knows who learned what.`,
3708
+ );
3709
+
3710
+ return lines.join('\n');
3711
+ }
3712
+
3356
3713
  // ── brain_doctor ──────────────────────────────────────────────────────────
3357
3714
  case 'brain_doctor': {
3358
3715
  const { instance_id } = args as { instance_id: string };