@cachly-dev/mcp-server 0.10.27 → 0.10.41
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/.env.test.example +23 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +50 -0
- package/.github/ISSUE_TEMPLATE/config.yml +8 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +25 -0
- package/.github/workflows/publish.yml +72 -0
- package/CHANGELOG.md +252 -0
- package/LAUNCH_TEXTS.md +182 -0
- package/LICENSE +202 -0
- package/README.md +145 -115
- package/dist/__tests__/auth.test.d.ts +1 -0
- package/dist/__tests__/auth.test.js +107 -0
- package/dist/__tests__/auth.test.js.map +1 -0
- package/dist/__tests__/e2e/api-auth.test.d.ts +7 -0
- package/dist/__tests__/e2e/api-auth.test.js +162 -0
- package/dist/__tests__/e2e/api-auth.test.js.map +1 -0
- package/dist/__tests__/e2e/api-health.test.d.ts +7 -0
- package/dist/__tests__/e2e/api-health.test.js +95 -0
- package/dist/__tests__/e2e/api-health.test.js.map +1 -0
- package/dist/__tests__/e2e/cli.test.d.ts +10 -0
- package/dist/__tests__/e2e/cli.test.js +204 -0
- package/dist/__tests__/e2e/cli.test.js.map +1 -0
- package/dist/__tests__/e2e/config.d.ts +35 -0
- package/dist/__tests__/e2e/config.js +53 -0
- package/dist/__tests__/e2e/config.js.map +1 -0
- package/dist/__tests__/e2e/funnel.test.d.ts +13 -0
- package/dist/__tests__/e2e/funnel.test.js +90 -0
- package/dist/__tests__/e2e/funnel.test.js.map +1 -0
- package/dist/__tests__/e2e/mcp-tools.test.d.ts +7 -0
- package/dist/__tests__/e2e/mcp-tools.test.js +129 -0
- package/dist/__tests__/e2e/mcp-tools.test.js.map +1 -0
- package/dist/__tests__/e2e/provisioning.test.d.ts +15 -0
- package/dist/__tests__/e2e/provisioning.test.js +123 -0
- package/dist/__tests__/e2e/provisioning.test.js.map +1 -0
- package/dist/handlers/advanced.js +38 -227
- package/dist/handlers/advanced.js.map +1 -1
- package/dist/handlers/brain.js +146 -82
- package/dist/handlers/brain.js.map +1 -1
- package/dist/handlers/fedbrain.js +93 -44
- package/dist/handlers/fedbrain.js.map +1 -1
- package/dist/handlers/roadmap.js +8 -2
- package/dist/handlers/roadmap.js.map +1 -1
- package/dist/handlers/syndicate.js +15 -7
- package/dist/handlers/syndicate.js.map +1 -1
- package/dist/handlers/team.js +62 -50
- package/dist/handlers/team.js.map +1 -1
- package/dist/index.js +1264 -234
- package/dist/index.js.map +1 -1
- package/dist/tools.d.ts +3 -54
- package/dist/tools.js +8 -62
- package/dist/tools.js.map +1 -1
- package/eslint.config.js +16 -0
- package/glama.json +36 -0
- package/llms.txt +155 -0
- package/package.json +38 -32
- package/scripts/e2e-run.sh +160 -0
- package/scripts/postinstall.js +12 -12
- package/server.json +3 -9
- package/smithery.yaml +15 -8
- package/src/__tests__/auth.test.ts +147 -0
- package/src/__tests__/e2e/api-auth.test.ts +182 -0
- package/src/__tests__/e2e/api-health.test.ts +103 -0
- package/src/__tests__/e2e/cli.test.ts +228 -0
- package/src/__tests__/e2e/config.ts +53 -0
- package/src/__tests__/e2e/funnel.test.ts +99 -0
- package/src/__tests__/e2e/mcp-tools.test.ts +191 -0
- package/src/__tests__/e2e/provisioning.test.ts +128 -0
- package/src/handlers/advanced.ts +37 -217
- package/src/handlers/brain.ts +143 -78
- package/src/handlers/fedbrain.ts +88 -38
- package/src/handlers/roadmap.ts +7 -2
- package/src/handlers/syndicate.ts +13 -7
- package/src/handlers/team.ts +57 -33
- package/src/index.ts +1312 -257
- package/src/tools.ts +8 -66
- package/vitest.config.ts +12 -0
- package/dist/__tests__/brain-predict-failures.test.d.ts +0 -17
- package/dist/__tests__/brain-predict-failures.test.js +0 -339
- package/dist/__tests__/brain-predict-failures.test.js.map +0 -1
- package/src/__tests__/brain-predict-failures.test.ts +0 -430
package/src/handlers/brain.ts
CHANGED
|
@@ -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.
|
|
15
|
+
const MCP_VERSION = '0.10.0';
|
|
16
16
|
const WHATS_NEW: Record<string, string[]> = {
|
|
17
|
-
'0.10.
|
|
17
|
+
'0.10.0': [
|
|
18
18
|
`🆕 **What's new in v${MCP_VERSION}:**`,
|
|
19
|
-
`
|
|
20
|
-
`
|
|
21
|
-
` ✅
|
|
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');
|
|
@@ -430,8 +417,27 @@ export async function handleBrainTool(
|
|
|
430
417
|
? `📌 **You've looked this up 3 times.** Consider pinning it for instant access: add \`pinned: true\` via \`learn_from_attempts\` to always surface it first.`
|
|
431
418
|
: '';
|
|
432
419
|
|
|
420
|
+
// ── Trust signal (today-safe consensus layer) ───────────────────────
|
|
421
|
+
// A lesson recalled many times — or confirmed by multiple distinct
|
|
422
|
+
// authors — has proven its value. Surface that as social proof.
|
|
423
|
+
const lessonAuthors = (lesson as { authors?: string[]; author?: string }).authors
|
|
424
|
+
?? ((lesson as { author?: string }).author ? [(lesson as { author?: string }).author!] : []);
|
|
425
|
+
const distinctAuthors = [...new Set(lessonAuthors.filter(Boolean))];
|
|
426
|
+
const rc = updatedLesson.recall_count;
|
|
427
|
+
let trustBadge = '';
|
|
428
|
+
if (distinctAuthors.length >= 2 && rc >= 5) {
|
|
429
|
+
trustBadge = `🏆 **Battle-tested** — recalled ${rc}× · verified by ${distinctAuthors.length} developers. Trust this.`;
|
|
430
|
+
} else if (rc >= 10) {
|
|
431
|
+
trustBadge = `🏆 **Battle-tested** — recalled ${rc}×. This is one of your most-proven solutions.`;
|
|
432
|
+
} else if (distinctAuthors.length >= 2) {
|
|
433
|
+
trustBadge = `✅ **Team-verified** — confirmed by ${distinctAuthors.length} developers.`;
|
|
434
|
+
} else if (rc >= 5) {
|
|
435
|
+
trustBadge = `✅ **Proven** — recalled ${rc}× without contradiction.`;
|
|
436
|
+
}
|
|
437
|
+
|
|
433
438
|
return [
|
|
434
439
|
rememberWhen,
|
|
440
|
+
trustBadge,
|
|
435
441
|
`${badge} **Best solution for \`${topic}\`** ${sevEmoji}${lesson.severity ? ` (${lesson.severity})` : ''} · recalled ${updatedLesson.recall_count}×`,
|
|
436
442
|
``,
|
|
437
443
|
`**What worked:** ${lesson.what_worked}`,
|
|
@@ -747,20 +753,69 @@ export async function handleBrainTool(
|
|
|
747
753
|
const isFirstSession = !lastSession && lessons.length === 0 && ctxCount === 0;
|
|
748
754
|
if (isFirstSession) {
|
|
749
755
|
lines.push('🎉 **Welcome! Your AI Brain is live.**', '');
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
756
|
+
if (workspace_path) {
|
|
757
|
+
lines.push(`🚀 **Auto-bootstrapping from your git history...** (this takes a few seconds)`);
|
|
758
|
+
lines.push(` Your brain will learn from your recent commits — no setup needed.`, '');
|
|
759
|
+
} else {
|
|
760
|
+
lines.push('It learns from your work automatically. After your first session it will look like this:', '');
|
|
761
|
+
lines.push(' ✅ `api:auth` — Bearer token in header, not cookie; 401 on missing scope');
|
|
762
|
+
lines.push(' ✅ `database:migrations` — always run migrations before deploy');
|
|
763
|
+
lines.push(' ⚠️ `docker:build` — ARG changes bust all subsequent cache layers');
|
|
764
|
+
lines.push('');
|
|
765
|
+
lines.push('**Tip:** Pass `workspace_path` to `session_start` to auto-learn from git history instantly.');
|
|
766
|
+
lines.push('');
|
|
767
|
+
}
|
|
768
|
+
lines.push('**Your brain grows automatically:**');
|
|
769
|
+
lines.push(' • End each session → `session_end(summary="What I did")` — auto-learns from git commits');
|
|
770
|
+
lines.push(' • After fixing bugs → `learn_from_attempts(topic="...", outcome="success", what_worked="...")`');
|
|
759
771
|
lines.push('');
|
|
760
|
-
lines.push('💡 Run `brain_doctor` for a
|
|
772
|
+
lines.push('💡 Run `brain_doctor` for a health-check and personalised tips.');
|
|
761
773
|
lines.push('');
|
|
762
774
|
}
|
|
763
775
|
|
|
776
|
+
// ── Team-virality: first-team-briefing wow moment ────────────────────
|
|
777
|
+
// When a user joins a team brain (has team lessons from colleagues but
|
|
778
|
+
// has never been briefed on them), show a dedicated "Welcome to your
|
|
779
|
+
// team's brain" section. Only fires once per user.
|
|
780
|
+
if (author && !isFirstSession) {
|
|
781
|
+
try {
|
|
782
|
+
const briefingKey = `cachly:team:first_briefing:${author}`;
|
|
783
|
+
const alreadyBriefed = await redis.get(briefingKey);
|
|
784
|
+
if (!alreadyBriefed) {
|
|
785
|
+
type LessonAny = typeof lessons[0] & { author?: string };
|
|
786
|
+
const teamLessons = (lessons as LessonAny[]).filter(l => l.author && l.author !== author);
|
|
787
|
+
if (teamLessons.length > 0) {
|
|
788
|
+
// Mark briefed so this only fires once
|
|
789
|
+
await redis.set(briefingKey, '1', 'EX', 365 * 86400);
|
|
790
|
+
const byAuthor = new Map<string, LessonAny[]>();
|
|
791
|
+
for (const l of teamLessons) {
|
|
792
|
+
const a = l.author!;
|
|
793
|
+
if (!byAuthor.has(a)) byAuthor.set(a, []);
|
|
794
|
+
byAuthor.get(a)!.push(l);
|
|
795
|
+
}
|
|
796
|
+
lines.push('');
|
|
797
|
+
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
798
|
+
lines.push('🤝 **Your team\'s AI brain has been briefing you.**');
|
|
799
|
+
lines.push('');
|
|
800
|
+
lines.push(`Your teammates have already solved problems you\'re about to hit:`);
|
|
801
|
+
lines.push('');
|
|
802
|
+
for (const [teamAuthor, tls] of byAuthor) {
|
|
803
|
+
lines.push(` 👤 **${teamAuthor}** fixed ${tls.length} thing${tls.length > 1 ? 's' : ''}:`);
|
|
804
|
+
for (const l of tls.slice(0, 2)) {
|
|
805
|
+
const emoji = l.outcome === 'success' ? '✅' : '⚠️';
|
|
806
|
+
lines.push(` ${emoji} \`${l.topic}\` — ${l.what_worked.slice(0, 90)}`);
|
|
807
|
+
}
|
|
808
|
+
if (tls.length > 2) lines.push(` … and ${tls.length - 2} more lessons`);
|
|
809
|
+
}
|
|
810
|
+
lines.push('');
|
|
811
|
+
lines.push(`💡 Use \`team_learn\` after your next fix to pay it forward.`);
|
|
812
|
+
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
813
|
+
lines.push('');
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
} catch { /* team briefing errors must never break session_start */ }
|
|
817
|
+
}
|
|
818
|
+
|
|
764
819
|
// Handoff from previous window (if any)
|
|
765
820
|
const handoffRaw = await redis.get('cachly:session:handoff');
|
|
766
821
|
if (handoffRaw) {
|
|
@@ -946,45 +1001,42 @@ export async function handleBrainTool(
|
|
|
946
1001
|
lines.push('');
|
|
947
1002
|
}
|
|
948
1003
|
|
|
949
|
-
//
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
const
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
if (
|
|
959
|
-
|
|
960
|
-
const
|
|
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;
|
|
1004
|
+
// Top lessons — sorted by recall_count desc (most-used = most proven value).
|
|
1005
|
+
// If focus is set, focus-matched lessons already shown above; show remaining here.
|
|
1006
|
+
if (lessons.length > 0) {
|
|
1007
|
+
const byRecall = [...lessons].sort((a, b) => (b.recall_count ?? 0) - (a.recall_count ?? 0));
|
|
1008
|
+
const topRecalled = byRecall.filter(l => (l.recall_count ?? 0) > 0).slice(0, 3);
|
|
1009
|
+
const remaining = (focusLessons.length > 0 ? lessons.filter(l => !focusLessons.includes(l)) : lessons)
|
|
1010
|
+
.filter(l => !topRecalled.includes(l))
|
|
1011
|
+
.slice(0, focusLessons.length > 0 ? 3 : 4);
|
|
1012
|
+
|
|
1013
|
+
if (topRecalled.length > 0) {
|
|
1014
|
+
lines.push(`🏆 **Most valuable** (recalled ${topRecalled[0]!.recall_count}× before):`);
|
|
1015
|
+
for (const l of topRecalled) {
|
|
966
1016
|
const emoji = l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
|
|
967
|
-
const sev
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1017
|
+
const sev = l.severity === 'critical' ? '🔴' : l.severity === 'major' ? '🟡' : '';
|
|
1018
|
+
const rc = l.recall_count ?? 0;
|
|
1019
|
+
const la = (l as { authors?: string[]; author?: string }).authors
|
|
1020
|
+
?? ((l as { author?: string }).author ? [(l as { author?: string }).author!] : []);
|
|
1021
|
+
const nAuthors = new Set(la.filter(Boolean)).size;
|
|
1022
|
+
const trust = (nAuthors >= 2 && rc >= 5) || rc >= 10 ? ' 🏆'
|
|
1023
|
+
: nAuthors >= 2 || rc >= 5 ? ' ✅' : '';
|
|
1024
|
+
const rct = rc > 1 ? ` _(${rc}× recalled)_` : '';
|
|
1025
|
+
lines.push(` ${emoji}${sev}${trust} \`${l.topic}\`${rct} — ${l.what_worked.slice(0, 100)}`);
|
|
974
1026
|
}
|
|
1027
|
+
lines.push('');
|
|
975
1028
|
}
|
|
976
|
-
} catch { /* non-critical */ }
|
|
977
1029
|
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
1030
|
+
if (remaining.length > 0) {
|
|
1031
|
+
const header = topRecalled.length > 0 ? `🕐 **Recent:**` : `🕐 **Recent lessons:**`;
|
|
1032
|
+
lines.push(header);
|
|
1033
|
+
for (const l of remaining) {
|
|
1034
|
+
const emoji = l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
|
|
1035
|
+
const sev = l.severity === 'critical' ? '🔴' : l.severity === 'major' ? '🟡' : '';
|
|
1036
|
+
lines.push(` ${emoji}${sev} \`${l.topic}\` — ${l.what_worked.slice(0, 100)}`);
|
|
1037
|
+
}
|
|
1038
|
+
lines.push('');
|
|
986
1039
|
}
|
|
987
|
-
lines.push('');
|
|
988
1040
|
} else {
|
|
989
1041
|
lines.push('📭 No lessons yet. Use `learn_from_attempts` after solving tasks.', '');
|
|
990
1042
|
}
|
|
@@ -1461,19 +1513,32 @@ export async function handleBrainTool(
|
|
|
1461
1513
|
}
|
|
1462
1514
|
|
|
1463
1515
|
const durationStr = durationMin !== undefined ? ` · ${durationMin} min` : '';
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1516
|
+
const totalAutoLearned = autoLearned.length + ambientLearned.length;
|
|
1517
|
+
|
|
1518
|
+
// ── Shareable Session Summary Card ────────────────────────────────────────
|
|
1519
|
+
// Generated after each session so the user can share their progress.
|
|
1520
|
+
const tweetLines: string[] = [];
|
|
1521
|
+
if (durationMin !== undefined && durationMin > 0) tweetLines.push(`⏱ ${durationMin} min session`);
|
|
1522
|
+
if (totalAutoLearned > 0) tweetLines.push(`🧠 ${totalAutoLearned} lessons saved to Brain`);
|
|
1523
|
+
if (files_changed.length > 0) tweetLines.push(`📁 ${files_changed.length} file${files_changed.length > 1 ? 's' : ''} changed`);
|
|
1524
|
+
const tweetBody = tweetLines.length > 0
|
|
1525
|
+
? `${tweetLines.join(' · ')}\n\nMy AI Brain remembers this so I never repeat it. @cachlydev\ncachly.dev`
|
|
1526
|
+
: `Session saved to my AI Brain. No more re-explaining this tomorrow. @cachlydev\ncachly.dev`;
|
|
1527
|
+
const tweetURL = `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweetBody)}`;
|
|
1528
|
+
|
|
1529
|
+
const sessionCard = [
|
|
1530
|
+
``,
|
|
1531
|
+
`┌─────────────────────────────────────────────┐`,
|
|
1532
|
+
`│ 🧠 Session Summary Card │`,
|
|
1533
|
+
`│ Share your progress — cached forever │`,
|
|
1534
|
+
`├─────────────────────────────────────────────┤`,
|
|
1535
|
+
durationMin !== undefined ? `│ ⏱ Duration : ${String(durationMin + ' min').padEnd(26)}│` : '',
|
|
1536
|
+
totalAutoLearned > 0 ? `│ 📚 Learned : ${String(totalAutoLearned + ' lessons').padEnd(26)}│` : '',
|
|
1537
|
+
files_changed.length > 0 ? `│ 📁 Changed : ${String(files_changed.length + ' file' + (files_changed.length > 1 ? 's' : '')).padEnd(26)}│` : '',
|
|
1538
|
+
`├─────────────────────────────────────────────┤`,
|
|
1539
|
+
`│ 📣 Share: ${tweetURL.slice(0, 34).padEnd(34)}│`,
|
|
1540
|
+
`└─────────────────────────────────────────────┘`,
|
|
1541
|
+
].filter(l => l !== '').join('\n');
|
|
1477
1542
|
|
|
1478
1543
|
return [
|
|
1479
1544
|
`✅ **Session saved**${durationStr}`,
|
|
@@ -1483,7 +1548,7 @@ export async function handleBrainTool(
|
|
|
1483
1548
|
lessons_learned !== undefined ? `🧠 **Lessons stored:** ${lessons_learned}` : '',
|
|
1484
1549
|
autoLearned.length > 0 ? `🤖 **Auto-learned:** ${autoLearned.length} lessons extracted from summary (${autoLearned.slice(0, 3).map(t => `\`${t}\``).join(', ')}${autoLearned.length > 3 ? '…' : ''})` : '',
|
|
1485
1550
|
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
|
-
|
|
1551
|
+
sessionCard,
|
|
1487
1552
|
``,
|
|
1488
1553
|
`💡 Next session: \`session_start(focus="...")\` to see this summary.`,
|
|
1489
1554
|
].filter(l => l !== '').join('\n');
|
package/src/handlers/fedbrain.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
|
829
|
-
if (wip
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
1048
|
-
fix = lesson
|
|
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
|
}
|
package/src/handlers/roadmap.ts
CHANGED
|
@@ -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 =
|
|
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).
|
|
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
|
|
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
|
|
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
|
|
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 ?
|
|
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
|
-
|
|
393
|
-
|
|
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
|
-
}
|
|
406
|
+
}
|
|
401
407
|
}
|
|
402
408
|
textPredictions.sort((a, b) => b.confidence - a.confidence);
|
|
403
409
|
|