@cachly-dev/mcp-server 0.10.27 → 0.10.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.env.test.example +23 -0
  2. package/.github/ISSUE_TEMPLATE/bug_report.md +50 -0
  3. package/.github/ISSUE_TEMPLATE/config.yml +8 -0
  4. package/.github/ISSUE_TEMPLATE/feature_request.md +25 -0
  5. package/.github/workflows/publish.yml +72 -0
  6. package/CHANGELOG.md +252 -0
  7. package/LAUNCH_TEXTS.md +182 -0
  8. package/LICENSE +202 -0
  9. package/README.md +145 -115
  10. package/dist/__tests__/auth.test.d.ts +1 -0
  11. package/dist/__tests__/auth.test.js +107 -0
  12. package/dist/__tests__/auth.test.js.map +1 -0
  13. package/dist/__tests__/e2e/api-auth.test.d.ts +7 -0
  14. package/dist/__tests__/e2e/api-auth.test.js +162 -0
  15. package/dist/__tests__/e2e/api-auth.test.js.map +1 -0
  16. package/dist/__tests__/e2e/api-health.test.d.ts +7 -0
  17. package/dist/__tests__/e2e/api-health.test.js +95 -0
  18. package/dist/__tests__/e2e/api-health.test.js.map +1 -0
  19. package/dist/__tests__/e2e/cli.test.d.ts +10 -0
  20. package/dist/__tests__/e2e/cli.test.js +204 -0
  21. package/dist/__tests__/e2e/cli.test.js.map +1 -0
  22. package/dist/__tests__/e2e/config.d.ts +35 -0
  23. package/dist/__tests__/e2e/config.js +53 -0
  24. package/dist/__tests__/e2e/config.js.map +1 -0
  25. package/dist/__tests__/e2e/funnel.test.d.ts +13 -0
  26. package/dist/__tests__/e2e/funnel.test.js +90 -0
  27. package/dist/__tests__/e2e/funnel.test.js.map +1 -0
  28. package/dist/__tests__/e2e/mcp-tools.test.d.ts +7 -0
  29. package/dist/__tests__/e2e/mcp-tools.test.js +129 -0
  30. package/dist/__tests__/e2e/mcp-tools.test.js.map +1 -0
  31. package/dist/__tests__/e2e/provisioning.test.d.ts +15 -0
  32. package/dist/__tests__/e2e/provisioning.test.js +123 -0
  33. package/dist/__tests__/e2e/provisioning.test.js.map +1 -0
  34. package/dist/handlers/advanced.js +38 -227
  35. package/dist/handlers/advanced.js.map +1 -1
  36. package/dist/handlers/brain.js +146 -82
  37. package/dist/handlers/brain.js.map +1 -1
  38. package/dist/handlers/fedbrain.js +93 -44
  39. package/dist/handlers/fedbrain.js.map +1 -1
  40. package/dist/handlers/roadmap.js +8 -2
  41. package/dist/handlers/roadmap.js.map +1 -1
  42. package/dist/handlers/syndicate.js +15 -7
  43. package/dist/handlers/syndicate.js.map +1 -1
  44. package/dist/handlers/team.js +62 -50
  45. package/dist/handlers/team.js.map +1 -1
  46. package/dist/index.js +1264 -234
  47. package/dist/index.js.map +1 -1
  48. package/dist/tools.d.ts +3 -54
  49. package/dist/tools.js +8 -62
  50. package/dist/tools.js.map +1 -1
  51. package/eslint.config.js +16 -0
  52. package/glama.json +36 -0
  53. package/llms.txt +155 -0
  54. package/package.json +38 -32
  55. package/scripts/e2e-run.sh +160 -0
  56. package/scripts/postinstall.js +12 -12
  57. package/server.json +3 -9
  58. package/smithery.yaml +15 -8
  59. package/src/__tests__/auth.test.ts +147 -0
  60. package/src/__tests__/e2e/api-auth.test.ts +182 -0
  61. package/src/__tests__/e2e/api-health.test.ts +103 -0
  62. package/src/__tests__/e2e/cli.test.ts +228 -0
  63. package/src/__tests__/e2e/config.ts +53 -0
  64. package/src/__tests__/e2e/funnel.test.ts +99 -0
  65. package/src/__tests__/e2e/mcp-tools.test.ts +191 -0
  66. package/src/__tests__/e2e/provisioning.test.ts +128 -0
  67. package/src/handlers/advanced.ts +37 -217
  68. package/src/handlers/brain.ts +143 -78
  69. package/src/handlers/fedbrain.ts +88 -38
  70. package/src/handlers/roadmap.ts +7 -2
  71. package/src/handlers/syndicate.ts +13 -7
  72. package/src/handlers/team.ts +57 -33
  73. package/src/index.ts +1312 -257
  74. package/src/tools.ts +8 -66
  75. package/vitest.config.ts +12 -0
  76. package/dist/__tests__/brain-predict-failures.test.d.ts +0 -17
  77. package/dist/__tests__/brain-predict-failures.test.js +0 -339
  78. package/dist/__tests__/brain-predict-failures.test.js.map +0 -1
  79. package/src/__tests__/brain-predict-failures.test.ts +0 -430
