@cachly-dev/mcp-server 0.10.62 → 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.
package/dist/src/utils.js CHANGED
@@ -10,4 +10,80 @@ export function safeJsonParse(raw, fallback) {
10
10
  return fallback;
11
11
  }
12
12
  }
13
+ /**
14
+ * Runs a promise with a hard timeout. If it doesn't settle within `ms`, resolves
15
+ * with `fallback` instead of hanging. A memory tool must NEVER block the agent's
16
+ * turn — a slow Redis scan or unreachable backend degrades gracefully to a fallback.
17
+ */
18
+ export async function withTimeout(p, ms, fallback) {
19
+ let timer;
20
+ const guard = new Promise((resolve) => {
21
+ timer = setTimeout(() => resolve(fallback), ms);
22
+ });
23
+ try {
24
+ return await Promise.race([p, guard]);
25
+ }
26
+ catch {
27
+ return fallback;
28
+ }
29
+ finally {
30
+ if (timer)
31
+ clearTimeout(timer);
32
+ }
33
+ }
34
+ /**
35
+ * Collects keys matching a pattern with a hard cap and timeout. Prevents a tool
36
+ * from hanging on a huge keyspace: stops early at `max` keys, and resolves with
37
+ * whatever was gathered if the scan exceeds `timeoutMs`. Never rejects.
38
+ */
39
+ export function scanKeys(redis, pattern, opts = {}) {
40
+ const max = opts.max ?? 1000;
41
+ const timeoutMs = opts.timeoutMs ?? 3000;
42
+ return new Promise((resolve) => {
43
+ const keys = [];
44
+ let done = false;
45
+ const finish = () => { if (!done) {
46
+ done = true;
47
+ resolve(keys);
48
+ } };
49
+ const timer = setTimeout(finish, timeoutMs);
50
+ try {
51
+ const stream = redis.scanStream({ match: pattern, count: opts.count ?? 200 });
52
+ stream.on('data', (batch) => {
53
+ for (const k of batch) {
54
+ keys.push(k);
55
+ if (keys.length >= max) {
56
+ clearTimeout(timer);
57
+ finish();
58
+ return;
59
+ }
60
+ }
61
+ });
62
+ stream.on('end', () => { clearTimeout(timer); finish(); });
63
+ stream.on('error', () => { clearTimeout(timer); finish(); });
64
+ }
65
+ catch {
66
+ clearTimeout(timer);
67
+ finish();
68
+ }
69
+ });
70
+ }
71
+ /**
72
+ * Normalizes a git path that may come from `--name-only` with rename notation,
73
+ * e.g. `src/{old => new}/file.ts` → `src/new/file.ts`, and `a => b` → `b`.
74
+ * Returns the post-rename path so file nodes don't fragment on renames.
75
+ */
76
+ export function normalizeGitPath(raw) {
77
+ let p = raw.trim();
78
+ if (!p)
79
+ return p;
80
+ // Brace rename: dir/{old => new}/file → dir/new/file
81
+ p = p.replace(/\{[^}]*=>\s*([^}]*)\}/g, (_m, after) => after.trim());
82
+ // Bare rename: old => new → new
83
+ const arrow = p.split('=>');
84
+ if (arrow.length === 2)
85
+ p = arrow[1].trim();
86
+ // Collapse any double slashes introduced by an empty rename segment.
87
+ return p.replace(/\/{2,}/g, '/').replace(/^\/|\/$/g, '');
88
+ }
13
89
  //# sourceMappingURL=utils.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"utils.js","sourceRoot":"","sources":["../../src/utils.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,gFAAgF;AAChF,MAAM,UAAU,aAAa,CAAI,GAA8B,EAAE,QAAW;IAC1E,IAAI,CAAC,GAAG;QAAE,OAAO,QAAQ,CAAC;IAC1B,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAM,CAAC;IAC9B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,QAAQ,CAAC;IAClB,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"utils.js","sourceRoot":"","sources":["../../src/utils.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@cachly-dev/mcp-server",
3
- "version": "0.10.62",
3
+ "version": "0.10.64",
4
4
  "mcpName": "io.github.cachly-dev/cachly-mcp",
