@cachly-dev/mcp-server 0.10.5 → 0.10.7

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.
@@ -1,8 +1,21 @@
1
1
  import { execSync } from 'node:child_process';
2
2
  import { calculateConfidence, confidenceBadge, STRUCTURED_TEMPLATES, CONFIDENCE_WARN_VALUE, CONFIDENCE_STALE_VALUE, CONFIDENCE_WARN_DAYS } from '../confidence.js';
3
3
  import { ckgSlug, extractProblemConcept, ckgUpsertNode, ckgUpdateEdge } from '../ckg.js';
4
+ import { safeJsonParse } from '../utils.js';
4
5
  import { keywordSearch, tokenize, splitMultiQuery, levenshtein, indexVocab as _indexVocab } from '../search.js';
5
6
  import { computeEmbedding, hasEmbedProvider } from '../embeddings.js';
7
+ // ── Changelog (shown once per version in session_start) ──────────────────────
8
+ const MCP_VERSION = '0.10.0';
9
+ const WHATS_NEW = {
10
+ '0.10.0': [
11
+ `🆕 **What's new in v${MCP_VERSION}:**`,
12
+ ` ✅ Zero-friction setup — auto-provisions your Brain, no manual config needed`,
13
+ ` ✅ 10× faster briefings — batch Redis fetches replace per-key loops`,
14
+ ` ✅ Crash-proof data reads — corrupted cache entries no longer break tools`,
15
+ ` ✅ \`session_end\` warns when called without an active session`,
16
+ ` ✅ \`get_api_status\` now shows live Redis ping + all instance statuses`,
17
+ ],
18
+ };
6
19
  export const BRAIN_TOOL_NAMES = new Set([
7
20
  'learn_from_attempts', 'recall_best_solution', 'smart_recall',
8
21
  'session_start', 'session_end', 'session_ping', 'session_handoff', 'auto_learn_session',
@@ -98,7 +111,7 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
98
111
  for (const dep of depends_on) {
99
112
  const depKey = `cachly:dep:${dep}`;
100
113
  const existing = await redis.get(depKey);
101
- const depTopics = existing ? JSON.parse(existing) : [];
114
+ const depTopics = safeJsonParse(existing, []);
102
115
  if (!depTopics.includes(topic))
103
116
  depTopics.push(topic);
104
117
  await redis.set(depKey, JSON.stringify(depTopics));
@@ -190,7 +203,9 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
190
203
  const er = await redis.get(ek);
191
204
  if (!er)
192
205
  continue;
193
- const existEdge = JSON.parse(er);
206
+ const existEdge = safeJsonParse(er, null);
207
+ if (!existEdge)
208
+ continue;
194
209
  if (existEdge.edgeType === 'fixes' && existEdge.confidence > 0.7 && existEdge.trials >= 3) {
195
210
  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.`;
196
211
  // Store conflict marker
@@ -198,7 +213,7 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
198
213
  await redis.set(conflictKey, JSON.stringify({ topic, detected_at: new Date().toISOString(), fix_confidence: existEdge.confidence, fix_trials: existEdge.trials, failure_outcome: outcome }), 'EX', 60 * 60 * 24 * 90);
199
214
  // Auto-trigger MADC deliberation in background — never blocks learn_from_attempts
200
215
  // Note: fire-and-forget; madc_deliberate is handled elsewhere in the switch
201
- apiFetch(`/api/v1/instances/${instance_id}`).catch(() => undefined); // keep apiFetch warm
216
+ // (conflict marker stored above — no warm-up call needed)
202
217
  }
203
218
  }
204
219
  }
@@ -210,7 +225,9 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
210
225
  const er = await redis.get(ek);
211
226
  if (!er)
212
227
  continue;
213
- const e2 = JSON.parse(er);
228
+ const e2 = safeJsonParse(er, null);
229
+ if (!e2)
230
+ continue;
214
231
  if (e2.edgeType !== 'fixes')
215
232
  continue;
216
233
  // Boost second-degree: edges from e2.to get a small fractional success
@@ -219,7 +236,9 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
219
236
  const sr = await redis.get(sk);
220
237
  if (!sr)
221
238
  continue;
222
- const se = JSON.parse(sr);
239
+ const se = safeJsonParse(sr, null);
240
+ if (!se)
241
+ continue;
223
242
  if (se.edgeType !== 'co-occurs')
224
243
  continue;
225
244
  // Add 0.1 fractional success (second-degree signal)
@@ -241,7 +260,9 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
241
260
  const er = await redis.get(ek);
242
261
  if (!er)
243
262
  continue;
244
- const de = JSON.parse(er);
263
+ const de = safeJsonParse(er, null);
264
+ if (!de)
265
+ continue;
245
266
  if (de.trials < 3 && de.last_updated && new Date(de.last_updated).getTime() < decayCutoff) {
246
267
  de.confidence = de.confidence * 0.9;
247
268
  de.last_updated = new Date().toISOString();
@@ -281,7 +302,9 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
281
302
  // Try exact best-solution key first
282
303
  const best = await redis.get(`cachly:lesson:best:${topic}`);
283
304
  if (best) {
284
- const lesson = JSON.parse(best);
305
+ const lesson = safeJsonParse(best, null);
306
+ if (!lesson)
307
+ return `⚠️ Lesson data for \`${topic}\` is corrupted. Re-store it with \`learn_from_attempts\`.`;
285
308
  // ── Confidence decay check ───────────────────────────────────────────
286
309
  const confidence = calculateConfidence(lesson);
287
310
  const ref = lesson.verified_at ?? lesson.ts;
@@ -295,6 +318,9 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
295
318
  confidence: 1.0,
296
319
  };
297
320
  await redis.set(`cachly:lesson:best:${topic}`, JSON.stringify(updatedLesson));
321
+ // Track estimated time saved (30m minor · 60m major · 240m critical)
322
+ const savedMins = lesson.severity === 'critical' ? 240 : lesson.severity === 'major' ? 60 : 30;
323
+ redis.incrbyfloat(`cachly:stats:time_saved_mins:${instance_id}`, savedMins).catch(() => { });
298
324
  const sevEmoji = lesson.severity === 'critical' ? '🔴' : lesson.severity === 'major' ? '🟡' : lesson.severity ? '🟢' : '';
299
325
  const auditSummary = (lesson.audit_trail ?? []).length > 1
300
326
  ? `_Audit: ${(lesson.audit_trail ?? []).length} changes · stored ${new Date(lesson.ts).toLocaleDateString('de-DE')}_`
@@ -336,7 +362,7 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
336
362
  const histKey = `cachly:lessons:${topic}`;
337
363
  const all = await redis.lrange(histKey, -3, -1);
338
364
  if (all.length > 0) {
339
- const parsed = all.map(e => JSON.parse(e));
365
+ const parsed = all.map(e => safeJsonParse(e, null)).filter(Boolean);
340
366
  const lines = parsed.map(p => `- ${p.outcome === 'success' ? '✅' : '❌'} ${p.what_worked.slice(0, 120)} (${new Date(p.ts).toLocaleDateString('de-DE')})`);
341
367
  return `⚠️ No successful solution for \`${topic}\` yet. Last attempts:\n\n${lines.join('\n')}`;
342
368
  }
@@ -348,7 +374,9 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
348
374
  const raw = await redis.get(k);
349
375
  if (!raw)
350
376
  continue;
351
- const lesson = JSON.parse(raw);
377
+ const lesson = safeJsonParse(raw, null);
378
+ if (!lesson)
379
+ continue;
352
380
  results.push(`**\`${lesson.topic}\`** — ${lesson.what_worked.slice(0, 200)}`);
353
381
  }
354
382
  return `🔍 **Partial matches for \`${topic}\`:**\n\n${results.join('\n\n')}`;
@@ -485,14 +513,13 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
485
513
  lStream.on('error', reject);
486
514
  });
