@cachly-dev/mcp-server 0.5.43 → 0.5.45

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/src/index.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { createHash } from 'node:crypto';
3
4
  import { readdir, readFile, stat } from 'node:fs/promises';
5
+ import { readFileSync } from 'node:fs';
6
+ import { execSync } from 'node:child_process';
4
7
  import { join, relative, extname } from 'node:path';
5
8
  /**
6
- * cachly MCP Server v0.5.43
9
+ * cachly MCP Server v0.4.0
7
10
  *
8
11
  * Exposes cachly.dev as MCP tools so any AI assistant
9
12
  * (GitHub Copilot, Claude, Cursor, Windsurf, Continue.dev …) can:
@@ -31,7 +34,6 @@ import { join, relative, extname } from 'node:path';
31
34
  *
32
35
  * Configuration (env vars):
33
36
  * CACHLY_API_URL – default https://api.cachly.dev
34
- * CACHLY_AUTH_URL – default https://auth.cachly.dev (Keycloak base URL for health checks)
35
37
  * CACHLY_JWT – your JWT (Keycloak access token)
36
38
  * CACHLY_EMBED_PROVIDER – embedding backend: openai (default), gemini, mistral, cohere, ollama, cachly (server fallback)
37
39
  * CACHLY_EMBED_MODEL – override embedding model (optional)
@@ -39,6 +41,7 @@ import { join, relative, extname } from 'node:path';
39
41
 
40
42
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
41
43
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
44
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
42
45
  import {
43
46
  CallToolRequestSchema,
44
47
  ErrorCode,
@@ -50,9 +53,9 @@ import { Redis } from 'ioredis';
50
53
  // ── Config ────────────────────────────────────────────────────────────────────
51
54
 
52
55
  const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
53
- const AUTH_URL = process.env.CACHLY_AUTH_URL ?? 'https://auth.cachly.dev';
54
56
  const JWT = process.env.CACHLY_JWT ?? '';
55
57
  const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
58
+ const CURRENT_VERSION = '0.5.26';
56
59
  /**
57
60
  * ┌──────────────────────────────────────────────────────────────────────┐
58
61
  * │ EMBEDDING PROVIDER — pluggable, client-side first │
@@ -72,6 +75,44 @@ const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
72
75
  */
73
76
  const EMBED_PROVIDER = (process.env.CACHLY_EMBED_PROVIDER ?? detectEmbedProvider()).toLowerCase();
74
77
 
