@cachly-dev/mcp-server 0.10.62 → 0.10.65
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/BENCH.md +36 -14
- package/CHANGELOG.md +49 -0
- package/PROGRESS.md +141 -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__/rerank.test.js +10 -0
- package/dist/src/__tests__/rerank.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/bench/cachly-bench.d.ts +5 -1
- package/dist/src/bench/cachly-bench.js +60 -19
- package/dist/src/bench/cachly-bench.js.map +1 -1
- 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__/rerank.test.ts +11 -0
- package/src/__tests__/utils.test.ts +81 -0
- package/src/bench/cachly-bench.ts +69 -23
- 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.65",
|
|
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.65",
|
|
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.65",
|
|
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
|
+
});
|
|
@@ -127,4 +127,15 @@ describe('Cachly-Bench regression guard', () => {
|
|
|
127
127
|
// A fair benchmark: BM25 alone already gets most queries right.
|
|
128
128
|
expect(r.baseline.mrr).toBeGreaterThan(0.7);
|
|
129
129
|
});
|
|
130
|
+
|
|
131
|
+
it('beats a flat-file memory on the decisive metrics (right answer first)', async () => {
|
|
132
|
+
const r = await runBenchmark();
|
|
133
|
+
// Even when the flat-file memory is steelmanned (recency tiebreak), cachly puts
|
|
134
|
+
// the single best answer first more often — Precision@1 and MRR are what matter
|
|
135
|
+
// for an agent that acts on the top result.
|
|
136
|
+
expect(r.cachly.precisionAt1).toBeGreaterThanOrEqual(r.flatfile.precisionAt1 - 1e-9);
|
|
137
|
+
expect(r.cachly.mrr).toBeGreaterThanOrEqual(r.flatfile.mrr - 1e-9);
|
|
138
|
+
// The flat-file simulation is honest: it isn't trivially terrible.
|
|
139
|
+
expect(r.flatfile.mrr).toBeGreaterThan(0.5);
|
|
140
|
+
});
|
|
130
141
|
});
|
|
@@ -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
|
+
});
|
|
@@ -102,14 +102,45 @@ export interface BenchMetrics {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
export interface BenchResult {
|
|
105
|
+
/** Flat-file memory simulation: naive term-overlap, no IDF, no quality signal. */
|
|
106
|
+
flatfile: BenchMetrics;
|
|
105
107
|
baseline: BenchMetrics;
|
|
106
108
|
cachly: BenchMetrics;
|
|
107
|
-
/** Relative lift
|
|
109
|
+
/** Relative lift of cachly vs the BM25 baseline. */
|
|
108
110
|
lift: BenchMetrics;
|
|
111
|
+
/** Relative lift of cachly vs a flat-file memory (the head-to-head that matters). */
|
|
112
|
+
liftVsFlatfile: BenchMetrics;
|
|
109
113
|
queryCount: number;
|
|
110
114
|
corpusSize: number;
|
|
111
115
|
}
|
|
112
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Simulates how a flat-file memory (e.g. an LLM reading a `/memories` directory)
|
|
119
|
+
* actually surfaces knowledge: it has NO ranking engine. Practical retrieval is
|
|
120
|
+
* naive lexical overlap — count how many distinct query terms appear in the file's
|
|
121
|
+
* text — with NO inverse-document-frequency, NO length normalization, and crucially
|
|
122
|
+
* NO quality signal (a proven fix and a failed attempt are just text). Ties break by
|
|
123
|
+
* recency, mimicking "most recently edited note first". This is a fair, and honestly
|
|
124
|
+
* weaker, stand-in for "Claude reads its own memory files".
|
|
125
|
+
*/
|
|
126
|
+
function flatFileRank(lessons: BenchLesson[], query: string): string[] {
|
|
127
|
+
const qTerms = new Set(
|
|
128
|
+
query.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter(t => t.length >= 3),
|
|
129
|
+
);
|
|
130
|
+
const scored = lessons.map(l => {
|
|
131
|
+
const blob = [l.topic, l.what_worked, l.what_failed ?? '', l.context ?? '', (l.tags ?? []).join(' ')]
|
|
132
|
+
.join(' ').toLowerCase();
|
|
133
|
+
let overlap = 0;
|
|
134
|
+
for (const t of qTerms) if (blob.includes(t)) overlap++;
|
|
135
|
+
const recency = l.ts ? new Date(l.ts).getTime() : 0;
|
|
136
|
+
return { topic: l.topic, overlap, recency };
|
|
137
|
+
});
|
|
138
|
+
return scored
|
|
139
|
+
.filter(s => s.overlap > 0)
|
|
140
|
+
.sort((a, b) => (b.overlap - a.overlap) || (b.recency - a.recency))
|
|
141
|
+
.map(s => s.topic);
|
|
142
|
+
}
|
|
143
|
+
|
|
113
144
|
const matchTopic = (m: KeywordMatch): string => m.key.replace(LESSON_PREFIX, '');
|
|
114
145
|
|
|
115
146
|
function mean(xs: number[]): number {
|
|
@@ -139,34 +170,45 @@ function scoreRanking(ranked: string[], relevant: Set<string>): BenchMetrics {
|
|
|
139
170
|
/** Run the benchmark over the fixture corpus. Pure-ish: no network, no real Redis. */
|
|
140
171
|
export async function runBenchmark(): Promise<BenchResult> {
|
|
141
172
|
const redis = indexCorpus(BENCH_LESSONS);
|
|
173
|
+
const flatfileRows: BenchMetrics[] = [];
|
|
142
174
|
const baselineRows: BenchMetrics[] = [];
|
|
143
175
|
const cachlyRows: BenchMetrics[] = [];
|
|
144
176
|
|
|
145
177
|
for (const q of BENCH_QUERIES) {
|
|
146
178
|
const relevant = new Set(q.relevant);
|
|
147
179
|
|
|
148
|
-
//
|
|
180
|
+
// BM25 baseline + cachly share the same candidate set — isolates ranking quality.
|
|
149
181
|
const matches = await keywordSearch(redis as never, [`${LESSON_PREFIX}*`], q.query, 10);
|
|
150
|
-
|
|
151
182
|
const baselineRanked = matches.map(matchTopic);
|
|
152
183
|
const cachlyRanked = rerankByQuality(matches).map(matchTopic);
|
|
153
184
|
|
|
185
|
+
// Flat-file memory sees the whole corpus but has no ranking engine.
|
|
186
|
+
const flatfileRanked = flatFileRank(BENCH_LESSONS, q.query);
|
|
187
|
+
|
|
188
|
+
flatfileRows.push(scoreRanking(flatfileRanked, relevant));
|
|
154
189
|
baselineRows.push(scoreRanking(baselineRanked, relevant));
|
|
155
190
|
cachlyRows.push(scoreRanking(cachlyRanked, relevant));
|
|
156
191
|
}
|
|
157
192
|
|
|
193
|
+
const flatfile = aggregate(flatfileRows);
|
|
158
194
|
const baseline = aggregate(baselineRows);
|
|
159
195
|
const cachly = aggregate(cachlyRows);
|
|
160
196
|
const liftOf = (b: number, c: number) => (b === 0 ? (c > 0 ? 1 : 0) : (c - b) / b);
|
|
161
|
-
const
|
|
162
|
-
precisionAt1: liftOf(
|
|
163
|
-
precisionAt3: liftOf(
|
|
164
|
-
recallAt3: liftOf(
|
|
165
|
-
mrr: liftOf(
|
|
166
|
-
ndcgAt5: liftOf(
|
|
167
|
-
};
|
|
197
|
+
const liftBetween = (from: BenchMetrics): BenchMetrics => ({
|
|
198
|
+
precisionAt1: liftOf(from.precisionAt1, cachly.precisionAt1),
|
|
199
|
+
precisionAt3: liftOf(from.precisionAt3, cachly.precisionAt3),
|
|
200
|
+
recallAt3: liftOf(from.recallAt3, cachly.recallAt3),
|
|
201
|
+
mrr: liftOf(from.mrr, cachly.mrr),
|
|
202
|
+
ndcgAt5: liftOf(from.ndcgAt5, cachly.ndcgAt5),
|
|
203
|
+
});
|
|
168
204
|
|
|
169
|
-
return {
|
|
205
|
+
return {
|
|
206
|
+
flatfile, baseline, cachly,
|
|
207
|
+
lift: liftBetween(baseline),
|
|
208
|
+
liftVsFlatfile: liftBetween(flatfile),
|
|
209
|
+
queryCount: BENCH_QUERIES.length,
|
|
210
|
+
corpusSize: BENCH_LESSONS.length,
|
|
211
|
+
};
|
|
170
212
|
}
|
|
171
213
|
|
|
172
214
|
// ── Reporting ──────────────────────────────────────────────────────────────────
|
|
@@ -174,22 +216,26 @@ const pct = (x: number) => `${(x * 100).toFixed(1)}%`;
|
|
|
174
216
|
const signedPct = (x: number) => `${x >= 0 ? '+' : ''}${(x * 100).toFixed(1)}%`;
|
|
175
217
|
|
|
176
218
|
export function formatReport(r: BenchResult): string {
|
|
177
|
-
const row = (label: string, b: number, c: number,
|
|
178
|
-
` ${label.padEnd(
|
|
219
|
+
const row = (label: string, f: number, b: number, c: number, lf: number) =>
|
|
220
|
+
` ${label.padEnd(12)} ${pct(f).padStart(8)} ${pct(b).padStart(8)} ${pct(c).padStart(8)} ${signedPct(lf).padStart(8)}`;
|
|
179
221
|
return [
|
|
180
222
|
'',
|
|
181
|
-
'🧠 Cachly-Bench — recall quality
|
|
182
|
-
'
|
|
223
|
+
'🧠 Cachly-Bench — recall quality head-to-head',
|
|
224
|
+
'──────────────────────────────────────────────────────────────────────────',
|
|
183
225
|
` corpus: ${r.corpusSize} lessons · queries: ${r.queryCount}`,
|
|
226
|
+
' flat-file = naive term-overlap, no quality signal (≈ LLM reading memory files)',
|
|
227
|
+
' baseline = raw BM25+ keyword ranking',
|
|
228
|
+
' cachly = BM25+ followed by quality-aware reranking',
|
|
184
229
|
'',
|
|
185
|
-
` ${'metric'.padEnd(
|
|
186
|
-
row('Precision@1', r.baseline.precisionAt1, r.cachly.precisionAt1, r.
|
|
187
|
-
row('Precision@3', r.baseline.precisionAt3, r.cachly.precisionAt3, r.
|
|
188
|
-
row('Recall@3', r.baseline.recallAt3, r.cachly.recallAt3, r.
|
|
189
|
-
row('MRR', r.baseline.mrr, r.cachly.mrr, r.
|
|
190
|
-
row('nDCG@5', r.baseline.ndcgAt5, r.cachly.ndcgAt5, r.
|
|
191
|
-
'
|
|
192
|
-
`
|
|
230
|
+
` ${'metric'.padEnd(12)} ${'flatfile'.padStart(8)} ${'baseline'.padStart(8)} ${'cachly'.padStart(8)} ${'vs flat'.padStart(8)}`,
|
|
231
|
+
row('Precision@1', r.flatfile.precisionAt1, r.baseline.precisionAt1, r.cachly.precisionAt1, r.liftVsFlatfile.precisionAt1),
|
|
232
|
+
row('Precision@3', r.flatfile.precisionAt3, r.baseline.precisionAt3, r.cachly.precisionAt3, r.liftVsFlatfile.precisionAt3),
|
|
233
|
+
row('Recall@3', r.flatfile.recallAt3, r.baseline.recallAt3, r.cachly.recallAt3, r.liftVsFlatfile.recallAt3),
|
|
234
|
+
row('MRR', r.flatfile.mrr, r.baseline.mrr, r.cachly.mrr, r.liftVsFlatfile.mrr),
|
|
235
|
+
row('nDCG@5', r.flatfile.ndcgAt5, r.baseline.ndcgAt5, r.cachly.ndcgAt5, r.liftVsFlatfile.ndcgAt5),
|
|
236
|
+
'──────────────────────────────────────────────────────────────────────────',
|
|
237
|
+
` vs BM25 baseline : MRR ${signedPct(r.lift.mrr)} · Precision@1 ${signedPct(r.lift.precisionAt1)}`,
|
|
238
|
+
` vs flat-file mem : MRR ${signedPct(r.liftVsFlatfile.mrr)} · Precision@1 ${signedPct(r.liftVsFlatfile.precisionAt1)}`,
|
|
193
239
|
'',
|
|
194
240
|
].join('\n');
|
|
195
241
|
}
|