@cachly-dev/mcp-server 0.9.17 → 0.9.18

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.
@@ -0,0 +1,608 @@
1
+ /**
2
+ * Use-case tests for the Cachly Brain flow.
3
+ *
4
+ * Tests the core Brain operations end-to-end using an in-memory Redis mock:
5
+ * learn_from_attempts → CKG update → recall_best_solution (BM25)
6
+ * session_start → lessons summary
7
+ * CKG: Bayesian confidence, contradiction detection
8
+ * Embeddings: provider detection, config sync
9
+ *
10
+ * Run: npx vitest run src/__tests__/brain-flow.test.ts
11
+ */
12
+
13
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
14
+ import { EventEmitter } from 'node:events';
15
+
16
+ // ── Modules under test ────────────────────────────────────────────────────────
17
+ import { ckgSlug, extractProblemConcept, ckgUpsertNode, ckgUpdateEdge } from '../ckg.js';
18
+ import { EMBED_PROVIDER, hasEmbedProvider, embedProviderHint, embedConfig, setEmbedJwt } from '../embeddings.js';
19
+ import { keywordSearch } from '../search.js';
20
+ import type { Redis } from 'ioredis';
21
+
22
+ // ── In-memory Redis mock ──────────────────────────────────────────────────────
23
+
24
+ class MockRedis {
25
+ private store = new Map<string, string>();
26
+ private lists = 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 rpush(key: string, ...values: string[]): Promise<number> {
39
+ const list = this.lists.get(key) ?? [];
40
+ list.push(...values);
41
+ this.lists.set(key, list);
42
+ return list.length;
43
+ }
44
+
45
+ async lrange(key: string, start: number, stop: number): Promise<string[]> {
46
+ const list = this.lists.get(key) ?? [];
47
+ const end = stop < 0 ? list.length + stop + 1 : stop + 1;
48
+ return list.slice(start < 0 ? list.length + start : start, end);
49
+ }
50
+
51
+ async ltrim(key: string, start: number, stop: number): Promise<'OK'> {
52
+ const list = this.lists.get(key) ?? [];
53
+ const end = stop < 0 ? list.length + stop + 1 : stop + 1;
54
+ this.lists.set(key, list.slice(start < 0 ? list.length + start : start, end));
55
+ return 'OK';
56
+ }
57
+
58
+ async sadd(key: string, ...members: string[]): Promise<number> {
59
+ const s = this.sets.get(key) ?? new Set<string>();
60
+ let added = 0;
61
+ for (const m of members) { if (!s.has(m)) { s.add(m); added++; } }
62
+ this.sets.set(key, s);
63
+ return added;
64
+ }
65
+
66
+ async smembers(key: string): Promise<string[]> {
67
+ return [...(this.sets.get(key) ?? [])];
68
+ }
69
+
70
+ /** Simplified scanStream: returns all matching keys in a single 'data' event. */
71
+ scanStream(opts: { match: string; count?: number }): EventEmitter {
72
+ const emitter = new EventEmitter();
73
+ const pattern = opts.match.replace(/\*/g, '.*').replace(/\?/g, '.');
74
+ const regex = new RegExp(`^${pattern}$`);
75
+ const matches = [...this.store.keys()].filter(k => regex.test(k));
76
+ setImmediate(() => {
77
+ emitter.emit('data', matches);
78
+ emitter.emit('end');
79
+ });
80
+ return emitter;
81
+ }
82
+
83
+ /** Minimal pipeline: batches GET calls, returns [err, value][] */
84
+ pipeline() {
85
+ const commands: Array<{ cmd: string; key: string }> = [];
86
+ const self = this;
87
+ return {
88
+ get(key: string) { commands.push({ cmd: 'get', key }); return this; },
89
+ async exec(): Promise<Array<[null, string | null]>> {
90
+ const out: Array<[null, string | null]> = [];
91
+ for (const c of commands) {
92
+ out.push([null, self.store.get(c.key) ?? null]);
93
+ }
94
+ return out;
95
+ },
96
+ };
97
+ }
98
+
99
+ // Expose raw store for assertions
100
+ _getStore() { return this.store; }
101
+ _getLists() { return this.lists; }
102
+ _getSets() { return this.sets; }
103
+ }
104
+
105
+ // Lesson schema (mirrors what learn_from_attempts stores)
106
+ interface LessonObj {
107
+ topic: string;
108
+ outcome: 'success' | 'failure' | 'partial';
109
+ what_worked: string;
110
+ what_failed?: string;
111
+ context?: string;
112
+ severity?: 'critical' | 'major' | 'minor';
113
+ commands?: string[];
114
+ tags?: string[];
115
+ file_paths?: string[];
116
+ recall_count: number;
117
+ ts: string;
118
+ verified_at?: string;
119
+ confidence: number;
120
+ audit_trail: Array<{ ts: string; action: string; prev_outcome?: string }>;
121
+ version: number;
122
+ }
123
+
124
+ /** Minimal implementation of the learn_from_attempts Redis storage logic */
125
+ async function storeLessonInRedis(
126
+ redis: MockRedis,
127
+ lesson: Omit<LessonObj, 'recall_count' | 'ts' | 'verified_at' | 'confidence' | 'audit_trail' | 'version'>,
128
+ ): Promise<LessonObj> {
129
+ const ts = new Date().toISOString();
130
+ const existingRaw = await redis.get(`cachly:lesson:best:${lesson.topic}`);
131
+ const auditTrail: LessonObj['audit_trail'] = existingRaw
132
+ ? [{ ts, action: 'updated', prev_outcome: (JSON.parse(existingRaw) as LessonObj).outcome }]
133
+ : [{ ts, action: 'created' }];
134
+ const obj: LessonObj = {
135
+ ...lesson,
136
+ recall_count: 0,
137
+ ts,
138
+ verified_at: lesson.outcome !== 'failure' ? ts : undefined,
139
+ confidence: 1.0,
140
+ audit_trail: auditTrail,
141
+ version: 3,
142
+ };
143
+ const serialized = JSON.stringify(obj);
144
+ await redis.rpush(`cachly:lessons:${lesson.topic}`, serialized);
145
+ if (lesson.outcome === 'success' || lesson.outcome === 'partial' || !existingRaw) {
146
+ await redis.set(`cachly:lesson:best:${lesson.topic}`, serialized);
147
+ }
148
+ return obj;
149
+ }
150
+
151
+ // ── Tests ─────────────────────────────────────────────────────────────────────
152
+
153
+ describe('CKG helpers (ckg.ts)', () => {
154
+ let redis: MockRedis;
155
+ beforeEach(() => { redis = new MockRedis(); });
156
+
157
+ // ── ckgSlug ─────────────────────────────────────────────────────────────────
158
+ describe('ckgSlug', () => {
159
+ it('lowercases and replaces non-alnum with hyphens', () => {
160
+ // trailing '!' becomes '-' which is then stripped by the trailing-hyphen rule
161
+ expect(ckgSlug('Deploy API!')).toBe('deploy-api');
162
+ });
163
+ it('collapses multiple hyphens', () => {
164
+ expect(ckgSlug('foo -- bar')).toBe('foo-bar');
165
+ });
166
+ it('strips leading/trailing hyphens', () => {
167
+ expect(ckgSlug('--hello--')).toBe('hello');
168
+ });
169
+ it('allows colon for namespaced slugs', () => {
170
+ expect(ckgSlug('deploy:api')).toBe('deploy:api');
171
+ });
172
+ it('truncates at 80 chars', () => {
173
+ expect(ckgSlug('a'.repeat(100))).toHaveLength(80);
174
+ });
175
+ });
176
+
177
+ // ── extractProblemConcept ────────────────────────────────────────────────────
178
+ describe('extractProblemConcept', () => {
179
+ it('extracts 2 keywords from text', () => {
180
+ const result = extractProblemConcept('docker container fails to start');
181
+ expect(result).toBe('docker-container');
182
+ });
183
+ it('filters short words', () => {
184
+ const result = extractProblemConcept('the key issue');
185
+ // 'the' is stopword, 'key' is 3 chars (filtered), 'issue' passes
186
+ expect(result).toBe('issue');
187
+ });
188
+ it('returns null for empty/all-stopword text', () => {
189
+ expect(extractProblemConcept('')).toBeNull();
190
+ });
191
+ });
192
+
193
+ // ── ckgUpsertNode ────────────────────────────────────────────────────────────
194
+ describe('ckgUpsertNode', () => {
195
+ it('creates a new node with count=1', async () => {
196
+ await ckgUpsertNode(redis as unknown as Redis, 'deploy-api', 'backend', 'concept');
197
+ const raw = await redis.get('cachly:ckg:node:deploy-api');
198
+ expect(raw).not.toBeNull();
199
+ const node = JSON.parse(raw!);
200
+ expect(node.id).toBe('deploy-api');
201
+ expect(node.domain).toBe('backend');
202
+ expect(node.type).toBe('concept');
203
+ expect(node.count).toBe(1);
204
+ });
205
+ it('increments count on repeated upsert', async () => {
206
+ await ckgUpsertNode(redis as unknown as Redis, 'deploy-api', 'backend', 'concept');
207
+ await ckgUpsertNode(redis as unknown as Redis, 'deploy-api', 'backend', 'concept');
208
+ const node = JSON.parse((await redis.get('cachly:ckg:node:deploy-api'))!);
209
+ expect(node.count).toBe(2);
210
+ });
211
+ });
212
+
213
+ // ── ckgUpdateEdge ────────────────────────────────────────────────────────────
214
+ describe('ckgUpdateEdge', () => {
215
+ it('creates edge with Bayesian smoothed confidence', async () => {
216
+ await ckgUpdateEdge(redis as unknown as Redis, 'problem-A', 'solved_by', 'solution-B', true);
217
+ const edgeKey = 'cachly:ckg:edge:problem-A:solved_by:solution-B';
218
+ const edge = JSON.parse((await redis.get(edgeKey))!);
219
+ expect(edge.successes).toBe(1);
220
+ expect(edge.trials).toBe(1);
221
+ // Beta(successes+1, trials+2) = 2/3 ≈ 0.667
222
+ expect(edge.confidence).toBeCloseTo(2 / 3, 5);
223
+ });
224
+ it('increases confidence after multiple successes', async () => {
225
+ const r = redis as unknown as Redis;
226
+ await ckgUpdateEdge(r, 'A', 'leads_to', 'B', true);
227
+ await ckgUpdateEdge(r, 'A', 'leads_to', 'B', true);
228
+ await ckgUpdateEdge(r, 'A', 'leads_to', 'B', true);
229
+ const edge = JSON.parse((await redis.get('cachly:ckg:edge:A:leads_to:B'))!);
230
+ // 3 successes, 3 trials → (3+1)/(3+2) = 0.8
231
+ expect(edge.confidence).toBeCloseTo(0.8, 5);
232
+ });
233
+ it('partial success adds 0.5 to successes', async () => {
234
+ const r = redis as unknown as Redis;
235
+ await ckgUpdateEdge(r, 'A', 'leads_to', 'B', false, true); // partial
236
+ const edge = JSON.parse((await redis.get('cachly:ckg:edge:A:leads_to:B'))!);
237
+ expect(edge.successes).toBe(0.5);
238
+ // (0.5+1)/(1+2) = 1.5/3 = 0.5
239
+ expect(edge.confidence).toBeCloseTo(0.5, 5);
240
+ });
241
+ it('decreases confidence on failure', async () => {
242
+ const r = redis as unknown as Redis;
243
+ await ckgUpdateEdge(r, 'A', 'leads_to', 'B', true);
244
+ const before = JSON.parse((await redis.get('cachly:ckg:edge:A:leads_to:B'))!).confidence;
245
+ await ckgUpdateEdge(r, 'A', 'leads_to', 'B', false);
246
+ const after = JSON.parse((await redis.get('cachly:ckg:edge:A:leads_to:B'))!).confidence;
247
+ expect(after).toBeLessThan(before);
248
+ });
249
+ it('indexes edge in from/to index sets', async () => {
250
+ const r = redis as unknown as Redis;
251
+ await ckgUpdateEdge(r, 'prob', 'solved_by', 'sol', true);
252
+ const fromIdx = await redis.smembers('cachly:ckg:idx:from:prob');
253
+ const toIdx = await redis.smembers('cachly:ckg:idx:to:sol');
254
+ expect(fromIdx).toContain('cachly:ckg:edge:prob:solved_by:sol');
255
+ expect(toIdx).toContain('cachly:ckg:edge:prob:solved_by:sol');
256
+ });
257
+ });
258
+ });
259
+
260
+ // ── Embeddings (embeddings.ts) ────────────────────────────────────────────────
261
+
262
+ describe('Embeddings config (embeddings.ts)', () => {
263
+ it('EMBED_PROVIDER is a string', () => {
264
+ expect(typeof EMBED_PROVIDER).toBe('string');
265
+ });
266
+
267
+ it('hasEmbedProvider returns false when no provider keys are set', () => {
268
+ // In test environment (no API keys), EMBED_PROVIDER is 'none'
269
+ // hasEmbedProvider() depends on env — just verify it returns a boolean
270
+ expect(typeof hasEmbedProvider()).toBe('boolean');
271
+ });
272
+
273
+ it('embedProviderHint returns a non-empty string', () => {
274
+ const hint = embedProviderHint();
275
+ expect(typeof hint).toBe('string');
276
+ expect(hint.length).toBeGreaterThan(0);
277
+ });
278
+
279
+ it('setEmbedJwt updates embedConfig.jwt', () => {
280
+ const prev = embedConfig.jwt;
281
+ setEmbedJwt('test-jwt-token-xyz');
282
+ expect(embedConfig.jwt).toBe('test-jwt-token-xyz');
283
+ // Restore
284
+ setEmbedJwt(prev);
285
+ });
286
+
287
+ it('embedConfig.apiUrl defaults to cachly.dev', () => {
288
+ expect(embedConfig.apiUrl).toContain('cachly.dev');
289
+ });
290
+ });
291
+
292
+ // ── Brain Lesson Flow (learn → store → recall) ───────────────────────────────
293
+
294
+ describe('Brain lesson flow', () => {
295
+ let redis: MockRedis;
296
+ beforeEach(() => { redis = new MockRedis(); });
297
+
298
+ describe('storeLessonInRedis (learn_from_attempts logic)', () => {
299
+ it('stores lesson at cachly:lesson:best:{topic}', async () => {
300
+ await storeLessonInRedis(redis, {
301
+ topic: 'deploy:api',
302
+ outcome: 'success',
303
+ what_worked: 'docker compose up -d --build api',
304
+ what_failed: 'docker compose up (hangs on SSH timeout)',
305
+ severity: 'critical',
306
+ commands: ['docker compose up -d --build api'],
307
+ tags: ['docker', 'deploy'],
308
+ file_paths: [],
309
+ });
310
+
311
+ const raw = await redis.get('cachly:lesson:best:deploy:api');
312
+ expect(raw).not.toBeNull();
313
+ const lesson = JSON.parse(raw!) as LessonObj;
314
+ expect(lesson.topic).toBe('deploy:api');
315
+ expect(lesson.outcome).toBe('success');
316
+ expect(lesson.what_worked).toBe('docker compose up -d --build api');
317
+ expect(lesson.commands).toContain('docker compose up -d --build api');
318
+ expect(lesson.confidence).toBe(1.0);
319
+ expect(lesson.version).toBe(3);
320
+ });
321
+
322
+ it('appends to history list cachly:lessons:{topic}', async () => {
323
+ await storeLessonInRedis(redis, {
324
+ topic: 'fix:redis-timeout',
325
+ outcome: 'success',
326
+ what_worked: 'increase timeout to 30s',
327
+ severity: 'major',
328
+ commands: [],
329
+ tags: [],
330
+ file_paths: [],
331
+ });
332
+ await storeLessonInRedis(redis, {
333
+ topic: 'fix:redis-timeout',
334
+ outcome: 'partial',
335
+ what_worked: 'add retry logic',
336
+ severity: 'minor',
337
+ commands: [],
338
+ tags: [],
339
+ file_paths: [],
340
+ });
341
+
342
+ const history = await redis.lrange('cachly:lessons:fix:redis-timeout', 0, -1);
343
+ expect(history).toHaveLength(2);
344
+ });
345
+
346
+ it('failure does not overwrite an existing success lesson', async () => {
347
+ await storeLessonInRedis(redis, {
348
+ topic: 'infra:wireguard',
349
+ outcome: 'success',
350
+ what_worked: 'add WireGuard IP to TLS-SAN',
351
+ severity: 'critical',
352
+ commands: [],
353
+ tags: [],
354
+ file_paths: [],
355
+ });
356
+ await storeLessonInRedis(redis, {
357
+ topic: 'infra:wireguard',
358
+ outcome: 'failure',
359
+ what_worked: '',
360
+ severity: 'major',
361
+ commands: [],
362
+ tags: [],
363
+ file_paths: [],
364
+ });
365
+
366
+ const best = JSON.parse((await redis.get('cachly:lesson:best:infra:wireguard'))!) as LessonObj;
367
+ // The success lesson must win
368
+ expect(best.outcome).toBe('success');
369
+ });
370
+
371
+ it('audit trail records creation and update', async () => {
372
+ await storeLessonInRedis(redis, {
373
+ topic: 'debug:clickhouse',
374
+ outcome: 'failure',
375
+ what_worked: '',
376
+ severity: 'major',
377
+ commands: [],
378
+ tags: [],
379
+ file_paths: [],
380
+ });
381
+ await storeLessonInRedis(redis, {
382
+ topic: 'debug:clickhouse',
383
+ outcome: 'success',
384
+ what_worked: 'use 127.0.0.1 not localhost',
385
+ severity: 'major',
386
+ commands: [],
387
+ tags: [],
388
+ file_paths: [],
389
+ });
390
+
391
+ const best = JSON.parse((await redis.get('cachly:lesson:best:debug:clickhouse'))!) as LessonObj;
392
+ expect(best.audit_trail.length).toBe(1);
393
+ expect(best.audit_trail[0].action).toBe('updated');
394
+ expect(best.audit_trail[0].prev_outcome).toBe('failure');
395
+ });
396
+ });
397
+
398
+ // ── BM25 recall ─────────────────────────────────────────────────────────────
399
+
400
+ describe('BM25 recall (recall_best_solution keyword search)', () => {
401
+ beforeEach(async () => {
402
+ // Seed lessons
403
+ const lessons = [
404
+ { topic: 'deploy:api', what_worked: 'nohup docker compose up -d --build api' },
405
+ { topic: 'infra:clickhouse-ipv6', what_worked: 'use 127.0.0.1 not localhost in ClickHouse healthcheck' },
406
+ { topic: 'fix:keycloak-auth', what_worked: 'set redirect_uri to https not http' },
407
+ { topic: 'bash:macos-lowercase', what_worked: 'use tr instead of ${var,,} on macOS' },
408
+ { topic: 'deploy:web', what_worked: 'rsync --checksum --delete with ssh port 2222' },
409
+ ];
410
+ for (const l of lessons) {
411
+ await storeLessonInRedis(redis, {
412
+ ...l,
413
+ outcome: 'success',
414
+ severity: 'critical',
415
+ commands: [],
416
+ tags: [],
417
+ file_paths: [],
418
+ });
419
+ }
420
+ });
421
+
422
+ it('finds deploy lesson by keyword', async () => {
423
+ const results = await keywordSearch(
424
+ redis as unknown as Redis,
425
+ ['cachly:lesson:best:*'],
426
+ 'docker deploy api',
427
+ 5,
428
+ );
429
+ expect(results.length).toBeGreaterThan(0);
430
+ expect(results[0].content).toContain('docker');
431
+ });
432
+
433
+ it('finds clickhouse lesson by infrastructure topic', async () => {
434
+ const results = await keywordSearch(
435
+ redis as unknown as Redis,
436
+ ['cachly:lesson:best:*'],
437
+ 'clickhouse localhost healthcheck',
438
+ 5,
439
+ );
440
+ expect(results.length).toBeGreaterThan(0);
441
+ const topResult = results[0];
442
+ expect(topResult.content.toLowerCase()).toContain('clickhouse');
443
+ });
444
+
445
+ it('ranks more specific matches higher', async () => {
446
+ const results = await keywordSearch(
447
+ redis as unknown as Redis,
448
+ ['cachly:lesson:best:*'],
449
+ 'macos bash lowercase variable',
450
+ 5,
451
+ );
452
+ expect(results.length).toBeGreaterThan(0);
453
+ // macOS bash lesson should rank highest since it matches all terms
454
+ expect(results[0].content.toLowerCase()).toContain('macos');
455
+ });
456
+
457
+ it('returns empty array for completely unrelated query', async () => {
458
+ const results = await keywordSearch(
459
+ redis as unknown as Redis,
460
+ ['cachly:lesson:best:*'],
461
+ 'xyzzy quantum entanglement unobtanium',
462
+ 5,
463
+ );
464
+ // Might return 0 or very low-score results; score should be minimal
465
+ // Either empty or score near 0 is acceptable
466
+ if (results.length > 0) {
467
+ expect(results[0].score).toBeLessThan(0.1);
468
+ }
469
+ });
470
+ });
471
+
472
+ // ── session_start context ────────────────────────────────────────────────────
473
+
474
+ describe('session_start lesson aggregation', () => {
475
+ it('correctly sorts lessons by timestamp (newest first)', async () => {
476
+ const topics = ['old:lesson', 'mid:lesson', 'new:lesson'];
477
+ for (let i = 0; i < topics.length; i++) {
478
+ await storeLessonInRedis(redis, {
479
+ topic: topics[i],
480
+ outcome: 'success',
481
+ what_worked: `fix ${i}`,
482
+ severity: 'minor',
483
+ commands: [],
484
+ tags: [],
485
+ file_paths: [],
486
+ });
487
+ // Tiny delay to ensure different timestamps
488
+ await new Promise(r => setTimeout(r, 5));
489
+ }
490
+
491
+ // Simulate what session_start does: scan all lesson keys, sort by ts descending
492
+ const allKeys: string[] = [];
493
+ await new Promise<void>((resolve, reject) => {
494
+ const stream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
495
+ stream.on('data', (batch: string[]) => allKeys.push(...batch));
496
+ stream.on('end', resolve);
497
+ stream.on('error', reject);
498
+ });
499
+
500
+ const lessons: LessonObj[] = [];
501
+ for (const k of allKeys) {
502
+ const raw = await redis.get(k);
503
+ if (raw) lessons.push(JSON.parse(raw) as LessonObj);
504
+ }
505
+ lessons.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
506
+
507
+ expect(lessons[0].topic).toBe('new:lesson');
508
+ expect(lessons[lessons.length - 1].topic).toBe('old:lesson');
509
+ });
510
+
511
+ it('scanStream returns all stored lesson keys', async () => {
512
+ await storeLessonInRedis(redis, { topic: 'alpha', outcome: 'success', what_worked: 'x', severity: 'minor', commands: [], tags: [], file_paths: [] });
513
+ await storeLessonInRedis(redis, { topic: 'beta', outcome: 'success', what_worked: 'y', severity: 'minor', commands: [], tags: [], file_paths: [] });
514
+ await storeLessonInRedis(redis, { topic: 'gamma', outcome: 'success', what_worked: 'z', severity: 'minor', commands: [], tags: [], file_paths: [] });
515
+
516
+ const keys: string[] = [];
517
+ await new Promise<void>((resolve, reject) => {
518
+ const stream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 100 });
519
+ stream.on('data', (batch: string[]) => keys.push(...batch));
520
+ stream.on('end', resolve);
521
+ stream.on('error', reject);
522
+ });
523
+
524
+ expect(keys).toHaveLength(3);
525
+ expect(keys.some(k => k.includes('alpha'))).toBe(true);
526
+ expect(keys.some(k => k.includes('beta'))).toBe(true);
527
+ expect(keys.some(k => k.includes('gamma'))).toBe(true);
528
+ });
529
+ });
530
+ });
531
+
532
+ // ── End-to-end Brain flow integration ────────────────────────────────────────
533
+
534
+ describe('Brain E2E: learn → CKG → recall', () => {
535
+ let redis: MockRedis;
536
+ beforeEach(() => { redis = new MockRedis(); });
537
+
538
+ it('full cycle: store lesson + update CKG + retrieve via BM25', async () => {
539
+ const r = redis as unknown as Redis;
540
+
541
+ // 1. Learn: store a deploy lesson
542
+ await storeLessonInRedis(redis, {
543
+ topic: 'deploy:api',
544
+ outcome: 'success',
545
+ what_worked: 'nohup docker compose up -d --build api avoids SSH timeout',
546
+ what_failed: 'plain docker compose up hangs when SSH connection drops',
547
+ severity: 'critical',
548
+ commands: ['nohup docker compose up -d --build api'],
549
+ tags: ['docker', 'deploy', 'ssh'],
550
+ file_paths: ['docker-compose.yml'],
551
+ });
552
+
553
+ // 2. CKG: record the causal connection
554
+ await ckgUpsertNode(r, 'deploy-api', 'infra', 'concept');
555
+ await ckgUpdateEdge(r, 'ssh-timeout', 'solved_by', 'nohup-docker', true);
556
+
557
+ // 3. Recall via BM25
558
+ const results = await keywordSearch(
559
+ r,
560
+ ['cachly:lesson:best:*'],
561
+ 'docker compose ssh timeout deploy',
562
+ 5,
563
+ );
564
+
565
+ expect(results.length).toBeGreaterThan(0);
566
+ expect(results[0].content).toContain('nohup');
567
+
568
+ // 4. Verify CKG state
569
+ const edge = JSON.parse((await redis.get('cachly:ckg:edge:ssh-timeout:solved_by:nohup-docker'))!);
570
+ expect(edge.confidence).toBeCloseTo(2 / 3, 5); // Beta(1+1, 1+2)
571
+
572
+ // 5. Verify lesson persisted correctly
573
+ const lesson = JSON.parse((await redis.get('cachly:lesson:best:deploy:api'))!) as LessonObj;
574
+ expect(lesson.outcome).toBe('success');
575
+ expect(lesson.commands).toContain('nohup docker compose up -d --build api');
576
+ });
577
+
578
+ it('contradiction: failure after success is detected', async () => {
579
+ await storeLessonInRedis(redis, {
580
+ topic: 'infra:k3s',
581
+ outcome: 'success',
582
+ what_worked: 'add WireGuard IP to k3s TLS-SAN',
583
+ severity: 'critical',
584
+ commands: [],
585
+ tags: [],
586
+ file_paths: [],
587
+ });
588
+ await storeLessonInRedis(redis, {
589
+ topic: 'infra:k3s',
590
+ outcome: 'failure',
591
+ what_worked: '',
592
+ severity: 'major',
593
+ commands: [],
594
+ tags: [],
595
+ file_paths: [],
596
+ });
597
+
598
+ // Success lesson should still be the "best" lesson
599
+ const best = JSON.parse((await redis.get('cachly:lesson:best:infra:k3s'))!) as LessonObj;
600
+ expect(best.outcome).toBe('success');
601
+
602
+ // But failure is logged in history
603
+ const history = await redis.lrange('cachly:lessons:infra:k3s', 0, -1);
604
+ expect(history).toHaveLength(2);
605
+ const lastAttempt = JSON.parse(history[1]) as LessonObj;
606
+ expect(lastAttempt.outcome).toBe('failure');
607
+ });
608
+ });
package/src/ckg.ts ADDED
@@ -0,0 +1,53 @@
1
+ import { Redis } from 'ioredis';
2
+
3
+ // ── Layer 1: Causal Knowledge Graph (CKG) helpers ────────────────────────────
4
+ // Implements the CKG from the 10x Vision Document.
5
+ // Nodes: cachly:ckg:node:{id} → { id, domain, type, count, ts }
6
+ // Edges: cachly:ckg:edge:{from}:{edgeType}:{to} → { from, to, edgeType, successes, trials, confidence, last_updated }
7
+
8
+ export type CKGEdge = {
9
+ from: string; to: string; edgeType: string;
10
+ successes: number; trials: number; confidence: number; last_updated: string;
11
+ };
12
+ export type CKGNode = { id: string; domain: string; type: string; count: number; ts: string };
13
+
14
+ const STOPWORDS_CKG = new Set(['that','this','with','from','when','then','also','have','been','will','were','they','them','than','more','some','into','over','only','just','where','while','which','there','their','would','could','should','after','before','about']);
15
+
16
+ export function ckgSlug(text: string): string {
17
+ return text.toLowerCase().replace(/[^a-z0-9\-:]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 80);
18
+ }
19
+
20
+ /** Extract 1-3 significant keywords from free text for a problem concept */
21
+ export function extractProblemConcept(text: string): string | null {
22
+ const words = text.toLowerCase()
23
+ .replace(/[^a-z0-9\s\-_]/g, ' ').split(/\s+/)
24
+ .filter(w => w.length > 3 && !STOPWORDS_CKG.has(w))
25
+ .slice(0, 5);
26
+ if (words.length === 0) return null;
27
+ return words.slice(0, 2).join('-');
28
+ }
29
+
30
+ export async function ckgUpsertNode(redis: Redis, id: string, domain: string, type: string): Promise<void> {
31
+ const key = `cachly:ckg:node:${id}`;
32
+ const raw = await redis.get(key);
33
+ const node: CKGNode = raw ? JSON.parse(raw) : { id, domain, type, count: 0, ts: new Date().toISOString() };
34
+ node.count = (node.count || 0) + 1;
35
+ node.ts = new Date().toISOString();
36
+ await redis.set(key, JSON.stringify(node));
37
+ }
38
+
39
+ export async function ckgUpdateEdge(redis: Redis, from: string, edgeType: string, to: string, success: boolean, partial = false): Promise<void> {
40
+ const key = `cachly:ckg:edge:${from}:${edgeType}:${to}`;
41
+ const raw = await redis.get(key);
42
+ const edge: CKGEdge = raw ? JSON.parse(raw) : { from, to, edgeType, successes: 0, trials: 0, confidence: 0, last_updated: '' };
43
+ edge.trials = (edge.trials || 0) + 1;
44
+ if (success) edge.successes = (edge.successes || 0) + 1;
45
+ else if (partial) edge.successes = (edge.successes || 0) + 0.5;
46
+ // Beta distribution smoothed confidence: (successes+1) / (trials+2)
47
+ edge.confidence = (edge.successes + 1) / (edge.trials + 2);
48
+ edge.last_updated = new Date().toISOString();
49
+ await redis.set(key, JSON.stringify(edge));
50
+ // Index: set of edge keys per source node (for fast traversal)
51
+ await redis.sadd(`cachly:ckg:idx:from:${from}`, key);
52
+ await redis.sadd(`cachly:ckg:idx:to:${to}`, key);
53
+ }