@cachly-dev/mcp-server 0.10.4 → 0.10.6

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.
Files changed (50) hide show
  1. package/.github/workflows/publish.yml +45 -0
  2. package/LAUNCH_TEXTS.md +182 -0
  3. package/LICENSE +202 -0
  4. package/dist/__tests__/auth.test.d.ts +1 -0
  5. package/dist/__tests__/auth.test.js +107 -0
  6. package/dist/__tests__/auth.test.js.map +1 -0
  7. package/dist/auth.d.ts +3 -0
  8. package/dist/auth.js +32 -0
  9. package/dist/auth.js.map +1 -0
  10. package/dist/handlers/advanced.js +38 -31
  11. package/dist/handlers/advanced.js.map +1 -1
  12. package/dist/handlers/brain.js +144 -50
  13. package/dist/handlers/brain.js.map +1 -1
  14. package/dist/handlers/fedbrain.js +55 -38
  15. package/dist/handlers/fedbrain.js.map +1 -1
  16. package/dist/handlers/roadmap.js +8 -2
  17. package/dist/handlers/roadmap.js.map +1 -1
  18. package/dist/handlers/syndicate.js +15 -7
  19. package/dist/handlers/syndicate.js.map +1 -1
  20. package/dist/handlers/team.js +62 -50
  21. package/dist/handlers/team.js.map +1 -1
  22. package/dist/index.js +222 -217
  23. package/dist/index.js.map +1 -1
  24. package/dist/tools.d.ts +0 -8
  25. package/dist/tools.js +5 -15
  26. package/dist/tools.js.map +1 -1
  27. package/dist/utils.d.ts +1 -0
  28. package/dist/utils.js +13 -0
  29. package/dist/utils.js.map +1 -0
  30. package/glama.json +29 -0
  31. package/llms.txt +155 -0
  32. package/package.json +29 -13
  33. package/scripts/postinstall.js +12 -19
  34. package/server.json +3 -9
  35. package/smithery.yaml +6 -8
  36. package/src/__tests__/auth.test.ts +147 -0
  37. package/src/auth.ts +46 -0
  38. package/src/handlers/advanced.ts +37 -23
  39. package/src/handlers/brain.ts +141 -47
  40. package/src/handlers/fedbrain.ts +43 -31
  41. package/src/handlers/roadmap.ts +7 -2
  42. package/src/handlers/syndicate.ts +13 -7
  43. package/src/handlers/team.ts +57 -33
  44. package/src/index.ts +224 -234
  45. package/src/tools.ts +5 -16
  46. package/src/utils.ts +10 -0
  47. package/dist/__tests__/brain-predict-failures.test.d.ts +0 -17
  48. package/dist/__tests__/brain-predict-failures.test.js +0 -339
  49. package/dist/__tests__/brain-predict-failures.test.js.map +0 -1
  50. package/src/__tests__/brain-predict-failures.test.ts +0 -430
