@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.
@@ -1,13 +1,27 @@
1
1
  import { execSync } from 'node:child_process';
2
2
  import { calculateConfidence, confidenceBadge, STRUCTURED_TEMPLATES, CONFIDENCE_WARN_VALUE, CONFIDENCE_STALE_VALUE, CONFIDENCE_WARN_DAYS } from '../confidence.js';
3
- import { ckgSlug, extractProblemConcept, ckgUpsertNode, ckgUpdateEdge } from '../ckg.js';
3
+ import { ckgSlug, extractProblemConcept, ckgUpsertNode, ckgUpdateEdge, ckgUpsertPersonNode, ckgUpsertFileNode } from '../ckg.js';
4
4
  import { safeJsonParse } from '../utils.js';
5
5
  import { keywordSearch, tokenize, splitMultiQuery, levenshtein, indexVocab as _indexVocab } from '../search.js';
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.58';
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
+ ],
18
+ '0.10.59': [
19
+ `🆕 **Phase 3A: Org-wide knowledge graph**`,
20
+ ` 👥 \`brain_who_knows(topic="...")\` — find your team's experts on any topic instantly`,
21
+ ` 🕸️ Person + File nodes auto-built from \`learn_from_attempts(author="...", file_paths=[...])\``,
22
+ ` 👤 Author attribution now shown inline in \`smart_recall\` results`,
23
+ ` 📊 96 MCP tools · 386 tests green`,
24
+ ],
11
25
  '0.10.58': [
12
26
  `🧹 Zero lint warnings — all unused imports cleaned across every handler`,
13
27
  `✅ Build: 0 errors, 0 warnings · 379 tests green`,
@@ -48,11 +62,12 @@ const WHATS_NEW = {
48
62
  export const BRAIN_TOOL_NAMES = new Set([
49
63
  'learn_from_attempts', 'recall_best_solution', 'smart_recall',
50
64
  'session_start', 'session_end', 'session_ping', 'session_handoff', 'auto_learn_session',
65
+ 'brain_who_knows', 'brain_file_map', 'team_expertise_map',
51
66
  ]);
52
67
  export async function handleBrainTool(name, args, getConnection, apiFetch) {
53
68
  switch (name) {
54
69
  case 'learn_from_attempts': {
55
- 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;
56
71
  const redis = await getConnection(instance_id);
57
72
  const ts = new Date().toISOString();
58
73
  // ── Structured template hints ──────────────────────────────────────────
@@ -170,6 +185,7 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
170
185
  tags,
171
186
  depends_on,
172
187
  ...(author ? { author } : {}),
188
+ visibility,
173
189
  recall_count: recallCount,
174
190
  ts,
175
191
  verified_at: outcome === 'success' || outcome === 'partial' ? ts : undefined,
@@ -316,6 +332,22 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
316
332
  }
317
333
  }
318
334
  catch { /* CKG updates are non-critical */ }
335
+ // ── Phase 3A: People + File nodes in knowledge graph ──────────────────────
336
+ // Builds the "who knows what" map automatically from author + file_paths.
337
+ // Person → authored → Concept edges power brain_who_knows queries.
338
+ if (author) {
339
+ try {
340
+ const domain = topic.split(':')[0] ?? 'unknown';
341
+ const personId = await ckgUpsertPersonNode(redis, author, domain);
342
+ const conceptId = ckgSlug(topic);
343
+ await ckgUpdateEdge(redis, personId, 'authored', conceptId, outcome === 'success', outcome === 'partial');
344
+ for (const fp of file_paths.slice(0, 8)) {
345
+ const fileId = await ckgUpsertFileNode(redis, fp);
346
+ await ckgUpdateEdge(redis, personId, 'touched', fileId, true);
347
+ }
348
+ }
349
+ catch { /* non-critical */ }
350
+ }
319
351
  // ── Ambient team propagation: if author is set + outcome is success,
320
352
  // auto-store as a team lesson so knowledge is shared without a separate
321
353
  // team_learn call. Tags gain 'team'; the lesson is attributed to the author.
@@ -503,9 +535,9 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
503
535
  }
504
536
  }
505
537
  // ── Layer 2: Semantic search (parallel, optional) ────────────────────────
506
- const inst = await apiFetch(`/api/v1/instances/${instance_id}`);
538
+ const inst = await apiFetch(`/api/v1/instances/${instance_id}`).catch(() => null);
507
539
  const semHits = [];
508
- if (inst.vector_token && hasEmbedProvider()) {
540
+ if (inst?.vector_token && hasEmbedProvider()) {
509
541
  try {
510
542
  const embedding = await computeEmbedding(query);
511
543
  const vectorUrl = `https://api.cachly.dev/v1/sem/${inst.vector_token}`;
@@ -610,7 +642,15 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
610
642
  }
611
643
  }
612
644
  catch { /* non-critical — keyword + semantic always available */ }
613
- 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);
614
654
  // ── Build output ──────────────────────────────────────────────────────────
615
655
  const lines = [`🧠 **Smart Recall** for: _"${query}"_\n`];
616
656
  // "Brain saved you here" banner — surfaces value inline for proven recalled lessons
@@ -670,13 +710,19 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
670
710
  else {
671
711
  for (const r of hybridResults.slice(0, 8)) {
672
712
  const label = r.key.replace('cachly:ctx:', '📝 ').replace('cachly:lesson:best:', '💡 ').replace('cachly:idx:', '📂 ');
713
+ let authorBadge = '';
714
+ if (r.key.startsWith('cachly:lesson:best:')) {
715
+ const ld = safeJsonParse(r.content, {});
716
+ if (ld.author)
717
+ authorBadge = ` · 👤 ${ld.author}`;
718
+ }
673
719
  const scorePart = r.matchType === 'hybrid'
674
720
  ? `BM25: ${(r.bm25Score ?? 0).toFixed(2)}, sem: ${((r.semScore ?? 0) * 100).toFixed(0)}%, 🔀 hybrid`
675
721
  : r.matchType === 'semantic'
676
722
  ? `sem: ${((r.semScore ?? 0) * 100).toFixed(0)}%, 🎯 semantic`
677
723
  : `BM25: ${(r.bm25Score ?? 0).toFixed(2)}, matched: ${r.matchedWords?.join(', ')}`;
678
724
  const preview = r.content.slice(0, 400).replace(/\n/g, ' ');
679
- lines.push(`**${label}** _(${scorePart})_`);
725
+ lines.push(`**${label}**${authorBadge} _(${scorePart})_`);
680
726
  lines.push(`> ${preview}${r.content.length > 400 ? '…' : ''}\n`);
681
727
  }
682
728
  }
@@ -1785,6 +1831,275 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
1785
1831
  lines.push(`**Skipped** (better lesson already exists): ${skipped.map(t => `\`${t}\``).join(', ')}`);
1786
1832
  return lines.join('\n');
1787
1833
  }
1834
+ // ── brain_who_knows ───────────────────────────────────────────────────────
1835
+ // Phase 3A: Org-wide people intelligence. Returns ranked list of contributors
1836
+ // whose authored edges in the CKG match the query topic.
1837
+ case 'brain_who_knows': {
1838
+ const { instance_id, topic, limit = 10, } = args;
1839
+ const redis = await getConnection(instance_id);
1840
+ // Step 1: Find candidate concept node IDs matching the query
1841
+ const querySlug = ckgSlug(topic);
1842
+ const qTokens = tokenize(topic).filter((t) => t.length >= 3).slice(0, 5);
1843
+ const candidateConceptIds = new Set([querySlug]);
1844
+ for (const token of qTokens.slice(0, 3)) {
1845
+ const nodeStream = redis.scanStream({ match: `cachly:ckg:node:*${token}*`, count: 30 });
1846
+ await new Promise((res, rej) => {
1847
+ nodeStream.on('data', (batch) => {
1848
+ for (const k of batch.slice(0, 10)) {
1849
+ const nodeId = k.replace('cachly:ckg:node:', '');
1850
+ if (!nodeId.startsWith('person:') && !nodeId.startsWith('file:'))
1851
+ candidateConceptIds.add(nodeId);
1852
+ }
1853
+ });
1854
+ nodeStream.on('end', res);
1855
+ nodeStream.on('error', rej);
1856
+ });
1857
+ }
1858
+ const personScores = new Map();
1859
+ for (const conceptId of [...candidateConceptIds].slice(0, 25)) {
1860
+ const inboundEdgeKeys = await redis.smembers(`cachly:ckg:idx:to:${conceptId}`);
1861
+ for (const ek of inboundEdgeKeys) {
1862
+ const er = await redis.get(ek);
1863
+ if (!er)
1864
+ continue;
1865
+ const edge = safeJsonParse(er, null);
1866
+ if (!edge || edge.edgeType !== 'authored')
1867
+ continue;
1868
+ const personNodeRaw = await redis.get(`cachly:ckg:node:${edge.from}`);
1869
+ if (!personNodeRaw)
1870
+ continue;
1871
+ const personNode = safeJsonParse(personNodeRaw, null);
1872
+ if (!personNode || personNode.type !== 'person')
1873
+ continue;
1874
+ const existing = personScores.get(edge.from);
1875
+ if (existing) {
1876
+ existing.lessonCount++;
1877
+ existing.totalConfidence += edge.confidence;
1878
+ existing.domains.add(personNode.domain);
1879
+ if (edge.last_updated > existing.lastActive)
1880
+ existing.lastActive = edge.last_updated;
1881
+ }
1882
+ else {
1883
+ personScores.set(edge.from, {
1884
+ handle: personNode.handle,
1885
+ lessonCount: 1,
1886
+ totalConfidence: edge.confidence,
1887
+ domains: new Set([personNode.domain]),
1888
+ lastActive: personNode.last_active ?? edge.last_updated,
1889
+ });
1890
+ }
1891
+ }
1892
+ }
1893
+ if (personScores.size === 0) {
1894
+ return [
1895
+ `## 👥 Who Knows About \`${topic}\`?`,
1896
+ ``,
1897
+ `No attributed lessons found yet.`,
1898
+ ``,
1899
+ `Knowledge attribution builds automatically — add \`author="name"\` to \`learn_from_attempts\` calls:`,
1900
+ `\`learn_from_attempts(topic="${topic}", author="your-handle", ...)\``,
1901
+ ].join('\n');
1902
+ }
1903
+ // Step 3: Sort by expertise score (lesson count × avg confidence)
1904
+ const ranked = [...personScores.entries()]
1905
+ .map(([, s]) => ({
1906
+ handle: s.handle,
1907
+ lessonCount: s.lessonCount,
1908
+ avgConfidence: s.totalConfidence / s.lessonCount,
1909
+ domains: [...s.domains].slice(0, 3).join(', '),
1910
+ lastActive: s.lastActive,
1911
+ }))
1912
+ .sort((a, b) => (b.lessonCount * b.avgConfidence) - (a.lessonCount * a.avgConfidence))
1913
+ .slice(0, limit);
1914
+ const lines = [
1915
+ `## 👥 Who Knows About \`${topic}\`?`,
1916
+ ``,
1917
+ `**${ranked.length}** contributor${ranked.length !== 1 ? 's' : ''} with relevant lessons:`,
1918
+ ``,
1919
+ ];
1920
+ for (const [i, p] of ranked.entries()) {
1921
+ const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}.`;
1922
+ const conf = (p.avgConfidence * 100).toFixed(0);
1923
+ const daysSince = Math.floor((Date.now() - new Date(p.lastActive).getTime()) / 86400000);
1924
+ const recency = daysSince < 1 ? 'today' : daysSince < 7 ? `${daysSince}d ago` : daysSince < 30 ? `${Math.floor(daysSince / 7)}w ago` : `${Math.floor(daysSince / 30)}mo ago`;
1925
+ lines.push(`${medal} **${p.handle}** — ${p.lessonCount} lesson${p.lessonCount !== 1 ? 's' : ''} · ${conf}% confidence · last active ${recency}`, ` _domains: ${p.domains}_`, ``);
1926
+ }
1927
+ lines.push(`---`, `_Attribution grows with every \`learn_from_attempts(author="...", ...)\` call._`);
1928
+ return lines.join('\n');
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
+ }
1788
2103
  // ── sync_file_changes ─────────────────────────────────────────────────────
1789
2104
  default:
1790
2105
  return null;