@cachly-dev/mcp-server 0.9.24 → 0.10.0

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.
@@ -3,6 +3,7 @@ import type { Redis } from 'ioredis';
3
3
  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 } from '../ckg.js';
6
+ import { safeJsonParse } from '../utils.js';
6
7
  import type { CKGEdge, CKGNode } from '../ckg.js';
7
8
  import { keywordSearch, tokenize, splitMultiQuery, levenshtein,
8
9
  ZERO_RESULTS_LOG, zeroResultsTotal, indexVocab as _indexVocab } from '../search.js';
@@ -10,6 +11,19 @@ import type { KeywordMatch } from '../search.js';
10
11
  import { computeEmbedding, hasEmbedProvider, embedProviderHint, EMBED_PROVIDER } from '../embeddings.js';
11
12
  import { detectNamespace } from '../namespace.js';
12
13
 
14
+ // ── Changelog (shown once per version in session_start) ──────────────────────
15
+ const MCP_VERSION = '0.10.0';
16
+ const WHATS_NEW: Record<string, string[]> = {
17
+ '0.10.0': [
18
+ `🆕 **What's new in v${MCP_VERSION}:**`,
19
+ ` ✅ Zero-friction setup — auto-provisions your Brain, no manual config needed`,
20
+ ` ✅ 10× faster briefings — batch Redis fetches replace per-key loops`,
21
+ ` ✅ Crash-proof data reads — corrupted cache entries no longer break tools`,
22
+ ` ✅ \`session_end\` warns when called without an active session`,
23
+ ` ✅ \`get_api_status\` now shows live Redis ping + all instance statuses`,
24
+ ],
25
+ };
26
+
13
27
  // Shared types used in brain handlers