5
- "description": "Your AI forgets everything between sessions. cachly fixes that permanently. Persistent memory + causal root-cause analysis for Claude Code, Cursor, Copilot & Windsurf. Learns from every bug fix, git commit, and CI run automatically. Arrives pre-briefed. Predicts failures before they happen. 100 MCP tools · Free tier forever · GDPR · EU servers.",
5
+ "description": "Your AI forgets everything between sessions. cachly fixes that permanently. Persistent memory + causal root-cause analysis for Claude Code, Cursor, Copilot & Windsurf. Learns from every bug fix, git commit, and CI run automatically. Arrives pre-briefed. Predicts failures before they happen. 101 MCP tools · Free tier forever · GDPR · EU servers.",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "cachly-mcp": "dist/src/index.js"
package/server.json CHANGED
@@ -1,18 +1,18 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.cachly-dev/cachly-mcp",
4
- "description": "Your AI forgets everything between sessions. cachly fixes that permanently. A causal brain that learns WHY things break — built from every bug fix, git commit, and CI run, without any explicit calls. Arrives pre-briefed each session. Predicts failures before they happen. Never re-researches the same problem. 100 MCP tools · Free tier · EU servers · GDPR.",
4
+ "description": "Your AI forgets everything between sessions. cachly fixes that permanently. A causal brain that learns WHY things break — built from every bug fix, git commit, and CI run, without any explicit calls. Arrives pre-briefed each session. Predicts failures before they happen. Never re-researches the same problem. 101 MCP tools · Free tier · EU servers · GDPR.",
5
5
  "repository": {
6
6
  "url": "https://github.com/cachly-dev/cachly-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.10.62",
9
+ "version": "0.10.64",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "name": "@cachly-dev/mcp-server",
14
14
  "identifier": "@cachly-dev/mcp-server",
15
- "version": "0.10.62",
15
+ "version": "0.10.64",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
@@ -33,11 +33,19 @@ class MockRedis {
33
33
  return this.store.get(key) ?? null;
34
34
  }
35
35
 
36
- async set(key: string, value: string): Promise<'OK'> {
36
+ async set(key: string, value: string, ...opts: unknown[]): Promise<'OK' | null> {
37
+ // Honor a trailing 'NX' flag so first-wins semantics (born_at, first_recall_at) work.
38
+ if (opts.includes('NX') && this.store.has(key)) return null;
37
39
  this.store.set(key, value);
38
40
  return 'OK';
39
41
  }
40
42
 
43
+ async incr(key: string): Promise<number> {
44
+ const next = parseInt(this.store.get(key) ?? '0', 10) + 1;
45
+ this.store.set(key, String(next));
46
+ return next;
47
+ }
48
+
41
49
  async rpush(key: string, ...values: string[]): Promise<number> {
42
50
  const list = this.lists.get(key) ?? [];
43
51
  list.push(...values);
@@ -1389,3 +1397,76 @@ describe('Phase 3 stability: input guards', () => {
1389
1397
  expect(out).toContain('Pass at least one file path');
1390
1398
  });
1391
1399
  });
1400
+
1401
+ describe('Phase 3C: brain_metrics — the three decisive metrics', () => {
1402
+ let redis: MockRedis;
1403
+ const iid = 'i1';
1404
+ const getConn = async () => redis as unknown as Redis;
1405
+
1406
+ beforeEach(() => { redis = new MockRedis(); });
1407
+
1408
+ it('reports "not enough data" before any learning', async () => {
1409
+ const out = await handleBrainTool('brain_metrics', { instance_id: iid }, getConn, noopApiFetch);
1410
+ expect(out).toContain('Brain Metrics');
1411
+ expect(out).toContain('Not enough data');
1412
+ });
1413
+
1414
+ it('always shows the published recall-lift moat number', async () => {
1415
+ const out = await handleBrainTool('brain_metrics', { instance_id: iid }, getConn, noopApiFetch);
1416
+ expect(out).toContain('+22.2 % Precision@1');
1417
+ });
1418
+
1419
+ it('records born_at on first learn (first-wins NX semantics)', async () => {
1420
+ await handleBrainTool('learn_from_attempts', {
1421
+ instance_id: iid, topic: 'fix:a', outcome: 'success', what_worked: 'x', author: 'alice',
1422
+ }, getConn, noopApiFetch);
1423
+ const born1 = await redis.get(`cachly:stats:born_at:${iid}`);
1424
+ expect(born1).not.toBeNull();
1425
+ // Second learn must NOT overwrite born_at
1426
+ await handleBrainTool('learn_from_attempts', {
1427
+ instance_id: iid, topic: 'fix:b', outcome: 'success', what_worked: 'y', author: 'bob',
1428
+ }, getConn, noopApiFetch);
1429
+ const born2 = await redis.get(`cachly:stats:born_at:${iid}`);
1430
+ expect(born2).toBe(born1);
1431
+ });
1432
+
1433
+ it('tracks cross-author reuse when one person recalls another\'s lesson', async () => {
1434
+ // Alice stores a proven lesson, recalled once so it counts as "proven"
1435
+ await handleBrainTool('learn_from_attempts', {
1436
+ instance_id: iid, topic: 'fix:race', outcome: 'success',
1437
+ what_worked: 'add mutex around xyzzy counter', author: 'alice',
1438
+ }, getConn, noopApiFetch);
1439
+ // Bob recalls it (twice → first recall marks proven, second counts reuse on a proven lesson)
1440
+ await handleBrainTool('smart_recall', { instance_id: iid, query: 'xyzzy counter mutex', author: 'bob' }, getConn, noopApiFetch);
1441
+ await handleBrainTool('smart_recall', { instance_id: iid, query: 'xyzzy counter mutex', author: 'bob' }, getConn, noopApiFetch);
1442
+
1443
+ const cross = await redis.get(`cachly:stats:cross_author_recalls:${iid}`);
1444
+ expect(Number(cross)).toBeGreaterThanOrEqual(1);
1445
+
1446
+ const out = await handleBrainTool('brain_metrics', { instance_id: iid }, getConn, noopApiFetch);
1447
+ expect(out).toContain('Team-knowledge-reuse');
1448
+ expect(out).toContain('distinct reuse relationship');
1449
+ });
1450
+
1451
+ it('does not count self-recall as cross-author reuse', async () => {
1452
+ await handleBrainTool('learn_from_attempts', {
1453
+ instance_id: iid, topic: 'fix:solo', outcome: 'success',
1454
+ what_worked: 'fixed the wibble myself', author: 'alice',
1455
+ }, getConn, noopApiFetch);
1456
+ await handleBrainTool('smart_recall', { instance_id: iid, query: 'wibble', author: 'alice' }, getConn, noopApiFetch);
1457
+ await handleBrainTool('smart_recall', { instance_id: iid, query: 'wibble', author: 'alice' }, getConn, noopApiFetch);
1458
+ const cross = await redis.get(`cachly:stats:cross_author_recalls:${iid}`);
1459
+ expect(Number(cross ?? 0)).toBe(0);
1460
+ });
1461
+
1462
+ it('surfaces the team-reuse banner inline in smart_recall', async () => {
1463
+ await handleBrainTool('learn_from_attempts', {
1464
+ instance_id: iid, topic: 'fix:plimsoll', outcome: 'success',
1465
+ what_worked: 'adjust the plimsoll threshold', author: 'carol',
1466
+ }, getConn, noopApiFetch);
1467
+ // First recall by dave marks it proven; second recall shows the reuse banner
1468
+ await handleBrainTool('smart_recall', { instance_id: iid, query: 'plimsoll threshold', author: 'dave' }, getConn, noopApiFetch);
1469
+ const out = await handleBrainTool('smart_recall', { instance_id: iid, query: 'plimsoll threshold', author: 'dave' }, getConn, noopApiFetch);
1470
+ expect(out).toContain('Team knowledge reuse');
1471
+ });
1472
+ });
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { EventEmitter } from 'node:events';
3
+ import { safeJsonParse, withTimeout, normalizeGitPath, scanKeys } from '../utils.js';
4
+
5
+ describe('safeJsonParse', () => {
6
+ it('parses valid JSON', () => {
7
+ expect(safeJsonParse('{"a":1}', {})).toEqual({ a: 1 });
8
+ });
9
+ it('returns fallback on null/empty/malformed', () => {
10
+ expect(safeJsonParse(null, 'fb')).toBe('fb');
11
+ expect(safeJsonParse('', 'fb')).toBe('fb');
12
+ expect(safeJsonParse('{not json', 'fb')).toBe('fb');
13
+ });
14
+ });
15
+
16
+ describe('withTimeout', () => {
17
+ it('resolves with the promise value when it settles in time', async () => {
18
+ const out = await withTimeout(Promise.resolve('ok'), 1000, 'fallback');
19
+ expect(out).toBe('ok');
20
+ });
21
+ it('resolves with fallback when the promise is too slow', async () => {
22
+ const slow = new Promise<string>((r) => setTimeout(() => r('late'), 100));
23
+ const out = await withTimeout(slow, 10, 'fallback');
24
+ expect(out).toBe('fallback');
25
+ });
26
+ it('resolves with fallback when the promise rejects', async () => {
27
+ const out = await withTimeout(Promise.reject(new Error('boom')), 1000, 'fallback');
28
+ expect(out).toBe('fallback');
29
+ });
30
+ });
31
+
32
+ describe('normalizeGitPath', () => {
33
+ it('passes a normal path through', () => {
34
+ expect(normalizeGitPath('src/auth/jwt.ts')).toBe('src/auth/jwt.ts');
35
+ });
36
+ it('resolves brace rename notation to the post-rename path', () => {
37
+ expect(normalizeGitPath('src/{old => new}/file.ts')).toBe('src/new/file.ts');
38
+ });
39
+ it('resolves bare rename notation', () => {
40
+ expect(normalizeGitPath('old/path.ts => new/path.ts')).toBe('new/path.ts');
41
+ });
42
+ it('collapses an empty rename segment cleanly', () => {
43
+ // dir/{ => sub}/f.ts → dir/sub/f.ts
44
+ expect(normalizeGitPath('dir/{ => sub}/f.ts')).toBe('dir/sub/f.ts');
45
+ });
46
+ it('trims whitespace', () => {
47
+ expect(normalizeGitPath(' src/x.ts ')).toBe('src/x.ts');
48
+ });
49
+ });
50
+
51
+ // Minimal scan-capable mock
52
+ class ScanMock {
53
+ constructor(private keys: string[], private opts: { hang?: boolean } = {}) {}
54
+ scanStream(_o: { match: string; count?: number }) {
55
+ const em = new EventEmitter();
56
+ if (this.opts.hang) return em; // never emits — exercises the timeout path
57
+ setImmediate(() => {
58
+ em.emit('data', this.keys);
59
+ em.emit('end');
60
+ });
61
+ return em;
62
+ }
63
+ }
64
+
65
+ describe('scanKeys', () => {
66
+ it('collects all matching keys', async () => {
67
+ const mock = new ScanMock(['a', 'b', 'c']);
68
+ const out = await scanKeys(mock, 'pattern:*');
69
+ expect(out).toEqual(['a', 'b', 'c']);
70
+ });
71
+ it('caps at max keys', async () => {
72
+ const mock = new ScanMock(['a', 'b', 'c', 'd', 'e']);
73
+ const out = await scanKeys(mock, 'pattern:*', { max: 3 });
74
+ expect(out).toHaveLength(3);
75
+ });
76
+ it('resolves with gathered keys on timeout instead of hanging', async () => {
77
+ const mock = new ScanMock([], { hang: true });
78
+ const out = await scanKeys(mock, 'pattern:*', { timeoutMs: 20 });
79
+ expect(out).toEqual([]);
80
+ });
81
+ });
@@ -4,7 +4,7 @@ import { calculateConfidence, confidenceBadge, STRUCTURED_TEMPLATES,
4
4
  CONFIDENCE_WARN_VALUE, CONFIDENCE_STALE_VALUE, CONFIDENCE_WARN_DAYS } from '../confidence.js';
5
5
  import { ckgSlug, extractProblemConcept, ckgUpsertNode, ckgUpdateEdge,
6
6
  ckgUpsertPersonNode, ckgUpsertFileNode } from '../ckg.js';
7
- import { safeJsonParse } from '../utils.js';
7
+ import { safeJsonParse, scanKeys } from '../utils.js';
8
8
  import type { CKGEdge, CKGNode, PersonNode } from '../ckg.js';
9
9
  import { keywordSearch, tokenize, splitMultiQuery, levenshtein,
10
10
  indexVocab as _indexVocab } from '../search.js';
@@ -12,8 +12,14 @@ 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.62';
15
+ const MCP_VERSION = '0.10.64';
16
16
  const WHATS_NEW: Record<string, string[]> = {
17
+ '0.10.64': [
18
+ `📈 **The three decisive metrics, now measurable**`,
19
+ ` ⏱️ \`brain_metrics()\` — time-to-first-recall, recall-lift, team-knowledge-reuse in one view`,
20
+ ` 👥 Cross-author reuse tracked: recall a teammate's lesson → counted + surfaced inline`,
21
+ ` 🛡️ Stability: scans now capped + timed out (a huge keyspace can't hang the agent)`,
22
+ ],
17
23
  '0.10.61': [
18
24
  `🎯 **Phase 3C: 100 MCP tools milestone — zero-setup knowledge graph**`,
19
25
  ` 🔄 \`brain_from_git\` now auto-builds Person+File nodes from git history (zero setup!)`,
@@ -94,7 +100,7 @@ export const BRAIN_TOOL_NAMES = new Set([
94
100
  'learn_from_attempts', 'recall_best_solution', 'smart_recall',
95
101
  'session_start', 'session_end', 'session_ping', 'session_handoff', 'auto_learn_session',
96
102
  'brain_who_knows', 'brain_file_map', 'team_expertise_map',
97
- 'skill_gaps', 'brain_coverage',
103
+ 'skill_gaps', 'brain_coverage', 'brain_metrics',
98
104
  ]);
99
105
 
100
106
  export async function handleBrainTool(
@@ -272,6 +278,10 @@ export async function handleBrainTool(
272
278
  };
273
279
  const lesson = JSON.stringify(lessonObj);
274
280
 
281
+ // Metric 1 (time-to-first-recall): mark the moment the Brain first gained
282
+ // knowledge. SET NX so only the very first learn wins. Fire-and-forget.
283
+ redis.set(`cachly:stats:born_at:${instance_id}`, ts, 'EX', 365 * 86400, 'NX').catch(() => {});
284
+
275
285
  // Always append to the history list (audit log); keep last 100 entries, 90-day TTL
276
286
  const listKey = `cachly:lessons:${topic}`;
277
287
  await redis.rpush(listKey, lesson);
@@ -598,7 +608,8 @@ export async function handleBrainTool(
598
608
  instance_id,
599
609
  query,
600
610
  threshold = 0.78,
601
- } = args as { instance_id: string; query: string; threshold?: number };
611
+ author: requester = '',
612
+ } = args as { instance_id: string; query: string; threshold?: number; author?: string };
602
613
 
603
614
  const redis = await getConnection(instance_id);
604
615
 
@@ -618,16 +629,28 @@ export async function handleBrainTool(
618
629
  type RecalledLesson = { topic: string; severity: string; recall_count: number; savedMins: number };
619
630
  const savedHere: RecalledLesson[] = [];
620
631
  const lessonMatches = kwMatches.filter(m => m.key.startsWith('cachly:lesson:best:'));
632
+ let crossAuthorThisCall = 0;
621
633
  for (const m of lessonMatches.slice(0, 5)) {
622
634
  const existing = await redis.get(m.key).catch(() => null);
623
635
  if (existing) {
624
- const lesson = safeJsonParse(existing, null as null | { recall_count?: number; outcome?: string; severity?: string; [k: string]: unknown });
636
+ const lesson = safeJsonParse(existing, null as null | { recall_count?: number; outcome?: string; severity?: string; author?: string; [k: string]: unknown });
625
637
  if (lesson) {
626
638
  const updated = { ...lesson, recall_count: (lesson.recall_count ?? 0) + 1, verified_at: new Date().toISOString() };
627
639
  redis.set(m.key, JSON.stringify(updated)).catch(() => {});
628
640
  const sev = lesson.severity as string;
629
641
  const savedMins = sev === 'critical' ? 240 : sev === 'major' ? 60 : 30;
630
642
  redis.incrbyfloat(`cachly:stats:time_saved_mins:${instance_id}`, savedMins).catch(() => {});
643
+
644
+ // Metric 3 (team-knowledge-reuse): a recall of a lesson authored by
645
+ // someone OTHER than the requester is cross-author reuse — the value
646
+ // only cachly delivers. Track total proven recalls + cross-author ones.
647
+ redis.incr(`cachly:stats:recalls_total:${instance_id}`).catch(() => {});
648
+ if (lesson.author && requester && lesson.author !== requester) {
649
+ crossAuthorThisCall++;
650
+ redis.incr(`cachly:stats:cross_author_recalls:${instance_id}`).catch(() => {});
651
+ redis.sadd(`cachly:stats:reuse_pairs:${instance_id}`, `${requester}<-${lesson.author}`).catch(() => {});
652
+ }
653
+
631
654
  // Surface banner for proven successes (recall_count >= 1 means it's been validated)
632
655
  if (lesson.outcome === 'success' && (lesson.recall_count ?? 0) >= 1) {
633
656
  savedHere.push({
@@ -641,6 +664,12 @@ export async function handleBrainTool(
641
664
  }
642
665
  }
643
666
 
667
+ // Metric 1 (time-to-first-recall): stamp the first time recall returned a
668
+ // proven lesson. SET NX so only the first successful recall wins.
669
+ if (savedHere.length > 0) {
670
+ redis.set(`cachly:stats:first_recall_at:${instance_id}`, new Date().toISOString(), 'EX', 365 * 86400, 'NX').catch(() => {});
671
+ }
672
+
644
673
  // ── Layer 2: Semantic search (parallel, optional) ────────────────────────
645
674
  const inst = await apiFetch<Instance | null>(`/api/v1/instances/${instance_id}`).catch(() => null);
646
675
  type SemHit = { key: string; similarity: number; content: string };
@@ -771,6 +800,9 @@ export async function handleBrainTool(
771
800
  if (topLesson) {
772
801
  lines.push(`> 💡 **Brain saved you ~${topLesson.savedMins}m here** — ${topLesson.severity} issue from \`${topLesson.topic}\` recalled ${topLesson.recall_count}× proven\n`);
773
802
  }
803
+ if (crossAuthorThisCall > 0) {
804
+ 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`);
805
+ }
774
806
 
775
807
  // Show sub-query info if multi-topic was detected
776
808
  const subQueries = splitMultiQuery(query);
@@ -2230,13 +2262,8 @@ export async function handleBrainTool(
2230
2262
  }
2231
2263
  // Also: exact file_paths match via lesson scan
2232
2264
  if (relatedLessons.length < 2) {
2233
- const scanKeys: string[] = [];
2234
- const stream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
2235
- await new Promise<void>((res, rej) => {
2236
- stream.on('data', (b: string[]) => scanKeys.push(...b));
2237
- stream.on('end', res); stream.on('error', rej);
2238
- });
2239
- for (const k of scanKeys.slice(0, 300)) {
2265
+ const allLessonKeys = await scanKeys(redis, 'cachly:lesson:best:*', { max: 1500 });
2266
+ for (const k of allLessonKeys.slice(0, 300)) {
2240
2267
  const raw = await redis.get(k);
2241
2268
  if (!raw) continue;
2242
2269
  const ld = safeJsonParse<{
@@ -2280,13 +2307,8 @@ export async function handleBrainTool(
2280
2307
 
2281
2308
  const redis = await getConnection(instance_id);
2282
2309
 
2283
- // Scan all person nodes
2284
- const personKeys: string[] = [];
2285
- const pStream = redis.scanStream({ match: 'cachly:ckg:node:person:*', count: 200 });
2286
- await new Promise<void>((res, rej) => {
2287
- pStream.on('data', (b: string[]) => personKeys.push(...b));
2288
- pStream.on('end', res); pStream.on('error', rej);
2289
- });
2310
+ // Scan all person nodes (capped + timed out so a huge keyspace can't hang the agent)
2311
+ const personKeys = await scanKeys(redis, 'cachly:ckg:node:person:*', { max: 2000 });
2290
2312
 
2291
2313
  if (personKeys.length === 0) {
2292
2314
  return [
@@ -2362,13 +2384,8 @@ export async function handleBrainTool(
2362
2384
 
2363
2385
  const redis = await getConnection(instance_id);
2364
2386
 
2365
- // Scan all best lessons
2366
- const lessonKeys: string[] = [];
2367
- const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 500 });
2368
- await new Promise<void>((res, rej) => {
2369
- lStream.on('data', (b: string[]) => lessonKeys.push(...b));
2370
- lStream.on('end', res); lStream.on('error', rej);
2371
- });
2387
+ // Scan all best lessons (capped + timed out)
2388
+ const lessonKeys = await scanKeys(redis, 'cachly:lesson:best:*', { max: 3000 });
2372
2389
 
2373
2390
  type DomainStats = { successes: number; failures: number; attributed: number; topics: string[] };
2374
2391
  const domainMap = new Map<string, DomainStats>();
@@ -2412,14 +2429,6 @@ export async function handleBrainTool(
2412
2429
  }
2413
2430
  }
2414
2431
 
2415
- // Check for domains with person nodes but no lessons (contributors without lessons)
2416
- const personKeys: string[] = [];
2417
- const pStream = redis.scanStream({ match: 'cachly:ckg:node:person:*', count: 200 });
2418
- await new Promise<void>((res, rej) => {
2419
- pStream.on('data', (b: string[]) => personKeys.push(...b));
2420
- pStream.on('end', res); pStream.on('error', rej);
2421
- });
2422
-
2423
2432
  const lines = [
2424
2433
  `## 🔍 Skill Gaps — Knowledge Blind Spots`,
2425
2434
  ``,
@@ -2463,29 +2472,12 @@ export async function handleBrainTool(
2463
2472
 
2464
2473
  const redis = await getConnection(instance_id);
2465
2474
 
2466
- // Count lessons
2467
- const lessonKeys: string[] = [];
2468
- const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 500 });
2469
- await new Promise<void>((res, rej) => {
2470
- lStream.on('data', (b: string[]) => lessonKeys.push(...b));
2471
- lStream.on('end', res); lStream.on('error', rej);
2472
- });
2473
-
2474
- // Count person nodes
2475
- const personKeys: string[] = [];
2476
- const pStream = redis.scanStream({ match: 'cachly:ckg:node:person:*', count: 200 });
2477
- await new Promise<void>((res, rej) => {
2478
- pStream.on('data', (b: string[]) => personKeys.push(...b));
2479
- pStream.on('end', res); pStream.on('error', rej);
2480
- });
2481
-
2482
- // Count file nodes in CKG
2483
- const fileKeys: string[] = [];
2484
- const fStream = redis.scanStream({ match: 'cachly:ckg:node:file:*', count: 500 });
2485
- await new Promise<void>((res, rej) => {
2486
- fStream.on('data', (b: string[]) => fileKeys.push(...b));
2487
- fStream.on('end', res); fStream.on('error', rej);
2488
- });
2475
+ // Count lessons, person + file nodes (each capped + timed out, gathered in parallel)
2476
+ const [lessonKeys, personKeys, fileKeys] = await Promise.all([
2477
+ scanKeys(redis, 'cachly:lesson:best:*', { max: 5000 }),
2478
+ scanKeys(redis, 'cachly:ckg:node:person:*', { max: 2000 }),
2479
+ scanKeys(redis, 'cachly:ckg:node:file:*', { max: 5000 }),
2480
+ ]);
2489
2481
 
2490
2482
  // Get git file count for comparison (optional — non-critical if not a git repo)
2491
2483
  let gitFileCount = 0;
@@ -2553,6 +2545,75 @@ export async function handleBrainTool(
2553
2545
  ].join('\n');
2554
2546
  }
2555
2547
 
2548
+ // ── brain_metrics ─────────────────────────────────────────────────────────
2549
+ // The three decisive metrics (PROGRESS.md §3 / VISION_10X.md §4):
2550
+ // 1. Time-to-first-recall — onboarding friction
2551
+ // 2. Recall-lift — the moat proof (vs. raw BM25)
2552
+ // 3. Team-knowledge-reuse — the value only a shared brain delivers
2553
+ case 'brain_metrics': {
2554
+ const { instance_id } = args as { instance_id: string };
2555
+ const redis = await getConnection(instance_id);
2556
+
2557
+ const [bornAt, firstRecallAt, recallsTotalRaw, crossAuthorRaw, timeSavedRaw, reusePairs] =
2558
+ await Promise.all([
2559
+ redis.get(`cachly:stats:born_at:${instance_id}`).catch(() => null),
2560
+ redis.get(`cachly:stats:first_recall_at:${instance_id}`).catch(() => null),
2561
+ redis.get(`cachly:stats:recalls_total:${instance_id}`).catch(() => null),
2562
+ redis.get(`cachly:stats:cross_author_recalls:${instance_id}`).catch(() => null),
2563
+ redis.get(`cachly:stats:time_saved_mins:${instance_id}`).catch(() => null),
2564
+ redis.smembers(`cachly:stats:reuse_pairs:${instance_id}`).catch(() => [] as string[]),
2565
+ ]);
2566
+
2567
+ // ── Metric 1: Time-to-first-recall ──────────────────────────────────────
2568
+ let ttfrLine: string;
2569
+ if (bornAt && firstRecallAt) {
2570
+ const ms = new Date(firstRecallAt).getTime() - new Date(bornAt).getTime();
2571
+ const mins = Math.max(0, Math.round(ms / 60000));
2572
+ const human = mins < 1 ? '<1 min' : mins < 60 ? `${mins} min` : `${(mins / 60).toFixed(1)} h`;
2573
+ const target = ms <= 2 * 60000 ? '🟢 under 2 min target' : ms <= 60 * 60000 ? '🟡 over 2 min' : '🔴 over 1 h';
2574
+ ttfrLine = `**${human}** from first lesson → first proven recall · ${target}`;
2575
+ } else if (bornAt && !firstRecallAt) {
2576
+ ttfrLine = `🟡 Brain has knowledge but no proven recall yet — try \`smart_recall\``;
2577
+ } else {
2578
+ ttfrLine = `⚪ Not enough data yet — store a lesson, then recall it`;
2579
+ }
2580
+
2581
+ // ── Metric 2: Recall-lift (the moat proof) ──────────────────────────────
2582
+ // Published headline from Cachly-Bench (CI-defended in rerank.test.ts).
2583
+ const recallLiftLine = `**+22.2 % Precision@1**, **+10.9 % MRR**, **+8.1 % nDCG@5** vs. raw BM25`;
2584
+
2585
+ // ── Metric 3: Team-knowledge-reuse ──────────────────────────────────────
2586
+ const recallsTotal = Number(recallsTotalRaw ?? 0);
2587
+ const crossAuthor = Number(crossAuthorRaw ?? 0);
2588
+ const reusePct = recallsTotal > 0 ? Math.round((crossAuthor / recallsTotal) * 100) : 0;
2589
+ const reuseTarget = reusePct >= 30 ? '🟢 above 30 % target' : recallsTotal === 0 ? '⚪ no recalls yet' : '🟡 below 30 % target';
2590
+ const timeSaved = Math.round(Number(timeSavedRaw ?? 0));
2591
+ const timeSavedHuman = timeSaved < 60 ? `${timeSaved} min` : `${(timeSaved / 60).toFixed(1)} h`;
2592
+
2593
+ const lines = [
2594
+ `## 📈 Brain Metrics — the three that decide everything`,
2595
+ ``,
2596
+ `### 1. Time-to-first-recall _(onboarding friction)_`,
2597
+ ttfrLine,
2598
+ ``,
2599
+ `### 2. Recall-lift _(the moat proof)_`,
2600
+ recallLiftLine,
2601
+ `_Reproduce: \`npm run bench\` · CI-defended in \`rerank.test.ts\` · details in BENCH.md_`,
2602
+ ``,
2603
+ `### 3. Team-knowledge-reuse _(the value only a shared brain delivers)_`,
2604
+ `**${reusePct}%** of proven recalls used a teammate's lesson (${crossAuthor}/${recallsTotal}) · ${reuseTarget}`,
2605
+ reusePairs.length > 0 ? `**${reusePairs.length}** distinct reuse relationship${reusePairs.length !== 1 ? 's' : ''} across the team` : '',
2606
+ ``,
2607
+ `---`,
2608
+ `⏱️ **${timeSavedHuman}** total saved not re-researching known fixes.`,
2609
+ recallsTotal === 0
2610
+ ? `_Tip: pass \`author="your-handle"\` to \`smart_recall\` so cross-author reuse can be tracked._`
2611
+ : `_These numbers compound: every learned lesson and every teammate raises all three._`,
2612
+ ].filter(Boolean);
2613
+
2614
+ return lines.join('\n');
2615
+ }
2616
+
2556
2617
  // ── sync_file_changes ─────────────────────────────────────────────────────
2557
2618
 
2558
2619
  default:
@@ -3,7 +3,7 @@ import type { Redis } from 'ioredis';
3
3
  import { ckgSlug, extractProblemConcept, ckgUpsertNode, ckgUpdateEdge,
4
4
  ckgUpsertPersonNode, ckgUpsertFileNode } from '../ckg.js';
5
5
  import type { CKGEdge, CKGNode } from '../ckg.js';
6
- import { safeJsonParse } from '../utils.js';
6
+ import { safeJsonParse, normalizeGitPath } from '../utils.js';
7
7
 
8
8
  // Last brain_from_git category counts — set after each run so index.ts can include them in telemetry
9
9
  export let _lastBrainFromGitCounts: { fixes: number; features: number; refactors: number; total: number } | null = null;
@@ -945,7 +945,8 @@ export async function handleFedbrainTool(
945
945
  const [, sha, subject, date, author] = line.split('|||');
946
946
  current = { sha: (sha ?? '').trim(), subject: (subject ?? '').trim(), date: (date ?? '').trim(), author: (author ?? '').trim(), files: [] };
947
947
  } else if (line && current) {
948
- current.files.push(line);
948
+ const norm = normalizeGitPath(line);
949
+ if (norm) current.files.push(norm);
949
950
  }
950
951
  }
951
952
  if (current) commits.push(current);
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.62';
76
+ const CURRENT_VERSION = '0.10.64';
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 · 100 MCP tools · GDPR · EU servers',
575
+ '✨ Free forever · No credit card · 101 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 · 100 MCP tools\x1b[0m');
1819
+ console.log(' \x1b[90mFree forever · GDPR · German servers · 101 MCP tools\x1b[0m');
1820
1820
  console.log('');
1821
1821
  process.exit(0);
1822
1822
  }