487
515
  const lessons = [];
488
- for (const k of lessonKeys) {
489
- const raw = await redis.get(k);
490
- if (!raw)
491
- continue;
492
- try {
493
- lessons.push(JSON.parse(raw));
516
+ if (lessonKeys.length > 0) {
517
+ const raws = await redis.mget(...lessonKeys);
518
+ for (const raw of raws) {
519
+ const l = safeJsonParse(raw ?? null, null);
520
+ if (l)
521
+ lessons.push(l);
494
522
  }
495
- catch { /* skip corrupt */ }
496
523
  }
497
524
  lessons.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
498
525
  // 3. Count context entries (filter :meta keys)
@@ -507,13 +534,7 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
507
534
  });
508
535
  // 4. Last session
509
536
  const lastSessionRaw = await redis.get('cachly:session:last');
510
- let lastSession = null;
511
- if (lastSessionRaw) {
512
- try {
513
- lastSession = JSON.parse(lastSessionRaw);
514
- }
515
- catch { /* ignore */ }
516
- }
537
+ const lastSession = safeJsonParse(lastSessionRaw, null);
517
538
  // 5. Focus filtering
518
539
  const focusTerms = focus.toLowerCase().split(/\s+/).filter(Boolean);
519
540
  const focusLessons = focusTerms.length > 0