78
+ // ── Confidence Decay Config (all values configurable via env) ─────────────────
79
+ const CONFIDENCE_WARN_DAYS = Number(process.env.CACHLY_CONFIDENCE_WARN_DAYS ?? 5); // → 0.7
80
+ const CONFIDENCE_STALE_DAYS = Number(process.env.CACHLY_CONFIDENCE_STALE_DAYS ?? 10); // → 0.5
81
+ const CONFIDENCE_WARN_VALUE = 0.7;
82
+ const CONFIDENCE_STALE_VALUE = 0.5;
83
+
84
+ /** Calculate current confidence for a lesson based on how long since last verified. */
85
+ function calculateConfidence(lesson: { verified_at?: string; ts: string; recall_count?: number }): number {
86
+ const ref = lesson.verified_at ?? lesson.ts;
87
+ const ageMs = Date.now() - new Date(ref).getTime();
88
+ const ageDays = ageMs / 86400000;
89
+ if (ageDays >= CONFIDENCE_STALE_DAYS) return CONFIDENCE_STALE_VALUE;
90
+ if (ageDays >= CONFIDENCE_WARN_DAYS) return CONFIDENCE_WARN_VALUE;
91
+ // Linear interpolation between fresh (1.0) and warn threshold
92
+ return 1.0 - (ageDays / CONFIDENCE_WARN_DAYS) * (1.0 - CONFIDENCE_WARN_VALUE);
93
+ }
94
+
95
+ /** Render a confidence badge string. */
96
+ function confidenceBadge(confidence: number, ageDays: number): string {
97
+ if (confidence >= 0.9) return '✅';
98
+ if (confidence >= 0.7) return `⚠️ (${Math.round(ageDays)}d old, confidence ${(confidence * 100).toFixed(0)}% — verify before applying)`;
99
+ return `🔴 STALE (${Math.round(ageDays)}d old, confidence ${(confidence * 100).toFixed(0)}% — likely outdated!)`;
100
+ }
101
+
102
+ /** Category-specific required fields for structured lessons. */
103
+ const STRUCTURED_TEMPLATES: Record<string, { required: string[]; hint: string }> = {
104
+ 'deploy': { required: ['commands'], hint: 'deploy:* needs commands[]' },
105
+ 'bash': { required: ['commands'], hint: 'bash:* needs commands[]' },
106
+ 'infra': { required: ['commands'], hint: 'infra:* needs commands[] + verified on real system' },
107
+ 'pricing': { required: [], hint: 'pricing:* — add context with source (e.g. "Stripe Dashboard Apr 2026")' },
108
+ 'stripe': { required: [], hint: 'stripe:* — add context with Stripe API version' },
109
+ };
110
+
111
+ /** Fast content hash for index invalidation (not cryptographic, just change detection) */
112
+ function simpleHash(text: string): string {
113
+ return createHash('md5').update(text).digest('hex').slice(0, 12);
114
+ }
115
+
75
116
  function detectEmbedProvider(): string {
76
117
  // Client-side keys first (direct, fast, no server roundtrip)
77
118
  if (process.env.OPENAI_API_KEY) return 'openai';
@@ -260,13 +301,18 @@ function embedProviderHint(): string {
260
301
  // • Recency boost — newer entries rank higher (exponential decay, 7-day half-life)
261
302
  // • Multi-query splitting — numbered lists, semicolons, conjunctions
262
303
  // • Fuzzy matching with Levenshtein distance ≤ 2
263
- // • Multilingual stopwords (EN, DE, FR, ES, IT, PT)
304
+ // • Multilingual stopwords (EN, DE, FR, ES, IT, PT, ZH, JA, KO, AR, HE)
305
+ // • CJK character bigram extraction — handles Chinese, Japanese, Korean
306
+ // • RTL language support (Arabic, Hebrew) — word tokenization + light stemming
307
+ // • Romanization matching — katakana → romaji tokens for romaji queries
308
+ // • Cross-language retrieval — tech term synonyms EN↔JA↔ZH↔KO↔AR↔HE
264
309
  // • Pipeline Redis reads for performance
265
310
  //
266
311
 
267
312
  /**
268
313
  * Stopwords — filtered out during tokenization.
269
- * Covers: English, German, French, Spanish, Italian, Portuguese.
314
+ * Covers: English, German, French, Spanish, Italian, Portuguese,
315
+ * Chinese (Simplified + Traditional), Japanese, Korean.
270
316
  * Keeps the index small and scores meaningful.
271
317
  */
272
318
  const STOPWORDS = new Set([
@@ -340,15 +386,436 @@ const STOPWORDS = new Set([
340
386
  'eu', 'tu', 'ele', 'ela', 'nós', 'eles', 'elas', 'você', 'vocês',
341
387
  'não', 'já', 'sim', 'bem', 'muito', 'também', 'ainda', 'sempre',
342
388
  'todo', 'toda', 'todos', 'cada', 'outro', 'outra', 'quando', 'porque',
389
+ // ── Chinese (Simplified + Traditional) — high-frequency function characters ──
390
+ '的', '了', '是', '在', '不', '和', '我', '他', '这', '中', '大', '为',
391
+ '上', '个', '国', '以', '要', '就', '出', '说', '们', '有', '来', '到',
392
+ '时', '地', '年', '得', '着', '那', '过', '后', '还', '与', '也', '可',
393
+ '于', '从', '但', '而', '被', '把', '让', '使', '对', '很', '都', '一',
394
+ '会', '没', '人', '它', '这个', '那个', '什么', '如果', '因为', '所以',
395
+ '已经', '可以', '这些', '那些', '我们', '他们', '她们', '它们',
396
+ // ── Japanese — common hiragana particles and auxiliary verbs ──
397
+ 'の', 'は', 'が', 'を', 'に', 'で', 'と', 'も', 'や', 'か', 'な', 'ね',
398
+ 'よ', 'わ', 'て', 'い', 'う', 'え', 'お', 'き', 'く', 'け', 'こ', 'さ',
399
+ 'し', 'す', 'せ', 'そ', 'た', 'ち', 'つ', 'ぬ', 'ん', 'から', 'まで',
400
+ 'より', 'へ', 'です', 'ます', 'ない', 'ある', 'いる', 'する', 'こと',
401
+ 'もの', 'ので', 'では', 'には', 'との', 'への', 'から', 'まで',
402
+ // ── Korean — common particles and auxiliary forms ──
403
+ '이', '가', '은', '는', '을', '를', '의', '에', '와', '과', '도',
404
+ '로', '에서', '한', '하', '있', '없', '것', '수', '않', '들',
405
+ '이다', '하다', '이고', '이며', '라고', '에게', '에서', '으로',
406
+ // ── Arabic — high-frequency function words & particles ──
407
+ 'في', 'من', 'إلى', 'على', 'عن', 'مع', 'هذا', 'هذه', 'ذلك', 'تلك',
408
+ 'الذي', 'التي', 'الذين', 'اللواتي', 'هو', 'هي', 'هم', 'هن', 'نحن',
409
+ 'أنت', 'أنتم', 'أنا', 'كان', 'كانت', 'كانوا', 'يكون', 'تكون',
410
+ 'أن', 'إن', 'لأن', 'لكن', 'أو', 'بل', 'ثم', 'حتى', 'إذا', 'كي',
411
+ 'قد', 'لقد', 'لم', 'لن', 'ما', 'لا', 'ليس', 'غير', 'بعض', 'كل',
412
+ 'جميع', 'أي', 'كيف', 'متى', 'أين', 'لماذا', 'ماذا', 'هل', 'ال',
413
+ 'و', 'ف', 'ب', 'ل', 'ك', 'يا', 'أم', 'إلا', 'عند', 'بين',
414
+ // ── Hebrew — common particles, pronouns, conjunctions ──
415
+ 'של', 'את', 'אל', 'על', 'עם', 'לא', 'הוא', 'היא', 'הם', 'הן',
416
+ 'אני', 'אתה', 'אנחנו', 'אתם', 'כי', 'אם', 'אבל', 'גם', 'כבר',
417
+ 'רק', 'עוד', 'יש', 'אין', 'מה', 'זה', 'זאת', 'אלה', 'כל', 'כן',
418
+ 'לו', 'לה', 'להם', 'בו', 'בה', 'בהם', 'שם', 'כך', 'כן', 'מי',
419
+ 'אשר', 'אחרי', 'לפני', 'תחת', 'בין', 'מאז', 'עד', 'כמו', 'אז',
420
+ ]);
421
+
422
+ // CJK Unicode ranges used for bigram extraction
423
+ const CJK_RE = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]/;
424
+ const SEGMENT_RE = /([\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]+|[^\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]+)/g;
425
+
426
+ // RTL Unicode ranges: Arabic (U+0600–U+06FF + extended), Hebrew (U+0590–U+05FF)
427
+ const RTL_RE = /[\u0590-\u05ff\u0600-\u06ff\u0750-\u077f\u08a0-\u08ff\ufb1d-\ufb4f\ufb50-\ufdff\ufe70-\ufeff]/;
428
+
429
+ // ── Katakana → Romaji conversion table (Hepburn system) ──────────────────────
430
+ // Digraphs must be listed before single chars so they match first.
431
+ const KATA_ROMAJI_TABLE: [string, string][] = [
432
+ // Special combinations for loanwords
433
+ ['ファ', 'fa'], ['フィ', 'fi'], ['フェ', 'fe'], ['フォ', 'fo'],
434
+ ['ティ', 'ti'], ['ディ', 'di'], ['トゥ', 'tu'], ['ドゥ', 'du'],
435
+ ['ウィ', 'wi'], ['ウェ', 'we'], ['ウォ', 'wo'],
436
+ ['チェ', 'che'], ['ジェ', 'je'], ['シェ', 'she'],
437
+ ['イェ', 'ye'], ['ヴァ', 'va'], ['ヴィ', 'vi'], ['ヴェ', 've'], ['ヴォ', 'vo'],
438
+ // Digraphs (2-char → romaji)
439
+ ['キャ', 'kya'], ['キュ', 'kyu'], ['キョ', 'kyo'],
440
+ ['シャ', 'sha'], ['シュ', 'shu'], ['ショ', 'sho'],
441
+ ['チャ', 'cha'], ['チュ', 'chu'], ['チョ', 'cho'],
442
+ ['ニャ', 'nya'], ['ニュ', 'nyu'], ['ニョ', 'nyo'],
443
+ ['ヒャ', 'hya'], ['ヒュ', 'hyu'], ['ヒョ', 'hyo'],
444
+ ['ミャ', 'mya'], ['ミュ', 'myu'], ['ミョ', 'myo'],
445
+ ['リャ', 'rya'], ['リュ', 'ryu'], ['リョ', 'ryo'],
446
+ ['ギャ', 'gya'], ['ギュ', 'gyu'], ['ギョ', 'gyo'],
447
+ ['ジャ', 'ja'], ['ジュ', 'ju'], ['ジョ', 'jo'],
448
+ ['ビャ', 'bya'], ['ビュ', 'byu'], ['ビョ', 'byo'],
449
+ ['ピャ', 'pya'], ['ピュ', 'pyu'], ['ピョ', 'pyo'],
450
+ // Single chars
451
+ ['ア', 'a'], ['イ', 'i'], ['ウ', 'u'], ['エ', 'e'], ['オ', 'o'],
452
+ ['カ', 'ka'], ['キ', 'ki'], ['ク', 'ku'], ['ケ', 'ke'], ['コ', 'ko'],
453
+ ['サ', 'sa'], ['シ', 'shi'], ['ス', 'su'], ['セ', 'se'], ['ソ', 'so'],
454
+ ['タ', 'ta'], ['チ', 'chi'], ['ツ', 'tsu'], ['テ', 'te'], ['ト', 'to'],
455
+ ['ナ', 'na'], ['ニ', 'ni'], ['ヌ', 'nu'], ['ネ', 'ne'], ['ノ', 'no'],
456
+ ['ハ', 'ha'], ['ヒ', 'hi'], ['フ', 'fu'], ['ヘ', 'he'], ['ホ', 'ho'],
457
+ ['マ', 'ma'], ['ミ', 'mi'], ['ム', 'mu'], ['メ', 'me'], ['モ', 'mo'],
458
+ ['ヤ', 'ya'], ['ユ', 'yu'], ['ヨ', 'yo'],
459
+ ['ラ', 'ra'], ['リ', 'ri'], ['ル', 'ru'], ['レ', 're'], ['ロ', 'ro'],
460
+ ['ワ', 'wa'], ['ヲ', 'wo'], ['ン', 'n'],
461
+ // Voiced
462
+ ['ガ', 'ga'], ['ギ', 'gi'], ['グ', 'gu'], ['ゲ', 'ge'], ['ゴ', 'go'],
463
+ ['ザ', 'za'], ['ジ', 'ji'], ['ズ', 'zu'], ['ゼ', 'ze'], ['ゾ', 'zo'],
464
+ ['ダ', 'da'], ['ヂ', 'ji'], ['ヅ', 'zu'], ['デ', 'de'], ['ド', 'do'],
465
+ ['バ', 'ba'], ['ビ', 'bi'], ['ブ', 'bu'], ['ベ', 'be'], ['ボ', 'bo'],
466
+ ['パ', 'pa'], ['ピ', 'pi'], ['プ', 'pu'], ['ペ', 'pe'], ['ポ', 'po'],
467
+ ['ヴ', 'v'],
468
+ // Long vowel / small chars
469
+ ['ー', ''], ['ァ', 'a'], ['ィ', 'i'], ['ゥ', 'u'], ['ェ', 'e'], ['ォ', 'o'],
470
+ ['ッ', ''], // handled separately (doubles next consonant)
471
+ ];
472
+
473
+ const KATA_MAP = new Map<string, string>(KATA_ROMAJI_TABLE);
474
+
475
+ /**
476
+ * Convert a katakana string to Hepburn romaji.
477
+ * Handles digraphs, geminate consonants (ッ), and long vowel marks (ー).
478
+ */
479
+ function katakanaToRomaji(kata: string): string {
480
+ let result = '';
481
+ let i = 0;
482
+ while (i < kata.length) {
483
+ // Geminate consonant: ッ doubles the following consonant
484
+ if (kata[i] === 'ッ' && i + 1 < kata.length) {
485
+ const next = KATA_MAP.get(kata[i + 1]) ?? KATA_MAP.get(kata[i + 1] + kata[i + 2]) ?? '';
486
+ if (next.length > 0) result += next[0]; // double first consonant
487
+ i++;
488
+ continue;
489
+ }
490
+ // Try 2-char digraph first
491
+ if (i + 1 < kata.length) {
492
+ const two = kata[i] + kata[i + 1];
493
+ const r2 = KATA_MAP.get(two);
494
+ if (r2 !== undefined) { result += r2; i += 2; continue; }
495
+ }
496
+ // Single char
497
+ const r1 = KATA_MAP.get(kata[i]);
498
+ if (r1 !== undefined) result += r1;
499
+ i++;
500
+ }
501
+ return result;
502
+ }
503
+
504
+ /**
505
+ * Arabic light stemmer — strips definite article and common prefix particles.
506
+ * Handles: ال (al-), و (wa-), ب (bi-), ل (li-), ف (fa-), ك (ka-).
507
+ * Only strips when the result is still ≥ 3 chars to avoid over-stemming.
508
+ */
509
+ function arabicLightStem(word: string): string {
510
+ if (word.startsWith('ال') && word.length > 4) return word.slice(2);
511
+ if (word.length > 3 && 'وبلفك'.includes(word[0]) && RTL_RE.test(word[1])) return word.slice(1);
512
+ return word;
513
+ }
514
+
515
+ // ── Cross-lingual tech term dictionary ────────────────────────────────────────
516
+ // Maps tokens (English, Japanese katakana, Chinese, Korean, Arabic, Hebrew)
517
+ // to their equivalents in other languages. Used to expand tokens at search time
518
+ // so "deploy" finds documents containing "デプロイ" / "部署" / "배포", and vice versa.
519
+ //
520
+ // Keys must be lowercase. CJK values will be bigram-expanded at use time.
521
+ const CROSS_LINGUAL_MAP = new Map<string, string[]>([
522
+ // English → all
523
+ ['deploy', ['デプロイ', '部署', '배포', 'deployment', 'deploying']],
524
+ ['deployment', ['デプロイ', '部署', '배포', 'deploy']],
525
+ ['container', ['コンテナ', '容器', '컨테이너', 'docker']],
526
+ ['server', ['サーバー', 'サーバ', '服务器', '서버', 'سيرفر', 'שרת']],
527
+ ['database', ['データベース', '数据库', '데이터베이스', 'db', 'قاعدة البيانات']],
528
+ ['cache', ['キャッシュ', '缓存', '캐시', 'كاش', 'מטמון']],
529
+ ['error', ['エラー', '错误', '오류', 'خطأ', 'שגיאה', 'exception', 'err']],
530
+ ['bug', ['バグ', '缺陷', '버그', 'issue', 'defect', 'خلل']],
531
+ ['fix', ['修正', '修复', '수정', 'bugfix', 'patch', 'hotfix', 'إصلاح', 'תיקון']],
532
+ ['build', ['ビルド', '构建', '빌드', 'بناء', 'בנייה']],
533
+ ['test', ['テスト', '测试', '테스트', 'اختبار', 'בדיקה']],
534
+ ['auth', ['認証', '认证', '인증', 'authentication', 'login', 'oauth', 'مصادقة', 'אימות']],
535
+ ['kubernetes', ['クベルネテス', 'k8s', 'kube']],
536
+ ['network', ['ネットワーク', '网络', '네트워크', 'شبكة', 'רשת']],
537
+ ['timeout', ['タイムアウト', '超时', '타임아웃', 'مهلة']],
538
+ ['memory', ['メモリ', '内存', '메모리', 'ram', 'ذاكرة', 'זיכרון']],
539
+ ['config', ['設定', '配置', '설정', 'configuration', 'settings', 'conf', 'إعدادات', 'הגדרות']],
540
+ ['install', ['インストール', '安装', '설치', 'تثبيت', 'התקנה']],
541
+ ['update', ['アップデート', '更新', '업데이트', 'upgrade', 'تحديث', 'עדכון']],
542
+ ['log', ['ログ', '日志', '로그', 'logging', 'سجل', 'יומן']],
543
+ ['port', ['ポート', '端口', '포트', 'منفذ', 'פורט']],
544
+ ['file', ['ファイル', '文件', '파일', 'ملف', 'קובץ']],
545
+ ['image', ['イメージ', '镜像', '이미지', 'صورة', 'תמונה']],
546
+ ['volume', ['ボリューム', '卷', '볼륨']],
547
+ ['cluster', ['クラスター', '集群', '클러스터']],
548
+ ['node', ['ノード', '节点', '노드']],
549
+ ['service', ['サービス', '服务', '서비스', 'svc', 'خدمة', 'שירות']],
550
+ ['certificate', ['証明書', '证书', '인증서', 'cert', 'ssl', 'tls', 'شهادة', 'תעודה']],
551
+ ['password', ['パスワード', '密码', '비밀번호', 'passwd', 'pwd', 'كلمة المرور', 'סיסמה']],
552
+ ['token', ['トークン', '令牌', '토큰', 'jwt', 'secret', 'رمز', 'אסימון']],
553
+ ['health', ['ヘルス', '健康', '헬스', 'healthcheck', 'probe', 'صحة']],
554
+ ['migration', ['マイグレーション', '迁移', '마이그레이션', 'migrate', 'هجرة']],
555
+ ['backup', ['バックアップ', '备份', '백업', 'نسخ احتياطي', 'גיבוי']],
556
+ ['monitor', ['モニター', '监控', '모니터링', 'monitoring', 'metrics', 'alert', 'مراقبة', 'ניטור']],
557
+ ['performance', ['パフォーマンス', '性能', '성능', 'latency', 'throughput']],
558
+ ['connection', ['接続', '连接', '연결', 'conn', 'socket', 'اتصال', 'חיבור']],
559
+ ['queue', ['キュー', '队列', '큐', 'طابور']],
560
+ ['redis', ['レディス', '레디스']],
561
+ ['nginx', ['エンジンエックス']],
562
+ ['linux', ['リナックス', 'لينكس']],
563
+ ['api', ['エーピーアイ', '接口', 'endpoint', 'واجهة برمجية', 'ממשק']],
564
+ ['healthcheck', ['ヘルスチェック', 'health', 'probe', 'فحص الصحة']],
565
+ ['loadbalancer',['ロードバランサー', '负载均衡', '로드밸런서', 'lb']],
566
+ ['ssl', ['tls', 'https', 'certificate', 'cert']],
567
+ ['tls', ['ssl', 'https', 'certificate', 'cert']],
568
+ ['docker', ['container', 'コンテナ', '容器', '컨테이너', 'دوكر']],
569
+ ['git', ['version control', 'repo', 'repository', 'commit', 'push', 'pull']],
570
+ ['ci', ['pipeline', 'github actions', 'gitlab', 'jenkins', 'build']],
571
+ ['cd', ['deploy', 'deployment', 'release']],
572
+ ['debug', ['デバッグ', '调试', '디버그', 'debugging', 'breakpoint', 'تصحيح', 'איתור באגים']],
573
+ ['crash', ['クラッシュ', '崩溃', '크래시', 'panic', 'segfault', 'انهيار']],
574
+ ['restart', ['再起動', '重启', '재시작', 'reboot', 'إعادة تشغيل', 'הפעלה מחדש']],
575
+ ['permission', ['権限', '权限', '권한', 'access', 'acl', 'chmod', 'صلاحية', 'הרשאה']],
576
+ // Japanese katakana → English
577
+ ['デプロイ', ['deploy', 'deployment']],
578
+ ['コンテナ', ['container', 'docker']],
579
+ ['サーバー', ['server']],
580
+ ['サーバ', ['server']],
581
+ ['データベース',['database', 'db']],
582
+ ['キャッシュ', ['cache']],
583
+ ['エラー', ['error', 'err']],
584
+ ['バグ', ['bug', 'issue']],
585
+ ['ビルド', ['build']],
586
+ ['テスト', ['test']],
587
+ ['認証', ['auth', 'authentication']],
588
+ ['設定', ['config', 'configuration']],
589
+ ['インストール',['install']],
590
+ ['アップデート',['update', 'upgrade']],
591
+ ['ログ', ['log', 'logs']],
592
+ ['ポート', ['port']],
593
+ ['ファイル', ['file']],
594
+ ['イメージ', ['image']],
595
+ ['クラスター', ['cluster']],
596
+ ['ノード', ['node']],
597
+ ['サービス', ['service']],
598
+ ['パスワード', ['password']],
599
+ ['トークン', ['token']],
600
+ ['バックアップ',['backup']],
601
+ ['モニター', ['monitor', 'monitoring']],
602
+ ['接続', ['connection']],
603
+ ['キュー', ['queue']],
604
+ ['ヘルスチェック',['healthcheck', 'health check', 'health']],
605
+ ['ロードバランサー',['loadbalancer', 'load balancer']],
606
+ ['デバッグ', ['debug']],
607
+ ['クラッシュ', ['crash', 'panic']],
608
+ ['再起動', ['restart', 'reboot']],
609
+ ['権限', ['permission', 'access']],
610
+ // Chinese → English
611
+ ['部署', ['deploy', 'deployment']],
612
+ ['容器', ['container', 'docker']],
613
+ ['服务器', ['server']],
614
+ ['数据库', ['database']],
615
+ ['缓存', ['cache']],
616
+ ['错误', ['error']],
617
+ ['修复', ['fix', 'bugfix']],
618
+ ['构建', ['build']],
619
+ ['测试', ['test']],
620
+ ['认证', ['auth']],
621
+ ['配置', ['config']],
622
+ ['安装', ['install']],
623
+ ['更新', ['update']],
624
+ ['日志', ['log']],
625
+ ['端口', ['port']],
626
+ ['镜像', ['image']],
627
+ ['集群', ['cluster']],
628
+ ['节点', ['node']],
629
+ ['服务', ['service']],
630
+ ['密码', ['password']],
631
+ ['令牌', ['token']],
632
+ ['备份', ['backup']],
633
+ ['监控', ['monitor']],
634
+ ['连接', ['connection']],
635
+ ['队列', ['queue']],
636
+ ['性能', ['performance']],
637
+ ['内存', ['memory']],
638
+ ['调试', ['debug']],
639
+ ['崩溃', ['crash']],
640
+ ['重启', ['restart']],
641
+ ['权限', ['permission']],
642
+ // Korean → English
643
+ ['배포', ['deploy', 'deployment']],
644
+ ['컨테이너', ['container']],
645
+ ['서버', ['server']],
646
+ ['데이터베이스',['database']],
647
+ ['캐시', ['cache']],
648
+ ['오류', ['error']],
649
+ ['수정', ['fix']],
650
+ ['빌드', ['build']],
651
+ ['테스트', ['test']],
652
+ ['인증', ['auth']],
653
+ ['설정', ['config']],
654
+ ['설치', ['install']],
655
+ ['업데이트', ['update']],
656
+ ['로그', ['log']],
657
+ ['포트', ['port']],
658
+ ['이미지', ['image']],
659
+ ['클러스터', ['cluster']],
660
+ ['노드', ['node']],
661
+ ['서비스', ['service']],
662
+ ['토큰', ['token']],
663
+ ['백업', ['backup']],
664
+ ['모니터링', ['monitor']],
665
+ ['연결', ['connection']],
666
+ ['큐', ['queue']],
667
+ ['성능', ['performance']],
668
+ ['메모리', ['memory']],
669
+ ['디버그', ['debug']],
670
+ ['크래시', ['crash']],
671
+ ['재시작', ['restart']],
672
+ ['권한', ['permission']],
673
+ // Arabic → English
674
+ ['خطأ', ['error']],
675
+ ['إصلاح', ['fix']],
676
+ ['بناء', ['build']],
677
+ ['اختبار', ['test']],
678
+ ['مصادقة', ['auth']],
679
+ ['إعدادات', ['config']],
680
+ ['تثبيت', ['install']],
681
+ ['تحديث', ['update']],
682
+ ['سجل', ['log']],
683
+ ['صورة', ['image']],
684
+ ['خدمة', ['service']],
685
+ ['نسخ احتياطي', ['backup']],
686
+ ['مراقبة', ['monitor']],
687
+ ['اتصال', ['connection']],
688
+ ['ذاكرة', ['memory']],
689
+ ['تصحيح', ['debug']],
690
+ ['شبكة', ['network']],
691
+ // Hebrew → English
692
+ ['שגיאה', ['error']],
693
+ ['תיקון', ['fix']],
694
+ ['בנייה', ['build']],
695
+ ['בדיקה', ['test']],
696
+ ['אימות', ['auth']],
697
+ ['הגדרות', ['config']],
698
+ ['התקנה', ['install']],
699
+ ['עדכון', ['update']],
700
+ ['יומן', ['log']],
701
+ ['שרת', ['server']],
702
+ ['שירות', ['service']],
703
+ ['גיבוי', ['backup']],
704
+ ['ניטור', ['monitor']],
705
+ ['חיבור', ['connection']],
706
+ ['זיכרון', ['memory']],
707
+ ['רשת', ['network']],
708
+ ['תמונה', ['image']],
709
+ ['סיסמה', ['password']],
710
+ ['הרשאה', ['permission']],
343
711
  ]);
344
712
 
345
- /** Tokenize text into meaningful keywords. Handles unicode (accented chars). */
713
+ /**
714
+ * Expand a token to its cross-lingual synonyms.
715
+ * Returns synonyms to be added to the token stream (pre-tokenized).
716
+ * CJK synonyms are NOT pre-bigram'd here — caller handles that.
717
+ */
718
+ function expandCrossLingual(token: string): string[] {
719
+ return CROSS_LINGUAL_MAP.get(token) ?? [];
720
+ }
721
+
722
+ /**
723
+ * Tokenize text into meaningful keywords.
724
+ * Handles accented Latin, RTL (Arabic, Hebrew), and CJK (Chinese, Japanese, Korean).
725
+ *
726
+ * Features:
727
+ * • CJK → character bigrams
728
+ * • Katakana → additionally emits Hepburn romaji tokens (enables romaji queries)
729
+ * • Arabic/Hebrew → word tokenization with light Arabic stemming
730
+ * • All scripts → cross-lingual synonym expansion (EN↔JA↔ZH↔KO↔AR↔HE)
731
+ */
346
732
  function tokenize(text: string): string[] {
347
- return text
348
- .toLowerCase()
349
- .replace(/[^a-záàâãäåæçéèêëíìîïñóòôõöúùûüýÿßœ0-9:_\-./]/g, ' ')
350
- .split(/\s+/)
351
- .filter(w => w.length > 1 && !STOPWORDS.has(w));
733
+ const tokens: string[] = [];
734
+ const lower = text.toLowerCase();
735
+
736
+ for (const [seg] of lower.matchAll(SEGMENT_RE)) {
737
+ if (CJK_RE.test(seg)) {
738
+ // CJK segment — extract overlapping character bigrams
739
+ for (let i = 0; i < seg.length - 1; i++) {
740
+ const bigram = seg[i] + seg[i + 1];
741
+ if (!STOPWORDS.has(seg[i]) && !STOPWORDS.has(bigram)) {
742
+ tokens.push(bigram);
743
+ }
744
+ }
745
+ // Trailing single character (for 1-char CJK terms)
746
+ if (seg.length >= 1) {
747
+ const last = seg[seg.length - 1];
748
+ if (!STOPWORDS.has(last)) tokens.push(last);
749
+ }
750
+ // Whole-segment cross-lingual lookup (for known tech terms like デプロイ, 部署, 배포)
751
+ // This ensures CJK full-words map to their English equivalents
752
+ const segSyns = expandCrossLingual(seg);
753
+ for (const syn of segSyns) {
754
+ if (!CJK_RE.test(syn)) {
755
+ syn.split(/\s+/).filter(w => w.length > 1 && !STOPWORDS.has(w)).forEach(w => tokens.push(w));
756
+ }
757
+ }
758
+ // Romanization: extract katakana-only portion and emit romaji tokens.
759
+ // This allows users to search "kontena" or "depuroi" to find katakana docs.
760
+ const kataOnly = seg.replace(/[^\u30a0-\u30ff]/g, '');
761
+ if (kataOnly.length >= 2) {
762
+ const romaji = katakanaToRomaji(kataOnly);
763
+ if (romaji.length >= 2 && romaji.length <= 40 && !STOPWORDS.has(romaji)) {
764
+ tokens.push(romaji);
765
+ }
766
+ }
767
+ } else if (RTL_RE.test(seg)) {
768
+ // RTL segment (Arabic / Hebrew) — space-separated words with light stemming
769
+ const words = seg.trim().split(/\s+/);
770
+ for (const w of words) {
771
+ if (w.length <= 1 || STOPWORDS.has(w)) continue;
772
+ const stemmed = arabicLightStem(w);
773
+ if (stemmed.length > 1 && !STOPWORDS.has(stemmed)) tokens.push(stemmed);
774
+ }
775
+ } else {
776
+ // Latin / other — existing logic preserved exactly
777
+ const words = seg
778
+ .replace(/[^a-záàâãäåæçéèêëíìîïñóòôõöúùûüýÿßœ0-9:_\-./]/g, ' ')
779
+ .split(/\s+/)
780
+ .filter(w => w.length > 1 && !STOPWORDS.has(w));
781
+ tokens.push(...words);
782
+ }
783
+ }
784
+
785
+ // ── Cross-lingual expansion ──────────────────────────────────────────────
786
+ // For each token, look up synonyms in other languages and add them.
787
+ // CJK synonyms are bigram-expanded inline; Latin/RTL synonyms added as-is.
788
+ const expansions: string[] = [];
789
+ const seen = new Set(tokens);
790
+ for (const tok of tokens) {
791
+ const syns = expandCrossLingual(tok);
792
+ for (const syn of syns) {
793
+ if (seen.has(syn)) continue;
794
+ seen.add(syn);
795
+ if (CJK_RE.test(syn)) {
796
+ // CJK synonym → expand to character bigrams
797
+ for (let i = 0; i < syn.length - 1; i++) {
798
+ const bg = syn[i] + syn[i + 1];
799
+ if (!STOPWORDS.has(syn[i]) && !STOPWORDS.has(bg)) expansions.push(bg);
800
+ }
801
+ if (syn.length >= 1) {
802
+ const last = syn[syn.length - 1];
803
+ if (!STOPWORDS.has(last)) expansions.push(last);
804
+ }
805
+ } else if (RTL_RE.test(syn)) {
806
+ // RTL synonym
807
+ const stemmed = arabicLightStem(syn);
808
+ if (stemmed.length > 1 && !STOPWORDS.has(stemmed)) expansions.push(stemmed);
809
+ } else {
810
+ // Latin synonym — split in case of multi-word (e.g. "load balancer")
811
+ const parts = syn.split(/\s+/).filter(w => w.length > 1 && !STOPWORDS.has(w));
812
+ expansions.push(...parts);
813
+ }
814
+ }
815
+ }
816
+ tokens.push(...expansions);
817
+
818
+ return tokens;
352
819
  }
353
820
 
354
821
  /**
@@ -661,7 +1128,8 @@ async function keywordSearch(
661
1128
  }
662
1129
 
663
1130
  // ── Exported for testing ──────────────────────────────────────────────────────
664
- export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS };
1131
+ export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS,
1132
+ katakanaToRomaji, arabicLightStem, expandCrossLingual, CROSS_LINGUAL_MAP };
665
1133
 
666
1134
  // ── Types ─────────────────────────────────────────────────────────────────────
667
1135
 
@@ -696,26 +1164,11 @@ interface SemanticSearchResponse {
696
1164
 
697
1165
  // ── Connection pool ───────────────────────────────────────────────────────────
698
1166
 
699
- /** Reuse Redis connections across tool calls (keyed by instance_id). Max 20 entries, LRU eviction. */
1167
+ /** Reuse Redis connections across tool calls (keyed by instance_id). */
700
1168
  const pool = new Map<string, Redis>();
701
- const POOL_MAX = 20;
702
1169
 
703
1170
  async function getConnection(instance_id: string): Promise<Redis> {
704
- if (pool.has(instance_id)) {
705
- // Move to end (most-recently-used)
706
- const conn = pool.get(instance_id)!;
707
- pool.delete(instance_id);
708
- pool.set(instance_id, conn);
709
- return conn;
710
- }
711
- // Evict oldest entry when pool is full
712
- if (pool.size >= POOL_MAX) {
713
- const oldest = pool.keys().next().value;
714
- if (oldest) {
715
- pool.get(oldest)?.quit().catch(() => undefined);
716
- pool.delete(oldest);
717
- }
718
- }
1171
+ if (pool.has(instance_id)) return pool.get(instance_id)!;
719
1172
 
720
1173
  const inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
721
1174
  if (inst.status !== 'running') {
@@ -785,34 +1238,13 @@ async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T>
785
1238
  }
786
1239
  const res = await fetch(`${API_URL}${path}`, {
787
1240
  ...options,
788
- signal: AbortSignal.timeout(15000),
789
1241
  headers: {
790
1242
  'Content-Type': 'application/json',
791
1243
  Authorization: `Bearer ${JWT}`,
792
1244
  ...options.headers,
793
1245
  },
794
- }).catch((err: Error) => {
795
- if (err.name === 'TimeoutError' || err.name === 'AbortError') {
796
- throw new McpError(ErrorCode.InternalError,
797
- `cachly API timed out after 15s — check your connection or try again.`);
798
- }
799
- throw new McpError(ErrorCode.InternalError, `cachly API unreachable: ${err.message}`);
800
1246
  });
801
1247
  if (!res.ok) {
802
- if (res.status === 401 || res.status === 403) {
803
- throw new McpError(
804
- ErrorCode.InvalidRequest,
805
- `cachly API: authentication failed (${res.status}) — your JWT may be expired or invalid.\n` +
806
- `Get a new API token at https://cachly.dev/settings or run \`get_api_status\` to check token expiry.`
807
- );
808
- }
809
- if (res.status === 503 || res.status === 502) {
810
- throw new McpError(
811
- ErrorCode.InternalError,
812
- `cachly API: auth service unavailable (${res.status}) — the authentication service may be temporarily down.\n` +
813
- `Please try again in a moment or contact support@cachly.dev`
814
- );
815
- }
816
1248
  const body = await res.json().catch(() => ({ error: res.statusText }));
817
1249
  throw new McpError(
818
1250
  ErrorCode.InternalError,
@@ -839,8 +1271,8 @@ const TOOLS = [
839
1271
  description:
840
1272
  'Create a new managed Valkey/Redis cache instance on cachly.dev. ' +
841
1273
  'Free tier provisions in ~30 seconds. Paid tiers return a Stripe checkout URL. ' +
842
- 'Available tiers: free (30 MB), dev (256 MB, €8/mo), pro (1 GB, €25/mo), ' +
843
- 'speed (1 GB Dragonfly + Semantic Cache, €39/mo), business (8 GB, €99/mo).',
1274
+ 'Available tiers: free (25 MB), dev (200 MB, €19/mo), pro (900 MB, €49/mo), ' +
1275
+ 'speed (900 MB Dragonfly + Semantic Cache, €79/mo), business (7 GB, €199/mo).',
844
1276
  inputSchema: {
845
1277
  type: 'object',
846
1278
  properties: {
@@ -1215,10 +1647,10 @@ const TOOLS = [
1215
1647
  {
1216
1648
  name: 'get_api_status',
1217
1649
  description:
1218
- 'Check the cachly API health, auth service reachability, and your authentication status. ' +
1219
- 'Returns API health, whether the Keycloak auth service is reachable, JWT validity, ' +
1220
- 'user ID (sub claim), token expiry, and the auth provider. ' +
1221
- 'Use this to debug connection issues, verify your CACHLY_JWT, or diagnose "Auth Service Unavailable" errors.',
1650
+ 'Check the cachly API health and your authentication status. ' +
1651
+ 'Returns whether the JWT is valid, your user ID (sub claim), ' +
1652
+ 'token expiry, and the auth provider (keycloak). ' +
1653
+ 'Use this to debug connection issues or verify your CACHLY_JWT is correct.',
1222
1654
  inputSchema: { type: 'object', properties: {}, required: [] },
1223
1655
  },
1224
1656
  // ── Thinking/Context Cache (for AI assistants) ────────────────────────────
@@ -1309,7 +1741,9 @@ const TOOLS = [
1309
1741
  'Fields: topic (short slug like "deploy:web"), outcome ("success"|"failure"), ' +
1310
1742
  'what_worked (what solved it), what_failed (what did NOT work), context (extra details). ' +
1311
1743
  'Supports structured metadata: severity, file_paths (files involved), commands (working commands), tags. ' +
1312
- 'Deduplication: if a lesson for this topic already exists, it is updated instead of duplicated. ' +
1744
+ 'Deduplication: if a lesson for this topic already exists, it is updated with full audit trail. ' +
1745
+ 'Contradiction detection: warns if new outcome conflicts with existing lesson outcome. ' +
1746
+ 'Confidence: lesson starts at 1.0, decays after 5d (→0.7) and 10d (→0.5) without recall. ' +
1313
1747
  'Example: learn_from_attempts(topic="deploy:api", outcome="success", what_worked="nohup docker compose up -d --build", what_failed="docker compose up hangs on SSH timeout", severity="critical", commands=["nohup docker compose up -d --build"])',
1314
1748
  inputSchema: {
1315
1749
  type: 'object',
@@ -1340,6 +1774,15 @@ const TOOLS = [
1340
1774
  items: { type: 'string' },
1341
1775
  description: 'Topic tags for filtering (e.g. ["bash", "deploy", "env"])',
1342
1776
  },
1777
+ depends_on: {
1778
+ type: 'array',
1779
+ items: { type: 'string' },
1780
+ description: 'Prerequisites this lesson depends on (e.g. ["node:>=20", "docker:running", "wireguard:active"]). When a dependency is marked stale, all dependent lessons get needs_review.',
1781
+ },
1782
+ author: {
1783
+ type: 'string',
1784
+ description: 'Name or handle of the person storing this lesson (e.g. "alice", "bob"). Used for Team Telepathy — teammates see each other\'s lessons in session_start.',
1785
+ },
1343
1786
  },
1344
1787
  required: ['instance_id', 'topic', 'outcome', 'what_worked'],
1345
1788
  },
@@ -1349,7 +1792,9 @@ const TOOLS = [
1349
1792
  description:
1350
1793
  'Recall the best known solution for a topic from past lessons. ' +
1351
1794
  'Call this BEFORE attempting any task that might have been done before. ' +
1352
- 'Returns the most recent successful lesson for the topic, or a summary of attempts. ' +
1795
+ 'Returns the most recent successful lesson for the topic, with confidence indicator. ' +
1796
+ '⚠️ badge = lesson is >5d old (verify before applying). 🔴 = >10d old (likely stale!). ' +
1797
+ 'Recalling a lesson resets its confidence clock to 1.0 (marks as recently verified). ' +
1353
1798
  'Example: recall_best_solution(topic="deploy:web") → returns the working deploy command.',
1354
1799
  inputSchema: {
1355
1800
  type: 'object',
@@ -1360,35 +1805,6 @@ const TOOLS = [
1360
1805
  required: ['instance_id', 'topic'],
1361
1806
  },
1362
1807
  },
1363
- {
1364
- name: 'list_lessons',
1365
- description:
1366
- 'List all topics stored in the Brain. Use this to see what the AI has learned, find topics to recall, or identify outdated lessons to delete with forget_lesson. ' +
1367
- 'Returns topics sorted by most recently stored, with severity and recall count.',
1368
- inputSchema: {
1369
- type: 'object',
1370
- properties: {
1371
- instance_id: { type: 'string', description: 'UUID of the cache instance' },
1372
- filter: { type: 'string', description: 'Optional substring filter, e.g. "deploy" returns only deploy-related topics' },
1373
- },
1374
- required: ['instance_id'],
1375
- },
1376
- },
1377
- {
1378
- name: 'forget_lesson',
1379
- description:
1380
- 'Delete a stored lesson from the Brain. Use this when a lesson is wrong, outdated, or was learned incorrectly. ' +
1381
- 'Removes both the best-solution entry and the attempt history for the topic. ' +
1382
- 'Example: forget_lesson(topic="deploy:web") → deletes the wrong deployment lesson so a correct one can be stored.',
1383
- inputSchema: {
1384
- type: 'object',
1385
- properties: {
1386
- instance_id: { type: 'string', description: 'UUID of the cache instance' },
1387
- topic: { type: 'string', description: 'Exact topic slug to delete, e.g. "deploy:web"' },
1388
- },
1389
- required: ['instance_id', 'topic'],
1390
- },
1391
- },
1392
1808
  {
1393
1809
  name: 'smart_recall',
1394
1810
  description:
@@ -1411,7 +1827,8 @@ const TOOLS = [
1411
1827
  description:
1412
1828
  'Single-call session briefing. Call this at the START of every session INSTEAD of multiple separate smart_recall/recall_best_solution calls. ' +
1413
1829
  'Returns: last session summary, recent lessons sorted by recency, relevant lessons for your focus area, ' +
1414
- 'open failures (topics with only failure outcomes), and brain health stats (lesson count, context count). ' +
1830
+ 'open failures (topics with only failure outcomes), brain health stats, team telepathy (what teammates learned this week), ' +
1831
+ 'predictive pre-warnings (if your focus area has known failure patterns), and memory crystals (compressed wisdom from old sessions). ' +
1415
1832
  'Also saves a session start marker so session_end can compute duration.',
1416
1833
  inputSchema: {
1417
1834
  type: 'object',
@@ -1421,6 +1838,18 @@ const TOOLS = [
1421
1838
  type: 'string',
1422
1839
  description: 'Keywords for what you plan to work on today (e.g. "deploy infra api"). Used to surface relevant lessons at the top.',
1423
1840
  },
1841
+ author: {
1842
+ type: 'string',
1843
+ description: 'Your name or handle (e.g. "alice"). Enables Team Telepathy — filters YOUR lessons vs TEAM lessons from past 7 days.',
1844
+ },
1845
+ provider: {
1846
+ type: 'string',
1847
+ description: 'Current AI provider (e.g. "claude-code", "copilot", "cursor", "windsurf"). Shown in the briefing header and saved so the next provider can see who was last active.',
1848
+ },
1849
+ workspace_path: {
1850
+ type: 'string',
1851
+ description: 'Absolute path to the project root. If no session_end was found (e.g. context limit hit), reads git log to reconstruct what happened since last session.',
1852
+ },
1424
1853
  },
1425
1854
  required: ['instance_id'],
1426
1855
  },
@@ -1431,7 +1860,8 @@ const TOOLS = [
1431
1860
  'Save a session summary when you finish working. ' +
1432
1861
  'Records what was accomplished, files changed, and lesson count. ' +
1433
1862
  'The next session_start will show this summary as "Last session". ' +
1434
- 'Call this when ending a work session, before going idle, or before summarizing.',
1863
+ 'Call this when ending a work session, before going idle, or before summarizing. ' +
1864
+ 'Ambient Learning: if workspace_path is provided, reads git log since session start and auto-learns from commits.',
1435
1865
  inputSchema: {
1436
1866
  type: 'object',
1437
1867
  properties: {
@@ -1449,10 +1879,101 @@ const TOOLS = [
1449
1879
  type: 'number',
1450
1880
  description: 'Number of new lessons stored this session (optional)',
1451
1881
  },
1882
+ workspace_path: {
1883
+ type: 'string',
1884
+ description: 'Absolute path to the project root (e.g. "/Users/you/myproject"). Enables Ambient Learning — reads git log since session start and auto-learns from commit messages.',
1885
+ },
1452
1886
  },
1453
1887
  required: ['instance_id', 'summary'],
1454
1888
  },
1455
1889
  },
1890
+ // ── Session Handoff — cross-window continuity ─────────────────────────────
1891
+ {
1892
+ name: 'session_handoff',
1893
+ description:
1894
+ 'Save a detailed handoff for the NEXT chat window / session. ' +
1895
+ 'Stores: current progress, TODO list (done + remaining), changed files with descriptions, ' +
1896
+ 'instructions for the next assistant, and any incomplete work. ' +
1897
+ 'The next session_start automatically includes this handoff so the new window knows EXACTLY what happened and what remains. ' +
1898
+ 'Call this BEFORE closing a chat window, especially if work is incomplete. ' +
1899
+ 'This prevents the "continue" problem where new windows lose context, skip tasks, or produce broken code.',
1900
+ inputSchema: {
1901
+ type: 'object',
1902
+ properties: {
1903
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
1904
+ completed_tasks: {
1905
+ type: 'array',
1906
+ items: { type: 'string' },
1907
+ description: 'Tasks that were fully completed (e.g. "Implemented brainSearch() in JS SDK")',
1908
+ },
1909
+ remaining_tasks: {
1910
+ type: 'array',
1911
+ items: { type: 'string' },
1912
+ description: 'Tasks NOT yet done — the next window MUST pick these up',
1913
+ },
1914
+ files_changed: {
1915
+ type: 'array',
1916
+ items: {
1917
+ type: 'object',
1918
+ properties: {
1919
+ path: { type: 'string', description: 'File path relative to project root' },
1920
+ status: { type: 'string', enum: ['complete', 'partial', 'broken'], description: 'State of this file' },
1921
+ description: { type: 'string', description: 'What was changed and what still needs work' },
1922
+ },
1923
+ required: ['path', 'status'],
1924
+ },
1925
+ description: 'Changed files with their current state — marks partial/broken files so next window knows to fix them',
1926
+ },
1927
+ instructions: {
1928
+ type: 'string',
1929
+ description: 'Free-form instructions for the next assistant. Be specific: what to do next, what to avoid, what broke.',
1930
+ },
1931
+ context_summary: {
1932
+ type: 'string',
1933
+ description: 'Brief summary of what happened this session (architecture decisions, key findings, blockers)',
1934
+ },
1935
+ blocked_on: {
1936
+ type: 'string',
1937
+ description: 'If work is blocked, describe what is needed to unblock (e.g. "waiting for API deploy", "needs user input on design")',
1938
+ },
1939
+ },
1940
+ required: ['instance_id', 'completed_tasks', 'remaining_tasks'],
1941
+ },
1942
+ },
1943
+ // ── session_ping — lightweight in-session checkpoint ─────────────────────
1944
+ {
1945
+ name: 'session_ping',
1946
+ description:
1947
+ 'Lightweight checkpoint — call this every ~5 tool calls or whenever you complete a significant step. ' +
1948
+ 'Stores the current task + files touched so session_start on the NEXT provider can reconstruct what happened ' +
1949
+ 'even if session_end was never called (e.g. Claude context limit hit, window crashed). ' +
1950
+ 'This solves the provider-switching problem: Claude → Copilot → Cursor all see the same last checkpoint. ' +
1951
+ 'Extremely fast — one Redis SET, no blocking operations.',
1952
+ inputSchema: {
1953
+ type: 'object',
1954
+ properties: {
1955
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
1956
+ task: {
1957
+ type: 'string',
1958
+ description: 'What you are currently working on (e.g. "Implementing invite handler in handler/invite.go")',
1959
+ },
1960
+ files_touched: {
1961
+ type: 'array',
1962
+ items: { type: 'string' },
1963
+ description: 'Files modified so far this session',
1964
+ },
1965
+ next_step: {
1966
+ type: 'string',
1967
+ description: 'What the NEXT step is after this checkpoint (helps next provider resume immediately)',
1968
+ },
1969
+ provider: {
1970
+ type: 'string',
1971
+ description: 'Current AI provider (e.g. "claude-code", "copilot", "cursor", "windsurf")',
1972
+ },
1973
+ },
1974
+ required: ['instance_id', 'task'],
1975
+ },
1976
+ },
1456
1977
  // ── AI Brain — Extended features ─────────────────────────────────────────
1457
1978
  {
1458
1979
  name: 'auto_learn_session',
@@ -1544,50 +2065,135 @@ const TOOLS = [
1544
2065
  },
1545
2066
  },
1546
2067
  {
1547
- name: 'brain_stats',
2068
+ name: 'team_synthesize',
2069
+ description:
2070
+ 'Team Brain Synthesis — merge multiple contributors\' lessons on the same topic into one canonical version. ' +
2071
+ 'When 2+ developers store lessons for the same topic with different details, this proposes the best merged version. ' +
2072
+ 'Shows: all contributions by author, what worked (consensus), what failed (union), canonical lesson to store. ' +
2073
+ 'Use this when onboarding new team members or before documenting a process.',
2074
+ inputSchema: {
2075
+ type: 'object',
2076
+ properties: {
2077
+ instance_id: { type: 'string', description: 'UUID of the shared team brain instance' },
2078
+ topic: { type: 'string', description: 'Topic slug to synthesize (e.g. "deploy:api")' },
2079
+ },
2080
+ required: ['instance_id', 'topic'],
2081
+ },
2082
+ },
2083
+ {
2084
+ name: 'memory_crystalize',
1548
2085
  description:
1549
- 'Complete statistics dashboard for your AI Brain — lessons, recalls, time saved, top topics, team authors, memory usage. ' +
1550
- 'Returns a formatted report designed for sharing (screenshot-worthy). ' +
1551
- 'Use this when you want a full overview or want to share your brain health with others.',
2086
+ 'Compress the last 30-50 sessions and auto-learned lessons into a dense Memory Crystal. ' +
2087
+ 'A crystal is a compact, structured summary of everything the brain learned — grouped by category (deploy, fix, debug, …). ' +
2088
+ 'Crystals survive session cleanup and appear in session_start once enough sessions have accumulated. ' +
2089
+ 'Run this monthly or after a big milestone to preserve institutional knowledge. ' +
2090
+ 'Returns a digest of what was crystallized.',
1552
2091
  inputSchema: {
1553
2092
  type: 'object',
1554
2093
  properties: {
1555
2094
  instance_id: { type: 'string', description: 'UUID of the cache instance' },
2095
+ label: {
2096
+ type: 'string',
2097
+ description: 'Optional label for this crystal (e.g. "Q1 2026", "v2 launch"). Auto-generated from date if omitted.',
2098
+ },
1556
2099
  },
1557
2100
  required: ['instance_id'],
1558
2101
  },
1559
2102
  },
2103
+ // ── Roadmap — Persistent project plan tracker ───────────────────────────
2104
+ {
2105
+ name: 'roadmap_add',
2106
+ description:
2107
+ 'Add a new item to the persistent project roadmap stored in the Brain. ' +
2108
+ 'Items survive across sessions and editors — the roadmap is always up to date. ' +
2109
+ 'Use for features, bugs, refactors, or any planned work. ' +
2110
+ 'Call roadmap_list to see all open items, roadmap_next to get the next actionable item.',
2111
+ inputSchema: {
2112
+ type: 'object',
2113
+ properties: {
2114
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
2115
+ title: { type: 'string', description: 'Short title of the task/feature (3–10 words)' },
2116
+ description: { type: 'string', description: 'What needs to be done, acceptance criteria, context' },
2117
+ priority: {
2118
+ type: 'string',
2119
+ enum: ['critical', 'high', 'medium', 'low'],
2120
+ description: 'Priority level (default: medium)',
2121
+ },
2122
+ tags: {
2123
+ type: 'array',
2124
+ items: { type: 'string' },
2125
+ description: 'Tags for filtering (e.g. ["api", "web", "sdk", "infra"])',
2126
+ },
2127
+ milestone: { type: 'string', description: 'Milestone/epic this belongs to (optional)' },
2128
+ },
2129
+ required: ['instance_id', 'title'],
2130
+ },
2131
+ },
2132
+ {
2133
+ name: 'roadmap_update',
2134
+ description:
2135
+ 'Update the status, priority, or details of a roadmap item. ' +
2136
+ 'Use to move items through the lifecycle: planned → in-progress → done (or blocked/cancelled). ' +
2137
+ 'Also use to add notes/findings while working on an item.',
2138
+ inputSchema: {
2139
+ type: 'object',
2140
+ properties: {
2141
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
2142
+ id: { type: 'string', description: 'Item ID returned by roadmap_add or roadmap_list' },
2143
+ status: {
2144
+ type: 'string',
2145
+ enum: ['planned', 'in-progress', 'done', 'blocked', 'cancelled'],
2146
+ description: 'New status for the item',
2147
+ },
2148
+ priority: {
2149
+ type: 'string',
2150
+ enum: ['critical', 'high', 'medium', 'low'],
2151
+ description: 'Updated priority (optional)',
2152
+ },
2153
+ notes: { type: 'string', description: 'Progress notes, findings, or blockers (appended to existing notes)' },
2154
+ title: { type: 'string', description: 'Updated title (optional)' },
2155
+ description: { type: 'string', description: 'Updated description (optional)' },
2156
+ },
2157
+ required: ['instance_id', 'id'],
2158
+ },
2159
+ },
1560
2160
  {
1561
- name: 'export_brain',
2161
+ name: 'roadmap_list',
1562
2162
  description:
1563
- 'Export your entire AI Brain as a portable Markdown file. ' +
1564
- 'Includes all lessons (topic, outcome, what worked, severity), context entries (keys + content), ' +
1565
- 'session history, and metadata. Perfect for sharing with teammates, archiving, posting to GitHub, ' +
1566
- 'or importing into a new instance. ' +
1567
- 'Example: export_brain() → returns full markdown string you can save to a .md file.',
2163
+ 'List all roadmap items, optionally filtered by status, priority, tag, or milestone. ' +
2164
+ 'Returns items sorted by priority then creation date. ' +
2165
+ 'Called automatically by session_start to show open work.',
1568
2166
  inputSchema: {
1569
2167
  type: 'object',
1570
2168
  properties: {
1571
2169
  instance_id: { type: 'string', description: 'UUID of the cache instance' },
1572
- include_context: { type: 'boolean', description: 'Include context entries (default: true)' },
1573
- max_lessons: { type: 'number', description: 'Max lessons to include (default: all)' },
2170
+ status: {
2171
+ type: 'string',
2172
+ enum: ['planned', 'in-progress', 'done', 'blocked', 'cancelled', 'open'],
2173
+ description: 'Filter by status. Use \'open\' to see planned+in-progress+blocked (default: open)',
2174
+ },
2175
+ tag: { type: 'string', description: 'Filter by tag (optional)' },
2176
+ milestone: { type: 'string', description: 'Filter by milestone (optional)' },
2177
+ priority: {
2178
+ type: 'string',
2179
+ enum: ['critical', 'high', 'medium', 'low'],
2180
+ description: 'Filter by minimum priority (optional)',
2181
+ },
1574
2182
  },
1575
2183
  required: ['instance_id'],
1576
2184
  },
1577
2185
  },
1578
2186
  {
1579
- name: 'invite_link',
2187
+ name: 'roadmap_next',
1580
2188
  description:
1581
- 'Generate a one-click team invite link for your Brain instance. ' +
1582
- 'Share the link (or the npx join command) with a teammate — they run one command and are instantly ' +
1583
- 'connected to the same shared Brain with all your lessons and context. ' +
1584
- 'No dashboard visit, no copy-paste of instance IDs required. ' +
1585
- 'Example: invite_link() → returns "npx @cachly-dev/mcp-server@latest join <token>"',
2189
+ 'Get the single most important next actionable roadmap item. ' +
2190
+ 'Returns the highest-priority in-progress item first, then planned items, sorted by priority. ' +
2191
+ 'Call at session start to immediately know what to work on next.',
1586
2192
  inputSchema: {
1587
2193
  type: 'object',
1588
2194
  properties: {
1589
- instance_id: { type: 'string', description: 'UUID of the cache instance to share' },
1590
- label: { type: 'string', description: 'Optional label shown to the invitee (e.g. "my-api project")' },
2195
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
2196
+ tag: { type: 'string', description: 'Filter by tag (optional)' },
1591
2197
  },
1592
2198
  required: ['instance_id'],
1593
2199
  },
@@ -1596,12 +2202,16 @@ const TOOLS = [
1596
2202
  name: 'brain_doctor',
1597
2203
  description:
1598
2204
  'Check the health of your AI Brain and get actionable recommendations. ' +
1599
- 'Reports: lesson count, context entries, last session age, open failures, quality score. ' +
2205
+ 'Reports: lesson count, context entries, last session age, open failures, quality score, effective IQ boost, stale index. ' +
1600
2206
  'Returns a prioritized list of issues with fix instructions.',
1601
2207
  inputSchema: {
1602
2208
  type: 'object',
1603
2209
  properties: {
1604
2210
  instance_id: { type: 'string', description: 'UUID of the cache instance' },
2211
+ workspace_path: {
2212
+ type: 'string',
2213
+ description: 'Absolute path to workspace root — enables package.json analysis for openclaw cross-promo (optional)',
2214
+ },
1605
2215
  },
1606
2216
  required: ['instance_id'],
1607
2217
  },
@@ -1673,6 +2283,100 @@ const TOOLS = [
1673
2283
  required: ['instance_id', 'framework'],
1674
2284
  },
1675
2285
  },
2286
+ // ── Brain Archaeology + Causal Chain ────────────────────────────────────
2287
+ {
2288
+ name: 'recall_at',
2289
+ description:
2290
+ 'Brain Archaeology — see what a lesson looked like at a specific point in time. ' +
2291
+ '"What did we know about deployments 3 months ago?" ' +
2292
+ 'Returns the history of a topic filtered to entries before the given date. ' +
2293
+ 'Shows how the lesson evolved: failure → partial → success. ' +
2294
+ 'Also useful to understand WHY old code decisions were made.',
2295
+ inputSchema: {
2296
+ type: 'object',
2297
+ properties: {
2298
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
2299
+ topic: { type: 'string', description: 'Topic slug to look up, e.g. "deploy:api"' },
2300
+ date: { type: 'string', description: 'ISO date string (e.g. "2026-01-15") — returns entries stored BEFORE this date' },
2301
+ },
2302
+ required: ['instance_id', 'topic', 'date'],
2303
+ },
2304
+ },
2305
+ {
2306
+ name: 'trace_dependency',
2307
+ description:
2308
+ 'Causal Chain — find all lessons that depend on a given prerequisite. ' +
2309
+ '"What lessons are affected if node version changes?" ' +
2310
+ 'When a dependency changes (new version, different provider, new OS), call this to see which lessons need review. ' +
2311
+ 'Lessons store dependencies via the depends_on field in learn_from_attempts.',
2312
+ inputSchema: {
2313
+ type: 'object',
2314
+ properties: {
2315
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
2316
+ dependency: { type: 'string', description: 'Dependency to trace (e.g. "node:>=20", "docker:running", "wireguard:active")' },
2317
+ mark_review: { type: 'boolean', description: 'If true, marks all dependent lessons as needs_review (default: false)' },
2318
+ },
2319
+ required: ['instance_id', 'dependency'],
2320
+ },
2321
+ },
2322
+ // ── Team / Org Management ────────────────────────────────────────────────
2323
+ {
2324
+ name: 'list_orgs',
2325
+ description:
2326
+ 'List your Cachly organizations (team/org plans). ' +
2327
+ 'Returns each org with plan, seat count, and member info. ' +
2328
+ 'Org plans (Team €99, Business €299, Enterprise custom) are billed separately from cache tiers.',
2329
+ inputSchema: {
2330
+ type: 'object',
2331
+ properties: {},
2332
+ required: [],
2333
+ },
2334
+ },
2335
+ {
2336
+ name: 'create_org',
2337
+ description:
2338
+ 'Create a new Cachly organization for team collaboration. ' +
2339
+ 'After creation, invite team members with invite_member and upgrade the plan via the billing portal. ' +
2340
+ 'Org plans: Team (€99/mo, 10 seats), Business (€299/mo, 50 seats), Enterprise (custom).',
2341
+ inputSchema: {
2342
+ type: 'object',
2343
+ properties: {
2344
+ name: { type: 'string', description: 'Organization display name (e.g. "Acme Engineering")' },
2345
+ slug: { type: 'string', description: 'URL-safe slug (e.g. "acme-eng"). Auto-generated from name if omitted.' },
2346
+ },
2347
+ required: ['name'],
2348
+ },
2349
+ },
2350
+ {
2351
+ name: 'invite_member',
2352
+ description:
2353
+ 'Invite a team member to a Cachly organization by email. ' +
2354
+ 'They will receive an invite email and can join via the dashboard. ' +
2355
+ 'Roles: owner (full access), admin (manage members + instances), member (read + cache ops).',
2356
+ inputSchema: {
2357
+ type: 'object',
2358
+ properties: {
2359
+ org_id: { type: 'string', description: 'UUID of the organization' },
2360
+ email: { type: 'string', description: 'Email address to invite' },
2361
+ role: { type: 'string', enum: ['admin', 'member'], description: 'Role for the invited member (default: member)' },
2362
+ },
2363
+ required: ['org_id', 'email'],
2364
+ },
2365
+ },
2366
+ {
2367
+ name: 'get_org_plan',
2368
+ description:
2369
+ 'Get the current org plan, seat usage, and billing info for an organization. ' +
2370
+ 'Shows: plan name, price, seats used/max, next billing date. ' +
2371
+ 'To upgrade: use the billing portal URL returned by this tool.',
2372
+ inputSchema: {
2373
+ type: 'object',
2374
+ properties: {
2375
+ org_id: { type: 'string', description: 'UUID of the organization' },
2376
+ },
2377
+ required: ['org_id'],
2378
+ },
2379
+ },
1676
2380
  // ── Legacy / Setup ────────────────────────────────────────────────────────
1677
2381
  {
1678
2382
  name: 'setup_ai_memory',
@@ -1770,10 +2474,50 @@ function buildConnectionString(inst: Instance): string {
1770
2474
  return `${scheme}://${pw}${inst.host}:${inst.port}`;
1771
2475
  }
1772
2476
 
2477
+ // Fires once per process when no JWT is set (anonymous, opt-out via CACHLY_NO_TELEMETRY=1)
2478
+ let _telemetryPingSent = false;
2479
+ async function sendAnonymousTelemetry(toolName: string): Promise<void> {
2480
+ if (_telemetryPingSent) return;
2481
+ if (process.env.CACHLY_NO_TELEMETRY === '1') return;
2482
+ _telemetryPingSent = true;
2483
+ // Detect editor from common env vars injected by IDE extensions
2484
+ const editor = process.env.CURSOR_TRACE_ID ? 'cursor'
2485
+ : process.env.WINDSURF_SESSION_ID ? 'windsurf'
2486
+ : process.env.GITHUB_COPILOT_WORKSPACE ? 'copilot'
2487
+ : process.env.CLAUDE_CODE_ENTRYPOINT ? 'claude'
2488
+ : 'unknown';
2489
+ try {
2490
+ await fetch(`${API_URL}/api/v1/telemetry/mcp`, {
2491
+ method: 'POST',
2492
+ headers: { 'Content-Type': 'application/json' },
2493
+ body: JSON.stringify({ event: 'first_call_no_jwt', version: CURRENT_VERSION, editor, tool: toolName }),
2494
+ signal: AbortSignal.timeout(3000),
2495
+ });
2496
+ } catch { /* fire-and-forget, never block the user */ }
2497
+ }
2498
+
1773
2499
  async function handleTool(name: string, args: Record<string, unknown>): Promise<string> {
1774
- // Default instance_id from env so tools work even if AI omits it
1775
- if (!args.instance_id && process.env.CACHLY_BRAIN_INSTANCE_ID) {
1776
- args = { ...args, instance_id: process.env.CACHLY_BRAIN_INSTANCE_ID };
2500
+ // Guard: if no JWT, return actionable onboarding message instead of HTTP 401
2501
+ if (!JWT) {
2502
+ void sendAnonymousTelemetry(name);
2503
+ return [
2504
+ '🧠 **cachly AI Brain — Setup required**',
2505
+ '',
2506
+ 'You have no API credentials configured. Get started in 30 seconds:',
2507
+ '',
2508
+ '**Step 1:** Register for free (no credit card):',
2509
+ ' 👉 https://cachly.dev/setup-ai',
2510
+ '',
2511
+ '**Step 2:** Run the setup wizard in your terminal:',
2512
+ ' ```',
2513
+ ' npx @cachly-dev/mcp-server@latest setup',
2514
+ ' ```',
2515
+ '',
2516
+ 'The wizard auto-detects your editor (Claude Code, Cursor, Copilot, Windsurf)',
2517
+ 'and writes the config file with your credentials.',
2518
+ '',
2519
+ '✨ Free tier includes: 1 Brain instance, persistent memory, semantic search.',
2520
+ ].join('\n');
1777
2521
  }
1778
2522
 
1779
2523
  // Delegate v0.2 bulk/lock/stream tools first
@@ -1794,7 +2538,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
1794
2538
  const { name: instName, tier } = args as { name: string; tier: string };
1795
2539
  const res = await apiFetch<CreateResponse>('/api/v1/instances', {
1796
2540
  method: 'POST',
1797
- body: JSON.stringify({ name: instName, tier }),
2541
+ body: JSON.stringify({ name: instName, tier, created_via: 'api' }),
1798
2542
  });
1799
2543
  if (res.checkout_url) {
1800
2544
  return [
@@ -1854,25 +2598,88 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
1854
2598
  return `✅ Instance \`${instance_id}\` has been deleted and all data removed.`;
1855
2599
  }
1856
2600
 
1857
- // ── Live cache operations ────────────────────────────────────────────
1858
- case 'cache_get': {
1859
- const { instance_id, key } = args as { instance_id: string; key: string };
1860
- const redis = await getConnection(instance_id);
1861
- const value = await redis.get(key);
1862
- if (value === null) return `Key \`${key}\` → **not found** (null)`;
1863
- let pretty = value;
1864
- try {
1865
- pretty = JSON.stringify(JSON.parse(value), null, 2);
1866
- } catch {
1867
- // not JSON — return raw
1868
- }
1869
- return `Key \`${key}\`:\n\`\`\`\n${pretty}\n\`\`\``;
2601
+ // ── Org / Team management ────────────────────────────────────────────
2602
+ case 'list_orgs': {
2603
+ const res = await apiFetch<{ orgs: Array<{ id: string; name: string; slug: string; plan: string; max_members: number; member_count?: number }> }>('/api/v1/orgs');
2604
+ const orgs = res.orgs ?? [];
2605
+ if (orgs.length === 0) return `📭 No organizations yet.\n\nCreate one with \`create_org(name="My Team")\`.\nOrg plans: Team €99/mo (10 seats), Business €299/mo (50 seats), Enterprise custom.`;
2606
+ return [
2607
+ `🏢 **Your organizations (${orgs.length})**\n`,
2608
+ ...orgs.map(o => `• **${o.name}** (\`${o.slug}\`) — plan: ${o.plan} · seats: ${o.member_count ?? '?'}/${o.max_members}\n ID: \`${o.id}\``),
2609
+ `\n_Manage: \`get_org_plan\`, \`invite_member\`, dashboard → /team_`,
2610
+ ].join('\n');
1870
2611
  }
1871
2612
 
1872
- case 'cache_set': {
1873
- const { instance_id, key, value, ttl } = args as {
1874
- instance_id: string;
1875
- key: string;
2613
+ case 'create_org': {
2614
+ const { name: orgName, slug } = args as { name: string; slug?: string };
2615
+ const body: Record<string, string> = { name: orgName };
2616
+ if (slug) body.slug = slug;
2617
+ const res = await apiFetch<{ id: string; name: string; slug: string; plan: string }>('/api/v1/orgs', {
2618
+ method: 'POST',
2619
+ body: JSON.stringify(body),
2620
+ });
2621
+ return [
2622
+ `✅ **Organization created:** ${res.name}`,
2623
+ ` ID: \`${res.id}\` · Slug: \`${res.slug}\` · Plan: ${res.plan}`,
2624
+ ``,
2625
+ `**Next steps:**`,
2626
+ `1. Invite team members: \`invite_member(org_id="${res.id}", email="dev@example.com")\``,
2627
+ `2. Upgrade plan: open billing portal via dashboard → /team`,
2628
+ ` Team: €99/mo (10 seats) · Business: €299/mo (50 seats)`,
2629
+ ].join('\n');
2630
+ }
2631
+
2632
+ case 'invite_member': {
2633
+ const { org_id, email, role = 'member' } = args as { org_id: string; email: string; role?: string };
2634
+ await apiFetch(`/api/v1/orgs/${org_id}/members`, {
2635
+ method: 'POST',
2636
+ body: JSON.stringify({ email, role }),
2637
+ });
2638
+ return `✅ Invite sent to **${email}** as \`${role}\` in org \`${org_id}\`.\n\nThey will receive an email to join the organization.`;
2639
+ }
2640
+
2641
+ case 'get_org_plan': {
2642
+ const { org_id } = args as { org_id: string };
2643
+ const org = await apiFetch<{
2644
+ id: string; name: string; plan: string; max_members: number;
2645
+ members: Array<{ role: string; invite_email: string; accepted_at?: string }>;
2646
+ stripe_customer_id?: string;
2647
+ }>(`/api/v1/orgs/${org_id}`);
2648
+ const accepted = (org.members ?? []).filter(m => m.accepted_at).length;
2649
+ const pending = (org.members ?? []).filter(m => !m.accepted_at).length;
2650
+ const planPrice: Record<string, string> = { free: '€0', team: '€99/mo', business: '€299/mo', enterprise: 'custom' };
2651
+ return [
2652
+ `🏢 **${org.name}** — Plan: **${org.plan}** (${planPrice[org.plan] ?? org.plan})`,
2653
+ ` Seats: ${accepted} active + ${pending} pending / ${org.max_members} max`,
2654
+ ``,
2655
+ `**Members:**`,
2656
+ ...(org.members ?? []).map(m => ` • ${m.invite_email} (${m.role})${m.accepted_at ? '' : ' — pending'}`),
2657
+ ``,
2658
+ org.stripe_customer_id
2659
+ ? `💳 Billing: managed via Stripe. Upgrade/cancel: dashboard → /billing`
2660
+ : `💳 No payment method yet. Upgrade: dashboard → /billing → Team Plans`,
2661
+ ].join('\n');
2662
+ }
2663
+
2664
+ // ── Live cache operations ────────────────────────────────────────────
2665
+ case 'cache_get': {
2666
+ const { instance_id, key } = args as { instance_id: string; key: string };
2667
+ const redis = await getConnection(instance_id);
2668
+ const value = await redis.get(key);
2669
+ if (value === null) return `Key \`${key}\` → **not found** (null)`;
2670
+ let pretty = value;
2671
+ try {
2672
+ pretty = JSON.stringify(JSON.parse(value), null, 2);
2673
+ } catch {
2674
+ // not JSON — return raw
2675
+ }
2676
+ return `Key \`${key}\`:\n\`\`\`\n${pretty}\n\`\`\``;
2677
+ }
2678
+
2679
+ case 'cache_set': {
2680
+ const { instance_id, key, value, ttl } = args as {
2681
+ instance_id: string;
2682
+ key: string;
1876
2683
  value: string;
1877
2684
  ttl?: number;
1878
2685
  };
@@ -2280,6 +3087,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2280
3087
  let skipped = 0;
2281
3088
  let errors = 0;
2282
3089
  let semanticIndexed = 0;
3090
+ let unchanged = 0;
2283
3091
  const details: string[] = [];
2284
3092
  const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
2285
3093
  const redis = await getConnection(instance_id);
@@ -2287,15 +3095,31 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2287
3095
  for (const filePath of files) {
2288
3096
  const relPath = relative(dir, filePath);
2289
3097
  let content: string;
3098
+ let fileSize: number;
2290
3099
  try {
2291
3100
  const s = await stat(filePath);
2292
3101
  if (s.size > 200_000) { skipped++; continue; } // skip files >200 KB
3102
+ fileSize = s.size;
2293
3103
  content = await readFile(filePath, 'utf-8');
2294
3104
  } catch {
2295
3105
  errors++;
2296
3106
  continue;
2297
3107
  }
2298
3108
 
3109
+ // ── Smart invalidation: hash-based change detection ──
3110
+ // Compute a simple hash of file content to skip unchanged files
3111
+ const hashKey = `cachly:idx:hash:${relPath}`;
3112
+ const contentHash = `${fileSize}:${content.length}:${simpleHash(content)}`;
3113
+ const existingHash = await redis.get(hashKey);
3114
+ if (existingHash === contentHash) {
3115
+ // File unchanged — refresh TTL but skip re-indexing
3116
+ const idxKey = `cachly:idx:${relPath}`;
3117
+ if (ttl > 0) await redis.expire(idxKey, ttl);
3118
+ if (ttl > 0) await redis.expire(hashKey, ttl);
3119
+ unchanged++;
3120
+ continue;
3121
+ }
3122
+
2299
3123
  const excerpt = content.slice(0, summary_chars).replace(/\s+/g, ' ').trim();
2300
3124
 
2301
3125
  // ── Layer 1: Keyword index in Valkey (always works, no embedding needed) ──
@@ -2306,6 +3130,12 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2306
3130
  } else {
2307
3131
  await redis.set(idxKey, idxValue);
2308
3132
  }
3133
+ // Store content hash for smart invalidation on next run
3134
+ if (ttl > 0) {
3135
+ await redis.set(hashKey, contentHash, 'EX', ttl);
3136
+ } else {
3137
+ await redis.set(hashKey, contentHash);
3138
+ }
2309
3139
  indexed++;
2310
3140
  details.push(` ✅ ${relPath}`);
2311
3141
 
@@ -2334,7 +3164,8 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2334
3164
  `📂 **index_project Complete** — ${mode}`,
2335
3165
  ``,
2336
3166
  ` 📁 Dir: ${dir}`,
2337
- ` ✅ Indexed: **${indexed}** files (keyword-searchable via smart_recall)`,
3167
+ ` ✅ Indexed: **${indexed}** files (new/changed)`,
3168
+ ` ♻️ Unchanged: ${unchanged} files (hash match — skipped)`,
2338
3169
  ...(canEmbed ? [` 🎯 Semantic: **${semanticIndexed}** files (vector-searchable)`] : []),
2339
3170
  ` ⏭️ Skipped: ${skipped} (too large or filtered)`,
2340
3171
  ` ❌ Errors: ${errors}`,
@@ -2382,8 +3213,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2382
3213
  await redis.set(`${cacheKey}:meta`, meta);
2383
3214
  }
2384
3215
 
2385
- // Also index semantically for smart_recall (if vector available and embedding is configured)
2386
- if (EMBED_PROVIDER !== 'none') {
3216
+ // Also index semantically for smart_recall (if vector available)
2387
3217
  const inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
2388
3218
  if (inst.vector_token) {
2389
3219
  try {
@@ -2407,7 +3237,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2407
3237
  // Embedding optional — continue silently
2408
3238
  }
2409
3239
  }
2410
- } // end EMBED_PROVIDER !== 'none'
2411
3240
 
2412
3241
  return [
2413
3242
  `🧠 **Context Saved**`,
@@ -2559,6 +3388,8 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2559
3388
  file_paths = [],
2560
3389
  commands = [],
2561
3390
  tags = [],
3391
+ depends_on = [],
3392
+ author = '',
2562
3393
  } = args as {
2563
3394
  instance_id: string;
2564
3395
  topic: string;
@@ -2570,26 +3401,107 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2570
3401
  file_paths?: string[];
2571
3402
  commands?: string[];
2572
3403
  tags?: string[];
3404
+ depends_on?: string[];
3405
+ author?: string;
2573
3406
  };
2574
3407
 
2575
3408
  const redis = await getConnection(instance_id);
2576
3409
  const ts = new Date().toISOString();
2577
3410
 
2578
- // ── Deduplication: check for existing lesson with same topic ─────────────
3411
+ // ── Structured template hints ──────────────────────────────────────────
3412
+ const category = topic.split(':')[0];
3413
+ const template = STRUCTURED_TEMPLATES[category];
3414
+ const templateWarnings: string[] = [];
3415
+ if (template) {
3416
+ for (const req of template.required) {
3417
+ if (req === 'commands' && commands.length === 0) {
3418
+ templateWarnings.push(`📋 ${template.hint}`);
3419
+ }
3420
+ }
3421
+ }
3422
+
3423
+ // ── Deduplication + audit trail ────────────────────────────────────────
2579
3424
  let isUpdate = false;
2580
3425
  let recallCount = 0;
2581
- if (outcome === 'success') {
2582
- const existing = await redis.get(`cachly:lesson:best:${topic}`);
2583
- if (existing) {
3426
+ let auditTrail: Array<{ ts: string; action: string; prev_outcome?: string }> = [];
3427
+ const existingRaw = await redis.get(`cachly:lesson:best:${topic}`);
3428
+ if (existingRaw) {
3429
+ try {
3430
+ const prev = JSON.parse(existingRaw) as {
3431
+ recall_count?: number;
3432
+ outcome?: string;
3433
+ audit_trail?: Array<{ ts: string; action: string; prev_outcome?: string }>;
3434
+ };
3435
+ recallCount = prev.recall_count ?? 0;
3436
+ auditTrail = prev.audit_trail ?? [];
3437
+ auditTrail.push({ ts, action: 'updated', prev_outcome: prev.outcome });
3438
+ if (auditTrail.length > 20) auditTrail = auditTrail.slice(-20);
3439
+ isUpdate = true;
3440
+
3441
+ // ── Contradiction detection ─────────────────────────────────────────
3442
+ const contradictionWarning: string[] = [];
3443
+ if (prev.outcome === 'success' && outcome === 'failure') {
3444
+ contradictionWarning.push(
3445
+ `⚠️ **Contradiction detected!** Existing lesson has outcome: \`success\`, but you're storing \`failure\`.`,
3446
+ `The existing "success" lesson will be preserved. Only the audit trail is updated.`,
3447
+ `If you meant to mark this as failed permanently, store a new lesson with a distinct topic slug.`,
3448
+ );
3449
+ } else if (prev.outcome === 'failure' && outcome === 'success') {
3450
+ contradictionWarning.push(
3451
+ `✅ **Conflict resolved!** Previous lesson was \`failure\` — now overwriting with \`success\`.`,
3452
+ );
3453
+ }
3454
+ if (contradictionWarning.length > 0) {
3455
+ // Store contradiction audit but don't block
3456
+ auditTrail[auditTrail.length - 1].action = 'contradiction-resolved';
3457
+ }
3458
+ } catch { /* ignore parse error */ }
3459
+ } else {
3460
+ auditTrail = [{ ts, action: 'created' }];
3461
+ }
3462
+
3463
+ // ── "I Was Wrong" Protocol — failure attribution ───────────────────────
3464
+ const iWasWrongWarning: string[] = [];
3465
+ if (outcome === 'failure') {
3466
+ // Search for related success lessons that might have prevented this failure
3467
+ const scanKeys: string[] = [];
3468
+ const scanStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 100 });
3469
+ await new Promise<void>((res, rej) => {
3470
+ scanStream.on('data', (b: string[]) => scanKeys.push(...b));
3471
+ scanStream.on('end', res);
3472
+ scanStream.on('error', rej);
3473
+ });
3474
+ const topicWords = topic.split(/[:\-_]/).filter(w => w.length > 2);
3475
+ for (const k of scanKeys.slice(0, 50)) {
3476
+ const raw = await redis.get(k);
3477
+ if (!raw) continue;
2584
3478
  try {
2585
- const prev = JSON.parse(existing) as { recall_count?: number };
2586
- recallCount = (prev.recall_count ?? 0); // preserve existing recall count
2587
- isUpdate = true;
2588
- } catch { /* ignore parse error */ }
3479
+ const l = JSON.parse(raw) as { outcome?: string; topic?: string; severity?: string };
3480
+ if (l.outcome !== 'success') continue;
3481
+ const lWords = (l.topic ?? '').split(/[:\-_]/).filter(w => w.length > 2);
3482
+ const overlap = topicWords.filter(w => lWords.includes(w)).length;
3483
+ if (overlap >= 1 && l.topic !== topic) {
3484
+ iWasWrongWarning.push(
3485
+ `⚠️ **"I Was Wrong"**: lesson \`${l.topic}\` (success, ${l.severity ?? 'major'}) might have prevented this failure.`,
3486
+ ` → Use \`recall_best_solution(topic="${l.topic}")\` before next attempt.`,
3487
+ ` → To mark it critical: \`learn_from_attempts(topic="${l.topic}", ..., severity="critical")\``,
3488
+ );
3489
+ break; // only show the most relevant match
3490
+ }
3491
+ } catch { /* skip */ }
2589
3492
  }
2590
3493
  }
2591
3494
 
2592
- const lesson = JSON.stringify({
3495
+ // ── Register dependency index for causal chain ─────────────────────────
3496
+ for (const dep of depends_on) {
3497
+ const depKey = `cachly:dep:${dep}`;
3498
+ const existing = await redis.get(depKey);
3499
+ const depTopics: string[] = existing ? JSON.parse(existing) : [];
3500
+ if (!depTopics.includes(topic)) depTopics.push(topic);
3501
+ await redis.set(depKey, JSON.stringify(depTopics));
3502
+ }
3503
+
3504
+ const lessonObj = {
2593
3505
  topic,
2594
3506
  outcome,
2595
3507
  what_worked,
@@ -2599,20 +3511,36 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2599
3511
  file_paths,
2600
3512
  commands,
2601
3513
  tags,
3514
+ depends_on,
3515
+ ...(author ? { author } : {}),
2602
3516
  recall_count: recallCount,
2603
3517
  ts,
2604
- version: 2,
2605
- });
3518
+ verified_at: outcome === 'success' || outcome === 'partial' ? ts : undefined,
3519
+ confidence: 1.0,
3520
+ audit_trail: auditTrail,
3521
+ version: 3,
3522
+ };
3523
+ const lesson = JSON.stringify(lessonObj);
2606
3524
 
2607
- // Always append to the history list
3525
+ // Always append to the history list (audit log)
2608
3526
  const listKey = `cachly:lessons:${topic}`;
2609
3527
  await redis.rpush(listKey, lesson);
2610
3528
 
2611
- // Update best key only for success/partial (not for failures)
3529
+ // Update best key for success/partial; for failure only update if no success exists
2612
3530
  if (outcome === 'success' || outcome === 'partial') {
2613
3531
  await redis.set(`cachly:lesson:best:${topic}`, lesson);
3532
+ } else if (!existingRaw) {
3533
+ await redis.set(`cachly:lesson:best:${topic}`, lesson);
2614
3534
  }
2615
3535
 
3536
+ // Track in decision log for session replay
3537
+ try {
3538
+ const dlKey = 'cachly:session:decision-log';
3539
+ const dlEntry = JSON.stringify({ ts, topic, outcome, what_worked: what_worked.slice(0, 120) });
3540
+ await redis.rpush(dlKey, dlEntry);
3541
+ await redis.ltrim(dlKey, -50, -1);
3542
+ } catch { /* non-critical */ }
3543
+
2616
3544
  const emoji = outcome === 'success' ? '✅' : outcome === 'partial' ? '⚠️' : '❌';
2617
3545
  const sevEmoji = severity === 'critical' ? '🔴' : severity === 'major' ? '🟡' : '🟢';
2618
3546
  const action = isUpdate ? 'updated' : 'stored';
@@ -2627,8 +3555,13 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2627
3555
  tags.length > 0 ? `**Tags:** ${tags.map(t => `#${t}`).join(' ')}` : '',
2628
3556
  ``,
2629
3557
  isUpdate
2630
- ? `♻️ Updated existing lesson (preserved recall count: ${recallCount})`
3558
+ ? `♻️ Updated (recall count: ${recallCount} · audit entries: ${auditTrail.length})`
2631
3559
  : `💡 Recall later with \`recall_best_solution(topic="${topic}")\``,
3560
+ depends_on.length > 0
3561
+ ? `🔗 Depends on: ${depends_on.map(d => `\`${d}\``).join(', ')} → trace with \`trace_dependency\``
3562
+ : '',
3563
+ ...templateWarnings,
3564
+ ...iWasWrongWarning,
2632
3565
  ].filter(l => l !== '').join('\n');
2633
3566
  }
2634
3567
 
@@ -2639,43 +3572,47 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2639
3572
  // Try exact best-solution key first
2640
3573
  const best = await redis.get(`cachly:lesson:best:${topic}`);
2641
3574
  if (best) {
2642
- let lesson: {
3575
+ const lesson = JSON.parse(best) as {
2643
3576
  topic: string; outcome: string; what_worked: string; what_failed?: string;
2644
- context?: string; ts: string; severity?: string; file_paths?: string[];
2645
- commands?: string[]; tags?: string[]; recall_count?: number;
3577
+ context?: string; ts: string; verified_at?: string; severity?: string;
3578
+ file_paths?: string[]; commands?: string[]; tags?: string[];
3579
+ recall_count?: number; audit_trail?: unknown[];
2646
3580
  };
2647
- try { lesson = JSON.parse(best); } catch {
2648
- return `⚠️ Lesson for \`${topic}\` could not be read (corrupted data). Use \`learn_from_attempts\` to overwrite it.`;
2649
- }
2650
- // Increment recall_count
2651
- const updatedLesson = { ...lesson, recall_count: (lesson.recall_count ?? 0) + 1 };
2652
- await redis.set(`cachly:lesson:best:${topic}`, JSON.stringify(updatedLesson));
2653
3581
 
2654
- // Fire-and-forget: track Magic Moment in Plausible + API engagement counter
2655
- const isFirstRecall = (lesson.recall_count ?? 0) === 0;
2656
- const recallEvent = isFirstRecall ? 'First Brain Recall' : 'Brain Recall Hit';
2657
- fetch('https://analytics.cachly.dev/api/event', {
2658
- method: 'POST',
2659
- headers: { 'Content-Type': 'application/json', 'User-Agent': 'cachly-mcp/recall' },
2660
- body: JSON.stringify({
2661
- domain: 'cachly.dev', name: recallEvent,
2662
- url: 'https://cachly.dev/mcp/recall', props: { topic },
2663
- }),
2664
- }).catch(() => { /* non-critical */ });
2665
- if (JWT) {
2666
- fetch(`${API_URL}/api/v1/telemetry/mcp`, {
2667
- method: 'POST',
2668
- headers: { 'Content-Type': 'application/json' },
2669
- body: JSON.stringify({ event: isFirstRecall ? 'first_recall' : 'recall', api_key: JWT }),
2670
- }).catch(() => { /* non-critical */ });
2671
- }
3582
+ // ── Confidence decay check ───────────────────────────────────────────
3583
+ const confidence = calculateConfidence(lesson);
3584
+ const ref = lesson.verified_at ?? lesson.ts;
3585
+ const ageDays = (Date.now() - new Date(ref).getTime()) / 86400000;
3586
+ const badge = confidenceBadge(confidence, ageDays);
3587
+
3588
+ // Recall resets verified_at (confidence clock restart)
3589
+ const updatedLesson = {
3590
+ ...lesson,
3591
+ recall_count: (lesson.recall_count ?? 0) + 1,
3592
+ verified_at: new Date().toISOString(),
3593
+ confidence: 1.0,
3594
+ };
3595
+ await redis.set(`cachly:lesson:best:${topic}`, JSON.stringify(updatedLesson));
2672
3596
 
2673
3597
  const sevEmoji = lesson.severity === 'critical' ? '🔴' : lesson.severity === 'major' ? '🟡' : lesson.severity ? '🟢' : '';
2674
- const sessionAgeMs = Date.now() - new Date(lesson.ts).getTime();
2675
- const sessionAgeDays = Math.round(sessionAgeMs / 86_400_000);
2676
- const ageLabel = sessionAgeDays === 0 ? 'today' : sessionAgeDays === 1 ? 'yesterday' : `${sessionAgeDays} days ago`;
3598
+ const auditSummary = (lesson.audit_trail ?? []).length > 1
3599
+ ? `_Audit: ${(lesson.audit_trail ?? []).length} changes · stored ${new Date(lesson.ts).toLocaleDateString('de-DE')}_`
3600
+ : '';
3601
+
3602
+ // "Remember when..." — emotional header for lessons > 60 days old
3603
+ const ageFromStoreDays = (Date.now() - new Date(lesson.ts).getTime()) / 86400000;
3604
+ const rememberWhen = ageFromStoreDays > 60
3605
+ ? `💭 _Remember when you solved this ${Math.round(ageFromStoreDays / 30)} months ago? Still works._`
3606
+ : '';
3607
+
3608
+ // "Never Google This Again" — suggest pinning after 3rd recall
3609
+ const suggestPin = updatedLesson.recall_count === 3 && !(lesson as { pinned?: boolean }).pinned
3610
+ ? `📌 **You've looked this up 3 times.** Consider pinning it for instant access: add \`pinned: true\` via \`learn_from_attempts\` to always surface it first.`
3611
+ : '';
3612
+
2677
3613
  return [
2678
- `✅ **Best solution for \`${topic}\`** ${sevEmoji}${lesson.severity ? ` (${lesson.severity})` : ''} · recalled ${updatedLesson.recall_count}× · stored ${ageLabel}`,
3614
+ rememberWhen,
3615
+ `${badge} **Best solution for \`${topic}\`** ${sevEmoji}${lesson.severity ? ` (${lesson.severity})` : ''} · recalled ${updatedLesson.recall_count}×`,
2679
3616
  ``,
2680
3617
  `**What worked:** ${lesson.what_worked}`,
2681
3618
  lesson.what_failed ? `**What failed (avoid this):** ${lesson.what_failed}` : '',
@@ -2683,8 +3620,8 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2683
3620
  (lesson.file_paths ?? []).length > 0 ? `**Files:** ${(lesson.file_paths ?? []).map((f: string) => `\`${f}\``).join(', ')}` : '',
2684
3621
  (lesson.commands ?? []).length > 0 ? `**Commands:** ${(lesson.commands ?? []).map((c: string) => `\`${c}\``).join(', ')}` : '',
2685
3622
  (lesson.tags ?? []).length > 0 ? `**Tags:** ${(lesson.tags ?? []).map((t: string) => `#${t}`).join(' ')}` : '',
2686
- ``,
2687
- `> 🧠 *Your AI remembered this from ${ageLabel} — persistent memory by [cachly.dev](https://cachly.dev)*`,
3623
+ auditSummary,
3624
+ suggestPin,
2688
3625
  ].filter(l => l !== '').join('\n');
2689
3626
  }
2690
3627
 
@@ -2703,7 +3640,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2703
3640
  const histKey = `cachly:lessons:${topic}`;
2704
3641
  const all = await redis.lrange(histKey, -3, -1);
2705
3642
  if (all.length > 0) {
2706
- const parsed = all.flatMap(e => { try { return [JSON.parse(e) as { outcome: string; what_worked: string; ts: string }]; } catch { return []; } });
3643
+ const parsed = all.map(e => JSON.parse(e) as { outcome: string; what_worked: string; ts: string });
2707
3644
  const lines = parsed.map(p => `- ${p.outcome === 'success' ? '✅' : '❌'} ${p.what_worked.slice(0, 120)} (${new Date(p.ts).toLocaleDateString('de-DE')})`);
2708
3645
  return `⚠️ No successful solution for \`${topic}\` yet. Last attempts:\n\n${lines.join('\n')}`;
2709
3646
  }
@@ -2715,70 +3652,12 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2715
3652
  for (const k of matching.slice(0, 5)) {
2716
3653
  const raw = await redis.get(k);
2717
3654
  if (!raw) continue;
2718
- let lesson: { topic: string; what_worked: string; context?: string; ts: string };
2719
- try { lesson = JSON.parse(raw); } catch { continue; }
3655
+ const lesson = JSON.parse(raw) as { topic: string; what_worked: string; context?: string; ts: string };
2720
3656
  results.push(`**\`${lesson.topic}\`** — ${lesson.what_worked.slice(0, 200)}`);
2721
3657
  }
2722
3658
  return `🔍 **Partial matches for \`${topic}\`:**\n\n${results.join('\n\n')}`;
2723
3659
  }
2724
3660
 
2725
- case 'list_lessons': {
2726
- const { instance_id, filter = '' } = args as { instance_id: string; filter?: string };
2727
- const redis = await getConnection(instance_id);
2728
- const keys: string[] = [];
2729
- const stream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
2730
- await new Promise<void>((resolve, reject) => {
2731
- stream.on('data', (batch: string[]) => keys.push(...batch));
2732
- stream.on('end', resolve);
2733
- stream.on('error', reject);
2734
- });
2735
- if (keys.length === 0) return `📭 No lessons stored yet. Use \`learn_from_attempts\` after solving any problem.`;
2736
-
2737
- const values = await redis.mget(...keys);
2738
- type LessonMeta = { topic: string; ts: string; severity?: string; recall_count?: number; outcome?: string };
2739
- const lessons: LessonMeta[] = values.flatMap(raw => {
2740
- if (!raw) return [];
2741
- try { return [JSON.parse(raw) as LessonMeta]; } catch { return []; }
2742
- });
2743
-
2744
- const filtered = filter
2745
- ? lessons.filter(l => l.topic.toLowerCase().includes(filter.toLowerCase()))
2746
- : lessons;
2747
-
2748
- filtered.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
2749
-
2750
- if (filtered.length === 0) return `📭 No lessons matching \`${filter}\`.`;
2751
-
2752
- const sevEmoji = (s?: string) => s === 'critical' ? '🔴' : s === 'major' ? '🟡' : '🟢';
2753
- const lines = [
2754
- `🧠 **${filtered.length} lesson${filtered.length === 1 ? '' : 's'}${filter ? ` matching "${filter}"` : ''} in Brain:**`,
2755
- '',
2756
- ...filtered.slice(0, 30).map(l => {
2757
- const age = Math.round((Date.now() - new Date(l.ts).getTime()) / 86400000);
2758
- const ageStr = age === 0 ? 'today' : age === 1 ? 'yesterday' : `${age}d ago`;
2759
- const recalls = l.recall_count ? ` · recalled ${l.recall_count}×` : '';
2760
- return `${sevEmoji(l.severity)} \`${l.topic}\` — ${ageStr}${recalls}`;
2761
- }),
2762
- filtered.length > 30 ? `\n_…and ${filtered.length - 30} more. Use filter to narrow down._` : '',
2763
- ].filter(l => l !== '');
2764
- return lines.join('\n');
2765
- }
2766
-
2767
- case 'forget_lesson': {
2768
- const { instance_id, topic } = args as { instance_id: string; topic: string };
2769
- const redis = await getConnection(instance_id);
2770
- const bestKey = `cachly:lesson:best:${topic}`;
2771
- const histKey = `cachly:lessons:${topic}`;
2772
- const [delBest, delHist] = await Promise.all([
2773
- redis.del(bestKey),
2774
- redis.del(histKey),
2775
- ]);
2776
- if (delBest === 0 && delHist === 0) {
2777
- return `📭 No lesson found for \`${topic}\` — nothing to delete.`;
2778
- }
2779
- return `🗑️ Lesson for \`${topic}\` deleted (best-solution: ${delBest > 0 ? 'removed' : 'not found'}, history: ${delHist > 0 ? 'removed' : 'not found'}). You can now store a corrected lesson with \`learn_from_attempts\`.`;
2780
- }
2781
-
2782
3661
  case 'smart_recall': {
2783
3662
  const {
2784
3663
  instance_id,
@@ -2893,32 +3772,18 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2893
3772
 
2894
3773
  // ── get_api_status ────────────────────────────────────────────────────────
2895
3774
  case 'get_api_status': {
2896
- // Check API health and auth service in parallel
2897
- const [healthResult, authServiceResult] = await Promise.allSettled([
2898
- fetch(`${API_URL}/health`, { signal: AbortSignal.timeout(5000) }),
2899
- fetch(`${AUTH_URL}/.well-known/openid-configuration`, { signal: AbortSignal.timeout(5000) }),
2900
- ]);
2901
-
3775
+ // Check health
2902
3776
  let healthStatus = 'unknown';
2903
- if (healthResult.status === 'fulfilled') {
2904
- const healthRes = healthResult.value;
3777
+ try {
3778
+ const healthRes = await fetch(`${API_URL}/health`);
2905
3779
  if (healthRes.ok) {
2906
- const body = await healthRes.json().catch(() => ({})) as { status?: string; db?: string };
2907
- healthStatus = `✅ ${body.status ?? 'ok'}${body.db ? ` (db: ${body.db})` : ''}`;
3780
+ const body = await healthRes.json() as { status?: string; db?: string };
3781
+ healthStatus = `${body.status ?? 'ok'} (db: ${body.db ?? '?'})`;
2908
3782
  } else {
2909
- healthStatus = `❌ HTTP ${healthRes.status}`;
3783
+ healthStatus = `HTTP ${healthRes.status}`;
2910
3784
  }
2911
- } else {
2912
- healthStatus = `❌ unreachable: ${healthResult.reason?.message ?? 'timeout'}`;
2913
- }
2914
-
2915
- let authSvcStatus = 'unknown';
2916
- if (authServiceResult.status === 'fulfilled') {
2917
- authSvcStatus = authServiceResult.value.ok
2918
- ? `✅ reachable (${AUTH_URL})`
2919
- : `❌ HTTP ${authServiceResult.value.status} — auth service may be down`;
2920
- } else {
2921
- authSvcStatus = `❌ unreachable: ${authServiceResult.reason?.message ?? 'timeout'} — auth service may be down`;
3785
+ } catch (e) {
3786
+ healthStatus = `unreachable: ${(e as Error).message}`;
2922
3787
  }
2923
3788
 
2924
3789
  // Check JWT / auth
@@ -2926,18 +3791,16 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2926
3791
  return [
2927
3792
  `📡 **cachly API Status**`,
2928
3793
  ``,
2929
- ` 🌐 API: ${API_URL}`,
2930
- ` 💓 API Health: ${healthStatus}`,
2931
- ` 🔐 Auth Service: ${authSvcStatus}`,
2932
- ` 🔑 JWT: ❌ CACHLY_JWT not set`,
3794
+ ` 🌐 API: ${API_URL}`,
3795
+ ` 💓 Health: ${healthStatus}`,
3796
+ ` 🔑 Auth: ❌ CACHLY_JWT not set`,
2933
3797
  ``,
2934
- `💡 Get your API token at https://cachly.dev/settings`,
3798
+ `💡 Get your API token at https://cachly.dev/instances → Settings → API Token`,
2935
3799
  ].join('\n');
2936
3800
  }
2937
3801
 
2938
3802
  // Decode JWT claims (inspection only, no verification)
2939
3803
  let authInfo = '❌ invalid JWT format';
2940
- let isExpired = false;
2941
3804
  try {
2942
3805
  const parts = JWT.split('.');
2943
3806
  if (parts.length === 3) {
@@ -2948,44 +3811,33 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2948
3811
  const iss = payload.iss ?? '(unknown)';
2949
3812
  const provider = iss.includes('keycloak') ? 'Keycloak' : 'OIDC';
2950
3813
  const expTs = payload.exp ? new Date(payload.exp * 1000) : null;
2951
- isExpired = expTs ? expTs < new Date() : false;
3814
+ const expired = expTs ? expTs < new Date() : false;
2952
3815
  authInfo = [
2953
- `${isExpired ? '⚠️ ' : '✅'} JWT decoded`,
3816
+ `✅ JWT decoded`,
2954
3817
  ` Sub (user ID): ${sub}`,
2955
3818
  ` Provider: ${provider}`,
2956
3819
  ` Issuer: ${iss}`,
2957
- ` Expires: ${expTs ? expTs.toISOString() : 'never'} ${isExpired ? '⚠️ EXPIRED' : '✅'}`,
3820
+ ` Expires: ${expTs ? expTs.toISOString() : 'never'} ${expired ? '⚠️ EXPIRED – get a new token!' : '✅'}`,
2958
3821
  ].join('\n');
2959
3822
  }
2960
3823
  } catch {
2961
3824
  authInfo = '❌ JWT decode failed – check CACHLY_JWT format';
2962
3825
  }
2963
3826
 
2964
- const lines = [
3827
+ return [
2965
3828
  `📡 **cachly API Status**`,
2966
3829
  ``,
2967
- ` 🌐 API: ${API_URL}`,
2968
- ` 💓 API Health: ${healthStatus}`,
2969
- ` 🔐 Auth Service: ${authSvcStatus}`,
3830
+ ` 🌐 API: ${API_URL}`,
3831
+ ` 💓 Health: ${healthStatus}`,
2970
3832
  ``,
2971
- `🔑 **Auth (JWT):**`,
3833
+ `🔑 **Auth:**`,
2972
3834
  authInfo,
2973
- ];
2974
-
2975
- if (isExpired) {
2976
- lines.push(``, `⚠️ Your token is expired — get a new one at https://cachly.dev/settings`);
2977
- }
2978
- if (authSvcStatus.startsWith('❌')) {
2979
- lines.push(``, `⚠️ Auth service appears down. Registration and login on cachly.dev may be unavailable.`);
2980
- lines.push(` Contact support@cachly.dev if this persists.`);
2981
- }
2982
-
2983
- return lines.join('\n');
3835
+ ].join('\n');
2984
3836
  }
2985
3837
 
2986
3838
  // ── session_start ─────────────────────────────────────────────────────────
2987
3839
  case 'session_start': {
2988
- const { instance_id, focus = '' } = args as { instance_id: string; focus?: string };
3840
+ const { instance_id, focus = '', author = '', provider = '', workspace_path = '' } = args as { instance_id: string; focus?: string; author?: string; provider?: string; workspace_path?: string };
2989
3841
  const redis = await getConnection(instance_id);
2990
3842
 
2991
3843
  // 1. Scan all best-solution lessons
@@ -3000,16 +3852,14 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
3000
3852
  // 2. Fetch all lesson values for recency sorting + focus matching
3001
3853
  type Lesson = {
3002
3854
  topic: string; outcome: string; what_worked: string; what_failed?: string;
3003
- ts: string; severity?: string; recall_count?: number; tags?: string[];
3855
+ ts: string; verified_at?: string; severity?: string; recall_count?: number;
3856
+ tags?: string[]; confidence?: number; audit_trail?: unknown[];
3004
3857
  };
3005
3858
  const lessons: Lesson[] = [];
3006
- if (lessonKeys.length > 0) {
3007
- // Batch fetch all lesson values in one round-trip instead of N sequential gets
3008
- const values = await redis.mget(...lessonKeys);
3009
- for (const raw of values) {
3010
- if (!raw) continue;
3011
- try { lessons.push(JSON.parse(raw) as Lesson); } catch { /* skip corrupt */ }
3012
- }
3859
+ for (const k of lessonKeys) {
3860
+ const raw = await redis.get(k);
3861
+ if (!raw) continue;
3862
+ try { lessons.push(JSON.parse(raw) as Lesson); } catch { /* skip corrupt */ }
3013
3863
  }
3014
3864
  lessons.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
3015
3865
 
@@ -3042,14 +3892,138 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
3042
3892
  )
3043
3893
  : [];
3044
3894
 
3045
- // 6. Save session start marker
3895
+ // 6. Streak tracking
3896
+ let streakDays = 0;
3897
+ let streakRecord = 0;
3898
+ let streakMessage = '';
3899
+ try {
3900
+ const streakRaw = await redis.get('cachly:streak:current');
3901
+ const streak = streakRaw ? JSON.parse(streakRaw) as { days: number; last_date: string; record: number } : null;
3902
+ const today = new Date().toISOString().slice(0, 10);
3903
+ if (streak) {
3904
+ const lastDate = streak.last_date;
3905
+ const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
3906
+ if (lastDate === today) {
3907
+ // Already counted today
3908
+ streakDays = streak.days;
3909
+ streakRecord = streak.record;
3910
+ } else if (lastDate === yesterday) {
3911
+ // Continuing streak
3912
+ streakDays = streak.days + 1;
3913
+ streakRecord = Math.max(streakDays, streak.record);
3914
+ await redis.set('cachly:streak:current', JSON.stringify({ days: streakDays, last_date: today, record: streakRecord }));
3915
+ } else {
3916
+ // Streak broken
3917
+ streakDays = 1;
3918
+ streakRecord = streak.record;
3919
+ await redis.set('cachly:streak:current', JSON.stringify({ days: 1, last_date: today, record: streakRecord }));
3920
+ }
3921
+ } else {
3922
+ // First session ever
3923
+ streakDays = 1;
3924
+ streakRecord = 1;
3925
+ await redis.set('cachly:streak:current', JSON.stringify({ days: 1, last_date: today, record: 1 }));
3926
+ }
3927
+ if (streakDays >= 7) streakMessage = `🔥 **${streakDays}-day streak!** ${streakDays === streakRecord ? ' New record!' : `Best: ${streakRecord}d`}`;
3928
+ else if (streakDays > 1) streakMessage = `🔥 ${streakDays}-day streak`;
3929
+ } catch { /* non-critical */ }
3930
+
3931
+ // 7. Save session start marker
3046
3932
  await redis.set('cachly:session:current', JSON.stringify({
3047
3933
  started: new Date().toISOString(),
3048
3934
  focus,
3935
+ provider,
3049
3936
  }), 'EX', 86400); // auto-expire after 24h if session_end never called
3050
3937
 
3051
3938
  // ── Build briefing ──────────────────────────────────────────────────────
3052
- const lines: string[] = ['🧠 **Session Briefing**', ''];
3939
+ const providerLabel = provider ? ` · ${provider}` : '';
3940
+ const lines: string[] = [`🧠 **Session Briefing**${providerLabel}`, ''];
3941
+ if (streakMessage) lines.push(streakMessage, '');
3942
+
3943
+ // Handoff from previous window (if any)
3944
+ const handoffRaw = await redis.get('cachly:session:handoff');
3945
+ if (handoffRaw) {
3946
+ try {
3947
+ const handoff = JSON.parse(handoffRaw) as {
3948
+ ts: string; completed_tasks: string[]; remaining_tasks: string[];
3949
+ files_changed?: { path: string; status: string; description?: string }[];
3950
+ instructions?: string; context_summary?: string; blocked_on?: string;
3951
+ };
3952
+ const ago = Math.round((Date.now() - new Date(handoff.ts).getTime()) / 60000);
3953
+ const agoStr = ago < 60 ? `${ago}m ago` : ago < 1440 ? `${Math.round(ago / 60)}h ago` : `${Math.round(ago / 1440)}d ago`;
3954
+
3955
+ lines.push(`🤝 **Handoff from previous window** (${agoStr}):`);
3956
+ if (handoff.context_summary) lines.push(` ${handoff.context_summary}`);
3957
+ if (handoff.remaining_tasks.length > 0) {
3958
+ lines.push(` ⏳ **Remaining tasks:**`);
3959
+ for (const t of handoff.remaining_tasks) lines.push(` - ${t}`);
3960
+ }
3961
+ if (handoff.completed_tasks.length > 0) {
3962
+ lines.push(` ✅ **Already done:** ${handoff.completed_tasks.join(', ')}`);
3963
+ }
3964
+ const brokenFiles = (handoff.files_changed ?? []).filter(f => f.status === 'broken' || f.status === 'partial');
3965
+ if (brokenFiles.length > 0) {
3966
+ lines.push(` ⚠️ **Needs fix:** ${brokenFiles.map(f => `\`${f.path}\` (${f.status}${f.description ? ': ' + f.description : ''})`).join(', ')}`);
3967
+ }
3968
+ if (handoff.blocked_on) lines.push(` 🚫 **Blocked on:** ${handoff.blocked_on}`);
3969
+ if (handoff.instructions) lines.push(` 📝 **Instructions:** ${handoff.instructions}`);
3970
+ lines.push('');
3971
+ } catch { /* ignore corrupt handoff */ }
3972
+ }
3973
+
3974
+ // ── Last checkpoint (session_ping) — shown when no session_end found ────
3975
+ const checkpointRaw = await redis.get('cachly:session:checkpoint');
3976
+ if (checkpointRaw) {
3977
+ try {
3978
+ const cp = JSON.parse(checkpointRaw) as {
3979
+ ts: string; task: string; files_touched: string[]; next_step?: string; provider?: string;
3980
+ };
3981
+ // Only show checkpoint if it's more recent than last session_end
3982
+ const cpTime = new Date(cp.ts).getTime();
3983
+ const lastSessionTime = lastSession ? new Date(lastSession.ts).getTime() : 0;
3984
+ if (cpTime > lastSessionTime) {
3985
+ const ago = Math.round((Date.now() - cpTime) / 60000);
3986
+ const agoStr = ago < 60 ? `${ago}m ago` : ago < 1440 ? `${Math.round(ago / 60)}h ago` : `${Math.round(ago / 1440)}d ago`;
3987
+ const providerStr = cp.provider ? ` [${cp.provider}]` : '';
3988
+ lines.push(`📌 **Last checkpoint**${providerStr} (${agoStr}): ${cp.task}`);
3989
+ if (cp.files_touched.length > 0) {
3990
+ lines.push(` Files: ${cp.files_touched.slice(0, 5).map(f => `\`${f}\``).join(', ')}`);
3991
+ }
3992
+ if (cp.next_step) lines.push(` 📍 Next step was: ${cp.next_step}`);
3993
+ if (!lastSession || cpTime - lastSessionTime > 300_000) {
3994
+ lines.push(` ⚠️ No \`session_end\` found — reconstructed from last checkpoint`);
3995
+ }
3996
+ lines.push('');
3997
+ }
3998
+ } catch { /* ignore */ }
3999
+ }
4000
+
4001
+ // ── Git reconstruction — when no session_end + workspace_path given ─────
4002
+ if (workspace_path && !lastSession) {
4003
+ try {
4004
+ const { execSync } = await import('node:child_process');
4005
+ const gitLog = execSync(
4006
+ `git -C "${workspace_path}" log --oneline --format="%h %s" -15 2>/dev/null`,
4007
+ { encoding: 'utf-8', timeout: 5000 },
4008
+ ).trim();
4009
+ const gitDiff = execSync(
4010
+ `git -C "${workspace_path}" diff --stat HEAD~3 2>/dev/null || git -C "${workspace_path}" diff --stat 2>/dev/null`,
4011
+ { encoding: 'utf-8', timeout: 5000 },
4012
+ ).trim();
4013
+ if (gitLog) {
4014
+ lines.push(`🔍 **Git reconstruction** (no session_end found — reconstructed from git):`);
4015
+ for (const l of gitLog.split('\n').slice(0, 8)) lines.push(` ${l}`);
4016
+ if (gitDiff) {
4017
+ const diffLines = gitDiff.split('\n').filter(l => l.includes('|') || l.includes('changed'));
4018
+ if (diffLines.length > 0) {
4019
+ lines.push(` **Recent changes:**`);
4020
+ for (const dl of diffLines.slice(0, 5)) lines.push(` ${dl.trim()}`);
4021
+ }
4022
+ }
4023
+ lines.push('');
4024
+ }
4025
+ } catch { /* git not available or no repo — silent */ }
4026
+ }
3053
4027
 
3054
4028
  // Last session
3055
4029
  if (lastSession) {
@@ -3063,26 +4037,8 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
3063
4037
  lines.push('');
3064
4038
  }
3065
4039
 
3066
- // Brain health + first-time onboarding hint
3067
- if (lessons.length === 0 && !lastSession) {
3068
- lines.push(`🆕 **Brain is empty — first session!**`);
3069
- lines.push(`After solving anything, call \`learn_from_attempts\` to store the lesson.`);
3070
- lines.push(`After this session, call \`session_end\` to save a summary.`);
3071
- lines.push('');
3072
- } else {
3073
- const totalRecalls = lessons.reduce((s, l) => s + (l.recall_count ?? 0), 0);
3074
- const savedMin = totalRecalls * 15;
3075
- const savedStr = savedMin >= 60
3076
- ? `~${(savedMin / 60).toFixed(1)}h saved`
3077
- : savedMin > 0 ? `~${savedMin}min saved` : '';
3078
- const statsLine = [
3079
- `${lessons.length} lessons`,
3080
- ctxCount > 0 ? `${ctxCount} context entries` : '',
3081
- totalRecalls > 0 ? `${totalRecalls} recalls` : '',
3082
- savedStr,
3083
- ].filter(Boolean).join(' · ');
3084
- lines.push(`📊 **Brain:** ${statsLine}`, '');
3085
- }
4040
+ // Brain health
4041
+ lines.push(`📊 **Brain:** ${lessons.length} lessons · ${ctxCount} context entries`, '');
3086
4042
 
3087
4043
  // Focus-relevant lessons
3088
4044
  if (focusLessons.length > 0) {
@@ -3119,6 +4075,136 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
3119
4075
  lines.push('');
3120
4076
  }
3121
4077
 
4078
+ // ── Stale / low-confidence lessons (confidence decay) ─────────────────
4079
+ const staleSuccessLessons = lessons.filter(l => {
4080
+ if (l.outcome !== 'success' && l.outcome !== 'partial') return false;
4081
+ return calculateConfidence(l) < CONFIDENCE_WARN_VALUE;
4082
+ });
4083
+ if (staleSuccessLessons.length > 0) {
4084
+ lines.push(`🔴 **Stale lessons** (not recalled in >${CONFIDENCE_WARN_DAYS}d — verify before applying):`);
4085
+ for (const l of staleSuccessLessons.slice(0, 4)) {
4086
+ const conf = calculateConfidence(l);
4087
+ const ageDays = Math.round((Date.now() - new Date(l.verified_at ?? l.ts).getTime()) / 86400000);
4088
+ const flag = conf < CONFIDENCE_STALE_VALUE ? '🔴' : '⚠️';
4089
+ lines.push(` ${flag} \`${l.topic}\` — ${ageDays}d stale, ${(conf * 100).toFixed(0)}% confidence`);
4090
+ }
4091
+ lines.push(` _Run \`recall_best_solution\` on these to reset their confidence clock._`);
4092
+ lines.push('');
4093
+ }
4094
+
4095
+ // ── Session Replay: show last session's decision log ──────────────────
4096
+ const lastSessionAny = lastSession as unknown as { decision_log?: Array<{ topic: string; outcome: string; what_worked: string }> } | null;
4097
+ if (lastSessionAny?.decision_log?.length) {
4098
+ const dl = lastSessionAny.decision_log;
4099
+ const successes = dl.filter(d => d.outcome === 'success');
4100
+ const failures = dl.filter(d => d.outcome === 'failure');
4101
+ lines.push(`🎬 **Last session decisions** (${dl.length} lessons stored):`);
4102
+ if (successes.length > 0) lines.push(` ✅ Worked: ${successes.slice(0, 3).map(d => `\`${d.topic}\``).join(', ')}`);
4103
+ if (failures.length > 0) lines.push(` ❌ Failed: ${failures.slice(0, 3).map(d => `\`${d.topic}\``).join(', ')}`);
4104
+ lines.push('');
4105
+ }
4106
+
4107
+ // ── 🔮 Predictive Pre-Warning — intent-based danger detection ────────────
4108
+ // Fires BEFORE work starts. If focus area has known failure patterns → warn loudly.
4109
+ if (focusTerms.length > 0) {
4110
+ type LessonAny = typeof lessons[0] & { author?: string; tags?: string[] };
4111
+ const dangerLessons = (lessons as LessonAny[]).filter(l => {
4112
+ if (l.outcome === 'success') return false;
4113
+ const topicCategory = l.topic.split(':')[0];
4114
+ return focusTerms.some(term =>
4115
+ l.topic.toLowerCase().includes(term) ||
4116
+ topicCategory === term ||
4117
+ (l.tags ?? []).some((t: string) => t.toLowerCase() === term),
4118
+ );
4119
+ });
4120
+ if (dangerLessons.length >= 1) {
4121
+ // Insert warning block right after the title line (index 1 = blank line after title)
4122
+ const warning = [
4123
+ `🚨 **PRE-WARNING** — Read this BEFORE starting:`,
4124
+ ` Known pitfalls for **"${focus}"** (${dangerLessons.length} past failure${dangerLessons.length > 1 ? 's' : ''}):`,
4125
+ ...dangerLessons.slice(0, 3).map(l => ` ❌ \`${l.topic}\` — ${(l.what_failed ?? l.what_worked).slice(0, 80)}`),
4126
+ '',
4127
+ ];
4128
+ lines.splice(2, 0, ...warning); // after '🧠 **Session Briefing**' + empty line
4129
+ }
4130
+ }
4131
+
4132
+ // ── 👥 Team Telepathy — what teammates learned this week ─────────────────
4133
+ if (author) {
4134
+ type LessonAny = typeof lessons[0] & { author?: string };
4135
+ const oneWeekAgo = Date.now() - 7 * 86_400_000;
4136
+ const teamLessons = (lessons as LessonAny[]).filter(l =>
4137
+ l.author && l.author !== author && new Date(l.ts).getTime() > oneWeekAgo,
4138
+ );
4139
+ if (teamLessons.length > 0) {
4140
+ // Group by author
4141
+ const byAuthor = new Map<string, LessonAny[]>();
4142
+ for (const l of teamLessons) {
4143
+ const a = l.author!;
4144
+ if (!byAuthor.has(a)) byAuthor.set(a, []);
4145
+ byAuthor.get(a)!.push(l);
4146
+ }
4147
+ lines.push(`👥 **Team this week** (${teamLessons.length} lesson${teamLessons.length > 1 ? 's' : ''} from teammates):`);
4148
+ for (const [teamAuthor, tls] of byAuthor) {
4149
+ lines.push(` 👤 **${teamAuthor}**:`);
4150
+ for (const l of tls.slice(0, 3)) {
4151
+ const emoji = l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
4152
+ lines.push(` ${emoji} \`${l.topic}\` — ${l.what_worked.slice(0, 80)}`);
4153
+ }
4154
+ if (tls.length > 3) lines.push(` … and ${tls.length - 3} more`);
4155
+ }
4156
+ lines.push('');
4157
+ }
4158
+ }
4159
+
4160
+ // ── 💎 Memory Crystal — compressed wisdom from old sessions ──────────────
4161
+ try {
4162
+ const crystalRaw = await redis.get('cachly:crystal:latest');
4163
+ if (crystalRaw) {
4164
+ const crystal = JSON.parse(crystalRaw) as {
4165
+ label: string; ts: string; session_count: number;
4166
+ top_patterns: Array<{ category: string; insight: string; count: number }>;
4167
+ };
4168
+ const crystalAge = Math.round((Date.now() - new Date(crystal.ts).getTime()) / 86_400_000);
4169
+ if (crystalAge <= 90) {
4170
+ lines.push(`💎 **Memory Crystal** (${crystal.label} · ${crystal.session_count} sessions compressed):`);
4171
+ for (const p of crystal.top_patterns.slice(0, 3)) {
4172
+ lines.push(` • **${p.category}** (${p.count}×): ${p.insight.slice(0, 90)}`);
4173
+ }
4174
+ lines.push('');
4175
+ }
4176
+ }
4177
+ } catch { /* non-critical */ }
4178
+
4179
+ // ── 🗺️ Roadmap — open items at session start ────────────────────────────
4180
+ try {
4181
+ const roadmapAll = await redis.hgetall(`cachly:roadmap:${instance_id}`);
4182
+ if (roadmapAll && Object.keys(roadmapAll).length > 0) {
4183
+ const PRIORITY_ORDER: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
4184
+ const PRIORITY_ICON: Record<string, string> = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
4185
+ const openStatuses = new Set(['planned', 'in-progress', 'blocked']);
4186
+ const allItems = Object.values(roadmapAll).map(v => JSON.parse(v as string) as Record<string, unknown>);
4187
+ const openItems = allItems
4188
+ .filter(i => openStatuses.has(i.status as string))
4189
+ .sort((a, b) => {
4190
+ if (a.status === 'in-progress' && b.status !== 'in-progress') return -1;
4191
+ if (b.status === 'in-progress' && a.status !== 'in-progress') return 1;
4192
+ return (PRIORITY_ORDER[a.priority as string] ?? 99) - (PRIORITY_ORDER[b.priority as string] ?? 99);
4193
+ });
4194
+ const doneCount = allItems.filter(i => i.status === 'done').length;
4195
+ if (openItems.length > 0) {
4196
+ lines.push(`🗺️ **Roadmap** (${openItems.length} open · ${doneCount} done):`);
4197
+ for (const it of openItems.slice(0, 5)) {
4198
+ const statusIcon = it.status === 'in-progress' ? '⚡' : it.status === 'blocked' ? '🚫' : '📋';
4199
+ lines.push(` ${statusIcon} ${PRIORITY_ICON[it.priority as string] ?? '⚪'} \`${it.id}\` **${it.title}**`);
4200
+ }
4201
+ if (openItems.length > 5) lines.push(` … and ${openItems.length - 5} more`);
4202
+ lines.push(` _Use \`roadmap_next\` for the top priority item · \`roadmap_list\` for full view_`);
4203
+ lines.push('');
4204
+ }
4205
+ }
4206
+ } catch { /* non-critical */ }
4207
+
3122
4208
  return lines.join('\n');
3123
4209
  }
3124
4210
 
@@ -3129,11 +4215,13 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
3129
4215
  summary,
3130
4216
  files_changed = [],
3131
4217
  lessons_learned,
4218
+ workspace_path = '',
3132
4219
  } = args as {
3133
4220
  instance_id: string;
3134
4221
  summary: string;
3135
4222
  files_changed?: string[];
3136
4223
  lessons_learned?: number;
4224
+ workspace_path?: string;
3137
4225
  };
3138
4226
 
3139
4227
  const redis = await getConnection(instance_id);
@@ -3149,12 +4237,21 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
3149
4237
  } catch { /* ignore */ }
3150
4238
  }
3151
4239
 
4240
+ // ── Session Replay: capture decision log ─────────────────────────────
4241
+ let decisionLog: Array<{ ts: string; topic: string; outcome: string; what_worked: string }> = [];
4242
+ try {
4243
+ const dlEntries = await redis.lrange('cachly:session:decision-log', 0, -1);
4244
+ decisionLog = dlEntries.map(e => JSON.parse(e) as { ts: string; topic: string; outcome: string; what_worked: string });
4245
+ await redis.del('cachly:session:decision-log');
4246
+ } catch { /* non-critical */ }
4247
+
3152
4248
  const sessionRecord = {
3153
4249
  ts: now.toISOString(),
3154
4250
  summary,
3155
4251
  files_changed,
3156
4252
  ...(lessons_learned !== undefined ? { lessons_learned } : {}),
3157
4253
  ...(durationMin !== undefined ? { duration_min: durationMin } : {}),
4254
+ ...(decisionLog.length > 0 ? { decision_log: decisionLog } : {}),
3158
4255
  };
3159
4256
 
3160
4257
  // Save as "last session"
@@ -3168,6 +4265,127 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
3168
4265
  // Clean up current session marker
3169
4266
  await redis.del('cachly:session:current');
3170
4267
 
4268
+ // ── AUTO-LEARN from session summary (no manual call needed) ─────────────
4269
+ // Parse the summary for actionable lessons and store them automatically.
4270
+ const autoLearned: string[] = [];
4271
+ try {
4272
+ // Extract key sentences from the summary that contain action verbs
4273
+ const actionVerbs = /\b(fixed|deployed|added|removed|refactored|migrated|updated|resolved|implemented|improved|optimized|configured|created|deleted|disabled|enabled|discovered|found|learned|debugged|patched|upgraded|installed|tested|built|rewrote|moved|renamed|split|merged|extracted)\b/i;
4274
+ const sentences = summary
4275
+ .split(/[.!\n]+/)
4276
+ .map(s => s.trim())
4277
+ .filter(s => s.length > 20 && actionVerbs.test(s));
4278
+
4279
+ for (const sentence of sentences.slice(0, 6)) {
4280
+ // Build a topic slug from the first meaningful words
4281
+ const words = sentence.toLowerCase()
4282
+ .replace(/[^a-z0-9\s]/g, ' ')
4283
+ .split(/\s+/)
4284
+ .filter(w => w.length > 3 && !['that', 'this', 'with', 'from', 'have', 'been', 'were', 'they', 'then', 'when', 'also', 'into', 'will', 'would', 'could', 'should'].includes(w));
4285
+ const slug = words.slice(0, 4).join('-');
4286
+ if (!slug) continue;
4287
+ const topic = `auto:${slug}`;
4288
+ const key = `cachly:lesson:best:${topic}`;
4289
+
4290
+ // Don't overwrite existing successful lessons
4291
+ const existing = await redis.get(key);
4292
+ if (existing) {
4293
+ try {
4294
+ const ex = JSON.parse(existing) as { outcome: string };
4295
+ if (ex.outcome === 'success') continue;
4296
+ } catch { /* ignore */ }
4297
+ }
4298
+
4299
+ const lesson = {
4300
+ topic,
4301
+ outcome: 'success',
4302
+ what_worked: sentence,
4303
+ context: `Auto-learned from session summary. Full summary: ${summary.slice(0, 300)}`,
4304
+ severity: 'minor',
4305
+ ts: now.toISOString(),
4306
+ recall_count: 0,
4307
+ auto_learned: true,
4308
+ session_ts: now.toISOString(),
4309
+ version: 2,
4310
+ };
4311
+ await redis.set(key, JSON.stringify(lesson));
4312
+ // 90-day TTL for auto-learned lessons
4313
+ await redis.expire(key, 90 * 86400);
4314
+ autoLearned.push(topic);
4315
+ }
4316
+
4317
+ // Also store a lesson per changed file area if files were changed
4318
+ if (files_changed.length > 0) {
4319
+ const areas = [...new Set(files_changed.map(f => f.split('/').slice(0, 2).join('/')))].slice(0, 3);
4320
+ for (const area of areas) {
4321
+ const slug = area.replace(/[^a-z0-9]/gi, '-').toLowerCase().replace(/-+/g, '-').slice(0, 30);
4322
+ const topic = `auto:changed:${slug}`;
4323
+ const key = `cachly:lesson:best:${topic}`;
4324
+ const lesson = {
4325
+ topic,
4326
+ outcome: 'success',
4327
+ what_worked: `Files changed in ${area}: ${files_changed.filter(f => f.startsWith(area.split('/')[0])).slice(0, 5).join(', ')}`,
4328
+ context: summary.slice(0, 200),
4329
+ severity: 'minor',
4330
+ ts: now.toISOString(),
4331
+ recall_count: 0,
4332
+ auto_learned: true,
4333
+ version: 2,
4334
+ };
4335
+ await redis.set(key, JSON.stringify(lesson));
4336
+ await redis.expire(key, 90 * 86400);
4337
+ autoLearned.push(topic);
4338
+ }
4339
+ }
4340
+ } catch { /* auto-learn errors must never break session_end */ }
4341
+
4342
+ // ── 🌿 Ambient Git Learning ────────────────────────────────────────────────
4343
+ // Read git commits since session start → auto-learn each meaningful commit.
4344
+ const ambientLearned: string[] = [];
4345
+ if (workspace_path) {
4346
+ try {
4347
+ // Get the session start time (stored by session_start)
4348
+ const sessionStartTs = currentRaw
4349
+ ? (() => { try { return (JSON.parse(currentRaw) as { started?: string }).started ?? ''; } catch { return ''; } })()
4350
+ : '';
4351
+ const sinceArg = sessionStartTs ? `--since="${sessionStartTs}"` : '--since="1 hour ago"';
4352
+ const gitOut = execSync(
4353
+ `git -C "${workspace_path}" log ${sinceArg} --oneline --format="%H|||%s|||%ai"`,
4354
+ { timeout: 5000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
4355
+ ).trim();
4356
+ if (gitOut) {
4357
+ const commitActionRe = /\b(fix|add|remove|refactor|migrate|update|resolve|implement|improve|optimize|configure|create|delete|disable|enable|debug|patch|upgrade|build|rewrite|deploy|feat|chore|docs|test|perf|ci)\b/i;
4358
+ for (const line of gitOut.split('\n').slice(0, 10)) {
4359
+ const [hash, msg, dateStr] = line.split('|||');
4360
+ if (!msg || !commitActionRe.test(msg)) continue;
4361
+ const slug = msg
4362
+ .toLowerCase().replace(/^(fix|feat|chore|docs|test|ci|perf|refactor|build|revert)[:(\s]/i, '')
4363
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
4364
+ if (!slug) continue;
4365
+ const topic = `git:${slug}`;
4366
+ const key = `cachly:lesson:best:${topic}`;
4367
+ const existing = await redis.get(key);
4368
+ if (existing) continue; // don't overwrite existing
4369
+ const commitLesson = {
4370
+ topic,
4371
+ outcome: 'success' as const,
4372
+ what_worked: msg.slice(0, 200),
4373
+ context: `Auto-learned from git commit ${(hash ?? '').slice(0, 7)} at ${dateStr ?? ''} in ${workspace_path}`,
4374
+ severity: 'minor' as const,
4375
+ ts: now.toISOString(),
4376
+ recall_count: 0,
4377
+ auto_learned: true,
4378
+ source: 'ambient-git',
4379
+ version: 3,
4380
+ };
4381
+ await redis.set(key, JSON.stringify(commitLesson));
4382
+ await redis.expire(key, 60 * 86400); // 60 day TTL for git lessons
4383
+ ambientLearned.push(topic);
4384
+ }
4385
+ }
4386
+ } catch { /* git not available or not a repo — silent skip */ }
4387
+ }
4388
+
3171
4389
  const durationStr = durationMin !== undefined ? ` · ${durationMin} min` : '';
3172
4390
  return [
3173
4391
  `✅ **Session saved**${durationStr}`,
@@ -3175,11 +4393,115 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
3175
4393
  `📋 **Summary:** ${summary}`,
3176
4394
  files_changed.length > 0 ? `📁 **Files changed:** ${files_changed.map(f => `\`${f}\``).join(', ')}` : '',
3177
4395
  lessons_learned !== undefined ? `🧠 **Lessons stored:** ${lessons_learned}` : '',
4396
+ autoLearned.length > 0 ? `🤖 **Auto-learned:** ${autoLearned.length} lessons extracted from summary (${autoLearned.slice(0, 3).map(t => `\`${t}\``).join(', ')}${autoLearned.length > 3 ? '…' : ''})` : '',
4397
+ ambientLearned.length > 0 ? `🌿 **Ambient git learning:** ${ambientLearned.length} commit${ambientLearned.length > 1 ? 's' : ''} auto-learned (${ambientLearned.slice(0, 3).map(t => `\`${t}\``).join(', ')}${ambientLearned.length > 3 ? '…' : ''})` : '',
3178
4398
  ``,
3179
4399
  `💡 Next session: \`session_start(focus="...")\` to see this summary.`,
3180
4400
  ].filter(l => l !== '').join('\n');
3181
4401
  }
3182
4402
 
4403
+ // ── session_ping — lightweight checkpoint ─────────────────────────────────
4404
+ case 'session_ping': {
4405
+ const {
4406
+ instance_id,
4407
+ task,
4408
+ files_touched = [],
4409
+ next_step = '',
4410
+ provider = '',
4411
+ } = args as {
4412
+ instance_id: string;
4413
+ task: string;
4414
+ files_touched?: string[];
4415
+ next_step?: string;
4416
+ provider?: string;
4417
+ };
4418
+
4419
+ const redis = await getConnection(instance_id);
4420
+ const checkpoint = {
4421
+ ts: new Date().toISOString(),
4422
+ task,
4423
+ files_touched,
4424
+ next_step,
4425
+ provider,
4426
+ };
4427
+
4428
+ // Store as the latest checkpoint — session_start reads this when no session_end found
4429
+ await redis.set('cachly:session:checkpoint', JSON.stringify(checkpoint), 'EX', 86400 * 3); // 3-day TTL
4430
+
4431
+ // Also keep a short rolling log (last 20 checkpoints for history)
4432
+ await redis.lpush('cachly:session:checkpoint:log', JSON.stringify(checkpoint));
4433
+ await redis.ltrim('cachly:session:checkpoint:log', 0, 19);
4434
+
4435
+ const providerStr = provider ? ` [${provider}]` : '';
4436
+ const filesStr = files_touched.length > 0 ? ` · ${files_touched.length} file${files_touched.length > 1 ? 's' : ''} touched` : '';
4437
+ const nextStr = next_step ? `\n📍 **Next step:** ${next_step}` : '';
4438
+
4439
+ return [
4440
+ `📌 **Checkpoint saved**${providerStr} — ${new Date().toLocaleTimeString()}`,
4441
+ `🔨 **Working on:** ${task}${filesStr}`,
4442
+ nextStr,
4443
+ ``,
4444
+ `💡 If you switch providers, \`session_start\` will show this checkpoint automatically.`,
4445
+ ].filter(l => l !== '').join('\n');
4446
+ }
4447
+
4448
+ // ── session_handoff — cross-window continuity ─────────────────────────────
4449
+ case 'session_handoff': {
4450
+ const {
4451
+ instance_id,
4452
+ completed_tasks = [],
4453
+ remaining_tasks = [],
4454
+ files_changed = [],
4455
+ instructions = '',
4456
+ context_summary = '',
4457
+ blocked_on = '',
4458
+ } = args as {
4459
+ instance_id: string;
4460
+ completed_tasks: string[];
4461
+ remaining_tasks: string[];
4462
+ files_changed?: { path: string; status: string; description?: string }[];
4463
+ instructions?: string;
4464
+ context_summary?: string;
4465
+ blocked_on?: string;
4466
+ };
4467
+
4468
+ const redis = await getConnection(instance_id);
4469
+ const now = new Date();
4470
+
4471
+ const handoff = {
4472
+ ts: now.toISOString(),
4473
+ completed_tasks,
4474
+ remaining_tasks,
4475
+ files_changed,
4476
+ instructions,
4477
+ context_summary,
4478
+ blocked_on,
4479
+ };
4480
+
4481
+ // Store handoff — never expires until next handoff overwrites it
4482
+ await redis.set('cachly:session:handoff', JSON.stringify(handoff));
4483
+
4484
+ // Also append to history
4485
+ await redis.lpush('cachly:session:handoff:history', JSON.stringify(handoff));
4486
+ await redis.ltrim('cachly:session:handoff:history', 0, 19);
4487
+
4488
+ const totalTasks = completed_tasks.length + remaining_tasks.length;
4489
+ const pct = totalTasks > 0 ? Math.round((completed_tasks.length / totalTasks) * 100) : 0;
4490
+ const brokenFiles = files_changed.filter(f => f.status === 'broken' || f.status === 'partial');
4491
+
4492
+ return [
4493
+ `🤝 **Handoff saved** — ${completed_tasks.length}/${totalTasks} tasks done (${pct}%)`,
4494
+ ``,
4495
+ completed_tasks.length > 0 ? `✅ **Completed:**\n${completed_tasks.map(t => ` - ${t}`).join('\n')}` : '',
4496
+ remaining_tasks.length > 0 ? `\n⏳ **Remaining for next window:**\n${remaining_tasks.map(t => ` - ${t}`).join('\n')}` : '',
4497
+ brokenFiles.length > 0 ? `\n⚠️ **Needs attention:** ${brokenFiles.map(f => `\`${f.path}\` (${f.status})`).join(', ')}` : '',
4498
+ blocked_on ? `\n🚫 **Blocked on:** ${blocked_on}` : '',
4499
+ instructions ? `\n📝 **Instructions:** ${instructions}` : '',
4500
+ ``,
4501
+ `💡 The next \`session_start\` will include this handoff automatically.`,
4502
+ ].filter(l => l !== '').join('\n');
4503
+ }
4504
+
3183
4505
  // ── auto_learn_session ────────────────────────────────────────────────────
3184
4506
  case 'auto_learn_session': {
3185
4507
  const { instance_id, observations } = args as {
@@ -3205,8 +4527,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
3205
4527
  // Only overwrite if this is a success and existing is failure, or topic is new
3206
4528
  const existing = await redis.get(key);
3207
4529
  if (existing) {
3208
- let existingLesson: { outcome: string };
3209
- try { existingLesson = JSON.parse(existing); } catch { existingLesson = { outcome: 'failure' }; }
4530
+ const existingLesson = JSON.parse(existing) as { outcome: string };
3210
4531
  if (existingLesson.outcome === 'success' && obs.outcome !== 'success') {
3211
4532
  skipped.push(topic);
3212
4533
  continue;
@@ -3271,23 +4592,20 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
3271
4592
 
3272
4593
  type Lesson = { topic: string; what_worked: string; outcome: string; file_paths?: string[] };
3273
4594
  const relevant: string[] = [];
3274
- if (lessonKeys.length > 0) {
3275
- const values = await redis.mget(...lessonKeys);
3276
- for (const raw of values) {
3277
- if (!raw) continue;
3278
- let lesson: Lesson;
3279
- try { lesson = JSON.parse(raw) as Lesson; } catch { continue; }
3280
- // Match by file_paths stored in lesson OR by topic keywords matching file name
3281
- const topicWords = lesson.topic.toLowerCase().split(/[:\-_]/);
3282
- const fileMatches = changed_files.some(f => {
3283
- const fname = f.split('/').pop()?.replace(/\.[^.]+$/, '').toLowerCase() ?? '';
3284
- return topicWords.some(w => w.length > 3 && fname.includes(w))
3285
- || (lesson.file_paths ?? []).some(lf => f.includes(lf) || lf.includes(f));
3286
- });
3287
- if (fileMatches) {
3288
- const emoji = lesson.outcome === 'success' ? '✅' : '⚠️';
3289
- relevant.push(` ${emoji} \`${lesson.topic}\` — ${lesson.what_worked.slice(0, 80)}`);
3290
- }
4595
+ for (const k of lessonKeys) {
4596
+ const raw = await redis.get(k);
4597
+ if (!raw) continue;
4598
+ const lesson = JSON.parse(raw) as Lesson;
4599
+ // Match by file_paths stored in lesson OR by topic keywords matching file name
4600
+ const topicWords = lesson.topic.toLowerCase().split(/[:\-_]/);
4601
+ const fileMatches = changed_files.some(f => {
4602
+ const fname = f.split('/').pop()?.replace(/\.[^.]+$/, '').toLowerCase() ?? '';
4603
+ return topicWords.some(w => w.length > 3 && fname.includes(w))
4604
+ || (lesson.file_paths ?? []).some(lf => f.includes(lf) || lf.includes(f));
4605
+ });
4606
+ if (fileMatches) {
4607
+ const emoji = lesson.outcome === 'success' ? '✅' : '⚠️';
4608
+ relevant.push(` ${emoji} \`${lesson.topic}\` — ${lesson.what_worked.slice(0, 80)}`);
3291
4609
  }
3292
4610
  }
3293
4611
 
@@ -3328,7 +4646,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
3328
4646
  file_paths: file_paths ?? [],
3329
4647
  commands: commands ?? [],
3330
4648
  tags: [...(tags ?? []), 'team'],
3331
- ts: new Date().toISOString(),
4649
+ timestamp: new Date().toISOString(),
3332
4650
  recall_count: 0,
3333
4651
  version: 2,
3334
4652
  };
@@ -3367,12 +4685,10 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
3367
4685
  author?: string; tags?: string[];
3368
4686
  };
3369
4687
  let lessons: TeamLesson[] = [];
3370
- if (lessonKeys.length > 0) {
3371
- const values = await redis.mget(...lessonKeys);
3372
- for (const raw of values) {
3373
- if (!raw) continue;
3374
- try { lessons.push(JSON.parse(raw) as TeamLesson); } catch { /* skip */ }
3375
- }
4688
+ for (const k of lessonKeys) {
4689
+ const raw = await redis.get(k);
4690
+ if (!raw) continue;
4691
+ try { lessons.push(JSON.parse(raw) as TeamLesson); } catch { /* skip */ }
3376
4692
  }
3377
4693
 
3378
4694
  // Filter
@@ -3413,306 +4729,153 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
3413
4729
  return lines.join('\n');
3414
4730
  }
3415
4731
 
3416
- // ── brain_stats ───────────────────────────────────────────────────────────
3417
- case 'brain_stats': {
3418
- const { instance_id } = args as { instance_id: string };
4732
+ // ── team_synthesize — Team Brain Synthesis ────────────────────────────────
4733
+ case 'team_synthesize': {
4734
+ const { instance_id, topic } = args as { instance_id: string; topic: string };
3419
4735
  const redis = await getConnection(instance_id);
3420
4736
 
3421
- // Scan all lesson keys
3422
- const lessonKeys: string[] = [];
3423
- const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
3424
- await new Promise<void>((resolve, reject) => {
3425
- lStream.on('data', (batch: string[]) => lessonKeys.push(...batch));
3426
- lStream.on('end', resolve);
3427
- lStream.on('error', reject);
3428
- });
3429
-
3430
- type Lesson = { topic: string; outcome: string; recall_count?: number; ts: string; severity?: string; what_worked: string; author?: string };
3431
- const lessons: Lesson[] = [];
3432
- if (lessonKeys.length > 0) {
3433
- const vals = await redis.mget(...lessonKeys);
3434
- for (const raw of vals) {
3435
- if (!raw) continue;
3436
- try { lessons.push(JSON.parse(raw) as Lesson); } catch { /* skip */ }
3437
- }
4737
+ // Load history list for this topic (all authors' contributions)
4738
+ const listKey = `cachly:lessons:${topic}`;
4739
+ const all = await redis.lrange(listKey, 0, -1);
4740
+ if (all.length < 2) {
4741
+ return `📭 Need at least 2 entries for topic \`${topic}\` to synthesize.\n\nCurrently: ${all.length} entr${all.length === 1 ? 'y' : 'ies'}.\n\nHave team members store lessons via \`learn_from_attempts(topic="${topic}", ...)\`.`;
3438
4742
  }
3439
4743
 
3440
- // Context count
3441
- let ctxCount = 0;
3442
- const ctxStream = redis.scanStream({ match: 'cachly:ctx:*', count: 200 });
3443
- await new Promise<void>((resolve, reject) => {
3444
- ctxStream.on('data', (batch: string[]) => { ctxCount += batch.filter((k: string) => !k.endsWith(':meta')).length; });
3445
- ctxStream.on('end', resolve);
3446
- ctxStream.on('error', reject);
3447
- });
3448
-
3449
- const lastSessionRaw = await redis.get('cachly:session:last');
3450
- let lastSession: { ts: string; summary: string; duration_min?: number } | null = null;
3451
- if (lastSessionRaw) { try { lastSession = JSON.parse(lastSessionRaw); } catch { /* ignore */ } }
3452
-
3453
- const totalRecalls = lessons.reduce((s, l) => s + (l.recall_count ?? 0), 0);
3454
- const savedMin = totalRecalls * 15;
3455
- const savedHours = (savedMin / 60).toFixed(1);
3456
- const successCount = lessons.filter(l => l.outcome === 'success').length;
3457
- const failureCount = lessons.filter(l => l.outcome === 'failure' || l.outcome === 'partial').length;
3458
- const criticalCount = lessons.filter(l => l.severity === 'critical').length;
3459
- const majorCount = lessons.filter(l => l.severity === 'major').length;
3460
- const authors = [...new Set(lessons.map(l => l.author).filter(Boolean))];
3461
- const topByRecall = [...lessons].sort((a, b) => (b.recall_count ?? 0) - (a.recall_count ?? 0)).slice(0, 5);
3462
- const newestLesson = [...lessons].sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime())[0];
3463
-
3464
- // Age of last session
3465
- let lastSessionStr = 'none yet';
3466
- if (lastSession) {
3467
- const ageMin = Math.round((Date.now() - new Date(lastSession.ts).getTime()) / 60000);
3468
- lastSessionStr = ageMin < 60 ? `${ageMin}m ago`
3469
- : ageMin < 1440 ? `${Math.round(ageMin / 60)}h ago`
3470
- : `${Math.round(ageMin / 1440)}d ago`;
3471
- }
4744
+ type Entry = { outcome: string; what_worked: string; what_failed?: string; author?: string; ts: string; severity?: string };
4745
+ const entries: Entry[] = all.map(r => { try { return JSON.parse(r) as Entry; } catch { return null; } }).filter((e): e is Entry => e !== null);
3472
4746
 
3473
- const tokensSaved = (totalRecalls * 1200);
3474
- const tokenStr = tokensSaved >= 1000 ? `~${(tokensSaved / 1000).toFixed(0)}k tokens` : `~${tokensSaved} tokens`;
3475
- const costSaved = (tokensSaved * 0.000003).toFixed(2);
4747
+ // Group by outcome
4748
+ const successes = entries.filter(e => e.outcome === 'success');
4749
+ const failures = entries.filter(e => e.outcome === 'failure');
4750
+ const partials = entries.filter(e => e.outcome === 'partial');
3476
4751
 
3477
- const lines = [
3478
- `🧠 **Brain Stats — Full Dashboard**`,
3479
- ``,
3480
- `╔══════════════════════════════════════╗`,
3481
- `║ 📚 Lessons: ${String(lessons.length).padEnd(18)}║`,
3482
- `║ 🔄 Total recalls: ${String(totalRecalls).padEnd(18)}║`,
3483
- `║ 🕐 Time saved: ~${savedHours}h${' '.repeat(Math.max(0, 16 - savedHours.length))}║`,
3484
- `║ 💎 Tokens saved: ${tokenStr.padEnd(18)}║`,
3485
- `║ 💵 Cost saved: ~$${costSaved.padEnd(17)}║`,
3486
- `║ 📁 Context keys: ${String(ctxCount).padEnd(18)}║`,
3487
- `╚══════════════════════════════════════╝`,
3488
- ``,
3489
- `**Lessons breakdown:**`,
3490
- ` ✅ Success: ${successCount} ❌ Failures/partial: ${failureCount}`,
3491
- ` 🔴 Critical: ${criticalCount} 🟡 Major: ${majorCount}`,
3492
- ``,
3493
- `**Last session:** ${lastSessionStr}${lastSession?.summary ? ` — ${lastSession.summary.slice(0, 80)}` : ''}`,
3494
- `**Newest lesson:** ${newestLesson ? `\`${newestLesson.topic}\`` : 'none'}`,
3495
- ``,
3496
- ];
4752
+ const authors = [...new Set(entries.map(e => e.author).filter(Boolean))];
4753
+ const hasMultiAuthor = authors.length > 1;
3497
4754
 
3498
- if (topByRecall.length > 0) {
3499
- lines.push(`**Top recalled lessons (most reused):**`);
3500
- for (const l of topByRecall) {
3501
- const emoji = l.outcome === 'success' ? '✅' : '⚠️';
3502
- lines.push(` ${emoji} \`${l.topic}\` — recalled ${l.recall_count ?? 0}× · ${l.what_worked.slice(0, 60)}`);
3503
- }
3504
- lines.push('');
3505
- }
4755
+ // Build canonical merged version
4756
+ // what_worked: pick the most recent success, or longest for most detail
4757
+ const bestSuccess = successes.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime())[0];
4758
+ const whatWorkedCandidates = successes.map(e => e.what_worked).filter(w => w && w.length > 10);
4759
+ const canonicalWorked = whatWorkedCandidates.sort((a, b) => b.length - a.length)[0] ?? bestSuccess?.what_worked ?? '';
3506
4760
 
3507
- if (authors.length > 0) {
3508
- lines.push(`**Team contributors (${authors.length}):** ${(authors as string[]).join(', ')}`);
3509
- lines.push('');
3510
- }
4761
+ // what_failed: union of all unique failure reasons
4762
+ const allFailed = [...new Set(
4763
+ [...failures, ...partials].map(e => e.what_failed).filter((w): w is string => !!w && w.length > 5)
4764
+ )];
3511
4765
 
3512
- lines.push(`💡 Share your brain: \`invite_link()\` — generates a one-command team invite.`);
3513
- lines.push(`📤 Export to Markdown: \`export_brain()\` — portable snapshot you can save or share.`);
4766
+ const severities = entries.map(e => e.severity).filter(Boolean);
4767
+ const canonicalSeverity = severities.includes('critical') ? 'critical' : severities.includes('major') ? 'major' : 'minor';
3514
4768
 
3515
- return lines.join('\n');
4769
+ const lines = [
4770
+ `🧬 **Team Brain Synthesis: \`${topic}\`**`,
4771
+ `_${entries.length} entries from ${authors.length} author${authors.length === 1 ? '' : 's'}${hasMultiAuthor ? ` (${authors.join(', ')})` : ''} · ${successes.length} success · ${failures.length} failure · ${partials.length} partial_`,
4772
+ '',
4773
+ `**Canonical "what worked":**`,
4774
+ `> ${canonicalWorked}`,
4775
+ '',
4776
+ allFailed.length > 0 ? `**Avoid (combined failures):**` : '',
4777
+ ...allFailed.map(f => `> ❌ ${f}`),
4778
+ allFailed.length > 0 ? '' : '',
4779
+ `**Suggested canonical lesson:**`,
4780
+ '```',
4781
+ `learn_from_attempts(`,
4782
+ ` topic = "${topic}",`,
4783
+ ` outcome = "success",`,
4784
+ ` what_worked = "${canonicalWorked.replace(/"/g, "'")}",`,
4785
+ allFailed.length > 0 ? ` what_failed = "${allFailed[0].replace(/"/g, "'")}",` : '',
4786
+ ` severity = "${canonicalSeverity}",`,
4787
+ `)`,
4788
+ '```',
4789
+ '',
4790
+ hasMultiAuthor
4791
+ ? `💡 _${authors.length} team members contributed to this synthesis. Store the canonical version to replace individual entries._`
4792
+ : `💡 _Single author — more value when multiple team members contribute to the same topic._`,
4793
+ ].filter(l => l !== undefined).join('\n');
4794
+ return lines;
3516
4795
  }
3517
4796
 
3518
- // ── export_brain ──────────────────────────────────────────────────────────
3519
- case 'export_brain': {
3520
- const { instance_id, include_context = true, max_lessons } = args as {
3521
- instance_id: string;
3522
- include_context?: boolean;
3523
- max_lessons?: number;
3524
- };
4797
+ // ── brain_doctor ──────────────────────────────────────────────────────────
4798
+ // ── memory_crystalize ─────────────────────────────────────────────────────
4799
+ case 'memory_crystalize': {
4800
+ const { instance_id, label: crystalLabel = '' } = args as { instance_id: string; label?: string };
3525
4801
  const redis = await getConnection(instance_id);
3526
-
3527
- // Scan all lessons
3528
- const lessonKeys: string[] = [];
3529
- const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
3530
- await new Promise<void>((resolve, reject) => {
3531
- lStream.on('data', (batch: string[]) => lessonKeys.push(...batch));
3532
- lStream.on('end', resolve);
3533
- lStream.on('error', reject);
4802
+ const now = new Date();
4803
+ const week = `${now.getFullYear()}-W${String(Math.ceil((now.getDate() - now.getDay() + 10) / 7)).padStart(2, '0')}`;
4804
+ const effectiveLabel = crystalLabel || `${now.toISOString().slice(0, 7)} Crystal`;
4805
+
4806
+ // Read session history
4807
+ const sessionHistory = await redis.lrange('cachly:session:history', 0, 49);
4808
+
4809
+ // Read all auto-learned lessons
4810
+ const allLessonKeys: string[] = [];
4811
+ const ls = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
4812
+ await new Promise<void>((res, rej) => {
4813
+ ls.on('data', (b: string[]) => allLessonKeys.push(...b));
4814
+ ls.on('end', res);
4815
+ ls.on('error', rej);
3534
4816
  });
3535
4817
 
3536
- type FullLesson = {
3537
- topic: string; outcome: string; what_worked: string; what_failed?: string;
3538
- severity?: string; ts: string; recall_count?: number; author?: string;
3539
- file_paths?: string[]; commands?: string[]; tags?: string[];
3540
- };
3541
- let lessons: FullLesson[] = [];
3542
- if (lessonKeys.length > 0) {
3543
- const vals = await redis.mget(...lessonKeys);
3544
- for (const raw of vals) {
3545
- if (!raw) continue;
3546
- try { lessons.push(JSON.parse(raw) as FullLesson); } catch { /* skip */ }
3547
- }
3548
- }
3549
- lessons.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
3550
- if (max_lessons && max_lessons > 0) lessons = lessons.slice(0, max_lessons);
3551
-
3552
- // Last session + crystal
3553
- const lastSessionRaw = await redis.get('cachly:session:last');
3554
- let lastSession: { ts: string; summary: string } | null = null;
3555
- if (lastSessionRaw) { try { lastSession = JSON.parse(lastSessionRaw); } catch { /* ignore */ } }
3556
-
3557
- const crystalRaw = await redis.get('cachly:crystal');
3558
- let crystal: { summary: string; created_at: string } | null = null;
3559
- if (crystalRaw) { try { crystal = JSON.parse(crystalRaw); } catch { /* ignore */ } }
3560
-
3561
- // Context entries
3562
- type CtxEntry = { key: string; value: string };
3563
- const ctxEntries: CtxEntry[] = [];
3564
- if (include_context) {
3565
- const ctxKeys: string[] = [];
3566
- const ctxStream = redis.scanStream({ match: 'cachly:ctx:*', count: 200 });
3567
- await new Promise<void>((resolve, reject) => {
3568
- ctxStream.on('data', (batch: string[]) => {
3569
- ctxKeys.push(...batch.filter((k: string) => !k.endsWith(':meta')));
3570
- });
3571
- ctxStream.on('end', resolve);
3572
- ctxStream.on('error', reject);
3573
- });
3574
- if (ctxKeys.length > 0) {
3575
- const ctxVals = await redis.mget(...ctxKeys);
3576
- for (let i = 0; i < ctxKeys.length; i++) {
3577
- const val = ctxVals[i];
3578
- if (val) ctxEntries.push({ key: ctxKeys[i].replace('cachly:ctx:', ''), value: val });
3579
- }
3580
- }
4818
+ type RawLesson = { topic: string; outcome: string; what_worked: string; severity?: string; ts: string; auto_learned?: boolean };
4819
+ const allLessons: RawLesson[] = [];
4820
+ for (const k of allLessonKeys) {
4821
+ const raw = await redis.get(k);
4822
+ if (!raw) continue;
4823
+ try { allLessons.push(JSON.parse(raw) as RawLesson); } catch { /* skip */ }
3581
4824
  }
3582
4825
 
3583
- const exportedAt = new Date().toISOString();
3584
- const totalRecalls = lessons.reduce((s, l) => s + (l.recall_count ?? 0), 0);
3585
- const savedHours = (totalRecalls * 15 / 60).toFixed(1);
3586
-
3587
- const md: string[] = [
3588
- `# 🧠 cachly Brain Export`,
3589
- ``,
3590
- `> Exported: ${exportedAt} `,
3591
- `> Instance: \`${instance_id}\` `,
3592
- `> Lessons: ${lessons.length} · Recalls: ${totalRecalls} · Time saved: ~${savedHours}h`,
3593
- ``,
3594
- ];
3595
-
3596
- if (crystal) {
3597
- md.push(`## 💎 Memory Crystal`, ``, `> ${crystal.summary}`, ``, `_Generated: ${crystal.created_at}_`, ``);
4826
+ // Group lessons by top-level category
4827
+ const categoryMap = new Map<string, RawLesson[]>();
4828
+ for (const l of allLessons) {
4829
+ const cat = l.topic.split(':')[0] || 'misc';
4830
+ if (!categoryMap.has(cat)) categoryMap.set(cat, []);
4831
+ categoryMap.get(cat)!.push(l);
3598
4832
  }
3599
4833
 
3600
- if (lastSession) {
3601
- md.push(`## 📅 Last Session`, ``, `**${lastSession.ts.slice(0, 10)}** — ${lastSession.summary}`, ``);
4834
+ // Build top patterns (most frequent categories with a representative insight)
4835
+ const topPatterns: Array<{ category: string; insight: string; count: number }> = [];
4836
+ for (const [cat, catLessons] of [...categoryMap.entries()].sort((a, b) => b[1].length - a[1].length).slice(0, 8)) {
4837
+ const successLessons = catLessons.filter(l => l.outcome === 'success');
4838
+ const best = successLessons[0] ?? catLessons[0];
4839
+ if (!best) continue;
4840
+ topPatterns.push({
4841
+ category: cat,
4842
+ insight: best.what_worked.slice(0, 120),
4843
+ count: catLessons.length,
4844
+ });
3602
4845
  }
3603
4846
 
3604
- // Group lessons by outcome
3605
- const successLessons = lessons.filter(l => l.outcome === 'success');
3606
- const failureLessons = lessons.filter(l => l.outcome !== 'success');
3607
-
3608
- const formatLesson = (l: FullLesson) => {
3609
- const sev = l.severity === 'critical' ? ' 🔴' : l.severity === 'major' ? ' 🟡' : '';
3610
- const lines: string[] = [
3611
- `### \`${l.topic}\`${sev}`,
3612
- ``,
3613
- `**Outcome:** ${l.outcome} `,
3614
- `**Recalls:** ${l.recall_count ?? 0}× `,
3615
- `**Stored:** ${l.ts.slice(0, 10)}${l.author ? ` \n**Author:** ${l.author}` : ''}`,
3616
- ``,
3617
- `**What worked:** `,
3618
- l.what_worked,
3619
- ];
3620
- if (l.what_failed) { lines.push(``, `**What failed:** `, l.what_failed); }
3621
- if ((l.commands ?? []).length > 0) {
3622
- lines.push(``, `**Commands:**`);
3623
- for (const cmd of l.commands!) lines.push(`\`\`\`\n${cmd}\n\`\`\``);
3624
- }
3625
- if ((l.file_paths ?? []).length > 0) {
3626
- lines.push(``, `**Files:** ${l.file_paths!.map(f => `\`${f}\``).join(', ')}`);
3627
- }
3628
- if ((l.tags ?? []).length > 0) {
3629
- lines.push(``, `**Tags:** ${l.tags!.map(t => `\`${t}\``).join(', ')}`);
3630
- }
3631
- lines.push(``);
3632
- return lines.join('\n');
4847
+ const crystal = {
4848
+ label: effectiveLabel,
4849
+ ts: now.toISOString(),
4850
+ session_count: sessionHistory.length,
4851
+ lesson_count: allLessons.length,
4852
+ top_patterns: topPatterns,
4853
+ categories: [...categoryMap.keys()],
4854
+ created_from: `${sessionHistory.length} sessions, ${allLessons.length} lessons`,
3633
4855
  };
3634
4856
 
3635
- if (successLessons.length > 0) {
3636
- md.push(`## ✅ Successful Solutions (${successLessons.length})`, ``);
3637
- for (const l of successLessons) md.push(formatLesson(l));
3638
- }
3639
-
3640
- if (failureLessons.length > 0) {
3641
- md.push(`## ⚠️ Open / Partial Issues (${failureLessons.length})`, ``);
3642
- for (const l of failureLessons) md.push(formatLesson(l));
3643
- }
3644
-
3645
- if (ctxEntries.length > 0) {
3646
- md.push(`## 📁 Context Entries (${ctxEntries.length})`, ``);
3647
- for (const e of ctxEntries) {
3648
- md.push(`### \`${e.key}\``, ``, e.value.slice(0, 500) + (e.value.length > 500 ? '\n\n_[truncated]_' : ''), ``);
3649
- }
3650
- }
3651
-
3652
- md.push(
3653
- `---`,
3654
- ``,
3655
- `_Exported from [cachly AI Brain](https://cachly.dev) · Re-import with \`npx @cachly-dev/mcp-server@latest setup\`_`,
3656
- );
3657
-
3658
- return md.join('\n');
3659
- }
3660
-
3661
- // ── invite_link ───────────────────────────────────────────────────────────
3662
- case 'invite_link': {
3663
- const { instance_id, label = '' } = args as { instance_id: string; label?: string };
3664
-
3665
- // Generate a share token via API
3666
- let shareToken = '';
3667
- let inviteUrl = '';
3668
- try {
3669
- const res = await apiFetch<{ token?: string; url?: string }>(
3670
- `/api/v1/instances/${instance_id}/invite`,
3671
- { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label }) }
3672
- );
3673
- shareToken = res.token ?? '';
3674
- inviteUrl = res.url ?? '';
3675
- } catch {
3676
- // API doesn't support invite yet — generate a local share snippet instead
3677
- }
3678
-
3679
- // Build the join command
3680
- const joinCmd = shareToken
3681
- ? `npx @cachly-dev/mcp-server@latest join ${shareToken}`
3682
- : `npx @cachly-dev/mcp-server@latest setup`;
4857
+ const crystalJson = JSON.stringify(crystal);
4858
+ await redis.set('cachly:crystal:latest', crystalJson);
4859
+ await redis.expire('cachly:crystal:latest', 90 * 86400);
4860
+ await redis.set(`cachly:crystal:${week}`, crystalJson);
4861
+ await redis.expire(`cachly:crystal:${week}`, 365 * 86400);
3683
4862
 
3684
4863
  const lines = [
3685
- `🔗 **Team Invite — ${label || instance_id.slice(0, 8) + '…'}**`,
3686
- ``,
3687
- `Share this command with your teammate:`,
4864
+ `💎 **Memory Crystal created: ${effectiveLabel}**`,
3688
4865
  ``,
3689
- `\`\`\`bash`,
3690
- joinCmd,
3691
- `\`\`\``,
4866
+ `📊 Compressed: **${sessionHistory.length} sessions** + **${allLessons.length} lessons** → ${topPatterns.length} top patterns`,
3692
4867
  ``,
3693
- `**What happens when they run it:**`,
3694
- ` 1. Browser opens → they sign in (free, 30 seconds)`,
3695
- ` 2. Their Brain is auto-connected to this shared instance`,
3696
- ` 3. All ${label ? `"${label}" ` : ''}lessons and context are instantly available`,
3697
- ` 4. Editor configs written automatically — no copy-paste`,
4868
+ `**Top patterns by category:**`,
4869
+ ...topPatterns.slice(0, 6).map(p => ` • **${p.category}** (${p.count}×): ${p.insight.slice(0, 90)}`),
3698
4870
  ``,
4871
+ `💡 This crystal will appear in every future \`session_start\` briefing.`,
4872
+ `💡 Re-run \`memory_crystalize\` monthly to keep it fresh.`,
3699
4873
  ];
3700
-
3701
- if (inviteUrl) {
3702
- lines.push(`**Or share the link directly:** ${inviteUrl}`, ``);
3703
- }
3704
-
3705
- lines.push(
3706
- `💡 Teammates can also run \`session_start\` immediately to see all your lessons.`,
3707
- ` Use \`team_learn\` with an \`author\` param so everyone knows who learned what.`,
3708
- );
3709
-
3710
4874
  return lines.join('\n');
3711
4875
  }
3712
4876
 
3713
- // ── brain_doctor ──────────────────────────────────────────────────────────
3714
4877
  case 'brain_doctor': {
3715
- const { instance_id } = args as { instance_id: string };
4878
+ const { instance_id, workspace_path: drWorkspacePath = '' } = args as { instance_id: string; workspace_path?: string };
3716
4879
  const redis = await getConnection(instance_id);
3717
4880
  const issues: string[] = [];
3718
4881
  const checks: string[] = [];
@@ -3737,15 +4900,16 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
3737
4900
  ctxStream.on('error', reject);
3738
4901
  });
3739
4902
 
3740
- // Load lessons for analysis — batch fetch
3741
- type Lesson = { topic: string; outcome: string; recall_count?: number; ts: string; severity?: string };
3742
- const lessons: Lesson[] = [];
3743
- if (lessonKeys.length > 0) {
3744
- const vals = await redis.mget(...lessonKeys);
3745
- for (const raw of vals) {
3746
- if (!raw) continue;
3747
- try { lessons.push(JSON.parse(raw) as Lesson); } catch { /* skip */ }
3748
- }
4903
+ // Load lessons for analysis
4904
+ type DrLesson = {
4905
+ topic: string; outcome: string; recall_count?: number; ts: string;
4906
+ verified_at?: string; severity?: string; audit_trail?: unknown[];
4907
+ };
4908
+ const lessons: DrLesson[] = [];
4909
+ for (const k of lessonKeys) {
4910
+ const raw = await redis.get(k);
4911
+ if (!raw) continue;
4912
+ try { lessons.push(JSON.parse(raw) as DrLesson); } catch { /* skip */ }
3749
4913
  }
3750
4914
 
3751
4915
  // Last session
@@ -3761,6 +4925,17 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
3761
4925
  const unusedLessons = lessons.filter(l => (l.recall_count ?? 0) === 0);
3762
4926
  // Critical lessons
3763
4927
  const criticalLessons = lessons.filter(l => l.severity === 'critical');
4928
+ // Confidence decay analysis
4929
+ const staleLessons = lessons.filter(l => l.outcome === 'success' && calculateConfidence(l) < CONFIDENCE_STALE_VALUE);
4930
+ const warnLessons = lessons.filter(l => l.outcome === 'success' && calculateConfidence(l) >= CONFIDENCE_STALE_VALUE && calculateConfidence(l) < CONFIDENCE_WARN_VALUE);
4931
+ const withAudit = lessons.filter(l => (l.audit_trail ?? []).length > 1);
4932
+ // Team lessons
4933
+ type DrLessonWithAuthor = DrLesson & { author?: string };
4934
+ const teamLessons = (lessons as DrLessonWithAuthor[]).filter(l => l.author);
4935
+ const uniqueAuthors = new Set((lessons as DrLessonWithAuthor[]).map(l => l.author).filter(Boolean));
4936
+ // Effective IQ boost: total recalls / lessons (how much the brain actually helped)
4937
+ const totalRecalls = lessons.reduce((sum, l) => sum + (l.recall_count ?? 0), 0);
4938
+ const iqBoostPct = lessons.length > 0 ? Math.min(100, Math.round((totalRecalls / lessons.length) * 10)) : 0;
3764
4939
 
3765
4940
  // Quality score (0-100)
3766
4941
  let score = 50;
@@ -3772,12 +4947,67 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
3772
4947
  if (openFailures.length === 0) score += 5;
3773
4948
  const unusedRatio = lessons.length > 0 ? unusedLessons.length / lessons.length : 0;
3774
4949
  if (unusedRatio < 0.5) score += 10;
4950
+ if (staleLessons.length === 0) score += 5;
4951
+ if (uniqueAuthors.size >= 2) score += 5; // team collaboration bonus
3775
4952
 
3776
4953
  const scoreEmoji = score >= 80 ? '🟢' : score >= 50 ? '🟡' : '🔴';
4954
+ const iqEmoji = iqBoostPct >= 50 ? '🚀' : iqBoostPct >= 20 ? '📈' : '💤';
3777
4955
 
3778
4956
  checks.push(`${scoreEmoji} **Brain Quality Score: ${score}/100**`);
3779
- checks.push(`📚 **Lessons:** ${lessonKeys.length} (${criticalLessons.length} critical)`);
4957
+ checks.push(`${iqEmoji} **Effective IQ Boost: ${iqBoostPct}%** (${totalRecalls} recalls across ${lessons.length} lessons)`);
4958
+ checks.push(`📚 **Lessons:** ${lessonKeys.length} (${criticalLessons.length} critical · ${withAudit.length} with audit trail · ${teamLessons.length} from team)`);
3780
4959
  checks.push(`💾 **Context entries:** ${ctxCount}`);
4960
+ checks.push(`🎯 **Confidence:** ${lessons.length - staleLessons.length - warnLessons.length} fresh · ${warnLessons.length} warn · ${staleLessons.length} stale`);
4961
+ checks.push(`⏱️ **Decay config:** warn after ${CONFIDENCE_WARN_DAYS}d · stale after ${CONFIDENCE_STALE_DAYS}d`);
4962
+ if (uniqueAuthors.size >= 2) {
4963
+ checks.push(`👥 **Team:** ${uniqueAuthors.size} contributors (${[...uniqueAuthors].join(', ')})`);
4964
+ }
4965
+
4966
+ // Stale index detection
4967
+ try {
4968
+ const lastIndexRaw = await redis.get('cachly:index:last_run');
4969
+ if (lastIndexRaw) {
4970
+ const lastIndexAge = Math.round((Date.now() - new Date(lastIndexRaw).getTime()) / 86_400_000);
4971
+ if (lastIndexAge > 7) {
4972
+ issues.push(`🔄 Index is ${lastIndexAge}d stale — run \`index_project\` to re-sync semantic search`);
4973
+ } else {
4974
+ checks.push(`🗂️ **Semantic index:** ${lastIndexAge}d old (fresh)`);
4975
+ }
4976
+ } else {
4977
+ issues.push(`💡 No semantic index — run \`index_project(dir="<your-src>")\` to enable semantic search`);
4978
+ }
4979
+ } catch { /* non-critical */ }
4980
+
4981
+ // Memory crystal status
4982
+ try {
4983
+ const crystalRaw = await redis.get('cachly:crystal:latest');
4984
+ if (crystalRaw) {
4985
+ const crystal = JSON.parse(crystalRaw) as { ts: string; label: string };
4986
+ const crystalAge = Math.round((Date.now() - new Date(crystal.ts).getTime()) / 86_400_000);
4987
+ checks.push(`💎 **Memory Crystal:** ${crystal.label} (${crystalAge}d ago)`);
4988
+ if (crystalAge > 30) issues.push(`💡 Memory Crystal is ${crystalAge}d old — re-run \`memory_crystalize\` to compress new sessions`);
4989
+ } else if (lessonKeys.length >= 10) {
4990
+ issues.push(`💡 ${lessonKeys.length} lessons but no Memory Crystal — run \`memory_crystalize\` to compress wisdom`);
4991
+ }
4992
+ } catch { /* non-critical */ }
4993
+
4994
+ // openclaw cross-promo (check package.json in workspace)
4995
+ if (drWorkspacePath) {
4996
+ try {
4997
+ const pkgPath = drWorkspacePath.replace(/\/$/, '') + '/package.json';
4998
+ const pkgRaw = readFileSync(pkgPath, 'utf-8');
4999
+ const pkg = JSON.parse(pkgRaw) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
5000
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
5001
+ const hasLLMDep = ['openai', '@anthropic-ai/sdk', '@google/generative-ai', 'mistralai', 'cohere-ai'].some(d => d in allDeps);
5002
+ const hasOpenclaw = '@cachly-dev/openclaw' in allDeps;
5003
+ if (hasLLMDep && !hasOpenclaw) {
5004
+ issues.push(`💡 **openclaw missing:** you use LLM APIs (${Object.keys(allDeps).filter(d => ['openai','@anthropic-ai/sdk'].includes(d)).join(', ')}) but not \`@cachly-dev/openclaw\``);
5005
+ issues.push(` → \`npm install @cachly-dev/openclaw\` cuts LLM costs 60–90% with 3 lines of code`);
5006
+ } else if (hasOpenclaw) {
5007
+ checks.push(`✅ **@cachly-dev/openclaw installed** (LLM cost caching active)`);
5008
+ }
5009
+ } catch { /* no package.json or unreadable */ }
5010
+ }
3781
5011
 
3782
5012
  if (lastSession) {
3783
5013
  const ageMin = Math.round((Date.now() - new Date(lastSession.ts).getTime()) / 60000);
@@ -3793,6 +5023,10 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
3793
5023
  issues.push(`💡 Only ${lessonKeys.length} lessons — add more after each problem solved`);
3794
5024
  }
3795
5025
 
5026
+ if (iqBoostPct === 0 && lessons.length >= 5) {
5027
+ issues.push(`💤 **IQ Boost is 0%** — lessons exist but are never recalled. Use \`recall_best_solution\` BEFORE tasks.`);
5028
+ }
5029
+
3796
5030
  if (ctxCount === 0) {
3797
5031
  issues.push('💡 No context — use `remember_context` to cache architecture docs, ADRs, etc.');
3798
5032
  }
@@ -3801,6 +5035,15 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
3801
5035
  issues.push(`⚠️ ${openFailures.length} unresolved failure${openFailures.length > 1 ? 's' : ''}: ${openFailures.slice(0, 3).map(l => `\`${l.topic}\``).join(', ')}`);
3802
5036
  }
3803
5037
 
5038
+ if (staleLessons.length > 0) {
5039
+ issues.push(`🔴 ${staleLessons.length} STALE lesson${staleLessons.length > 1 ? 's' : ''} (>${CONFIDENCE_STALE_DAYS}d, confidence <${CONFIDENCE_STALE_VALUE * 100}%): ${staleLessons.slice(0, 3).map(l => `\`${l.topic}\``).join(', ')}`);
5040
+ issues.push(` → Re-verify with \`recall_best_solution\` to reset confidence clock`);
5041
+ }
5042
+
5043
+ if (warnLessons.length > 0) {
5044
+ issues.push(`⚠️ ${warnLessons.length} lesson${warnLessons.length > 1 ? 's' : ''} aging (>${CONFIDENCE_WARN_DAYS}d): ${warnLessons.slice(0, 3).map(l => `\`${l.topic}\``).join(', ')}`);
5045
+ }
5046
+
3804
5047
  if (unusedRatio > 0.7 && lessons.length > 5) {
3805
5048
  issues.push(`💡 ${unusedLessons.length} lessons never recalled — verify topics match your workflow`);
3806
5049
  }
@@ -3816,6 +5059,77 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
3816
5059
  return lines.join('\n');
3817
5060
  }
3818
5061
 
5062
+ // ── recall_at — Brain Archaeology ────────────────────────────────────────
5063
+ case 'recall_at': {
5064
+ const { instance_id, topic, date } = args as { instance_id: string; topic: string; date: string };
5065
+ const redis = await getConnection(instance_id);
5066
+ const cutoff = new Date(date).getTime();
5067
+ if (isNaN(cutoff)) return `❌ Invalid date "${date}". Use ISO format: "2026-01-15"`;
5068
+
5069
+ const listKey = `cachly:lessons:${topic}`;
5070
+ const all = await redis.lrange(listKey, 0, -1);
5071
+ if (all.length === 0) return `📭 No history found for \`${topic}\`. Lessons are stored via \`learn_from_attempts\`.`;
5072
+
5073
+ const before = all
5074
+ .map(raw => { try { return JSON.parse(raw) as { ts: string; outcome?: string; what_worked?: string; what_failed?: string }; } catch { return null; } })
5075
+ .filter((l): l is NonNullable<typeof l> => l !== null && new Date(l.ts).getTime() <= cutoff)
5076
+ .sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
5077
+
5078
+ if (before.length === 0) return `📭 No entries for \`${topic}\` found before **${date}**. Earliest entry: ${new Date(JSON.parse(all[0]).ts).toLocaleDateString('de-DE')}.`;
5079
+
5080
+ const lines = [
5081
+ `🏺 **Brain Archaeology: \`${topic}\` before ${date}**`,
5082
+ `_${before.length} of ${all.length} total entries shown_`,
5083
+ '',
5084
+ ];
5085
+ for (const l of before.slice(-10)) {
5086
+ const emoji = l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
5087
+ const d = new Date(l.ts).toLocaleDateString('de-DE');
5088
+ lines.push(`**${d}** ${emoji} ${l.outcome}`);
5089
+ if (l.what_worked) lines.push(` → ${l.what_worked.slice(0, 100)}`);
5090
+ lines.push('');
5091
+ }
5092
+ lines.push(`_Full evolution: ${all.map(r => { try { const l = JSON.parse(r) as { outcome?: string }; return l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌'; } catch { return '?'; } }).join(' → ')}_`);
5093
+ return lines.join('\n');
5094
+ }
5095
+
5096
+ // ── trace_dependency — Causal Chain ──────────────────────────────────────
5097
+ case 'trace_dependency': {
5098
+ const { instance_id, dependency, mark_review = false } = args as { instance_id: string; dependency: string; mark_review?: boolean };
5099
+ const redis = await getConnection(instance_id);
5100
+
5101
+ const depKey = `cachly:dep:${dependency}`;
5102
+ const raw = await redis.get(depKey);
5103
+ if (!raw) return `📭 No lessons found that depend on \`${dependency}\`.\n\nAdd dependencies via: \`learn_from_attempts(..., depends_on=["${dependency}"])\``;
5104
+
5105
+ const topics: string[] = JSON.parse(raw);
5106
+ const lines = [
5107
+ `🔗 **Causal Chain: \`${dependency}\`** — ${topics.length} dependent lesson${topics.length === 1 ? '' : 's'}`,
5108
+ '',
5109
+ ];
5110
+
5111
+ for (const t of topics) {
5112
+ const lessonRaw = await redis.get(`cachly:lesson:best:${t}`);
5113
+ if (!lessonRaw) { lines.push(` • \`${t}\` _(lesson deleted)_`); continue; }
5114
+ const lesson = JSON.parse(lessonRaw) as { outcome?: string; severity?: string; needs_review?: boolean };
5115
+ const emoji = lesson.outcome === 'success' ? '✅' : lesson.outcome === 'partial' ? '⚠️' : '❌';
5116
+ const reviewBadge = lesson.needs_review ? ' 🔍 **needs_review**' : '';
5117
+ lines.push(` ${emoji} \`${t}\` (${lesson.severity ?? 'major'})${reviewBadge}`);
5118
+
5119
+ if (mark_review) {
5120
+ const updated = { ...lesson, needs_review: true };
5121
+ await redis.set(`cachly:lesson:best:${t}`, JSON.stringify(updated));
5122
+ }
5123
+ }
5124
+
5125
+ if (mark_review) {
5126
+ lines.push('', `🔍 All ${topics.length} lessons marked as **needs_review** — verify they still work with the changed dependency.`);
5127
+ } else {
5128
+ lines.push('', `_Run with \`mark_review: true\` to flag all dependent lessons for re-verification._`);
5129
+ }
5130
+ return lines.join('\n');
5131
+ }
5132
+
3819
5133
  // ── global_learn ──────────────────────────────────────────────────────────
3820
5134
  case 'global_learn': {
3821
5135
  const { instance_id, topic, lesson, severity = 'minor', tags = [] } = args as {
@@ -4097,65 +5411,148 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
4097
5411
  // Generate copilot-instructions.md content
4098
5412
  const tier = inst.tier.toUpperCase();
4099
5413
  const smartRecallNote = hasVector
4100
- ? '3. Call `smart_recall` with keywords for semantic context search'
4101
- : '3. Call `recall_context` with relevant key patterns like "arch:*" or "file:*"';
5414
+ ? '- `smart_recall("natural language query")` — semantic search (finds by meaning)'
5415
+ : '- `recall_context("arch:*")` — exact/glob key lookup (upgrade to Speed/Business for smart_recall)';
4102
5416
  const layerNote = hasVector
4103
- ? `Layer 3 (Semantic): smart_recall uses pgvector HNSW on your ${tier} instance for meaning-based search`
4104
- : `Layer 3 (Autopilot): This file — upgrade to Speed/Business tier to unlock smart_recall`;
5417
+ ? `Layer 3 (Semantic): smart_recall uses pgvector HNSW on your ${tier} instance`
5418
+ : `Layer 3 (Autopilot): this file — upgrade to Speed/Business tier to unlock smart_recall`;
4105
5419
 
4106
- const copilotInstructions = `# cachly AI Memory — ${project_description}
5420
+ const copilotInstructions = `# cachly AI Brain — ${project_description}
4107
5421
 
4108
- ## 3-Layer AI Memory System
5422
+ > AI memory system powered by [cachly.dev](https://cachly.dev). Works with GitHub Copilot, Claude Code, Cursor, Windsurf, Continue.dev, and any MCP-compatible editor.
4109
5423
 
4110
- - **Layer 1 — Storage:** cachly instance \`${inst.name}\` (${tier}) — persistent Valkey brain, survives restarts
4111
- - **Layer 2 — Tools:** learn_from_attempts · recall_best_solution · smart_recall — the memory API
4112
- - **Layer 3 — Autopilot (this file):** instructs the AI to run memory tools automatically
5424
+ ---
4113
5425
 
4114
- ## BEFORE every task — check memory first
5426
+ ## 🔴 MANDATORY RULES — Always enforced, no exceptions
4115
5427
 
4116
- 1. Call \`recall_best_solution\` with the task topic to get known solutions
4117
- - \`recall_best_solution("deploy:web")\` → returns working deploy command
4118
- - \`recall_best_solution("debug:redis-tls")\` → returns root cause + fix
4119
- - If a solution exists, use it — do NOT re-discover from scratch
5428
+ ### Rule 1 — Session Start (FIRST thing in every window)
5429
+ Call \`session_start\` **before reading any source files or making any changes**:
4120
5430
 
4121
- 2. Call \`recall_context\` with the area you're working in
4122
- - \`recall_context("arch:*")\` → cached architecture decisions
4123
- - \`recall_context("file:src/auth*")\` → cached file summaries
5431
+ \`\`\`
5432
+ session_start(
5433
+ instance_id = "${instance_id}",
5434
+ focus = "<what you plan to work on today>",
5435
+ )
5436
+ \`\`\`
4124
5437
 
4125
- ${smartRecallNote}
4126
- - \`smart_recall("how does authentication work")\` → returns cached auth notes
5438
+ Returns: last session summary, handoff from previous window, recent lessons, open failures, brain health.
4127
5439
 
4128
- 4. If nothing is found, proceed normally — then save (see below)
5440
+ **If \`session_start\` is unavailable**, fall back:
5441
+ 1. \`smart_recall("recent lessons <topic>")\`
5442
+ 2. \`recall_best_solution("<topic>")\`
4129
5443
 
4130
- ## AFTER every non-trivial task — save to memory
5444
+ ### Rule 2 — Learn After Every Fix/Deploy/Discovery (AUTOMATIC — no prompting needed)
5445
+ After **every** bug fix, deployment, failed attempt, or discovery — call \`learn_from_attempts\` immediately:
4131
5446
 
4132
- 1. Call \`learn_from_attempts\` for deploy/debug/fix tasks:
4133
- - Always set outcome = "success" | "failure" | "partial"
4134
- - Always include what_failed — it prevents future wasted time
4135
- - Example: \`learn_from_attempts(topic="deploy:api", outcome="success", what_worked="nohup docker compose up -d --build", what_failed="direct docker compose hangs on SSH timeout")\`
5447
+ \`\`\`
5448
+ learn_from_attempts(
5449
+ instance_id = "${instance_id}",
5450
+ topic = "category:keyword", # deploy:web | fix:auth | debug:db | infra:docker
5451
+ outcome = "success" | "failure" | "partial",
5452
+ what_worked = "exact approach or command that worked",
5453
+ what_failed = "what did NOT work (prevents future wasted time)",
5454
+ severity = "critical" | "major" | "minor",
5455
+ commands = ["exact command that worked"],
5456
+ file_paths = ["path/to/changed/file"],
5457
+ )
5458
+ \`\`\`
5459
+
5460
+ **These events MUST automatically trigger \`learn_from_attempts\`:**
5461
+ - ✅ Successfully deployed → \`topic="deploy:<component>", outcome="success"\`
5462
+ - ✅ Fixed a bug → \`topic="fix:<description>", outcome="success"\`
5463
+ - ✅ Discovered root cause → \`topic="debug:<area>", outcome="partial"\`
5464
+ - ✅ Tried something that failed → \`topic="<area>", outcome="failure", what_failed="..."\`
5465
+ - ✅ Found a config quirk → \`topic="infra:<topic>", outcome="success"\`
5466
+ - ✅ Ran a working command → store it in \`commands=[...]\`
4136
5467
 
4137
- 2. Call \`remember_context\` for code/architecture findings:
4138
- - \`remember_context("arch:auth", "Uses Keycloak with JWT...", category="architecture")\`
4139
- - \`remember_context("file:src/index.ts", "Entry point, registers routes...", category="file_summary")\`
5468
+ ### Rule 3 — Session Handoff (when closing window or task list > 3 items)
5469
+ \`\`\`
5470
+ session_handoff(
5471
+ instance_id = "${instance_id}",
5472
+ completed_tasks = ["what was done"],
5473
+ remaining_tasks = ["what is still to do"],
5474
+ files_changed = [{"path": "src/...", "status": "modified", "description": "what changed"}],
5475
+ instructions = "IMPORTANT: what the next window MUST know to not break things",
5476
+ context_summary = "current state of the system",
5477
+ )
5478
+ \`\`\`
5479
+
5480
+ The next \`session_start\` automatically includes this handoff. The new window sees exactly what's done, what remains, and what NOT to break.
5481
+
5482
+ ### Rule 4 — Session End (LAST thing before closing)
5483
+ \`\`\`
5484
+ session_end(
5485
+ instance_id = "${instance_id}",
5486
+ summary = "What was accomplished (2-3 sentences with specifics)",
5487
+ files_changed = ["list", "of", "changed", "files"],
5488
+ lessons_learned = <number of learn_from_attempts calls made>,
5489
+ )
5490
+ \`\`\`
5491
+
5492
+ > \`session_end\` **automatically extracts and stores lessons** from the summary text — no manual calls needed.
5493
+
5494
+ ### Rule 5 — Code Index Invalidation (AUTOMATIC)
5495
+ - \`index_project\` tracks MD5 hashes per file — **skips unchanged files automatically**
5496
+ - After code changes: next \`index_project\` run re-indexes only modified files
5497
+ - After delete/rename: run \`index_project\` again to sync
5498
+ - No manual invalidation needed
5499
+
5500
+ ---
5501
+
5502
+ ## Task-type trigger table
5503
+
5504
+ | You're about to... | Call BEFORE | Call AFTER |
5505
+ |---|---|---|
5506
+ | Deploy anything | \`recall_best_solution("deploy:<component>")\` | \`learn_from_attempts(topic="deploy:...")\` |
5507
+ | Fix a bug | \`recall_best_solution("fix:<area>")\` | \`learn_from_attempts(topic="fix:...")\` |
5508
+ | Add a feature | \`session_start(focus="feat:<area>")\` | \`learn_from_attempts(topic="feat:...")\` |
5509
+ | Infra/server work | \`recall_best_solution("infra:<topic>")\` | \`learn_from_attempts(topic="infra:...")\` |
5510
+ | Debug an issue | \`smart_recall("<error message or symptom>")\` | \`learn_from_attempts(topic="debug:...")\` |
5511
+
5512
+ ---
5513
+
5514
+ ## Available Brain Tools
5515
+
5516
+ | Tool | When to use |
5517
+ |------|-------------|
5518
+ | \`session_start\` | **FIRST** — mandatory at start of every session |
5519
+ | \`session_end\` | **LAST** — mandatory at end, auto-learns from summary |
5520
+ | \`session_handoff\` | When closing window with remaining tasks |
5521
+ | \`learn_from_attempts\` | **AUTOMATIC** after every fix/deploy/discovery |
5522
+ | \`recall_best_solution\` | Before any non-trivial task |
5523
+ | \`remember_context\` | After analyzing code — save findings for future sessions |
5524
+ ${smartRecallNote ? `| \`smart_recall\` | Search brain by meaning/keywords |\n` : ''}\
5525
+ | \`recall_context\` | Get exact key (supports glob: \`arch:*\`, \`file:*\`) |
5526
+ | \`brain_search\` | BM25+ full-text search over all brain data |
5527
+ | \`auto_learn_session\` | Batch-learn from a list of observations (optional) |
5528
+ | \`index_project\` | Index source files (smart hash, skips unchanged files) |
5529
+ | \`list_remembered\` | See what's cached in the brain |
5530
+ | \`forget_context\` | Remove stale context |
5531
+
5532
+ ---
4140
5533
 
4141
5534
  ## Instance Details
4142
5535
 
4143
- - Instance ID: \`${instance_id}\`
4144
- - Instance name: ${inst.name}
4145
- - Tier: ${tier}
4146
- - ${layerNote}
4147
- - Embedding provider: ${providerArg}
5536
+ - **Instance ID:** \`${instance_id}\`
5537
+ - **Instance name:** ${inst.name}
5538
+ - **Tier:** ${tier}
5539
+ - **${layerNote}**
5540
+ - **Embedding provider:** ${providerArg}
5541
+
5542
+ ---
4148
5543
 
4149
- ## Quick reference
5544
+ ## How the 3-layer system works
4150
5545
 
4151
5546
  \`\`\`
4152
- recall_best_solution("topic") # before task
4153
- smart_recall("natural language") # find by meaning
4154
- learn_from_attempts(...) # after task
4155
- remember_context("key", "content") # save analysis
5547
+ Layer 1 — Storage: Your cachly Valkey instance (${inst.name}) — persists forever
5548
+ Layer 2 — Tools: learn_from_attempts · recall_best_solution · brain_search · session_start/end
5549
+ Layer 3 — Autopilot: This file — AI reads it and runs tools automatically every session
4156
5550
  \`\`\`
5551
+
5552
+ Result: Your AI **never solves the same problem twice** and always picks up exactly where it left off. 🚀
4157
5553
  `;
4158
5554
 
5555
+
4159
5556
  const lines: string[] = [
4160
5557
  `🧠 **cachly AI Memory Setup Complete**`,
4161
5558
  ``,
@@ -4179,8 +5576,8 @@ remember_context("key", "content") # save analysis
4179
5576
  `**Step 3 — copilot-instructions.md (Layer 3 Autopilot):**`,
4180
5577
  ``,
4181
5578
  ...(project_dir
4182
- ? [`Writing to \`${project_dir}/.github/copilot-instructions.md\`…`]
4183
- : [`Copy this to \`.github/copilot-instructions.md\` in your project:`]),
5579
+ ? [`🔍 Detecting IDEs in \`${project_dir}\`…`]
5580
+ : [`Copy this to \`.github/copilot-instructions.md\` (Copilot), \`CLAUDE.md\` (Claude Code), or \`.cursor/rules\` (Cursor) in your project:`]),
4184
5581
  ``,
4185
5582
  `\`\`\`markdown`,
4186
5583
  copilotInstructions,
@@ -4195,16 +5592,104 @@ remember_context("key", "content") # save analysis
4195
5592
  `Result: Your AI never solves the same problem twice. 🚀`,
4196
5593
  ];
4197
5594
 
4198
- // Optionally write the file
5595
+ // ── IDE auto-detection + file writing ────────────────────────────────
4199
5596
  if (project_dir) {
4200
- const { mkdir, writeFile } = await import('node:fs/promises');
4201
- const githubDir = join(project_dir, '.github');
4202
- await mkdir(githubDir, { recursive: true });
4203
- const filePath = join(githubDir, 'copilot-instructions.md');
4204
- await writeFile(filePath, copilotInstructions, 'utf-8');
4205
- lines.splice(lines.indexOf(`Writing to \`${project_dir}/.github/copilot-instructions.md\`…`) + 1, 0,
4206
- `✅ Written to \`${project_dir}/.github/copilot-instructions.md\``
4207
- );
5597
+ const { mkdir, writeFile, access } = await import('node:fs/promises');
5598
+ const { constants } = await import('node:fs');
5599
+
5600
+ const exists = async (p: string) => access(p, constants.F_OK).then(() => true).catch(() => false);
5601
+
5602
+ // Detect which IDEs are present based on marker files/dirs
5603
+ interface IdeTarget { ide: string; path: string; content: string }
5604
+ const targets: IdeTarget[] = [];
5605
+ let stopHookWritten = false;
5606
+
5607
+ // GitHub Copilot — always write (universal fallback)
5608
+ targets.push({
5609
+ ide: 'GitHub Copilot',
5610
+ path: join(project_dir, '.github', 'copilot-instructions.md'),
5611
+ content: copilotInstructions,
5612
+ });
5613
+
5614
+ // Claude Code — CLAUDE.md in project root
5615
+ if (await exists(join(project_dir, 'CLAUDE.md')) || await exists(join(project_dir, '.claude'))) {
5616
+ targets.push({
5617
+ ide: 'Claude Code',
5618
+ path: join(project_dir, 'CLAUDE.md'),
5619
+ content: copilotInstructions,
5620
+ });
5621
+
5622
+ // Claude Code Stop-Hook — auto-saves checkpoint when Claude stops responding
5623
+ const claudeDir = join(project_dir, '.claude');
5624
+ await mkdir(claudeDir, { recursive: true });
5625
+ const stopHook = {
5626
+ hooks: {
5627
+ Stop: [
5628
+ {
5629
+ matcher: '',
5630
+ hooks: [
5631
+ {
5632
+ type: 'command',
5633
+ command: `npx --yes @cachly-dev/mcp-server@latest checkpoint --instance-id ${instance_id}`,
5634
+ },
5635
+ ],
5636
+ },
5637
+ ],
5638
+ },
5639
+ };
5640
+ const settingsPath = join(claudeDir, 'settings.json');
5641
+ let existingSettings: Record<string, unknown> = {};
5642
+ try {
5643
+ const { readFile: rf } = await import('node:fs/promises');
5644
+ existingSettings = JSON.parse(await rf(settingsPath, 'utf-8'));
5645
+ } catch { /* new file */ }
5646
+ const merged = { ...existingSettings, hooks: (stopHook as Record<string, unknown>).hooks };
5647
+ await writeFile(settingsPath, JSON.stringify(merged, null, 2), 'utf-8');
5648
+ stopHookWritten = true;
5649
+ }
5650
+
5651
+ // Cursor — .cursor/rules (new format) or .cursorrules (legacy)
5652
+ if (
5653
+ await exists(join(project_dir, '.cursor')) ||
5654
+ await exists(join(project_dir, '.cursorrules'))
5655
+ ) {
5656
+ const cursorDir = join(project_dir, '.cursor');
5657
+ await mkdir(cursorDir, { recursive: true });
5658
+ targets.push({
5659
+ ide: 'Cursor',
5660
+ path: join(cursorDir, 'rules'),
5661
+ content: copilotInstructions,
5662
+ });
5663
+ }
5664
+
5665
+ // Windsurf — .windsurfrules
5666
+ if (
5667
+ await exists(join(project_dir, '.windsurfrules')) ||
5668
+ await exists(join(project_dir, '.windsurf'))
5669
+ ) {
5670
+ targets.push({
5671
+ ide: 'Windsurf',
5672
+ path: join(project_dir, '.windsurfrules'),
5673
+ content: copilotInstructions,
5674
+ });
5675
+ }
5676
+
5677
+ // VS Code (Copilot) — already covered by .github/copilot-instructions.md above
5678
+ // Continue.dev — .continue/config.json is JSON, not markdown — skip, copilot-instructions handles it
5679
+
5680
+ const written: string[] = [];
5681
+ for (const target of targets) {
5682
+ const dir = target.path.substring(0, target.path.lastIndexOf('/'));
5683
+ await mkdir(dir, { recursive: true });
5684
+ await writeFile(target.path, target.content, 'utf-8');
5685
+ written.push(`✅ [${target.ide}] → \`${target.path.replace(project_dir, '.')}\``);
5686
+ }
5687
+
5688
+ if (stopHookWritten) {
5689
+ written.push(`✅ [Claude Code Stop-Hook] → \`.claude/settings.json\` (auto-checkpoint on stop)`);
5690
+ }
5691
+
5692
+ lines.push(...written);
4208
5693
  }
4209
5694
 
4210
5695
  return lines.join('\n');
@@ -4332,6 +5817,178 @@ async function handleBulkLockStream(name: string, args: Record<string, unknown>)
4332
5817
  );
4333
5818
  }
4334
5819
 
5820
+ // ── Roadmap ──────────────────────────────────────────────────────────────
5821
+
5822
+ case 'roadmap_add': {
5823
+ const {
5824
+ instance_id: rid,
5825
+ title,
5826
+ description: desc = '',
5827
+ priority = 'medium',
5828
+ tags: rtags = [],
5829
+ milestone = '',
5830
+ } = args as {
5831
+ instance_id: string; title: string; description?: string;
5832
+ priority?: string; tags?: string[]; milestone?: string;
5833
+ };
5834
+ const redis = await getConnection(rid);
5835
+ const id = `rm_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
5836
+ const item = {
5837
+ id, title, description: desc, priority, tags: rtags, milestone,
5838
+ status: 'planned',
5839
+ created: new Date().toISOString(),
5840
+ updated: new Date().toISOString(),
5841
+ notes: '',
5842
+ };
5843
+ await redis.hset(`cachly:roadmap:${rid}`, id, JSON.stringify(item));
5844
+ const PRIORITY_ICON: Record<string, string> = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
5845
+ return [
5846
+ `📋 **Roadmap item added**`,
5847
+ ``,
5848
+ ` ID: \`${id}\``,
5849
+ ` Title: ${title}`,
5850
+ ` Priority: ${PRIORITY_ICON[priority] ?? '⚪'} ${priority}`,
5851
+ ` Status: planned`,
5852
+ milestone ? ` Milestone: ${milestone}` : '',
5853
+ rtags.length ? ` Tags: ${rtags.join(', ')}` : '',
5854
+ ``,
5855
+ `💡 Use \`roadmap_update(id: "${id}", status: "in-progress")\` when you start working on it.`,
5856
+ ].filter(Boolean).join('\n');
5857
+ }
5858
+
5859
+ case 'roadmap_update': {
5860
+ const {
5861
+ instance_id: rid,
5862
+ id: itemId,
5863
+ status: newStatus,
5864
+ priority: newPriority,
5865
+ notes: newNotes,
5866
+ title: newTitle,
5867
+ description: newDesc,
5868
+ } = args as {
5869
+ instance_id: string; id: string; status?: string; priority?: string;
5870
+ notes?: string; title?: string; description?: string;
5871
+ };
5872
+ const redis = await getConnection(rid);
5873
+ const raw = await redis.hget(`cachly:roadmap:${rid}`, itemId);
5874
+ if (!raw) return `⚠️ **roadmap_update** – Item \`${itemId}\` not found. Use \`roadmap_list\` to see all items.`;
5875
+ const item = JSON.parse(raw) as Record<string, unknown>;
5876
+ const oldStatus = item.status as string;
5877
+ if (newStatus) item.status = newStatus;
5878
+ if (newPriority) item.priority = newPriority;
5879
+ if (newTitle) item.title = newTitle;
5880
+ if (newDesc) item.description = newDesc;
5881
+ if (newNotes) item.notes = item.notes ? `${item.notes}\n[${new Date().toISOString().slice(0, 10)}] ${newNotes}` : `[${new Date().toISOString().slice(0, 10)}] ${newNotes}`;
5882
+ item.updated = new Date().toISOString();
5883
+ await redis.hset(`cachly:roadmap:${rid}`, itemId, JSON.stringify(item));
5884
+ const statusEmoji: Record<string, string> = { planned: '📋', 'in-progress': '⚡', done: '✅', blocked: '🚫', cancelled: '🗑️' };
5885
+ return [
5886
+ `${statusEmoji[newStatus ?? oldStatus] ?? '📋'} **Roadmap updated** \`${itemId}\``,
5887
+ ``,
5888
+ ` Title: ${item.title}`,
5889
+ oldStatus !== newStatus ? ` Status: ${oldStatus} → ${newStatus}` : ` Status: ${item.status}`,
5890
+ newNotes ? ` Notes: ${newNotes}` : '',
5891
+ ].filter(Boolean).join('\n');
5892
+ }
5893
+
5894
+ case 'roadmap_list': {
5895
+ const {
5896
+ instance_id: rid,
5897
+ status: filterStatus = 'open',
5898
+ tag: filterTag,
5899
+ milestone: filterMilestone,
5900
+ priority: filterPriority,
5901
+ } = args as {
5902
+ instance_id: string; status?: string; tag?: string;
5903
+ milestone?: string; priority?: string;
5904
+ };
5905
+ const redis = await getConnection(rid);
5906
+ const all = await redis.hgetall(`cachly:roadmap:${rid}`);
5907
+ if (!all || Object.keys(all).length === 0) {
5908
+ return '📋 **Roadmap is empty.**\n\nUse `roadmap_add` to create your first item.';
5909
+ }
5910
+ const PRIORITY_ORDER: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
5911
+ const PRIORITY_ICON: Record<string, string> = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
5912
+ const STATUS_ICON: Record<string, string> = { planned: '📋', 'in-progress': '⚡', done: '✅', blocked: '🚫', cancelled: '🗑️' };
5913
+ const openStatuses = new Set(['planned', 'in-progress', 'blocked']);
5914
+ let items = Object.values(all).map(v => JSON.parse(v as string) as Record<string, string | string[]>);
5915
+ // Filter
5916
+ if (filterStatus === 'open') items = items.filter(i => openStatuses.has(i.status as string));
5917
+ else if (filterStatus) items = items.filter(i => i.status === filterStatus);
5918
+ if (filterTag) items = items.filter(i => (i.tags as string[]).includes(filterTag));
5919
+ if (filterMilestone) items = items.filter(i => i.milestone === filterMilestone);
5920
+ if (filterPriority) items = items.filter(i => (PRIORITY_ORDER[i.priority as string] ?? 99) <= (PRIORITY_ORDER[filterPriority] ?? 99));
5921
+ // Sort: priority asc, then created asc
5922
+ items.sort((a, b) => {
5923
+ const pa = PRIORITY_ORDER[a.priority as string] ?? 99;
5924
+ const pb = PRIORITY_ORDER[b.priority as string] ?? 99;
5925
+ return pa !== pb ? pa - pb : (a.created as string).localeCompare(b.created as string);
5926
+ });
5927
+ if (items.length === 0) return `📋 **No roadmap items** match the current filter (status: ${filterStatus}).`;
5928
+ const grouped: Record<string, typeof items> = {};
5929
+ for (const it of items) {
5930
+ const st = it.status as string;
5931
+ if (!grouped[st]) grouped[st] = [];
5932
+ grouped[st].push(it);
5933
+ }
5934
+ const lines: string[] = [`📋 **Roadmap** (${items.length} item${items.length !== 1 ? 's' : ''})`, ''];
5935
+ for (const [st, grp] of Object.entries(grouped)) {
5936
+ lines.push(`**${STATUS_ICON[st] ?? '•'} ${st.toUpperCase()}** (${grp.length})`);
5937
+ for (const it of grp) {
5938
+ const tags = (it.tags as string[]).length ? ` [${(it.tags as string[]).join(', ')}]` : '';
5939
+ const milestone = it.milestone ? ` · ${it.milestone}` : '';
5940
+ lines.push(` ${PRIORITY_ICON[it.priority as string] ?? '⚪'} \`${it.id}\` **${it.title}**${tags}${milestone}`);
5941
+ if (it.description) lines.push(` ${(it.description as string).slice(0, 120)}`);
5942
+ if (it.notes) lines.push(` 📝 ${(it.notes as string).split('\n').pop()?.slice(0, 100)}`);
5943
+ }
5944
+ lines.push('');
5945
+ }
5946
+ lines.push(`💡 \`roadmap_update(id, status: "in-progress")\` to start · \`roadmap_next\` for top priority item`);
5947
+ return lines.join('\n');
5948
+ }
5949
+
5950
+ case 'roadmap_next': {
5951
+ const { instance_id: rid, tag: filterTag } = args as { instance_id: string; tag?: string };
5952
+ const redis = await getConnection(rid);
5953
+ const all = await redis.hgetall(`cachly:roadmap:${rid}`);
5954
+ if (!all || Object.keys(all).length === 0) {
5955
+ return '📋 **Roadmap is empty.** Use `roadmap_add` to plan your first task.';
5956
+ }
5957
+ const PRIORITY_ORDER: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
5958
+ const PRIORITY_ICON: Record<string, string> = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
5959
+ let items = Object.values(all)
5960
+ .map(v => JSON.parse(v as string) as Record<string, unknown>)
5961
+ .filter(i => i.status === 'in-progress' || i.status === 'planned')
5962
+ .filter(i => !filterTag || (i.tags as string[]).includes(filterTag));
5963
+ if (items.length === 0) return '🎉 **No open roadmap items!** All tasks are done (or use `roadmap_list` to check).';
5964
+ // in-progress first, then by priority
5965
+ items.sort((a, b) => {
5966
+ if (a.status === 'in-progress' && b.status !== 'in-progress') return -1;
5967
+ if (b.status === 'in-progress' && a.status !== 'in-progress') return 1;
5968
+ return (PRIORITY_ORDER[a.priority as string] ?? 99) - (PRIORITY_ORDER[b.priority as string] ?? 99);
5969
+ });
5970
+ const next = items[0];
5971
+ const remaining = items.length - 1;
5972
+ const tags = (next.tags as string[]).length ? `\nTags: ${(next.tags as string[]).join(', ')}` : '';
5973
+ const milestone = next.milestone ? `\nMilestone: ${next.milestone}` : '';
5974
+ const notes = next.notes ? `\nNotes: ${(next.notes as string).split('\n').pop()?.slice(0, 120)}` : '';
5975
+ return [
5976
+ `${next.status === 'in-progress' ? '⚡' : '📋'} **Next up: ${next.title}**`,
5977
+ ``,
5978
+ `ID: \`${next.id}\``,
5979
+ `Priority: ${PRIORITY_ICON[next.priority as string] ?? '⚪'} ${next.priority}`,
5980
+ `Status: ${next.status}`,
5981
+ next.description ? `\nWhat to do:\n${next.description}` : '',
5982
+ tags, milestone, notes,
5983
+ ``,
5984
+ remaining > 0 ? `(+${remaining} more open item${remaining !== 1 ? 's' : ''} in roadmap)` : '(last open item)',
5985
+ ``,
5986
+ next.status === 'planned'
5987
+ ? `💡 Start with: \`roadmap_update(id: "${next.id}", status: "in-progress")\``
5988
+ : `💡 Finish with: \`roadmap_update(id: "${next.id}", status: "done", notes: "...")\``,
5989
+ ].filter(s => s !== undefined).join('\n');
5990
+ }
5991
+
4335
5992
  default:
4336
5993
  return null;
4337
5994
  }
@@ -4340,14 +5997,81 @@ async function handleBulkLockStream(name: string, args: Record<string, unknown>)
4340
5997
  // ── Server setup ──────────────────────────────────────────────────────────────
4341
5998
 
4342
5999
  const server = new Server(
4343
- { name: 'cachly-mcp', version: '0.4.0' },
6000
+ { name: 'cachly-mcp', version: CURRENT_VERSION },
4344
6001
  { capabilities: { tools: {} } }
4345
6002
  );
4346
6003
 
4347
6004
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
4348
6005
 
6006
+ // ── Auto-session management ───────────────────────────────────────────────────
6007
+ // Transparently starts a Brain session on the first tool call of a process
6008
+ // and saves a session_end summary on SIGTERM/exit so users never have to call
6009
+ // session_start/session_end manually.
6010
+
6011
+ let _autoSessionInstanceId: string | null = null;
6012
+ let _autoSessionStarted = false;
6013
+ let _autoSessionToolCount = 0;
6014
+
6015
+ async function autoStartSession(instanceId: string): Promise<void> {
6016
+ if (_autoSessionStarted) return;
6017
+ _autoSessionStarted = true;
6018
+ _autoSessionInstanceId = instanceId;
6019
+ try {
6020
+ await handleTool('session_start', { instance_id: instanceId, focus: 'auto (MCP session)' });
6021
+ } catch { /* non-fatal — session tracking is a best-effort feature */ }
6022
+
6023
+ // Auto-index the project if it hasn't been indexed in the last 24h.
6024
+ // This is the main lever for growing token savings: more indexed code →
6025
+ // more semantic cache hits → fewer LLM calls for repeated questions.
6026
+ if (process.env.CACHLY_AUTO_INDEX !== 'false') {
6027
+ try {
6028
+ const redis = await getConnection(instanceId);
6029
+ const lastIndexed = await redis.get(`cachly:index:last_indexed:${instanceId}`);
6030
+ const staleMs = 24 * 60 * 60 * 1000;
6031
+ const isStale = !lastIndexed || (Date.now() - parseInt(lastIndexed, 10)) > staleMs;
6032
+ if (isStale) {
6033
+ // Mark as indexing now to prevent concurrent re-runs.
6034
+ await redis.set(`cachly:index:last_indexed:${instanceId}`, String(Date.now()), 'EX', 90000);
6035
+ // Run in background — don't block the first tool call.
6036
+ handleTool('index_project', {
6037
+ instance_id: instanceId,
6038
+ dir: process.cwd(),
6039
+ max_files: 150,
6040
+ ttl: 86400 * 7, // cache for 7 days
6041
+ namespace: 'cachly:sem:code',
6042
+ }).catch(() => undefined);
6043
+ }
6044
+ } catch { /* never block the session on indexing errors */ }
6045
+ }
6046
+ }
6047
+
6048
+ async function autoEndSession(): Promise<void> {
6049
+ if (!_autoSessionStarted || !_autoSessionInstanceId) return;
6050
+ _autoSessionStarted = false;
6051
+ try {
6052
+ await handleTool('session_end', {
6053
+ instance_id: _autoSessionInstanceId,
6054
+ summary: `Auto-ended after ${_autoSessionToolCount} tool call(s). Session was started automatically.`,
6055
+ files_changed: [],
6056
+ lessons_learned: 0,
6057
+ });
6058
+ } catch { /* non-fatal */ }
6059
+ }
6060
+
4349
6061
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
4350
6062
  const { name, arguments: args } = request.params;
6063
+
6064
+ // Auto-start brain session on first tool call that has an instance_id.
6065
+ // Skip session management tools to avoid recursion.
6066
+ const sessionTools = new Set(['session_start', 'session_end', 'auto_learn_session']);
6067
+ if (!sessionTools.has(name) && !_autoSessionStarted) {
6068
+ const instanceId = (args as Record<string, unknown>)?.instance_id as string | undefined;
6069
+ if (instanceId) {
6070
+ await autoStartSession(instanceId).catch(() => undefined);
6071
+ }
6072
+ }
6073
+ if (!sessionTools.has(name)) _autoSessionToolCount++;
6074
+
4351
6075
  try {
4352
6076
  const text = await handleTool(name, (args ?? {}) as Record<string, unknown>);
4353
6077
  return { content: [{ type: 'text', text }] };
@@ -4357,8 +6081,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4357
6081
  }
4358
6082
  });
