@cachly-dev/mcp-server 0.10.0 → 0.10.1
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.
- package/CHANGELOG.md +42 -0
- package/README.md +170 -221
- package/dist/__tests__/brain-predict-failures.test.d.ts +17 -0
- package/dist/__tests__/brain-predict-failures.test.js +339 -0
- package/dist/__tests__/brain-predict-failures.test.js.map +1 -0
- package/dist/handlers/brain.js +33 -93
- package/dist/handlers/brain.js.map +1 -1
- package/dist/handlers/fedbrain.js +38 -55
- package/dist/handlers/fedbrain.js.map +1 -1
- package/dist/handlers/team.js +50 -62
- package/dist/handlers/team.js.map +1 -1
- package/dist/index.js +28 -147
- package/dist/index.js.map +1 -1
- package/dist/tools.js +4 -5
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/brain-predict-failures.test.ts +430 -0
- package/src/handlers/brain.ts +27 -89
- package/src/handlers/fedbrain.ts +31 -43
- package/src/handlers/team.ts +33 -57
- package/src/index.ts +31 -150
- package/src/tools.ts +4 -5
- package/.github/workflows/publish.yml +0 -42
- package/LICENSE +0 -202
- package/dist/utils.d.ts +0 -1
- package/dist/utils.js +0 -13
- package/dist/utils.js.map +0 -1
- package/glama.json +0 -6
- package/src/utils.ts +0 -10
package/src/handlers/fedbrain.ts
CHANGED
|
@@ -6,7 +6,6 @@ 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';
|
|
10
9
|
import { computeEmbedding, hasEmbedProvider } from '../embeddings.js';
|
|
11
10
|
|
|
12
11
|
// Git concurrency semaphore (for brain_from_git parallel workers)
|
|
@@ -398,8 +397,7 @@ export async function handleFedbrainTool(
|
|
|
398
397
|
const raw = await redis.get(`cachly:lesson:best:${lesson_key}`);
|
|
399
398
|
if (!raw) return `❌ Lesson \`${lesson_key}\` not found. Store it first with \`learn_from_attempts\`.`;
|
|
400
399
|
|
|
401
|
-
const lesson =
|
|
402
|
-
if (!lesson) return `❌ Lesson \`${lesson_key}\` data is corrupted. Re-store with \`learn_from_attempts\`.`;
|
|
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 };
|
|
403
401
|
|
|
404
402
|
const domainTokens = [lesson.topic.split(':')[0], ...(lesson.tags ?? [])].filter(Boolean);
|
|
405
403
|
const domainFingerprint = [...new Set(domainTokens)].sort().join(',');
|
|
@@ -749,8 +747,7 @@ export async function handleFedbrainTool(
|
|
|
749
747
|
}
|
|
750
748
|
|
|
751
749
|
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 };
|
|
752
|
-
const crystal
|
|
753
|
-
if (!crystal) return `❌ Memory Crystal data is corrupted. Re-create with \`memory_crystalize()\`.`;
|
|
750
|
+
const crystal: Crystal = JSON.parse(raw);
|
|
754
751
|
const age = Math.floor((Date.now() - new Date(crystal.ts).getTime()) / 86400000);
|
|
755
752
|
const freshEmoji = age <= 7 ? '🟢' : age <= 30 ? '🟡' : '🔴';
|
|
756
753
|
|
|
@@ -787,55 +784,49 @@ export async function handleFedbrainTool(
|
|
|
787
784
|
const crystalRaw = await redis.get('cachly:crystal:latest');
|
|
788
785
|
if (crystalRaw) {
|
|
789
786
|
type Crystal = { label: string; ts: string; session_count: number; lesson_count: number; top_patterns: Array<{ category: string; insight: string; count: number }> };
|
|
790
|
-
const crystal
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
lines.push('');
|
|
799
|
-
}
|
|
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('');
|
|
800
795
|
}
|
|
801
796
|
|
|
802
797
|
// 2. Last session summary
|
|
803
798
|
const lastSession = await redis.get('cachly:session:last');
|
|
804
799
|
if (lastSession) {
|
|
805
800
|
type Session = { summary?: string; ts?: string; focus?: string };
|
|
806
|
-
const sess
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
lines.push('');
|
|
812
|
-
}
|
|
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('');
|
|
813
806
|
}
|
|
814
807
|
|
|
815
808
|
// 3. Session handoff
|
|
816
809
|
const handoff = await redis.get('cachly:session:handoff');
|
|
817
810
|
if (handoff) {
|
|
818
811
|
type Handoff = { remaining_tasks?: string[]; instructions?: string; context_summary?: string; blocked_on?: string };
|
|
819
|
-
const h
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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('');
|
|
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}`);
|
|
830
818
|
}
|
|
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('');
|
|
831
822
|
}
|
|
832
823
|
|
|
833
824
|
// 4. WIP registry
|
|
834
825
|
const wipRaw = await redis.get('cachly:ctx:wip-registry');
|
|
835
826
|
if (wipRaw) {
|
|
836
827
|
type Ctx = { content?: string };
|
|
837
|
-
const wip
|
|
838
|
-
if (wip
|
|
828
|
+
const wip: Ctx = JSON.parse(wipRaw);
|
|
829
|
+
if (wip.content) {
|
|
839
830
|
lines.push(`### 🔧 WIP Registry`);
|
|
840
831
|
lines.push(wip.content.slice(0, 400));
|
|
841
832
|
lines.push('');
|
|
@@ -1035,14 +1026,12 @@ export async function handleFedbrainTool(
|
|
|
1035
1026
|
for (const nk of nodeKeys.slice(0, 5)) {
|
|
1036
1027
|
const nodeRaw = await redis.get(nk);
|
|
1037
1028
|
if (!nodeRaw) continue;
|
|
1038
|
-
const node
|
|
1039
|
-
if (!node) continue;
|
|
1029
|
+
const node: CKGNode = JSON.parse(nodeRaw);
|
|
1040
1030
|
const edgeKeys = await redis.smembers(`cachly:ckg:idx:from:${node.id}`);
|
|
1041
1031
|
for (const ek of edgeKeys.slice(0, 20)) {
|
|
1042
1032
|
const edgeRaw = await redis.get(ek);
|
|
1043
1033
|
if (!edgeRaw) continue;
|
|
1044
|
-
const edge
|
|
1045
|
-
if (!edge) continue;
|
|
1034
|
+
const edge: CKGEdge = JSON.parse(edgeRaw);
|
|
1046
1035
|
if (edge.edgeType !== 'causes' && edge.edgeType !== 'degrades_under') continue;
|
|
1047
1036
|
|
|
1048
1037
|
// Look up fix for this failure from CKG 'fixes' edges
|
|
@@ -1051,13 +1040,12 @@ export async function handleFedbrainTool(
|
|
|
1051
1040
|
for (const fek of fixEdgeKeys.slice(0, 10)) {
|
|
1052
1041
|
const feRaw = await redis.get(fek);
|
|
1053
1042
|
if (!feRaw) continue;
|
|
1054
|
-
const fe
|
|
1055
|
-
if (!fe) continue;
|
|
1043
|
+
const fe: CKGEdge = JSON.parse(feRaw);
|
|
1056
1044
|
if (fe.edgeType === 'fixes') {
|
|
1057
1045
|
const lessonRaw = await redis.get(`cachly:lesson:best:${fe.from}`);
|
|
1058
1046
|
if (lessonRaw) {
|
|
1059
|
-
const lesson =
|
|
1060
|
-
fix = lesson
|
|
1047
|
+
const lesson = JSON.parse(lessonRaw) as { what_worked?: string };
|
|
1048
|
+
fix = lesson.what_worked?.slice(0, 120);
|
|
1061
1049
|
break;
|
|
1062
1050
|
}
|
|
1063
1051
|
}
|
package/src/handlers/team.ts
CHANGED
|
@@ -5,7 +5,6 @@ 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';
|
|
9
8
|
|
|
10
9
|
type GetConnection = (instanceId: string) => Promise<Redis>;
|
|
11
10
|
type ApiFetch = <T>(path: string, options?: RequestInit) => Promise<T>;
|
|
@@ -54,11 +53,10 @@ export async function handleTeamTool(
|
|
|
54
53
|
|
|
55
54
|
type Lesson = { topic: string; what_worked: string; outcome: string; file_paths?: string[] };
|
|
56
55
|
const relevant: string[] = [];
|
|
57
|
-
const
|
|
58
|
-
|
|
56
|
+
for (const k of lessonKeys) {
|
|
57
|
+
const raw = await redis.get(k);
|
|
59
58
|
if (!raw) continue;
|
|
60
|
-
const lesson =
|
|
61
|
-
if (!lesson) continue;
|
|
59
|
+
const lesson = JSON.parse(raw) as Lesson;
|
|
62
60
|
// Match by file_paths stored in lesson OR by topic keywords matching file name
|
|
63
61
|
const topicWords = lesson.topic.toLowerCase().split(/[:\-_]/);
|
|
64
62
|
const fileMatches = changed_files.some(f => {
|
|
@@ -148,12 +146,10 @@ export async function handleTeamTool(
|
|
|
148
146
|
author?: string; tags?: string[];
|
|
149
147
|
};
|
|
150
148
|
let lessons: TeamLesson[] = [];
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (l) lessons.push(l);
|
|
156
|
-
}
|
|
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 */ }
|
|
157
153
|
}
|
|
158
154
|
|
|
159
155
|
// Filter
|
|
@@ -282,12 +278,10 @@ export async function handleTeamTool(
|
|
|
282
278
|
|
|
283
279
|
type RawLesson = { topic: string; outcome: string; what_worked: string; severity?: string; ts: string; auto_learned?: boolean };
|
|
284
280
|
const allLessons: RawLesson[] = [];
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
if (l) allLessons.push(l);
|
|
290
|
-
}
|
|
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 */ }
|
|
291
285
|
}
|
|
292
286
|
|
|
293
287
|
// Group lessons by top-level category
|
|
@@ -373,17 +367,18 @@ export async function handleTeamTool(
|
|
|
373
367
|
verified_at?: string; severity?: string; audit_trail?: unknown[];
|
|
374
368
|
};
|
|
375
369
|
const lessons: DrLesson[] = [];
|
|
376
|
-
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
if (l) lessons.push(l);
|
|
381
|
-
}
|
|
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 */ }
|
|
382
374
|
}
|
|
383
375
|
|
|
384
376
|
// Last session
|
|
385
377
|
const lastSessionRaw = await redis.get('cachly:session:last');
|
|
386
|
-
|
|
378
|
+
let lastSession: { ts: string; summary: string } | null = null;
|
|
379
|
+
if (lastSessionRaw) {
|
|
380
|
+
try { lastSession = JSON.parse(lastSessionRaw); } catch { /* ignore */ }
|
|
381
|
+
}
|
|
387
382
|
|
|
388
383
|
// Open failures
|
|
389
384
|
const openFailures = lessons.filter(l => l.outcome === 'failure' || l.outcome === 'partial');
|
|
@@ -514,24 +509,13 @@ export async function handleTeamTool(
|
|
|
514
509
|
issues.push(`💡 ${unusedLessons.length} lessons never recalled — verify topics match your workflow`);
|
|
515
510
|
}
|
|
516
511
|
|
|
517
|
-
const
|
|
518
|
-
const lines = [
|
|
519
|
-
`🩺 **Brain Doctor**`,
|
|
520
|
-
`\`${divider}\``,
|
|
521
|
-
'',
|
|
522
|
-
'**📊 Health Checks**',
|
|
523
|
-
...checks.map(c => ' ' + c),
|
|
524
|
-
'',
|
|
525
|
-
];
|
|
512
|
+
const lines = ['🩺 **Brain Doctor Report**', '', ...checks.map(c => ' ' + c), ''];
|
|
526
513
|
if (issues.length > 0) {
|
|
527
|
-
lines.push(
|
|
528
|
-
|
|
514
|
+
lines.push('**Issues to fix:**');
|
|
515
|
+
for (const i of issues) lines.push(' ' + i);
|
|
529
516
|
lines.push('');
|
|
530
|
-
lines.push(`_Fix the action items above, then re-run \`brain_doctor\` to verify._`);
|
|
531
517
|
} else {
|
|
532
|
-
lines.push('
|
|
533
|
-
lines.push('');
|
|
534
|
-
lines.push('_Keep the brain sharp: `session_start` → work → `learn_from_attempts` → `session_end`_');
|
|
518
|
+
lines.push(' 🎉 Brain looks healthy! Keep calling session_start/session_end.');
|
|
535
519
|
}
|
|
536
520
|
return lines.join('\n');
|
|
537
521
|
}
|
|
@@ -552,11 +536,7 @@ export async function handleTeamTool(
|
|
|
552
536
|
.filter((l): l is NonNullable<typeof l> => l !== null && new Date(l.ts).getTime() <= cutoff)
|
|
553
537
|
.sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
|
|
554
538
|
|
|
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
|
-
}
|
|
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')}.`;
|
|
560
540
|
|
|
561
541
|
const lines = [
|
|
562
542
|
`🏺 **Brain Archaeology: \`${topic}\` before ${date}**`,
|
|
@@ -583,7 +563,7 @@ export async function handleTeamTool(
|
|
|
583
563
|
const raw = await redis.get(depKey);
|
|
584
564
|
if (!raw) return `📭 No lessons found that depend on \`${dependency}\`.\n\nAdd dependencies via: \`learn_from_attempts(..., depends_on=["${dependency}"])\``;
|
|
585
565
|
|
|
586
|
-
const topics: string[] =
|
|
566
|
+
const topics: string[] = JSON.parse(raw);
|
|
587
567
|
const lines = [
|
|
588
568
|
`🔗 **Causal Chain: \`${dependency}\`** — ${topics.length} dependent lesson${topics.length === 1 ? '' : 's'}`,
|
|
589
569
|
'',
|
|
@@ -592,8 +572,7 @@ export async function handleTeamTool(
|
|
|
592
572
|
for (const t of topics) {
|
|
593
573
|
const lessonRaw = await redis.get(`cachly:lesson:best:${t}`);
|
|
594
574
|
if (!lessonRaw) { lines.push(` • \`${t}\` _(lesson deleted)_`); continue; }
|
|
595
|
-
const lesson =
|
|
596
|
-
if (!lesson) { lines.push(` • \`${t}\` _(lesson corrupted)_`); continue; }
|
|
575
|
+
const lesson = JSON.parse(lessonRaw) as { outcome?: string; severity?: string; needs_review?: boolean };
|
|
597
576
|
const emoji = lesson.outcome === 'success' ? '✅' : lesson.outcome === 'partial' ? '⚠️' : '❌';
|
|
598
577
|
const reviewBadge = lesson.needs_review ? ' 🔍 **needs_review**' : '';
|
|
599
578
|
lines.push(` ${emoji} \`${t}\` (${lesson.severity ?? 'major'})${reviewBadge}`);
|
|
@@ -635,8 +614,8 @@ export async function handleTeamTool(
|
|
|
635
614
|
// Preserve recall_count on update
|
|
636
615
|
const existing = await redis.get(key);
|
|
637
616
|
if (existing) {
|
|
638
|
-
const prev =
|
|
639
|
-
record.recall_count = prev
|
|
617
|
+
const prev = JSON.parse(existing) as { recall_count?: number };
|
|
618
|
+
record.recall_count = prev.recall_count ?? 0;
|
|
640
619
|
}
|
|
641
620
|
await redis.set(key, JSON.stringify(record));
|
|
642
621
|
return `🌐 **Global lesson stored**: \`${topic}\`\n\n${lesson}\n\nRecallable from any project via \`global_recall(topic="${topic}")\`.`;
|
|
@@ -656,12 +635,10 @@ export async function handleTeamTool(
|
|
|
656
635
|
|
|
657
636
|
type GlobalLesson = { topic: string; lesson: string; severity?: string; ts: string; recall_count?: number };
|
|
658
637
|
let lessons: GlobalLesson[] = [];
|
|
659
|
-
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
if (l) lessons.push(l);
|
|
664
|
-
}
|
|
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 */ }
|
|
665
642
|
}
|
|
666
643
|
|
|
667
644
|
if (topic) {
|
|
@@ -678,8 +655,7 @@ export async function handleTeamTool(
|
|
|
678
655
|
const k = `cachly:global:lesson:${l.topic}`;
|
|
679
656
|
const raw = await redis.get(k);
|
|
680
657
|
if (raw) {
|
|
681
|
-
const rec =
|
|
682
|
-
if (!rec) continue;
|
|
658
|
+
const rec = JSON.parse(raw) as { recall_count?: number };
|
|
683
659
|
rec.recall_count = (rec.recall_count ?? 0) + 1;
|
|
684
660
|
await redis.set(k, JSON.stringify(rec));
|
|
685
661
|
}
|
package/src/index.ts
CHANGED
|
@@ -52,22 +52,19 @@ import { Redis } from 'ioredis';
|
|
|
52
52
|
const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
|
|
53
53
|
let JWT = process.env.CACHLY_JWT ?? '';
|
|
54
54
|
const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
|
|
55
|
-
const CURRENT_VERSION = '0.
|
|
55
|
+
const CURRENT_VERSION = '0.9.17';
|
|
56
56
|
|
|
57
57
|
// ── Default Instance Resolution (for Smithery & single-credential setups) ────
|
|
58
58
|
// When CACHLY_BRAIN_INSTANCE_ID is set, tools can omit the instance_id parameter.
|
|
59
59
|
// When neither is set, we auto-fetch the first running instance once per process.
|
|
60
60
|
let _defaultInstanceId: string = process.env.CACHLY_BRAIN_INSTANCE_ID ?? '';
|
|
61
|
-
|
|
62
|
-
let _defaultInstanceLastAttempt = 0;
|
|
61
|
+
let _defaultInstanceFetched = false;
|
|
63
62
|
|
|
64
63
|
async function resolveDefaultInstanceId(): Promise<string> {
|
|
65
64
|
if (_defaultInstanceId) return _defaultInstanceId;
|
|
65
|
+
if (_defaultInstanceFetched) return '';
|
|
66
|
+
_defaultInstanceFetched = true;
|
|
66
67
|
if (!JWT) return '';
|
|
67
|
-
// Cooldown: don't hammer the API on every tool call after a transient failure.
|
|
68
|
-
const now = Date.now();
|
|
69
|
-
if (_defaultInstanceLastAttempt > 0 && now - _defaultInstanceLastAttempt < 30_000) return '';
|
|
70
|
-
_defaultInstanceLastAttempt = now;
|
|
71
68
|
try {
|
|
72
69
|
const res = await fetch(`${API_URL}/api/v1/instances`, {
|
|
73
70
|
headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
|
|
@@ -75,15 +72,9 @@ async function resolveDefaultInstanceId(): Promise<string> {
|
|
|
75
72
|
});
|
|
76
73
|
if (!res.ok) return '';
|
|
77
74
|
const data = await res.json() as { data?: Array<{ id: string; status: string }> };
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (best) {
|
|
82
|
-
_defaultInstanceId = best.id;
|
|
83
|
-
_defaultInstanceLastAttempt = 0;
|
|
84
|
-
return _defaultInstanceId;
|
|
85
|
-
}
|
|
86
|
-
} catch { /* transient error — will retry after cooldown */ }
|
|
75
|
+
const running = (data?.data ?? []).filter(i => i.status === 'running');
|
|
76
|
+
if (running.length > 0) { _defaultInstanceId = running[0].id; return _defaultInstanceId; }
|
|
77
|
+
} catch { /* non-fatal — caller will surface missing instance_id error */ }
|
|
87
78
|
return '';
|
|
88
79
|
}
|
|
89
80
|
|
|
@@ -158,55 +149,24 @@ async function pollDeviceFlow(flow: DeviceFlowState): Promise<'pending' | 'expir
|
|
|
158
149
|
JWT = apiKey;
|
|
159
150
|
setEmbedJwt(apiKey); // keep embeddings.ts in sync
|
|
160
151
|
_deviceFlow = null;
|
|
161
|
-
// Auto-provision
|
|
162
|
-
|
|
152
|
+
// Auto-provision instance
|
|
153
|
+
_defaultInstanceFetched = false;
|
|
163
154
|
await resolveDefaultInstanceId();
|
|
164
155
|
if (!_defaultInstanceId) {
|
|
156
|
+
// Try auto-provision
|
|
165
157
|
try {
|
|
166
158
|
const autoRes = await fetch(`${API_URL}/api/v1/instances/auto`, {
|
|
167
159
|
method: 'POST',
|
|
168
160
|
headers: { Authorization: `Bearer ${JWT}`, 'Content-Type': 'application/json' },
|
|
169
|
-
signal: AbortSignal.timeout(
|
|
161
|
+
signal: AbortSignal.timeout(10000),
|
|
170
162
|
});
|
|
171
163
|
if (autoRes.ok) {
|
|
172
|
-
const body = await autoRes.json() as {
|
|
173
|
-
instance?: { id: string; status?: string };
|
|
174
|
-
instance_id?: string;
|
|
175
|
-
status?: string;
|
|
176
|
-
};
|
|
164
|
+
const body = await autoRes.json() as { instance?: { id: string }; instance_id?: string };
|
|
177
165
|
const id = body.instance?.id ?? body.instance_id;
|
|
178
166
|
if (id) _defaultInstanceId = id;
|
|
179
167
|
}
|
|
180
168
|
} catch { /* non-fatal */ }
|
|
181
169
|
}
|
|
182
|
-
// Give a provisioning instance a head-start so getConnection's wait is shorter.
|
|
183
|
-
// The instance is almost always ready by the time the user's tool re-enters.
|
|
184
|
-
if (_defaultInstanceId) {
|
|
185
|
-
try {
|
|
186
|
-
const checkRes = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
|
|
187
|
-
headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
|
|
188
|
-
signal: AbortSignal.timeout(4000),
|
|
189
|
-
});
|
|
190
|
-
if (checkRes.ok) {
|
|
191
|
-
const checkInst = await checkRes.json() as { status?: string };
|
|
192
|
-
if (checkInst.status === 'provisioning') {
|
|
193
|
-
// Wait up to 20s here so the re-entered tool call lands on a running instance.
|
|
194
|
-
const pDeadline = Date.now() + 20_000;
|
|
195
|
-
while (checkInst.status === 'provisioning' && Date.now() < pDeadline) {
|
|
196
|
-
await new Promise(r => setTimeout(r, 3000));
|
|
197
|
-
const rr = await fetch(`${API_URL}/api/v1/instances/${_defaultInstanceId}`, {
|
|
198
|
-
headers: { Authorization: `Bearer ${JWT}`, Accept: 'application/json' },
|
|
199
|
-
signal: AbortSignal.timeout(4000),
|
|
200
|
-
}).catch(() => null);
|
|
201
|
-
if (rr?.ok) {
|
|
202
|
-
const ri = await rr.json() as { status?: string };
|
|
203
|
-
(checkInst as { status?: string }).status = ri.status;
|
|
204
|
-
} else { break; }
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
} catch { /* non-fatal — getConnection will handle the wait if still provisioning */ }
|
|
209
|
-
}
|
|
210
170
|
return 'done';
|
|
211
171
|
}
|
|
212
172
|
if (data.error === 'slow_down') flow.pollInterval = Math.min(flow.pollInterval + 2000, 15000);
|
|
@@ -256,48 +216,18 @@ interface SemanticSearchResponse {
|
|
|
256
216
|
const pool = new Map<string, Redis>();
|
|
257
217
|
|
|
258
218
|
async function getConnection(instance_id: string): Promise<Redis> {
|
|
259
|
-
if (!instance_id) {
|
|
260
|
-
throw new McpError(
|
|
261
|
-
ErrorCode.InvalidRequest,
|
|
262
|
-
'No instance_id provided and no running instance could be resolved automatically.\n\n' +
|
|
263
|
-
'• Set CACHLY_BRAIN_INSTANCE_ID in your MCP config, or\n' +
|
|
264
|
-
'• Pass instance_id explicitly to the tool, or\n' +
|
|
265
|
-
'• Run `list_instances` to see your available instances.'
|
|
266
|
-
);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
219
|
if (pool.has(instance_id)) return pool.get(instance_id)!;
|
|
270
220
|
|
|
271
|
-
|
|
272
|
-
// This covers the zero-friction path: device-flow auth → auto-provision → first tool call
|
|
273
|
-
// all happen in quick succession and the instance isn't running yet.
|
|
274
|
-
let inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
|
|
275
|
-
if (inst.status === 'provisioning') {
|
|
276
|
-
const deadline = Date.now() + 25_000;
|
|
277
|
-
while (inst.status === 'provisioning' && Date.now() < deadline) {
|
|
278
|
-
await new Promise(r => setTimeout(r, 3000));
|
|
279
|
-
inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
221
|
+
const inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
|
|
283
222
|
if (inst.status !== 'running') {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
signal: AbortSignal.timeout(3000),
|
|
290
|
-
}).catch(() => {});
|
|
291
|
-
const hint = inst.status === 'provisioning'
|
|
292
|
-
? `⏳ Brain instance "${inst.name}" is still starting up.\n\nThis usually finishes within 30 seconds. Please try again in a moment.`
|
|
293
|
-
: `Brain instance "${inst.name}" is not reachable (status: ${inst.status}).\n\n` +
|
|
294
|
-
`• View your instance at: https://cachly.dev/instances\n` +
|
|
295
|
-
`• Run \`get_api_status\` for a full diagnostic.`;
|
|
296
|
-
throw new McpError(ErrorCode.InvalidRequest, hint);
|
|
223
|
+
throw new McpError(
|
|
224
|
+
ErrorCode.InvalidRequest,
|
|
225
|
+
`Instance "${inst.name}" is not running (status: ${inst.status}). ` +
|
|
226
|
+
`It may still be provisioning or awaiting payment.`
|
|
227
|
+
);
|
|
297
228
|
}
|
|
298
229
|
if (!inst.host || !inst.port) {
|
|
299
|
-
throw new McpError(ErrorCode.InternalError,
|
|
300
|
-
`Instance "${inst.name}" is running but has no host/port — contact support@cachly.dev.`);
|
|
230
|
+
throw new McpError(ErrorCode.InternalError, `Instance "${inst.name}" has no host/port yet.`);
|
|
301
231
|
}
|
|
302
232
|
|
|
303
233
|
// Fetch connection details (includes password) from the dedicated endpoint.
|
|
@@ -310,7 +240,7 @@ async function getConnection(instance_id: string): Promise<Redis> {
|
|
|
310
240
|
password = conn.password ?? undefined;
|
|
311
241
|
tlsEnabled = conn.tls_enabled !== false;
|
|
312
242
|
} catch {
|
|
313
|
-
// Fallback: use TLS
|
|
243
|
+
// Fallback: no password, use TLS default from instance
|
|
314
244
|
}
|
|
315
245
|
|
|
316
246
|
const client = new Redis({
|
|
@@ -318,33 +248,26 @@ async function getConnection(instance_id: string): Promise<Redis> {
|
|
|
318
248
|
port: inst.port,
|
|
319
249
|
password: password || undefined,
|
|
320
250
|
...(tlsEnabled ? { tls: {} } : {}),
|
|
321
|
-
lazyConnect: true,
|
|
251
|
+
lazyConnect: true, // don't auto-connect — we connect explicitly below
|
|
322
252
|
enableReadyCheck: true,
|
|
323
|
-
connectTimeout:
|
|
253
|
+
connectTimeout: 5000,
|
|
324
254
|
retryStrategy: () => null, // fail fast, no reconnect loops in MCP context
|
|
325
255
|
});
|
|
326
256
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
257
|
+
client.on('error', () => {
|
|
258
|
+
pool.delete(instance_id); // remove stale connection on error
|
|
259
|
+
});
|
|
330
260
|
|
|
261
|
+
// Connect explicitly so we can catch connection errors as a proper rejected
|
|
262
|
+
// Promise instead of an unhandled 'error' event that would kill the process.
|
|
331
263
|
try {
|
|
332
264
|
await client.connect();
|
|
333
265
|
} catch (err: unknown) {
|
|
334
266
|
client.disconnect();
|
|
335
267
|
const msg = err instanceof Error ? err.message : String(err);
|
|
336
|
-
void fetch(`${API_URL}/api/v1/telemetry/mcp`, {
|
|
337
|
-
method: 'POST',
|
|
338
|
-
headers: { 'Content-Type': 'application/json', ...(JWT ? { Authorization: `Bearer ${JWT}` } : {}) },
|
|
339
|
-
body: JSON.stringify({ event: 'redis_connect_failed', instance_id, host: inst.host, port: inst.port, error: msg, version: CURRENT_VERSION }),
|
|
340
|
-
signal: AbortSignal.timeout(3000),
|
|
341
|
-
}).catch(() => {});
|
|
342
268
|
throw new McpError(
|
|
343
269
|
ErrorCode.InternalError,
|
|
344
|
-
`Could not
|
|
345
|
-
`• Check your network connection\n` +
|
|
346
|
-
`• Run \`get_api_status\` to verify instance status\n` +
|
|
347
|
-
`• Technical detail: ${msg}`
|
|
270
|
+
`Could not connect to instance "${inst.name}" (${inst.host}:${inst.port}): ${msg}`
|
|
348
271
|
);
|
|
349
272
|
}
|
|
350
273
|
|
|
@@ -614,44 +537,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
614
537
|
authInfo = '❌ JWT decode failed – check CACHLY_JWT format';
|
|
615
538
|
}
|
|
616
539
|
|
|
617
|
-
// List instances + connectivity
|
|
618
|
-
let instanceInfo = '';
|
|
619
|
-
try {
|
|
620
|
-
const listRes = await apiFetch<{ data: Instance[] }>('/api/v1/instances');
|
|
621
|
-
const instances = listRes.data ?? [];
|
|
622
|
-
if (instances.length === 0) {
|
|
623
|
-
instanceInfo = '\n\n🧠 **Brain Instances:** none — create one at https://cachly.dev/instances';
|
|
624
|
-
} else {
|
|
625
|
-
const lines: string[] = ['\n\n🧠 **Brain Instances:**'];
|
|
626
|
-
for (const inst of instances) {
|
|
627
|
-
const badge = inst.status === 'running' ? '🟢' : inst.status === 'provisioning' ? '🟡' : '🔴';
|
|
628
|
-
const defaultMark = inst.id === _defaultInstanceId ? ' ← active' : '';
|
|
629
|
-
lines.push(` ${badge} **${inst.name}** (\`${inst.id}\`) · ${inst.status}${defaultMark}`);
|
|
630
|
-
if (inst.status === 'running' && inst.host) {
|
|
631
|
-
// Try a quick ping on the cached connection
|
|
632
|
-
const pooled = pool.get(inst.id);
|
|
633
|
-
if (pooled) {
|
|
634
|
-
try {
|
|
635
|
-
await pooled.ping();
|
|
636
|
-
lines.push(` 💓 Redis: connected`);
|
|
637
|
-
} catch {
|
|
638
|
-
lines.push(` ⚠️ Redis: pooled but ping failed — will reconnect on next use`);
|
|
639
|
-
}
|
|
640
|
-
} else {
|
|
641
|
-
lines.push(` 💤 Redis: not connected yet (connects on first tool use)`);
|
|
642
|
-
}
|
|
643
|
-
} else if (inst.status === 'provisioning') {
|
|
644
|
-
lines.push(` ⏳ Still provisioning — check back in ~30 s`);
|
|
645
|
-
} else if (inst.status !== 'running') {
|
|
646
|
-
lines.push(` ❌ Not reachable (status: ${inst.status})`);
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
instanceInfo = lines.join('\n');
|
|
650
|
-
}
|
|
651
|
-
} catch (e) {
|
|
652
|
-
instanceInfo = `\n\n🧠 **Brain Instances:** could not fetch — ${(e as Error).message}`;
|
|
653
|
-
}
|
|
654
|
-
|
|
655
540
|
return [
|
|
656
541
|
`📡 **cachly API Status**`,
|
|
657
542
|
``,
|
|
@@ -660,7 +545,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
660
545
|
``,
|
|
661
546
|
`🔑 **Auth:**`,
|
|
662
547
|
authInfo,
|
|
663
|
-
instanceInfo,
|
|
664
548
|
].join('\n');
|
|
665
549
|
}
|
|
666
550
|
|
|
@@ -698,9 +582,8 @@ async function autoStartSession(instanceId: string): Promise<void> {
|
|
|
698
582
|
_autoSessionInstanceId = instanceId;
|
|
699
583
|
try {
|
|
700
584
|
await handleTool('session_start', { instance_id: instanceId, focus: 'auto (MCP session)' });
|
|
701
|
-
} catch { /* non-fatal — session tracking is a best-effort feature */ }
|
|
702
|
-
|
|
703
|
-
}
|
|
585
|
+
} catch { /* non-fatal — session tracking is a best-effort feature */ }
|
|
586
|
+
_autoSessionStarting = null;
|
|
704
587
|
})();
|
|
705
588
|
await _autoSessionStarting;
|
|
706
589
|
|
|
@@ -749,9 +632,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
749
632
|
// Skip session management tools to avoid recursion.
|
|
750
633
|
const sessionTools = new Set(['session_start', 'session_end', 'auto_learn_session']);
|
|
751
634
|
if (!sessionTools.has(name) && !_autoSessionStarted && !_autoSessionStarting) {
|
|
752
|
-
const instanceId = (
|
|
753
|
-
|| _defaultInstanceId
|
|
754
|
-
|| process.env.CACHLY_BRAIN_INSTANCE_ID;
|
|
635
|
+
const instanceId = (args as Record<string, unknown>)?.instance_id as string | undefined;
|
|
755
636
|
if (instanceId) {
|
|
756
637
|
await autoStartSession(instanceId).catch(() => undefined);
|
|
757
638
|
}
|
package/src/tools.ts
CHANGED
|
@@ -437,11 +437,10 @@ const TOOLS = [
|
|
|
437
437
|
{
|
|
438
438
|
name: 'get_api_status',
|
|
439
439
|
description:
|
|
440
|
-
'
|
|
441
|
-
'Returns
|
|
442
|
-
'
|
|
443
|
-
'
|
|
444
|
-
'Workflow: run get_api_status → read the issue it flags → fix it → retry your tool.',
|
|
440
|
+
'Check the cachly API health and your authentication status. ' +
|
|
441
|
+
'Returns whether the JWT is valid, your user ID (sub claim), ' +
|
|
442
|
+
'token expiry, and the auth provider (keycloak). ' +
|
|
443
|
+
'Use this to debug connection issues or verify your CACHLY_JWT is correct.',
|
|
445
444
|
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
446
445
|
},
|
|
447
446
|
// ── Thinking/Context Cache (for AI assistants) ────────────────────────────
|