14
28
  export interface Instance {
15
29
  id: string; name: string; tier: string; status: string; region: string;
@@ -167,7 +181,7 @@ export async function handleBrainTool(
167
181
  for (const dep of depends_on) {
168
182
  const depKey = `cachly:dep:${dep}`;
169
183
  const existing = await redis.get(depKey);
170
- const depTopics: string[] = existing ? JSON.parse(existing) : [];
184
+ const depTopics: string[] = safeJsonParse<string[]>(existing, []);
171
185
  if (!depTopics.includes(topic)) depTopics.push(topic);
172
186
  await redis.set(depKey, JSON.stringify(depTopics));
173
187
  }
@@ -266,7 +280,8 @@ export async function handleBrainTool(
266
280
  for (const ek of existingEdgeKeys) {
267
281
  const er = await redis.get(ek);
268
282
  if (!er) continue;
269
- const existEdge: CKGEdge = JSON.parse(er);
283
+ const existEdge = safeJsonParse<CKGEdge | null>(er, null);
284
+ if (!existEdge) continue;
270
285
  if (existEdge.edgeType === 'fixes' && existEdge.confidence > 0.7 && existEdge.trials >= 3) {
271
286
  beliefConflict = `⚠️ **belief_conflict** on \`${topic}\`: previously confirmed fix (confidence ${existEdge.confidence.toFixed(2)}, n=${existEdge.trials}) now reports failure. Both beliefs retained as \`contested\`. Use \`ckg_inspect(concept="${conceptId}")\` to review.`;
272
287
  // Store conflict marker
@@ -286,14 +301,16 @@ export async function handleBrainTool(
286
301
  for (const ek of fromEdgeKeys.slice(0, 10)) {
287
302
  const er = await redis.get(ek);
288
303
  if (!er) continue;
289
- const e2: CKGEdge = JSON.parse(er);
304
+ const e2 = safeJsonParse<CKGEdge | null>(er, null);
305
+ if (!e2) continue;
290
306
  if (e2.edgeType !== 'fixes') continue;
291
307
  // Boost second-degree: edges from e2.to get a small fractional success
292
308
  const secondKeys = await redis.smembers(`cachly:ckg:idx:from:${e2.to}`);
293
309
  for (const sk of secondKeys.slice(0, 5)) {
294
310
  const sr = await redis.get(sk);
295
311
  if (!sr) continue;
296
- const se: CKGEdge = JSON.parse(sr);
312
+ const se = safeJsonParse<CKGEdge | null>(sr, null);
313
+ if (!se) continue;
297
314
  if (se.edgeType !== 'co-occurs') continue;
298
315
  // Add 0.1 fractional success (second-degree signal)
299
316
  se.successes = (se.successes || 0) + 0.1;
@@ -314,7 +331,8 @@ export async function handleBrainTool(
314
331
  for (const ek of fromEdgeKeys) {
315
332
  const er = await redis.get(ek);
316
333
  if (!er) continue;
317
- const de: CKGEdge = JSON.parse(er);
334
+ const de = safeJsonParse<CKGEdge | null>(er, null);
335
+ if (!de) continue;
318
336
  if (de.trials < 3 && de.last_updated && new Date(de.last_updated).getTime() < decayCutoff) {
319
337
  de.confidence = de.confidence * 0.9;
320
338
  de.last_updated = new Date().toISOString();
@@ -356,12 +374,13 @@ export async function handleBrainTool(
356
374
  // Try exact best-solution key first
357
375
  const best = await redis.get(`cachly:lesson:best:${topic}`);
358
376
  if (best) {
359
- const lesson = JSON.parse(best) as {
377
+ const lesson = safeJsonParse(best, null as null | {
360
378
  topic: string; outcome: string; what_worked: string; what_failed?: string;
361
379
  context?: string; ts: string; verified_at?: string; severity?: string;
362
380
  file_paths?: string[]; commands?: string[]; tags?: string[];
363
381
  recall_count?: number; audit_trail?: unknown[];
364
- };
382
+ });
383
+ if (!lesson) return `⚠️ Lesson data for \`${topic}\` is corrupted. Re-store it with \`learn_from_attempts\`.`;
365
384
 
366
385
  // ── Confidence decay check ───────────────────────────────────────────
367
386
  const confidence = calculateConfidence(lesson);
@@ -424,7 +443,7 @@ export async function handleBrainTool(
424
443
  const histKey = `cachly:lessons:${topic}`;
425
444
  const all = await redis.lrange(histKey, -3, -1);
426
445
  if (all.length > 0) {
427
- const parsed = all.map(e => JSON.parse(e) as { outcome: string; what_worked: string; ts: string });
446
+ const parsed = all.map(e => safeJsonParse(e, null as null | { outcome: string; what_worked: string; ts: string })).filter(Boolean) as Array<{ outcome: string; what_worked: string; ts: string }>;
428
447
  const lines = parsed.map(p => `- ${p.outcome === 'success' ? '✅' : '❌'} ${p.what_worked.slice(0, 120)} (${new Date(p.ts).toLocaleDateString('de-DE')})`);
429
448
  return `⚠️ No successful solution for \`${topic}\` yet. Last attempts:\n\n${lines.join('\n')}`;
430
449
  }
@@ -436,7 +455,8 @@ export async function handleBrainTool(
436
455
  for (const k of matching.slice(0, 5)) {
437
456
  const raw = await redis.get(k);
438
457
  if (!raw) continue;
439
- const lesson = JSON.parse(raw) as { topic: string; what_worked: string; context?: string; ts: string };
458
+ const lesson = safeJsonParse(raw, null as null | { topic: string; what_worked: string; context?: string; ts: string });
459
+ if (!lesson) continue;
440
460
  results.push(`**\`${lesson.topic}\`** — ${lesson.what_worked.slice(0, 200)}`);
441
461
  }
442
462
  return `🔍 **Partial matches for \`${topic}\`:**\n\n${results.join('\n\n')}`;
@@ -597,10 +617,12 @@ export async function handleBrainTool(
597
617
  tags?: string[]; confidence?: number; audit_trail?: unknown[];
598
618
  };
599
619
  const lessons: Lesson[] = [];
600
- for (const k of lessonKeys) {
601
- const raw = await redis.get(k);
602
- if (!raw) continue;
603
- try { lessons.push(JSON.parse(raw) as Lesson); } catch { /* skip corrupt */ }
620
+ if (lessonKeys.length > 0) {
621
+ const raws = await redis.mget(...lessonKeys);
622
+ for (const raw of raws) {
623
+ const l = safeJsonParse<Lesson | null>(raw ?? null, null);
624
+ if (l) lessons.push(l);
625
+ }
604
626
  }
605
627
  lessons.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
606
628
 
@@ -617,10 +639,8 @@ export async function handleBrainTool(
617
639
 
618
640
  // 4. Last session
619
641
  const lastSessionRaw = await redis.get('cachly:session:last');
620
- let lastSession: { summary: string; ts: string; files_changed?: string[]; duration_min?: number } | null = null;
621
- if (lastSessionRaw) {
622
- try { lastSession = JSON.parse(lastSessionRaw); } catch { /* ignore */ }
623
- }
642
+ type LastSession = { summary: string; ts: string; files_changed?: string[]; duration_min?: number };
643
+ const lastSession = safeJsonParse<LastSession | null>(lastSessionRaw, null);
624
644
 
625
645
  // 5. Focus filtering
626
646
  const focusTerms = focus.toLowerCase().split(/\s+/).filter(Boolean);
@@ -639,7 +659,7 @@ export async function handleBrainTool(
639
659
  let streakMessage = '';
640
660
  try {
641
661
  const streakRaw = await redis.get('cachly:streak:current');
642
- const streak = streakRaw ? JSON.parse(streakRaw) as { days: number; last_date: string; record: number } : null;
662
+ const streak = safeJsonParse<{ days: number; last_date: string; record: number } | null>(streakRaw, null);
643
663
  const today = new Date().toISOString().slice(0, 10);
644
664
  if (streak) {
645
665
  const lastDate = streak.last_date;
@@ -681,6 +701,34 @@ export async function handleBrainTool(
681
701
  const lines: string[] = [`🧠 **Session Briefing**${providerLabel}`, ''];
682
702
  if (streakMessage) lines.push(streakMessage, '');
683
703
 
704
+ // ── What's New (shown once per version update) ──────────────────────────
705
+ try {
706
+ const seenVersion = await redis.get('cachly:mcp:version:last_seen');
707
+ if (seenVersion !== MCP_VERSION) {
708
+ await redis.set('cachly:mcp:version:last_seen', MCP_VERSION, 'EX', 365 * 86400);
709
+ const changelog = WHATS_NEW[MCP_VERSION];
710
+ if (changelog) { lines.push(...changelog, ''); }
711
+ }
712
+ } catch { /* non-critical */ }
713
+
714
+ // ── First-time welcome (empty brain) ───────────────────────────────────
715
+ const isFirstSession = !lastSession && lessons.length === 0 && ctxCount === 0;
716
+ if (isFirstSession) {
717
+ lines.push('🎉 **Welcome! Your AI Brain is live.**', '');
718
+ lines.push('It learns from your work automatically. After your first session it will look like this:', '');
719
+ lines.push(' ✅ `api:auth` — Bearer token in header, not cookie; 401 on missing scope');
720
+ lines.push(' ✅ `database:migrations` — always run migrations before deploy');
721
+ lines.push(' ⚠️ `docker:build` — ARG changes bust all subsequent cache layers');
722
+ lines.push('');
723
+ lines.push('**3 steps to grow your brain:**');
724
+ lines.push(' 1. Fix something → `learn_from_attempts(topic="bug:name", outcome="success", what_worked="...")`');
725
+ lines.push(' 2. Save context → `remember_context(key="arch", value="...")`');
726
+ lines.push(' 3. End session → `session_end(summary="What I did")`');
727
+ lines.push('');
728
+ lines.push('💡 Run `brain_doctor` for a setup health-check and personalised tips.');
729
+ lines.push('');
730
+ }
731
+
684
732
  // Handoff from previous window (if any)
685
733
  const handoffRaw = await redis.get('cachly:session:handoff');
686
734
  if (handoffRaw) {
@@ -1125,8 +1173,6 @@ export async function handleBrainTool(
1125
1173
  } catch { /* non-critical */ }
1126
1174
 
1127
1175
  // ── 🌍 Knowledge Commons — community stats banner ───────────────────────
1128
- // Fetch syndication stats and show a 1-liner: total lessons + confirms + weekly growth.
1129
- // Non-fatal: never blocks session start if the API call fails.
1130
1176
  try {
1131
1177
  const commonsStats = await apiFetch<{
1132
1178
  total_lessons: number;
@@ -1143,6 +1189,17 @@ export async function handleBrainTool(
1143
1189
  }
1144
1190
  } catch { /* non-critical — never block session start */ }
1145
1191
 
1192
+ // ── Brain Doctor hint (Punkt 6) — surface when brain needs attention ───
1193
+ const hasOpenFailures = lessons.filter(l => l.outcome === 'failure' || l.outcome === 'partial').length > 0;
1194
+ const hasStaleLessons = lessons.some(l => l.outcome === 'success' && calculateConfidence(l) < CONFIDENCE_WARN_VALUE);
1195
+ if (lessons.length === 0 || hasOpenFailures || hasStaleLessons) {
1196
+ const reasons: string[] = [];
1197
+ if (lessons.length === 0) reasons.push('brain is empty');
1198
+ if (hasOpenFailures) reasons.push('open failures');
1199
+ if (hasStaleLessons) reasons.push('stale lessons');
1200
+ lines.push(`🩺 _Run \`brain_doctor\` to fix: ${reasons.join(', ')}._`);
1201
+ }
1202
+
1146
1203
  return lines.join('\n');
1147
1204
  }
1148
1205
 
@@ -1168,18 +1225,23 @@ export async function handleBrainTool(
1168
1225
  // Calculate duration from session_start marker
1169
1226
  let durationMin: number | undefined;
1170
1227
  const currentRaw = await redis.get('cachly:session:current');
1171
- if (currentRaw) {
1172
- try {
1173
- const current = JSON.parse(currentRaw) as { started: string };
1174
- durationMin = Math.round((now.getTime() - new Date(current.started).getTime()) / 60000);
1175
- } catch { /* ignore */ }
1228
+ if (!currentRaw) {
1229
+ return `⚠️ No active session found.\n\nRun \`session_start\` first to begin tracking a session, then call \`session_end\` when you're done.`;
1230
+ }
1231
+ const current = safeJsonParse<{ started: string } | null>(currentRaw, null);
1232
+ if (current?.started) {
1233
+ durationMin = Math.round((now.getTime() - new Date(current.started).getTime()) / 60000);
1176
1234
  }
1177
1235
 
1178
1236
  // ── Session Replay: capture decision log ─────────────────────────────
1179
- let decisionLog: Array<{ ts: string; topic: string; outcome: string; what_worked: string }> = [];
1237
+ type DecisionEntry = { ts: string; topic: string; outcome: string; what_worked: string };
1238
+ let decisionLog: DecisionEntry[] = [];
1180
1239
  try {
1181
1240
  const dlEntries = await redis.lrange('cachly:session:decision-log', 0, -1);
1182
- decisionLog = dlEntries.map(e => JSON.parse(e) as { ts: string; topic: string; outcome: string; what_worked: string });
1241
+ decisionLog = dlEntries.flatMap(e => {
1242
+ const entry = safeJsonParse<DecisionEntry | null>(e, null);
1243
+ return entry ? [entry] : [];
1244
+ });
1183
1245
  await redis.del('cachly:session:decision-log');
1184
1246
  } catch { /* non-critical */ }
1185
1247
 
@@ -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
  }
@@ -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
  }