@cachly-dev/mcp-server 0.10.58 → 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.
@@ -2,17 +2,32 @@ import { execSync } from 'node:child_process';
2
2
  import type { Redis } from 'ioredis';
3
3
  import { calculateConfidence, confidenceBadge, STRUCTURED_TEMPLATES,
4
4
  CONFIDENCE_WARN_VALUE, CONFIDENCE_STALE_VALUE, CONFIDENCE_WARN_DAYS } from '../confidence.js';
5
- import { ckgSlug, extractProblemConcept, ckgUpsertNode, ckgUpdateEdge } from '../ckg.js';
5
+ import { ckgSlug, extractProblemConcept, ckgUpsertNode, ckgUpdateEdge,
6
+ ckgUpsertPersonNode, ckgUpsertFileNode } from '../ckg.js';
6
7
  import { safeJsonParse } from '../utils.js';
7
- import type { CKGEdge, CKGNode } from '../ckg.js';
8
+ import type { CKGEdge, CKGNode, PersonNode } from '../ckg.js';
8
9
  import { keywordSearch, tokenize, splitMultiQuery, levenshtein,
9
10
  indexVocab as _indexVocab } from '../search.js';
10
11
  import { rerankByQuality } from '../rerank.js';
11
12
  import { computeEmbedding, hasEmbedProvider } from '../embeddings.js';
12
13
 
13
14
  // ── Changelog (shown once per version in session_start) ──────────────────────
14
- const MCP_VERSION = '0.10.58';
15
+ const MCP_VERSION = '0.10.60';
15
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
+ ],
24
+ '0.10.59': [
25
+ `🆕 **Phase 3A: Org-wide knowledge graph**`,
26
+ ` 👥 \`brain_who_knows(topic="...")\` — find your team's experts on any topic instantly`,
27
+ ` 🕸️ Person + File nodes auto-built from \`learn_from_attempts(author="...", file_paths=[...])\``,
28
+ ` 👤 Author attribution now shown inline in \`smart_recall\` results`,
29
+ ` 📊 96 MCP tools · 386 tests green`,
30
+ ],
16
31
  '0.10.58': [
17
32
  `🧹 Zero lint warnings — all unused imports cleaned across every handler`,
18
33
  `✅ Build: 0 errors, 0 warnings · 379 tests green`,
@@ -72,6 +87,7 @@ type ApiFetch = <T>(path: string, options?: RequestInit) => Promise<T>;
72
87
  export const BRAIN_TOOL_NAMES = new Set([
73
88
  'learn_from_attempts', 'recall_best_solution', 'smart_recall',
74
89
  'session_start', 'session_end', 'session_ping', 'session_handoff', 'auto_learn_session',
90
+ 'brain_who_knows', 'brain_file_map', 'team_expertise_map',
75
91
  ]);
76
92
 
77
93
  export async function handleBrainTool(
@@ -95,6 +111,7 @@ export async function handleBrainTool(
95
111
  tags = [],
96
112
  depends_on = [],
97
113
  author = '',
114
+ visibility = 'team',
98
115
  } = args as {
99
116
  instance_id: string;
100
117
  topic: string;
@@ -108,6 +125,7 @@ export async function handleBrainTool(
108
125
  tags?: string[];
109
126
  depends_on?: string[];
110
127
  author?: string;
128
+ visibility?: 'public' | 'team' | 'private';
111
129
  };
112
130
 
113
131
  const redis = await getConnection(instance_id);
@@ -237,6 +255,7 @@ export async function handleBrainTool(
237
255
  tags,
238
256
  depends_on,
239
257
  ...(author ? { author } : {}),
258
+ visibility,
240
259
  recall_count: recallCount,
241
260
  ts,
242
261
  verified_at: outcome === 'success' || outcome === 'partial' ? ts : undefined,
@@ -383,6 +402,22 @@ export async function handleBrainTool(
383
402
  }
384
403
  } catch { /* CKG updates are non-critical */ }
385
404
 
405
+ // ── Phase 3A: People + File nodes in knowledge graph ──────────────────────
406
+ // Builds the "who knows what" map automatically from author + file_paths.
407
+ // Person → authored → Concept edges power brain_who_knows queries.
408
+ if (author) {
409
+ try {
410
+ const domain = topic.split(':')[0] ?? 'unknown';
411
+ const personId = await ckgUpsertPersonNode(redis, author, domain);
412
+ const conceptId = ckgSlug(topic);
413
+ await ckgUpdateEdge(redis, personId, 'authored', conceptId, outcome === 'success', outcome === 'partial');
414
+ for (const fp of file_paths.slice(0, 8)) {
415
+ const fileId = await ckgUpsertFileNode(redis, fp);
416
+ await ckgUpdateEdge(redis, personId, 'touched', fileId, true);
417
+ }
418
+ } catch { /* non-critical */ }
419
+ }
420
+
386
421
  // ── Ambient team propagation: if author is set + outcome is success,
387
422
  // auto-store as a team lesson so knowledge is shared without a separate
388
423
  // team_learn call. Tags gain 'team'; the lesson is attributed to the author.
@@ -600,13 +635,13 @@ export async function handleBrainTool(
600
635
  }
601
636
 
602
637
  // ── Layer 2: Semantic search (parallel, optional) ────────────────────────
603
- const inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
638
+ const inst = await apiFetch<Instance | null>(`/api/v1/instances/${instance_id}`).catch(() => null);
604
639
  type SemHit = { key: string; similarity: number; content: string };
605
640
  const semHits: SemHit[] = [];
606
- if (inst.vector_token && hasEmbedProvider()) {
641
+ if (inst?.vector_token && hasEmbedProvider()) {
607
642
  try {
608
643
  const embedding = await computeEmbedding(query);
609
- const vectorUrl = `https://api.cachly.dev/v1/sem/${inst.vector_token}`;
644
+ const vectorUrl = `https://api.cachly.dev/v1/sem/${inst!.vector_token}`;
610
645
  const searchRes = await fetch(`${vectorUrl}/search`, {
611
646
  method: 'POST',
612
647
  headers: { 'Content-Type': 'application/json' },
@@ -711,7 +746,14 @@ export async function handleBrainTool(
711
746
  }
712
747
  } catch { /* non-critical — keyword + semantic always available */ }
713
748
 
714
- 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);
715
757
 
716
758
  // ── Build output ──────────────────────────────────────────────────────────
717
759
  const lines: string[] = [`🧠 **Smart Recall** for: _"${query}"_\n`];
@@ -774,13 +816,18 @@ export async function handleBrainTool(
774
816
  } else {
775
817
  for (const r of hybridResults.slice(0, 8)) {
776
818
  const label = r.key.replace('cachly:ctx:', '📝 ').replace('cachly:lesson:best:', '💡 ').replace('cachly:idx:', '📂 ');
819
+ let authorBadge = '';
820
+ if (r.key.startsWith('cachly:lesson:best:')) {
821
+ const ld = safeJsonParse<{ author?: string }>(r.content, {});
822
+ if (ld.author) authorBadge = ` · 👤 ${ld.author}`;
823
+ }
777
824
  const scorePart = r.matchType === 'hybrid'
778
825
  ? `BM25: ${(r.bm25Score ?? 0).toFixed(2)}, sem: ${((r.semScore ?? 0) * 100).toFixed(0)}%, 🔀 hybrid`
779
826
  : r.matchType === 'semantic'
780
827
  ? `sem: ${((r.semScore ?? 0) * 100).toFixed(0)}%, 🎯 semantic`
781
828
  : `BM25: ${(r.bm25Score ?? 0).toFixed(2)}, matched: ${r.matchedWords?.join(', ')}`;
782
829
  const preview = r.content.slice(0, 400).replace(/\n/g, ' ');
783
- lines.push(`**${label}** _(${scorePart})_`);
830
+ lines.push(`**${label}**${authorBadge} _(${scorePart})_`);
784
831
  lines.push(`> ${preview}${r.content.length > 400 ? '…' : ''}\n`);
785
832
  }
786
833
  }
@@ -1973,6 +2020,321 @@ export async function handleBrainTool(
1973
2020
  return lines.join('\n');
1974
2021
  }
1975
2022
 
2023
+ // ── brain_who_knows ───────────────────────────────────────────────────────
2024
+ // Phase 3A: Org-wide people intelligence. Returns ranked list of contributors
2025
+ // whose authored edges in the CKG match the query topic.
2026
+ case 'brain_who_knows': {
2027
+ const {
2028
+ instance_id,
2029
+ topic,
2030
+ limit = 10,
2031
+ } = args as { instance_id: string; topic: string; limit?: number };
2032
+
2033
+ const redis = await getConnection(instance_id);
2034
+
2035
+ // Step 1: Find candidate concept node IDs matching the query
2036
+ const querySlug = ckgSlug(topic);
2037
+ const qTokens = tokenize(topic).filter((t: string) => t.length >= 3).slice(0, 5);
2038
+
2039
+ const candidateConceptIds = new Set<string>([querySlug]);
2040
+ for (const token of qTokens.slice(0, 3)) {
2041
+ const nodeStream = redis.scanStream({ match: `cachly:ckg:node:*${token}*`, count: 30 });
2042
+ await new Promise<void>((res, rej) => {
2043
+ nodeStream.on('data', (batch: string[]) => {
2044
+ for (const k of batch.slice(0, 10)) {
2045
+ const nodeId = k.replace('cachly:ckg:node:', '');
2046
+ if (!nodeId.startsWith('person:') && !nodeId.startsWith('file:'))
2047
+ candidateConceptIds.add(nodeId);
2048
+ }
2049
+ });
2050
+ nodeStream.on('end', res);
2051
+ nodeStream.on('error', rej);
2052
+ });
2053
+ }
2054
+
2055
+ // Step 2: Traverse authored edges (person → concept) to find experts
2056
+ type PersonScore = {
2057
+ handle: string; lessonCount: number; totalConfidence: number;
2058
+ domains: Set<string>; lastActive: string;
2059
+ };
2060
+ const personScores = new Map<string, PersonScore>();
2061
+
2062
+ for (const conceptId of [...candidateConceptIds].slice(0, 25)) {
2063
+ const inboundEdgeKeys = await redis.smembers(`cachly:ckg:idx:to:${conceptId}`);
2064
+ for (const ek of inboundEdgeKeys) {
2065
+ const er = await redis.get(ek);
2066
+ if (!er) continue;
2067
+ const edge = safeJsonParse<CKGEdge | null>(er, null);
2068
+ if (!edge || edge.edgeType !== 'authored') continue;
2069
+
2070
+ const personNodeRaw = await redis.get(`cachly:ckg:node:${edge.from}`);
2071
+ if (!personNodeRaw) continue;
2072
+ const personNode = safeJsonParse<PersonNode | null>(personNodeRaw, null);
2073
+ if (!personNode || personNode.type !== 'person') continue;
2074
+
2075
+ const existing = personScores.get(edge.from);
2076
+ if (existing) {
2077
+ existing.lessonCount++;
2078
+ existing.totalConfidence += edge.confidence;
2079
+ existing.domains.add(personNode.domain);
2080
+ if (edge.last_updated > existing.lastActive) existing.lastActive = edge.last_updated;
2081
+ } else {
2082
+ personScores.set(edge.from, {
2083
+ handle: personNode.handle,
2084
+ lessonCount: 1,
2085
+ totalConfidence: edge.confidence,
2086
+ domains: new Set([personNode.domain]),
2087
+ lastActive: personNode.last_active ?? edge.last_updated,
2088
+ });
2089
+ }
2090
+ }
2091
+ }
2092
+
2093
+ if (personScores.size === 0) {
2094
+ return [
2095
+ `## 👥 Who Knows About \`${topic}\`?`,
2096
+ ``,
2097
+ `No attributed lessons found yet.`,
2098
+ ``,
2099
+ `Knowledge attribution builds automatically — add \`author="name"\` to \`learn_from_attempts\` calls:`,
2100
+ `\`learn_from_attempts(topic="${topic}", author="your-handle", ...)\``,
2101
+ ].join('\n');
2102
+ }
2103
+
2104
+ // Step 3: Sort by expertise score (lesson count × avg confidence)
2105
+ const ranked = [...personScores.entries()]
2106
+ .map(([, s]) => ({
2107
+ handle: s.handle,
2108
+ lessonCount: s.lessonCount,
2109
+ avgConfidence: s.totalConfidence / s.lessonCount,
2110
+ domains: [...s.domains].slice(0, 3).join(', '),
2111
+ lastActive: s.lastActive,
2112
+ }))
2113
+ .sort((a, b) => (b.lessonCount * b.avgConfidence) - (a.lessonCount * a.avgConfidence))
2114
+ .slice(0, limit);
2115
+
2116
+ const lines = [
2117
+ `## 👥 Who Knows About \`${topic}\`?`,
2118
+ ``,
2119
+ `**${ranked.length}** contributor${ranked.length !== 1 ? 's' : ''} with relevant lessons:`,
2120
+ ``,
2121
+ ];
2122
+
2123
+ for (const [i, p] of ranked.entries()) {
2124
+ const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}.`;
2125
+ const conf = (p.avgConfidence * 100).toFixed(0);
2126
+ const daysSince = Math.floor((Date.now() - new Date(p.lastActive).getTime()) / 86400000);
2127
+ const recency = daysSince < 1 ? 'today' : daysSince < 7 ? `${daysSince}d ago` : daysSince < 30 ? `${Math.floor(daysSince / 7)}w ago` : `${Math.floor(daysSince / 30)}mo ago`;
2128
+ lines.push(
2129
+ `${medal} **${p.handle}** — ${p.lessonCount} lesson${p.lessonCount !== 1 ? 's' : ''} · ${conf}% confidence · last active ${recency}`,
2130
+ ` _domains: ${p.domains}_`,
2131
+ ``,
2132
+ );
2133
+ }
2134
+
2135
+ lines.push(
2136
+ `---`,
2137
+ `_Attribution grows with every \`learn_from_attempts(author="...", ...)\` call._`,
2138
+ );
2139
+
2140
+ return lines.join('\n');
2141
+ }
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
+
1976
2338
  // ── sync_file_changes ─────────────────────────────────────────────────────
1977
2339
 
1978
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.58';
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 · 95 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 · 95 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'],
@@ -799,6 +804,61 @@ const TOOLS = [
799
804
  required: ['instance_id', 'observations'],
800
805
  },
801
806
  },
807
+ {
808
+ name: 'brain_who_knows',
809
+ description:
810
+ 'Find who in your team has the most expertise on a given topic. ' +
811
+ 'Queries the org-wide knowledge graph (built automatically from learn_from_attempts author fields) ' +
812
+ 'and returns a ranked list of contributors whose lessons match the query, ordered by lesson count and confidence. ' +
813
+ 'Use to find the right person to ask before starting a task, or to understand knowledge distribution. ' +
814
+ 'Example: brain_who_knows(topic="kubernetes deployment") → "🥇 alice — 5 lessons, 94% confidence".',
815
+ inputSchema: {
816
+ type: 'object',
817
+ properties: {
818
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
819
+ topic: { type: 'string', description: 'Topic or question to find experts for' },
820
+ limit: { type: 'number', description: 'Max number of experts to return (default: 10)' },
821
+ },
822
+ required: ['instance_id', 'topic'],
823
+ },
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
+ },
802
862
  {
803
863
  name: 'sync_file_changes',
804
864
  description: