@cachly-dev/mcp-server 0.10.13 → 0.10.15

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 (44) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +50 -0
  2. package/.github/ISSUE_TEMPLATE/config.yml +8 -0
  3. package/.github/ISSUE_TEMPLATE/feature_request.md +25 -0
  4. package/.github/workflows/publish.yml +72 -0
  5. package/CHANGELOG.md +28 -0
  6. package/LAUNCH_TEXTS.md +182 -0
  7. package/LICENSE +202 -0
  8. package/README.md +106 -53
  9. package/dist/__tests__/auth.test.d.ts +1 -0
  10. package/dist/__tests__/auth.test.js +107 -0
  11. package/dist/__tests__/auth.test.js.map +1 -0
  12. package/dist/handlers/brain.js +7 -67
  13. package/dist/handlers/brain.js.map +1 -1
  14. package/dist/handlers/fedbrain.js +93 -44
  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 +555 -93
  23. package/dist/index.js.map +1 -1
  24. package/dist/tools.d.ts +3 -54
  25. package/dist/tools.js +8 -62
  26. package/dist/tools.js.map +1 -1
  27. package/glama.json +29 -0
  28. package/llms.txt +155 -0
  29. package/package.json +29 -13
  30. package/scripts/postinstall.js +12 -12
  31. package/server.json +3 -9
  32. package/smithery.yaml +6 -8
  33. package/src/__tests__/auth.test.ts +147 -0
  34. package/src/handlers/brain.ts +7 -64
  35. package/src/handlers/fedbrain.ts +88 -38
  36. package/src/handlers/roadmap.ts +7 -2
  37. package/src/handlers/syndicate.ts +13 -7
  38. package/src/handlers/team.ts +57 -33
  39. package/src/index.ts +599 -106
  40. package/src/tools.ts +8 -66
  41. package/dist/__tests__/brain-predict-failures.test.d.ts +0 -17
  42. package/dist/__tests__/brain-predict-failures.test.js +0 -339
  43. package/dist/__tests__/brain-predict-failures.test.js.map +0 -1
  44. package/src/__tests__/brain-predict-failures.test.ts +0 -430
