@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/CHANGELOG.md.tmp +0 -0
- package/PROGRESS.md +137 -0
- package/README.md +210 -180
- package/VISION_10X.md +133 -0
- package/dist/src/__tests__/brain-flow.test.js +72 -1
- package/dist/src/__tests__/brain-flow.test.js.map +1 -1
- package/dist/src/__tests__/utils.test.d.ts +1 -0
- package/dist/src/__tests__/utils.test.js +83 -0
- package/dist/src/__tests__/utils.test.js.map +1 -0
- package/dist/src/handlers/brain.js +106 -60
- package/dist/src/handlers/brain.js.map +1 -1
- package/dist/src/handlers/fedbrain.js +4 -2
- package/dist/src/handlers/fedbrain.js.map +1 -1
- package/dist/src/index.js +3 -3
- package/dist/src/tools.d.ts +17 -0
- package/dist/src/tools.js +16 -0
- package/dist/src/tools.js.map +1 -1
- package/dist/src/utils.d.ts +33 -0
- package/dist/src/utils.js +76 -0
- package/dist/src/utils.js.map +1 -1
- package/package.json +2 -2
- package/server.json +3 -3
- package/src/__tests__/brain-flow.test.ts +82 -1
- package/src/__tests__/utils.test.ts +81 -0
- package/src/handlers/brain.ts +118 -57
- package/src/handlers/fedbrain.ts +3 -2
- package/src/index.ts +3 -3
- package/src/tools.ts +17 -0
- package/src/utils.ts +80 -0
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
|
package/dist/src/utils.js.map
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
});
|
package/src/handlers/brain.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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:
|
|
2234
|
-
const
|
|
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:
|
|
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:
|
|
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
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
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:
|
package/src/handlers/fedbrain.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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 ·
|
|
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 ·
|
|
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
|
}
|