@cachly-dev/mcp-server 0.10.25 → 0.10.27
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/README.md +6 -2
- package/dist/handlers/advanced.js +227 -38
- package/dist/handlers/advanced.js.map +1 -1
- package/dist/index.js +120 -6
- package/dist/index.js.map +1 -1
- package/package.json +21 -3
- package/src/handlers/advanced.ts +217 -37
- package/src/index.ts +132 -6
package/src/handlers/advanced.ts
CHANGED
|
@@ -4,13 +4,13 @@ import { calculateConfidence, confidenceBadge, CONFIDENCE_STALE_VALUE, CONFIDENC
|
|
|
4
4
|
import { ckgSlug, ckgUpdateEdge } from '../ckg.js';
|
|
5
5
|
import type { CKGEdge, CKGNode } from '../ckg.js';
|
|
6
6
|
import { keywordSearch } from '../search.js';
|
|
7
|
-
import { safeJsonParse } from '../utils.js';
|
|
8
7
|
|
|
9
8
|
type GetConnection = (instanceId: string) => Promise<Redis>;
|
|
10
9
|
type ApiFetch = <T>(path: string, options?: RequestInit) => Promise<T>;
|
|
11
10
|
|
|
12
11
|
export const ADVANCED_TOOL_NAMES = new Set([
|
|
13
12
|
'memory_consolidate', 'brain_diff', 'causal_trace', 'knowledge_decay', 'autopilot',
|
|
13
|
+
'brain_stats', 'pin_lesson', 'unpin_lesson',
|
|
14
14
|
]);
|
|
15
15
|
|
|
16
16
|
export async function handleAdvancedTool(
|
|
@@ -46,8 +46,7 @@ export async function handleAdvancedTool(
|
|
|
46
46
|
for (const k of lessonKeys) {
|
|
47
47
|
const raw = await redis.get(k);
|
|
48
48
|
if (!raw) continue;
|
|
49
|
-
|
|
50
|
-
if (lesson) lessons.set(k, lesson);
|
|
49
|
+
try { lessons.set(k, JSON.parse(raw) as Lesson); } catch { /* skip malformed */ }
|
|
51
50
|
}
|
|
52
51
|
|
|
53
52
|
// Detect duplicates: same topic prefix (e.g. deploy:api vs deploy:api-v2)
|
|
@@ -105,6 +104,25 @@ export async function handleAdvancedTool(
|
|
|
105
104
|
for (const k of stale) {
|
|
106
105
|
await redis.expire(k, 86400 * 30);
|
|
107
106
|
}
|
|
107
|
+
// Auto-unpin lessons whose lesson key was deleted (merged or stale-expired)
|
|
108
|
+
// Also unpin if the lesson outcome changed to 'failure' (auto-pin only fires on success/partial)
|
|
109
|
+
const pinKeys: string[] = [];
|
|
110
|
+
const pinStream = redis.scanStream({ match: 'cachly:lesson:pin:*', count: 100 });
|
|
111
|
+
await new Promise<void>((res, rej) => { pinStream.on('data', (b: string[]) => pinKeys.push(...b)); pinStream.on('end', res); pinStream.on('error', rej); });
|
|
112
|
+
let unpinned = 0;
|
|
113
|
+
for (const pk of pinKeys) {
|
|
114
|
+
const pinnedTopic = pk.replace('cachly:lesson:pin:', '');
|
|
115
|
+
const lessonStillExists = await redis.exists(`cachly:lesson:best:${pinnedTopic}`);
|
|
116
|
+
if (!lessonStillExists) { await redis.del(pk); unpinned++; continue; }
|
|
117
|
+
const raw = await redis.get(`cachly:lesson:best:${pinnedTopic}`);
|
|
118
|
+
if (raw) {
|
|
119
|
+
try {
|
|
120
|
+
const l = JSON.parse(raw) as { outcome: string };
|
|
121
|
+
if (l.outcome === 'failure') { await redis.del(pk); unpinned++; }
|
|
122
|
+
} catch { /* skip */ }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (unpinned > 0) stale.push(...Array(unpinned).fill('__unpin_placeholder__')); // just for count
|
|
108
126
|
}
|
|
109
127
|
|
|
110
128
|
const lines = [
|
|
@@ -168,9 +186,8 @@ export async function handleAdvancedTool(
|
|
|
168
186
|
for (const k of lessonKeys) {
|
|
169
187
|
const raw = await redis.get(k);
|
|
170
188
|
if (!raw) continue;
|
|
171
|
-
{
|
|
172
|
-
const l =
|
|
173
|
-
if (!l) continue;
|
|
189
|
+
try {
|
|
190
|
+
const l = JSON.parse(raw) as Lesson;
|
|
174
191
|
const ts = new Date(l.ts).getTime();
|
|
175
192
|
if (ts >= sinceMs) {
|
|
176
193
|
// Check history to determine add vs update
|
|
@@ -184,7 +201,7 @@ export async function handleAdvancedTool(
|
|
|
184
201
|
// lessons with recalls as "active"
|
|
185
202
|
recalled.push(l);
|
|
186
203
|
}
|
|
187
|
-
}
|
|
204
|
+
} catch { /* skip */ }
|
|
188
205
|
}
|
|
189
206
|
|
|
190
207
|
const sinceLabel = match ? `last ${since}` : new Date(sinceMs).toLocaleDateString('de-DE');
|
|
@@ -218,10 +235,10 @@ export async function handleAdvancedTool(
|
|
|
218
235
|
for (const rk of resolutionKeys) {
|
|
219
236
|
const rr = await redis.get(rk);
|
|
220
237
|
if (!rr) continue;
|
|
221
|
-
{
|
|
222
|
-
const rn =
|
|
223
|
-
if (rn
|
|
224
|
-
}
|
|
238
|
+
try {
|
|
239
|
+
const rn = JSON.parse(rr) as { topic?: string; resolution?: string; ts?: string };
|
|
240
|
+
if (rn.ts && new Date(rn.ts).getTime() >= sinceMs) recentResolutions.push({ topic: rn.topic ?? rk, resolution: rn.resolution ?? 'unknown', ts: rn.ts });
|
|
241
|
+
} catch { /* skip */ }
|
|
225
242
|
}
|
|
226
243
|
if (recentResolutions.length > 0) {
|
|
227
244
|
lines.push(`🗳️ **MADC Resolutions** (${recentResolutions.length} contested beliefs resolved):`);
|
|
@@ -241,10 +258,8 @@ export async function handleAdvancedTool(
|
|
|
241
258
|
|
|
242
259
|
// 3. FedBrain transfers received in window
|
|
243
260
|
const fedHistRaw = await redis.lrange('cachly:fedbrain:federations', -20, -1);
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
.map(r => safeJsonParse<FedEntry | null>(r, null))
|
|
247
|
-
.filter((f): f is FedEntry => f !== null && new Date(f.transferred_at).getTime() >= sinceMs);
|
|
261
|
+
const recentFeds = fedHistRaw.map(r => { try { return JSON.parse(r) as { source: string; domain: string; transferred_at: string; nodes: number; edges: number }; } catch { return null; } })
|
|
262
|
+
.filter(f => f && new Date(f!.transferred_at).getTime() >= sinceMs) as Array<{ source: string; domain: string; transferred_at: string; nodes: number; edges: number }>;
|
|
248
263
|
if (recentFeds.length > 0) {
|
|
249
264
|
lines.push(`🧠 **FedBrain transfers received (${recentFeds.length}):**`);
|
|
250
265
|
for (const f of recentFeds.slice(0, 3)) {
|
|
@@ -287,30 +302,27 @@ export async function handleAdvancedTool(
|
|
|
287
302
|
for (const nodeKey of nodeKeys.slice(0, 10)) {
|
|
288
303
|
const nodeRaw = await redis.get(nodeKey);
|
|
289
304
|
if (!nodeRaw) continue;
|
|
290
|
-
const node
|
|
291
|
-
if (!node) continue;
|
|
305
|
+
const node: CKGNode = JSON.parse(nodeRaw);
|
|
292
306
|
// Get edges from this node
|
|
293
307
|
const edgeKeys = await redis.smembers(`cachly:ckg:idx:from:${node.id}`);
|
|
294
308
|
for (const ek of edgeKeys.slice(0, 20)) {
|
|
295
309
|
const edgeRaw = await redis.get(ek);
|
|
296
310
|
if (!edgeRaw) continue;
|
|
297
|
-
const edge
|
|
298
|
-
if (!edge) continue;
|
|
311
|
+
const edge: CKGEdge = JSON.parse(edgeRaw);
|
|
299
312
|
if (edge.edgeType !== 'fixes' && edge.edgeType !== 'requires') continue;
|
|
300
313
|
// Find lesson for this concept
|
|
301
314
|
const lessonRaw = await redis.get(`cachly:lesson:best:${edge.from.replace(/-/g, ':').replace(/^fix:/, 'fix:')}`);
|
|
302
|
-
const lesson = lessonRaw ?
|
|
303
|
-
ckgResults.push({ conceptId: node.id, edge, lesson
|
|
315
|
+
const lesson = lessonRaw ? JSON.parse(lessonRaw) : undefined;
|
|
316
|
+
ckgResults.push({ conceptId: node.id, edge, lesson });
|
|
304
317
|
}
|
|
305
318
|
}
|
|
306
319
|
for (const ek of [...fromKeys, ...toKeys].slice(0, 20)) {
|
|
307
320
|
const edgeRaw = await redis.get(ek);
|
|
308
321
|
if (!edgeRaw) continue;
|
|
309
|
-
const edge
|
|
310
|
-
if (!edge) continue;
|
|
322
|
+
const edge: CKGEdge = JSON.parse(edgeRaw);
|
|
311
323
|
const lessonRaw = await redis.get(`cachly:lesson:best:${edge.from}`);
|
|
312
|
-
const lesson = lessonRaw ?
|
|
313
|
-
ckgResults.push({ conceptId: edge.from, edge, lesson
|
|
324
|
+
const lesson = lessonRaw ? JSON.parse(lessonRaw) : undefined;
|
|
325
|
+
ckgResults.push({ conceptId: edge.from, edge, lesson });
|
|
314
326
|
}
|
|
315
327
|
}
|
|
316
328
|
} catch { /* CKG traversal non-critical */ }
|
|
@@ -344,15 +356,14 @@ export async function handleAdvancedTool(
|
|
|
344
356
|
for (const k of lessonKeys) {
|
|
345
357
|
const raw = await redis.get(k);
|
|
346
358
|
if (!raw) continue;
|
|
347
|
-
{
|
|
348
|
-
const l =
|
|
349
|
-
if (!l) continue;
|
|
359
|
+
try {
|
|
360
|
+
const l = JSON.parse(raw) as Lesson;
|
|
350
361
|
if (filterTags.length > 0 && !(l.tags ?? []).some((t: string) => filterTags.includes(t))) continue;
|
|
351
362
|
const haystack = [l.topic, l.what_failed ?? '', l.what_worked ?? '', l.context ?? '']
|
|
352
363
|
.join(' ').toLowerCase();
|
|
353
364
|
const score = tokens.reduce((s, t) => s + (haystack.includes(t) ? 1 : 0), 0);
|
|
354
365
|
if (score > 0) scored.push({ score, lesson: l, key: k });
|
|
355
|
-
}
|
|
366
|
+
} catch { /* skip */ }
|
|
356
367
|
}
|
|
357
368
|
scored.sort((a, b) => b.score - a.score);
|
|
358
369
|
const chain = scored.slice(0, max_depth);
|
|
@@ -429,10 +440,6 @@ export async function handleAdvancedTool(
|
|
|
429
440
|
lines.push(topSolution.what_worked.slice(0, 500));
|
|
430
441
|
lines.push(`\`\`\``);
|
|
431
442
|
lines.push('');
|
|
432
|
-
|
|
433
|
-
// Track estimated time saved — causal_trace finding a known fix is worth 1-4h
|
|
434
|
-
const savedMins = topSolution.severity === 'critical' ? 240 : topSolution.severity === 'major' ? 120 : 60;
|
|
435
|
-
redis.incrbyfloat(`cachly:stats:time_saved_mins:${instance_id}`, savedMins).catch(() => {});
|
|
436
443
|
}
|
|
437
444
|
}
|
|
438
445
|
|
|
@@ -465,9 +472,8 @@ export async function handleAdvancedTool(
|
|
|
465
472
|
for (const k of lessonKeys) {
|
|
466
473
|
const raw = await redis.get(k);
|
|
467
474
|
if (!raw) continue;
|
|
468
|
-
{
|
|
469
|
-
const l =
|
|
470
|
-
if (!l) continue;
|
|
475
|
+
try {
|
|
476
|
+
const l = JSON.parse(raw) as Lesson;
|
|
471
477
|
const ageMs = now - new Date(l.ts).getTime();
|
|
472
478
|
if (ageMs < minAgeMs) continue;
|
|
473
479
|
const age_days = Math.floor(ageMs / 86400000);
|
|
@@ -484,7 +490,7 @@ export async function handleAdvancedTool(
|
|
|
484
490
|
const confidence = Math.min(100, Math.max(0, base + recallBoost + outcomePenalty));
|
|
485
491
|
|
|
486
492
|
scores.push({ topic: l.topic, confidence, age_days, recalls, outcome: l.outcome });
|
|
487
|
-
}
|
|
493
|
+
} catch { /* skip */ }
|
|
488
494
|
}
|
|
489
495
|
|
|
490
496
|
// Sort by lowest confidence first
|
|
@@ -541,6 +547,12 @@ export async function handleAdvancedTool(
|
|
|
541
547
|
- **Window opens** → call \`session_start(instance_id="${instance_id}", focus="<today's goal>")\`
|
|
542
548
|
- **Bug fixed / deployed / discovered** → call \`learn_from_attempts(instance_id="${instance_id}", topic="fix:<area>", outcome="success", what_worked="...")\`
|
|
543
549
|
- **Window closes** → call \`session_end(instance_id="${instance_id}", summary="...", files_changed=[...])\`
|
|
550
|
+
|
|
551
|
+
### New team member setup:
|
|
552
|
+
\`\`\`bash
|
|
553
|
+
npx @cachly-dev/init
|
|
554
|
+
\`\`\`
|
|
555
|
+
Run once — auto-detects all AI editors and gives instant full context.
|
|
544
556
|
`;
|
|
545
557
|
|
|
546
558
|
const full = `## Cachly AI Brain — Autopilot for ${project_name}
|
|
@@ -612,9 +624,22 @@ smart_recall(
|
|
|
612
624
|
|
|
613
625
|
---
|
|
614
626
|
|
|
627
|
+
## 👋 New team member?
|
|
628
|
+
|
|
629
|
+
\`\`\`bash
|
|
630
|
+
npx @cachly-dev/init
|
|
631
|
+
\`\`\`
|
|
632
|
+
|
|
633
|
+
Run once — auto-detects Claude Code, Cursor, Copilot, Windsurf, Continue and writes all MCP configs. Free, no credit card.
|
|
634
|
+
|
|
635
|
+
Demo: [cachly.dev](https://cachly.dev)
|
|
636
|
+
|
|
637
|
+
---
|
|
638
|
+
|
|
615
639
|
*Cachly v0.6 · Generated ${new Date().toISOString().slice(0, 10)}*
|
|
616
640
|
`;
|
|
617
641
|
|
|
642
|
+
|
|
618
643
|
const content = style === 'minimal' ? minimal : full;
|
|
619
644
|
const filename = editor === 'copilot'
|
|
620
645
|
? '.github/copilot-instructions.md'
|
|
@@ -643,6 +668,161 @@ smart_recall(
|
|
|
643
668
|
].join('\n');
|
|
644
669
|
}
|
|
645
670
|
|
|
671
|
+
// ── brain_stats ─────────────────────────────────────────────────────────
|
|
672
|
+
case 'brain_stats': {
|
|
673
|
+
const { instance_id } = args as { instance_id: string };
|
|
674
|
+
const redis = await getConnection(instance_id);
|
|
675
|
+
|
|
676
|
+
// Lessons
|
|
677
|
+
const lessonKeys: string[] = [];
|
|
678
|
+
const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
|
|
679
|
+
await new Promise<void>((res, rej) => { lStream.on('data', (b: string[]) => lessonKeys.push(...b)); lStream.on('end', res); lStream.on('error', rej); });
|
|
680
|
+
|
|
681
|
+
type LessonMeta = { topic: string; outcome: string; ts: string; severity?: string; recall_count?: number };
|
|
682
|
+
const lessons: LessonMeta[] = [];
|
|
683
|
+
if (lessonKeys.length > 0) {
|
|
684
|
+
const raws = await redis.mget(...lessonKeys);
|
|
685
|
+
for (const r of raws) {
|
|
686
|
+
if (!r) continue;
|
|
687
|
+
try { lessons.push(JSON.parse(r) as LessonMeta); } catch { /* skip */ }
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Context entries
|
|
692
|
+
let ctxCount = 0;
|
|
693
|
+
const ctxStream = redis.scanStream({ match: 'cachly:ctx:*', count: 200 });
|
|
694
|
+
await new Promise<void>((res, rej) => {
|
|
695
|
+
ctxStream.on('data', (b: string[]) => { ctxCount += (b as string[]).filter(k => !k.endsWith(':meta')).length; });
|
|
696
|
+
ctxStream.on('end', res); ctxStream.on('error', rej);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
// Roadmap items
|
|
700
|
+
let roadmapOpen = 0; let roadmapDone = 0;
|
|
701
|
+
try {
|
|
702
|
+
const rm = await redis.hgetall(`cachly:roadmap:${instance_id}`);
|
|
703
|
+
for (const v of Object.values(rm ?? {})) {
|
|
704
|
+
try {
|
|
705
|
+
const item = JSON.parse(v as string) as { status: string };
|
|
706
|
+
if (item.status === 'done') roadmapDone++;
|
|
707
|
+
else if (['planned', 'in-progress', 'blocked'].includes(item.status)) roadmapOpen++;
|
|
708
|
+
} catch { /* skip */ }
|
|
709
|
+
}
|
|
710
|
+
} catch { /* non-critical */ }
|
|
711
|
+
|
|
712
|
+
// Pinned lessons
|
|
713
|
+
const pinKeys: string[] = [];
|
|
714
|
+
const pStream = redis.scanStream({ match: 'cachly:lesson:pin:*', count: 100 });
|
|
715
|
+
await new Promise<void>((res, rej) => { pStream.on('data', (b: string[]) => pinKeys.push(...b)); pStream.on('end', res); pStream.on('error', rej); });
|
|
716
|
+
const pinnedTopics = pinKeys.map(k => k.replace('cachly:lesson:pin:', ''));
|
|
717
|
+
|
|
718
|
+
// Streak
|
|
719
|
+
let streakDays = 0;
|
|
720
|
+
try {
|
|
721
|
+
const sr = await redis.get('cachly:streak:current');
|
|
722
|
+
if (sr) streakDays = (JSON.parse(sr) as { days: number }).days;
|
|
723
|
+
} catch { /* skip */ }
|
|
724
|
+
|
|
725
|
+
// Last session
|
|
726
|
+
let lastSessionSummary = 'none yet';
|
|
727
|
+
let lastSessionAgo = '';
|
|
728
|
+
try {
|
|
729
|
+
const lsr = await redis.get('cachly:session:last');
|
|
730
|
+
if (lsr) {
|
|
731
|
+
const ls = JSON.parse(lsr) as { summary: string; ts: string };
|
|
732
|
+
const ago = Math.round((Date.now() - new Date(ls.ts).getTime()) / 60000);
|
|
733
|
+
lastSessionAgo = ago < 60 ? `${ago}m ago` : ago < 1440 ? `${Math.round(ago / 60)}h ago` : `${Math.round(ago / 1440)}d ago`;
|
|
734
|
+
lastSessionSummary = ls.summary;
|
|
735
|
+
}
|
|
736
|
+
} catch { /* skip */ }
|
|
737
|
+
|
|
738
|
+
// Crystal
|
|
739
|
+
let crystalLabel = '';
|
|
740
|
+
try {
|
|
741
|
+
const cr = await redis.get('cachly:crystal:latest');
|
|
742
|
+
if (cr) crystalLabel = (JSON.parse(cr) as { label: string }).label;
|
|
743
|
+
} catch { /* skip */ }
|
|
744
|
+
|
|
745
|
+
// Top topics
|
|
746
|
+
const topicFreq = new Map<string, number>();
|
|
747
|
+
for (const l of lessons) {
|
|
748
|
+
const cat = l.topic.split(':')[0] ?? 'other';
|
|
749
|
+
topicFreq.set(cat, (topicFreq.get(cat) ?? 0) + 1);
|
|
750
|
+
}
|
|
751
|
+
const topTopics = [...topicFreq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
752
|
+
|
|
753
|
+
// Outcomes
|
|
754
|
+
const successCount = lessons.filter(l => l.outcome === 'success').length;
|
|
755
|
+
const failCount = lessons.filter(l => l.outcome === 'failure').length;
|
|
756
|
+
const totalRecalls = lessons.reduce((s, l) => s + (l.recall_count ?? 0), 0);
|
|
757
|
+
|
|
758
|
+
const lines: string[] = [
|
|
759
|
+
'🧠 **Brain Stats**',
|
|
760
|
+
'',
|
|
761
|
+
`📚 **Lessons:** ${lessons.length} (✅ ${successCount} success · ❌ ${failCount} open · recalled ${totalRecalls}×)`,
|
|
762
|
+
`📁 **Context:** ${ctxCount} entries`,
|
|
763
|
+
`🗺️ **Roadmap:** ${roadmapOpen} open · ${roadmapDone} done`,
|
|
764
|
+
`🔥 **Streak:** ${streakDays > 0 ? `${streakDays} day${streakDays > 1 ? 's' : ''}` : 'no streak yet'}`,
|
|
765
|
+
'',
|
|
766
|
+
];
|
|
767
|
+
|
|
768
|
+
if (pinnedTopics.length > 0) {
|
|
769
|
+
lines.push(`📌 **Pinned lessons (${pinnedTopics.length}):** ${pinnedTopics.map(t => `\`${t}\``).join(' · ')}`);
|
|
770
|
+
lines.push('');
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (topTopics.length > 0) {
|
|
774
|
+
lines.push('📊 **Top knowledge domains:**');
|
|
775
|
+
for (const [cat, count] of topTopics) {
|
|
776
|
+
lines.push(` · ${cat.padEnd(20)} ${count} lesson${count > 1 ? 's' : ''}`);
|
|
777
|
+
}
|
|
778
|
+
lines.push('');
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (lastSessionSummary !== 'none yet') {
|
|
782
|
+
lines.push(`📅 **Last session** (${lastSessionAgo}): ${lastSessionSummary}`);
|
|
783
|
+
lines.push('');
|
|
784
|
+
}
|
|
785
|
+
if (crystalLabel) {
|
|
786
|
+
lines.push(`💎 **Memory Crystal:** ${crystalLabel}`);
|
|
787
|
+
lines.push('');
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
lines.push('💡 `brain_doctor` for health recommendations · `brain_diff(since="7d")` for what changed');
|
|
791
|
+
return lines.join('\n');
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// ── pin_lesson ──────────────────────────────────────────────────────────
|
|
795
|
+
case 'pin_lesson': {
|
|
796
|
+
const { instance_id, topic } = args as { instance_id: string; topic: string };
|
|
797
|
+
const redis = await getConnection(instance_id);
|
|
798
|
+
|
|
799
|
+
// Verify the lesson exists
|
|
800
|
+
const lessonRaw = await redis.get(`cachly:lesson:best:${topic}`);
|
|
801
|
+
if (!lessonRaw) {
|
|
802
|
+
return `❌ No lesson found for topic \`${topic}\`.\n\nFirst create it with \`learn_from_attempts(topic="${topic}", ...)\`.`;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
await redis.set(`cachly:lesson:pin:${topic}`, '1');
|
|
806
|
+
return [
|
|
807
|
+
`📌 **Pinned** \`${topic}\``,
|
|
808
|
+
'',
|
|
809
|
+
'This lesson will now appear at the top of every `session_start` briefing, regardless of age.',
|
|
810
|
+
'',
|
|
811
|
+
`Remove with: \`unpin_lesson(instance_id="...", topic="${topic}")\``,
|
|
812
|
+
].join('\n');
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// ── unpin_lesson ─────────────────────────────────────────────────────────
|
|
816
|
+
case 'unpin_lesson': {
|
|
817
|
+
const { instance_id, topic } = args as { instance_id: string; topic: string };
|
|
818
|
+
const redis = await getConnection(instance_id);
|
|
819
|
+
const deleted = await redis.del(`cachly:lesson:pin:${topic}`);
|
|
820
|
+
if (deleted === 0) {
|
|
821
|
+
return `ℹ️ \`${topic}\` was not pinned. Use \`brain_stats\` to see all pinned lessons.`;
|
|
822
|
+
}
|
|
823
|
+
return `📌 Unpinned \`${topic}\`. It will now follow normal recency/confidence decay in session_start.`;
|
|
824
|
+
}
|
|
825
|
+
|
|
646
826
|
// ── v0.7 Knowledge Syndication: syndicate ────────────────────────────────
|
|
647
827
|
|
|
648
828
|
default:
|
package/src/index.ts
CHANGED
|
@@ -873,6 +873,124 @@ function buildMcpConfig(apiKey: string, instanceId: string, editor: string): str
|
|
|
873
873
|
}, null, 2);
|
|
874
874
|
}
|
|
875
875
|
|
|
876
|
+
/**
|
|
877
|
+
* Build only the per-server entry that gets merged into an existing config.
|
|
878
|
+
* Returned shape varies by editor — see writeMergedMcpConfig for the merge
|
|
879
|
+
* locations.
|
|
880
|
+
*/
|
|
881
|
+
function buildCachlyServerEntry(apiKey: string, instanceId: string, editor: string): unknown {
|
|
882
|
+
if (editor === 'continue') {
|
|
883
|
+
return {
|
|
884
|
+
transport: { type: 'stdio', command: 'npx', args: ['-y', '@cachly-dev/mcp-server@latest'] },
|
|
885
|
+
env: { CACHLY_API_URL: 'https://api.cachly.dev', CACHLY_JWT: apiKey, CACHLY_BRAIN_INSTANCE_ID: instanceId },
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
if (editor === 'zed') {
|
|
889
|
+
return {
|
|
890
|
+
command: {
|
|
891
|
+
path: 'npx',
|
|
892
|
+
args: ['-y', '@cachly-dev/mcp-server@latest'],
|
|
893
|
+
env: { CACHLY_API_URL: 'https://api.cachly.dev', CACHLY_JWT: apiKey, CACHLY_BRAIN_INSTANCE_ID: instanceId },
|
|
894
|
+
},
|
|
895
|
+
settings: {},
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
return {
|
|
899
|
+
command: 'npx',
|
|
900
|
+
args: ['-y', '@cachly-dev/mcp-server@latest'],
|
|
901
|
+
env: { CACHLY_API_URL: 'https://api.cachly.dev', CACHLY_JWT: apiKey, CACHLY_BRAIN_INSTANCE_ID: instanceId },
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Merge the cachly server into an existing editor MCP config file — preserving
|
|
907
|
+
* every other MCP server the user has configured. Creates the file if missing.
|
|
908
|
+
* Writes a one-time .cachly-backup-<timestamp> next to the file before any
|
|
909
|
+
* mutation so the user can recover from corrupt/unexpected merges.
|
|
910
|
+
*
|
|
911
|
+
* Returns 'created' if the file did not exist, 'updated' if cachly was already
|
|
912
|
+
* present (re-pointed to a possibly new instance), or 'merged' if cachly was
|
|
913
|
+
* added alongside other servers.
|
|
914
|
+
*/
|
|
915
|
+
async function writeMergedMcpConfig(
|
|
916
|
+
configPath: string,
|
|
917
|
+
apiKey: string,
|
|
918
|
+
instanceId: string,
|
|
919
|
+
editor: string,
|
|
920
|
+
): Promise<'created' | 'updated' | 'merged'> {
|
|
921
|
+
const { writeFile, readFile, mkdir, copyFile } = await import('node:fs/promises');
|
|
922
|
+
const { existsSync } = await import('node:fs');
|
|
923
|
+
const { dirname } = await import('node:path');
|
|
924
|
+
|
|
925
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
926
|
+
|
|
927
|
+
// No existing file → write fresh full config.
|
|
928
|
+
if (!existsSync(configPath)) {
|
|
929
|
+
await writeFile(configPath, buildMcpConfig(apiKey, instanceId, editor), 'utf-8');
|
|
930
|
+
return 'created';
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Parse existing config. On parse failure, back it up and start fresh
|
|
934
|
+
// (never silently destroy user data — the .cachly-backup gives them a
|
|
935
|
+
// recovery path).
|
|
936
|
+
const raw = await readFile(configPath, 'utf-8');
|
|
937
|
+
let existing: Record<string, unknown>;
|
|
938
|
+
try {
|
|
939
|
+
existing = JSON.parse(raw) as Record<string, unknown>;
|
|
940
|
+
if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
|
|
941
|
+
throw new Error('config root is not an object');
|
|
942
|
+
}
|
|
943
|
+
} catch {
|
|
944
|
+
const backupPath = `${configPath}.cachly-backup-${Date.now()}`;
|
|
945
|
+
await copyFile(configPath, backupPath);
|
|
946
|
+
await writeFile(configPath, buildMcpConfig(apiKey, instanceId, editor), 'utf-8');
|
|
947
|
+
return 'created';
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// Defensive backup of the valid config before mutation — first time only.
|
|
951
|
+
// The cachly-backup file is overwritten on each re-init so we don't pollute
|
|
952
|
+
// the project, but it gives the user one undo level.
|
|
953
|
+
const backupPath = `${configPath}.cachly-backup`;
|
|
954
|
+
try {
|
|
955
|
+
await copyFile(configPath, backupPath);
|
|
956
|
+
} catch { /* non-critical — backup is best-effort */ }
|
|
957
|
+
|
|
958
|
+
const entry = buildCachlyServerEntry(apiKey, instanceId, editor);
|
|
959
|
+
let hadCachly = false;
|
|
960
|
+
|
|
961
|
+
if (editor === 'continue') {
|
|
962
|
+
// experimental.modelContextProtocolServers is an Array — replace existing
|
|
963
|
+
// cachly entry (matched by command/args containing @cachly-dev/mcp-server)
|
|
964
|
+
// or append.
|
|
965
|
+
type ContinueServer = { transport?: { args?: string[] }; env?: Record<string, string> };
|
|
966
|
+
const exp = (existing.experimental as Record<string, unknown> | undefined) ?? {};
|
|
967
|
+
const servers = Array.isArray(exp.modelContextProtocolServers)
|
|
968
|
+
? (exp.modelContextProtocolServers as ContinueServer[])
|
|
969
|
+
: [];
|
|
970
|
+
const isCachly = (s: ContinueServer) =>
|
|
971
|
+
s?.transport?.args?.some((a) => typeof a === 'string' && a.includes('@cachly-dev/mcp-server')) ?? false;
|
|
972
|
+
const filtered = servers.filter((s) => {
|
|
973
|
+
if (isCachly(s)) { hadCachly = true; return false; }
|
|
974
|
+
return true;
|
|
975
|
+
});
|
|
976
|
+
filtered.push(entry as ContinueServer);
|
|
977
|
+
existing.experimental = { ...exp, modelContextProtocolServers: filtered };
|
|
978
|
+
} else if (editor === 'zed') {
|
|
979
|
+
// context_servers is a Record — set cachly key.
|
|
980
|
+
const cs = (existing.context_servers as Record<string, unknown> | undefined) ?? {};
|
|
981
|
+
hadCachly = 'cachly' in cs;
|
|
982
|
+
existing.context_servers = { ...cs, cachly: entry };
|
|
983
|
+
} else {
|
|
984
|
+
// Standard mcpServers map — Claude / Cursor / Windsurf / VS Code / Cline.
|
|
985
|
+
const mcp = (existing.mcpServers as Record<string, unknown> | undefined) ?? {};
|
|
986
|
+
hadCachly = 'cachly' in mcp;
|
|
987
|
+
existing.mcpServers = { ...mcp, cachly: entry };
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
await writeFile(configPath, JSON.stringify(existing, null, 2), 'utf-8');
|
|
991
|
+
return hadCachly ? 'updated' : 'merged';
|
|
992
|
+
}
|
|
993
|
+
|
|
876
994
|
function buildClaudeMdBlock(instanceId: string): string {
|
|
877
995
|
return `${CLAUDE_MD_MARKER_START}
|
|
878
996
|
## Cachly AI Brain — Always Active
|
|
@@ -969,9 +1087,11 @@ if (process.argv[2] === 'init') {
|
|
|
969
1087
|
|
|
970
1088
|
const configFile = EDITOR_FILES[editor] ?? '.mcp.json';
|
|
971
1089
|
const configPath = resolve(projectDir, configFile);
|
|
972
|
-
await
|
|
973
|
-
|
|
974
|
-
|
|
1090
|
+
const mergeResult = await writeMergedMcpConfig(configPath, apiKey, instanceId, editor);
|
|
1091
|
+
const verb = mergeResult === 'created' ? '✅ Written'
|
|
1092
|
+
: mergeResult === 'updated' ? '✅ Updated (kept existing servers)'
|
|
1093
|
+
: '✅ Merged into existing config (other servers untouched)';
|
|
1094
|
+
console.log(`\n${verb}: ${configFile}`);
|
|
975
1095
|
|
|
976
1096
|
// Always write CLAUDE.md (idempotent — safe to run multiple times)
|
|
977
1097
|
const result = await writeClaudeMd(projectDir, instanceId);
|
|
@@ -1252,12 +1372,18 @@ if (process.argv[2] === 'setup') {
|
|
|
1252
1372
|
console.log(`Step 3: Configuring for: ${editorsToSetup.map(editorLabel).join(', ')}\n`);
|
|
1253
1373
|
|
|
1254
1374
|
// ── Step 4: Write editor configs (project-level) ─────────────────────────
|
|
1375
|
+
// Critical: NEVER overwrite existing configs blindly — merge cachly into the
|
|
1376
|
+
// existing mcpServers map so the user's other MCP servers (filesystem,
|
|
1377
|
+
// github, postgres, …) are preserved. writeMergedMcpConfig also drops a
|
|
1378
|
+
// .cachly-backup file next to the original for one-step recovery.
|
|
1255
1379
|
for (const editor of editorsToSetup) {
|
|
1256
1380
|
const configFile = EDITOR_FILES[editor] ?? '.mcp.json';
|
|
1257
1381
|
const configPath = resolve(cwd, configFile);
|
|
1258
|
-
await
|
|
1259
|
-
|
|
1260
|
-
|
|
1382
|
+
const result = await writeMergedMcpConfig(configPath, token, instance.id, editor);
|
|
1383
|
+
const verb = result === 'created' ? '✅ Written'
|
|
1384
|
+
: result === 'updated' ? '✅ Updated (kept existing servers)'
|
|
1385
|
+
: '✅ Merged (other servers untouched)';
|
|
1386
|
+
console.log(`${verb}: ${configFile}`);
|
|
1261
1387
|
}
|
|
1262
1388
|
|
|
1263
1389
|
// ── Step 4b: Write global Claude Code config (~/.claude/mcp.json) ─────────
|