@cachly-dev/mcp-server 0.10.61 → 0.10.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.61",
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.61",
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.61",
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);
@@ -1352,3 +1360,113 @@ describe('Phase 3C: brain_coverage', () => {
1352
1360
  expect(scoreAfter).toBeGreaterThan(scoreBefore);
1353
1361
  });
1354
1362
  });
1363
+
1364
+ describe('Phase 3 stability: input guards', () => {
1365
+ let redis: MockRedis;
1366
+ const iid = 'i1';
1367
+ const getConn = async () => redis as unknown as Redis;
1368
+
1369
+ beforeEach(() => { redis = new MockRedis(); });
1370
+
1371
+ it('brain_who_knows rejects empty topic without crashing', async () => {
1372
+ const out = await handleBrainTool('brain_who_knows', { instance_id: iid, topic: '' }, getConn, noopApiFetch);
1373
+ expect(out).toContain('requires a non-empty');
1374
+ });
1375
+
1376
+ it('brain_who_knows rejects undefined topic without crashing', async () => {
1377
+ const out = await handleBrainTool('brain_who_knows', { instance_id: iid }, getConn, noopApiFetch);
1378
+ expect(out).toContain('requires a non-empty');
1379
+ });
1380
+
1381
+ it('brain_who_knows clamps an absurd limit', async () => {
1382
+ await handleBrainTool('learn_from_attempts', {
1383
+ instance_id: iid, topic: 'fix:x', outcome: 'success',
1384
+ what_worked: 'patch', author: 'alice',
1385
+ }, getConn, noopApiFetch);
1386
+ const out = await handleBrainTool('brain_who_knows', { instance_id: iid, topic: 'fix', limit: 99999 }, getConn, noopApiFetch);
1387
+ expect(out).toContain('alice');
1388
+ });
1389
+
1390
+ it('brain_file_map filters out empty path strings', async () => {
1391
+ const out = await handleBrainTool('brain_file_map', { instance_id: iid, file_paths: ['', ' ', ''] }, getConn, noopApiFetch);
1392
+ expect(out).toContain('Pass at least one file path');
1393
+ });
1394
+
1395
+ it('brain_file_map handles non-array file_paths gracefully', async () => {
1396
+ const out = await handleBrainTool('brain_file_map', { instance_id: iid, file_paths: 'not-an-array' as unknown as string[] }, getConn, noopApiFetch);
1397
+ expect(out).toContain('Pass at least one file path');
1398
+ });
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
+ });