@@ -6,6 +6,7 @@ import { calculateConfidence, confidenceBadge, CONFIDENCE_WARN_VALUE, CONFIDENCE
6
6
  import { ckgSlug, extractProblemConcept, ckgUpsertNode, ckgUpdateEdge } from '../ckg.js';
7
7
  import type { CKGEdge, CKGNode } from '../ckg.js';
8
8
  import { keywordSearch, tokenize } from '../search.js';
9
+ import { safeJsonParse } from '../utils.js';
9
10
  import { computeEmbedding, hasEmbedProvider } from '../embeddings.js';
10
11
 
11
12
  // Git concurrency semaphore (for brain_from_git parallel workers)
@@ -397,7 +398,8 @@ export async function handleFedbrainTool(
397
398
  const raw = await redis.get(`cachly:lesson:best:${lesson_key}`);
398
399
  if (!raw) return `❌ Lesson \`${lesson_key}\` not found. Store it first with \`learn_from_attempts\`.`;
399
400
 
400
- const lesson = JSON.parse(raw) as { topic: string; outcome: string; what_worked: string; what_failed?: string; tags?: string[]; commands?: string[]; severity?: string; ts?: string };
401
+ const lesson = safeJsonParse(raw, null as null | { topic: string; outcome: string; what_worked: string; what_failed?: string; tags?: string[]; commands?: string[]; severity?: string; ts?: string });
402
+ if (!lesson) return `❌ Lesson \`${lesson_key}\` data is corrupted. Re-store with \`learn_from_attempts\`.`;
401
403
 
402
404
  const domainTokens = [lesson.topic.split(':')[0], ...(lesson.tags ?? [])].filter(Boolean);
403
405
  const domainFingerprint = [...new Set(domainTokens)].sort().join(',');
@@ -747,7 +749,8 @@ export async function handleFedbrainTool(
747
749
  }
748
750
 
749
751
  type Crystal = { label: string; ts: string; session_count: number; lesson_count: number; top_patterns: Array<{ category: string; insight: string; count: number }>; categories: string[]; created_from: string };
750
- const crystal: Crystal = JSON.parse(raw);
752
+ const crystal = safeJsonParse<Crystal | null>(raw, null);
753
+ if (!crystal) return `❌ Memory Crystal data is corrupted. Re-create with \`memory_crystalize()\`.`;
751
754
  const age = Math.floor((Date.now() - new Date(crystal.ts).getTime()) / 86400000);
752
755
  const freshEmoji = age <= 7 ? '🟢' : age <= 30 ? '🟡' : '🔴';
753
756
 
@@ -784,49 +787,55 @@ export async function handleFedbrainTool(
784
787
  const crystalRaw = await redis.get('cachly:crystal:latest');
785
788
  if (crystalRaw) {
786
789
  type Crystal = { label: string; ts: string; session_count: number; lesson_count: number; top_patterns: Array<{ category: string; insight: string; count: number }> };
787
- const crystal: Crystal = JSON.parse(crystalRaw);
788
- lines.push(`### 💎 Memory Crystal: ${crystal.label}`);
789
- lines.push(`Compressed from ${crystal.session_count} sessions, ${crystal.lesson_count} lessons.`);
790
- const topN = focus
791
- ? crystal.top_patterns.filter(p => p.category.toLowerCase().includes(focus.toLowerCase()) || p.insight.toLowerCase().includes(focus.toLowerCase())).slice(0, 4)
792
- : crystal.top_patterns.slice(0, 4);
793
- for (const p of topN) lines.push(` • **${p.category}**: ${p.insight.slice(0, 100)}`);
794
- lines.push('');
790
+ const crystal = safeJsonParse<Crystal | null>(crystalRaw, null);
791
+ if (crystal) {
792
+ lines.push(`### 💎 Memory Crystal: ${crystal.label}`);
793
+ lines.push(`Compressed from ${crystal.session_count} sessions, ${crystal.lesson_count} lessons.`);
794
+ const topN = focus
795
+ ? crystal.top_patterns.filter(p => p.category.toLowerCase().includes(focus.toLowerCase()) || p.insight.toLowerCase().includes(focus.toLowerCase())).slice(0, 4)
796
+ : crystal.top_patterns.slice(0, 4);
797
+ for (const p of topN) lines.push(` • **${p.category}**: ${p.insight.slice(0, 100)}`);
798
+ lines.push('');
799
+ }
795
800
  }
796
801
 
797
802
  // 2. Last session summary
798
803
  const lastSession = await redis.get('cachly:session:last');
799
804
  if (lastSession) {
800
805
  type Session = { summary?: string; ts?: string; focus?: string };
801
- const sess: Session = JSON.parse(lastSession);
802
- lines.push(`### 🕐 Last Session`);
803
- if (sess.focus) lines.push(`Focus: _${sess.focus}_`);
804
- if (sess.summary) lines.push(`Summary: ${sess.summary.slice(0, 300)}`);
805
- lines.push('');
806
+ const sess = safeJsonParse<Session | null>(lastSession, null);
807
+ if (sess) {
808
+ lines.push(`### 🕐 Last Session`);
809
+ if (sess.focus) lines.push(`Focus: _${sess.focus}_`);
810
+ if (sess.summary) lines.push(`Summary: ${sess.summary.slice(0, 300)}`);
811
+ lines.push('');
812
+ }
806
813
  }
807
814
 
808
815
  // 3. Session handoff
809
816
  const handoff = await redis.get('cachly:session:handoff');
810
817
  if (handoff) {
811
818
  type Handoff = { remaining_tasks?: string[]; instructions?: string; context_summary?: string; blocked_on?: string };
812
- const h: Handoff = JSON.parse(handoff);
813
- lines.push(`### 📋 Handoff (from last window)`);
814
- if (h.context_summary) lines.push(`Context: ${h.context_summary.slice(0, 200)}`);
815
- if (h.remaining_tasks?.length) {
816
- lines.push(`Remaining tasks:`);
817
- for (const t of h.remaining_tasks.slice(0, 5)) lines.push(` • ${t}`);
819
+ const h = safeJsonParse<Handoff | null>(handoff, null);
820
+ if (h) {
821
+ lines.push(`### 📋 Handoff (from last window)`);
822
+ if (h.context_summary) lines.push(`Context: ${h.context_summary.slice(0, 200)}`);
823
+ if (h.remaining_tasks?.length) {
824
+ lines.push(`Remaining tasks:`);
825
+ for (const t of h.remaining_tasks.slice(0, 5)) lines.push(` • ${t}`);
826
+ }
827
+ if (h.instructions) lines.push(`⚠️ Instructions: ${h.instructions.slice(0, 200)}`);
828
+ if (h.blocked_on) lines.push(`🚧 Blocked on: ${h.blocked_on}`);
829
+ lines.push('');
818
830
  }
819
- if (h.instructions) lines.push(`⚠️ Instructions: ${h.instructions.slice(0, 200)}`);
820
- if (h.blocked_on) lines.push(`🚧 Blocked on: ${h.blocked_on}`);
821
- lines.push('');
822
831
  }
823
832
 
824
833
  // 4. WIP registry
825
834
  const wipRaw = await redis.get('cachly:ctx:wip-registry');
826
835
  if (wipRaw) {
827
836
  type Ctx = { content?: string };
828
- const wip: Ctx = JSON.parse(wipRaw);
829
- if (wip.content) {
837
+ const wip = safeJsonParse<Ctx | null>(wipRaw, null);
838
+ if (wip?.content) {
830
839
  lines.push(`### 🔧 WIP Registry`);
831
840
  lines.push(wip.content.slice(0, 400));
832
841
  lines.push('');
@@ -1026,12 +1035,14 @@ export async function handleFedbrainTool(
1026
1035
  for (const nk of nodeKeys.slice(0, 5)) {
1027
1036
  const nodeRaw = await redis.get(nk);
1028
1037
  if (!nodeRaw) continue;
1029
- const node: CKGNode = JSON.parse(nodeRaw);
1038
+ const node = safeJsonParse<CKGNode | null>(nodeRaw, null);
1039
+ if (!node) continue;
1030
1040
  const edgeKeys = await redis.smembers(`cachly:ckg:idx:from:${node.id}`);
1031
1041
  for (const ek of edgeKeys.slice(0, 20)) {
1032
1042
  const edgeRaw = await redis.get(ek);
1033
1043
  if (!edgeRaw) continue;
1034
- const edge: CKGEdge = JSON.parse(edgeRaw);
1044
+ const edge = safeJsonParse<CKGEdge | null>(edgeRaw, null);
1045
+ if (!edge) continue;
1035
1046
  if (edge.edgeType !== 'causes' && edge.edgeType !== 'degrades_under') continue;
1036
1047
 
1037
1048
  // Look up fix for this failure from CKG 'fixes' edges
@@ -1040,12 +1051,13 @@ export async function handleFedbrainTool(
1040
1051
  for (const fek of fixEdgeKeys.slice(0, 10)) {
1041
1052
  const feRaw = await redis.get(fek);
1042
1053
  if (!feRaw) continue;
1043
- const fe: CKGEdge = JSON.parse(feRaw);
1054
+ const fe = safeJsonParse<CKGEdge | null>(feRaw, null);
1055
+ if (!fe) continue;
1044
1056
  if (fe.edgeType === 'fixes') {
1045
1057
  const lessonRaw = await redis.get(`cachly:lesson:best:${fe.from}`);
1046
1058
  if (lessonRaw) {
1047
- const lesson = JSON.parse(lessonRaw) as { what_worked?: string };
1048
- fix = lesson.what_worked?.slice(0, 120);
1059
+ const lesson = safeJsonParse(lessonRaw, null as null | { what_worked?: string });
1060
+ fix = lesson?.what_worked?.slice(0, 120);
1049
1061
  break;
1050
1062
  }
1051
1063
  }
@@ -1,4 +1,5 @@
1
1
  import type { Redis } from 'ioredis';
2
+ import { safeJsonParse } from '../utils.js';
2
3
 
3
4
  type GetConnection = (instanceId: string) => Promise<Redis>;
4
5
  type ApiFetch = <T>(path: string, options?: RequestInit) => Promise<T>;
@@ -67,7 +68,8 @@ export async function handleRoadmapTool(
67
68
  const redis = await getConnection(rid);
68
69
  const raw = await redis.hget(`cachly:roadmap:${rid}`, itemId);
69
70
  if (!raw) return `⚠️ **roadmap_update** – Item \`${itemId}\` not found. Use \`roadmap_list\` to see all items.`;
70
- const item = JSON.parse(raw) as Record<string, unknown>;
71
+ const item = safeJsonParse<Record<string, unknown> | null>(raw, null);
72
+ if (!item) return `⚠️ **roadmap_update** – Item \`${itemId}\` data is corrupted. Re-add with \`roadmap_add\`.`;
71
73
  const oldStatus = item.status as string;
72
74
  if (newStatus) item.status = newStatus;
73
75
  if (newPriority) item.priority = newPriority;
@@ -106,7 +108,10 @@ export async function handleRoadmapTool(
106
108
  const PRIORITY_ICON: Record<string, string> = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
107
109
  const STATUS_ICON: Record<string, string> = { planned: '📋', 'in-progress': '⚡', done: '✅', blocked: '🚫', cancelled: '🗑️' };
108
110
  const openStatuses = new Set(['planned', 'in-progress', 'blocked']);
109
- let items = Object.values(all).map(v => JSON.parse(v as string) as Record<string, string | string[]>);
111
+ let items = Object.values(all).flatMap(v => {
112
+ const item = safeJsonParse<Record<string, string | string[]> | null>(v as string, null);
113
+ return item ? [item] : [];
114
+ });
110
115
  // Filter
111
116
  if (filterStatus === 'open') items = items.filter(i => openStatuses.has(i.status as string));
112
117
  else if (filterStatus) items = items.filter(i => i.status === filterStatus);
@@ -4,6 +4,7 @@ import { calculateConfidence } from '../confidence.js';
4
4
  import { ckgSlug, ckgUpdateEdge } from '../ckg.js';
5
5
  import type { CKGEdge, CKGNode } from '../ckg.js';
6
6
  import { keywordSearch, tokenize } from '../search.js';
7
+ import { safeJsonParse } from '../utils.js';
7
8
 
8
9
  type GetConnection = (instanceId: string) => Promise<Redis>;
9
10
  type ApiFetch = <T>(path: string, options?: RequestInit) => Promise<T>;
@@ -292,7 +293,8 @@ export async function handleSyndicateTool(
292
293
  for (const ek of [...fromKeys, ...toKeys].slice(0, 50)) {
293
294
  const raw = await redis.get(ek);
294
295
  if (!raw) continue;
295
- const edge: CKGEdge = JSON.parse(raw);
296
+ const edge = safeJsonParse<CKGEdge | null>(raw, null);
297
+ if (!edge) continue;
296
298
  allEdges.push(edge);
297
299
  if (hop < max_hops) {
298
300
  if (!visited.has(edge.from)) queue.push({ id: edge.from, hop: hop + 1 });
@@ -366,16 +368,18 @@ export async function handleSyndicateTool(
366
368
  for (const nk of nodeKeys.slice(0, 5)) {
367
369
  const nodeRaw = await redis.get(nk);
368
370
  if (!nodeRaw) continue;
369
- const node: CKGNode = JSON.parse(nodeRaw);
371
+ const node = safeJsonParse<CKGNode | null>(nodeRaw, null);
372
+ if (!node) continue;
370
373
  const edgeKeys = await redis.smembers(`cachly:ckg:idx:from:${node.id}`);
371
374
  for (const ek of edgeKeys.slice(0, 20)) {
372
375
  const edgeRaw = await redis.get(ek);
373
376
  if (!edgeRaw) continue;
374
- const edge: CKGEdge = JSON.parse(edgeRaw);
377
+ const edge = safeJsonParse<CKGEdge | null>(edgeRaw, null);
378
+ if (!edge) continue;
375
379
  // Only interested in fixes and co-occurs for prediction
376
380
  if (edge.edgeType !== 'fixes' && edge.edgeType !== 'co-occurs' && edge.edgeType !== 'causes') continue;
377
381
  const lessonRaw = await redis.get(`cachly:lesson:best:${edge.from}`);
378
- const lesson = lessonRaw ? JSON.parse(lessonRaw) : undefined;
382
+ const lesson = lessonRaw ? safeJsonParse<{ what_worked?: string; topic: string } | null>(lessonRaw, null) ?? undefined : undefined;
379
383
  predictions.push({ concept: node.id, edgeType: edge.edgeType, target: edge.to, confidence: edge.confidence, lesson });
380
384
  }
381
385
  }
@@ -389,15 +393,17 @@ export async function handleSyndicateTool(
389
393
  for (const k of lessonKeys) {
390
394
  const raw = await redis.get(k);
391
395
  if (!raw) continue;
392
- try {
393
- const l = JSON.parse(raw) as { topic: string; what_worked?: string; what_failed?: string; outcome: string; severity?: string; ts: string; verified_at?: string; recall_count?: number };
396
+ {
397
+ type PredLesson = { topic: string; what_worked?: string; what_failed?: string; outcome: string; severity?: string; ts: string; verified_at?: string; recall_count?: number };
398
+ const l = safeJsonParse<PredLesson | null>(raw, null);
399
+ if (!l) continue;
394
400
  const haystack = [l.topic, l.what_failed ?? '', l.what_worked ?? ''].join(' ').toLowerCase();
395
401
  const score = ctxTokens.reduce((s, t) => s + (haystack.includes(t) ? 1 : 0), 0);
396
402
  if (score >= 1 && l.outcome !== 'failure') {
397
403
  const conf = calculateConfidence(l);
398
404
  textPredictions.push({ ...l, confidence: conf });
399
405
  }
400
- } catch { /* skip */ }
406
+ }
401
407
  }
402
408
  textPredictions.sort((a, b) => b.confidence - a.confidence);
403
409
 
@@ -5,6 +5,7 @@ import type { Redis } from 'ioredis';
5
5
  import { calculateConfidence, confidenceBadge, CONFIDENCE_STALE_VALUE, CONFIDENCE_WARN_VALUE,
6
6
  CONFIDENCE_WARN_DAYS, CONFIDENCE_STALE_DAYS, simpleHash } from '../confidence.js';
7
7
  import type { Instance } from './brain.js';
8
+ import { safeJsonParse } from '../utils.js';
8
9
 
9
10
  type GetConnection = (instanceId: string) => Promise<Redis>;
10
11
  type ApiFetch = <T>(path: string, options?: RequestInit) => Promise<T>;
@@ -53,10 +54,11 @@ export async function handleTeamTool(
53
54
 
54
55
  type Lesson = { topic: string; what_worked: string; outcome: string; file_paths?: string[] };
55
56
  const relevant: string[] = [];
56
- for (const k of lessonKeys) {
57
- const raw = await redis.get(k);
57
+ const sfRaws = lessonKeys.length > 0 ? await redis.mget(...lessonKeys) : [];
58
+ for (const raw of sfRaws) {
58
59
  if (!raw) continue;
59
- const lesson = JSON.parse(raw) as Lesson;
60
+ const lesson = safeJsonParse<Lesson | null>(raw, null);
61
+ if (!lesson) continue;
60
62
  // Match by file_paths stored in lesson OR by topic keywords matching file name
61
63
  const topicWords = lesson.topic.toLowerCase().split(/[:\-_]/);
62
64
  const fileMatches = changed_files.some(f => {
@@ -146,10 +148,12 @@ export async function handleTeamTool(
146
148
  author?: string; tags?: string[];
147
149
  };
148
150
  let lessons: TeamLesson[] = [];
149
- for (const k of lessonKeys) {
150
- const raw = await redis.get(k);
151
- if (!raw) continue;
152
- try { lessons.push(JSON.parse(raw) as TeamLesson); } catch { /* skip */ }
151
+ if (lessonKeys.length > 0) {
152
+ const raws = await redis.mget(...lessonKeys);
153
+ for (const raw of raws) {
154
+ const l = safeJsonParse<TeamLesson | null>(raw ?? null, null);
155
+ if (l) lessons.push(l);
156
+ }
153
157
  }
154
158
 
155
159
  // Filter
@@ -278,10 +282,12 @@ export async function handleTeamTool(
278
282
 
279
283
  type RawLesson = { topic: string; outcome: string; what_worked: string; severity?: string; ts: string; auto_learned?: boolean };
280
284
  const allLessons: RawLesson[] = [];
281
- for (const k of allLessonKeys) {
282
- const raw = await redis.get(k);
283
- if (!raw) continue;
284
- try { allLessons.push(JSON.parse(raw) as RawLesson); } catch { /* skip */ }
285
+ if (allLessonKeys.length > 0) {
286
+ const raws = await redis.mget(...allLessonKeys);
287
+ for (const raw of raws) {
288
+ const l = safeJsonParse<RawLesson | null>(raw ?? null, null);
289
+ if (l) allLessons.push(l);
290
+ }
285
291
  }
286
292
 
287
293
  // Group lessons by top-level category
@@ -367,18 +373,17 @@ export async function handleTeamTool(
367
373
  verified_at?: string; severity?: string; audit_trail?: unknown[];
368
374
  };
369
375
  const lessons: DrLesson[] = [];
370
- for (const k of lessonKeys) {
371
- const raw = await redis.get(k);
372
- if (!raw) continue;
373
- try { lessons.push(JSON.parse(raw) as DrLesson); } catch { /* skip */ }
376
+ if (lessonKeys.length > 0) {
377
+ const raws = await redis.mget(...lessonKeys);
378
+ for (const raw of raws) {
379
+ const l = safeJsonParse<DrLesson | null>(raw ?? null, null);
380
+ if (l) lessons.push(l);
381
+ }
374
382
  }
375
383
 
376
384
  // Last session
377
385
  const lastSessionRaw = await redis.get('cachly:session:last');
378
- let lastSession: { ts: string; summary: string } | null = null;
379
- if (lastSessionRaw) {
380
- try { lastSession = JSON.parse(lastSessionRaw); } catch { /* ignore */ }
381
- }
386
+ const lastSession = safeJsonParse<{ ts: string; summary: string } | null>(lastSessionRaw, null);
382
387
 
383
388
  // Open failures
384
389
  const openFailures = lessons.filter(l => l.outcome === 'failure' || l.outcome === 'partial');
@@ -509,13 +514,24 @@ export async function handleTeamTool(
509
514
  issues.push(`💡 ${unusedLessons.length} lessons never recalled — verify topics match your workflow`);
510
515
  }
511
516
 
512
- const lines = ['🩺 **Brain Doctor Report**', '', ...checks.map(c => ' ' + c), ''];
517
+ const divider = '─'.repeat(48);
518
+ const lines = [
519
+ `🩺 **Brain Doctor**`,
520
+ `\`${divider}\``,
521
+ '',
522
+ '**📊 Health Checks**',
523
+ ...checks.map(c => ' ' + c),
524
+ '',
525
+ ];
513
526
  if (issues.length > 0) {
514
- lines.push('**Issues to fix:**');
515
- for (const i of issues) lines.push(' ' + i);
527
+ lines.push(`**🔧 Action Items** (${issues.length} issue${issues.length > 1 ? 's' : ''})`);
528
+ issues.forEach((item, idx) => lines.push(` ${idx + 1}. ${item}`));
516
529
  lines.push('');
530
+ lines.push(`_Fix the action items above, then re-run \`brain_doctor\` to verify._`);
517
531
  } else {
518
- lines.push(' 🎉 Brain looks healthy! Keep calling session_start/session_end.');
532
+ lines.push('**✅ All checks passed** — Brain is healthy!');
533
+ lines.push('');
534
+ lines.push('_Keep the brain sharp: `session_start` → work → `learn_from_attempts` → `session_end`_');
519
535
  }
520
536
  return lines.join('\n');
521
537
  }
@@ -536,7 +552,11 @@ export async function handleTeamTool(
536
552
  .filter((l): l is NonNullable<typeof l> => l !== null && new Date(l.ts).getTime() <= cutoff)
537
553
  .sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
538
554
 
539
- if (before.length === 0) return `📭 No entries for \`${topic}\` found before **${date}**. Earliest entry: ${new Date(JSON.parse(all[0]).ts).toLocaleDateString('de-DE')}.`;
555
+ if (before.length === 0) {
556
+ const first = safeJsonParse(all[0] ?? null, null as null | { ts?: string });
557
+ const earliest = first?.ts ? new Date(first.ts).toLocaleDateString('de-DE') : 'unknown';
558
+ return `📭 No entries for \`${topic}\` found before **${date}**. Earliest entry: ${earliest}.`;
559
+ }
540
560
 
541
561
  const lines = [
542
562
  `🏺 **Brain Archaeology: \`${topic}\` before ${date}**`,
@@ -563,7 +583,7 @@ export async function handleTeamTool(
563
583
  const raw = await redis.get(depKey);
564
584
  if (!raw) return `📭 No lessons found that depend on \`${dependency}\`.\n\nAdd dependencies via: \`learn_from_attempts(..., depends_on=["${dependency}"])\``;
565
585
 
566
- const topics: string[] = JSON.parse(raw);
586
+ const topics: string[] = safeJsonParse<string[]>(raw, []);
567
587
  const lines = [
568
588
  `🔗 **Causal Chain: \`${dependency}\`** — ${topics.length} dependent lesson${topics.length === 1 ? '' : 's'}`,
569
589
  '',
@@ -572,7 +592,8 @@ export async function handleTeamTool(
572
592
  for (const t of topics) {
573
593
  const lessonRaw = await redis.get(`cachly:lesson:best:${t}`);
574
594
  if (!lessonRaw) { lines.push(` • \`${t}\` _(lesson deleted)_`); continue; }
575
- const lesson = JSON.parse(lessonRaw) as { outcome?: string; severity?: string; needs_review?: boolean };
595
+ const lesson = safeJsonParse(lessonRaw, null as null | { outcome?: string; severity?: string; needs_review?: boolean });
596
+ if (!lesson) { lines.push(` • \`${t}\` _(lesson corrupted)_`); continue; }
576
597
  const emoji = lesson.outcome === 'success' ? '✅' : lesson.outcome === 'partial' ? '⚠️' : '❌';
577
598
  const reviewBadge = lesson.needs_review ? ' 🔍 **needs_review**' : '';
578
599
  lines.push(` ${emoji} \`${t}\` (${lesson.severity ?? 'major'})${reviewBadge}`);
@@ -614,8 +635,8 @@ export async function handleTeamTool(
614
635
  // Preserve recall_count on update
615
636
  const existing = await redis.get(key);
616
637
  if (existing) {
617
- const prev = JSON.parse(existing) as { recall_count?: number };
618
- record.recall_count = prev.recall_count ?? 0;
638
+ const prev = safeJsonParse(existing, null as null | { recall_count?: number });
639
+ record.recall_count = prev?.recall_count ?? 0;
619
640
  }
620
641
  await redis.set(key, JSON.stringify(record));
621
642
  return `🌐 **Global lesson stored**: \`${topic}\`\n\n${lesson}\n\nRecallable from any project via \`global_recall(topic="${topic}")\`.`;
@@ -635,10 +656,12 @@ export async function handleTeamTool(
635
656
 
636
657
  type GlobalLesson = { topic: string; lesson: string; severity?: string; ts: string; recall_count?: number };
637
658
  let lessons: GlobalLesson[] = [];
638
- for (const k of keys) {
639
- const raw = await redis.get(k);
640
- if (!raw) continue;
641
- try { lessons.push(JSON.parse(raw) as GlobalLesson); } catch { /* skip */ }
659
+ if (keys.length > 0) {
660
+ const raws = await redis.mget(...keys);
661
+ for (const raw of raws) {
662
+ const l = safeJsonParse<GlobalLesson | null>(raw ?? null, null);
663
+ if (l) lessons.push(l);
664
+ }
642
665
  }
643
666
 
644
667
  if (topic) {
@@ -655,7 +678,8 @@ export async function handleTeamTool(
655
678
  const k = `cachly:global:lesson:${l.topic}`;
656
679
  const raw = await redis.get(k);
657
680
  if (raw) {
658
- const rec = JSON.parse(raw) as { recall_count?: number };
681
+ const rec = safeJsonParse(raw, null as null | { recall_count?: number });
682
+ if (!rec) continue;
659
683
  rec.recall_count = (rec.recall_count ?? 0) + 1;
660
684
  await redis.set(k, JSON.stringify(rec));
661
685
  }