@@ -0,0 +1,128 @@
1
+ /**
2
+ * E2E: Provisioning flow
3
+ *
4
+ * Tests the full instance lifecycle:
5
+ * create → poll until active (or timeout) → connect → delete
6
+ *
7
+ * ⚠️ This test creates a real instance and WILL be billed if not deleted.
8
+ * It always cleans up in afterAll, even on failure.
9
+ *
10
+ * Skipped if E2E_SKIP_SLOW=true.
11
+ *
12
+ * Requires: E2E_JWT
13
+ * Run: npx vitest run src/__tests__/e2e/provisioning.test.ts --reporter=verbose
14
+ */
15
+
16
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
17
+ import { cfg, requireAuth, apiGet, apiPost } from './config.js';
18
+
19
+ beforeAll(() => {
20
+ requireAuth();
21
+ if (cfg.skipSlow) {
22
+ console.log('Skipping provisioning tests (E2E_SKIP_SLOW=true)');
23
+ }
24
+ });
25
+
26
+ let createdInstanceId: string | null = null;
27
+
28
+ afterAll(async () => {
29
+ if (!createdInstanceId) return;
30
+ try {
31
+ const res = await fetch(`${cfg.apiUrl}/api/v1/instances/${createdInstanceId}`, {
32
+ method: 'DELETE',
33
+ headers: { Authorization: `Bearer ${cfg.jwt}` },
34
+ });
35
+ console.log(`Cleanup: deleted instance ${createdInstanceId} → ${res.status}`);
36
+ } catch (e) {
37
+ console.warn(`Cleanup failed for ${createdInstanceId}:`, e);
38
+ }
39
+ });
40
+
41
+ describe('Instance provisioning lifecycle', () => {
42
+ it.skipIf(cfg.skipSlow)('POST /api/v1/instances creates a new free instance', async () => {
43
+ const { status, body } = await apiPost('/api/v1/instances', {
44
+ name: `e2e-test-${Date.now()}`,
45
+ tier: 'free',
46
+ });
47
+ expect([200, 201]).toContain(status);
48
+ const inst = body as Record<string, unknown>;
49
+ expect(typeof inst.id).toBe('string');
50
+ expect(inst.status).toMatch(/^(provisioning|active)$/);
51
+ createdInstanceId = inst.id as string;
52
+ });
53
+
54
+ it.skipIf(cfg.skipSlow)('instance status transitions from provisioning → active within 5 min', async () => {
55
+ if (!createdInstanceId) return;
56
+
57
+ const maxWaitMs = 5 * 60 * 1000; // 5 minutes
58
+ const pollMs = 10_000; // poll every 10 seconds
59
+ const start = Date.now();
60
+ let status = 'provisioning';
61
+
62
+ while (Date.now() - start < maxWaitMs) {
63
+ await new Promise((r) => setTimeout(r, pollMs));
64
+ const { body } = await apiGet(`/api/v1/instances/${createdInstanceId}`);
65
+ status = (body as Record<string, unknown>).status as string;
66
+ console.log(` [${Math.round((Date.now() - start) / 1000)}s] status: ${status}`);
67
+ if (status === 'active') break;
68
+ if (status === 'failed') break;
69
+ }
70
+
71
+ expect(status).toBe('active');
72
+ }, 6 * 60 * 1000); // vitest timeout: 6 min
73
+
74
+ it.skipIf(cfg.skipSlow)('active instance returns valid connection details', async () => {
75
+ if (!createdInstanceId) return;
76
+ const { status, body } = await apiGet(`/api/v1/instances/${createdInstanceId}/connection`);
77
+ expect(status).toBe(200);
78
+ const conn = body as Record<string, unknown>;
79
+ expect(typeof conn.host).toBe('string');
80
+ expect(Number(conn.port)).toBeGreaterThan(0);
81
+ expect(typeof conn.password).toBe('string');
82
+ });
83
+
84
+ it.skipIf(cfg.skipSlow)('can PING the provisioned Redis', async () => {
85
+ if (!createdInstanceId) return;
86
+ const { body } = await apiGet(`/api/v1/instances/${createdInstanceId}/connection`);
87
+ const conn = body as Record<string, unknown>;
88
+
89
+ const { Redis: RedisClass } = await import('ioredis');
90
+ const r = new RedisClass({
91
+ host: conn.host as string,
92
+ port: Number(conn.port),
93
+ password: conn.password as string,
94
+ tls: cfg.redisTls ? {} : undefined,
95
+ connectTimeout: 8000,
96
+ lazyConnect: true,
97
+ });
98
+ try {
99
+ await r.connect();
100
+ expect(await r.ping()).toBe('PONG');
101
+ } finally {
102
+ await r.quit();
103
+ }
104
+ });
105
+ });
106
+
107
+ describe('Provisioning status — failed instance handling', () => {
108
+ it('failed status returns meaningful error (not 500) from connection endpoint', async () => {
109
+ // We use a known-bad UUID to simulate a missing/failed instance
110
+ const { status } = await apiGet('/api/v1/instances/00000000-0000-0000-0000-000000000000/connection');
111
+ // Should be a client error (4xx), never a server error (5xx)
112
+ expect(status).toBeGreaterThanOrEqual(400);
113
+ expect(status).toBeLessThan(500);
114
+ });
115
+ });
116
+
117
+ describe('Auto-provision via /instances/auto', () => {
118
+ it.skipIf(cfg.skipSlow)('POST /api/v1/instances/auto returns instance id', async () => {
119
+ const { status, body } = await apiPost('/api/v1/instances/auto', {});
120
+ // 200 = existing instance returned
121
+ // 201 = new instance created
122
+ // 409 = already has an instance (also fine)
123
+ expect([200, 201, 409]).toContain(status);
124
+ if (status !== 409) {
125
+ expect(typeof (body as Record<string, unknown>).id).toBe('string');
126
+ }
127
+ });
128
+ });
@@ -4,13 +4,13 @@ import { calculateConfidence, confidenceBadge, CONFIDENCE_STALE_VALUE, CONFIDENC
4
4
  import { ckgSlug, ckgUpdateEdge } from '../ckg.js';
5
5
  import type { CKGEdge, CKGNode } from '../ckg.js';
6
6
  import { keywordSearch } from '../search.js';
7
+ import { safeJsonParse } from '../utils.js';
7
8
 
8
9
  type GetConnection = (instanceId: string) => Promise<Redis>;
9
10
  type ApiFetch = <T>(path: string, options?: RequestInit) => Promise<T>;
10
11
 
11
12
  export const ADVANCED_TOOL_NAMES = new Set([
12
13
  'memory_consolidate', 'brain_diff', 'causal_trace', 'knowledge_decay', 'autopilot',
13
- 'brain_stats', 'pin_lesson', 'unpin_lesson',
14
14
  ]);
15
15
 
16
16
  export async function handleAdvancedTool(
@@ -46,7 +46,8 @@ export async function handleAdvancedTool(
46
46
  for (const k of lessonKeys) {
47
47
  const raw = await redis.get(k);
48
48
  if (!raw) continue;
49
- try { lessons.set(k, JSON.parse(raw) as Lesson); } catch { /* skip malformed */ }
49
+ const lesson = safeJsonParse<Lesson | null>(raw, null);
50
+ if (lesson) lessons.set(k, lesson);
50
51
  }
51
52
 
52
53
  // Detect duplicates: same topic prefix (e.g. deploy:api vs deploy:api-v2)
@@ -104,25 +105,6 @@ export async function handleAdvancedTool(
104
105
  for (const k of stale) {
105
106
  await redis.expire(k, 86400 * 30);
106
107
  }
107
- // Auto-unpin lessons whose lesson key was deleted (merged or stale-expired)
108
- // Also unpin if the lesson outcome changed to 'failure' (auto-pin only fires on success/partial)
109
- const pinKeys: string[] = [];
110
- const pinStream = redis.scanStream({ match: 'cachly:lesson:pin:*', count: 100 });
111
- await new Promise<void>((res, rej) => { pinStream.on('data', (b: string[]) => pinKeys.push(...b)); pinStream.on('end', res); pinStream.on('error', rej); });
112
- let unpinned = 0;
113
- for (const pk of pinKeys) {
114
- const pinnedTopic = pk.replace('cachly:lesson:pin:', '');
115
- const lessonStillExists = await redis.exists(`cachly:lesson:best:${pinnedTopic}`);
116
- if (!lessonStillExists) { await redis.del(pk); unpinned++; continue; }
117
- const raw = await redis.get(`cachly:lesson:best:${pinnedTopic}`);
118
- if (raw) {
119
- try {
120
- const l = JSON.parse(raw) as { outcome: string };
121
- if (l.outcome === 'failure') { await redis.del(pk); unpinned++; }
122
- } catch { /* skip */ }
123
- }
124
- }
125
- if (unpinned > 0) stale.push(...Array(unpinned).fill('__unpin_placeholder__')); // just for count
126
108
  }
127
109
 
128
110
  const lines = [
@@ -186,8 +168,9 @@ export async function handleAdvancedTool(
186
168
  for (const k of lessonKeys) {
187
169
  const raw = await redis.get(k);
188
170
  if (!raw) continue;
189
- try {
190
- const l = JSON.parse(raw) as Lesson;
171
+ {
172
+ const l = safeJsonParse<Lesson | null>(raw, null);
173
+ if (!l) continue;
191
174
  const ts = new Date(l.ts).getTime();
192
175
  if (ts >= sinceMs) {
193
176
  // Check history to determine add vs update
@@ -201,7 +184,7 @@ export async function handleAdvancedTool(
201
184
  // lessons with recalls as "active"
202
185
  recalled.push(l);
203
186
  }
204
- } catch { /* skip */ }
187
+ }
205
188
  }
206
189
 
207
190
  const sinceLabel = match ? `last ${since}` : new Date(sinceMs).toLocaleDateString('de-DE');
@@ -235,10 +218,10 @@ export async function handleAdvancedTool(
235
218
  for (const rk of resolutionKeys) {
236
219
  const rr = await redis.get(rk);
237
220
  if (!rr) continue;
238
- try {
239
- const rn = JSON.parse(rr) as { topic?: string; resolution?: string; ts?: string };
240
- if (rn.ts && new Date(rn.ts).getTime() >= sinceMs) recentResolutions.push({ topic: rn.topic ?? rk, resolution: rn.resolution ?? 'unknown', ts: rn.ts });
241
- } catch { /* skip */ }
221
+ {
222
+ const rn = safeJsonParse<{ topic?: string; resolution?: string; ts?: string } | null>(rr, null);
223
+ if (rn?.ts && new Date(rn.ts).getTime() >= sinceMs) recentResolutions.push({ topic: rn.topic ?? rk, resolution: rn.resolution ?? 'unknown', ts: rn.ts });
224
+ }
242
225
  }
243
226
  if (recentResolutions.length > 0) {
244
227
  lines.push(`🗳️ **MADC Resolutions** (${recentResolutions.length} contested beliefs resolved):`);
@@ -258,8 +241,10 @@ export async function handleAdvancedTool(
258
241
 
259
242
  // 3. FedBrain transfers received in window
260
243
  const fedHistRaw = await redis.lrange('cachly:fedbrain:federations', -20, -1);
261
- const recentFeds = fedHistRaw.map(r => { try { return JSON.parse(r) as { source: string; domain: string; transferred_at: string; nodes: number; edges: number }; } catch { return null; } })
262
- .filter(f => f && new Date(f!.transferred_at).getTime() >= sinceMs) as Array<{ source: string; domain: string; transferred_at: string; nodes: number; edges: number }>;
244
+ type FedEntry = { source: string; domain: string; transferred_at: string; nodes: number; edges: number };
245
+ const recentFeds = fedHistRaw
246
+ .map(r => safeJsonParse<FedEntry | null>(r, null))
247
+ .filter((f): f is FedEntry => f !== null && new Date(f.transferred_at).getTime() >= sinceMs);
263
248
  if (recentFeds.length > 0) {
264
249
  lines.push(`🧠 **FedBrain transfers received (${recentFeds.length}):**`);
265
250
  for (const f of recentFeds.slice(0, 3)) {
@@ -302,27 +287,30 @@ export async function handleAdvancedTool(
302
287
  for (const nodeKey of nodeKeys.slice(0, 10)) {
303
288
  const nodeRaw = await redis.get(nodeKey);
304
289
  if (!nodeRaw) continue;
305
- const node: CKGNode = JSON.parse(nodeRaw);
290
+ const node = safeJsonParse<CKGNode | null>(nodeRaw, null);
291
+ if (!node) continue;
306
292
  // Get edges from this node
307
293
  const edgeKeys = await redis.smembers(`cachly:ckg:idx:from:${node.id}`);
308
294
  for (const ek of edgeKeys.slice(0, 20)) {
309
295
  const edgeRaw = await redis.get(ek);
310
296
  if (!edgeRaw) continue;
311
- const edge: CKGEdge = JSON.parse(edgeRaw);
297
+ const edge = safeJsonParse<CKGEdge | null>(edgeRaw, null);
298
+ if (!edge) continue;
312
299
  if (edge.edgeType !== 'fixes' && edge.edgeType !== 'requires') continue;
313
300
  // Find lesson for this concept
314
301
  const lessonRaw = await redis.get(`cachly:lesson:best:${edge.from.replace(/-/g, ':').replace(/^fix:/, 'fix:')}`);
315
- const lesson = lessonRaw ? JSON.parse(lessonRaw) : undefined;
316
- ckgResults.push({ conceptId: node.id, edge, lesson });
302
+ const lesson = lessonRaw ? safeJsonParse<{ topic: string; what_worked?: string; ts: string; outcome: string; recall_count?: number; severity?: string } | null>(lessonRaw, null) ?? undefined : undefined;
303
+ ckgResults.push({ conceptId: node.id, edge, lesson: lesson ?? undefined });
317
304
  }
318
305
  }
319
306
  for (const ek of [...fromKeys, ...toKeys].slice(0, 20)) {
320
307
  const edgeRaw = await redis.get(ek);
321
308
  if (!edgeRaw) continue;
322
- const edge: CKGEdge = JSON.parse(edgeRaw);
309
+ const edge = safeJsonParse<CKGEdge | null>(edgeRaw, null);
310
+ if (!edge) continue;
323
311
  const lessonRaw = await redis.get(`cachly:lesson:best:${edge.from}`);
324
- const lesson = lessonRaw ? JSON.parse(lessonRaw) : undefined;
325
- ckgResults.push({ conceptId: edge.from, edge, lesson });
312
+ const lesson = lessonRaw ? safeJsonParse<{ topic: string; what_worked?: string; ts: string; outcome: string; recall_count?: number; severity?: string } | null>(lessonRaw, null) ?? undefined : undefined;
313
+ ckgResults.push({ conceptId: edge.from, edge, lesson: lesson ?? undefined });
326
314
  }
327
315
  }
328
316
  } catch { /* CKG traversal non-critical */ }
@@ -356,14 +344,15 @@ export async function handleAdvancedTool(
356
344
  for (const k of lessonKeys) {
357
345
  const raw = await redis.get(k);
358
346
  if (!raw) continue;
359
- try {
360
- const l = JSON.parse(raw) as Lesson;
347
+ {
348
+ const l = safeJsonParse<Lesson | null>(raw, null);
349
+ if (!l) continue;
361
350
  if (filterTags.length > 0 && !(l.tags ?? []).some((t: string) => filterTags.includes(t))) continue;
362
351
  const haystack = [l.topic, l.what_failed ?? '', l.what_worked ?? '', l.context ?? '']
363
352
  .join(' ').toLowerCase();
364
353
  const score = tokens.reduce((s, t) => s + (haystack.includes(t) ? 1 : 0), 0);
365
354
  if (score > 0) scored.push({ score, lesson: l, key: k });
366
- } catch { /* skip */ }
355
+ }
367
356
  }
368
357
  scored.sort((a, b) => b.score - a.score);
369
358
  const chain = scored.slice(0, max_depth);
@@ -440,6 +429,10 @@ export async function handleAdvancedTool(
440
429
  lines.push(topSolution.what_worked.slice(0, 500));
441
430
  lines.push(`\`\`\``);
442
431
  lines.push('');
432
+
433
+ // Track estimated time saved — causal_trace finding a known fix is worth 1-4h
434
+ const savedMins = topSolution.severity === 'critical' ? 240 : topSolution.severity === 'major' ? 120 : 60;
435
+ redis.incrbyfloat(`cachly:stats:time_saved_mins:${instance_id}`, savedMins).catch(() => {});
443
436
  }
444
437
  }
445
438
 
@@ -472,8 +465,9 @@ export async function handleAdvancedTool(
472
465
  for (const k of lessonKeys) {
473
466
  const raw = await redis.get(k);
474
467
  if (!raw) continue;
475
- try {
476
- const l = JSON.parse(raw) as Lesson;
468
+ {
469
+ const l = safeJsonParse<Lesson | null>(raw, null);
470
+ if (!l) continue;
477
471
  const ageMs = now - new Date(l.ts).getTime();
478
472
  if (ageMs < minAgeMs) continue;
479
473
  const age_days = Math.floor(ageMs / 86400000);
@@ -490,7 +484,7 @@ export async function handleAdvancedTool(
490
484
  const confidence = Math.min(100, Math.max(0, base + recallBoost + outcomePenalty));
491
485
 
492
486
  scores.push({ topic: l.topic, confidence, age_days, recalls, outcome: l.outcome });
493
- } catch { /* skip */ }
487
+ }
494
488
  }
495
489
 
496
490
  // Sort by lowest confidence first
@@ -547,12 +541,6 @@ export async function handleAdvancedTool(
547
541
  - **Window opens** → call \`session_start(instance_id="${instance_id}", focus="<today's goal>")\`
548
542
  - **Bug fixed / deployed / discovered** → call \`learn_from_attempts(instance_id="${instance_id}", topic="fix:<area>", outcome="success", what_worked="...")\`
549
543
  - **Window closes** → call \`session_end(instance_id="${instance_id}", summary="...", files_changed=[...])\`
550
-
551
- ### New team member setup:
552
- \`\`\`bash
553
- npx @cachly-dev/init
554
- \`\`\`
555
- Run once — auto-detects all AI editors and gives instant full context.
556
544
  `;
557
545
 
558
546
  const full = `## Cachly AI Brain — Autopilot for ${project_name}
@@ -624,22 +612,9 @@ smart_recall(
624
612
 
625
613
  ---
626
614
 
627
- ## 👋 New team member?
628
-
629
- \`\`\`bash
630
- npx @cachly-dev/init
631
- \`\`\`
632
-
633
- Run once — auto-detects Claude Code, Cursor, Copilot, Windsurf, Continue and writes all MCP configs. Free, no credit card.
634
-
635
- Demo: [cachly.dev](https://cachly.dev)
636
-
637
- ---
638
-
639
615
  *Cachly v0.6 · Generated ${new Date().toISOString().slice(0, 10)}*
640
616
  `;
641
617
 
642
-
643
618
  const content = style === 'minimal' ? minimal : full;
644
619
  const filename = editor === 'copilot'
645
620
  ? '.github/copilot-instructions.md'
@@ -668,161 +643,6 @@ Demo: [cachly.dev](https://cachly.dev)
668
643
  ].join('\n');
669
644
  }
670
645
 
671
- // ── brain_stats ─────────────────────────────────────────────────────────
672
- case 'brain_stats': {
673
- const { instance_id } = args as { instance_id: string };
674
- const redis = await getConnection(instance_id);
675
-
676
- // Lessons
677
- const lessonKeys: string[] = [];
678
- const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
679
- await new Promise<void>((res, rej) => { lStream.on('data', (b: string[]) => lessonKeys.push(...b)); lStream.on('end', res); lStream.on('error', rej); });
680
-
681
- type LessonMeta = { topic: string; outcome: string; ts: string; severity?: string; recall_count?: number };
682
- const lessons: LessonMeta[] = [];
683
- if (lessonKeys.length > 0) {
684
- const raws = await redis.mget(...lessonKeys);
685
- for (const r of raws) {
686
- if (!r) continue;
687
- try { lessons.push(JSON.parse(r) as LessonMeta); } catch { /* skip */ }
688
- }
689
- }
690
-
691
- // Context entries
692
- let ctxCount = 0;
693
- const ctxStream = redis.scanStream({ match: 'cachly:ctx:*', count: 200 });
694
- await new Promise<void>((res, rej) => {
695
- ctxStream.on('data', (b: string[]) => { ctxCount += (b as string[]).filter(k => !k.endsWith(':meta')).length; });
696
- ctxStream.on('end', res); ctxStream.on('error', rej);
697
- });
698
-
699
- // Roadmap items
700
- let roadmapOpen = 0; let roadmapDone = 0;
701
- try {
702
- const rm = await redis.hgetall(`cachly:roadmap:${instance_id}`);
703
- for (const v of Object.values(rm ?? {})) {
704
- try {
705
- const item = JSON.parse(v as string) as { status: string };
706
- if (item.status === 'done') roadmapDone++;
707
- else if (['planned', 'in-progress', 'blocked'].includes(item.status)) roadmapOpen++;
708
- } catch { /* skip */ }
709
- }
710
- } catch { /* non-critical */ }
711
-
712
- // Pinned lessons
713
- const pinKeys: string[] = [];
714
- const pStream = redis.scanStream({ match: 'cachly:lesson:pin:*', count: 100 });
715
- await new Promise<void>((res, rej) => { pStream.on('data', (b: string[]) => pinKeys.push(...b)); pStream.on('end', res); pStream.on('error', rej); });
716
- const pinnedTopics = pinKeys.map(k => k.replace('cachly:lesson:pin:', ''));
717
-
718
- // Streak
719
- let streakDays = 0;
720
- try {
721
- const sr = await redis.get('cachly:streak:current');
722
- if (sr) streakDays = (JSON.parse(sr) as { days: number }).days;
723
- } catch { /* skip */ }
724
-
725
- // Last session
726
- let lastSessionSummary = 'none yet';
727
- let lastSessionAgo = '';
728
- try {
729
- const lsr = await redis.get('cachly:session:last');
730
- if (lsr) {
731
- const ls = JSON.parse(lsr) as { summary: string; ts: string };
732
- const ago = Math.round((Date.now() - new Date(ls.ts).getTime()) / 60000);
733
- lastSessionAgo = ago < 60 ? `${ago}m ago` : ago < 1440 ? `${Math.round(ago / 60)}h ago` : `${Math.round(ago / 1440)}d ago`;
734
- lastSessionSummary = ls.summary;
735
- }
736
- } catch { /* skip */ }
737
-
738
- // Crystal
739
- let crystalLabel = '';
740
- try {
741
- const cr = await redis.get('cachly:crystal:latest');
742
- if (cr) crystalLabel = (JSON.parse(cr) as { label: string }).label;
743
- } catch { /* skip */ }
744
-
745
- // Top topics
746
- const topicFreq = new Map<string, number>();
747
- for (const l of lessons) {
748
- const cat = l.topic.split(':')[0] ?? 'other';
749
- topicFreq.set(cat, (topicFreq.get(cat) ?? 0) + 1);
750
- }
751
- const topTopics = [...topicFreq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
752
-
753
- // Outcomes
754
- const successCount = lessons.filter(l => l.outcome === 'success').length;
755
- const failCount = lessons.filter(l => l.outcome === 'failure').length;
756
- const totalRecalls = lessons.reduce((s, l) => s + (l.recall_count ?? 0), 0);
757
-
758
- const lines: string[] = [
759
- '🧠 **Brain Stats**',
760
- '',
761
- `📚 **Lessons:** ${lessons.length} (✅ ${successCount} success · ❌ ${failCount} open · recalled ${totalRecalls}×)`,
762
- `📁 **Context:** ${ctxCount} entries`,
763
- `🗺️ **Roadmap:** ${roadmapOpen} open · ${roadmapDone} done`,
764
- `🔥 **Streak:** ${streakDays > 0 ? `${streakDays} day${streakDays > 1 ? 's' : ''}` : 'no streak yet'}`,
765
- '',
766
- ];
767
-
768
- if (pinnedTopics.length > 0) {
769
- lines.push(`📌 **Pinned lessons (${pinnedTopics.length}):** ${pinnedTopics.map(t => `\`${t}\``).join(' · ')}`);
770
- lines.push('');
771
- }
772
-
773
- if (topTopics.length > 0) {
774
- lines.push('📊 **Top knowledge domains:**');
775
- for (const [cat, count] of topTopics) {
776
- lines.push(` · ${cat.padEnd(20)} ${count} lesson${count > 1 ? 's' : ''}`);
777
- }
778
- lines.push('');
779
- }
780
-
781
- if (lastSessionSummary !== 'none yet') {
782
- lines.push(`📅 **Last session** (${lastSessionAgo}): ${lastSessionSummary}`);
783
- lines.push('');
784
- }
785
- if (crystalLabel) {
786
- lines.push(`💎 **Memory Crystal:** ${crystalLabel}`);
787
- lines.push('');
788
- }
789
-
790
- lines.push('💡 `brain_doctor` for health recommendations · `brain_diff(since="7d")` for what changed');
791
- return lines.join('\n');
792
- }
793
-
794
- // ── pin_lesson ──────────────────────────────────────────────────────────
795
- case 'pin_lesson': {
796
- const { instance_id, topic } = args as { instance_id: string; topic: string };
797
- const redis = await getConnection(instance_id);
798
-
799
- // Verify the lesson exists
800
- const lessonRaw = await redis.get(`cachly:lesson:best:${topic}`);
801
- if (!lessonRaw) {
802
- return `❌ No lesson found for topic \`${topic}\`.\n\nFirst create it with \`learn_from_attempts(topic="${topic}", ...)\`.`;
803
- }
804
-
805
- await redis.set(`cachly:lesson:pin:${topic}`, '1');
806
- return [
807
- `📌 **Pinned** \`${topic}\``,
808
- '',
809
- 'This lesson will now appear at the top of every `session_start` briefing, regardless of age.',
810
- '',
811
- `Remove with: \`unpin_lesson(instance_id="...", topic="${topic}")\``,
812
- ].join('\n');
813
- }
814
-
815
- // ── unpin_lesson ─────────────────────────────────────────────────────────
816
- case 'unpin_lesson': {
817
- const { instance_id, topic } = args as { instance_id: string; topic: string };
818
- const redis = await getConnection(instance_id);
819
- const deleted = await redis.del(`cachly:lesson:pin:${topic}`);
820
- if (deleted === 0) {
821
- return `ℹ️ \`${topic}\` was not pinned. Use \`brain_stats\` to see all pinned lessons.`;
822
- }
823
- return `📌 Unpinned \`${topic}\`. It will now follow normal recency/confidence decay in session_start.`;
824
- }
825
-
826
646
  // ── v0.7 Knowledge Syndication: syndicate ────────────────────────────────
827
647
 
828
648
  default: