@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/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.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. 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.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.62",
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 per metric, e.g. { mrr: 0.23 } = +23%. */
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
- // Same candidate set for both rankers — isolates the ranking contribution.
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 lift: BenchMetrics = {
162
- precisionAt1: liftOf(baseline.precisionAt1, cachly.precisionAt1),
163
- precisionAt3: liftOf(baseline.precisionAt3, cachly.precisionAt3),
164
- recallAt3: liftOf(baseline.recallAt3, cachly.recallAt3),
165
- mrr: liftOf(baseline.mrr, cachly.mrr),
166
- ndcgAt5: liftOf(baseline.ndcgAt5, cachly.ndcgAt5),
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 { baseline, cachly, lift, queryCount: BENCH_QUERIES.length, corpusSize: BENCH_LESSONS.length };
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, l: number) =>
178
- ` ${label.padEnd(14)} ${pct(b).padStart(8)} → ${pct(c).padStart(8)} ${signedPct(l).padStart(8)}`;
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 (BM25 baseline vs quality-aware rerank)',
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(14)} ${'baseline'.padStart(8)} ${'cachly'.padStart(8)} ${'lift'.padStart(8)}`,
186
- row('Precision@1', r.baseline.precisionAt1, r.cachly.precisionAt1, r.lift.precisionAt1),
187
- row('Precision@3', r.baseline.precisionAt3, r.cachly.precisionAt3, r.lift.precisionAt3),
188
- row('Recall@3', r.baseline.recallAt3, r.cachly.recallAt3, r.lift.recallAt3),
189
- row('MRR', r.baseline.mrr, r.cachly.mrr, r.lift.mrr),
190
- row('nDCG@5', r.baseline.ndcgAt5, r.cachly.ndcgAt5, r.lift.ndcgAt5),
191
- '────────────────────────────────────────────────────────────────────────',
192
- ` Headline: MRR ${signedPct(r.lift.mrr)} · Precision@1 ${signedPct(r.lift.precisionAt1)}`,
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
  }