@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.
@@ -6,14 +6,21 @@ import { keywordSearch, tokenize, splitMultiQuery, levenshtein, indexVocab as _i
6
6
  import { rerankByQuality } from '../rerank.js';
7
7
  import { computeEmbedding, hasEmbedProvider } from '../embeddings.js';
8
8
  // ── Changelog (shown once per version in session_start) ──────────────────────
9
- const MCP_VERSION = '0.10.59';
9
+ const MCP_VERSION = '0.10.60';
10
10
  const WHATS_NEW = {
11
+ '0.10.60': [
12
+ `🆕 **Phase 3B: File map + team overview + visibility**`,
13
+ ` 📁 \`brain_file_map(file_paths=[...])\` — experts + lessons per file before you touch it`,
14
+ ` 🗺️ \`team_expertise_map()\` — full team skills matrix in one table`,
15
+ ` 🔒 \`visibility: "private"\` on \`learn_from_attempts\` — private notes never leak to smart_recall`,
16
+ ` 📊 98 MCP tools`,
17
+ ],
11
18
  '0.10.59': [
12
19
  `🆕 **Phase 3A: Org-wide knowledge graph**`,
13
20
  ` 👥 \`brain_who_knows(topic="...")\` — find your team's experts on any topic instantly`,
14
21
  ` 🕸️ Person + File nodes auto-built from \`learn_from_attempts(author="...", file_paths=[...])\``,
15
22
  ` 👤 Author attribution now shown inline in \`smart_recall\` results`,
16
- ` 📊 96 MCP tools · 379 tests green`,
23
+ ` 📊 96 MCP tools · 386 tests green`,
17
24
  ],
18
25
  '0.10.58': [
19
26
  `🧹 Zero lint warnings — all unused imports cleaned across every handler`,
@@ -55,12 +62,12 @@ const WHATS_NEW = {
55
62
  export const BRAIN_TOOL_NAMES = new Set([
56
63
  'learn_from_attempts', 'recall_best_solution', 'smart_recall',
57
64
  'session_start', 'session_end', 'session_ping', 'session_handoff', 'auto_learn_session',
58
- 'brain_who_knows',
65
+ 'brain_who_knows', 'brain_file_map', 'team_expertise_map',
59
66
  ]);
60
67
  export async function handleBrainTool(name, args, getConnection, apiFetch) {
61
68
  switch (name) {
62
69
  case 'learn_from_attempts': {
63
- const { instance_id, topic, outcome, what_worked, what_failed = '', context: ctx = '', severity = 'major', file_paths = [], commands = [], tags = [], depends_on = [], author = '', } = args;
70
+ const { instance_id, topic, outcome, what_worked, what_failed = '', context: ctx = '', severity = 'major', file_paths = [], commands = [], tags = [], depends_on = [], author = '', visibility = 'team', } = args;
64
71
  const redis = await getConnection(instance_id);
65
72
  const ts = new Date().toISOString();
66
73
  // ── Structured template hints ──────────────────────────────────────────
@@ -178,6 +185,7 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
178
185
  tags,
179
186
  depends_on,
180
187
  ...(author ? { author } : {}),
188
+ visibility,
181
189
  recall_count: recallCount,
182
190
  ts,
183
191
  verified_at: outcome === 'success' || outcome === 'partial' ? ts : undefined,
@@ -527,9 +535,9 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
527
535
  }
528
536
  }
529
537
  // ── Layer 2: Semantic search (parallel, optional) ────────────────────────
530
- const inst = await apiFetch(`/api/v1/instances/${instance_id}`);
538
+ const inst = await apiFetch(`/api/v1/instances/${instance_id}`).catch(() => null);
531
539
  const semHits = [];
532
- if (inst.vector_token && hasEmbedProvider()) {
540
+ if (inst?.vector_token && hasEmbedProvider()) {
533
541
  try {
534
542
  const embedding = await computeEmbedding(query);
535
543
  const vectorUrl = `https://api.cachly.dev/v1/sem/${inst.vector_token}`;
@@ -634,7 +642,15 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
634
642
  }
635
643
  }
636
644
  catch { /* non-critical — keyword + semantic always available */ }
637
- const hybridResults = [...hybridMap.values()].sort((a, b) => b.hybridScore - a.hybridScore);
645
+ // Filter private lessons — they are only accessible via exact recall_best_solution
646
+ const hybridResults = [...hybridMap.values()]
647
+ .filter(r => {
648
+ if (!r.key.startsWith('cachly:lesson:best:'))
649
+ return true;
650
+ const ld = safeJsonParse(r.content, {});
651
+ return ld.visibility !== 'private';
652
+ })
653
+ .sort((a, b) => b.hybridScore - a.hybridScore);
638
654
  // ── Build output ──────────────────────────────────────────────────────────
639
655
  const lines = [`🧠 **Smart Recall** for: _"${query}"_\n`];
640
656
  // "Brain saved you here" banner — surfaces value inline for proven recalled lessons
@@ -1911,6 +1927,179 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
1911
1927
  lines.push(`---`, `_Attribution grows with every \`learn_from_attempts(author="...", ...)\` call._`);
1912
1928
  return lines.join('\n');
1913
1929
  }
1930
+ // ── brain_file_map ────────────────────────────────────────────────────────
1931
+ // Phase 3B: "What do we know about these files?" — experts + lessons per file.
1932
+ case 'brain_file_map': {
1933
+ const { instance_id, file_paths = [], } = args;
1934
+ if (!file_paths.length)
1935
+ return '⚠️ Pass at least one file path.';
1936
+ const redis = await getConnection(instance_id);
1937
+ const lines = [
1938
+ `## 📁 File Knowledge Map`,
1939
+ ``,
1940
+ `_What cachly knows about the files you're about to touch:_`,
1941
+ ``,
1942
+ ];
1943
+ for (const fp of file_paths.slice(0, 10)) {
1944
+ lines.push(`### \`${fp}\``);
1945
+ // ── Experts via CKG file nodes ─────────────────────────────────────
1946
+ const fileId = `file:${ckgSlug(fp)}`;
1947
+ const inbound = await redis.smembers(`cachly:ckg:idx:to:${fileId}`);
1948
+ const experts = [];
1949
+ for (const ek of inbound) {
1950
+ const er = await redis.get(ek);
1951
+ if (!er)
1952
+ continue;
1953
+ const edge = safeJsonParse(er, null);
1954
+ if (!edge || edge.edgeType !== 'touched')
1955
+ continue;
1956
+ const personRaw = await redis.get(`cachly:ckg:node:${edge.from}`);
1957
+ if (!personRaw)
1958
+ continue;
1959
+ const pn = safeJsonParse(personRaw, null);
1960
+ if (!pn || pn.type !== 'person')
1961
+ continue;
1962
+ experts.push({ handle: pn.handle, touches: edge.trials, lastSeen: edge.last_updated });
1963
+ }
1964
+ experts.sort((a, b) => b.touches - a.touches);
1965
+ if (experts.length > 0) {
1966
+ const expLine = experts.slice(0, 5).map((e, i) => {
1967
+ const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}.`;
1968
+ const days = Math.floor((Date.now() - new Date(e.lastSeen).getTime()) / 86400000);
1969
+ const ago = days < 1 ? 'today' : days < 7 ? `${days}d ago` : `${Math.floor(days / 7)}w ago`;
1970
+ return `${medal} **${e.handle}** (${e.touches}× · ${ago})`;
1971
+ }).join(' · ');
1972
+ lines.push(`**Experts:** ${expLine}`);
1973
+ }
1974
+ else {
1975
+ lines.push(`**Experts:** _None yet — add \`author\` to \`learn_from_attempts\` calls_`);
1976
+ }
1977
+ // ── Related lessons via BM25 on filename tokens ────────────────────
1978
+ const tokens = fp.replace(/[^a-z0-9]/gi, ' ').split(/\s+/).filter(t => t.length >= 4);
1979
+ const lessonQuery = tokens.slice(0, 3).join(' ');
1980
+ const relatedLessons = [];
1981
+ if (lessonQuery) {
1982
+ try {
1983
+ const hits = await keywordSearch(redis, ['cachly:lesson:best:*'], lessonQuery, 4);
1984
+ for (const h of hits) {
1985
+ const ld = safeJsonParse(h.content, {});
1986
+ if (ld.visibility === 'private')
1987
+ continue;
1988
+ const emoji = ld.outcome === 'success' ? '✅' : ld.outcome === 'partial' ? '⚠️' : '❌';
1989
+ const sev = ld.severity === 'critical' ? ' 🔴' : ld.severity === 'major' ? ' 🟡' : '';
1990
+ const by = ld.author ? ` · 👤 ${ld.author}` : '';
1991
+ const topic = h.key.replace('cachly:lesson:best:', '');
1992
+ relatedLessons.push(` - ${emoji} \`${topic}\`${sev}${by} — ${(ld.what_worked ?? '').slice(0, 100)}`);
1993
+ }
1994
+ }
1995
+ catch { /* non-critical */ }
1996
+ }
1997
+ // Also: exact file_paths match via lesson scan
1998
+ if (relatedLessons.length < 2) {
1999
+ const scanKeys = [];
2000
+ const stream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
2001
+ await new Promise((res, rej) => {
2002
+ stream.on('data', (b) => scanKeys.push(...b));
2003
+ stream.on('end', res);
2004
+ stream.on('error', rej);
2005
+ });
2006
+ for (const k of scanKeys.slice(0, 300)) {
2007
+ const raw = await redis.get(k);
2008
+ if (!raw)
2009
+ continue;
2010
+ const ld = safeJsonParse(raw, {});
2011
+ if (ld.visibility === 'private')
2012
+ continue;
2013
+ if (!(ld.file_paths ?? []).includes(fp))
2014
+ continue;
2015
+ const topic = k.replace('cachly:lesson:best:', '');
2016
+ if (relatedLessons.some(l => l.includes(`\`${topic}\``)))
2017
+ continue;
2018
+ const emoji = ld.outcome === 'success' ? '✅' : ld.outcome === 'partial' ? '⚠️' : '❌';
2019
+ const sev = ld.severity === 'critical' ? ' 🔴' : ld.severity === 'major' ? ' 🟡' : '';
2020
+ const by = ld.author ? ` · 👤 ${ld.author}` : '';
2021
+ relatedLessons.push(` - ${emoji} \`${topic}\`${sev}${by} — ${(ld.what_worked ?? '').slice(0, 100)}`);
2022
+ }
2023
+ }
2024
+ if (relatedLessons.length > 0) {
2025
+ lines.push(`**Related lessons:**`);
2026
+ lines.push(...relatedLessons.slice(0, 5));
2027
+ }
2028
+ else {
2029
+ lines.push(`**Related lessons:** _None yet_`);
2030
+ }
2031
+ lines.push(``);
2032
+ }
2033
+ lines.push(`---`, `_Attribution builds with \`learn_from_attempts(file_paths=[...], author="...")\`. Run this before committing to track what changed and why._`);
2034
+ return lines.join('\n');
2035
+ }
2036
+ // ── team_expertise_map ────────────────────────────────────────────────────
2037
+ // Phase 3B: Full team expertise overview — who knows what, at a glance.
2038
+ case 'team_expertise_map': {
2039
+ const { instance_id, top_n = 20, } = args;
2040
+ const redis = await getConnection(instance_id);
2041
+ // Scan all person nodes
2042
+ const personKeys = [];
2043
+ const pStream = redis.scanStream({ match: 'cachly:ckg:node:person:*', count: 200 });
2044
+ await new Promise((res, rej) => {
2045
+ pStream.on('data', (b) => personKeys.push(...b));
2046
+ pStream.on('end', res);
2047
+ pStream.on('error', rej);
2048
+ });
2049
+ if (personKeys.length === 0) {
2050
+ return [
2051
+ `## 🗺️ Team Expertise Map`,
2052
+ ``,
2053
+ `No contributors yet.`,
2054
+ ``,
2055
+ `Add \`author="handle"\` to any \`learn_from_attempts\` call to start building the map.`,
2056
+ ].join('\n');
2057
+ }
2058
+ const people = [];
2059
+ for (const pk of personKeys) {
2060
+ const raw = await redis.get(pk);
2061
+ if (!raw)
2062
+ continue;
2063
+ const pn = safeJsonParse(raw, null);
2064
+ if (!pn || pn.type !== 'person')
2065
+ continue;
2066
+ // Count authored lessons + collect domains from authored edges
2067
+ const outEdgeKeys = await redis.smembers(`cachly:ckg:idx:from:${pn.id}`);
2068
+ const domainCounts = new Map();
2069
+ let lessonCount = 0;
2070
+ for (const ek of outEdgeKeys) {
2071
+ const er = await redis.get(ek);
2072
+ if (!er)
2073
+ continue;
2074
+ const edge = safeJsonParse(er, null);
2075
+ if (!edge || edge.edgeType !== 'authored')
2076
+ continue;
2077
+ lessonCount++;
2078
+ const domain = edge.to.split(':')[0] ?? 'unknown';
2079
+ domainCounts.set(domain, (domainCounts.get(domain) ?? 0) + 1);
2080
+ }
2081
+ people.push({ handle: pn.handle, lessonCount, domains: domainCounts, lastActive: pn.last_active });
2082
+ }
2083
+ people.sort((a, b) => b.lessonCount - a.lessonCount);
2084
+ const topPeople = people.slice(0, top_n);
2085
+ const lines = [
2086
+ `## 🗺️ Team Expertise Map`,
2087
+ ``,
2088
+ `**${topPeople.length}** contributor${topPeople.length !== 1 ? 's' : ''} tracked:`,
2089
+ ``,
2090
+ `| # | Handle | Lessons | Top domains | Last active |`,
2091
+ `|---|--------|---------|-------------|-------------|`,
2092
+ ];
2093
+ for (const [i, p] of topPeople.entries()) {
2094
+ const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}`;
2095
+ const days = Math.floor((Date.now() - new Date(p.lastActive).getTime()) / 86400000);
2096
+ const ago = days < 1 ? 'today' : days < 7 ? `${days}d ago` : days < 30 ? `${Math.floor(days / 7)}w ago` : `${Math.floor(days / 30)}mo ago`;
2097
+ const domainStr = [...p.domains.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([d, c]) => `${d}(${c})`).join(', ') || '—';
2098
+ lines.push(`| ${medal} | **${p.handle}** | ${p.lessonCount} | ${domainStr} | ${ago} |`);
2099
+ }
2100
+ lines.push(``, `---`, `_Use \`brain_who_knows(topic="...")\` to find the expert for a specific problem._`);
2101
+ return lines.join('\n');
2102
+ }
1914
2103
  // ── sync_file_changes ─────────────────────────────────────────────────────
1915
2104
  default:
1916
2105
  return null;