4359
6083
 
4360
- // Graceful shutdown – close all Redis connections
6084
+ // Graceful shutdown – close all Redis connections + auto-end brain session
4361
6085
  process.on('SIGTERM', async () => {
6086
+ await autoEndSession().catch(() => undefined);
6087
+ for (const [, client] of pool) await client.quit().catch(() => undefined);
6088
+ process.exit(0);
6089
+ });
6090
+
6091
+ process.on('SIGINT', async () => {
6092
+ await autoEndSession().catch(() => undefined);
4362
6093
  for (const [, client] of pool) await client.quit().catch(() => undefined);
4363
6094
  process.exit(0);
4364
6095
  });
@@ -4379,6 +6110,8 @@ const EDITOR_FILES: Record<string, string> = {
4379
6110
  windsurf: '.windsurf/mcp.json',
4380
6111
  copilot: '.vscode/mcp.json',
4381
6112
  continue: '.continue/config.json',
6113
+ cline: '.vscode/mcp.json', // Cline respects VS Code project MCP config
6114
+ zed: '.zed/settings.json', // Zed project-level context_servers
4382
6115
  };
4383
6116
 
4384
6117
  const CLAUDE_MD_MARKER_START = '<!-- cachly-brain-start -->';
@@ -4395,6 +6128,20 @@ function buildMcpConfig(apiKey: string, instanceId: string, editor: string): str
4395
6128
  },
4396
6129
  }, null, 2);
4397
6130
  }
6131
+ if (editor === 'zed') {
6132
+ return JSON.stringify({
6133
+ context_servers: {
6134
+ cachly: {
6135
+ command: {
6136
+ path: 'npx',
6137
+ args: ['-y', '@cachly-dev/mcp-server@latest'],
6138
+ env: { CACHLY_API_URL: 'https://api.cachly.dev', CACHLY_JWT: apiKey, CACHLY_BRAIN_INSTANCE_ID: instanceId },
6139
+ },
6140
+ settings: {},
6141
+ },
6142
+ },
6143
+ }, null, 2);
6144
+ }
4398
6145
  return JSON.stringify({
4399
6146
  mcpServers: {
4400
6147
  cachly: {
@@ -4408,48 +6155,54 @@ function buildMcpConfig(apiKey: string, instanceId: string, editor: string): str
4408
6155
 
4409
6156
  function buildClaudeMdBlock(instanceId: string): string {
4410
6157
  return `${CLAUDE_MD_MARKER_START}
4411
- ## Cachly AI Brain — Persistent Memory
6158
+ ## Cachly AI Brain
4412
6159
 
4413
- Your AI Brain instance ID: \`${instanceId}\`
6160
+ The Cachly MCP server is available as tool \`cachly\`.
6161
+ Your instance ID: \`${instanceId}\`
4414
6162
 
4415
- > The Brain gives you persistent memory across sessions. Every lesson you learn,
4416
- > every bug you fix, every architecture decision — recalled instantly next time.
4417
- > instance_id is pre-filled below. Do NOT ask the user for it.
6163
+ > **Sessions are fully automatic** (MCP ≥ 0.5.3).
6164
+ > The Brain starts tracking when you call any tool for the first time and saves a summary when your editor closes. You never need to call \`session_start\` or \`session_end\` manually.
4418
6165
 
4419
- ### MANDATORY: Call at the START of every session
4420
- \`\`\`
4421
- session_start(instance_id="${instanceId}", focus="<what you're working on today>")
4422
- \`\`\`
4423
- This loads your previous context, recent lessons, and open tasks automatically.
6166
+ ---
4424
6167
 
4425
- ### MANDATORY: Call at the END of every session
6168
+ ### Before editing any file — WIP-registry (prevents half-finished code across sessions):
4426
6169
  \`\`\`
4427
- session_end(instance_id="${instanceId}", summary="<2-3 sentences on what was done>", files_changed=["src/..."], lessons_learned=1)
6170
+ remember_context(
6171
+ instance_id = "${instanceId}",
6172
+ key = "wip-registry",
6173
+ content = "EDITING: <file:line> — goal: <what you are implementing>",
6174
+ category = "bug",
6175
+ ttl = 86400,
6176
+ )
4428
6177
  \`\`\`
6178
+ After the edit is complete, update \`content\` to \`"DONE: <file> — <what was completed>"\`.
4429
6179
 
4430
- ### After fixing ANY bug or solving a hard problem
6180
+ ### After fixing any bug or solving a tricky problem:
4431
6181
  \`\`\`
4432
6182
  learn_from_attempts(
4433
- instance_id="${instanceId}",
4434
- topic="category:keyword", # e.g. "deploy:web", "bug:redis-timeout", "arch:auth"
4435
- outcome="success",
4436
- what_worked="exact solution",
4437
- what_failed="what didn't work (optional)",
4438
- commands=["the command that worked"],
4439
- file_paths=["relevant/file.ts"],
4440
- severity="critical"|"major"|"minor",
6183
+ instance_id = "${instanceId}",
6184
+ topic = "category:keyword",
6185
+ outcome = "success",
6186
+ what_worked = "...",
6187
+ what_failed = "...",
6188
+ severity = "critical" | "major" | "minor",
6189
+ file_paths = ["path/to/file"],
6190
+ commands = ["the command that worked"],
6191
+ tags = ["tag1"],
4441
6192
  )
4442
6193
  \`\`\`
4443
6194
 
4444
- ### Before starting any non-trivial task
6195
+ ### Before starting any task — recall relevant lessons first:
4445
6196
  \`\`\`
4446
- recall_best_solution(instance_id="${instanceId}", topic="relevant:topic")
6197
+ smart_recall(
6198
+ instance_id = "${instanceId}",
6199
+ query = "<describe what you are about to do>",
6200
+ )
4447
6201
  \`\`\`
4448
6202
 
4449
- ### If a stored lesson is wrong
4450
- \`\`\`
4451
- forget_lesson(instance_id="${instanceId}", topic="wrong:topic")
4452
- \`\`\`
6203
+ ### Half-finished code rule:
6204
+ Never commit code that does not compile. Run \`tsc --noEmit\` / \`go build ./...\` before every commit.
6205
+ If a session ends mid-task, save the WIP-registry entry so the next session picks up exactly where you left off.
4453
6206
  ${CLAUDE_MD_MARKER_END}`;
4454
6207
  }
4455
6208
 
@@ -4540,8 +6293,9 @@ if (process.argv[2] === 'setup') {
4540
6293
  const AUTH_BASE = 'https://auth.cachly.dev/realms/cachly/protocol/openid-connect';
4541
6294
  const CLIENT_ID = 'cachly-cli';
4542
6295
 
4543
- console.log('Step 1: Sign in to cachly (free — no credit card required)\n');
6296
+ console.log('Step 1: Sign in to cachly (free, no credit card)\n');
4544
6297
 
6298
+ // Start device flow
4545
6299
  let deviceCode = '', userCode = '', verifyUri = '', pollInterval = 5000;
4546
6300
  try {
4547
6301
  const deviceRes = await fetch(`${AUTH_BASE}/auth/device`, {
@@ -4549,92 +6303,91 @@ if (process.argv[2] === 'setup') {
4549
6303
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
4550
6304
  body: `client_id=${CLIENT_ID}&scope=openid`,
4551
6305
  });
4552
- if (!deviceRes.ok) throw new Error(`HTTP ${deviceRes.status}`);
6306
+ if (!deviceRes.ok) throw new Error(`Device flow error: HTTP ${deviceRes.status}`);
4553
6307
  const data = await deviceRes.json() as {
4554
6308
  device_code: string; user_code: string;
4555
6309
  verification_uri_complete: string; interval: number;
4556
6310
  };
4557
- deviceCode = data.device_code;
4558
- userCode = data.user_code;
4559
- verifyUri = data.verification_uri_complete;
4560
- pollInterval = (data.interval ?? 5) * 1000;
6311
+ deviceCode = data.device_code;
6312
+ userCode = data.user_code;
6313
+ verifyUri = data.verification_uri_complete;
6314
+ pollInterval = (data.interval ?? 5) * 1000;
4561
6315
  } catch (e) {
4562
- console.error(`\nCould not reach auth service: ${(e as Error).message}`);
4563
- console.error('Fallback: sign in at https://cachly.dev and paste your API token.\n');
6316
+ console.error(`\nFailed to start device flow: ${(e as Error).message}`);
6317
+ console.error('Falling back: sign in at https://cachly.dev/setup-ai and paste your API token.\n');
4564
6318
  token = await ask(' Paste API token (cky_live_...): ');
4565
- if (!token) { console.error('\nToken required. Aborting.\n'); rl.close(); process.exit(1); }
4566
- deviceCode = '';
6319
+ if (!token) { console.error('\nToken is required. Aborting.\n'); rl.close(); process.exit(1); }
6320
+ console.log('');
6321
+ deviceCode = ''; // mark as fallback so we skip polling
4567
6322
  }
4568
6323
 
4569
- if (deviceCode) {
4570
- console.log(` Code: \x1b[1;33m${userCode}\x1b[0m`);
4571
- console.log(` URL: ${verifyUri}\n`);
6324
+ if (deviceCode!) {
6325
+ // Open browser
6326
+ console.log(` Code: \x1b[1;33m${userCode!}\x1b[0m`);
6327
+ console.log(` URL: ${verifyUri!}\n`);
4572
6328
  try {
4573
6329
  const { execSync } = await import('node:child_process');
4574
6330
  const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
4575
- execSync(`${openCmd} "${verifyUri}"`, { stdio: 'ignore' });
6331
+ execSync(`${openCmd} "${verifyUri!}"`, { stdio: 'ignore' });
4576
6332
  console.log(' ✓ Browser opened — confirm the code above to continue...\n');
4577
6333
  } catch {
4578
- console.log(' 👉 Open the URL above in your browser and confirm the code.\n');
6334
+ console.log(' 👉 Open the URL above in your browser and enter the code.\n');
4579
6335
  }
4580
6336
 
6337
+ // Poll for token
4581
6338
  process.stdout.write(' Waiting for authorization');
4582
- const deadline = Date.now() + 10 * 60 * 1000;
6339
+ const deadline = Date.now() + 10 * 60 * 1000; // 10 min
4583
6340
  while (Date.now() < deadline) {
4584
- await new Promise(r => setTimeout(r, pollInterval));
6341
+ await new Promise(r => setTimeout(r, pollInterval!));
4585
6342
  process.stdout.write('.');
4586
6343
  try {
4587
6344
  const tokenRes = await fetch(`${AUTH_BASE}/token`, {
4588
6345
  method: 'POST',
4589
6346
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
4590
- body: `client_id=${CLIENT_ID}&grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=${deviceCode}`,
6347
+ body: `client_id=${CLIENT_ID}&grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=${deviceCode!}`,
4591
6348
  });
4592
- const td = await tokenRes.json() as { access_token?: string; error?: string };
4593
- if (td.access_token) {
4594
- token = td.access_token;
6349
+ const tokenData = await tokenRes.json() as { access_token?: string; error?: string };
6350
+ if (tokenData.access_token) {
6351
+ token = tokenData.access_token;
4595
6352
  console.log(' \x1b[32m✓ Authorized!\x1b[0m\n');
4596
6353
  break;
4597
6354
  }
4598
- if (td.error === 'slow_down') pollInterval = Math.min(pollInterval + 2000, 15000);
4599
- else if (td.error && td.error !== 'authorization_pending') {
4600
- console.error(`\nAuth error: ${td.error}. Aborting.\n`);
6355
+ // authorization_pending = keep polling; slow_down = increase interval
6356
+ if (tokenData.error === 'slow_down') pollInterval = Math.min(pollInterval! + 2000, 15000);
6357
+ else if (tokenData.error && tokenData.error !== 'authorization_pending') {
6358
+ console.error(`\nAuth error: ${tokenData.error}. Aborting.\n`);
4601
6359
  rl.close(); process.exit(1);
4602
6360
  }
4603
6361
  } catch { /* network hiccup — keep polling */ }
4604
6362
  }
4605
- if (!token) { console.error('\nTimed out. Aborting.\n'); rl.close(); process.exit(1); }
6363
+ if (!token) { console.error('\nTimed out waiting for authorization. Aborting.\n'); rl.close(); process.exit(1); }
6364
+ console.log('');
4606
6365
  }
6366
+ }
4607
6367
 
4608
- // Exchange short-lived Keycloak JWT → long-lived cky_live_ API key
4609
- if (token.startsWith('eyJ')) {
4610
- process.stdout.write('⏳ Generating your API key...');
4611
- try {
4612
- const keyRes = await fetch(`${API_URL}/api/v1/api-keys`, {
4613
- method: 'POST',
4614
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
4615
- body: JSON.stringify({ name: 'cachly-mcp-setup', scope: 'read_write' }),
4616
- });
4617
- if (!keyRes.ok) throw new Error(`HTTP ${keyRes.status}`);
4618
- const keyBody = await keyRes.json() as { key?: string };
4619
- if (keyBody.key) { token = keyBody.key; console.log(' ✓\n'); }
4620
- else throw new Error('no key in response');
4621
- } catch (e) {
4622
- console.log(` (skipped — JWT will be used directly)\n`);
4623
- }
6368
+ // ── Step 1b: Exchange Keycloak JWT → long-lived cky_live_ API key ──────────
6369
+ // Only do this when the token looks like a Keycloak JWT (starts with "eyJ"),
6370
+ // not when the user already pasted a cky_live_ key directly.
6371
+ if (token.startsWith('eyJ')) {
6372
+ process.stdout.write('⏳ Generating your API key...');
6373
+ try {
6374
+ const keyRes = await fetch(`${API_URL}/api/v1/api-keys`, {
6375
+ method: 'POST',
6376
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
6377
+ body: JSON.stringify({ name: 'cachly-mcp-setup', scope: 'read_write' }),
6378
+ });
6379
+ if (!keyRes.ok) throw new Error(`HTTP ${keyRes.status}`);
6380
+ const keyBody = await keyRes.json() as { key: string };
6381
+ if (!keyBody.key) throw new Error('no key in response');
6382
+ token = keyBody.key; // swap JWT → cky_live_...
6383
+ console.log(' ✓\n');
6384
+ } catch (e) {
6385
+ console.log(' (skipped)\n');
6386
+ // Non-fatal: fall back to using the Keycloak JWT directly.
6387
+ // It will expire but setup still works for now.
4624
6388
  }
4625
6389
  }
4626
6390
 
4627
- // ── Step 1b: Auto-provision a Brain instance if none exist ─────────────────
4628
- // POST /api/v1/instances/auto is idempotent — returns existing or creates free Brain
4629
- let autoProvisioned = false;
4630
- try {
4631
- const autoRes = await fetch(`${API_URL}/api/v1/instances/auto`, {
4632
- method: 'POST',
4633
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
4634
- });
4635
- if (autoRes.ok) autoProvisioned = true;
4636
- } catch { /* non-fatal */ }
4637
-
4638
6391
  // ── Step 2: Fetch & pick instance ─────────────────────────────────────────
4639
6392
  process.stdout.write('⏳ Fetching your instances...');
4640
6393
  let instances: Array<{ id: string; name: string; status: string; tier: string; region: string }> = [];
@@ -4646,31 +6399,62 @@ if (process.argv[2] === 'setup') {
4646
6399
  const body = await res.json() as { data: typeof instances };
4647
6400
  instances = (body.data ?? []).filter(i => i.status === 'running');
4648
6401
  } catch (e) {
4649
- console.error(`\n\nFailed to fetch instances: ${(e as Error).message}`);
4650
- console.error('Check your token and try again.\n');
6402
+ const msg = (e as Error).message;
6403
+ console.error(`\n\nFailed to fetch instances: ${msg}`);
6404
+ if (msg.includes('401')) {
6405
+ console.error('Token rejected. Get a valid token at https://cachly.dev/setup-ai\n');
6406
+ try {
6407
+ const { execSync } = await import('node:child_process');
6408
+ const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
6409
+ execSync(`${openCmd} https://cachly.dev/setup-ai`, { stdio: 'ignore' });
6410
+ } catch { /* ignore */ }
6411
+ }
4651
6412
  rl.close(); process.exit(1);
4652
6413
  }
4653
6414
  console.log(` found ${instances.length}\n`);
4654
6415
 
4655
6416
  if (instances.length === 0) {
4656
- if (autoProvisioned) {
4657
- // Poll up to 10× every 3s (30s total) waiting for the Brain to start
4658
- process.stdout.write(' waiting for Brain to start');
4659
- for (let attempt = 0; attempt < 10 && instances.length === 0; attempt++) {
4660
- await new Promise(r => setTimeout(r, 3000));
4661
- process.stdout.write('.');
4662
- try {
4663
- const retry = await fetch(`${API_URL}/api/v1/instances`, { headers: { Authorization: `Bearer ${token}` } });
4664
- if (retry.ok) {
4665
- const b = await retry.json() as { data: typeof instances };
4666
- instances = (b.data ?? []).filter(i => i.status === 'running');
4667
- }
4668
- } catch { /* ignore */ }
6417
+ // Auto-provision a free Brain instance so users don't have to visit the website.
6418
+ process.stdout.write('⏳ Creating your free Brain instance...');
6419
+ try {
6420
+ const autoRes = await fetch(`${API_URL}/api/v1/instances/auto`, {
6421
+ method: 'POST',
6422
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
6423
+ });
6424
+ if (!autoRes.ok) throw new Error(`HTTP ${autoRes.status}`);
6425
+ const autoBody = await autoRes.json() as { instance?: { id: string; name: string; status: string; tier: string; region: string }; instance_id?: string; status?: string; created?: boolean };
6426
+ if (autoBody.instance) {
6427
+ // Returned an existing instance.
6428
+ instances = [autoBody.instance];
6429
+ } else if (autoBody.instance_id) {
6430
+ // Newly created — poll until running or give up after 30 s.
6431
+ const newId = autoBody.instance_id;
6432
+ console.log(` ✓ created (${newId.slice(0, 8)}…)\n`);
6433
+ process.stdout.write('⏳ Waiting for instance to start');
6434
+ for (let attempt = 0; attempt < 15; attempt++) {
6435
+ await new Promise(r => setTimeout(r, 2000));
6436
+ process.stdout.write('.');
6437
+ try {
6438
+ const checkRes = await fetch(`${API_URL}/api/v1/instances/${newId}`, { headers: { Authorization: `Bearer ${token}` } });
6439
+ if (checkRes.ok) {
6440
+ const inst = await checkRes.json() as { id: string; name: string; status: string; tier: string; region: string };
6441
+ if (inst.status === 'running') { instances = [inst]; break; }
6442
+ }
6443
+ } catch { /* keep polling */ }
6444
+ }
6445
+ console.log('');
4669
6446
  }
4670
- process.stdout.write(instances.length > 0 ? ' ready!\n' : '\n');
6447
+ } catch (e) {
6448
+ console.log(` failed: ${(e as Error).message}\n`);
4671
6449
  }
6450
+
4672
6451
  if (instances.length === 0) {
4673
- console.error('\nBrain provisioning is taking longer than expected. Run setup again in a minute.\n');
6452
+ console.error('\nCould not create an instance automatically. Opening https://cachly.dev/instances …\n');
6453
+ try {
6454
+ const { execSync } = await import('node:child_process');
6455
+ const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
6456
+ execSync(`${openCmd} https://cachly.dev/instances`, { stdio: 'ignore' });
6457
+ } catch { /* ignore */ }
4674
6458
  rl.close(); process.exit(1);
4675
6459
  }
4676
6460
  }
@@ -4690,15 +6474,28 @@ if (process.argv[2] === 'setup') {
4690
6474
  // ── Step 3: Detect editors ────────────────────────────────────────────────
4691
6475
  const cwd = process.cwd();
4692
6476
  const detected: string[] = [];
6477
+ const { homedir } = await import('node:os');
6478
+ const home = homedir();
4693
6479
  // Claude Code: always include (CLAUDE.md is universal)
4694
6480
  detected.push('claude');
4695
6481
  if (existsSync(resolve(cwd, '.cursor'))) detected.push('cursor');
4696
6482
  if (existsSync(resolve(cwd, '.windsurf'))) detected.push('windsurf');
4697
6483
  if (existsSync(resolve(cwd, '.vscode'))) detected.push('copilot');
4698
6484
  if (existsSync(resolve(cwd, '.continue'))) detected.push('continue');
6485
+ // Cline: VS Code extension — detect via globalStorage on the machine
6486
+ const clineGlobalDir = process.platform === 'darwin'
6487
+ ? resolve(home, 'Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev')
6488
+ : resolve(home, '.vscode/extensions');
6489
+ if (existsSync(clineGlobalDir) && !detected.includes('copilot')) detected.push('cline');
6490
+ else if (existsSync(clineGlobalDir)) detected.push('cline'); // both copilot + cline share .vscode/mcp.json — fine
6491
+ // Zed editor — detect via app data dir
6492
+ const zedDir = process.platform === 'darwin'
6493
+ ? resolve(home, 'Library/Application Support/Zed')
6494
+ : resolve(home, '.config/zed');
6495
+ if (existsSync(zedDir)) detected.push('zed');
4699
6496
 
4700
6497
  const editorLabel = (e: string) =>
4701
- ({ claude: 'Claude Code', cursor: 'Cursor', windsurf: 'Windsurf', copilot: 'GitHub Copilot', continue: 'Continue.dev' })[e] ?? e;
6498
+ ({ claude: 'Claude Code', cursor: 'Cursor', windsurf: 'Windsurf', copilot: 'GitHub Copilot', continue: 'Continue.dev', cline: 'Cline (VSCode)', zed: 'Zed' })[e] ?? e;
4702
6499
 
4703
6500
  console.log(`Step 3: Detected editors: ${detected.map(editorLabel).join(', ')}`);
4704
6501
  const editorsRaw = await ask(` Configure for which? [all / ${detected.join('/')}] (Enter = all): `);
@@ -4707,7 +6504,21 @@ if (process.argv[2] === 'setup') {
4707
6504
  : editorsRaw.split(/[,\s]+/).map(s => s.toLowerCase()).filter(s => EDITOR_FILES[s]);
4708
6505
  console.log('');
4709
6506
 
4710
- // ── Step 4: Write editor configs ──────────────────────────────────────────
6507
+ // ── Step 4: Email opt-in ──────────────────────────────────────────────────
6508
+ console.log('Step 4: Stay in the loop (optional)');
6509
+ const email = await ask(' Email for release notes & tips? [Enter to skip]: ');
6510
+ if (email && email.includes('@')) {
6511
+ try {
6512
+ await fetch(`${API_URL}/api/newsletter/subscribe`, {
6513
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
6514
+ body: JSON.stringify({ email: email.trim().toLowerCase(), source: 'mcp-setup' }),
6515
+ signal: AbortSignal.timeout(5000),
6516
+ });
6517
+ console.log('✅ Subscribed — you\'ll only hear from us when it matters.\n');
6518
+ } catch { console.log('⚠️ Could not subscribe (no internet?). Continuing...\n'); }
6519
+ } else { console.log(' Skipped.\n'); }
6520
+
6521
+ // ── Step 5: Write editor configs ──────────────────────────────────────────
4711
6522
  for (const editor of editorsToSetup) {
4712
6523
  const configFile = EDITOR_FILES[editor] ?? '.mcp.json';
4713
6524
  const configPath = resolve(cwd, configFile);
@@ -4716,7 +6527,7 @@ if (process.argv[2] === 'setup') {
4716
6527
  console.log(`✅ Written: ${configFile}`);
4717
6528
  }
4718
6529
 
4719
- // ── Step 5: CLAUDE.md (always — idempotent) ───────────────────────────────
6530
+ // ── Step 6: CLAUDE.md (always — idempotent) ───────────────────────────────
4720
6531
  const mdResult = await writeClaudeMd(cwd, instance.id);
4721
6532
  const mdLabel = mdResult === 'updated' ? '✅ Updated' : mdResult === 'appended' ? '✅ Appended to' : '✅ Written';
4722
6533
  console.log(`${mdLabel}: CLAUDE.md`);
@@ -4724,162 +6535,172 @@ if (process.argv[2] === 'setup') {
4724
6535
  rl.close();
4725
6536
  console.log(`\n🧠 Done! Restart your editor — the \`cachly\` MCP tools will appear.`);
4726
6537
  console.log(` Your AI now has persistent memory across every session.\n`);
6538
+
6539
+ // ── Step 7: Show first session_start preview (Aha moment) ────────────────
6540
+ // Fetch brain health from the API to show the user what their agent will see.
6541
+ process.stdout.write('\n⏳ Fetching your Brain health preview...');
6542
+ try {
6543
+ const brainRes = await fetch(`${API_URL}/api/v1/instances/${instance.id}/brain/stats`, {
6544
+ headers: { Authorization: `Bearer ${token}` },
6545
+ signal: AbortSignal.timeout(6000),
6546
+ });
6547
+ if (brainRes.ok) {
6548
+ const brainData = await brainRes.json() as {
6549
+ lesson_count?: number; context_count?: number;
6550
+ open_failures?: number; quality_score?: number;
6551
+ };
6552
+ const lessons = brainData.lesson_count ?? 0;
6553
+ const contexts = brainData.context_count ?? 0;
6554
+ const score = brainData.quality_score ?? 0;
6555
+ const level = lessons === 0 ? 'Intern 🌱' :
6556
+ lessons < 10 ? 'Junior Dev 🔧' :
6557
+ lessons < 30 ? 'Mid Dev ⚡' :
6558
+ lessons < 60 ? 'Senior Dev 🧠' :
6559
+ lessons < 100 ? 'Staff Eng 🚀' : 'Principal Eng 🏆';
6560
+ console.log(' ✓\n');
6561
+ console.log('┌──────────────────────────────────────────────────────┐');
6562
+ console.log(`│ 🧠 Brain Health Report │`);
6563
+ console.log('├──────────────────────────────────────────────────────┤');
6564
+ console.log(`│ Lessons stored : ${String(lessons).padEnd(6)} │ Level: ${level.padEnd(20)}│`);
6565
+ console.log(`│ Context entries : ${String(contexts).padEnd(6)} │ Quality score: ${String(Math.round(score * 100)).padEnd(3)}% │`);
6566
+ if (lessons === 0) {
6567
+ console.log('├──────────────────────────────────────────────────────┤');
6568
+ console.log('│ Your Brain is empty and ready to learn. │');
6569
+ console.log('│ After your first coding session it will contain: │');
6570
+ console.log('│ • Lessons from every bug you fixed │');
6571
+ console.log('│ • Your project\'s indexed files │');
6572
+ console.log('│ • A session summary for next time │');
6573
+ }
6574
+ console.log('└──────────────────────────────────────────────────────┘');
6575
+ } else {
6576
+ console.log(' (skipped)');
6577
+ }
6578
+ } catch { console.log(' (skipped)'); }
6579
+
6580
+ console.log(`\n Your AI will call session_start automatically when it uses the first tool.`);
6581
+ console.log(` To see your Brain at any time: https://cachly.dev/instances/${instance.id}\n`);
4727
6582
  process.exit(0);
4728
6583
  }
4729
6584
 
4730
- // ── CLI: cachly join <token> (team invite — zero friction) ────────────────────
4731
- // Usage: npx @cachly-dev/mcp-server join <token>
4732
- //
4733
- // Connects the user to a shared Brain instance via an invite link.
4734
- // Flow: resolve token → show instance info → Device Code auth → write configs.
6585
+ // ── CLI: cachly index <dir> ───────────────────────────────────────────────────
6586
+ // Usage: npx @cachly-dev/mcp-server@latest index [./path/to/project]
6587
+ // Indexes the project directory into the Brain — perfect for CI/CD cron jobs.
4735
6588
 
4736
- if (process.argv[2] === 'join') {
4737
- const { writeFile, mkdir } = await import('node:fs/promises');
4738
- const { existsSync } = await import('node:fs');
4739
- const { resolve, dirname } = await import('node:path');
6589
+ if (process.argv[2] === 'index') {
6590
+ const { resolve } = await import('node:path');
6591
+ const argv = process.argv.slice(3);
6592
+ const flag = (name: string) => { const i = argv.indexOf(`--${name}`); return i !== -1 ? argv[i + 1] : undefined; };
4740
6593
 
4741
- const inviteToken = process.argv[3] ?? '';
4742
- if (!inviteToken) {
4743
- console.error('\n❌ Usage: npx @cachly-dev/mcp-server@latest join <token>\n');
4744
- console.error(' Get a token from a teammate: invite_link() in their AI assistant.\n');
6594
+ const dir = resolve(flag('dir') ?? argv.find(a => !a.startsWith('--')) ?? '.');
6595
+ const instanceId = flag('instance-id') ?? process.env.CACHLY_BRAIN_INSTANCE_ID;
6596
+ const maxFiles = parseInt(flag('max-files') ?? '500', 10);
6597
+ const namespace = flag('namespace') ?? 'cachly:sem:code';
6598
+
6599
+ if (!instanceId || !JWT) {
6600
+ console.error('\n❌ CACHLY_BRAIN_INSTANCE_ID and CACHLY_JWT must be set\n');
6601
+ console.error(' export CACHLY_BRAIN_INSTANCE_ID=<uuid>');
6602
+ console.error(' export CACHLY_JWT=<cky_live_...>');
6603
+ console.error(' npx @cachly-dev/mcp-server@latest index ./my-project\n');
4745
6604
  process.exit(1);
4746
6605
  }
4747
6606
 
4748
- console.log('\n🧠 cachly AI Brain — Team Join');
4749
- console.log('──────────────────────────────────\n');
6607
+ console.log(`\n📂 Indexing: ${dir}`);
6608
+ console.log(` Instance: ${instanceId.slice(0, 8)}… Max files: ${maxFiles}\n`);
4750
6609
 
4751
- // Step 1: Resolve the invite token (public, no auth)
4752
- process.stdout.write('⏳ Resolving invite...');
4753
- let instanceId = '', instanceName = '', instanceTier = '';
4754
6610
  try {
4755
- const infoRes = await fetch(`${API_URL}/api/invite/${inviteToken}`, {
4756
- signal: AbortSignal.timeout(10000),
6611
+ const result = await handleTool('index_project', {
6612
+ instance_id: instanceId,
6613
+ dir,
6614
+ max_files: maxFiles,
6615
+ ttl: 86400 * 7,
6616
+ namespace,
4757
6617
  });
4758
- if (!infoRes.ok) {
4759
- console.error(`\n\n❌ Invite not found or expired (HTTP ${infoRes.status}).`);
4760
- console.error(' Ask your teammate to generate a new one with: invite_link()\n');
4761
- process.exit(1);
4762
- }
4763
- const info = await infoRes.json() as { instance_id: string; instance_name: string; tier: string; label?: string };
4764
- instanceId = info.instance_id;
4765
- instanceName = info.instance_name;
4766
- instanceTier = info.tier ?? 'free';
4767
- console.log(` ✓\n`);
4768
- const label = info.label ? ` ("${info.label}")` : '';
4769
- console.log(` Brain instance: \x1b[1m${instanceName}\x1b[0m${label} [${instanceTier}]`);
4770
- console.log(` Instance ID: ${instanceId}\n`);
4771
- } catch (e) {
4772
- console.error(`\n\n❌ Could not reach API: ${(e as Error).message}\n`);
6618
+ console.log(result);
6619
+ console.log('\n✅ Indexing complete.\n');
6620
+ } catch (err) {
6621
+ console.error(`\n❌ Indexing failed: ${(err as Error).message}\n`);
4773
6622
  process.exit(1);
4774
6623
  }
6624
+ process.exit(0);
6625
+ }
4775
6626
 
4776
- // Step 2: Authenticate via Device Code Flow
4777
- const AUTH_BASE = 'https://auth.cachly.dev/realms/cachly/protocol/openid-connect';
4778
- const CLIENT_ID = 'cachly-cli';
4779
-
4780
- console.log('Step 1: Sign in to cachly (your own account — free, no credit card)\n');
6627
+ // ── Start ─────────────────────────────────────────────────────────────────────
4781
6628
 
4782
- let token = process.env.CACHLY_JWT ?? '';
4783
- if (token) {
4784
- console.log('✓ Using token from CACHLY_JWT env var\n');
4785
- } else {
4786
- let deviceCode = '', userCode = '', verifyUri = '', pollInterval = 5000;
4787
- try {
4788
- const deviceRes = await fetch(`${AUTH_BASE}/auth/device`, {
4789
- method: 'POST',
4790
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
4791
- body: `client_id=${CLIENT_ID}&scope=openid`,
4792
- signal: AbortSignal.timeout(10000),
4793
- });
4794
- if (!deviceRes.ok) throw new Error(`HTTP ${deviceRes.status}`);
4795
- const data = await deviceRes.json() as {
4796
- device_code: string; user_code: string;
4797
- verification_uri_complete: string; interval: number;
4798
- };
4799
- deviceCode = data.device_code;
4800
- userCode = data.user_code;
4801
- verifyUri = data.verification_uri_complete;
4802
- pollInterval = (data.interval ?? 5) * 1000;
4803
- } catch (e) {
4804
- console.error(`\n❌ Auth service unreachable: ${(e as Error).message}`);
4805
- process.exit(1);
4806
- }
6629
+ // Warn on stderr when credentials are missing so the user sees a clear
6630
+ // actionable message in their editor's MCP log instead of silent failures.
6631
+ if (!JWT) {
6632
+ process.stderr.write(
6633
+ '\n' +
6634
+ '╔══════════════════════════════════════════════════════════════════╗\n' +
6635
+ '║ 🧠 cachly AI Brain — Setup required ║\n' +
6636
+ '╠══════════════════════════════════════════════════════════════════╣\n' +
6637
+ '║ ║\n' +
6638
+ '║ CACHLY_JWT is not set. Get your free credentials at: ║\n' +
6639
+ '║ ║\n' +
6640
+ '║ 👉 https://cachly.dev/setup-ai ║\n' +
6641
+ '║ ║\n' +
6642
+ '║ Then run the interactive setup wizard: ║\n' +
6643
+ '║ ║\n' +
6644
+ '║ npx @cachly-dev/mcp-server@latest setup ║\n' +
6645
+ '║ ║\n' +
6646
+ '║ Free tier — no credit card required. ║\n' +
6647
+ '╚══════════════════════════════════════════════════════════════════╝\n' +
6648
+ '\n',
6649
+ );
6650
+ }
4807
6651
 
4808
- console.log(` Code: \x1b[1;33m${userCode}\x1b[0m`);
4809
- console.log(` URL: ${verifyUri}\n`);
6652
+ // ── Update nudge (non-blocking, fire-and-forget) ─────────────────────────────
6653
+ // Check npm registry once per process start; if outdated, log to stderr so
6654
+ // the editor's MCP log shows an actionable one-liner. Skipped if opted out.
6655
+ if (!process.env.CACHLY_NO_UPDATE_CHECK) {
6656
+ (async () => {
4810
6657
  try {
4811
- const { execSync } = await import('node:child_process');
4812
- const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
4813
- execSync(`${openCmd} "${verifyUri}"`, { stdio: 'ignore' });
4814
- console.log(' ✓ Browser opened — confirm the code above to continue...\n');
4815
- } catch {
4816
- console.log(' 👉 Open the URL above in your browser and confirm the code.\n');
4817
- }
4818
-
4819
- process.stdout.write(' Waiting for authorization');
4820
- const deadline = Date.now() + 10 * 60 * 1000;
4821
- while (Date.now() < deadline) {
4822
- await new Promise(r => setTimeout(r, pollInterval));
4823
- process.stdout.write('.');
4824
- try {
4825
- const td = await (await fetch(`${AUTH_BASE}/token`, {
4826
- method: 'POST',
4827
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
4828
- body: `client_id=${CLIENT_ID}&grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=${deviceCode}`,
4829
- })).json() as { access_token?: string; error?: string };
4830
- if (td.access_token) { token = td.access_token; console.log(' \x1b[32m✓ Authorized!\x1b[0m\n'); break; }
4831
- if (td.error === 'slow_down') pollInterval = Math.min(pollInterval + 2000, 15000);
4832
- else if (td.error && td.error !== 'authorization_pending') {
4833
- console.error(`\n\n❌ Auth error: ${td.error}\n`); process.exit(1);
6658
+ const res = await fetch(
6659
+ `https://registry.npmjs.org/@cachly-dev/mcp-server/latest`,
6660
+ { signal: AbortSignal.timeout(4000) },
6661
+ );
6662
+ if (res.ok) {
6663
+ const data = await res.json() as { version: string };
6664
+ const latest = data?.version ?? '';
6665
+ if (latest && latest !== CURRENT_VERSION) {
6666
+ process.stderr.write(
6667
+ `\n⚡ cachly update available: ${CURRENT_VERSION} → ${latest}\n` +
6668
+ ` Run: npx @cachly-dev/mcp-server@latest setup\n\n`,
6669
+ );
4834
6670
  }
4835
- } catch { /* hiccup — keep polling */ }
4836
- }
4837
- if (!token) { console.error('\n\n❌ Timed out. Run the command again.\n'); process.exit(1); }
6671
+ }
6672
+ } catch { /* ignore – network unavailable or timeout */ }
6673
+ })();
6674
+ }
4838
6675
 
4839
- // Exchange Keycloak JWT → long-lived cky_live_ API key
4840
- if (token.startsWith('eyJ')) {
4841
- process.stdout.write('⏳ Generating your API key...');
4842
- try {
4843
- const keyBody = await (await fetch(`${API_URL}/api/v1/api-keys`, {
4844
- method: 'POST',
4845
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
4846
- body: JSON.stringify({ name: 'cachly-mcp-join', scope: 'read_write' }),
4847
- })).json() as { key?: string };
4848
- if (keyBody.key) { token = keyBody.key; console.log(' ✓\n'); }
4849
- else throw new Error('no key in response');
4850
- } catch { console.log(' (skipped — JWT used directly)\n'); }
6676
+ const httpPort = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined;
6677
+
6678
+ if (httpPort) {
6679
+ // ── HTTP mode (Streamable HTTP transport) ───────────────────────────────
6680
+ // Used for Smithery URL deployment: PORT=3000 node dist/index.js
6681
+ const { createServer } = await import('node:http');
6682
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
6683
+ await server.connect(transport);
6684
+ const httpServer = createServer((req, res) => {
6685
+ const url = new URL(req.url ?? '/', `http://localhost:${httpPort}`);
6686
+ if (url.pathname === '/health') {
6687
+ res.writeHead(200, { 'Content-Type': 'application/json' });
6688
+ res.end(JSON.stringify({ status: 'ok', version: CURRENT_VERSION }));
6689
+ return;
4851
6690
  }
4852
- }
4853
-
4854
- // Step 3: Detect editors and write configs
4855
- const cwd = process.cwd();
4856
- const detected: string[] = ['claude'];
4857
- if (existsSync(resolve(cwd, '.cursor'))) detected.push('cursor');
4858
- if (existsSync(resolve(cwd, '.windsurf'))) detected.push('windsurf');
4859
- if (existsSync(resolve(cwd, '.vscode'))) detected.push('copilot');
4860
- if (existsSync(resolve(cwd, '.continue'))) detected.push('continue');
4861
-
4862
- console.log(`Step 2: Writing configs for: ${detected.join(', ')}\n`);
4863
-
4864
- for (const editor of detected) {
4865
- const configFile = EDITOR_FILES[editor] ?? '.mcp.json';
4866
- const configPath = resolve(cwd, configFile);
4867
- await mkdir(dirname(configPath), { recursive: true });
4868
- await writeFile(configPath, buildMcpConfig(token, instanceId, editor), 'utf-8');
4869
- console.log(`✅ Written: ${configFile}`);
4870
- }
4871
-
4872
- const mdResult = await writeClaudeMd(cwd, instanceId);
4873
- const mdLabel = mdResult === 'updated' ? '✅ Updated' : mdResult === 'appended' ? '✅ Appended to' : '✅ Written';
4874
- console.log(`${mdLabel}: CLAUDE.md`);
4875
-
4876
- console.log(`\n🧠 Joined! You're now connected to "${instanceName}".`);
4877
- console.log(` Restart your editor — all ${instanceName} lessons are now available via session_start.\n`);
4878
- process.exit(0);
6691
+ if (url.pathname === '/mcp' || url.pathname === '/') {
6692
+ transport.handleRequest(req, res);
6693
+ return;
6694
+ }
6695
+ res.writeHead(404);
6696
+ res.end('not found');
6697
+ });
6698
+ httpServer.listen(httpPort, () => {
6699
+ process.stderr.write(`cachly-mcp HTTP server listening on :${httpPort}\n`);
6700
+ });
6701
+ } else {
6702
+ // ── stdio mode (default for local editor use) ───────────────────────────
6703
+ const transport = new StdioServerTransport();
6704
+ await server.connect(transport);
4879
6705
  }
4880
6706
 
4881
- // ── Start ─────────────────────────────────────────────────────────────────────
4882
-
4883
- const transport = new StdioServerTransport();
4884
- await server.connect(transport);
4885
-