@cachly-dev/mcp-server 0.10.61 → 0.10.64
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.tmp +0 -0
- package/PROGRESS.md +137 -0
- package/README.md +210 -180
- package/VISION_10X.md +133 -0
- package/dist/src/__tests__/brain-flow.test.js +102 -1
- package/dist/src/__tests__/brain-flow.test.js.map +1 -1
- package/dist/src/__tests__/utils.test.d.ts +1 -0
- package/dist/src/__tests__/utils.test.js +83 -0
- package/dist/src/__tests__/utils.test.js.map +1 -0
- package/dist/src/handlers/brain.js +118 -64
- package/dist/src/handlers/brain.js.map +1 -1
- package/dist/src/handlers/fedbrain.js +4 -2
- package/dist/src/handlers/fedbrain.js.map +1 -1
- package/dist/src/index.js +3 -3
- package/dist/src/tools.d.ts +17 -0
- package/dist/src/tools.js +16 -0
- package/dist/src/tools.js.map +1 -1
- package/dist/src/utils.d.ts +33 -0
- package/dist/src/utils.js +76 -0
- package/dist/src/utils.js.map +1 -1
- package/package.json +2 -2
- package/server.json +3 -3
- package/src/__tests__/brain-flow.test.ts +119 -1
- package/src/__tests__/utils.test.ts +81 -0
- package/src/handlers/brain.ts +130 -60
- package/src/handlers/fedbrain.ts +3 -2
- package/src/index.ts +3 -3
- package/src/tools.ts +17 -0
- package/src/utils.ts +80 -0
package/src/handlers/brain.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { calculateConfidence, confidenceBadge, STRUCTURED_TEMPLATES,
|
|
|
4
4
|
CONFIDENCE_WARN_VALUE, CONFIDENCE_STALE_VALUE, CONFIDENCE_WARN_DAYS } from '../confidence.js';
|
|
5
5
|
import { ckgSlug, extractProblemConcept, ckgUpsertNode, ckgUpdateEdge,
|
|
6
6
|
ckgUpsertPersonNode, ckgUpsertFileNode } from '../ckg.js';
|
|
7
|
-
import { safeJsonParse } from '../utils.js';
|
|
7
|
+
import { safeJsonParse, scanKeys } from '../utils.js';
|
|
8
8
|
import type { CKGEdge, CKGNode, PersonNode } from '../ckg.js';
|
|
9
9
|
import { keywordSearch, tokenize, splitMultiQuery, levenshtein,
|
|
10
10
|
indexVocab as _indexVocab } from '../search.js';
|
|
@@ -12,8 +12,14 @@ 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.64';
|
|
16
16
|
const WHATS_NEW: Record<string, string[]> = {
|
|
17
|
+
'0.10.64': [
|
|
18
|
+
`📈 **The three decisive metrics, now measurable**`,
|
|
19
|
+
` ⏱️ \`brain_metrics()\` — time-to-first-recall, recall-lift, team-knowledge-reuse in one view`,
|
|
20
|
+
` 👥 Cross-author reuse tracked: recall a teammate's lesson → counted + surfaced inline`,
|
|
21
|
+
` 🛡️ Stability: scans now capped + timed out (a huge keyspace can't hang the agent)`,
|
|
22
|
+
],
|
|
17
23
|
'0.10.61': [
|
|
18
24
|
`🎯 **Phase 3C: 100 MCP tools milestone — zero-setup knowledge graph**`,
|
|
19
25
|
` 🔄 \`brain_from_git\` now auto-builds Person+File nodes from git history (zero setup!)`,
|
|
@@ -94,7 +100,7 @@ export const BRAIN_TOOL_NAMES = new Set([
|
|
|
94
100
|
'learn_from_attempts', 'recall_best_solution', 'smart_recall',
|
|
95
101
|
'session_start', 'session_end', 'session_ping', 'session_handoff', 'auto_learn_session',
|
|
96
102
|
'brain_who_knows', 'brain_file_map', 'team_expertise_map',
|
|
97
|
-
'skill_gaps', 'brain_coverage',
|
|
103
|
+
'skill_gaps', 'brain_coverage', 'brain_metrics',
|
|
98
104
|
]);
|
|
99
105
|
|
|
100
106
|
export async function handleBrainTool(
|
|
@@ -272,6 +278,10 @@ export async function handleBrainTool(
|
|
|
272
278
|
};
|
|
273
279
|
const lesson = JSON.stringify(lessonObj);
|
|
274
280
|
|
|
281
|
+
// Metric 1 (time-to-first-recall): mark the moment the Brain first gained
|
|
282
|
+
// knowledge. SET NX so only the very first learn wins. Fire-and-forget.
|
|
283
|
+
redis.set(`cachly:stats:born_at:${instance_id}`, ts, 'EX', 365 * 86400, 'NX').catch(() => {});
|
|
284
|
+
|
|
275
285
|
// Always append to the history list (audit log); keep last 100 entries, 90-day TTL
|
|
276
286
|
const listKey = `cachly:lessons:${topic}`;
|
|
277
287
|
await redis.rpush(listKey, lesson);
|
|
@@ -598,7 +608,8 @@ export async function handleBrainTool(
|
|
|
598
608
|
instance_id,
|
|
599
609
|
query,
|
|
600
610
|
threshold = 0.78,
|
|
601
|
-
|
|
611
|
+
author: requester = '',
|
|
612
|
+
} = args as { instance_id: string; query: string; threshold?: number; author?: string };
|
|
602
613
|
|
|
603
614
|
const redis = await getConnection(instance_id);
|
|
604
615
|
|
|
@@ -618,16 +629,28 @@ export async function handleBrainTool(
|
|
|
618
629
|
type RecalledLesson = { topic: string; severity: string; recall_count: number; savedMins: number };
|
|
619
630
|
const savedHere: RecalledLesson[] = [];
|
|
620
631
|
const lessonMatches = kwMatches.filter(m => m.key.startsWith('cachly:lesson:best:'));
|
|
632
|
+
let crossAuthorThisCall = 0;
|
|
621
633
|
for (const m of lessonMatches.slice(0, 5)) {
|
|
622
634
|
const existing = await redis.get(m.key).catch(() => null);
|
|
623
635
|
if (existing) {
|
|
624
|
-
const lesson = safeJsonParse(existing, null as null | { recall_count?: number; outcome?: string; severity?: string; [k: string]: unknown });
|
|
636
|
+
const lesson = safeJsonParse(existing, null as null | { recall_count?: number; outcome?: string; severity?: string; author?: string; [k: string]: unknown });
|
|
625
637
|
if (lesson) {
|
|
626
638
|
const updated = { ...lesson, recall_count: (lesson.recall_count ?? 0) + 1, verified_at: new Date().toISOString() };
|
|
627
639
|
redis.set(m.key, JSON.stringify(updated)).catch(() => {});
|
|
628
640
|
const sev = lesson.severity as string;
|
|
629
641
|
const savedMins = sev === 'critical' ? 240 : sev === 'major' ? 60 : 30;
|
|
630
642
|
redis.incrbyfloat(`cachly:stats:time_saved_mins:${instance_id}`, savedMins).catch(() => {});
|
|
643
|
+
|
|
644
|
+
// Metric 3 (team-knowledge-reuse): a recall of a lesson authored by
|
|
645
|
+
// someone OTHER than the requester is cross-author reuse — the value
|
|
646
|
+
// only cachly delivers. Track total proven recalls + cross-author ones.
|
|
647
|
+
redis.incr(`cachly:stats:recalls_total:${instance_id}`).catch(() => {});
|
|
648
|
+
if (lesson.author && requester && lesson.author !== requester) {
|
|
649
|
+
crossAuthorThisCall++;
|
|
650
|
+
redis.incr(`cachly:stats:cross_author_recalls:${instance_id}`).catch(() => {});
|
|
651
|
+
redis.sadd(`cachly:stats:reuse_pairs:${instance_id}`, `${requester}<-${lesson.author}`).catch(() => {});
|
|
652
|
+
}
|
|
653
|
+
|
|
631
654
|
// Surface banner for proven successes (recall_count >= 1 means it's been validated)
|
|
632
655
|
if (lesson.outcome === 'success' && (lesson.recall_count ?? 0) >= 1) {
|
|
633
656
|
savedHere.push({
|
|
@@ -641,6 +664,12 @@ export async function handleBrainTool(
|
|
|
641
664
|
}
|
|
642
665
|
}
|
|
643
666
|
|
|
667
|
+
// Metric 1 (time-to-first-recall): stamp the first time recall returned a
|
|
668
|
+
// proven lesson. SET NX so only the first successful recall wins.
|
|
669
|
+
if (savedHere.length > 0) {
|
|
670
|
+
redis.set(`cachly:stats:first_recall_at:${instance_id}`, new Date().toISOString(), 'EX', 365 * 86400, 'NX').catch(() => {});
|
|
671
|
+
}
|
|
672
|
+
|
|
644
673
|
// ── Layer 2: Semantic search (parallel, optional) ────────────────────────
|
|
645
674
|
const inst = await apiFetch<Instance | null>(`/api/v1/instances/${instance_id}`).catch(() => null);
|
|
646
675
|
type SemHit = { key: string; similarity: number; content: string };
|
|
@@ -771,6 +800,9 @@ export async function handleBrainTool(
|
|
|
771
800
|
if (topLesson) {
|
|
772
801
|
lines.push(`> 💡 **Brain saved you ~${topLesson.savedMins}m here** — ${topLesson.severity} issue from \`${topLesson.topic}\` recalled ${topLesson.recall_count}× proven\n`);
|
|
773
802
|
}
|
|
803
|
+
if (crossAuthorThisCall > 0) {
|
|
804
|
+
lines.push(`> 👥 **Team knowledge reuse** — ${crossAuthorThisCall} of these lesson${crossAuthorThisCall !== 1 ? 's were' : ' was'} written by a teammate. This is the value only a shared brain delivers.\n`);
|
|
805
|
+
}
|
|
774
806
|
|
|
775
807
|
// Show sub-query info if multi-topic was detected
|
|
776
808
|
const subQueries = splitMultiQuery(query);
|
|
@@ -2037,6 +2069,11 @@ export async function handleBrainTool(
|
|
|
2037
2069
|
limit = 10,
|
|
2038
2070
|
} = args as { instance_id: string; topic: string; limit?: number };
|
|
2039
2071
|
|
|
2072
|
+
if (!topic || typeof topic !== 'string' || !topic.trim()) {
|
|
2073
|
+
return '⚠️ `brain_who_knows` requires a non-empty `topic`. Example: `brain_who_knows(topic="kubernetes deployment")`.';
|
|
2074
|
+
}
|
|
2075
|
+
const safeLimit = Math.max(1, Math.min(Number(limit) || 10, 50));
|
|
2076
|
+
|
|
2040
2077
|
const redis = await getConnection(instance_id);
|
|
2041
2078
|
|
|
2042
2079
|
// Step 1: Find candidate concept node IDs matching the query
|
|
@@ -2118,7 +2155,7 @@ export async function handleBrainTool(
|
|
|
2118
2155
|
lastActive: s.lastActive,
|
|
2119
2156
|
}))
|
|
2120
2157
|
.sort((a, b) => (b.lessonCount * b.avgConfidence) - (a.lessonCount * a.avgConfidence))
|
|
2121
|
-
.slice(0,
|
|
2158
|
+
.slice(0, safeLimit);
|
|
2122
2159
|
|
|
2123
2160
|
const lines = [
|
|
2124
2161
|
`## 👥 Who Knows About \`${topic}\`?`,
|
|
@@ -2155,7 +2192,11 @@ export async function handleBrainTool(
|
|
|
2155
2192
|
file_paths = [],
|
|
2156
2193
|
} = args as { instance_id: string; file_paths: string[] };
|
|
2157
2194
|
|
|
2158
|
-
|
|
2195
|
+
// Defensive: accept only a non-empty array of non-empty strings.
|
|
2196
|
+
const cleanPaths = (Array.isArray(file_paths) ? file_paths : [])
|
|
2197
|
+
.filter((p): p is string => typeof p === 'string' && p.trim().length > 0)
|
|
2198
|
+
.map(p => p.trim());
|
|
2199
|
+
if (!cleanPaths.length) return '⚠️ Pass at least one file path. Example: `brain_file_map(file_paths=["src/auth/jwt.ts"])`.';
|
|
2159
2200
|
|
|
2160
2201
|
const redis = await getConnection(instance_id);
|
|
2161
2202
|
const lines: string[] = [
|
|
@@ -2165,7 +2206,7 @@ export async function handleBrainTool(
|
|
|
2165
2206
|
``,
|
|
2166
2207
|
];
|
|
2167
2208
|
|
|
2168
|
-
for (const fp of
|
|
2209
|
+
for (const fp of cleanPaths.slice(0, 10)) {
|
|
2169
2210
|
lines.push(`### \`${fp}\``);
|
|
2170
2211
|
|
|
2171
2212
|
// ── Experts via CKG file nodes ─────────────────────────────────────
|
|
@@ -2221,13 +2262,8 @@ export async function handleBrainTool(
|
|
|
2221
2262
|
}
|
|
2222
2263
|
// Also: exact file_paths match via lesson scan
|
|
2223
2264
|
if (relatedLessons.length < 2) {
|
|
2224
|
-
const scanKeys:
|
|
2225
|
-
const
|
|
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)) {
|
|
2265
|
+
const allLessonKeys = await scanKeys(redis, 'cachly:lesson:best:*', { max: 1500 });
|
|
2266
|
+
for (const k of allLessonKeys.slice(0, 300)) {
|
|
2231
2267
|
const raw = await redis.get(k);
|
|
2232
2268
|
if (!raw) continue;
|
|
2233
2269
|
const ld = safeJsonParse<{
|
|
@@ -2271,13 +2307,8 @@ export async function handleBrainTool(
|
|
|
2271
2307
|
|
|
2272
2308
|
const redis = await getConnection(instance_id);
|
|
2273
2309
|
|
|
2274
|
-
// Scan all person nodes
|
|
2275
|
-
const personKeys:
|
|
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
|
-
});
|
|
2310
|
+
// Scan all person nodes (capped + timed out so a huge keyspace can't hang the agent)
|
|
2311
|
+
const personKeys = await scanKeys(redis, 'cachly:ckg:node:person:*', { max: 2000 });
|
|
2281
2312
|
|
|
2282
2313
|
if (personKeys.length === 0) {
|
|
2283
2314
|
return [
|
|
@@ -2353,13 +2384,8 @@ export async function handleBrainTool(
|
|
|
2353
2384
|
|
|
2354
2385
|
const redis = await getConnection(instance_id);
|
|
2355
2386
|
|
|
2356
|
-
// Scan all best lessons
|
|
2357
|
-
const lessonKeys:
|
|
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
|
-
});
|
|
2387
|
+
// Scan all best lessons (capped + timed out)
|
|
2388
|
+
const lessonKeys = await scanKeys(redis, 'cachly:lesson:best:*', { max: 3000 });
|
|
2363
2389
|
|
|
2364
2390
|
type DomainStats = { successes: number; failures: number; attributed: number; topics: string[] };
|
|
2365
2391
|
const domainMap = new Map<string, DomainStats>();
|
|
@@ -2403,14 +2429,6 @@ export async function handleBrainTool(
|
|
|
2403
2429
|
}
|
|
2404
2430
|
}
|
|
2405
2431
|
|
|
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
2432
|
const lines = [
|
|
2415
2433
|
`## 🔍 Skill Gaps — Knowledge Blind Spots`,
|
|
2416
2434
|
``,
|
|
@@ -2454,29 +2472,12 @@ export async function handleBrainTool(
|
|
|
2454
2472
|
|
|
2455
2473
|
const redis = await getConnection(instance_id);
|
|
2456
2474
|
|
|
2457
|
-
// Count lessons
|
|
2458
|
-
const lessonKeys
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
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
|
-
});
|
|
2475
|
+
// Count lessons, person + file nodes (each capped + timed out, gathered in parallel)
|
|
2476
|
+
const [lessonKeys, personKeys, fileKeys] = await Promise.all([
|
|
2477
|
+
scanKeys(redis, 'cachly:lesson:best:*', { max: 5000 }),
|
|
2478
|
+
scanKeys(redis, 'cachly:ckg:node:person:*', { max: 2000 }),
|
|
2479
|
+
scanKeys(redis, 'cachly:ckg:node:file:*', { max: 5000 }),
|
|
2480
|
+
]);
|
|
2480
2481
|
|
|
2481
2482
|
// Get git file count for comparison (optional — non-critical if not a git repo)
|
|
2482
2483
|
let gitFileCount = 0;
|
|
@@ -2544,6 +2545,75 @@ export async function handleBrainTool(
|
|
|
2544
2545
|
].join('\n');
|
|
2545
2546
|
}
|
|
2546
2547
|
|
|
2548
|
+
// ── brain_metrics ─────────────────────────────────────────────────────────
|
|
2549
|
+
// The three decisive metrics (PROGRESS.md §3 / VISION_10X.md §4):
|
|
2550
|
+
// 1. Time-to-first-recall — onboarding friction
|
|
2551
|
+
// 2. Recall-lift — the moat proof (vs. raw BM25)
|
|
2552
|
+
// 3. Team-knowledge-reuse — the value only a shared brain delivers
|
|
2553
|
+
case 'brain_metrics': {
|
|
2554
|
+
const { instance_id } = args as { instance_id: string };
|
|
2555
|
+
const redis = await getConnection(instance_id);
|
|
2556
|
+
|
|
2557
|
+
const [bornAt, firstRecallAt, recallsTotalRaw, crossAuthorRaw, timeSavedRaw, reusePairs] =
|
|
2558
|
+
await Promise.all([
|
|
2559
|
+
redis.get(`cachly:stats:born_at:${instance_id}`).catch(() => null),
|
|
2560
|
+
redis.get(`cachly:stats:first_recall_at:${instance_id}`).catch(() => null),
|
|
2561
|
+
redis.get(`cachly:stats:recalls_total:${instance_id}`).catch(() => null),
|
|
2562
|
+
redis.get(`cachly:stats:cross_author_recalls:${instance_id}`).catch(() => null),
|
|
2563
|
+
redis.get(`cachly:stats:time_saved_mins:${instance_id}`).catch(() => null),
|
|
2564
|
+
redis.smembers(`cachly:stats:reuse_pairs:${instance_id}`).catch(() => [] as string[]),
|
|
2565
|
+
]);
|
|
2566
|
+
|
|
2567
|
+
// ── Metric 1: Time-to-first-recall ──────────────────────────────────────
|
|
2568
|
+
let ttfrLine: string;
|
|
2569
|
+
if (bornAt && firstRecallAt) {
|
|
2570
|
+
const ms = new Date(firstRecallAt).getTime() - new Date(bornAt).getTime();
|
|
2571
|
+
const mins = Math.max(0, Math.round(ms / 60000));
|
|
2572
|
+
const human = mins < 1 ? '<1 min' : mins < 60 ? `${mins} min` : `${(mins / 60).toFixed(1)} h`;
|
|
2573
|
+
const target = ms <= 2 * 60000 ? '🟢 under 2 min target' : ms <= 60 * 60000 ? '🟡 over 2 min' : '🔴 over 1 h';
|
|
2574
|
+
ttfrLine = `**${human}** from first lesson → first proven recall · ${target}`;
|
|
2575
|
+
} else if (bornAt && !firstRecallAt) {
|
|
2576
|
+
ttfrLine = `🟡 Brain has knowledge but no proven recall yet — try \`smart_recall\``;
|
|
2577
|
+
} else {
|
|
2578
|
+
ttfrLine = `⚪ Not enough data yet — store a lesson, then recall it`;
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
// ── Metric 2: Recall-lift (the moat proof) ──────────────────────────────
|
|
2582
|
+
// Published headline from Cachly-Bench (CI-defended in rerank.test.ts).
|
|
2583
|
+
const recallLiftLine = `**+22.2 % Precision@1**, **+10.9 % MRR**, **+8.1 % nDCG@5** vs. raw BM25`;
|
|
2584
|
+
|
|
2585
|
+
// ── Metric 3: Team-knowledge-reuse ──────────────────────────────────────
|
|
2586
|
+
const recallsTotal = Number(recallsTotalRaw ?? 0);
|
|
2587
|
+
const crossAuthor = Number(crossAuthorRaw ?? 0);
|
|
2588
|
+
const reusePct = recallsTotal > 0 ? Math.round((crossAuthor / recallsTotal) * 100) : 0;
|
|
2589
|
+
const reuseTarget = reusePct >= 30 ? '🟢 above 30 % target' : recallsTotal === 0 ? '⚪ no recalls yet' : '🟡 below 30 % target';
|
|
2590
|
+
const timeSaved = Math.round(Number(timeSavedRaw ?? 0));
|
|
2591
|
+
const timeSavedHuman = timeSaved < 60 ? `${timeSaved} min` : `${(timeSaved / 60).toFixed(1)} h`;
|
|
2592
|
+
|
|
2593
|
+
const lines = [
|
|
2594
|
+
`## 📈 Brain Metrics — the three that decide everything`,
|
|
2595
|
+
``,
|
|
2596
|
+
`### 1. Time-to-first-recall _(onboarding friction)_`,
|
|
2597
|
+
ttfrLine,
|
|
2598
|
+
``,
|
|
2599
|
+
`### 2. Recall-lift _(the moat proof)_`,
|
|
2600
|
+
recallLiftLine,
|
|
2601
|
+
`_Reproduce: \`npm run bench\` · CI-defended in \`rerank.test.ts\` · details in BENCH.md_`,
|
|
2602
|
+
``,
|
|
2603
|
+
`### 3. Team-knowledge-reuse _(the value only a shared brain delivers)_`,
|
|
2604
|
+
`**${reusePct}%** of proven recalls used a teammate's lesson (${crossAuthor}/${recallsTotal}) · ${reuseTarget}`,
|
|
2605
|
+
reusePairs.length > 0 ? `**${reusePairs.length}** distinct reuse relationship${reusePairs.length !== 1 ? 's' : ''} across the team` : '',
|
|
2606
|
+
``,
|
|
2607
|
+
`---`,
|
|
2608
|
+
`⏱️ **${timeSavedHuman}** total saved not re-researching known fixes.`,
|
|
2609
|
+
recallsTotal === 0
|
|
2610
|
+
? `_Tip: pass \`author="your-handle"\` to \`smart_recall\` so cross-author reuse can be tracked._`
|
|
2611
|
+
: `_These numbers compound: every learned lesson and every teammate raises all three._`,
|
|
2612
|
+
].filter(Boolean);
|
|
2613
|
+
|
|
2614
|
+
return lines.join('\n');
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2547
2617
|
// ── sync_file_changes ─────────────────────────────────────────────────────
|
|
2548
2618
|
|
|
2549
2619
|
default:
|
package/src/handlers/fedbrain.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { Redis } from 'ioredis';
|
|
|
3
3
|
import { ckgSlug, extractProblemConcept, ckgUpsertNode, ckgUpdateEdge,
|
|
4
4
|
ckgUpsertPersonNode, ckgUpsertFileNode } from '../ckg.js';
|
|
5
5
|
import type { CKGEdge, CKGNode } from '../ckg.js';
|
|
6
|
-
import { safeJsonParse } from '../utils.js';
|
|
6
|
+
import { safeJsonParse, normalizeGitPath } from '../utils.js';
|
|
7
7
|
|
|
8
8
|
// Last brain_from_git category counts — set after each run so index.ts can include them in telemetry
|
|
9
9
|
export let _lastBrainFromGitCounts: { fixes: number; features: number; refactors: number; total: number } | null = null;
|
|
@@ -945,7 +945,8 @@ export async function handleFedbrainTool(
|
|
|
945
945
|
const [, sha, subject, date, author] = line.split('|||');
|
|
946
946
|
current = { sha: (sha ?? '').trim(), subject: (subject ?? '').trim(), date: (date ?? '').trim(), author: (author ?? '').trim(), files: [] };
|
|
947
947
|
} else if (line && current) {
|
|
948
|
-
|
|
948
|
+
const norm = normalizeGitPath(line);
|
|
949
|
+
if (norm) current.files.push(norm);
|
|
949
950
|
}
|
|
950
951
|
}
|
|
951
952
|
if (current) commits.push(current);
|
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.64';
|
|
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 · 101 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 · 101 MCP tools\x1b[0m');
|
|
1820
1820
|
console.log('');
|
|
1821
1821
|
process.exit(0);
|
|
1822
1822
|
}
|
package/src/tools.ts
CHANGED
|
@@ -617,6 +617,7 @@ const TOOLS = [
|
|
|
617
617
|
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
618
618
|
query: { type: 'string', description: 'Natural language query to find relevant cached context' },
|
|
619
619
|
threshold: { type: 'number', description: 'Similarity threshold 0-1 (default: 0.78)' },
|
|
620
|
+
author: { type: 'string', description: 'Handle of the person doing the recall (optional). Enables team-knowledge-reuse tracking: when you recall a lesson written by a teammate, cachly counts it as cross-author reuse — the value only a shared brain delivers.' },
|
|
620
621
|
},
|
|
621
622
|
required: ['instance_id', 'query'],
|
|
622
623
|
},
|
|
@@ -893,6 +894,22 @@ const TOOLS = [
|
|
|
893
894
|
required: ['instance_id'],
|
|
894
895
|
},
|
|
895
896
|
},
|
|
897
|
+
{
|
|
898
|
+
name: 'brain_metrics',
|
|
899
|
+
description:
|
|
900
|
+
'Report the three decisive Brain metrics: (1) time-to-first-recall (onboarding friction), ' +
|
|
901
|
+
'(2) recall-lift vs. raw BM25 (the moat proof, from Cachly-Bench), and ' +
|
|
902
|
+
'(3) team-knowledge-reuse — what % of proven recalls used a teammate\'s lesson. ' +
|
|
903
|
+
'Use to track whether the Brain is delivering its core value. ' +
|
|
904
|
+
'Pass author="handle" to smart_recall so cross-author reuse can be measured.',
|
|
905
|
+
inputSchema: {
|
|
906
|
+
type: 'object',
|
|
907
|
+
properties: {
|
|
908
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
909
|
+
},
|
|
910
|
+
required: ['instance_id'],
|
|
911
|
+
},
|
|
912
|
+
},
|
|
896
913
|
{
|
|
897
914
|
name: 'sync_file_changes',
|
|
898
915
|
description:
|
package/src/utils.ts
CHANGED
|
@@ -8,3 +8,83 @@ export function safeJsonParse<T>(raw: string | null | undefined, fallback: T): T
|
|
|
8
8
|
return fallback;
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Runs a promise with a hard timeout. If it doesn't settle within `ms`, resolves
|
|
14
|
+
* with `fallback` instead of hanging. A memory tool must NEVER block the agent's
|
|
15
|
+
* turn — a slow Redis scan or unreachable backend degrades gracefully to a fallback.
|
|
16
|
+
*/
|
|
17
|
+
export async function withTimeout<T>(p: Promise<T>, ms: number, fallback: T): Promise<T> {
|
|
18
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
19
|
+
const guard = new Promise<T>((resolve) => {
|
|
20
|
+
timer = setTimeout(() => resolve(fallback), ms);
|
|
21
|
+
});
|
|
22
|
+
try {
|
|
23
|
+
return await Promise.race([p, guard]);
|
|
24
|
+
} catch {
|
|
25
|
+
return fallback;
|
|
26
|
+
} finally {
|
|
27
|
+
if (timer) clearTimeout(timer);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Minimal structural type for the Redis scan API we depend on — avoids importing
|
|
32
|
+
// the full ioredis type into a leaf utility module.
|
|
33
|
+
interface ScanCapable {
|
|
34
|
+
scanStream(opts: { match: string; count?: number }): {
|
|
35
|
+
on(event: 'data', cb: (batch: string[]) => void): void;
|
|
36
|
+
on(event: 'end', cb: () => void): void;
|
|
37
|
+
on(event: 'error', cb: (err: Error) => void): void;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Collects keys matching a pattern with a hard cap and timeout. Prevents a tool
|
|
43
|
+
* from hanging on a huge keyspace: stops early at `max` keys, and resolves with
|
|
44
|
+
* whatever was gathered if the scan exceeds `timeoutMs`. Never rejects.
|
|
45
|
+
*/
|
|
46
|
+
export function scanKeys(
|
|
47
|
+
redis: ScanCapable,
|
|
48
|
+
pattern: string,
|
|
49
|
+
opts: { max?: number; count?: number; timeoutMs?: number } = {},
|
|
50
|
+
): Promise<string[]> {
|
|
51
|
+
const max = opts.max ?? 1000;
|
|
52
|
+
const timeoutMs = opts.timeoutMs ?? 3000;
|
|
53
|
+
return new Promise<string[]>((resolve) => {
|
|
54
|
+
const keys: string[] = [];
|
|
55
|
+
let done = false;
|
|
56
|
+
const finish = () => { if (!done) { done = true; resolve(keys); } };
|
|
57
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
58
|
+
try {
|
|
59
|
+
const stream = redis.scanStream({ match: pattern, count: opts.count ?? 200 });
|
|
60
|
+
stream.on('data', (batch: string[]) => {
|
|
61
|
+
for (const k of batch) {
|
|
62
|
+
keys.push(k);
|
|
63
|
+
if (keys.length >= max) { clearTimeout(timer); finish(); return; }
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
stream.on('end', () => { clearTimeout(timer); finish(); });
|
|
67
|
+
stream.on('error', () => { clearTimeout(timer); finish(); });
|
|
68
|
+
} catch {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
finish();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Normalizes a git path that may come from `--name-only` with rename notation,
|
|
77
|
+
* e.g. `src/{old => new}/file.ts` → `src/new/file.ts`, and `a => b` → `b`.
|
|
78
|
+
* Returns the post-rename path so file nodes don't fragment on renames.
|
|
79
|
+
*/
|
|
80
|
+
export function normalizeGitPath(raw: string): string {
|
|
81
|
+
let p = raw.trim();
|
|
82
|
+
if (!p) return p;
|
|
83
|
+
// Brace rename: dir/{old => new}/file → dir/new/file
|
|
84
|
+
p = p.replace(/\{[^}]*=>\s*([^}]*)\}/g, (_m, after: string) => after.trim());
|
|
85
|
+
// Bare rename: old => new → new
|
|
86
|
+
const arrow = p.split('=>');
|
|
87
|
+
if (arrow.length === 2) p = arrow[1]!.trim();
|
|
88
|
+
// Collapse any double slashes introduced by an empty rename segment.
|
|
89
|
+
return p.replace(/\/{2,}/g, '/').replace(/^\/|\/$/g, '');
|
|
90
|
+
}
|