@@ -526,7 +547,7 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
526
547
  let streakMessage = '';
527
548
  try {
528
549
  const streakRaw = await redis.get('cachly:streak:current');
529
- const streak = streakRaw ? JSON.parse(streakRaw) : null;
550
+ const streak = safeJsonParse(streakRaw, null);
530
551
  const today = new Date().toISOString().slice(0, 10);
531
552
  if (streak) {
532
553
  const lastDate = streak.last_date;
@@ -567,11 +588,54 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
567
588
  focus,
568
589
  provider,
569
590
  }), 'EX', 86400); // auto-expire after 24h if session_end never called
591
+ // 8. Time saved counter
592
+ let timeSavedMins = 0;
593
+ try {
594
+ const raw = await redis.get(`cachly:stats:time_saved_mins:${instance_id}`);
595
+ timeSavedMins = parseFloat(raw ?? '0');
596
+ }
597
+ catch { /* non-critical */ }
570
598
  // ── Build briefing ──────────────────────────────────────────────────────
571
599
  const providerLabel = provider ? ` · ${provider}` : '';
572
600
  const lines = [`🧠 **Session Briefing**${providerLabel}`, ''];
573
601
  if (streakMessage)
574
602
  lines.push(streakMessage, '');
603
+ // Time saved (only show when meaningful — 30+ minutes)
604
+ if (timeSavedMins >= 30) {
605
+ const h = Math.floor(timeSavedMins / 60);
606
+ const m = Math.round(timeSavedMins % 60);
607
+ const timeStr = h > 0 ? `${h}h${m > 0 ? ` ${m}m` : ''}` : `${m}m`;
608
+ lines.push(`⏱️ **Brain saved you ~${timeStr} total** (time not re-researching known fixes)`, '');
609
+ }
610
+ // ── What's New (shown once per version update) ──────────────────────────
611
+ try {
612
+ const seenVersion = await redis.get('cachly:mcp:version:last_seen');
613
+ if (seenVersion !== MCP_VERSION) {
614
+ await redis.set('cachly:mcp:version:last_seen', MCP_VERSION, 'EX', 365 * 86400);
615
+ const changelog = WHATS_NEW[MCP_VERSION];
616
+ if (changelog) {
617
+ lines.push(...changelog, '');
618
+ }
619
+ }
620
+ }
621
+ catch { /* non-critical */ }
622
+ // ── First-time welcome (empty brain) ───────────────────────────────────
623
+ const isFirstSession = !lastSession && lessons.length === 0 && ctxCount === 0;
624
+ if (isFirstSession) {
625
+ lines.push('🎉 **Welcome! Your AI Brain is live.**', '');
626
+ lines.push('It learns from your work automatically. After your first session it will look like this:', '');
627
+ lines.push(' ✅ `api:auth` — Bearer token in header, not cookie; 401 on missing scope');
628
+ lines.push(' ✅ `database:migrations` — always run migrations before deploy');
629
+ lines.push(' ⚠️ `docker:build` — ARG changes bust all subsequent cache layers');
630
+ lines.push('');
631
+ lines.push('**3 steps to grow your brain:**');
632
+ lines.push(' 1. Fix something → `learn_from_attempts(topic="bug:name", outcome="success", what_worked="...")`');
633
+ lines.push(' 2. Save context → `remember_context(key="arch", value="...")`');
634
+ lines.push(' 3. End session → `session_end(summary="What I did")`');
635
+ lines.push('');
636
+ lines.push('💡 Run `brain_doctor` for a setup health-check and personalised tips.');
637
+ lines.push('');
638
+ }
575
639
  // Handoff from previous window (if any)
576
640
  const handoffRaw = await redis.get('cachly:session:handoff');
577
641
  if (handoffRaw) {
@@ -707,7 +771,9 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
707
771
  const cr = await redis.get(ck);
708
772
  if (!cr)
709
773
  continue;
710
- const cf = JSON.parse(cr);
774
+ const cf = safeJsonParse(cr, null);
775
+ if (!cf)
776
+ continue;
711
777
  lines.push(` ⚠️ \`${cf.topic}\` — previously confirmed fix (${(cf.fix_confidence * 100).toFixed(0)}%, n=${cf.fix_trials}) now contradicted. Use \`ckg_inspect(concept="${ckgSlug(cf.topic)}")\``);
712
778
  }
