@cachly-dev/mcp-server 0.10.59 → 0.10.60

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.
@@ -12,14 +12,21 @@ import { rerankByQuality } from '../rerank.js';
12
12
  import { computeEmbedding, hasEmbedProvider } from '../embeddings.js';
13
13
 
14
14
  // ── Changelog (shown once per version in session_start) ──────────────────────
15
- const MCP_VERSION = '0.10.59';
15
+ const MCP_VERSION = '0.10.60';
16
16
  const WHATS_NEW: Record<string, string[]> = {
17
+ '0.10.60': [
18
+ `🆕 **Phase 3B: File map + team overview + visibility**`,
19
+ ` 📁 \`brain_file_map(file_paths=[...])\` — experts + lessons per file before you touch it`,
20
+ ` 🗺️ \`team_expertise_map()\` — full team skills matrix in one table`,
21
+ ` 🔒 \`visibility: "private"\` on \`learn_from_attempts\` — private notes never leak to smart_recall`,
22
+ ` 📊 98 MCP tools`,
23
+ ],
17
24
  '0.10.59': [
18
25
  `🆕 **Phase 3A: Org-wide knowledge graph**`,
19
26
  ` 👥 \`brain_who_knows(topic="...")\` — find your team's experts on any topic instantly`,
20
27
  ` 🕸️ Person + File nodes auto-built from \`learn_from_attempts(author="...", file_paths=[...])\``,
21
28
  ` 👤 Author attribution now shown inline in \`smart_recall\` results`,
22
- ` 📊 96 MCP tools · 379 tests green`,
29
+ ` 📊 96 MCP tools · 386 tests green`,
23
30
  ],
24
31
  '0.10.58': [
25
32
  `🧹 Zero lint warnings — all unused imports cleaned across every handler`,
@@ -80,7 +87,7 @@ type ApiFetch = <T>(path: string, options?: RequestInit) => Promise<T>;
80
87
  export const BRAIN_TOOL_NAMES = new Set([
81
88
  'learn_from_attempts', 'recall_best_solution', 'smart_recall',
82
89
  'session_start', 'session_end', 'session_ping', 'session_handoff', 'auto_learn_session',
83
- 'brain_who_knows',
90
+ 'brain_who_knows', 'brain_file_map', 'team_expertise_map',
84
91
  ]);
85
92
 
86
93
  export async function handleBrainTool(
@@ -104,6 +111,7 @@ export async function handleBrainTool(
104
111
  tags = [],
105
112
  depends_on = [],
106
113
  author = '',
114
+ visibility = 'team',
107
115
  } = args as {
108
116
  instance_id: string;
109
117
  topic: string;
@@ -117,6 +125,7 @@ export async function handleBrainTool(
117
125
  tags?: string[];
118
126
  depends_on?: string[];
119
127
  author?: string;
128
+ visibility?: 'public' | 'team' | 'private';
120
129
  };
121
130
 
122
131
  const redis = await getConnection(instance_id);
@@ -246,6 +255,7 @@ export async function handleBrainTool(
246
255
  tags,
247
256
  depends_on,
248
257
  ...(author ? { author } : {}),
258
+ visibility,
249
259
  recall_count: recallCount,
250
260
  ts,
251
261
  verified_at: outcome === 'success' || outcome === 'partial' ? ts : undefined,
@@ -625,13 +635,13 @@ export async function handleBrainTool(
625
635
  }
626
636
 
627
637
  // ── Layer 2: Semantic search (parallel, optional) ────────────────────────
628
- const inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
638
+ const inst = await apiFetch<Instance | null>(`/api/v1/instances/${instance_id}`).catch(() => null);
629
639
  type SemHit = { key: string; similarity: number; content: string };
630
640
  const semHits: SemHit[] = [];
631
- if (inst.vector_token && hasEmbedProvider()) {
641
+ if (inst?.vector_token && hasEmbedProvider()) {
632
642
  try {
633
643
  const embedding = await computeEmbedding(query);
634
- const vectorUrl = `https://api.cachly.dev/v1/sem/${inst.vector_token}`;
644
+ const vectorUrl = `https://api.cachly.dev/v1/sem/${inst!.vector_token}`;
635
645
  const searchRes = await fetch(`${vectorUrl}/search`, {
636
646
  method: 'POST',
637
647
  headers: { 'Content-Type': 'application/json' },
@@ -736,7 +746,14 @@ export async function handleBrainTool(
736
746
  }
737
747
  } catch { /* non-critical — keyword + semantic always available */ }
738
748
 
739
- const hybridResults = [...hybridMap.values()].sort((a, b) => b.hybridScore - a.hybridScore);
749
+ // Filter private lessons — they are only accessible via exact recall_best_solution
750
+ const hybridResults = [...hybridMap.values()]
751
+ .filter(r => {
752
+ if (!r.key.startsWith('cachly:lesson:best:')) return true;
753
+ const ld = safeJsonParse<{ visibility?: string }>(r.content, {});
754
+ return ld.visibility !== 'private';
755
+ })
756
+ .sort((a, b) => b.hybridScore - a.hybridScore);
740
757
 
741
758
  // ── Build output ──────────────────────────────────────────────────────────
742
759
  const lines: string[] = [`🧠 **Smart Recall** for: _"${query}"_\n`];
@@ -2123,6 +2140,201 @@ export async function handleBrainTool(
2123
2140
  return lines.join('\n');
2124
2141
  }
2125
2142
 
2143
+ // ── brain_file_map ────────────────────────────────────────────────────────
2144
+ // Phase 3B: "What do we know about these files?" — experts + lessons per file.
2145
+ case 'brain_file_map': {
2146
+ const {
2147
+ instance_id,
2148
+ file_paths = [],
2149
+ } = args as { instance_id: string; file_paths: string[] };
2150
+
2151
+ if (!file_paths.length) return '⚠️ Pass at least one file path.';
2152
+
2153
+ const redis = await getConnection(instance_id);
2154
+ const lines: string[] = [
2155
+ `## 📁 File Knowledge Map`,
2156
+ ``,
2157
+ `_What cachly knows about the files you're about to touch:_`,
2158
+ ``,
2159
+ ];
2160
+
2161
+ for (const fp of file_paths.slice(0, 10)) {
2162
+ lines.push(`### \`${fp}\``);
2163
+
2164
+ // ── Experts via CKG file nodes ─────────────────────────────────────
2165
+ const fileId = `file:${ckgSlug(fp)}`;
2166
+ const inbound = await redis.smembers(`cachly:ckg:idx:to:${fileId}`);
2167
+ type ExpertEntry = { handle: string; touches: number; lastSeen: string };
2168
+ const experts: ExpertEntry[] = [];
2169
+ for (const ek of inbound) {
2170
+ const er = await redis.get(ek);
2171
+ if (!er) continue;
2172
+ const edge = safeJsonParse<CKGEdge | null>(er, null);
2173
+ if (!edge || edge.edgeType !== 'touched') continue;
2174
+ const personRaw = await redis.get(`cachly:ckg:node:${edge.from}`);
2175
+ if (!personRaw) continue;
2176
+ const pn = safeJsonParse<PersonNode | null>(personRaw, null);
2177
+ if (!pn || pn.type !== 'person') continue;
2178
+ experts.push({ handle: pn.handle, touches: edge.trials, lastSeen: edge.last_updated });
2179
+ }
2180
+ experts.sort((a, b) => b.touches - a.touches);
2181
+
2182
+ if (experts.length > 0) {
2183
+ const expLine = experts.slice(0, 5).map((e, i) => {
2184
+ const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}.`;
2185
+ const days = Math.floor((Date.now() - new Date(e.lastSeen).getTime()) / 86400000);
2186
+ const ago = days < 1 ? 'today' : days < 7 ? `${days}d ago` : `${Math.floor(days / 7)}w ago`;
2187
+ return `${medal} **${e.handle}** (${e.touches}× · ${ago})`;
2188
+ }).join(' · ');
2189
+ lines.push(`**Experts:** ${expLine}`);
2190
+ } else {
2191
+ lines.push(`**Experts:** _None yet — add \`author\` to \`learn_from_attempts\` calls_`);
2192
+ }
2193
+
2194
+ // ── Related lessons via BM25 on filename tokens ────────────────────
2195
+ const tokens = fp.replace(/[^a-z0-9]/gi, ' ').split(/\s+/).filter(t => t.length >= 4);
2196
+ const lessonQuery = tokens.slice(0, 3).join(' ');
2197
+ const relatedLessons: string[] = [];
2198
+ if (lessonQuery) {
2199
+ try {
2200
+ const hits = await keywordSearch(redis, ['cachly:lesson:best:*'], lessonQuery, 4);
2201
+ for (const h of hits) {
2202
+ const ld = safeJsonParse<{
2203
+ topic?: string; outcome?: string; what_worked?: string;
2204
+ severity?: string; author?: string; visibility?: string;
2205
+ }>(h.content, {});
2206
+ if (ld.visibility === 'private') continue;
2207
+ const emoji = ld.outcome === 'success' ? '✅' : ld.outcome === 'partial' ? '⚠️' : '❌';
2208
+ const sev = ld.severity === 'critical' ? ' 🔴' : ld.severity === 'major' ? ' 🟡' : '';
2209
+ const by = ld.author ? ` · 👤 ${ld.author}` : '';
2210
+ const topic = h.key.replace('cachly:lesson:best:', '');
2211
+ relatedLessons.push(` - ${emoji} \`${topic}\`${sev}${by} — ${(ld.what_worked ?? '').slice(0, 100)}`);
2212
+ }
2213
+ } catch { /* non-critical */ }
2214
+ }
2215
+ // Also: exact file_paths match via lesson scan
2216
+ if (relatedLessons.length < 2) {
2217
+ const scanKeys: string[] = [];
2218
+ const stream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
2219
+ await new Promise<void>((res, rej) => {
2220
+ stream.on('data', (b: string[]) => scanKeys.push(...b));
2221
+ stream.on('end', res); stream.on('error', rej);
2222
+ });
2223
+ for (const k of scanKeys.slice(0, 300)) {
2224
+ const raw = await redis.get(k);
2225
+ if (!raw) continue;
2226
+ const ld = safeJsonParse<{
2227
+ topic?: string; outcome?: string; what_worked?: string;
2228
+ severity?: string; author?: string; visibility?: string; file_paths?: string[];
2229
+ }>(raw, {});
2230
+ if (ld.visibility === 'private') continue;
2231
+ if (!(ld.file_paths ?? []).includes(fp)) continue;
2232
+ const topic = k.replace('cachly:lesson:best:', '');
2233
+ if (relatedLessons.some(l => l.includes(`\`${topic}\``))) continue;
2234
+ const emoji = ld.outcome === 'success' ? '✅' : ld.outcome === 'partial' ? '⚠️' : '❌';
2235
+ const sev = ld.severity === 'critical' ? ' 🔴' : ld.severity === 'major' ? ' 🟡' : '';
2236
+ const by = ld.author ? ` · 👤 ${ld.author}` : '';
2237
+ relatedLessons.push(` - ${emoji} \`${topic}\`${sev}${by} — ${(ld.what_worked ?? '').slice(0, 100)}`);
2238
+ }
2239
+ }
2240
+
2241
+ if (relatedLessons.length > 0) {
2242
+ lines.push(`**Related lessons:**`);
2243
+ lines.push(...relatedLessons.slice(0, 5));
2244
+ } else {
2245
+ lines.push(`**Related lessons:** _None yet_`);
2246
+ }
2247
+ lines.push(``);
2248
+ }
2249
+
2250
+ lines.push(
2251
+ `---`,
2252
+ `_Attribution builds with \`learn_from_attempts(file_paths=[...], author="...")\`. Run this before committing to track what changed and why._`,
2253
+ );
2254
+ return lines.join('\n');
2255
+ }
2256
+
2257
+ // ── team_expertise_map ────────────────────────────────────────────────────
2258
+ // Phase 3B: Full team expertise overview — who knows what, at a glance.
2259
+ case 'team_expertise_map': {
2260
+ const {
2261
+ instance_id,
2262
+ top_n = 20,
2263
+ } = args as { instance_id: string; top_n?: number };
2264
+
2265
+ const redis = await getConnection(instance_id);
2266
+
2267
+ // Scan all person nodes
2268
+ const personKeys: string[] = [];
2269
+ const pStream = redis.scanStream({ match: 'cachly:ckg:node:person:*', count: 200 });
2270
+ await new Promise<void>((res, rej) => {
2271
+ pStream.on('data', (b: string[]) => personKeys.push(...b));
2272
+ pStream.on('end', res); pStream.on('error', rej);
2273
+ });
2274
+
2275
+ if (personKeys.length === 0) {
2276
+ return [
2277
+ `## 🗺️ Team Expertise Map`,
2278
+ ``,
2279
+ `No contributors yet.`,
2280
+ ``,
2281
+ `Add \`author="handle"\` to any \`learn_from_attempts\` call to start building the map.`,
2282
+ ].join('\n');
2283
+ }
2284
+
2285
+ type PersonEntry = { handle: string; lessonCount: number; domains: Map<string, number>; lastActive: string };
2286
+ const people: PersonEntry[] = [];
2287
+
2288
+ for (const pk of personKeys) {
2289
+ const raw = await redis.get(pk);
2290
+ if (!raw) continue;
2291
+ const pn = safeJsonParse<PersonNode | null>(raw, null);
2292
+ if (!pn || pn.type !== 'person') continue;
2293
+
2294
+ // Count authored lessons + collect domains from authored edges
2295
+ const outEdgeKeys = await redis.smembers(`cachly:ckg:idx:from:${pn.id}`);
2296
+ const domainCounts = new Map<string, number>();
2297
+ let lessonCount = 0;
2298
+ for (const ek of outEdgeKeys) {
2299
+ const er = await redis.get(ek);
2300
+ if (!er) continue;
2301
+ const edge = safeJsonParse<CKGEdge | null>(er, null);
2302
+ if (!edge || edge.edgeType !== 'authored') continue;
2303
+ lessonCount++;
2304
+ const domain = edge.to.split(':')[0] ?? 'unknown';
2305
+ domainCounts.set(domain, (domainCounts.get(domain) ?? 0) + 1);
2306
+ }
2307
+ people.push({ handle: pn.handle, lessonCount, domains: domainCounts, lastActive: pn.last_active });
2308
+ }
2309
+
2310
+ people.sort((a, b) => b.lessonCount - a.lessonCount);
2311
+ const topPeople = people.slice(0, top_n);
2312
+
2313
+ const lines = [
2314
+ `## 🗺️ Team Expertise Map`,
2315
+ ``,
2316
+ `**${topPeople.length}** contributor${topPeople.length !== 1 ? 's' : ''} tracked:`,
2317
+ ``,
2318
+ `| # | Handle | Lessons | Top domains | Last active |`,
2319
+ `|---|--------|---------|-------------|-------------|`,
2320
+ ];
2321
+
2322
+ for (const [i, p] of topPeople.entries()) {
2323
+ const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}`;
2324
+ const days = Math.floor((Date.now() - new Date(p.lastActive).getTime()) / 86400000);
2325
+ const ago = days < 1 ? 'today' : days < 7 ? `${days}d ago` : days < 30 ? `${Math.floor(days / 7)}w ago` : `${Math.floor(days / 30)}mo ago`;
2326
+ const domainStr = [...p.domains.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([d, c]) => `${d}(${c})`).join(', ') || '—';
2327
+ lines.push(`| ${medal} | **${p.handle}** | ${p.lessonCount} | ${domainStr} | ${ago} |`);
2328
+ }
2329
+
2330
+ lines.push(
2331
+ ``,
2332
+ `---`,
2333
+ `_Use \`brain_who_knows(topic="...")\` to find the expert for a specific problem._`,
2334
+ );
2335
+ return lines.join('\n');
2336
+ }
2337
+
2126
2338
  // ── sync_file_changes ─────────────────────────────────────────────────────
2127
2339
 
2128
2340
  default:
package/src/index.ts CHANGED
@@ -73,7 +73,7 @@ import { Redis } from 'ioredis';
73
73
  const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
74
74
  let JWT = process.env.CACHLY_JWT ?? '';
75
75
  const _EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
76
- const CURRENT_VERSION = '0.10.59';
76
+ const CURRENT_VERSION = '0.10.60';
77
77
 
78
78
  // Max time to wait for a freshly-provisioned instance to become "running" before
79
79
  // giving up. Free-tier provisioning in high-latency regions can take 45–90s, so the
@@ -572,7 +572,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
572
572
  '',
573
573
  'After sign-in: call **any tool again** — your Brain activates instantly.',
574
574
  '',
575
- '✨ Free forever · No credit card · 96 MCP tools · GDPR · EU servers',
575
+ '✨ Free forever · No credit card · 98 MCP tools · GDPR · EU servers',
576
576
  ].join('\n');
577
577
  }
578
578
 
@@ -1816,7 +1816,7 @@ if (!process.argv[2] && process.stdout.isTTY) {
1816
1816
  console.log(' \x1b[36m npx @cachly-dev/mcp-server@latest upgrade\x1b[0m ← Check for updates');
1817
1817
  console.log('');
1818
1818
  console.log(' \x1b[90mWorks with: Claude Code · Cursor · Windsurf · GitHub Copilot · Cline · Zed\x1b[0m');
1819
- console.log(' \x1b[90mFree forever · GDPR · German servers · 96 MCP tools\x1b[0m');
1819
+ console.log(' \x1b[90mFree forever · GDPR · German servers · 98 MCP tools\x1b[0m');
1820
1820
  console.log('');
1821
1821
  process.exit(0);
1822
1822
  }
package/src/tools.ts CHANGED
@@ -575,7 +575,12 @@ const TOOLS = [
575
575
  },
576
576
  author: {
577
577
  type: 'string',
578
- description: 'Name or handle of the person storing this lesson (e.g. "alice", "bob"). Used for Team Telepathy — teammates see each other\'s lessons in session_start.',
578
+ description: 'Name or handle of the person storing this lesson (e.g. "alice", "bob"). Used for Team Telepathy — teammates see each other\'s lessons in session_start. Also powers brain_who_knows and team_expertise_map.',
579
+ },
580
+ visibility: {
581
+ type: 'string',
582
+ enum: ['team', 'private', 'public'],
583
+ description: 'Who can see this lesson. "team" (default) = all team members. "private" = only accessible via exact recall_best_solution, never surfaced in smart_recall or team_recall. "public" = same as team (public syndication planned).',
579
584
  },
580
585
  },
581
586
  required: ['instance_id', 'topic', 'outcome', 'what_worked'],
@@ -817,6 +822,43 @@ const TOOLS = [
817
822
  required: ['instance_id', 'topic'],
818
823
  },
819
824
  },
825
+ {
826
+ name: 'brain_file_map',
827
+ description:
828
+ 'Show what cachly knows about a list of files — experts + related lessons per file. ' +
829
+ 'Call this before starting work on unfamiliar files, or in sync_file_changes to see what knowledge exists. ' +
830
+ 'For each file path: shows who has previously touched it (from learn_from_attempts author+file_paths) ' +
831
+ 'and which lessons reference it. ' +
832
+ 'Example: brain_file_map(file_paths=["src/auth/jwt.ts"]) → "🥇 alice (3× · today) — related: fix:jwt-expiry".',
833
+ inputSchema: {
834
+ type: 'object',
835
+ properties: {
836
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
837
+ file_paths: {
838
+ type: 'array',
839
+ items: { type: 'string' },
840
+ description: 'File paths to look up (max 10)',
841
+ },
842
+ },
843
+ required: ['instance_id', 'file_paths'],
844
+ },
845
+ },
846
+ {
847
+ name: 'team_expertise_map',
848
+ description:
849
+ 'Full team expertise overview — who knows what, at a glance. ' +
850
+ 'Returns a ranked table of all contributors with their lesson count, top domains, and last-active date. ' +
851
+ 'Use for onboarding (who to ask about X?), retrospectives, or to find knowledge gaps. ' +
852
+ 'Built automatically from learn_from_attempts(author=...) calls — no setup needed.',
853
+ inputSchema: {
854
+ type: 'object',
855
+ properties: {
856
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
857
+ top_n: { type: 'number', description: 'Max contributors to show (default: 20)' },
858
+ },
859
+ required: ['instance_id'],
860
+ },
861
+ },
820
862
  {
821
863
  name: 'sync_file_changes',
822
864
  description: