@cachly-dev/mcp-server 0.10.61 → 0.10.64

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,19 @@
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
3
  import { ckgSlug, extractProblemConcept, ckgUpsertNode, ckgUpdateEdge, ckgUpsertPersonNode, ckgUpsertFileNode } from '../ckg.js';
4
- import { safeJsonParse } from '../utils.js';
4
+ import { safeJsonParse, scanKeys } 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.61';
9
+ const MCP_VERSION = '0.10.64';
10
10
  const WHATS_NEW = {
11
+ '0.10.64': [
12
+ `📈 **The three decisive metrics, now measurable**`,
13
+ ` ⏱️ \`brain_metrics()\` — time-to-first-recall, recall-lift, team-knowledge-reuse in one view`,
14
+ ` 👥 Cross-author reuse tracked: recall a teammate's lesson → counted + surfaced inline`,
15
+ ` 🛡️ Stability: scans now capped + timed out (a huge keyspace can't hang the agent)`,
16
+ ],
11
17
  '0.10.61': [
12
18
  `🎯 **Phase 3C: 100 MCP tools milestone — zero-setup knowledge graph**`,
13
19
  ` 🔄 \`brain_from_git\` now auto-builds Person+File nodes from git history (zero setup!)`,
@@ -69,7 +75,7 @@ export const BRAIN_TOOL_NAMES = new Set([
69
75
  'learn_from_attempts', 'recall_best_solution', 'smart_recall',
70
76
  'session_start', 'session_end', 'session_ping', 'session_handoff', 'auto_learn_session',
71
77
  'brain_who_knows', 'brain_file_map', 'team_expertise_map',
72
- 'skill_gaps', 'brain_coverage',
78
+ 'skill_gaps', 'brain_coverage', 'brain_metrics',
73
79
  ]);
74
80
  export async function handleBrainTool(name, args, getConnection, apiFetch) {
75
81
  switch (name) {
@@ -201,6 +207,9 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
201
207
  version: 3,
202
208
  };
203
209
  const lesson = JSON.stringify(lessonObj);
210
+ // Metric 1 (time-to-first-recall): mark the moment the Brain first gained
211
+ // knowledge. SET NX so only the very first learn wins. Fire-and-forget.
212
+ redis.set(`cachly:stats:born_at:${instance_id}`, ts, 'EX', 365 * 86400, 'NX').catch(() => { });
204
213
  // Always append to the history list (audit log); keep last 100 entries, 90-day TTL
205
214
  const listKey = `cachly:lessons:${topic}`;
206
215
  await redis.rpush(listKey, lesson);
@@ -510,7 +519,7 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
510
519
  return `🔍 **Partial matches for \`${topic}\`:**\n\n${results.join('\n\n')}`;
511
520
  }
512
521
  case 'smart_recall': {
513
- const { instance_id, query, threshold = 0.78, } = args;
522
+ const { instance_id, query, threshold = 0.78, author: requester = '', } = args;
514
523
  const redis = await getConnection(instance_id);
515
524
  // ── Layer 1: Keyword search across ALL brain data (always works, no embedding) ──
516
525
  const rawMatches = await keywordSearch(redis, ['cachly:ctx:*', 'cachly:lesson:best:*', 'cachly:idx:*'], query, 10);
@@ -519,6 +528,7 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
519
528
  const kwMatches = rerankByQuality(rawMatches);
520
529
  const savedHere = [];
521
530
  const lessonMatches = kwMatches.filter(m => m.key.startsWith('cachly:lesson:best:'));
531
+ let crossAuthorThisCall = 0;
522
532
  for (const m of lessonMatches.slice(0, 5)) {
523
533
  const existing = await redis.get(m.key).catch(() => null);
524
534
  if (existing) {
@@ -529,6 +539,15 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
529
539
  const sev = lesson.severity;
530
540
  const savedMins = sev === 'critical' ? 240 : sev === 'major' ? 60 : 30;
531
541
  redis.incrbyfloat(`cachly:stats:time_saved_mins:${instance_id}`, savedMins).catch(() => { });
542
+ // Metric 3 (team-knowledge-reuse): a recall of a lesson authored by
543
+ // someone OTHER than the requester is cross-author reuse — the value
544
+ // only cachly delivers. Track total proven recalls + cross-author ones.
545
+ redis.incr(`cachly:stats:recalls_total:${instance_id}`).catch(() => { });
546
+ if (lesson.author && requester && lesson.author !== requester) {
547
+ crossAuthorThisCall++;
548
+ redis.incr(`cachly:stats:cross_author_recalls:${instance_id}`).catch(() => { });
549
+ redis.sadd(`cachly:stats:reuse_pairs:${instance_id}`, `${requester}<-${lesson.author}`).catch(() => { });
550
+ }
532
551
  // Surface banner for proven successes (recall_count >= 1 means it's been validated)
533
552
  if (lesson.outcome === 'success' && (lesson.recall_count ?? 0) >= 1) {
534
553
  savedHere.push({
@@ -541,6 +560,11 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
541
560
  }
542
561
  }
543
562
  }
563
+ // Metric 1 (time-to-first-recall): stamp the first time recall returned a
564
+ // proven lesson. SET NX so only the first successful recall wins.
565
+ if (savedHere.length > 0) {
566
+ redis.set(`cachly:stats:first_recall_at:${instance_id}`, new Date().toISOString(), 'EX', 365 * 86400, 'NX').catch(() => { });
567
+ }
544
568
  // ── Layer 2: Semantic search (parallel, optional) ────────────────────────
545
569
  const inst = await apiFetch(`/api/v1/instances/${instance_id}`).catch(() => null);
546
570
  const semHits = [];
@@ -666,6 +690,9 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
666
690
  if (topLesson) {
667
691
  lines.push(`> 💡 **Brain saved you ~${topLesson.savedMins}m here** — ${topLesson.severity} issue from \`${topLesson.topic}\` recalled ${topLesson.recall_count}× proven\n`);
668
692
  }
693
+ if (crossAuthorThisCall > 0) {
694
+ lines.push(`> 👥 **Team knowledge reuse** — ${crossAuthorThisCall} of these lesson${crossAuthorThisCall !== 1 ? 's were' : ' was'} written by a teammate. This is the value only a shared brain delivers.\n`);
695
+ }
669
696
  // Show sub-query info if multi-topic was detected
670
697
  const subQueries = splitMultiQuery(query);
671
698
  if (subQueries.length > 1) {
@@ -1843,6 +1870,10 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
1843
1870
  // whose authored edges in the CKG match the query topic.
1844
1871
  case 'brain_who_knows': {
1845
1872
  const { instance_id, topic, limit = 10, } = args;
1873
+ if (!topic || typeof topic !== 'string' || !topic.trim()) {
1874
+ return '⚠️ `brain_who_knows` requires a non-empty `topic`. Example: `brain_who_knows(topic="kubernetes deployment")`.';
1875
+ }
1876
+ const safeLimit = Math.max(1, Math.min(Number(limit) || 10, 50));
1846
1877
  const redis = await getConnection(instance_id);
1847
1878
  // Step 1: Find candidate concept node IDs matching the query
1848
1879
  const querySlug = ckgSlug(topic);
@@ -1917,7 +1948,7 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
1917
1948
  lastActive: s.lastActive,
1918
1949
  }))
1919
1950
  .sort((a, b) => (b.lessonCount * b.avgConfidence) - (a.lessonCount * a.avgConfidence))
1920
- .slice(0, limit);
1951
+ .slice(0, safeLimit);
1921
1952
  const lines = [
1922
1953
  `## 👥 Who Knows About \`${topic}\`?`,
1923
1954
  ``,
@@ -1938,8 +1969,12 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
1938
1969
  // Phase 3B: "What do we know about these files?" — experts + lessons per file.
1939
1970
  case 'brain_file_map': {
1940
1971
  const { instance_id, file_paths = [], } = args;
1941
- if (!file_paths.length)
1942
- return '⚠️ Pass at least one file path.';
1972
+ // Defensive: accept only a non-empty array of non-empty strings.
1973
+ const cleanPaths = (Array.isArray(file_paths) ? file_paths : [])
1974
+ .filter((p) => typeof p === 'string' && p.trim().length > 0)
1975
+ .map(p => p.trim());
1976
+ if (!cleanPaths.length)
1977
+ return '⚠️ Pass at least one file path. Example: `brain_file_map(file_paths=["src/auth/jwt.ts"])`.';
1943
1978
  const redis = await getConnection(instance_id);
1944
1979
  const lines = [
1945
1980
  `## 📁 File Knowledge Map`,
@@ -1947,7 +1982,7 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
1947
1982
  `_What cachly knows about the files you're about to touch:_`,
1948
1983
  ``,
1949
1984
  ];
1950
- for (const fp of file_paths.slice(0, 10)) {
1985
+ for (const fp of cleanPaths.slice(0, 10)) {
1951
1986
  lines.push(`### \`${fp}\``);
1952
1987
  // ── Experts via CKG file nodes ─────────────────────────────────────
1953
1988
  const fileId = `file:${ckgSlug(fp)}`;
@@ -2003,14 +2038,8 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
2003
2038
  }
2004
2039
  // Also: exact file_paths match via lesson scan
2005
2040
  if (relatedLessons.length < 2) {
2006
- const scanKeys = [];
2007
- const stream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
2008
- await new Promise((res, rej) => {
2009
- stream.on('data', (b) => scanKeys.push(...b));
2010
- stream.on('end', res);
2011
- stream.on('error', rej);
2012
- });
2013
- for (const k of scanKeys.slice(0, 300)) {
2041
+ const allLessonKeys = await scanKeys(redis, 'cachly:lesson:best:*', { max: 1500 });
2042
+ for (const k of allLessonKeys.slice(0, 300)) {
2014
2043
  const raw = await redis.get(k);
2015
2044
  if (!raw)
2016
2045
  continue;
@@ -2045,14 +2074,8 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
2045
2074
  case 'team_expertise_map': {
2046
2075
  const { instance_id, top_n = 20, } = args;
2047
2076
  const redis = await getConnection(instance_id);
2048
- // Scan all person nodes
2049
- const personKeys = [];
2050
- const pStream = redis.scanStream({ match: 'cachly:ckg:node:person:*', count: 200 });
2051
- await new Promise((res, rej) => {
2052
- pStream.on('data', (b) => personKeys.push(...b));
2053
- pStream.on('end', res);
2054
- pStream.on('error', rej);
2055
- });
2077
+ // Scan all person nodes (capped + timed out so a huge keyspace can't hang the agent)
2078
+ const personKeys = await scanKeys(redis, 'cachly:ckg:node:person:*', { max: 2000 });
2056
2079
  if (personKeys.length === 0) {
2057
2080
  return [
2058
2081
  `## 🗺️ Team Expertise Map`,
@@ -2113,14 +2136,8 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
2113
2136
  case 'skill_gaps': {
2114
2137
  const { instance_id, min_failures = 1, } = args;
2115
2138
  const redis = await getConnection(instance_id);
2116
- // Scan all best lessons
2117
- const lessonKeys = [];
2118
- const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 500 });
2119
- await new Promise((res, rej) => {
2120
- lStream.on('data', (b) => lessonKeys.push(...b));
2121
- lStream.on('end', res);
2122
- lStream.on('error', rej);
2123
- });
2139
+ // Scan all best lessons (capped + timed out)
2140
+ const lessonKeys = await scanKeys(redis, 'cachly:lesson:best:*', { max: 3000 });
2124
2141
  const domainMap = new Map();
2125
2142
  let totalAttributed = 0;
2126
2143
  let totalPrivate = 0;
@@ -2161,14 +2178,6 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
2161
2178
  });
2162
2179
  }
2163
2180
  }
2164
- // Check for domains with person nodes but no lessons (contributors without lessons)
2165
- const personKeys = [];
2166
- const pStream = redis.scanStream({ match: 'cachly:ckg:node:person:*', count: 200 });
2167
- await new Promise((res, rej) => {
2168
- pStream.on('data', (b) => personKeys.push(...b));
2169
- pStream.on('end', res);
2170
- pStream.on('error', rej);
2171
- });
2172
2181
  const lines = [
2173
2182
  `## 🔍 Skill Gaps — Knowledge Blind Spots`,
2174
2183
  ``,
@@ -2201,30 +2210,12 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
2201
2210
  case 'brain_coverage': {
2202
2211
  const { instance_id, repo_path = '.', } = args;
2203
2212
  const redis = await getConnection(instance_id);
2204
- // Count lessons
2205
- const lessonKeys = [];
2206
- const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 500 });
2207
- await new Promise((res, rej) => {
2208
- lStream.on('data', (b) => lessonKeys.push(...b));
2209
- lStream.on('end', res);
2210
- lStream.on('error', rej);
2211
- });
2212
- // Count person nodes
2213
- const personKeys = [];
2214
- const pStream = redis.scanStream({ match: 'cachly:ckg:node:person:*', count: 200 });
2215
- await new Promise((res, rej) => {
2216
- pStream.on('data', (b) => personKeys.push(...b));
2217
- pStream.on('end', res);
2218
- pStream.on('error', rej);
2219
- });
2220
- // Count file nodes in CKG
2221
- const fileKeys = [];
2222
- const fStream = redis.scanStream({ match: 'cachly:ckg:node:file:*', count: 500 });
2223
- await new Promise((res, rej) => {
2224
- fStream.on('data', (b) => fileKeys.push(...b));
2225
- fStream.on('end', res);
2226
- fStream.on('error', rej);
2227
- });
2213
+ // Count lessons, person + file nodes (each capped + timed out, gathered in parallel)
2214
+ const [lessonKeys, personKeys, fileKeys] = await Promise.all([
2215
+ scanKeys(redis, 'cachly:lesson:best:*', { max: 5000 }),
2216
+ scanKeys(redis, 'cachly:ckg:node:person:*', { max: 2000 }),
2217
+ scanKeys(redis, 'cachly:ckg:node:file:*', { max: 5000 }),
2218
+ ]);
2228
2219
  // Get git file count for comparison (optional — non-critical if not a git repo)
2229
2220
  let gitFileCount = 0;
2230
2221
  let _gitFileCountLabel = 'n/a';
@@ -2290,6 +2281,69 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
2290
2281
  `_Boost score: run \`brain_from_git\` · add \`author\` to \`learn_from_attempts\` · fix gaps with \`skill_gaps\`_`,
2291
2282
  ].join('\n');
2292
2283
  }
2284
+ // ── brain_metrics ─────────────────────────────────────────────────────────
2285
+ // The three decisive metrics (PROGRESS.md §3 / VISION_10X.md §4):
2286
+ // 1. Time-to-first-recall — onboarding friction
2287
+ // 2. Recall-lift — the moat proof (vs. raw BM25)
2288
+ // 3. Team-knowledge-reuse — the value only a shared brain delivers
2289
+ case 'brain_metrics': {
2290
+ const { instance_id } = args;
2291
+ const redis = await getConnection(instance_id);
2292
+ const [bornAt, firstRecallAt, recallsTotalRaw, crossAuthorRaw, timeSavedRaw, reusePairs] = await Promise.all([
2293
+ redis.get(`cachly:stats:born_at:${instance_id}`).catch(() => null),
2294
+ redis.get(`cachly:stats:first_recall_at:${instance_id}`).catch(() => null),
2295
+ redis.get(`cachly:stats:recalls_total:${instance_id}`).catch(() => null),
2296
+ redis.get(`cachly:stats:cross_author_recalls:${instance_id}`).catch(() => null),
2297
+ redis.get(`cachly:stats:time_saved_mins:${instance_id}`).catch(() => null),
2298
+ redis.smembers(`cachly:stats:reuse_pairs:${instance_id}`).catch(() => []),
2299
+ ]);
2300
+ // ── Metric 1: Time-to-first-recall ──────────────────────────────────────
2301
+ let ttfrLine;
2302
+ if (bornAt && firstRecallAt) {
2303
+ const ms = new Date(firstRecallAt).getTime() - new Date(bornAt).getTime();
2304
+ const mins = Math.max(0, Math.round(ms / 60000));
2305
+ const human = mins < 1 ? '<1 min' : mins < 60 ? `${mins} min` : `${(mins / 60).toFixed(1)} h`;
2306
+ const target = ms <= 2 * 60000 ? '🟢 under 2 min target' : ms <= 60 * 60000 ? '🟡 over 2 min' : '🔴 over 1 h';
2307
+ ttfrLine = `**${human}** from first lesson → first proven recall · ${target}`;
2308
+ }
2309
+ else if (bornAt && !firstRecallAt) {
2310
+ ttfrLine = `🟡 Brain has knowledge but no proven recall yet — try \`smart_recall\``;
2311
+ }
2312
+ else {
2313
+ ttfrLine = `⚪ Not enough data yet — store a lesson, then recall it`;
2314
+ }
2315
+ // ── Metric 2: Recall-lift (the moat proof) ──────────────────────────────
2316
+ // Published headline from Cachly-Bench (CI-defended in rerank.test.ts).
2317
+ const recallLiftLine = `**+22.2 % Precision@1**, **+10.9 % MRR**, **+8.1 % nDCG@5** vs. raw BM25`;
2318
+ // ── Metric 3: Team-knowledge-reuse ──────────────────────────────────────
2319
+ const recallsTotal = Number(recallsTotalRaw ?? 0);
2320
+ const crossAuthor = Number(crossAuthorRaw ?? 0);
2321
+ const reusePct = recallsTotal > 0 ? Math.round((crossAuthor / recallsTotal) * 100) : 0;
2322
+ const reuseTarget = reusePct >= 30 ? '🟢 above 30 % target' : recallsTotal === 0 ? '⚪ no recalls yet' : '🟡 below 30 % target';
2323
+ const timeSaved = Math.round(Number(timeSavedRaw ?? 0));
2324
+ const timeSavedHuman = timeSaved < 60 ? `${timeSaved} min` : `${(timeSaved / 60).toFixed(1)} h`;
2325
+ const lines = [
2326
+ `## 📈 Brain Metrics — the three that decide everything`,
2327
+ ``,
2328
+ `### 1. Time-to-first-recall _(onboarding friction)_`,
2329
+ ttfrLine,
2330
+ ``,
2331
+ `### 2. Recall-lift _(the moat proof)_`,
2332
+ recallLiftLine,
2333
+ `_Reproduce: \`npm run bench\` · CI-defended in \`rerank.test.ts\` · details in BENCH.md_`,
2334
+ ``,
2335
+ `### 3. Team-knowledge-reuse _(the value only a shared brain delivers)_`,
2336
+ `**${reusePct}%** of proven recalls used a teammate's lesson (${crossAuthor}/${recallsTotal}) · ${reuseTarget}`,
2337
+ reusePairs.length > 0 ? `**${reusePairs.length}** distinct reuse relationship${reusePairs.length !== 1 ? 's' : ''} across the team` : '',
2338
+ ``,
2339
+ `---`,
2340
+ `⏱️ **${timeSavedHuman}** total saved not re-researching known fixes.`,
2341
+ recallsTotal === 0
2342
+ ? `_Tip: pass \`author="your-handle"\` to \`smart_recall\` so cross-author reuse can be tracked._`
2343
+ : `_These numbers compound: every learned lesson and every teammate raises all three._`,
2344
+ ].filter(Boolean);
2345
+ return lines.join('\n');
2346
+ }
2293
2347
  // ── sync_file_changes ─────────────────────────────────────────────────────
2294
2348
  default:
2295
2349
  return null;