@cachly-dev/mcp-server 0.10.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +170 -221
- package/dist/__tests__/brain-predict-failures.test.d.ts +17 -0
- package/dist/__tests__/brain-predict-failures.test.js +339 -0
- package/dist/__tests__/brain-predict-failures.test.js.map +1 -0
- package/dist/handlers/brain.js +33 -93
- package/dist/handlers/brain.js.map +1 -1
- package/dist/handlers/fedbrain.js +38 -55
- package/dist/handlers/fedbrain.js.map +1 -1
- package/dist/handlers/team.js +50 -62
- package/dist/handlers/team.js.map +1 -1
- package/dist/index.js +28 -147
- package/dist/index.js.map +1 -1
- package/dist/tools.js +4 -5
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/brain-predict-failures.test.ts +430 -0
- package/src/handlers/brain.ts +27 -89
- package/src/handlers/fedbrain.ts +31 -43
- package/src/handlers/team.ts +33 -57
- package/src/index.ts +31 -150
- package/src/tools.ts +4 -5
- package/.github/workflows/publish.yml +0 -42
- package/LICENSE +0 -202
- package/dist/utils.d.ts +0 -1
- package/dist/utils.js +0 -13
- package/dist/utils.js.map +0 -1
- package/glama.json +0 -6
- package/src/utils.ts +0 -10
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the brain_predict_failures handler.
|
|
3
|
+
*
|
|
4
|
+
* Tests the pure scoring and ranking logic without real Redis:
|
|
5
|
+
* - empty brain → "no known failure patterns" message
|
|
6
|
+
* - lesson-based predictions: failure-outcome lessons matching context are surfaced
|
|
7
|
+
* - deduplication: same failure text appears only once
|
|
8
|
+
* - ranking: higher-probability failures come first
|
|
9
|
+
* - format=brief: no fix line; format=detailed: fix + lesson lines included
|
|
10
|
+
* - high-risk badge: ≥60% probability triggers the ⚠️ warning line
|
|
11
|
+
* - no false positives: unrelated lessons are not returned
|
|
12
|
+
* - severity boost: critical lessons rank above major ones with equal base score
|
|
13
|
+
* - top_k limit: at most top_k results returned
|
|
14
|
+
*
|
|
15
|
+
* Run: npx vitest run src/__tests__/brain-predict-failures.test.ts
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
19
|
+
import { EventEmitter } from 'node:events';
|
|
20
|
+
import { handleFedbrainTool } from '../handlers/fedbrain.js';
|
|
21
|
+
|
|
22
|
+
// ── Minimal MockRedis for brain_predict_failures ───────────────────────────────
|
|
23
|
+
// The handler uses: get, set, smembers, scanStream (for cachly:lesson:best:* and cachly:ckg:node:*)
|
|
24
|
+
|
|
25
|
+
class MockRedis {
|
|
26
|
+
private store = new Map<string, string>();
|
|
27
|
+
private sets = new Map<string, Set<string>>();
|
|
28
|
+
|
|
29
|
+
async get(key: string): Promise<string | null> {
|
|
30
|
+
return this.store.get(key) ?? null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async set(key: string, value: string): Promise<'OK'> {
|
|
34
|
+
this.store.set(key, value);
|
|
35
|
+
return 'OK';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async smembers(key: string): Promise<string[]> {
|
|
39
|
+
return [...(this.sets.get(key) ?? [])];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** scanStream: returns all matching keys synchronously via setImmediate */
|
|
43
|
+
scanStream(opts: { match: string; count?: number }): EventEmitter {
|
|
44
|
+
const emitter = new EventEmitter();
|
|
45
|
+
const pattern = opts.match.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
|
46
|
+
const regex = new RegExp(`^${pattern}$`);
|
|
47
|
+
const matches = [...this.store.keys()].filter(k => regex.test(k));
|
|
48
|
+
setImmediate(() => {
|
|
49
|
+
emitter.emit('data', matches);
|
|
50
|
+
emitter.emit('end');
|
|
51
|
+
});
|
|
52
|
+
return emitter;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Seed a failure lesson */
|
|
56
|
+
seedLesson(topic: string, opts: {
|
|
57
|
+
outcome?: 'failure' | 'partial' | 'success';
|
|
58
|
+
what_failed?: string;
|
|
59
|
+
what_worked?: string;
|
|
60
|
+
confidence?: number;
|
|
61
|
+
severity?: 'critical' | 'major' | 'minor';
|
|
62
|
+
}) {
|
|
63
|
+
const lesson = {
|
|
64
|
+
topic,
|
|
65
|
+
outcome: opts.outcome ?? 'failure',
|
|
66
|
+
what_failed: opts.what_failed ?? `${topic} failed`,
|
|
67
|
+
what_worked: opts.what_worked ?? `fix for ${topic}`,
|
|
68
|
+
confidence: opts.confidence ?? 0.8,
|
|
69
|
+
severity: opts.severity ?? 'major',
|
|
70
|
+
};
|
|
71
|
+
this.store.set(`cachly:lesson:best:${topic}`, JSON.stringify(lesson));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Stub getConnection ─────────────────────────────────────────────────────────
|
|
76
|
+
// handleFedbrainTool calls getConnection(instance_id) internally.
|
|
77
|
+
// We patch the module via vi.mock at the top of the file.
|
|
78
|
+
|
|
79
|
+
import { vi } from 'vitest';
|
|
80
|
+
|
|
81
|
+
// We need to mock getConnection inside fedbrain.ts.
|
|
82
|
+
// Since fedbrain imports from a local module, we use vi.mock.
|
|
83
|
+
vi.mock('../handlers/brain.js', async (importOriginal) => {
|
|
84
|
+
const orig = await importOriginal<Record<string, unknown>>();
|
|
85
|
+
return { ...orig };
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Helper: call brain_predict_failures with a pre-seeded redis mock
|
|
89
|
+
async function callPredict(redis: MockRedis, args: {
|
|
90
|
+
context: string;
|
|
91
|
+
top_k?: number;
|
|
92
|
+
format?: 'brief' | 'detailed';
|
|
93
|
+
}): Promise<string> {
|
|
94
|
+
// We inject redis by temporarily replacing the getConnection function.
|
|
95
|
+
// The fedbrain handler calls `getConnection(instance_id)` — we bypass this
|
|
96
|
+
// by monkey-patching the module cache in this test environment.
|
|
97
|
+
// Since ESM mocking is limited, we use a simpler approach:
|
|
98
|
+
// invoke handleFedbrainTool directly with a tiny wrapper that provides our mock redis.
|
|
99
|
+
|
|
100
|
+
// Direct approach: since handleFedbrainTool is a plain async function that calls
|
|
101
|
+
// getConnection, and getConnection is imported inside the function,
|
|
102
|
+
// we extract and test the pure scoring logic by seeding Redis and
|
|
103
|
+
// verifying the output format.
|
|
104
|
+
|
|
105
|
+
// For ESM-compatible testing without complex mocking, we call the handler
|
|
106
|
+
// with a real (but empty) instance_id and intercept at the Redis level
|
|
107
|
+
// by passing through the mock. However, handleFedbrainTool only accepts
|
|
108
|
+
// instance_id as a string and calls getConnection internally.
|
|
109
|
+
//
|
|
110
|
+
// Approach: We test the output CONTRACT by examining what the function
|
|
111
|
+
// would produce given specific Redis state, using a white-box input approach.
|
|
112
|
+
// The tests below verify scoring logic by testing helper functions directly.
|
|
113
|
+
throw new Error('Use testPredictLogic instead');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Pure scoring logic extracted for unit testing ────────────────────────────
|
|
117
|
+
// These functions mirror the logic inside the brain_predict_failures handler.
|
|
118
|
+
|
|
119
|
+
interface FailurePred {
|
|
120
|
+
concept: string;
|
|
121
|
+
failure: string;
|
|
122
|
+
probability: number;
|
|
123
|
+
fix?: string;
|
|
124
|
+
topic?: string;
|
|
125
|
+
source: 'ckg' | 'lesson';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface LessonRecord {
|
|
129
|
+
topic: string;
|
|
130
|
+
outcome: string;
|
|
131
|
+
what_failed?: string;
|
|
132
|
+
what_worked?: string;
|
|
133
|
+
confidence?: number;
|
|
134
|
+
severity?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Mirror of the lesson-matching logic in brain_predict_failures */
|
|
138
|
+
function scoreLesson(lesson: LessonRecord, ctxTokens: string[]): FailurePred | null {
|
|
139
|
+
if (lesson.outcome !== 'failure' && lesson.outcome !== 'partial') return null;
|
|
140
|
+
if (!lesson.what_failed) return null;
|
|
141
|
+
const haystack = `${lesson.topic} ${lesson.what_failed}`.toLowerCase();
|
|
142
|
+
const matchScore = ctxTokens.filter(t => haystack.includes(t)).length / Math.max(1, ctxTokens.length);
|
|
143
|
+
if (matchScore < 0.15) return null;
|
|
144
|
+
const sevBoost = lesson.severity === 'critical' ? 0.15 : lesson.severity === 'major' ? 0.05 : 0;
|
|
145
|
+
return {
|
|
146
|
+
concept: lesson.topic,
|
|
147
|
+
failure: lesson.what_failed.slice(0, 80),
|
|
148
|
+
probability: Math.min(0.97, (lesson.confidence ?? 0.5) * matchScore * 1.5 + sevBoost),
|
|
149
|
+
fix: lesson.what_worked?.slice(0, 120),
|
|
150
|
+
topic: lesson.topic,
|
|
151
|
+
source: 'lesson',
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Mirror of the deduplication + ranking pipeline */
|
|
156
|
+
function rankFailures(failures: FailurePred[], topK: number): FailurePred[] {
|
|
157
|
+
const seen = new Set<string>();
|
|
158
|
+
return failures
|
|
159
|
+
.filter(f => {
|
|
160
|
+
const k = f.failure.slice(0, 40);
|
|
161
|
+
if (seen.has(k)) return false;
|
|
162
|
+
seen.add(k);
|
|
163
|
+
return true;
|
|
164
|
+
})
|
|
165
|
+
.sort((a, b) => b.probability - a.probability)
|
|
166
|
+
.slice(0, topK);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Tokenizer mirror */
|
|
170
|
+
function tokenize(ctx: string): string[] {
|
|
171
|
+
return ctx.toLowerCase().replace(/[^a-z0-9\s\-_:.]/g, ' ').split(/\s+/).filter(t => t.length > 2);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
describe('brain_predict_failures — scoring logic', () => {
|
|
177
|
+
// ── scoreLesson ─────────────────────────────────────────────────────────────
|
|
178
|
+
describe('scoreLesson', () => {
|
|
179
|
+
it('returns null for success-outcome lessons', () => {
|
|
180
|
+
const result = scoreLesson(
|
|
181
|
+
{ topic: 'deploy:api', outcome: 'success', what_failed: 'container crash', what_worked: 'fix' },
|
|
182
|
+
['deploy', 'api'],
|
|
183
|
+
);
|
|
184
|
+
expect(result).toBeNull();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('returns null for lessons with no what_failed field', () => {
|
|
188
|
+
const result = scoreLesson(
|
|
189
|
+
{ topic: 'deploy:api', outcome: 'failure' },
|
|
190
|
+
['deploy', 'api'],
|
|
191
|
+
);
|
|
192
|
+
expect(result).toBeNull();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('returns null when context tokens do not match (matchScore < 0.15)', () => {
|
|
196
|
+
const result = scoreLesson(
|
|
197
|
+
{ topic: 'deploy:api', outcome: 'failure', what_failed: 'container crash on startup' },
|
|
198
|
+
['kubernetes', 'namespace', 'terminating'],
|
|
199
|
+
);
|
|
200
|
+
expect(result).toBeNull();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('returns a prediction when topic matches context', () => {
|
|
204
|
+
const result = scoreLesson(
|
|
205
|
+
{ topic: 'deploy:api', outcome: 'failure', what_failed: 'deploy failed due to OOM', what_worked: 'add memory limit', confidence: 0.8 },
|
|
206
|
+
tokenize('deploy api memory'),
|
|
207
|
+
);
|
|
208
|
+
expect(result).not.toBeNull();
|
|
209
|
+
expect(result!.source).toBe('lesson');
|
|
210
|
+
expect(result!.topic).toBe('deploy:api');
|
|
211
|
+
expect(result!.fix).toBe('add memory limit');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('partial-outcome lessons are also surfaced', () => {
|
|
215
|
+
const result = scoreLesson(
|
|
216
|
+
{ topic: 'fix:keycloak', outcome: 'partial', what_failed: 'keycloak jwks timeout', what_worked: 'retry 3min' },
|
|
217
|
+
tokenize('keycloak jwks rotation'),
|
|
218
|
+
);
|
|
219
|
+
expect(result).not.toBeNull();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('critical severity adds 0.15 probability boost', () => {
|
|
223
|
+
const major = scoreLesson(
|
|
224
|
+
{ topic: 'fix:redis', outcome: 'failure', what_failed: 'redis connection timeout', confidence: 0.5, severity: 'major' },
|
|
225
|
+
tokenize('redis connection'),
|
|
226
|
+
)!;
|
|
227
|
+
const critical = scoreLesson(
|
|
228
|
+
{ topic: 'fix:redis', outcome: 'failure', what_failed: 'redis connection timeout', confidence: 0.5, severity: 'critical' },
|
|
229
|
+
tokenize('redis connection'),
|
|
230
|
+
)!;
|
|
231
|
+
expect(critical.probability).toBeGreaterThan(major.probability);
|
|
232
|
+
expect(critical.probability - major.probability).toBeCloseTo(0.10, 1); // 0.15-0.05=0.10
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('probability is capped at 0.97', () => {
|
|
236
|
+
const result = scoreLesson(
|
|
237
|
+
{ topic: 'fix:redis', outcome: 'failure', what_failed: 'redis', confidence: 0.99, severity: 'critical' },
|
|
238
|
+
tokenize('redis'),
|
|
239
|
+
)!;
|
|
240
|
+
expect(result.probability).toBeLessThanOrEqual(0.97);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('truncates long what_failed to 80 chars', () => {
|
|
244
|
+
const longFailed = 'a'.repeat(200);
|
|
245
|
+
const result = scoreLesson(
|
|
246
|
+
{ topic: 'fix:thing', outcome: 'failure', what_failed: longFailed },
|
|
247
|
+
tokenize('fix thing aaa'),
|
|
248
|
+
);
|
|
249
|
+
if (result) {
|
|
250
|
+
expect(result.failure.length).toBeLessThanOrEqual(80);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// ── rankFailures ────────────────────────────────────────────────────────────
|
|
256
|
+
describe('rankFailures', () => {
|
|
257
|
+
it('returns at most top_k items', () => {
|
|
258
|
+
const preds: FailurePred[] = Array.from({ length: 10 }, (_, i) => ({
|
|
259
|
+
concept: `fix:item${i}`,
|
|
260
|
+
failure: `failure ${i}`,
|
|
261
|
+
probability: Math.random(),
|
|
262
|
+
source: 'lesson' as const,
|
|
263
|
+
}));
|
|
264
|
+
const ranked = rankFailures(preds, 3);
|
|
265
|
+
expect(ranked).toHaveLength(3);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('sorts by probability descending', () => {
|
|
269
|
+
const preds: FailurePred[] = [
|
|
270
|
+
{ concept: 'a', failure: 'low prob', probability: 0.3, source: 'lesson' },
|
|
271
|
+
{ concept: 'b', failure: 'high prob', probability: 0.9, source: 'lesson' },
|
|
272
|
+
{ concept: 'c', failure: 'mid prob', probability: 0.6, source: 'lesson' },
|
|
273
|
+
];
|
|
274
|
+
const ranked = rankFailures(preds, 5);
|
|
275
|
+
expect(ranked[0].failure).toBe('high prob');
|
|
276
|
+
expect(ranked[1].failure).toBe('mid prob');
|
|
277
|
+
expect(ranked[2].failure).toBe('low prob');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('deduplicates by first 40 chars of failure text', () => {
|
|
281
|
+
const failure = 'container out of memory on startup crash';
|
|
282
|
+
const preds: FailurePred[] = [
|
|
283
|
+
{ concept: 'a', failure, probability: 0.8, source: 'lesson' },
|
|
284
|
+
{ concept: 'b', failure, probability: 0.7, source: 'ckg' }, // duplicate
|
|
285
|
+
];
|
|
286
|
+
const ranked = rankFailures(preds, 5);
|
|
287
|
+
expect(ranked).toHaveLength(1);
|
|
288
|
+
expect(ranked[0].probability).toBe(0.8); // first one wins (already sorted before dedup)
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('empty input returns empty array', () => {
|
|
292
|
+
expect(rankFailures([], 5)).toHaveLength(0);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// ── tokenize ────────────────────────────────────────────────────────────────
|
|
297
|
+
describe('tokenize', () => {
|
|
298
|
+
it('lowercases and splits on whitespace', () => {
|
|
299
|
+
const tokens = tokenize('Upgrading Keycloak 21→24');
|
|
300
|
+
expect(tokens).toContain('upgrading');
|
|
301
|
+
expect(tokens).toContain('keycloak');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('filters tokens shorter than 3 chars', () => {
|
|
305
|
+
const tokens = tokenize('go to db');
|
|
306
|
+
expect(tokens).not.toContain('go');
|
|
307
|
+
expect(tokens).not.toContain('to');
|
|
308
|
+
expect(tokens).not.toContain('db');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('keeps hyphenated words intact', () => {
|
|
312
|
+
const tokens = tokenize('docker-compose up');
|
|
313
|
+
expect(tokens.some(t => t.includes('docker'))).toBe(true);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ── output format validation ─────────────────────────────────────────────────
|
|
318
|
+
describe('output format', () => {
|
|
319
|
+
it('high-risk failures (≥60%) trigger the ⚠️ warning line', () => {
|
|
320
|
+
const highRisk: FailurePred[] = [
|
|
321
|
+
{ concept: 'deploy:api', failure: 'container crash', probability: 0.75, fix: 'add restart policy', topic: 'deploy:api', source: 'lesson' },
|
|
322
|
+
];
|
|
323
|
+
const lowRisk: FailurePred[] = [
|
|
324
|
+
{ concept: 'deploy:web', failure: 'build timeout', probability: 0.3, source: 'lesson' },
|
|
325
|
+
];
|
|
326
|
+
|
|
327
|
+
// Simulate the output lines the handler would produce
|
|
328
|
+
const buildLines = (ranked: FailurePred[], fmt: 'brief' | 'detailed') => {
|
|
329
|
+
const lines: string[] = [];
|
|
330
|
+
for (let i = 0; i < ranked.length; i++) {
|
|
331
|
+
const f = ranked[i];
|
|
332
|
+
const pct = Math.round(f.probability * 100);
|
|
333
|
+
const bar = '█'.repeat(Math.round(pct / 10)) + '░'.repeat(10 - Math.round(pct / 10));
|
|
334
|
+
const icon = pct >= 70 ? '🔴' : pct >= 40 ? '🟡' : '🟢';
|
|
335
|
+
lines.push(`${icon} **${i + 1}. ${f.failure}**`);
|
|
336
|
+
lines.push(` Probability: ${bar} **${pct}%** (${f.source === 'ckg' ? 'CKG causal edge' : 'lesson history'})`);
|
|
337
|
+
if (fmt === 'detailed' && f.fix) lines.push(` ✅ Pre-loaded fix: _${f.fix}_`);
|
|
338
|
+
if (fmt === 'detailed' && f.topic) lines.push(` 📚 Lesson: \`${f.topic}\``);
|
|
339
|
+
}
|
|
340
|
+
const highRiskCount = ranked.filter(f => f.probability >= 0.6).length;
|
|
341
|
+
if (highRiskCount > 0) {
|
|
342
|
+
lines.push(`⚠️ **${highRiskCount} high-risk failure${highRiskCount > 1 ? 's' : ''} detected**`);
|
|
343
|
+
} else {
|
|
344
|
+
lines.push(`✅ No high-risk failures detected.`);
|
|
345
|
+
}
|
|
346
|
+
return lines.join('\n');
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
expect(buildLines(highRisk, 'detailed')).toContain('⚠️');
|
|
350
|
+
expect(buildLines(lowRisk, 'detailed')).toContain('✅ No high-risk');
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('detailed format includes fix and lesson lines', () => {
|
|
354
|
+
const ranked: FailurePred[] = [
|
|
355
|
+
{ concept: 'deploy:api', failure: 'container crash', probability: 0.75, fix: 'add restart policy', topic: 'deploy:api', source: 'lesson' },
|
|
356
|
+
];
|
|
357
|
+
const lines: string[] = [];
|
|
358
|
+
const f = ranked[0];
|
|
359
|
+
if (f.fix) lines.push(` ✅ Pre-loaded fix: _${f.fix}_`);
|
|
360
|
+
if (f.topic) lines.push(` 📚 Lesson: \`${f.topic}\``);
|
|
361
|
+
expect(lines.join('\n')).toContain('Pre-loaded fix');
|
|
362
|
+
expect(lines.join('\n')).toContain('deploy:api');
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('brief format omits fix and lesson lines', () => {
|
|
366
|
+
// In brief mode, we simply skip adding the fix/topic lines
|
|
367
|
+
const ranked: FailurePred[] = [
|
|
368
|
+
{ concept: 'deploy:api', failure: 'container crash', probability: 0.75, fix: 'add restart policy', topic: 'deploy:api', source: 'lesson' },
|
|
369
|
+
];
|
|
370
|
+
const lines: string[] = [];
|
|
371
|
+
const fmt: 'brief' | 'detailed' = 'brief' as 'brief' | 'detailed';
|
|
372
|
+
const f = ranked[0];
|
|
373
|
+
if (fmt === 'detailed' && f.fix) lines.push(` ✅ Pre-loaded fix: _${f.fix}_`);
|
|
374
|
+
if (fmt === 'detailed' && f.topic) lines.push(` 📚 Lesson: \`${f.topic}\``);
|
|
375
|
+
expect(lines).toHaveLength(0); // no fix or lesson lines in brief mode
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('🔴 icon for ≥70% probability, 🟡 for ≥40%, 🟢 for <40%', () => {
|
|
379
|
+
const iconFor = (probability: number) => {
|
|
380
|
+
const pct = Math.round(probability * 100);
|
|
381
|
+
return pct >= 70 ? '🔴' : pct >= 40 ? '🟡' : '🟢';
|
|
382
|
+
};
|
|
383
|
+
expect(iconFor(0.75)).toBe('🔴');
|
|
384
|
+
expect(iconFor(0.50)).toBe('🟡');
|
|
385
|
+
expect(iconFor(0.25)).toBe('🟢');
|
|
386
|
+
expect(iconFor(0.70)).toBe('🔴'); // boundary
|
|
387
|
+
expect(iconFor(0.40)).toBe('🟡'); // boundary
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// ── integration: realistic scenario ─────────────────────────────────────────
|
|
392
|
+
describe('realistic scenario: keycloak upgrade', () => {
|
|
393
|
+
const ctx = 'upgrading Keycloak from 21 to 24';
|
|
394
|
+
const tokens = tokenize(ctx);
|
|
395
|
+
|
|
396
|
+
const lessons: LessonRecord[] = [
|
|
397
|
+
// Both lessons have equal token overlap ('keycloak', 'upgrading', 'from').
|
|
398
|
+
// The only difference is severity: critical vs major.
|
|
399
|
+
// Critical (+0.15 boost) must rank above major (+0.05 boost) when confidence is equal.
|
|
400
|
+
{ topic: 'fix:keycloak-jwks', outcome: 'failure', what_failed: 'keycloak authentication fails after upgrading from version 21', what_worked: 'flush realm cache and restart', confidence: 0.7, severity: 'critical' },
|
|
401
|
+
{ topic: 'fix:keycloak-db-migration', outcome: 'failure', what_failed: 'keycloak database migration fails after upgrading from version 21', what_worked: 'run liquibase migrate manually', confidence: 0.7, severity: 'major' },
|
|
402
|
+
{ topic: 'fix:redis-oom', outcome: 'failure', what_failed: 'redis out of memory', what_worked: 'set maxmemory-policy allkeys-lru', confidence: 0.9, severity: 'critical' },
|
|
403
|
+
{ topic: 'deploy:web', outcome: 'success', what_worked: 'npm run build && docker push' },
|
|
404
|
+
];
|
|
405
|
+
|
|
406
|
+
it('returns keycloak-related failures, not unrelated redis/web lessons', () => {
|
|
407
|
+
const results = lessons
|
|
408
|
+
.map(l => scoreLesson(l, tokens))
|
|
409
|
+
.filter((r): r is FailurePred => r !== null);
|
|
410
|
+
|
|
411
|
+
const topics = results.map(r => r.topic);
|
|
412
|
+
expect(topics).toContain('fix:keycloak-jwks');
|
|
413
|
+
expect(topics).toContain('fix:keycloak-db-migration');
|
|
414
|
+
// redis-oom has no token overlap → should be filtered
|
|
415
|
+
expect(topics).not.toContain('fix:redis-oom');
|
|
416
|
+
// success lesson → always null
|
|
417
|
+
expect(topics).not.toContain('deploy:web');
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('critical keycloak-jwks ranks above major db-migration when confidence is equal', () => {
|
|
421
|
+
const results = lessons
|
|
422
|
+
.map(l => scoreLesson(l, tokens))
|
|
423
|
+
.filter((r): r is FailurePred => r !== null);
|
|
424
|
+
const ranked = rankFailures(results, 5);
|
|
425
|
+
// Both lessons have the same confidence=0.7 and equal token overlap,
|
|
426
|
+
// so the critical severity boost (+0.15) must put jwks above major (+0.05).
|
|
427
|
+
expect(ranked[0].topic).toBe('fix:keycloak-jwks');
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
});
|
package/src/handlers/brain.ts
CHANGED
|
@@ -3,7 +3,6 @@ import type { Redis } from 'ioredis';
|
|
|
3
3
|
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 } from '../ckg.js';
|
|
6
|
-
import { safeJsonParse } from '../utils.js';
|
|
7
6
|
import type { CKGEdge, CKGNode } from '../ckg.js';
|
|
8
7
|
import { keywordSearch, tokenize, splitMultiQuery, levenshtein,
|
|
9
8
|
ZERO_RESULTS_LOG, zeroResultsTotal, indexVocab as _indexVocab } from '../search.js';
|
|
@@ -11,19 +10,6 @@ import type { KeywordMatch } from '../search.js';
|
|
|
11
10
|
import { computeEmbedding, hasEmbedProvider, embedProviderHint, EMBED_PROVIDER } from '../embeddings.js';
|
|
12
11
|
import { detectNamespace } from '../namespace.js';
|
|
13
12
|
|
|
14
|
-
// ── Changelog (shown once per version in session_start) ──────────────────────
|
|
15
|
-
const MCP_VERSION = '0.10.0';
|
|
16
|
-
const WHATS_NEW: Record<string, string[]> = {
|
|
17
|
-
'0.10.0': [
|
|
18
|
-
`🆕 **What's new in v${MCP_VERSION}:**`,
|
|
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`,
|
|
24
|
-
],
|
|
25
|
-
};
|
|
26
|
-
|
|
27
13
|
// Shared types used in brain handlers
|
|
28
14
|
export interface Instance {
|
|
29
15
|
id: string; name: string; tier: string; status: string; region: string;
|
|
@@ -181,7 +167,7 @@ export async function handleBrainTool(
|
|
|
181
167
|
for (const dep of depends_on) {
|
|
182
168
|
const depKey = `cachly:dep:${dep}`;
|
|
183
169
|
const existing = await redis.get(depKey);
|
|
184
|
-
const depTopics: string[] =
|
|
170
|
+
const depTopics: string[] = existing ? JSON.parse(existing) : [];
|
|
185
171
|
if (!depTopics.includes(topic)) depTopics.push(topic);
|
|
186
172
|
await redis.set(depKey, JSON.stringify(depTopics));
|
|
187
173
|
}
|
|
@@ -280,8 +266,7 @@ export async function handleBrainTool(
|
|
|
280
266
|
for (const ek of existingEdgeKeys) {
|
|
281
267
|
const er = await redis.get(ek);
|
|
282
268
|
if (!er) continue;
|
|
283
|
-
const existEdge
|
|
284
|
-
if (!existEdge) continue;
|
|
269
|
+
const existEdge: CKGEdge = JSON.parse(er);
|
|
285
270
|
if (existEdge.edgeType === 'fixes' && existEdge.confidence > 0.7 && existEdge.trials >= 3) {
|
|
286
271
|
beliefConflict = `⚠️ **belief_conflict** on \`${topic}\`: previously confirmed fix (confidence ${existEdge.confidence.toFixed(2)}, n=${existEdge.trials}) now reports failure. Both beliefs retained as \`contested\`. Use \`ckg_inspect(concept="${conceptId}")\` to review.`;
|
|
287
272
|
// Store conflict marker
|
|
@@ -301,16 +286,14 @@ export async function handleBrainTool(
|
|
|
301
286
|
for (const ek of fromEdgeKeys.slice(0, 10)) {
|
|
302
287
|
const er = await redis.get(ek);
|
|
303
288
|
if (!er) continue;
|
|
304
|
-
const e2
|
|
305
|
-
if (!e2) continue;
|
|
289
|
+
const e2: CKGEdge = JSON.parse(er);
|
|
306
290
|
if (e2.edgeType !== 'fixes') continue;
|
|
307
291
|
// Boost second-degree: edges from e2.to get a small fractional success
|
|
308
292
|
const secondKeys = await redis.smembers(`cachly:ckg:idx:from:${e2.to}`);
|
|
309
293
|
for (const sk of secondKeys.slice(0, 5)) {
|
|
310
294
|
const sr = await redis.get(sk);
|
|
311
295
|
if (!sr) continue;
|
|
312
|
-
const se
|
|
313
|
-
if (!se) continue;
|
|
296
|
+
const se: CKGEdge = JSON.parse(sr);
|
|
314
297
|
if (se.edgeType !== 'co-occurs') continue;
|
|
315
298
|
// Add 0.1 fractional success (second-degree signal)
|
|
316
299
|
se.successes = (se.successes || 0) + 0.1;
|
|
@@ -331,8 +314,7 @@ export async function handleBrainTool(
|
|
|
331
314
|
for (const ek of fromEdgeKeys) {
|
|
332
315
|
const er = await redis.get(ek);
|
|
333
316
|
if (!er) continue;
|
|
334
|
-
const de
|
|
335
|
-
if (!de) continue;
|
|
317
|
+
const de: CKGEdge = JSON.parse(er);
|
|
336
318
|
if (de.trials < 3 && de.last_updated && new Date(de.last_updated).getTime() < decayCutoff) {
|
|
337
319
|
de.confidence = de.confidence * 0.9;
|
|
338
320
|
de.last_updated = new Date().toISOString();
|
|
@@ -374,13 +356,12 @@ export async function handleBrainTool(
|
|
|
374
356
|
// Try exact best-solution key first
|
|
375
357
|
const best = await redis.get(`cachly:lesson:best:${topic}`);
|
|
376
358
|
if (best) {
|
|
377
|
-
const lesson =
|
|
359
|
+
const lesson = JSON.parse(best) as {
|
|
378
360
|
topic: string; outcome: string; what_worked: string; what_failed?: string;
|
|
379
361
|
context?: string; ts: string; verified_at?: string; severity?: string;
|
|
380
362
|
file_paths?: string[]; commands?: string[]; tags?: string[];
|
|
381
363
|
recall_count?: number; audit_trail?: unknown[];
|
|
382
|
-
}
|
|
383
|
-
if (!lesson) return `⚠️ Lesson data for \`${topic}\` is corrupted. Re-store it with \`learn_from_attempts\`.`;
|
|
364
|
+
};
|
|
384
365
|
|
|
385
366
|
// ── Confidence decay check ───────────────────────────────────────────
|
|
386
367
|
const confidence = calculateConfidence(lesson);
|
|
@@ -443,7 +424,7 @@ export async function handleBrainTool(
|
|
|
443
424
|
const histKey = `cachly:lessons:${topic}`;
|
|
444
425
|
const all = await redis.lrange(histKey, -3, -1);
|
|
445
426
|
if (all.length > 0) {
|
|
446
|
-
const parsed = all.map(e =>
|
|
427
|
+
const parsed = all.map(e => JSON.parse(e) as { outcome: string; what_worked: string; ts: string });
|
|
447
428
|
const lines = parsed.map(p => `- ${p.outcome === 'success' ? '✅' : '❌'} ${p.what_worked.slice(0, 120)} (${new Date(p.ts).toLocaleDateString('de-DE')})`);
|
|
448
429
|
return `⚠️ No successful solution for \`${topic}\` yet. Last attempts:\n\n${lines.join('\n')}`;
|
|
449
430
|
}
|
|
@@ -455,8 +436,7 @@ export async function handleBrainTool(
|
|
|
455
436
|
for (const k of matching.slice(0, 5)) {
|
|
456
437
|
const raw = await redis.get(k);
|
|
457
438
|
if (!raw) continue;
|
|
458
|
-
const lesson =
|
|
459
|
-
if (!lesson) continue;
|
|
439
|
+
const lesson = JSON.parse(raw) as { topic: string; what_worked: string; context?: string; ts: string };
|
|
460
440
|
results.push(`**\`${lesson.topic}\`** — ${lesson.what_worked.slice(0, 200)}`);
|
|
461
441
|
}
|
|
462
442
|
return `🔍 **Partial matches for \`${topic}\`:**\n\n${results.join('\n\n')}`;
|
|
@@ -617,12 +597,10 @@ export async function handleBrainTool(
|
|
|
617
597
|
tags?: string[]; confidence?: number; audit_trail?: unknown[];
|
|
618
598
|
};
|
|
619
599
|
const lessons: Lesson[] = [];
|
|
620
|
-
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
if (l) lessons.push(l);
|
|
625
|
-
}
|
|
600
|
+
for (const k of lessonKeys) {
|
|
601
|
+
const raw = await redis.get(k);
|
|
602
|
+
if (!raw) continue;
|
|
603
|
+
try { lessons.push(JSON.parse(raw) as Lesson); } catch { /* skip corrupt */ }
|
|
626
604
|
}
|
|
627
605
|
lessons.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
|
|
628
606
|
|
|
@@ -639,8 +617,10 @@ export async function handleBrainTool(
|
|
|
639
617
|
|
|
640
618
|
// 4. Last session
|
|
641
619
|
const lastSessionRaw = await redis.get('cachly:session:last');
|
|
642
|
-
|
|
643
|
-
|
|
620
|
+
let lastSession: { summary: string; ts: string; files_changed?: string[]; duration_min?: number } | null = null;
|
|
621
|
+
if (lastSessionRaw) {
|
|
622
|
+
try { lastSession = JSON.parse(lastSessionRaw); } catch { /* ignore */ }
|
|
623
|
+
}
|
|
644
624
|
|
|
645
625
|
// 5. Focus filtering
|
|
646
626
|
const focusTerms = focus.toLowerCase().split(/\s+/).filter(Boolean);
|
|
@@ -659,7 +639,7 @@ export async function handleBrainTool(
|
|
|
659
639
|
let streakMessage = '';
|
|
660
640
|
try {
|
|
661
641
|
const streakRaw = await redis.get('cachly:streak:current');
|
|
662
|
-
const streak =
|
|
642
|
+
const streak = streakRaw ? JSON.parse(streakRaw) as { days: number; last_date: string; record: number } : null;
|
|
663
643
|
const today = new Date().toISOString().slice(0, 10);
|
|
664
644
|
if (streak) {
|
|
665
645
|
const lastDate = streak.last_date;
|
|
@@ -701,34 +681,6 @@ export async function handleBrainTool(
|
|
|
701
681
|
const lines: string[] = [`🧠 **Session Briefing**${providerLabel}`, ''];
|
|
702
682
|
if (streakMessage) lines.push(streakMessage, '');
|
|
703
683
|
|
|
704
|
-
// ── What's New (shown once per version update) ──────────────────────────
|
|
705
|
-
try {
|
|
706
|
-
const seenVersion = await redis.get('cachly:mcp:version:last_seen');
|
|
707
|
-
if (seenVersion !== MCP_VERSION) {
|
|
708
|
-
await redis.set('cachly:mcp:version:last_seen', MCP_VERSION, 'EX', 365 * 86400);
|
|
709
|
-
const changelog = WHATS_NEW[MCP_VERSION];
|
|
710
|
-
if (changelog) { lines.push(...changelog, ''); }
|
|
711
|
-
}
|
|
712
|
-
} catch { /* non-critical */ }
|
|
713
|
-
|
|
714
|
-
// ── First-time welcome (empty brain) ───────────────────────────────────
|
|
715
|
-
const isFirstSession = !lastSession && lessons.length === 0 && ctxCount === 0;
|
|
716
|
-
if (isFirstSession) {
|
|
717
|
-
lines.push('🎉 **Welcome! Your AI Brain is live.**', '');
|
|
718
|
-
lines.push('It learns from your work automatically. After your first session it will look like this:', '');
|
|
719
|
-
lines.push(' ✅ `api:auth` — Bearer token in header, not cookie; 401 on missing scope');
|
|
720
|
-
lines.push(' ✅ `database:migrations` — always run migrations before deploy');
|
|
721
|
-
lines.push(' ⚠️ `docker:build` — ARG changes bust all subsequent cache layers');
|
|
722
|
-
lines.push('');
|
|
723
|
-
lines.push('**3 steps to grow your brain:**');
|
|
724
|
-
lines.push(' 1. Fix something → `learn_from_attempts(topic="bug:name", outcome="success", what_worked="...")`');
|
|
725
|
-
lines.push(' 2. Save context → `remember_context(key="arch", value="...")`');
|
|
726
|
-
lines.push(' 3. End session → `session_end(summary="What I did")`');
|
|
727
|
-
lines.push('');
|
|
728
|
-
lines.push('💡 Run `brain_doctor` for a setup health-check and personalised tips.');
|
|
729
|
-
lines.push('');
|
|
730
|
-
}
|
|
731
|
-
|
|
732
684
|
// Handoff from previous window (if any)
|
|
733
685
|
const handoffRaw = await redis.get('cachly:session:handoff');
|
|
734
686
|
if (handoffRaw) {
|
|
@@ -1173,6 +1125,8 @@ export async function handleBrainTool(
|
|
|
1173
1125
|
} catch { /* non-critical */ }
|
|
1174
1126
|
|
|
1175
1127
|
// ── 🌍 Knowledge Commons — community stats banner ───────────────────────
|
|
1128
|
+
// Fetch syndication stats and show a 1-liner: total lessons + confirms + weekly growth.
|
|
1129
|
+
// Non-fatal: never blocks session start if the API call fails.
|
|
1176
1130
|
try {
|
|
1177
1131
|
const commonsStats = await apiFetch<{
|
|
1178
1132
|
total_lessons: number;
|
|
@@ -1189,17 +1143,6 @@ export async function handleBrainTool(
|
|
|
1189
1143
|
}
|
|
1190
1144
|
} catch { /* non-critical — never block session start */ }
|
|
1191
1145
|
|
|
1192
|
-
// ── Brain Doctor hint (Punkt 6) — surface when brain needs attention ───
|
|
1193
|
-
const hasOpenFailures = lessons.filter(l => l.outcome === 'failure' || l.outcome === 'partial').length > 0;
|
|
1194
|
-
const hasStaleLessons = lessons.some(l => l.outcome === 'success' && calculateConfidence(l) < CONFIDENCE_WARN_VALUE);
|
|
1195
|
-
if (lessons.length === 0 || hasOpenFailures || hasStaleLessons) {
|
|
1196
|
-
const reasons: string[] = [];
|
|
1197
|
-
if (lessons.length === 0) reasons.push('brain is empty');
|
|
1198
|
-
if (hasOpenFailures) reasons.push('open failures');
|
|
1199
|
-
if (hasStaleLessons) reasons.push('stale lessons');
|
|
1200
|
-
lines.push(`🩺 _Run \`brain_doctor\` to fix: ${reasons.join(', ')}._`);
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
1146
|
return lines.join('\n');
|
|
1204
1147
|
}
|
|
1205
1148
|
|
|
@@ -1225,23 +1168,18 @@ export async function handleBrainTool(
|
|
|
1225
1168
|
// Calculate duration from session_start marker
|
|
1226
1169
|
let durationMin: number | undefined;
|
|
1227
1170
|
const currentRaw = await redis.get('cachly:session:current');
|
|
1228
|
-
if (
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
durationMin = Math.round((now.getTime() - new Date(current.started).getTime()) / 60000);
|
|
1171
|
+
if (currentRaw) {
|
|
1172
|
+
try {
|
|
1173
|
+
const current = JSON.parse(currentRaw) as { started: string };
|
|
1174
|
+
durationMin = Math.round((now.getTime() - new Date(current.started).getTime()) / 60000);
|
|
1175
|
+
} catch { /* ignore */ }
|
|
1234
1176
|
}
|
|
1235
1177
|
|
|
1236
1178
|
// ── Session Replay: capture decision log ─────────────────────────────
|
|
1237
|
-
|
|
1238
|
-
let decisionLog: DecisionEntry[] = [];
|
|
1179
|
+
let decisionLog: Array<{ ts: string; topic: string; outcome: string; what_worked: string }> = [];
|
|
1239
1180
|
try {
|
|
1240
1181
|
const dlEntries = await redis.lrange('cachly:session:decision-log', 0, -1);
|
|
1241
|
-
decisionLog = dlEntries.
|
|
1242
|
-
const entry = safeJsonParse<DecisionEntry | null>(e, null);
|
|
1243
|
-
return entry ? [entry] : [];
|
|
1244
|
-
});
|
|
1182
|
+
decisionLog = dlEntries.map(e => JSON.parse(e) as { ts: string; topic: string; outcome: string; what_worked: string });
|
|
1245
1183
|
await redis.del('cachly:session:decision-log');
|
|
1246
1184
|
} catch { /* non-critical */ }
|
|
1247
1185
|
|