@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.
@@ -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.62';
15
+ const MCP_VERSION = '0.10.65';
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
- } = args as { instance_id: string; query: string; threshold?: number };
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: string[] = [];
2234
- const stream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
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: string[] = [];
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: string[] = [];
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: string[] = [];
2468
- const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 500 });
2469
- await new Promise<void>((res, rej) => {
2470
- lStream.on('data', (b: string[]) => lessonKeys.push(...b));
2471
- lStream.on('end', res); lStream.on('error', rej);
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:
@@ -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
- current.files.push(line);
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.62';
76
+ const CURRENT_VERSION = '0.10.65';
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 · 100 MCP tools · GDPR · EU servers',
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 · 100 MCP tools\x1b[0m');
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
  }
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
+ }