@@ -0,0 +1,147 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
3
+ import { jwtExpiryMs, checkJwt, handleApiError } from '../auth.js';
4
+
5
+ function makeJwt(payload: Record<string, unknown>): string {
6
+ const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
7
+ const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
8
+ return `${header}.${body}.fakesig`;
9
+ }
10
+
11
+ const nowSec = () => Math.floor(Date.now() / 1000);
12
+
13
+ describe('jwtExpiryMs', () => {
14
+ it('returns null for an empty string', () => {
15
+ expect(jwtExpiryMs('')).toBeNull();
16
+ });
17
+
18
+ it('returns null for a plain (non-JWT) string', () => {
19
+ expect(jwtExpiryMs('not-a-token')).toBeNull();
20
+ });
21
+
22
+ it('returns null when the JWT has only two parts', () => {
23
+ expect(jwtExpiryMs('header.payload')).toBeNull();
24
+ });
25
+
26
+ it('returns null when the JWT has four parts', () => {
27
+ expect(jwtExpiryMs('a.b.c.d')).toBeNull();
28
+ });
29
+
30
+ it('returns null when the payload base64 is invalid', () => {
31
+ expect(jwtExpiryMs('header.!!!.sig')).toBeNull();
32
+ });
33
+
34
+ it('returns null when the payload has no exp field', () => {
35
+ expect(jwtExpiryMs(makeJwt({ sub: 'user-123', iat: nowSec() }))).toBeNull();
36
+ });
37
+
38
+ it('returns null when exp is 0', () => {
39
+ expect(jwtExpiryMs(makeJwt({ exp: 0 }))).toBeNull();
40
+ });
41
+
42
+ it('converts exp (seconds) to milliseconds', () => {
43
+ const expSec = nowSec() + 3600;
44
+ expect(jwtExpiryMs(makeJwt({ exp: expSec }))).toBe(expSec * 1000);
45
+ });
46
+
47
+ it('returns a past timestamp for an expired token', () => {
48
+ expect(jwtExpiryMs(makeJwt({ exp: nowSec() - 300 }))).toBeLessThan(Date.now());
49
+ });
50
+
51
+ it('returns a future timestamp for a valid token', () => {
52
+ expect(jwtExpiryMs(makeJwt({ exp: nowSec() + 86400 }))).toBeGreaterThan(Date.now());
53
+ });
54
+
55
+ it('handles a token expiring in less than 60 minutes', () => {
56
+ const expSec = nowSec() + 30 * 60;
57
+ const result = jwtExpiryMs(makeJwt({ exp: expSec }))!;
58
+ const minsLeft = Math.floor((result - Date.now()) / 60_000);
59
+ expect(minsLeft).toBeGreaterThanOrEqual(29);
60
+ expect(minsLeft).toBeLessThanOrEqual(30);
61
+ });
62
+
63
+ it('ignores extra claims', () => {
64
+ const expSec = nowSec() + 3600;
65
+ expect(jwtExpiryMs(makeJwt({ sub: 'u', iat: nowSec(), exp: expSec, plan: 'free' }))).toBe(expSec * 1000);
66
+ });
67
+
68
+ it('handles padding-free base64url payloads', () => {
69
+ // base64url (no =) — Buffer.from handles this natively
70
+ const expSec = nowSec() + 7200;
71
+ expect(jwtExpiryMs(makeJwt({ exp: expSec, x: 'a' }))).toBe(expSec * 1000);
72
+ });
73
+ });
74
+
75
+ describe('checkJwt', () => {
76
+ it('throws InvalidRequest when jwt is empty', () => {
77
+ expect(() => checkJwt('')).toThrowError(
78
+ expect.objectContaining({ code: ErrorCode.InvalidRequest, message: expect.stringContaining('CACHLY_JWT env var not set') }),
79
+ );
80
+ });
81
+
82
+ it('throws with setup URL when jwt is missing', () => {
83
+ expect(() => checkJwt('')).toThrowError(
84
+ expect.objectContaining({ message: expect.stringContaining('cachly.dev/setup-ai') }),
85
+ );
86
+ });
87
+
88
+ it('throws InvalidRequest with expiry timestamp for an expired token', () => {
89
+ const expSec = nowSec() - 60;
90
+ const token = makeJwt({ exp: expSec });
91
+ expect(() => checkJwt(token)).toThrowError(
92
+ expect.objectContaining({
93
+ code: ErrorCode.InvalidRequest,
94
+ message: expect.stringContaining('expired at'),
95
+ }),
96
+ );
97
+ });
98
+
99
+ it('does not throw for a valid non-expired token', () => {
100
+ expect(() => checkJwt(makeJwt({ exp: nowSec() + 3600 }))).not.toThrow();
101
+ });
102
+
103
+ it('does not throw for a token with no exp claim', () => {
104
+ expect(() => checkJwt(makeJwt({ sub: 'user' }))).not.toThrow();
105
+ });
106
+ });
107
+
108
+ describe('handleApiError', () => {
109
+ it('throws InvalidRequest for 401 with the detail message', () => {
110
+ expect(() => handleApiError(401, 'token expired')).toThrowError(
111
+ expect.objectContaining({
112
+ code: ErrorCode.InvalidRequest,
113
+ message: expect.stringContaining('Authentication failed (401): token expired'),
114
+ }),
115
+ );
116
+ });
117
+
118
+ it('throws InvalidRequest for 403 with the detail message', () => {
119
+ expect(() => handleApiError(403, 'plan limit reached')).toThrowError(
120
+ expect.objectContaining({
121
+ code: ErrorCode.InvalidRequest,
122
+ message: expect.stringContaining('Access denied (403): plan limit reached'),
123
+ }),
124
+ );
125
+ });
126
+
127
+ it('throws InternalError for other status codes', () => {
128
+ expect(() => handleApiError(500, 'server crash')).toThrowError(
129
+ expect.objectContaining({
130
+ code: ErrorCode.InternalError,
131
+ message: expect.stringContaining('cachly API error 500: server crash'),
132
+ }),
133
+ );
134
+ });
135
+
136
+ it('includes the cachly.dev/setup-ai link for 401 errors', () => {
137
+ expect(() => handleApiError(401, 'Unauthorized')).toThrowError(
138
+ expect.objectContaining({ message: expect.stringContaining('cachly.dev/setup-ai') }),
139
+ );
140
+ });
141
+
142
+ it('includes account access hint for 403 errors', () => {
143
+ expect(() => handleApiError(403, 'Forbidden')).toThrowError(
144
+ expect.objectContaining({ message: expect.stringContaining('access to this resource') }),
145
+ );
146
+ });
147
+ });
@@ -12,13 +12,15 @@ import { computeEmbedding, hasEmbedProvider, embedProviderHint, EMBED_PROVIDER }
12
12
  import { detectNamespace } from '../namespace.js';
