@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/src/tools.ts CHANGED
@@ -617,6 +617,7 @@ const TOOLS = [
617
617
  instance_id: { type: 'string', description: 'UUID of the cache instance' },
618
618
  query: { type: 'string', description: 'Natural language query to find relevant cached context' },
619
619
  threshold: { type: 'number', description: 'Similarity threshold 0-1 (default: 0.78)' },
620
+ author: { type: 'string', description: 'Handle of the person doing the recall (optional). Enables team-knowledge-reuse tracking: when you recall a lesson written by a teammate, cachly counts it as cross-author reuse — the value only a shared brain delivers.' },
620
621
  },
621
622
  required: ['instance_id', 'query'],
622
623
  },
@@ -893,6 +894,22 @@ const TOOLS = [
893
894
  required: ['instance_id'],
894
895
  },
895
896
  },
897
+ {
898
+ name: 'brain_metrics',
899
+ description:
900
+ 'Report the three decisive Brain metrics: (1) time-to-first-recall (onboarding friction), ' +
901
+ '(2) recall-lift vs. raw BM25 (the moat proof, from Cachly-Bench), and ' +
902
+ '(3) team-knowledge-reuse — what % of proven recalls used a teammate\'s lesson. ' +
903
+ 'Use to track whether the Brain is delivering its core value. ' +
904
+ 'Pass author="handle" to smart_recall so cross-author reuse can be measured.',
905
+ inputSchema: {
906
+ type: 'object',
907
+ properties: {
908
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
909
+ },
910
+ required: ['instance_id'],
911
+ },
912
+ },
896
913
  {
897
914
  name: 'sync_file_changes',
898
915
  description:
package/src/utils.ts CHANGED
@@ -8,3 +8,83 @@ export function safeJsonParse<T>(raw: string | null | undefined, fallback: T): T
8
8
  return fallback;
9
9
  }
10
10
  }
11
+
12
+ /**
13
+ * Runs a promise with a hard timeout. If it doesn't settle within `ms`, resolves
14
+ * with `fallback` instead of hanging. A memory tool must NEVER block the agent's
15
+ * turn — a slow Redis scan or unreachable backend degrades gracefully to a fallback.
16
+ */
17
+ export async function withTimeout<T>(p: Promise<T>, ms: number, fallback: T): Promise<T> {
18
+ let timer: ReturnType<typeof setTimeout> | undefined;
19
+ const guard = new Promise<T>((resolve) => {
20
+ timer = setTimeout(() => resolve(fallback), ms);
21
+ });
22
+ try {
23
+ return await Promise.race([p, guard]);
24
+ } catch {
25
+ return fallback;
26
+ } finally {
27
+ if (timer) clearTimeout(timer);
28
+ }
29
+ }
30
+
31
+ // Minimal structural type for the Redis scan API we depend on — avoids importing
32
+ // the full ioredis type into a leaf utility module.
33
+ interface ScanCapable {
34
+ scanStream(opts: { match: string; count?: number }): {
35
+ on(event: 'data', cb: (batch: string[]) => void): void;
36
+ on(event: 'end', cb: () => void): void;
37
+ on(event: 'error', cb: (err: Error) => void): void;
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Collects keys matching a pattern with a hard cap and timeout. Prevents a tool
43
+ * from hanging on a huge keyspace: stops early at `max` keys, and resolves with
44
+ * whatever was gathered if the scan exceeds `timeoutMs`. Never rejects.
45
+ */
46
+ export function scanKeys(
47
+ redis: ScanCapable,
48
+ pattern: string,
49
+ opts: { max?: number; count?: number; timeoutMs?: number } = {},
50
+ ): Promise<string[]> {
51
+ const max = opts.max ?? 1000;
52
+ const timeoutMs = opts.timeoutMs ?? 3000;
53
+ return new Promise<string[]>((resolve) => {
54
+ const keys: string[] = [];
55
+ let done = false;
56
+ const finish = () => { if (!done) { done = true; resolve(keys); } };
57
+ const timer = setTimeout(finish, timeoutMs);
58
+ try {
59
+ const stream = redis.scanStream({ match: pattern, count: opts.count ?? 200 });
60
+ stream.on('data', (batch: string[]) => {
61
+ for (const k of batch) {
62
+ keys.push(k);
63
+ if (keys.length >= max) { clearTimeout(timer); finish(); return; }
64
+ }
65
+ });
66
+ stream.on('end', () => { clearTimeout(timer); finish(); });
67
+ stream.on('error', () => { clearTimeout(timer); finish(); });
68
+ } catch {
69
+ clearTimeout(timer);
70
+ finish();
71
+ }
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Normalizes a git path that may come from `--name-only` with rename notation,
77
+ * e.g. `src/{old => new}/file.ts` → `src/new/file.ts`, and `a => b` → `b`.
78
+ * Returns the post-rename path so file nodes don't fragment on renames.
79
+ */
80
+ export function normalizeGitPath(raw: string): string {
81
+ let p = raw.trim();
82
+ if (!p) return p;
83
+ // Brace rename: dir/{old => new}/file → dir/new/file
84
+ p = p.replace(/\{[^}]*=>\s*([^}]*)\}/g, (_m, after: string) => after.trim());
85
+ // Bare rename: old => new → new
86
+ const arrow = p.split('=>');
87
+ if (arrow.length === 2) p = arrow[1]!.trim();
88
+ // Collapse any double slashes introduced by an empty rename segment.
89
+ return p.replace(/\/{2,}/g, '/').replace(/^\/|\/$/g, '');
90
+ }