713
779
  lines.push('');
@@ -766,6 +832,14 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
766
832
  else {
767
833
  lines.push('📭 No lessons yet. Use `learn_from_attempts` after solving tasks.', '');
768
834
  }
835
+ // Team invite prompt — fires once after 10th lesson if no team use yet
836
+ if (lessons.length >= 10) {
837
+ const hasTeamUse = lessons.some(l => l.author);
838
+ if (!hasTeamUse) {
839
+ lines.push(`🤝 **Your Brain has ${lessons.length} lessons — your team could benefit instantly.**`);
840
+ lines.push(` Share: **cachly.dev/team** · or run \`team_learn\` after the next fix.`, '');
841
+ }
842
+ }
769
843
  // Open failures (lessons whose best-key has outcome != success)
770
844
  const openFailures = lessons.filter(l => l.outcome === 'failure' || l.outcome === 'partial');
771
845
  if (openFailures.length > 0) {
@@ -857,14 +931,16 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
857
931
  try {
858
932
  const crystalRaw = await redis.get('cachly:crystal:latest');
859
933
  if (crystalRaw) {
860
- const crystal = JSON.parse(crystalRaw);
861
- const crystalAge = Math.round((Date.now() - new Date(crystal.ts).getTime()) / 86_400_000);
862
- if (crystalAge <= 90) {
863
- lines.push(`💎 **Memory Crystal** (${crystal.label} · ${crystal.session_count} sessions compressed):`);
864
- for (const p of crystal.top_patterns.slice(0, 3)) {
865
- lines.push(` • **${p.category}** (${p.count}×): ${p.insight.slice(0, 90)}`);
934
+ const crystal = safeJsonParse(crystalRaw, null);
935
+ if (crystal) {
936
+ const crystalAge = Math.round((Date.now() - new Date(crystal.ts).getTime()) / 86_400_000);
937
+ if (crystalAge <= 90) {
938
+ lines.push(`💎 **Memory Crystal** (${crystal.label} · ${crystal.session_count} sessions compressed):`);
939
+ for (const p of crystal.top_patterns.slice(0, 3)) {
940
+ lines.push(` • **${p.category}** (${p.count}×): ${p.insight.slice(0, 90)}`);
941
+ }
942
+ lines.push('');
866
943
  }
867
- lines.push('');
868
944
  }
869
945
  }
870
946
  }
@@ -876,7 +952,10 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
876
952
  const PRIORITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
877
953
  const PRIORITY_ICON = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
878
954
  const openStatuses = new Set(['planned', 'in-progress', 'blocked']);
879
- const allItems = Object.values(roadmapAll).map(v => JSON.parse(v));
955
+ const allItems = Object.values(roadmapAll).flatMap(v => {
956
+ const item = safeJsonParse(v, null);
957
+ return item ? [item] : [];
958
+ });
880
959
  const openItems = allItems
881
960
  .filter(i => openStatuses.has(i.status))
882
961
  .sort((a, b) => {
@@ -917,23 +996,25 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
917
996
  const nodeRaw = await redis.get(nk);
918
997
  if (!nodeRaw)
919
998
  continue;
920
- const node = JSON.parse(nodeRaw);
999
+ const node = safeJsonParse(nodeRaw, null);
1000
+ if (!node)
1001
+ continue;
921
1002
  const edgeKeys = await redis.smembers(`cachly:ckg:idx:from:${node.id}`);
922
1003
  for (const ek of edgeKeys.slice(0, 15)) {
923
1004
  const edgeRaw = await redis.get(ek);
924
1005
  if (!edgeRaw)
925
1006
  continue;
926
- const edge = JSON.parse(edgeRaw);
1007
+ const edge = safeJsonParse(edgeRaw, null);
1008
+ if (!edge)
1009
+ continue;
927
1010
  if (edge.edgeType !== 'causes' && edge.edgeType !== 'co-occurs' && edge.edgeType !== 'fixes')
928
1011
  continue;
929
1012
  if (edge.confidence < 0.35)
930
1013
  continue;
931
- // For "fixes" edges, the lesson is on the "from" node (fix for "to")
932
- // For "causes" edges, target is the failure mode
933
1014
  const lessonKey = edge.edgeType === 'fixes' ? `cachly:lesson:best:${edge.from}` : `cachly:lesson:best:${edge.to}`;
934
1015
  const lessonRaw = await redis.get(lessonKey);
935
- const lesson = lessonRaw ? JSON.parse(lessonRaw) : undefined;
936
- ppePredictions.push({ concept: node.id, edgeType: edge.edgeType, target: edge.to, confidence: edge.confidence, lesson });
1016
+ const lesson = safeJsonParse(lessonRaw, null);
1017
+ ppePredictions.push({ concept: node.id, edgeType: edge.edgeType, target: edge.to, confidence: edge.confidence, lesson: lesson ?? undefined });
937
1018
  }
938
1019
  }
939
1020
  }
@@ -964,7 +1045,7 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
964
1045
  // Measures: of recalled lessons with confidence > 0.85, what % had outcome=success?
965
1046
  try {
966
1047
  const calRaw = await redis.get('cachly:mcm:calibration:last');
967
- const lastCalMs = calRaw ? JSON.parse(calRaw).ts : 0;
1048
+ const lastCalMs = safeJsonParse(calRaw, null)?.ts ?? 0;
968
1049
  const daysSinceCal = (Date.now() - lastCalMs) / 86_400_000;
969
1050
  if (daysSinceCal >= 30 && lessons.length >= 5) {
970
1051
  // Quick calibration pass on recalled lessons (recall_count > 0, outcome=success)
@@ -1011,8 +1092,6 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
1011
1092
  }
1012
1093
  catch { /* non-critical */ }
1013
1094
  // ── 🌍 Knowledge Commons — community stats banner ───────────────────────
1014
- // Fetch syndication stats and show a 1-liner: total lessons + confirms + weekly growth.
1015
- // Non-fatal: never blocks session start if the API call fails.
1016
1095
  try {
1017
1096
  const commonsStats = await apiFetch('/api/v1/syndication/stats');
1018
1097
  if (commonsStats.total_lessons > 0) {
@@ -1023,6 +1102,19 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
1023
1102
  }
1024
1103
  }
1025
1104
  catch { /* non-critical — never block session start */ }
1105
+ // ── Brain Doctor hint (Punkt 6) — surface when brain needs attention ───
1106
+ const hasOpenFailures = lessons.filter(l => l.outcome === 'failure' || l.outcome === 'partial').length > 0;
1107
+ const hasStaleLessons = lessons.some(l => l.outcome === 'success' && calculateConfidence(l) < CONFIDENCE_WARN_VALUE);
1108
+ if (lessons.length === 0 || hasOpenFailures || hasStaleLessons) {
1109
+ const reasons = [];
1110
+ if (lessons.length === 0)
1111
+ reasons.push('brain is empty');
1112
+ if (hasOpenFailures)
1113
+ reasons.push('open failures');
1114
+ if (hasStaleLessons)
1115
+ reasons.push('stale lessons');
1116
+ lines.push(`🩺 _Run \`brain_doctor\` to fix: ${reasons.join(', ')}._`);
1117
+ }
1026
1118
  return lines.join('\n');
1027
1119
  }
1028
1120
  // ── session_end ───────────────────────────────────────────────────────────
@@ -1033,18 +1125,20 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
1033
1125
  // Calculate duration from session_start marker
1034
1126
  let durationMin;
1035
1127
  const currentRaw = await redis.get('cachly:session:current');
1036
- if (currentRaw) {
1037
- try {
1038
- const current = JSON.parse(currentRaw);
1039
- durationMin = Math.round((now.getTime() - new Date(current.started).getTime()) / 60000);
1040
- }
1041
- catch { /* ignore */ }
1128
+ if (!currentRaw) {
1129
+ return `⚠️ No active session found.\n\nRun \`session_start\` first to begin tracking a session, then call \`session_end\` when you're done.`;
1130
+ }
1131
+ const current = safeJsonParse(currentRaw, null);
1132
+ if (current?.started) {
1133
+ durationMin = Math.round((now.getTime() - new Date(current.started).getTime()) / 60000);
1042
1134
  }
1043
- // ── Session Replay: capture decision log ─────────────────────────────
1044
1135
  let decisionLog = [];
1045
1136
  try {
1046
1137
  const dlEntries = await redis.lrange('cachly:session:decision-log', 0, -1);
1047
- decisionLog = dlEntries.map(e => JSON.parse(e));
1138
+ decisionLog = dlEntries.flatMap(e => {
1139
+ const entry = safeJsonParse(e, null);
1140
+ return entry ? [entry] : [];
1141
+ });
1048
1142
  await redis.del('cachly:session:decision-log');
1049
1143
  }
1050
1144
  catch { /* non-critical */ }