@cachly-dev/mcp-server 0.10.59 → 0.10.61
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 +43 -0
- package/README.md +1 -1
- package/dist/src/__tests__/brain-flow.test.js +174 -9
- package/dist/src/__tests__/brain-flow.test.js.map +1 -1
- package/dist/src/handlers/brain.js +386 -7
- package/dist/src/handlers/brain.js.map +1 -1
- package/dist/src/handlers/fedbrain.js +37 -10
- package/dist/src/handlers/fedbrain.js.map +1 -1
- package/dist/src/index.js +3 -3
- package/dist/src/index.js.map +1 -1
- package/dist/src/tools.d.ts +77 -1
- package/dist/src/tools.js +73 -1
- package/dist/src/tools.js.map +1 -1
- package/package.json +2 -2
- package/server.json +3 -3
- package/src/__tests__/brain-flow.test.ts +202 -9
- package/src/handlers/brain.ts +428 -7
- package/src/handlers/fedbrain.ts +38 -10
- package/src/index.ts +3 -3
- package/src/tools.ts +77 -1
package/src/handlers/brain.ts
CHANGED
|
@@ -12,14 +12,27 @@ import { rerankByQuality } from '../rerank.js';
|
|
|
12
12
|
import { computeEmbedding, hasEmbedProvider } from '../embeddings.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.61';
|
|
16
16
|
const WHATS_NEW: Record<string, string[]> = {
|
|
17
|
+
'0.10.61': [
|
|
18
|
+
`🎯 **Phase 3C: 100 MCP tools milestone — zero-setup knowledge graph**`,
|
|
19
|
+
` 🔄 \`brain_from_git\` now auto-builds Person+File nodes from git history (zero setup!)`,
|
|
20
|
+
` 🔍 \`skill_gaps()\` — find domains with unresolved failures, missing attribution`,
|
|
21
|
+
` 📊 \`brain_coverage()\` — scored 0-100 health report: lessons · attribution · file coverage`,
|
|
22
|
+
],
|
|
23
|
+
'0.10.60': [
|
|
24
|
+
`🆕 **Phase 3B: File map + team overview + visibility**`,
|
|
25
|
+
` 📁 \`brain_file_map(file_paths=[...])\` — experts + lessons per file before you touch it`,
|
|
26
|
+
` 🗺️ \`team_expertise_map()\` — full team skills matrix in one table`,
|
|
27
|
+
` 🔒 \`visibility: "private"\` on \`learn_from_attempts\` — private notes never leak to smart_recall`,
|
|
28
|
+
` 📊 98 MCP tools`,
|
|
29
|
+
],
|
|
17
30
|
'0.10.59': [
|
|
18
31
|
`🆕 **Phase 3A: Org-wide knowledge graph**`,
|
|
19
32
|
` 👥 \`brain_who_knows(topic="...")\` — find your team's experts on any topic instantly`,
|
|
20
33
|
` 🕸️ Person + File nodes auto-built from \`learn_from_attempts(author="...", file_paths=[...])\``,
|
|
21
34
|
` 👤 Author attribution now shown inline in \`smart_recall\` results`,
|
|
22
|
-
` 📊 96 MCP tools ·
|
|
35
|
+
` 📊 96 MCP tools · 386 tests green`,
|
|
23
36
|
],
|
|
24
37
|
'0.10.58': [
|
|
25
38
|
`🧹 Zero lint warnings — all unused imports cleaned across every handler`,
|
|
@@ -80,7 +93,8 @@ type ApiFetch = <T>(path: string, options?: RequestInit) => Promise<T>;
|
|
|
80
93
|
export const BRAIN_TOOL_NAMES = new Set([
|
|
81
94
|
'learn_from_attempts', 'recall_best_solution', 'smart_recall',
|
|
82
95
|
'session_start', 'session_end', 'session_ping', 'session_handoff', 'auto_learn_session',
|
|
83
|
-
'brain_who_knows',
|
|
96
|
+
'brain_who_knows', 'brain_file_map', 'team_expertise_map',
|
|
97
|
+
'skill_gaps', 'brain_coverage',
|
|
84
98
|
]);
|
|
85
99
|
|
|
86
100
|
export async function handleBrainTool(
|
|
@@ -104,6 +118,7 @@ export async function handleBrainTool(
|
|
|
104
118
|
tags = [],
|
|
105
119
|
depends_on = [],
|
|
106
120
|
author = '',
|
|
121
|
+
visibility = 'team',
|
|
107
122
|
} = args as {
|
|
108
123
|
instance_id: string;
|
|
109
124
|
topic: string;
|
|
@@ -117,6 +132,7 @@ export async function handleBrainTool(
|
|
|
117
132
|
tags?: string[];
|
|
118
133
|
depends_on?: string[];
|
|
119
134
|
author?: string;
|
|
135
|
+
visibility?: 'public' | 'team' | 'private';
|
|
120
136
|
};
|
|
121
137
|
|
|
122
138
|
const redis = await getConnection(instance_id);
|
|
@@ -246,6 +262,7 @@ export async function handleBrainTool(
|
|
|
246
262
|
tags,
|
|
247
263
|
depends_on,
|
|
248
264
|
...(author ? { author } : {}),
|
|
265
|
+
visibility,
|
|
249
266
|
recall_count: recallCount,
|
|
250
267
|
ts,
|
|
251
268
|
verified_at: outcome === 'success' || outcome === 'partial' ? ts : undefined,
|
|
@@ -625,13 +642,13 @@ export async function handleBrainTool(
|
|
|
625
642
|
}
|
|
626
643
|
|
|
627
644
|
// ── Layer 2: Semantic search (parallel, optional) ────────────────────────
|
|
628
|
-
const inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
|
|
645
|
+
const inst = await apiFetch<Instance | null>(`/api/v1/instances/${instance_id}`).catch(() => null);
|
|
629
646
|
type SemHit = { key: string; similarity: number; content: string };
|
|
630
647
|
const semHits: SemHit[] = [];
|
|
631
|
-
if (inst
|
|
648
|
+
if (inst?.vector_token && hasEmbedProvider()) {
|
|
632
649
|
try {
|
|
633
650
|
const embedding = await computeEmbedding(query);
|
|
634
|
-
const vectorUrl = `https://api.cachly.dev/v1/sem/${inst
|
|
651
|
+
const vectorUrl = `https://api.cachly.dev/v1/sem/${inst!.vector_token}`;
|
|
635
652
|
const searchRes = await fetch(`${vectorUrl}/search`, {
|
|
636
653
|
method: 'POST',
|
|
637
654
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -736,7 +753,14 @@ export async function handleBrainTool(
|
|
|
736
753
|
}
|
|
737
754
|
} catch { /* non-critical — keyword + semantic always available */ }
|
|
738
755
|
|
|
739
|
-
|
|
756
|
+
// Filter private lessons — they are only accessible via exact recall_best_solution
|
|
757
|
+
const hybridResults = [...hybridMap.values()]
|
|
758
|
+
.filter(r => {
|
|
759
|
+
if (!r.key.startsWith('cachly:lesson:best:')) return true;
|
|
760
|
+
const ld = safeJsonParse<{ visibility?: string }>(r.content, {});
|
|
761
|
+
return ld.visibility !== 'private';
|
|
762
|
+
})
|
|
763
|
+
.sort((a, b) => b.hybridScore - a.hybridScore);
|
|
740
764
|
|
|
741
765
|
// ── Build output ──────────────────────────────────────────────────────────
|
|
742
766
|
const lines: string[] = [`🧠 **Smart Recall** for: _"${query}"_\n`];
|
|
@@ -2123,6 +2147,403 @@ export async function handleBrainTool(
|
|
|
2123
2147
|
return lines.join('\n');
|
|
2124
2148
|
}
|
|
2125
2149
|
|
|
2150
|
+
// ── brain_file_map ────────────────────────────────────────────────────────
|
|
2151
|
+
// Phase 3B: "What do we know about these files?" — experts + lessons per file.
|
|
2152
|
+
case 'brain_file_map': {
|
|
2153
|
+
const {
|
|
2154
|
+
instance_id,
|
|
2155
|
+
file_paths = [],
|
|
2156
|
+
} = args as { instance_id: string; file_paths: string[] };
|
|
2157
|
+
|
|
2158
|
+
if (!file_paths.length) return '⚠️ Pass at least one file path.';
|
|
2159
|
+
|
|
2160
|
+
const redis = await getConnection(instance_id);
|
|
2161
|
+
const lines: string[] = [
|
|
2162
|
+
`## 📁 File Knowledge Map`,
|
|
2163
|
+
``,
|
|
2164
|
+
`_What cachly knows about the files you're about to touch:_`,
|
|
2165
|
+
``,
|
|
2166
|
+
];
|
|
2167
|
+
|
|
2168
|
+
for (const fp of file_paths.slice(0, 10)) {
|
|
2169
|
+
lines.push(`### \`${fp}\``);
|
|
2170
|
+
|
|
2171
|
+
// ── Experts via CKG file nodes ─────────────────────────────────────
|
|
2172
|
+
const fileId = `file:${ckgSlug(fp)}`;
|
|
2173
|
+
const inbound = await redis.smembers(`cachly:ckg:idx:to:${fileId}`);
|
|
2174
|
+
type ExpertEntry = { handle: string; touches: number; lastSeen: string };
|
|
2175
|
+
const experts: ExpertEntry[] = [];
|
|
2176
|
+
for (const ek of inbound) {
|
|
2177
|
+
const er = await redis.get(ek);
|
|
2178
|
+
if (!er) continue;
|
|
2179
|
+
const edge = safeJsonParse<CKGEdge | null>(er, null);
|
|
2180
|
+
if (!edge || edge.edgeType !== 'touched') continue;
|
|
2181
|
+
const personRaw = await redis.get(`cachly:ckg:node:${edge.from}`);
|
|
2182
|
+
if (!personRaw) continue;
|
|
2183
|
+
const pn = safeJsonParse<PersonNode | null>(personRaw, null);
|
|
2184
|
+
if (!pn || pn.type !== 'person') continue;
|
|
2185
|
+
experts.push({ handle: pn.handle, touches: edge.trials, lastSeen: edge.last_updated });
|
|
2186
|
+
}
|
|
2187
|
+
experts.sort((a, b) => b.touches - a.touches);
|
|
2188
|
+
|
|
2189
|
+
if (experts.length > 0) {
|
|
2190
|
+
const expLine = experts.slice(0, 5).map((e, i) => {
|
|
2191
|
+
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}.`;
|
|
2192
|
+
const days = Math.floor((Date.now() - new Date(e.lastSeen).getTime()) / 86400000);
|
|
2193
|
+
const ago = days < 1 ? 'today' : days < 7 ? `${days}d ago` : `${Math.floor(days / 7)}w ago`;
|
|
2194
|
+
return `${medal} **${e.handle}** (${e.touches}× · ${ago})`;
|
|
2195
|
+
}).join(' · ');
|
|
2196
|
+
lines.push(`**Experts:** ${expLine}`);
|
|
2197
|
+
} else {
|
|
2198
|
+
lines.push(`**Experts:** _None yet — add \`author\` to \`learn_from_attempts\` calls_`);
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
// ── Related lessons via BM25 on filename tokens ────────────────────
|
|
2202
|
+
const tokens = fp.replace(/[^a-z0-9]/gi, ' ').split(/\s+/).filter(t => t.length >= 4);
|
|
2203
|
+
const lessonQuery = tokens.slice(0, 3).join(' ');
|
|
2204
|
+
const relatedLessons: string[] = [];
|
|
2205
|
+
if (lessonQuery) {
|
|
2206
|
+
try {
|
|
2207
|
+
const hits = await keywordSearch(redis, ['cachly:lesson:best:*'], lessonQuery, 4);
|
|
2208
|
+
for (const h of hits) {
|
|
2209
|
+
const ld = safeJsonParse<{
|
|
2210
|
+
topic?: string; outcome?: string; what_worked?: string;
|
|
2211
|
+
severity?: string; author?: string; visibility?: string;
|
|
2212
|
+
}>(h.content, {});
|
|
2213
|
+
if (ld.visibility === 'private') continue;
|
|
2214
|
+
const emoji = ld.outcome === 'success' ? '✅' : ld.outcome === 'partial' ? '⚠️' : '❌';
|
|
2215
|
+
const sev = ld.severity === 'critical' ? ' 🔴' : ld.severity === 'major' ? ' 🟡' : '';
|
|
2216
|
+
const by = ld.author ? ` · 👤 ${ld.author}` : '';
|
|
2217
|
+
const topic = h.key.replace('cachly:lesson:best:', '');
|
|
2218
|
+
relatedLessons.push(` - ${emoji} \`${topic}\`${sev}${by} — ${(ld.what_worked ?? '').slice(0, 100)}`);
|
|
2219
|
+
}
|
|
2220
|
+
} catch { /* non-critical */ }
|
|
2221
|
+
}
|
|
2222
|
+
// Also: exact file_paths match via lesson scan
|
|
2223
|
+
if (relatedLessons.length < 2) {
|
|
2224
|
+
const scanKeys: string[] = [];
|
|
2225
|
+
const stream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
|
|
2226
|
+
await new Promise<void>((res, rej) => {
|
|
2227
|
+
stream.on('data', (b: string[]) => scanKeys.push(...b));
|
|
2228
|
+
stream.on('end', res); stream.on('error', rej);
|
|
2229
|
+
});
|
|
2230
|
+
for (const k of scanKeys.slice(0, 300)) {
|
|
2231
|
+
const raw = await redis.get(k);
|
|
2232
|
+
if (!raw) continue;
|
|
2233
|
+
const ld = safeJsonParse<{
|
|
2234
|
+
topic?: string; outcome?: string; what_worked?: string;
|
|
2235
|
+
severity?: string; author?: string; visibility?: string; file_paths?: string[];
|
|
2236
|
+
}>(raw, {});
|
|
2237
|
+
if (ld.visibility === 'private') continue;
|
|
2238
|
+
if (!(ld.file_paths ?? []).includes(fp)) continue;
|
|
2239
|
+
const topic = k.replace('cachly:lesson:best:', '');
|
|
2240
|
+
if (relatedLessons.some(l => l.includes(`\`${topic}\``))) continue;
|
|
2241
|
+
const emoji = ld.outcome === 'success' ? '✅' : ld.outcome === 'partial' ? '⚠️' : '❌';
|
|
2242
|
+
const sev = ld.severity === 'critical' ? ' 🔴' : ld.severity === 'major' ? ' 🟡' : '';
|
|
2243
|
+
const by = ld.author ? ` · 👤 ${ld.author}` : '';
|
|
2244
|
+
relatedLessons.push(` - ${emoji} \`${topic}\`${sev}${by} — ${(ld.what_worked ?? '').slice(0, 100)}`);
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
if (relatedLessons.length > 0) {
|
|
2249
|
+
lines.push(`**Related lessons:**`);
|
|
2250
|
+
lines.push(...relatedLessons.slice(0, 5));
|
|
2251
|
+
} else {
|
|
2252
|
+
lines.push(`**Related lessons:** _None yet_`);
|
|
2253
|
+
}
|
|
2254
|
+
lines.push(``);
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
lines.push(
|
|
2258
|
+
`---`,
|
|
2259
|
+
`_Attribution builds with \`learn_from_attempts(file_paths=[...], author="...")\`. Run this before committing to track what changed and why._`,
|
|
2260
|
+
);
|
|
2261
|
+
return lines.join('\n');
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
// ── team_expertise_map ────────────────────────────────────────────────────
|
|
2265
|
+
// Phase 3B: Full team expertise overview — who knows what, at a glance.
|
|
2266
|
+
case 'team_expertise_map': {
|
|
2267
|
+
const {
|
|
2268
|
+
instance_id,
|
|
2269
|
+
top_n = 20,
|
|
2270
|
+
} = args as { instance_id: string; top_n?: number };
|
|
2271
|
+
|
|
2272
|
+
const redis = await getConnection(instance_id);
|
|
2273
|
+
|
|
2274
|
+
// Scan all person nodes
|
|
2275
|
+
const personKeys: string[] = [];
|
|
2276
|
+
const pStream = redis.scanStream({ match: 'cachly:ckg:node:person:*', count: 200 });
|
|
2277
|
+
await new Promise<void>((res, rej) => {
|
|
2278
|
+
pStream.on('data', (b: string[]) => personKeys.push(...b));
|
|
2279
|
+
pStream.on('end', res); pStream.on('error', rej);
|
|
2280
|
+
});
|
|
2281
|
+
|
|
2282
|
+
if (personKeys.length === 0) {
|
|
2283
|
+
return [
|
|
2284
|
+
`## 🗺️ Team Expertise Map`,
|
|
2285
|
+
``,
|
|
2286
|
+
`No contributors yet.`,
|
|
2287
|
+
``,
|
|
2288
|
+
`Add \`author="handle"\` to any \`learn_from_attempts\` call to start building the map.`,
|
|
2289
|
+
].join('\n');
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
type PersonEntry = { handle: string; lessonCount: number; domains: Map<string, number>; lastActive: string };
|
|
2293
|
+
const people: PersonEntry[] = [];
|
|
2294
|
+
|
|
2295
|
+
for (const pk of personKeys) {
|
|
2296
|
+
const raw = await redis.get(pk);
|
|
2297
|
+
if (!raw) continue;
|
|
2298
|
+
const pn = safeJsonParse<PersonNode | null>(raw, null);
|
|
2299
|
+
if (!pn || pn.type !== 'person') continue;
|
|
2300
|
+
|
|
2301
|
+
// Count authored lessons + collect domains from authored edges
|
|
2302
|
+
const outEdgeKeys = await redis.smembers(`cachly:ckg:idx:from:${pn.id}`);
|
|
2303
|
+
const domainCounts = new Map<string, number>();
|
|
2304
|
+
let lessonCount = 0;
|
|
2305
|
+
for (const ek of outEdgeKeys) {
|
|
2306
|
+
const er = await redis.get(ek);
|
|
2307
|
+
if (!er) continue;
|
|
2308
|
+
const edge = safeJsonParse<CKGEdge | null>(er, null);
|
|
2309
|
+
if (!edge || edge.edgeType !== 'authored') continue;
|
|
2310
|
+
lessonCount++;
|
|
2311
|
+
const domain = edge.to.split(':')[0] ?? 'unknown';
|
|
2312
|
+
domainCounts.set(domain, (domainCounts.get(domain) ?? 0) + 1);
|
|
2313
|
+
}
|
|
2314
|
+
people.push({ handle: pn.handle, lessonCount, domains: domainCounts, lastActive: pn.last_active });
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
people.sort((a, b) => b.lessonCount - a.lessonCount);
|
|
2318
|
+
const topPeople = people.slice(0, top_n);
|
|
2319
|
+
|
|
2320
|
+
const lines = [
|
|
2321
|
+
`## 🗺️ Team Expertise Map`,
|
|
2322
|
+
``,
|
|
2323
|
+
`**${topPeople.length}** contributor${topPeople.length !== 1 ? 's' : ''} tracked:`,
|
|
2324
|
+
``,
|
|
2325
|
+
`| # | Handle | Lessons | Top domains | Last active |`,
|
|
2326
|
+
`|---|--------|---------|-------------|-------------|`,
|
|
2327
|
+
];
|
|
2328
|
+
|
|
2329
|
+
for (const [i, p] of topPeople.entries()) {
|
|
2330
|
+
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}`;
|
|
2331
|
+
const days = Math.floor((Date.now() - new Date(p.lastActive).getTime()) / 86400000);
|
|
2332
|
+
const ago = days < 1 ? 'today' : days < 7 ? `${days}d ago` : days < 30 ? `${Math.floor(days / 7)}w ago` : `${Math.floor(days / 30)}mo ago`;
|
|
2333
|
+
const domainStr = [...p.domains.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([d, c]) => `${d}(${c})`).join(', ') || '—';
|
|
2334
|
+
lines.push(`| ${medal} | **${p.handle}** | ${p.lessonCount} | ${domainStr} | ${ago} |`);
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
lines.push(
|
|
2338
|
+
``,
|
|
2339
|
+
`---`,
|
|
2340
|
+
`_Use \`brain_who_knows(topic="...")\` to find the expert for a specific problem._`,
|
|
2341
|
+
);
|
|
2342
|
+
return lines.join('\n');
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
// ── skill_gaps ────────────────────────────────────────────────────────────
|
|
2346
|
+
// Phase 3C: Show knowledge blind spots — domains/files with no success lessons,
|
|
2347
|
+
// no attribution, or only failures.
|
|
2348
|
+
case 'skill_gaps': {
|
|
2349
|
+
const {
|
|
2350
|
+
instance_id,
|
|
2351
|
+
min_failures = 1,
|
|
2352
|
+
} = args as { instance_id: string; min_failures?: number };
|
|
2353
|
+
|
|
2354
|
+
const redis = await getConnection(instance_id);
|
|
2355
|
+
|
|
2356
|
+
// Scan all best lessons
|
|
2357
|
+
const lessonKeys: string[] = [];
|
|
2358
|
+
const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 500 });
|
|
2359
|
+
await new Promise<void>((res, rej) => {
|
|
2360
|
+
lStream.on('data', (b: string[]) => lessonKeys.push(...b));
|
|
2361
|
+
lStream.on('end', res); lStream.on('error', rej);
|
|
2362
|
+
});
|
|
2363
|
+
|
|
2364
|
+
type DomainStats = { successes: number; failures: number; attributed: number; topics: string[] };
|
|
2365
|
+
const domainMap = new Map<string, DomainStats>();
|
|
2366
|
+
let totalAttributed = 0;
|
|
2367
|
+
let totalPrivate = 0;
|
|
2368
|
+
|
|
2369
|
+
for (const k of lessonKeys) {
|
|
2370
|
+
const raw = await redis.get(k);
|
|
2371
|
+
if (!raw) continue;
|
|
2372
|
+
const l = safeJsonParse<{
|
|
2373
|
+
topic?: string; outcome?: string; author?: string; visibility?: string;
|
|
2374
|
+
}>(raw, {});
|
|
2375
|
+
const domain = (l.topic ?? '').split(':')[0] ?? 'unknown';
|
|
2376
|
+
const s = domainMap.get(domain) ?? { successes: 0, failures: 0, attributed: 0, topics: [] };
|
|
2377
|
+
if (l.outcome === 'success' || l.outcome === 'partial') s.successes++;
|
|
2378
|
+
else s.failures++;
|
|
2379
|
+
if (l.author) { s.attributed++; totalAttributed++; }
|
|
2380
|
+
if (l.visibility === 'private') totalPrivate++;
|
|
2381
|
+
s.topics.push(l.topic ?? '');
|
|
2382
|
+
domainMap.set(domain, s);
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
// Identify gaps
|
|
2386
|
+
type Gap = { domain: string; type: string; severity: string; detail: string };
|
|
2387
|
+
const gaps: Gap[] = [];
|
|
2388
|
+
|
|
2389
|
+
for (const [domain, s] of domainMap) {
|
|
2390
|
+
if (s.failures >= min_failures && s.successes === 0) {
|
|
2391
|
+
gaps.push({
|
|
2392
|
+
domain, type: 'unresolved failures',
|
|
2393
|
+
severity: s.failures >= 3 ? '🔴 critical' : '🟡 warn',
|
|
2394
|
+
detail: `${s.failures} failure${s.failures !== 1 ? 's' : ''}, 0 solutions — use \`learn_from_attempts\` to store what fixed it`,
|
|
2395
|
+
});
|
|
2396
|
+
}
|
|
2397
|
+
if (s.successes + s.failures >= 3 && s.attributed === 0) {
|
|
2398
|
+
gaps.push({
|
|
2399
|
+
domain, type: 'no attribution',
|
|
2400
|
+
severity: '🔵 info',
|
|
2401
|
+
detail: `${s.successes + s.failures} lessons with no \`author\` field — attribution lost, \`brain_who_knows\` can't help here`,
|
|
2402
|
+
});
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
// Check for domains with person nodes but no lessons (contributors without lessons)
|
|
2407
|
+
const personKeys: string[] = [];
|
|
2408
|
+
const pStream = redis.scanStream({ match: 'cachly:ckg:node:person:*', count: 200 });
|
|
2409
|
+
await new Promise<void>((res, rej) => {
|
|
2410
|
+
pStream.on('data', (b: string[]) => personKeys.push(...b));
|
|
2411
|
+
pStream.on('end', res); pStream.on('error', rej);
|
|
2412
|
+
});
|
|
2413
|
+
|
|
2414
|
+
const lines = [
|
|
2415
|
+
`## 🔍 Skill Gaps — Knowledge Blind Spots`,
|
|
2416
|
+
``,
|
|
2417
|
+
`**${lessonKeys.length}** lessons scanned · **${totalAttributed}** attributed · **${gaps.length}** gap${gaps.length !== 1 ? 's' : ''} found`,
|
|
2418
|
+
totalPrivate > 0 ? `_(${totalPrivate} private lessons excluded from gap analysis)_` : '',
|
|
2419
|
+
``,
|
|
2420
|
+
].filter(Boolean);
|
|
2421
|
+
|
|
2422
|
+
if (gaps.length === 0) {
|
|
2423
|
+
lines.push(`✅ No significant knowledge gaps detected. All domains with failures have at least one success lesson.`);
|
|
2424
|
+
} else {
|
|
2425
|
+
const bySev = [
|
|
2426
|
+
...gaps.filter(g => g.severity.includes('critical')),
|
|
2427
|
+
...gaps.filter(g => g.severity.includes('warn')),
|
|
2428
|
+
...gaps.filter(g => g.severity.includes('info')),
|
|
2429
|
+
];
|
|
2430
|
+
for (const g of bySev.slice(0, 15)) {
|
|
2431
|
+
lines.push(`${g.severity} **\`${g.domain}\`** — ${g.type}`);
|
|
2432
|
+
lines.push(` ${g.detail}`);
|
|
2433
|
+
lines.push(``);
|
|
2434
|
+
}
|
|
2435
|
+
if (gaps.length > 15) lines.push(`_…and ${gaps.length - 15} more gaps. Fix critical ones first._`);
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
lines.push(
|
|
2439
|
+
`---`,
|
|
2440
|
+
`_Fix gaps with \`learn_from_attempts(topic="${gaps[0]?.domain ?? 'your:topic'}", outcome="success", what_worked="...")\`_`,
|
|
2441
|
+
`_Add \`author="name"\` to every call to enable \`brain_who_knows\` + \`team_expertise_map\`._`,
|
|
2442
|
+
);
|
|
2443
|
+
|
|
2444
|
+
return lines.join('\n');
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
// ── brain_coverage ────────────────────────────────────────────────────────
|
|
2448
|
+
// Phase 3C: Knowledge-coverage health score for the repository.
|
|
2449
|
+
case 'brain_coverage': {
|
|
2450
|
+
const {
|
|
2451
|
+
instance_id,
|
|
2452
|
+
repo_path = '.',
|
|
2453
|
+
} = args as { instance_id: string; repo_path?: string };
|
|
2454
|
+
|
|
2455
|
+
const redis = await getConnection(instance_id);
|
|
2456
|
+
|
|
2457
|
+
// Count lessons
|
|
2458
|
+
const lessonKeys: string[] = [];
|
|
2459
|
+
const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 500 });
|
|
2460
|
+
await new Promise<void>((res, rej) => {
|
|
2461
|
+
lStream.on('data', (b: string[]) => lessonKeys.push(...b));
|
|
2462
|
+
lStream.on('end', res); lStream.on('error', rej);
|
|
2463
|
+
});
|
|
2464
|
+
|
|
2465
|
+
// Count person nodes
|
|
2466
|
+
const personKeys: string[] = [];
|
|
2467
|
+
const pStream = redis.scanStream({ match: 'cachly:ckg:node:person:*', count: 200 });
|
|
2468
|
+
await new Promise<void>((res, rej) => {
|
|
2469
|
+
pStream.on('data', (b: string[]) => personKeys.push(...b));
|
|
2470
|
+
pStream.on('end', res); pStream.on('error', rej);
|
|
2471
|
+
});
|
|
2472
|
+
|
|
2473
|
+
// Count file nodes in CKG
|
|
2474
|
+
const fileKeys: string[] = [];
|
|
2475
|
+
const fStream = redis.scanStream({ match: 'cachly:ckg:node:file:*', count: 500 });
|
|
2476
|
+
await new Promise<void>((res, rej) => {
|
|
2477
|
+
fStream.on('data', (b: string[]) => fileKeys.push(...b));
|
|
2478
|
+
fStream.on('end', res); fStream.on('error', rej);
|
|
2479
|
+
});
|
|
2480
|
+
|
|
2481
|
+
// Get git file count for comparison (optional — non-critical if not a git repo)
|
|
2482
|
+
let gitFileCount = 0;
|
|
2483
|
+
let _gitFileCountLabel = 'n/a';
|
|
2484
|
+
try {
|
|
2485
|
+
const { execSync } = await import('node:child_process');
|
|
2486
|
+
const { resolve } = await import('node:path');
|
|
2487
|
+
const repoDir = resolve(repo_path);
|
|
2488
|
+
const output = execSync('git ls-files', { cwd: repoDir, encoding: 'utf-8', stdio: 'pipe' });
|
|
2489
|
+
gitFileCount = output.trim().split('\n').filter(Boolean).length;
|
|
2490
|
+
_gitFileCountLabel = String(gitFileCount);
|
|
2491
|
+
} catch { /* not a git repo — skip */ }
|
|
2492
|
+
|
|
2493
|
+
// Lesson quality breakdown
|
|
2494
|
+
let successCount = 0; let failureCount = 0; let attributedCount = 0;
|
|
2495
|
+
const domainSet = new Set<string>();
|
|
2496
|
+
for (const k of lessonKeys) {
|
|
2497
|
+
const raw = await redis.get(k);
|
|
2498
|
+
if (!raw) continue;
|
|
2499
|
+
const l = safeJsonParse<{ outcome?: string; author?: string; topic?: string }>(raw, {});
|
|
2500
|
+
if (l.outcome === 'success' || l.outcome === 'partial') successCount++;
|
|
2501
|
+
else failureCount++;
|
|
2502
|
+
if (l.author) attributedCount++;
|
|
2503
|
+
domainSet.add((l.topic ?? '').split(':')[0] ?? 'unknown');
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
const fileCoverage = gitFileCount > 0
|
|
2507
|
+
? `${Math.round((fileKeys.length / gitFileCount) * 100)}% (${fileKeys.length}/${gitFileCount} files)`
|
|
2508
|
+
: `${fileKeys.length} files tracked`;
|
|
2509
|
+
|
|
2510
|
+
const attributionPct = lessonKeys.length > 0
|
|
2511
|
+
? Math.round((attributedCount / lessonKeys.length) * 100)
|
|
2512
|
+
: 0;
|
|
2513
|
+
|
|
2514
|
+
const overallScore = Math.round(
|
|
2515
|
+
(Math.min(1, lessonKeys.length / 50) * 25) + // lesson volume (25 pts)
|
|
2516
|
+
(Math.min(1, successCount / Math.max(1, lessonKeys.length)) * 25) + // success ratio (25 pts)
|
|
2517
|
+
(Math.min(1, attributionPct / 100) * 25) + // attribution completeness (25 pts)
|
|
2518
|
+
(Math.min(1, personKeys.length / 5) * 25) // team engagement (25 pts)
|
|
2519
|
+
);
|
|
2520
|
+
|
|
2521
|
+
const scoreEmoji = overallScore >= 80 ? '🟢' : overallScore >= 50 ? '🟡' : '🔴';
|
|
2522
|
+
|
|
2523
|
+
return [
|
|
2524
|
+
`## 📊 Brain Coverage Report`,
|
|
2525
|
+
``,
|
|
2526
|
+
`${scoreEmoji} **Overall score: ${overallScore}/100**`,
|
|
2527
|
+
``,
|
|
2528
|
+
`| Metric | Value |`,
|
|
2529
|
+
`|--------|-------|`,
|
|
2530
|
+
`| Total lessons | **${lessonKeys.length}** (${successCount} success · ${failureCount} failure) |`,
|
|
2531
|
+
`| Problem domains | **${domainSet.size}** |`,
|
|
2532
|
+
`| Attribution | **${attributedCount}/${lessonKeys.length}** (${attributionPct}%) |`,
|
|
2533
|
+
`| Contributors | **${personKeys.length}** tracked in knowledge graph |`,
|
|
2534
|
+
`| File coverage | **${fileCoverage}** |`,
|
|
2535
|
+
``,
|
|
2536
|
+
`**Score breakdown (each 0–25):**`,
|
|
2537
|
+
`- Lesson volume: **${Math.round(Math.min(1, lessonKeys.length / 50) * 25)}/25** _(target: 50+ lessons)_`,
|
|
2538
|
+
`- Success ratio: **${Math.round(Math.min(1, successCount / Math.max(1, lessonKeys.length)) * 25)}/25** _(target: all lessons resolved)_`,
|
|
2539
|
+
`- Attribution: **${Math.round(Math.min(1, attributionPct / 100) * 25)}/25** _(target: 100% attributed)_`,
|
|
2540
|
+
`- Team engagement: **${Math.round(Math.min(1, personKeys.length / 5) * 25)}/25** _(target: 5+ contributors)_`,
|
|
2541
|
+
``,
|
|
2542
|
+
`---`,
|
|
2543
|
+
`_Boost score: run \`brain_from_git\` · add \`author\` to \`learn_from_attempts\` · fix gaps with \`skill_gaps\`_`,
|
|
2544
|
+
].join('\n');
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2126
2547
|
// ── sync_file_changes ─────────────────────────────────────────────────────
|
|
2127
2548
|
|
|
2128
2549
|
default:
|
package/src/handlers/fedbrain.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHmac } from 'node:crypto';
|
|
2
2
|
import type { Redis } from 'ioredis';
|
|
3
|
-
import { ckgSlug, extractProblemConcept, ckgUpsertNode, ckgUpdateEdge
|
|
3
|
+
import { ckgSlug, extractProblemConcept, ckgUpsertNode, ckgUpdateEdge,
|
|
4
|
+
ckgUpsertPersonNode, ckgUpsertFileNode } from '../ckg.js';
|
|
4
5
|
import type { CKGEdge, CKGNode } from '../ckg.js';
|
|
5
6
|
import { safeJsonParse } from '../utils.js';
|
|
6
7
|
|
|
@@ -920,12 +921,11 @@ export async function handleFedbrainTool(
|
|
|
920
921
|
isIncremental = Boolean(lastSha);
|
|
921
922
|
}
|
|
922
923
|
|
|
923
|
-
// Build git log command
|
|
924
|
-
// In incremental mode, only fetch commits after the last processed SHA
|
|
924
|
+
// Build git log command — use --name-only to get changed files per commit
|
|
925
925
|
const sinceFlag = since ? `--since="${since}"` : '';
|
|
926
926
|
const afterFlag = isIncremental ? `${lastSha}..HEAD` : '';
|
|
927
927
|
const revRange = afterFlag || branch;
|
|
928
|
-
const logCmd = `git log ${revRange} ${sinceFlag} --pretty=format:"
|
|
928
|
+
const logCmd = `git log ${revRange} ${sinceFlag} --pretty=format:"COMMIT|||%H|||%s|||%ad|||%an" --date=short --no-merges --name-only -n ${maxCommits}`;
|
|
929
929
|
|
|
930
930
|
let logOutput = '';
|
|
931
931
|
try {
|
|
@@ -934,10 +934,21 @@ export async function handleFedbrainTool(
|
|
|
934
934
|
return `❌ git log failed: ${(e as Error).message}. Check \`repo_path\` and \`branch\`.`;
|
|
935
935
|
}
|
|
936
936
|
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
937
|
+
// Parse multi-line output: each commit block = header line + blank line + file lines
|
|
938
|
+
type CommitEntry = { sha: string; subject: string; date: string; author: string; files: string[] };
|
|
939
|
+
const commits: CommitEntry[] = [];
|
|
940
|
+
let current: CommitEntry | null = null;
|
|
941
|
+
for (const raw of logOutput.split('\n')) {
|
|
942
|
+
const line = raw.trim();
|
|
943
|
+
if (line.startsWith('COMMIT|||')) {
|
|
944
|
+
if (current) commits.push(current);
|
|
945
|
+
const [, sha, subject, date, author] = line.split('|||');
|
|
946
|
+
current = { sha: (sha ?? '').trim(), subject: (subject ?? '').trim(), date: (date ?? '').trim(), author: (author ?? '').trim(), files: [] };
|
|
947
|
+
} else if (line && current) {
|
|
948
|
+
current.files.push(line);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
if (current) commits.push(current);
|
|
941
952
|
|
|
942
953
|
if (commits.length === 0) {
|
|
943
954
|
if (isIncremental) {
|
|
@@ -994,13 +1005,17 @@ export async function handleFedbrainTool(
|
|
|
994
1005
|
const topic = `${category}:${domain}`;
|
|
995
1006
|
categoryCount.set(category, (categoryCount.get(category) ?? 0) + 1);
|
|
996
1007
|
|
|
1008
|
+
const commitFiles = commit.files.filter(f => f.length > 0).slice(0, 12);
|
|
997
1009
|
const lessonObj = {
|
|
998
1010
|
topic, outcome, severity,
|
|
999
1011
|
what_worked: commit.subject.slice(0, 200),
|
|
1000
1012
|
what_failed: '',
|
|
1001
1013
|
context: `git:${commit.sha.slice(0, 8)} by ${commit.author} on ${commit.date}`,
|
|
1002
|
-
|
|
1014
|
+
author: commit.author || undefined,
|
|
1015
|
+
file_paths: commitFiles,
|
|
1016
|
+
commands: [`git show ${commit.sha.slice(0, 8)}`],
|
|
1003
1017
|
tags: ['brain_from_git', category, 'git-history'],
|
|
1018
|
+
visibility: 'team',
|
|
1004
1019
|
depends_on: [], recall_count: 0, ts, verified_at: ts,
|
|
1005
1020
|
confidence: 0.55, // lower confidence for auto-inferred lessons
|
|
1006
1021
|
audit_trail: [{ ts, action: 'brain_from_git', sha: commit.sha.slice(0, 8) }],
|
|
@@ -1021,9 +1036,22 @@ export async function handleFedbrainTool(
|
|
|
1021
1036
|
await redis.ltrim('cachly:lessons:brain_from_git:all', -500, -1);
|
|
1022
1037
|
await redis.expire('cachly:lessons:brain_from_git:all', 90 * 86400);
|
|
1023
1038
|
|
|
1024
|
-
// Update CKG
|
|
1039
|
+
// Update CKG — concept node + person node + file nodes
|
|
1025
1040
|
const conceptId = ckgSlug(topic);
|
|
1026
1041
|
await ckgUpsertNode(redis, conceptId, category, 'git-derived');
|
|
1042
|
+
|
|
1043
|
+
// Phase 3A: auto-build person + file nodes from git history
|
|
1044
|
+
if (commit.author) {
|
|
1045
|
+
try {
|
|
1046
|
+
const personId = await ckgUpsertPersonNode(redis, commit.author, category);
|
|
1047
|
+
await ckgUpdateEdge(redis, personId, 'authored', conceptId, outcome === 'success', outcome === 'partial');
|
|
1048
|
+
for (const fp of commitFiles.slice(0, 8)) {
|
|
1049
|
+
const fileId = await ckgUpsertFileNode(redis, fp);
|
|
1050
|
+
await ckgUpdateEdge(redis, personId, 'touched', fileId, true);
|
|
1051
|
+
}
|
|
1052
|
+
} catch { /* non-critical */ }
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1027
1055
|
ingested++;
|
|
1028
1056
|
}
|
|
1029
1057
|
|
package/src/index.ts
CHANGED
|
@@ -73,7 +73,7 @@ import { Redis } from 'ioredis';
|
|
|
73
73
|
const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
|
|
74
74
|
let JWT = process.env.CACHLY_JWT ?? '';
|
|
75
75
|
const _EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
|
|
76
|
-
const CURRENT_VERSION = '0.10.
|
|
76
|
+
const CURRENT_VERSION = '0.10.61';
|
|
77
77
|
|
|
78
78
|
// Max time to wait for a freshly-provisioned instance to become "running" before
|
|
79
79
|
// giving up. Free-tier provisioning in high-latency regions can take 45–90s, so the
|
|
@@ -572,7 +572,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
572
572
|
'',
|
|
573
573
|
'After sign-in: call **any tool again** — your Brain activates instantly.',
|
|
574
574
|
'',
|
|
575
|
-
'✨ Free forever · No credit card ·
|
|
575
|
+
'✨ Free forever · No credit card · 100 MCP tools · GDPR · EU servers',
|
|
576
576
|
].join('\n');
|
|
577
577
|
}
|
|
578
578
|
|
|
@@ -1816,7 +1816,7 @@ if (!process.argv[2] && process.stdout.isTTY) {
|
|
|
1816
1816
|
console.log(' \x1b[36m npx @cachly-dev/mcp-server@latest upgrade\x1b[0m ← Check for updates');
|
|
1817
1817
|
console.log('');
|
|
1818
1818
|
console.log(' \x1b[90mWorks with: Claude Code · Cursor · Windsurf · GitHub Copilot · Cline · Zed\x1b[0m');
|
|
1819
|
-
console.log(' \x1b[90mFree forever · GDPR · German servers ·
|
|
1819
|
+
console.log(' \x1b[90mFree forever · GDPR · German servers · 100 MCP tools\x1b[0m');
|
|
1820
1820
|
console.log('');
|
|
1821
1821
|
process.exit(0);
|
|
1822
1822
|
}
|