13
13
 
14
14
  // ── Changelog (shown once per version in session_start) ──────────────────────
15
- const MCP_VERSION = '0.10.10';
15
+ const MCP_VERSION = '0.10.0';
16
16
  const WHATS_NEW: Record<string, string[]> = {
17
- '0.10.10': [
17
+ '0.10.0': [
18
18
  `🆕 **What's new in v${MCP_VERSION}:**`,
19
- ` 📌 **pin_lesson / unpin_lesson** — Pin critical lessons to always show in session_start`,
20
- ` 📊 **brain_stats** — At-a-glance health dashboard (lessons, streak, roadmap, pinned, top topics)`,
21
- ` ✅ Pinned lessons appear at top of every session briefing — never decay, never forgotten`,
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`,
22
24
  ],
23
25
  };
24
26
 
@@ -340,20 +342,6 @@ export async function handleBrainTool(
340
342
  }
341
343
  } catch { /* CKG updates are non-critical */ }
342
344
 
343
- // ── Auto-pin critical lessons ──────────────────────────────────────────
344
- // severity=critical → always pin automatically so the lesson
345
- // appears in every session_start without the user ever calling pin_lesson.
346
- let autoPinNote = '';
347
- if (severity === 'critical' && (outcome === 'success' || outcome === 'partial')) {
348
- try {
349
- const alreadyPinned = await redis.exists(`cachly:lesson:pin:${topic}`);
350
- if (!alreadyPinned) {
351
- await redis.set(`cachly:lesson:pin:${topic}`, '1');
352
- autoPinNote = `📌 Auto-pinned (critical severity) — will appear in every session_start.`;
353
- }
354
- } catch { /* non-critical */ }
355
- }
356
-
357
345
  const emoji = outcome === 'success' ? '✅' : outcome === 'partial' ? '⚠️' : '❌';
358
346
  const sevEmoji = severity === 'critical' ? '🔴' : severity === 'major' ? '🟡' : '🟢';
359
347
  const action = isUpdate ? 'updated' : 'stored';
@@ -374,7 +362,6 @@ export async function handleBrainTool(
374
362
  depends_on.length > 0
375
363
  ? `🔗 Depends on: ${depends_on.map(d => `\`${d}\``).join(', ')} → trace with \`trace_dependency\``
376
364
  : '',
377
- autoPinNote,
378
365
  ...templateWarnings,
379
366
  ...iWasWrongWarning,
380
367
  ].filter(l => l !== '').join('\n');
@@ -946,35 +933,6 @@ export async function handleBrainTool(
946
933
  lines.push('');
947
934
  }
948
935
 
949
- // ── Pinned lessons — always shown regardless of age/confidence ───────────
950
- try {
951
- const pinKeys: string[] = [];
952
- const pinStream = redis.scanStream({ match: 'cachly:lesson:pin:*', count: 100 });
953
- await new Promise<void>((res, rej) => {
954
- pinStream.on('data', (b: string[]) => pinKeys.push(...b));
955
- pinStream.on('end', res);
956
- pinStream.on('error', rej);
957
- });
958
- if (pinKeys.length > 0) {
959
- const pinnedTopics = pinKeys.map(k => k.replace('cachly:lesson:pin:', ''));
960
- const pinLessonRaws = await redis.mget(...pinnedTopics.map(t => `cachly:lesson:best:${t}`));
961
- const pinLines: string[] = [];
962
- for (const raw of pinLessonRaws) {
963
- if (!raw) continue;
964
- const l = safeJsonParse<{ topic: string; outcome: string; what_worked: string; severity?: string } | null>(raw, null);
965
- if (!l) continue;
966
- const emoji = l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
967
- const sev = l.severity === 'critical' ? '🔴' : l.severity === 'major' ? '🟡' : '';
968
- pinLines.push(` 📌${emoji}${sev} \`${l.topic}\` — ${l.what_worked.slice(0, 100)}`);
969
- }
970
- if (pinLines.length > 0) {
971
- lines.push(`📌 **Pinned lessons (always shown):**`);
972
- lines.push(...pinLines);
973
- lines.push('');
974
- }
975
- }
976
- } catch { /* non-critical */ }
977
-
978
936
  // Recent lessons
979
937
  if (lessons.length > 0) {
980
938
  lines.push(`🕐 **Recent lessons:**`);
@@ -1461,20 +1419,6 @@ export async function handleBrainTool(
1461
1419
  }
1462
1420
 
1463
1421
  const durationStr = durationMin !== undefined ? ` · ${durationMin} min` : '';
1464
-
1465
- // ── Mini brain-stats snapshot at session end ───────────────────────────
1466
- let miniStatsLines: string[] = [];
1467
- try {
1468
- const totalLessons = await redis.keys('cachly:lesson:best:*').then(k => k.length).catch(() => 0);
1469
- const pinCount = await redis.keys('cachly:lesson:pin:*').then(k => k.length).catch(() => 0);
1470
- const streakRaw = await redis.get('cachly:streak:current');
1471
- const streak = streakRaw ? (JSON.parse(streakRaw) as { days: number }).days : 0;
1472
- const parts: string[] = [`📚 ${totalLessons} lesson${totalLessons !== 1 ? 's' : ''} total`];
1473
- if (pinCount > 0) parts.push(`📌 ${pinCount} pinned`);
1474
- if (streak > 1) parts.push(`🔥 ${streak}-day streak`);
1475
- miniStatsLines = [`🧠 **Brain:** ${parts.join(' · ')}`];
1476
- } catch { /* non-critical */ }
1477
-
1478
1422
  return [
1479
1423
  `✅ **Session saved**${durationStr}`,
1480
1424
  ``,
@@ -1483,7 +1427,6 @@ export async function handleBrainTool(
1483
1427
  lessons_learned !== undefined ? `🧠 **Lessons stored:** ${lessons_learned}` : '',
1484
1428
  autoLearned.length > 0 ? `🤖 **Auto-learned:** ${autoLearned.length} lessons extracted from summary (${autoLearned.slice(0, 3).map(t => `\`${t}\``).join(', ')}${autoLearned.length > 3 ? '…' : ''})` : '',
1485
1429
  ambientLearned.length > 0 ? `🌿 **Ambient git learning:** ${ambientLearned.length} commit${ambientLearned.length > 1 ? 's' : ''} auto-learned (${ambientLearned.slice(0, 3).map(t => `\`${t}\``).join(', ')}${ambientLearned.length > 3 ? '…' : ''})` : '',
1486
- ...miniStatsLines,
1487
1430
  ``,
1488
1431
  `💡 Next session: \`session_start(focus="...")\` to see this summary.`,
1489
1432
  ].filter(l => l !== '').join('\n');
@@ -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('');
@@ -884,8 +893,8 @@ export async function handleFedbrainTool(
884
893
 
885
894
  // ── brain_from_git ────────────────────────────────────────────────────────
886
895
  case 'brain_from_git': {
887
- const { instance_id, repo_path = '.', limit = 100, branch = 'HEAD', since = '' } = args as {
888
- instance_id: string; repo_path?: string; limit?: number; branch?: string; since?: string;
896
+ const { instance_id, repo_path = '.', limit = 100, branch = 'HEAD', since = '', incremental = true } = args as {
897
+ instance_id: string; repo_path?: string; limit?: number; branch?: string; since?: string; incremental?: boolean;
889
898
  };
890
899
  const redis = await getConnection(instance_id);
891
900
  const { execSync } = await import('node:child_process');
@@ -904,9 +913,21 @@ export async function handleFedbrainTool(
904
913
  return `❌ Not a git repository: \`${repoDir}\`. Pass \`repo_path\` pointing to a git checkout.`;
905
914
  }
906
915
 
916
+ // Incremental mode: resume from last processed commit SHA
917
+ const lastShaKey = `cachly:brain_from_git:last_sha:${Buffer.from(repoDir).toString('base64').slice(0, 32)}`;
918
+ let lastSha = '';
919
+ let isIncremental = false;
920
+ if (incremental !== false && !since) {
921
+ lastSha = (await redis.get(lastShaKey)) ?? '';
922
+ isIncremental = Boolean(lastSha);
923
+ }
924
+
907
925
  // Build git log command
926
+ // In incremental mode, only fetch commits after the last processed SHA
908
927
  const sinceFlag = since ? `--since="${since}"` : '';
909
- const logCmd = `git log ${branch} ${sinceFlag} --pretty=format:"%H|||%s|||%ad|||%an" --date=short --no-merges -n ${maxCommits}`;
928
+ const afterFlag = isIncremental ? `${lastSha}..HEAD` : '';
929
+ const revRange = afterFlag || branch;
930
+ const logCmd = `git log ${revRange} ${sinceFlag} --pretty=format:"%H|||%s|||%ad|||%an" --date=short --no-merges -n ${maxCommits}`;
910
931
 
911
932
  let logOutput = '';
912
933
  try {
@@ -921,6 +942,9 @@ export async function handleFedbrainTool(
921
942
  });
922
943
 
923
944
  if (commits.length === 0) {
945
+ if (isIncremental) {
946
+ return `✅ brain_from_git: Brain is up to date — no new commits since last run (last SHA: \`${lastSha.slice(0, 8)}\`).`;
947
+ }
924
948
  return `⚠️ No commits found in \`${repoDir}\` on branch \`${branch}\`${since ? ` since ${since}` : ''}.`;
925
949
  }
926
950
 
@@ -951,8 +975,21 @@ export async function handleFedbrainTool(
951
975
  let ingested = 0;
952
976
  let skipped = 0;
953
977
  const categoryCount = new Map<string, number>();
978
+ const total = commits.length;
979
+ const progressInterval = Math.max(1, Math.floor(total / 10)); // emit ~10 progress updates
980
+ let lastProgressAt = 0;
981
+
982
+ process.stderr.write(`\n🧠 brain_from_git: processing ${total} commits from ${repoDir}...\n`);
983
+
984
+ for (let i = 0; i < commits.length; i++) {
985
+ const commit = commits[i]!;
986
+
987
+ // Emit progress to stderr so editors can display it in MCP logs
988
+ if (i - lastProgressAt >= progressInterval) {
989
+ process.stderr.write(` ⏳ Processing ${i}/${total} commits (${ingested} lessons so far)...\n`);
990
+ lastProgressAt = i;
991
+ }
954
992
 
955
- for (const commit of commits) {
956
993
  if (!commit.subject) { skipped++; continue; }
957
994
  const { category, outcome, severity } = classifyCommit(commit.subject);
958
995
  const domain = extractDomain(commit.subject);
@@ -987,11 +1024,20 @@ export async function handleFedbrainTool(
987
1024
  ingested++;
988
1025
  }
989
1026
 
1027
+ process.stderr.write(` ✅ brain_from_git complete: ${ingested}/${total} commits ingested\n\n`);
1028
+
1029
+ // Save the latest commit SHA for incremental runs
1030
+ if (commits.length > 0 && commits[0]?.sha) {
1031
+ await redis.set(lastShaKey, commits[0].sha, 'EX', 90 * 24 * 3600); // expire after 90 days
1032
+ }
1033
+
990
1034
  const categoryBreakdown = [...categoryCount.entries()].sort((a, b) => b[1] - a[1]).map(([k, v]) => ` • **${k}** (${v})`).join('\n');
991
1035
 
992
1036
  return [
993
- `🔁 **brain_from_git: ${repoDir}**`, '',
994
- `📂 Branch: \`${branch}\` | Processed: **${commits.length}** commits | Ingested: **${ingested}** lessons | Skipped: ${skipped}`,
1037
+ `🔁 **brain_from_git: ${repoDir}**`,
1038
+ isIncremental ? `🔄 Incremental mode — only new commits since \`${lastSha.slice(0, 8)}\` were processed` : '',
1039
+ ``,
1040
+ `📂 Branch: \`${revRange}\` | Processed: **${commits.length}** commits | Ingested: **${ingested}** lessons | Skipped: ${skipped}`,
995
1041
  ``,
996
1042
  `**Breakdown by category:**`,
997
1043
  categoryBreakdown,
@@ -999,7 +1045,8 @@ export async function handleFedbrainTool(
999
1045
  `💡 New lessons are stored with confidence 0.55 (auto-inferred).`,
1000
1046
  `💡 As you confirm them via \`learn_from_attempts\`, confidence rises automatically.`,
1001
1047
  `🔍 Explore: \`brain_search(query="fix")\` | \`ckg_inspect(concept="deploy")\``,
1002
- ].join('\n');
1048
+ `💾 Next run will automatically continue from the latest commit (incremental mode).`,
1049
+ ].filter(Boolean).join('\n');
1003
1050
  } finally {
1004
1051
  _gitSemRelease();
1005
1052
  }
@@ -1026,12 +1073,14 @@ export async function handleFedbrainTool(
1026
1073
  for (const nk of nodeKeys.slice(0, 5)) {
1027
1074
  const nodeRaw = await redis.get(nk);
1028
1075
  if (!nodeRaw) continue;
1029
- const node: CKGNode = JSON.parse(nodeRaw);
1076
+ const node = safeJsonParse<CKGNode | null>(nodeRaw, null);
1077
+ if (!node) continue;
1030
1078
  const edgeKeys = await redis.smembers(`cachly:ckg:idx:from:${node.id}`);
1031
1079
  for (const ek of edgeKeys.slice(0, 20)) {
1032
1080
  const edgeRaw = await redis.get(ek);
1033
1081
  if (!edgeRaw) continue;
1034
- const edge: CKGEdge = JSON.parse(edgeRaw);
1082
+ const edge = safeJsonParse<CKGEdge | null>(edgeRaw, null);
1083
+ if (!edge) continue;
1035
1084
  if (edge.edgeType !== 'causes' && edge.edgeType !== 'degrades_under') continue;
1036
1085
 
1037
1086
  // Look up fix for this failure from CKG 'fixes' edges
@@ -1040,12 +1089,13 @@ export async function handleFedbrainTool(
1040
1089
  for (const fek of fixEdgeKeys.slice(0, 10)) {
1041
1090
  const feRaw = await redis.get(fek);
1042
1091
  if (!feRaw) continue;
1043
- const fe: CKGEdge = JSON.parse(feRaw);
1092
+ const fe = safeJsonParse<CKGEdge | null>(feRaw, null);
1093
+ if (!fe) continue;
1044
1094
  if (fe.edgeType === 'fixes') {
1045
1095
  const lessonRaw = await redis.get(`cachly:lesson:best:${fe.from}`);
1046
1096
  if (lessonRaw) {
1047
- const lesson = JSON.parse(lessonRaw) as { what_worked?: string };
1048
- fix = lesson.what_worked?.slice(0, 120);
1097
+ const lesson = safeJsonParse(lessonRaw, null as null | { what_worked?: string });
1098
+ fix = lesson?.what_worked?.slice(0, 120);
1049
1099
  break;
1050
1100
  }
1051
1101
  }
@@ -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