@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/dist/index.js 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,20 +34,20 @@ 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)
38
40
  */
39
41
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
40
42
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
43
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
41
44
  import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
42
45
  import { Redis } from 'ioredis';
43
46
  // ── Config ────────────────────────────────────────────────────────────────────
44
47
  const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
45
- const AUTH_URL = process.env.CACHLY_AUTH_URL ?? 'https://auth.cachly.dev';
46
48
  const JWT = process.env.CACHLY_JWT ?? '';
47
49
  const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
50
+ const CURRENT_VERSION = '0.5.26';
48
51
  /**
49
52
  * ┌──────────────────────────────────────────────────────────────────────┐
50
53
  * │ EMBEDDING PROVIDER — pluggable, client-side first │
@@ -63,6 +66,43 @@ const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
63
66
  * └──────────────────────────────────────────────────────────────────────┘
64
67
  */
65
68
  const EMBED_PROVIDER = (process.env.CACHLY_EMBED_PROVIDER ?? detectEmbedProvider()).toLowerCase();
69
+ // ── Confidence Decay Config (all values configurable via env) ─────────────────
70
+ const CONFIDENCE_WARN_DAYS = Number(process.env.CACHLY_CONFIDENCE_WARN_DAYS ?? 5); // → 0.7
71
+ const CONFIDENCE_STALE_DAYS = Number(process.env.CACHLY_CONFIDENCE_STALE_DAYS ?? 10); // → 0.5
72
+ const CONFIDENCE_WARN_VALUE = 0.7;
73
+ const CONFIDENCE_STALE_VALUE = 0.5;
74
+ /** Calculate current confidence for a lesson based on how long since last verified. */
75
+ function calculateConfidence(lesson) {
76
+ const ref = lesson.verified_at ?? lesson.ts;
77
+ const ageMs = Date.now() - new Date(ref).getTime();
78
+ const ageDays = ageMs / 86400000;
79
+ if (ageDays >= CONFIDENCE_STALE_DAYS)
80
+ return CONFIDENCE_STALE_VALUE;
81
+ if (ageDays >= CONFIDENCE_WARN_DAYS)
82
+ return CONFIDENCE_WARN_VALUE;
83
+ // Linear interpolation between fresh (1.0) and warn threshold
84
+ return 1.0 - (ageDays / CONFIDENCE_WARN_DAYS) * (1.0 - CONFIDENCE_WARN_VALUE);
85
+ }
86
+ /** Render a confidence badge string. */
87
+ function confidenceBadge(confidence, ageDays) {
88
+ if (confidence >= 0.9)
89
+ return '✅';
90
+ if (confidence >= 0.7)
91
+ return `⚠️ (${Math.round(ageDays)}d old, confidence ${(confidence * 100).toFixed(0)}% — verify before applying)`;
92
+ return `🔴 STALE (${Math.round(ageDays)}d old, confidence ${(confidence * 100).toFixed(0)}% — likely outdated!)`;
93
+ }
94
+ /** Category-specific required fields for structured lessons. */
95
+ const STRUCTURED_TEMPLATES = {
96
+ 'deploy': { required: ['commands'], hint: 'deploy:* needs commands[]' },
97
+ 'bash': { required: ['commands'], hint: 'bash:* needs commands[]' },
98
+ 'infra': { required: ['commands'], hint: 'infra:* needs commands[] + verified on real system' },
99
+ 'pricing': { required: [], hint: 'pricing:* — add context with source (e.g. "Stripe Dashboard Apr 2026")' },
100
+ 'stripe': { required: [], hint: 'stripe:* — add context with Stripe API version' },
101
+ };
102
+ /** Fast content hash for index invalidation (not cryptographic, just change detection) */
103
+ function simpleHash(text) {
104
+ return createHash('md5').update(text).digest('hex').slice(0, 12);
105
+ }
66
106
  function detectEmbedProvider() {
67
107
  // Client-side keys first (direct, fast, no server roundtrip)
68
108
  if (process.env.OPENAI_API_KEY)
@@ -248,12 +288,17 @@ function embedProviderHint() {
248
288
  // • Recency boost — newer entries rank higher (exponential decay, 7-day half-life)
249
289
  // • Multi-query splitting — numbered lists, semicolons, conjunctions
250
290
  // • Fuzzy matching with Levenshtein distance ≤ 2
251
- // • Multilingual stopwords (EN, DE, FR, ES, IT, PT)
291
+ // • Multilingual stopwords (EN, DE, FR, ES, IT, PT, ZH, JA, KO, AR, HE)
292
+ // • CJK character bigram extraction — handles Chinese, Japanese, Korean
293
+ // • RTL language support (Arabic, Hebrew) — word tokenization + light stemming
294
+ // • Romanization matching — katakana → romaji tokens for romaji queries
295
+ // • Cross-language retrieval — tech term synonyms EN↔JA↔ZH↔KO↔AR↔HE
252
296
  // • Pipeline Redis reads for performance
253
297
  //
254
298
  /**
255
299
  * Stopwords — filtered out during tokenization.
256
- * Covers: English, German, French, Spanish, Italian, Portuguese.
300
+ * Covers: English, German, French, Spanish, Italian, Portuguese,
301
+ * Chinese (Simplified + Traditional), Japanese, Korean.
257
302
  * Keeps the index small and scores meaningful.
258
303
  */
259
304
  const STOPWORDS = new Set([
@@ -327,14 +372,443 @@ const STOPWORDS = new Set([
327
372
  'eu', 'tu', 'ele', 'ela', 'nós', 'eles', 'elas', 'você', 'vocês',
328
373
  'não', 'já', 'sim', 'bem', 'muito', 'também', 'ainda', 'sempre',
329
374
  'todo', 'toda', 'todos', 'cada', 'outro', 'outra', 'quando', 'porque',
375
+ // ── Chinese (Simplified + Traditional) — high-frequency function characters ──
376
+ '的', '了', '是', '在', '不', '和', '我', '他', '这', '中', '大', '为',
377
+ '上', '个', '国', '以', '要', '就', '出', '说', '们', '有', '来', '到',
378
+ '时', '地', '年', '得', '着', '那', '过', '后', '还', '与', '也', '可',
379
+ '于', '从', '但', '而', '被', '把', '让', '使', '对', '很', '都', '一',
380
+ '会', '没', '人', '它', '这个', '那个', '什么', '如果', '因为', '所以',
381
+ '已经', '可以', '这些', '那些', '我们', '他们', '她们', '它们',
382
+ // ── Japanese — common hiragana particles and auxiliary verbs ──
383
+ 'の', 'は', 'が', 'を', 'に', 'で', 'と', 'も', 'や', 'か', 'な', 'ね',
384
+ 'よ', 'わ', 'て', 'い', 'う', 'え', 'お', 'き', 'く', 'け', 'こ', 'さ',
385
+ 'し', 'す', 'せ', 'そ', 'た', 'ち', 'つ', 'ぬ', 'ん', 'から', 'まで',
386
+ 'より', 'へ', 'です', 'ます', 'ない', 'ある', 'いる', 'する', 'こと',
387
+ 'もの', 'ので', 'では', 'には', 'との', 'への', 'から', 'まで',
388
+ // ── Korean — common particles and auxiliary forms ──
389
+ '이', '가', '은', '는', '을', '를', '의', '에', '와', '과', '도',
390
+ '로', '에서', '한', '하', '있', '없', '것', '수', '않', '들',
391
+ '이다', '하다', '이고', '이며', '라고', '에게', '에서', '으로',
392
+ // ── Arabic — high-frequency function words & particles ──
393
+ 'في', 'من', 'إلى', 'على', 'عن', 'مع', 'هذا', 'هذه', 'ذلك', 'تلك',
394
+ 'الذي', 'التي', 'الذين', 'اللواتي', 'هو', 'هي', 'هم', 'هن', 'نحن',
395
+ 'أنت', 'أنتم', 'أنا', 'كان', 'كانت', 'كانوا', 'يكون', 'تكون',
396
+ 'أن', 'إن', 'لأن', 'لكن', 'أو', 'بل', 'ثم', 'حتى', 'إذا', 'كي',
397
+ 'قد', 'لقد', 'لم', 'لن', 'ما', 'لا', 'ليس', 'غير', 'بعض', 'كل',
398
+ 'جميع', 'أي', 'كيف', 'متى', 'أين', 'لماذا', 'ماذا', 'هل', 'ال',
399
+ 'و', 'ف', 'ب', 'ل', 'ك', 'يا', 'أم', 'إلا', 'عند', 'بين',
400
+ // ── Hebrew — common particles, pronouns, conjunctions ──
401
+ 'של', 'את', 'אל', 'על', 'עם', 'לא', 'הוא', 'היא', 'הם', 'הן',
402
+ 'אני', 'אתה', 'אנחנו', 'אתם', 'כי', 'אם', 'אבל', 'גם', 'כבר',
403
+ 'רק', 'עוד', 'יש', 'אין', 'מה', 'זה', 'זאת', 'אלה', 'כל', 'כן',
404
+ 'לו', 'לה', 'להם', 'בו', 'בה', 'בהם', 'שם', 'כך', 'כן', 'מי',
405
+ 'אשר', 'אחרי', 'לפני', 'תחת', 'בין', 'מאז', 'עד', 'כמו', 'אז',
406
+ ]);
407
+ // CJK Unicode ranges used for bigram extraction
408
+ const CJK_RE = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]/;
409
+ const SEGMENT_RE = /([\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]+|[^\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]+)/g;
410
+ // RTL Unicode ranges: Arabic (U+0600–U+06FF + extended), Hebrew (U+0590–U+05FF)
411
+ const RTL_RE = /[\u0590-\u05ff\u0600-\u06ff\u0750-\u077f\u08a0-\u08ff\ufb1d-\ufb4f\ufb50-\ufdff\ufe70-\ufeff]/;
412
+ // ── Katakana → Romaji conversion table (Hepburn system) ──────────────────────
413
+ // Digraphs must be listed before single chars so they match first.
414
+ const KATA_ROMAJI_TABLE = [
415
+ // Special combinations for loanwords
416
+ ['ファ', 'fa'], ['フィ', 'fi'], ['フェ', 'fe'], ['フォ', 'fo'],
417
+ ['ティ', 'ti'], ['ディ', 'di'], ['トゥ', 'tu'], ['ドゥ', 'du'],
418
+ ['ウィ', 'wi'], ['ウェ', 'we'], ['ウォ', 'wo'],
419
+ ['チェ', 'che'], ['ジェ', 'je'], ['シェ', 'she'],
420
+ ['イェ', 'ye'], ['ヴァ', 'va'], ['ヴィ', 'vi'], ['ヴェ', 've'], ['ヴォ', 'vo'],
421
+ // Digraphs (2-char → romaji)
422
+ ['キャ', 'kya'], ['キュ', 'kyu'], ['キョ', 'kyo'],
423
+ ['シャ', 'sha'], ['シュ', 'shu'], ['ショ', 'sho'],
424
+ ['チャ', 'cha'], ['チュ', 'chu'], ['チョ', 'cho'],
425
+ ['ニャ', 'nya'], ['ニュ', 'nyu'], ['ニョ', 'nyo'],
426
+ ['ヒャ', 'hya'], ['ヒュ', 'hyu'], ['ヒョ', 'hyo'],
427
+ ['ミャ', 'mya'], ['ミュ', 'myu'], ['ミョ', 'myo'],
428
+ ['リャ', 'rya'], ['リュ', 'ryu'], ['リョ', 'ryo'],
429
+ ['ギャ', 'gya'], ['ギュ', 'gyu'], ['ギョ', 'gyo'],
430
+ ['ジャ', 'ja'], ['ジュ', 'ju'], ['ジョ', 'jo'],
431
+ ['ビャ', 'bya'], ['ビュ', 'byu'], ['ビョ', 'byo'],
432
+ ['ピャ', 'pya'], ['ピュ', 'pyu'], ['ピョ', 'pyo'],
433
+ // Single chars
434
+ ['ア', 'a'], ['イ', 'i'], ['ウ', 'u'], ['エ', 'e'], ['オ', 'o'],
435
+ ['カ', 'ka'], ['キ', 'ki'], ['ク', 'ku'], ['ケ', 'ke'], ['コ', 'ko'],
436
+ ['サ', 'sa'], ['シ', 'shi'], ['ス', 'su'], ['セ', 'se'], ['ソ', 'so'],
437
+ ['タ', 'ta'], ['チ', 'chi'], ['ツ', 'tsu'], ['テ', 'te'], ['ト', 'to'],
438
+ ['ナ', 'na'], ['ニ', 'ni'], ['ヌ', 'nu'], ['ネ', 'ne'], ['ノ', 'no'],
439
+ ['ハ', 'ha'], ['ヒ', 'hi'], ['フ', 'fu'], ['ヘ', 'he'], ['ホ', 'ho'],
440
+ ['マ', 'ma'], ['ミ', 'mi'], ['ム', 'mu'], ['メ', 'me'], ['モ', 'mo'],
441
+ ['ヤ', 'ya'], ['ユ', 'yu'], ['ヨ', 'yo'],
442
+ ['ラ', 'ra'], ['リ', 'ri'], ['ル', 'ru'], ['レ', 're'], ['ロ', 'ro'],
443
+ ['ワ', 'wa'], ['ヲ', 'wo'], ['ン', 'n'],
444
+ // Voiced
445
+ ['ガ', 'ga'], ['ギ', 'gi'], ['グ', 'gu'], ['ゲ', 'ge'], ['ゴ', 'go'],
446
+ ['ザ', 'za'], ['ジ', 'ji'], ['ズ', 'zu'], ['ゼ', 'ze'], ['ゾ', 'zo'],
447
+ ['ダ', 'da'], ['ヂ', 'ji'], ['ヅ', 'zu'], ['デ', 'de'], ['ド', 'do'],
448
+ ['バ', 'ba'], ['ビ', 'bi'], ['ブ', 'bu'], ['ベ', 'be'], ['ボ', 'bo'],
449
+ ['パ', 'pa'], ['ピ', 'pi'], ['プ', 'pu'], ['ペ', 'pe'], ['ポ', 'po'],
450
+ ['ヴ', 'v'],
451
+ // Long vowel / small chars
452
+ ['ー', ''], ['ァ', 'a'], ['ィ', 'i'], ['ゥ', 'u'], ['ェ', 'e'], ['ォ', 'o'],
453
+ ['ッ', ''], // handled separately (doubles next consonant)
454
+ ];
455
+ const KATA_MAP = new Map(KATA_ROMAJI_TABLE);
456
+ /**
457
+ * Convert a katakana string to Hepburn romaji.
458
+ * Handles digraphs, geminate consonants (ッ), and long vowel marks (ー).
459
+ */
460
+ function katakanaToRomaji(kata) {
461
+ let result = '';
462
+ let i = 0;
463
+ while (i < kata.length) {
464
+ // Geminate consonant: ッ doubles the following consonant
465
+ if (kata[i] === 'ッ' && i + 1 < kata.length) {
466
+ const next = KATA_MAP.get(kata[i + 1]) ?? KATA_MAP.get(kata[i + 1] + kata[i + 2]) ?? '';
467
+ if (next.length > 0)
468
+ result += next[0]; // double first consonant
469
+ i++;
470
+ continue;
471
+ }
472
+ // Try 2-char digraph first
473
+ if (i + 1 < kata.length) {
474
+ const two = kata[i] + kata[i + 1];
475
+ const r2 = KATA_MAP.get(two);
476
+ if (r2 !== undefined) {
477
+ result += r2;
478
+ i += 2;
479
+ continue;
480
+ }
481
+ }
482
+ // Single char
483
+ const r1 = KATA_MAP.get(kata[i]);
484
+ if (r1 !== undefined)
485
+ result += r1;
486
+ i++;
487
+ }
488
+ return result;
489
+ }
490
+ /**
491
+ * Arabic light stemmer — strips definite article and common prefix particles.
492
+ * Handles: ال (al-), و (wa-), ب (bi-), ل (li-), ف (fa-), ك (ka-).
493
+ * Only strips when the result is still ≥ 3 chars to avoid over-stemming.
494
+ */
495
+ function arabicLightStem(word) {
496
+ if (word.startsWith('ال') && word.length > 4)
497
+ return word.slice(2);
498
+ if (word.length > 3 && 'وبلفك'.includes(word[0]) && RTL_RE.test(word[1]))
499
+ return word.slice(1);
500
+ return word;
501
+ }
502
+ // ── Cross-lingual tech term dictionary ────────────────────────────────────────
503
+ // Maps tokens (English, Japanese katakana, Chinese, Korean, Arabic, Hebrew)
504
+ // to their equivalents in other languages. Used to expand tokens at search time
505
+ // so "deploy" finds documents containing "デプロイ" / "部署" / "배포", and vice versa.
506
+ //
507
+ // Keys must be lowercase. CJK values will be bigram-expanded at use time.
508
+ const CROSS_LINGUAL_MAP = new Map([
509
+ // English → all
510
+ ['deploy', ['デプロイ', '部署', '배포', 'deployment', 'deploying']],
511
+ ['deployment', ['デプロイ', '部署', '배포', 'deploy']],
512
+ ['container', ['コンテナ', '容器', '컨테이너', 'docker']],
513
+ ['server', ['サーバー', 'サーバ', '服务器', '서버', 'سيرفر', 'שרת']],
514
+ ['database', ['データベース', '数据库', '데이터베이스', 'db', 'قاعدة البيانات']],
515
+ ['cache', ['キャッシュ', '缓存', '캐시', 'كاش', 'מטמון']],
516
+ ['error', ['エラー', '错误', '오류', 'خطأ', 'שגיאה', 'exception', 'err']],
517
+ ['bug', ['バグ', '缺陷', '버그', 'issue', 'defect', 'خلل']],
518
+ ['fix', ['修正', '修复', '수정', 'bugfix', 'patch', 'hotfix', 'إصلاح', 'תיקון']],
519
+ ['build', ['ビルド', '构建', '빌드', 'بناء', 'בנייה']],
520
+ ['test', ['テスト', '测试', '테스트', 'اختبار', 'בדיקה']],
521
+ ['auth', ['認証', '认证', '인증', 'authentication', 'login', 'oauth', 'مصادقة', 'אימות']],
522
+ ['kubernetes', ['クベルネテス', 'k8s', 'kube']],
523
+ ['network', ['ネットワーク', '网络', '네트워크', 'شبكة', 'רשת']],
524
+ ['timeout', ['タイムアウト', '超时', '타임아웃', 'مهلة']],
525
+ ['memory', ['メモリ', '内存', '메모리', 'ram', 'ذاكرة', 'זיכרון']],
526
+ ['config', ['設定', '配置', '설정', 'configuration', 'settings', 'conf', 'إعدادات', 'הגדרות']],
527
+ ['install', ['インストール', '安装', '설치', 'تثبيت', 'התקנה']],
528
+ ['update', ['アップデート', '更新', '업데이트', 'upgrade', 'تحديث', 'עדכון']],
529
+ ['log', ['ログ', '日志', '로그', 'logging', 'سجل', 'יומן']],
530
+ ['port', ['ポート', '端口', '포트', 'منفذ', 'פורט']],
531
+ ['file', ['ファイル', '文件', '파일', 'ملف', 'קובץ']],
532
+ ['image', ['イメージ', '镜像', '이미지', 'صورة', 'תמונה']],
533
+ ['volume', ['ボリューム', '卷', '볼륨']],
534
+ ['cluster', ['クラスター', '集群', '클러스터']],
535
+ ['node', ['ノード', '节点', '노드']],
536
+ ['service', ['サービス', '服务', '서비스', 'svc', 'خدمة', 'שירות']],
537
+ ['certificate', ['証明書', '证书', '인증서', 'cert', 'ssl', 'tls', 'شهادة', 'תעודה']],
538
+ ['password', ['パスワード', '密码', '비밀번호', 'passwd', 'pwd', 'كلمة المرور', 'סיסמה']],
539
+ ['token', ['トークン', '令牌', '토큰', 'jwt', 'secret', 'رمز', 'אסימון']],
540
+ ['health', ['ヘルス', '健康', '헬스', 'healthcheck', 'probe', 'صحة']],
541
+ ['migration', ['マイグレーション', '迁移', '마이그레이션', 'migrate', 'هجرة']],
542
+ ['backup', ['バックアップ', '备份', '백업', 'نسخ احتياطي', 'גיבוי']],
543
+ ['monitor', ['モニター', '监控', '모니터링', 'monitoring', 'metrics', 'alert', 'مراقبة', 'ניטור']],
544
+ ['performance', ['パフォーマンス', '性能', '성능', 'latency', 'throughput']],
545
+ ['connection', ['接続', '连接', '연결', 'conn', 'socket', 'اتصال', 'חיבור']],
546
+ ['queue', ['キュー', '队列', '큐', 'طابور']],
547
+ ['redis', ['レディス', '레디스']],
548
+ ['nginx', ['エンジンエックス']],
549
+ ['linux', ['リナックス', 'لينكس']],
550
+ ['api', ['エーピーアイ', '接口', 'endpoint', 'واجهة برمجية', 'ממשק']],
551
+ ['healthcheck', ['ヘルスチェック', 'health', 'probe', 'فحص الصحة']],
552
+ ['loadbalancer', ['ロードバランサー', '负载均衡', '로드밸런서', 'lb']],
553
+ ['ssl', ['tls', 'https', 'certificate', 'cert']],
554
+ ['tls', ['ssl', 'https', 'certificate', 'cert']],
555
+ ['docker', ['container', 'コンテナ', '容器', '컨테이너', 'دوكر']],
556
+ ['git', ['version control', 'repo', 'repository', 'commit', 'push', 'pull']],
557
+ ['ci', ['pipeline', 'github actions', 'gitlab', 'jenkins', 'build']],
558
+ ['cd', ['deploy', 'deployment', 'release']],
559
+ ['debug', ['デバッグ', '调试', '디버그', 'debugging', 'breakpoint', 'تصحيح', 'איתור באגים']],
560
+ ['crash', ['クラッシュ', '崩溃', '크래시', 'panic', 'segfault', 'انهيار']],
561
+ ['restart', ['再起動', '重启', '재시작', 'reboot', 'إعادة تشغيل', 'הפעלה מחדש']],
562
+ ['permission', ['権限', '权限', '권한', 'access', 'acl', 'chmod', 'صلاحية', 'הרשאה']],
563
+ // Japanese katakana → English
564
+ ['デプロイ', ['deploy', 'deployment']],
565
+ ['コンテナ', ['container', 'docker']],
566
+ ['サーバー', ['server']],
567
+ ['サーバ', ['server']],
568
+ ['データベース', ['database', 'db']],
569
+ ['キャッシュ', ['cache']],
570
+ ['エラー', ['error', 'err']],
571
+ ['バグ', ['bug', 'issue']],
572
+ ['ビルド', ['build']],
573
+ ['テスト', ['test']],
574
+ ['認証', ['auth', 'authentication']],
575
+ ['設定', ['config', 'configuration']],
576
+ ['インストール', ['install']],
577
+ ['アップデート', ['update', 'upgrade']],
578
+ ['ログ', ['log', 'logs']],
579
+ ['ポート', ['port']],
580
+ ['ファイル', ['file']],
581
+ ['イメージ', ['image']],
582
+ ['クラスター', ['cluster']],
583
+ ['ノード', ['node']],
584
+ ['サービス', ['service']],
585
+ ['パスワード', ['password']],
586
+ ['トークン', ['token']],
587
+ ['バックアップ', ['backup']],
588
+ ['モニター', ['monitor', 'monitoring']],
589
+ ['接続', ['connection']],
590
+ ['キュー', ['queue']],
591
+ ['ヘルスチェック', ['healthcheck', 'health check', 'health']],
592
+ ['ロードバランサー', ['loadbalancer', 'load balancer']],
593
+ ['デバッグ', ['debug']],
594
+ ['クラッシュ', ['crash', 'panic']],
595
+ ['再起動', ['restart', 'reboot']],
596
+ ['権限', ['permission', 'access']],
597
+ // Chinese → English
598
+ ['部署', ['deploy', 'deployment']],
599
+ ['容器', ['container', 'docker']],
600
+ ['服务器', ['server']],
601
+ ['数据库', ['database']],
602
+ ['缓存', ['cache']],
603
+ ['错误', ['error']],
604
+ ['修复', ['fix', 'bugfix']],
605
+ ['构建', ['build']],
606
+ ['测试', ['test']],
607
+ ['认证', ['auth']],
608
+ ['配置', ['config']],
609
+ ['安装', ['install']],
610
+ ['更新', ['update']],
611
+ ['日志', ['log']],
612
+ ['端口', ['port']],
613
+ ['镜像', ['image']],
614
+ ['集群', ['cluster']],
615
+ ['节点', ['node']],
616
+ ['服务', ['service']],
617
+ ['密码', ['password']],
618
+ ['令牌', ['token']],
619
+ ['备份', ['backup']],
620
+ ['监控', ['monitor']],
621
+ ['连接', ['connection']],
622
+ ['队列', ['queue']],
623
+ ['性能', ['performance']],
624
+ ['内存', ['memory']],
625
+ ['调试', ['debug']],
626
+ ['崩溃', ['crash']],
627
+ ['重启', ['restart']],
628
+ ['权限', ['permission']],
629
+ // Korean → English
630
+ ['배포', ['deploy', 'deployment']],
631
+ ['컨테이너', ['container']],
632
+ ['서버', ['server']],
633
+ ['데이터베이스', ['database']],
634
+ ['캐시', ['cache']],
635
+ ['오류', ['error']],
636
+ ['수정', ['fix']],
637
+ ['빌드', ['build']],
638
+ ['테스트', ['test']],
639
+ ['인증', ['auth']],
640
+ ['설정', ['config']],
641
+ ['설치', ['install']],
642
+ ['업데이트', ['update']],
643
+ ['로그', ['log']],
644
+ ['포트', ['port']],
645
+ ['이미지', ['image']],
646
+ ['클러스터', ['cluster']],
647
+ ['노드', ['node']],
648
+ ['서비스', ['service']],
649
+ ['토큰', ['token']],
650
+ ['백업', ['backup']],
651
+ ['모니터링', ['monitor']],
652
+ ['연결', ['connection']],
653
+ ['큐', ['queue']],
654
+ ['성능', ['performance']],
655
+ ['메모리', ['memory']],
656
+ ['디버그', ['debug']],
657
+ ['크래시', ['crash']],
658
+ ['재시작', ['restart']],
659
+ ['권한', ['permission']],
660
+ // Arabic → English
661
+ ['خطأ', ['error']],
662
+ ['إصلاح', ['fix']],
663
+ ['بناء', ['build']],
664
+ ['اختبار', ['test']],
665
+ ['مصادقة', ['auth']],
666
+ ['إعدادات', ['config']],
667
+ ['تثبيت', ['install']],
668
+ ['تحديث', ['update']],
669
+ ['سجل', ['log']],
670
+ ['صورة', ['image']],
671
+ ['خدمة', ['service']],
672
+ ['نسخ احتياطي', ['backup']],
673
+ ['مراقبة', ['monitor']],
674
+ ['اتصال', ['connection']],
675
+ ['ذاكرة', ['memory']],
676
+ ['تصحيح', ['debug']],
677
+ ['شبكة', ['network']],
678
+ // Hebrew → English
679
+ ['שגיאה', ['error']],
680
+ ['תיקון', ['fix']],
681
+ ['בנייה', ['build']],
682
+ ['בדיקה', ['test']],
683
+ ['אימות', ['auth']],
684
+ ['הגדרות', ['config']],
685
+ ['התקנה', ['install']],
686
+ ['עדכון', ['update']],
687
+ ['יומן', ['log']],
688
+ ['שרת', ['server']],
689
+ ['שירות', ['service']],
690
+ ['גיבוי', ['backup']],
691
+ ['ניטור', ['monitor']],
692
+ ['חיבור', ['connection']],
693
+ ['זיכרון', ['memory']],
694
+ ['רשת', ['network']],
695
+ ['תמונה', ['image']],
696
+ ['סיסמה', ['password']],
697
+ ['הרשאה', ['permission']],
330
698
  ]);
331
- /** Tokenize text into meaningful keywords. Handles unicode (accented chars). */
699
+ /**
700
+ * Expand a token to its cross-lingual synonyms.
701
+ * Returns synonyms to be added to the token stream (pre-tokenized).
702
+ * CJK synonyms are NOT pre-bigram'd here — caller handles that.
703
+ */
704
+ function expandCrossLingual(token) {
705
+ return CROSS_LINGUAL_MAP.get(token) ?? [];
706
+ }
707
+ /**
708
+ * Tokenize text into meaningful keywords.
709
+ * Handles accented Latin, RTL (Arabic, Hebrew), and CJK (Chinese, Japanese, Korean).
710
+ *
711
+ * Features:
712
+ * • CJK → character bigrams
713
+ * • Katakana → additionally emits Hepburn romaji tokens (enables romaji queries)
714
+ * • Arabic/Hebrew → word tokenization with light Arabic stemming
715
+ * • All scripts → cross-lingual synonym expansion (EN↔JA↔ZH↔KO↔AR↔HE)
716
+ */
332
717
  function tokenize(text) {
333
- return text
334
- .toLowerCase()
335
- .replace(/[^a-záàâãäåæçéèêëíìîïñóòôõöúùûüýÿßœ0-9:_\-./]/g, ' ')
336
- .split(/\s+/)
337
- .filter(w => w.length > 1 && !STOPWORDS.has(w));
718
+ const tokens = [];
719
+ const lower = text.toLowerCase();
720
+ for (const [seg] of lower.matchAll(SEGMENT_RE)) {
721
+ if (CJK_RE.test(seg)) {
722
+ // CJK segment — extract overlapping character bigrams
723
+ for (let i = 0; i < seg.length - 1; i++) {
724
+ const bigram = seg[i] + seg[i + 1];
725
+ if (!STOPWORDS.has(seg[i]) && !STOPWORDS.has(bigram)) {
726
+ tokens.push(bigram);
727
+ }
728
+ }
729
+ // Trailing single character (for 1-char CJK terms)
730
+ if (seg.length >= 1) {
731
+ const last = seg[seg.length - 1];
732
+ if (!STOPWORDS.has(last))
733
+ tokens.push(last);
734
+ }
735
+ // Whole-segment cross-lingual lookup (for known tech terms like デプロイ, 部署, 배포)
736
+ // This ensures CJK full-words map to their English equivalents
737
+ const segSyns = expandCrossLingual(seg);
738
+ for (const syn of segSyns) {
739
+ if (!CJK_RE.test(syn)) {
740
+ syn.split(/\s+/).filter(w => w.length > 1 && !STOPWORDS.has(w)).forEach(w => tokens.push(w));
741
+ }
742
+ }
743
+ // Romanization: extract katakana-only portion and emit romaji tokens.
744
+ // This allows users to search "kontena" or "depuroi" to find katakana docs.
745
+ const kataOnly = seg.replace(/[^\u30a0-\u30ff]/g, '');
746
+ if (kataOnly.length >= 2) {
747
+ const romaji = katakanaToRomaji(kataOnly);
748
+ if (romaji.length >= 2 && romaji.length <= 40 && !STOPWORDS.has(romaji)) {
749
+ tokens.push(romaji);
750
+ }
751
+ }
752
+ }
753
+ else if (RTL_RE.test(seg)) {
754
+ // RTL segment (Arabic / Hebrew) — space-separated words with light stemming
755
+ const words = seg.trim().split(/\s+/);
756
+ for (const w of words) {
757
+ if (w.length <= 1 || STOPWORDS.has(w))
758
+ continue;
759
+ const stemmed = arabicLightStem(w);
760
+ if (stemmed.length > 1 && !STOPWORDS.has(stemmed))
761
+ tokens.push(stemmed);
762
+ }
763
+ }
764
+ else {
765
+ // Latin / other — existing logic preserved exactly
766
+ const words = seg
767
+ .replace(/[^a-záàâãäåæçéèêëíìîïñóòôõöúùûüýÿßœ0-9:_\-./]/g, ' ')
768
+ .split(/\s+/)
769
+ .filter(w => w.length > 1 && !STOPWORDS.has(w));
770
+ tokens.push(...words);
771
+ }
772
+ }
773
+ // ── Cross-lingual expansion ──────────────────────────────────────────────
774
+ // For each token, look up synonyms in other languages and add them.
775
+ // CJK synonyms are bigram-expanded inline; Latin/RTL synonyms added as-is.
776
+ const expansions = [];
777
+ const seen = new Set(tokens);
778
+ for (const tok of tokens) {
779
+ const syns = expandCrossLingual(tok);
780
+ for (const syn of syns) {
781
+ if (seen.has(syn))
782
+ continue;
783
+ seen.add(syn);
784
+ if (CJK_RE.test(syn)) {
785
+ // CJK synonym → expand to character bigrams
786
+ for (let i = 0; i < syn.length - 1; i++) {
787
+ const bg = syn[i] + syn[i + 1];
788
+ if (!STOPWORDS.has(syn[i]) && !STOPWORDS.has(bg))
789
+ expansions.push(bg);
790
+ }
791
+ if (syn.length >= 1) {
792
+ const last = syn[syn.length - 1];
793
+ if (!STOPWORDS.has(last))
794
+ expansions.push(last);
795
+ }
796
+ }
797
+ else if (RTL_RE.test(syn)) {
798
+ // RTL synonym
799
+ const stemmed = arabicLightStem(syn);
800
+ if (stemmed.length > 1 && !STOPWORDS.has(stemmed))
801
+ expansions.push(stemmed);
802
+ }
803
+ else {
804
+ // Latin synonym — split in case of multi-word (e.g. "load balancer")
805
+ const parts = syn.split(/\s+/).filter(w => w.length > 1 && !STOPWORDS.has(w));
806
+ expansions.push(...parts);
807
+ }
808
+ }
809
+ }
810
+ tokens.push(...expansions);
811
+ return tokens;
338
812
  }
339
813
  /**
340
814
  * Split a multi-topic query into sub-queries.
@@ -607,27 +1081,13 @@ async function keywordSearch(redis, patterns, query, topK = 10) {
607
1081
  return sorted.slice(0, topK);
608
1082
  }
609
1083
  // ── Exported for testing ──────────────────────────────────────────────────────
610
- export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS };
1084
+ export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS, katakanaToRomaji, arabicLightStem, expandCrossLingual, CROSS_LINGUAL_MAP };
611
1085
  // ── Connection pool ───────────────────────────────────────────────────────────
612
- /** Reuse Redis connections across tool calls (keyed by instance_id). Max 20 entries, LRU eviction. */
1086
+ /** Reuse Redis connections across tool calls (keyed by instance_id). */
613
1087
  const pool = new Map();
614
- const POOL_MAX = 20;
615
1088
  async function getConnection(instance_id) {
616
- if (pool.has(instance_id)) {
617
- // Move to end (most-recently-used)
618
- const conn = pool.get(instance_id);
619
- pool.delete(instance_id);
620
- pool.set(instance_id, conn);
621
- return conn;
622
- }
623
- // Evict oldest entry when pool is full
624
- if (pool.size >= POOL_MAX) {
625
- const oldest = pool.keys().next().value;
626
- if (oldest) {
627
- pool.get(oldest)?.quit().catch(() => undefined);
628
- pool.delete(oldest);
629
- }
630
- }
1089
+ if (pool.has(instance_id))
1090
+ return pool.get(instance_id);
631
1091
  const inst = await apiFetch(`/api/v1/instances/${instance_id}`);
632
1092
  if (inst.status !== 'running') {
633
1093
  throw new McpError(ErrorCode.InvalidRequest, `Instance "${inst.name}" is not running (status: ${inst.status}). ` +
@@ -680,27 +1140,13 @@ async function apiFetch(path, options = {}) {
680
1140
  }
681
1141
  const res = await fetch(`${API_URL}${path}`, {
682
1142
  ...options,
683
- signal: AbortSignal.timeout(15000),
684
1143
  headers: {
685
1144
  'Content-Type': 'application/json',
686
1145
  Authorization: `Bearer ${JWT}`,
687
1146
  ...options.headers,
688
1147
  },
689
- }).catch((err) => {
690
- if (err.name === 'TimeoutError' || err.name === 'AbortError') {
691
- throw new McpError(ErrorCode.InternalError, `cachly API timed out after 15s — check your connection or try again.`);
692
- }
693
- throw new McpError(ErrorCode.InternalError, `cachly API unreachable: ${err.message}`);
694
1148
  });
695
1149
  if (!res.ok) {
696
- if (res.status === 401 || res.status === 403) {
697
- throw new McpError(ErrorCode.InvalidRequest, `cachly API: authentication failed (${res.status}) — your JWT may be expired or invalid.\n` +
698
- `Get a new API token at https://cachly.dev/settings or run \`get_api_status\` to check token expiry.`);
699
- }
700
- if (res.status === 503 || res.status === 502) {
701
- throw new McpError(ErrorCode.InternalError, `cachly API: auth service unavailable (${res.status}) — the authentication service may be temporarily down.\n` +
702
- `Please try again in a moment or contact support@cachly.dev`);
703
- }
704
1150
  const body = await res.json().catch(() => ({ error: res.statusText }));
705
1151
  throw new McpError(ErrorCode.InternalError, `cachly API error ${res.status}: ${body.error ?? res.statusText}`);
706
1152
  }
@@ -721,8 +1167,8 @@ const TOOLS = [
721
1167
  name: 'create_instance',
722
1168
  description: 'Create a new managed Valkey/Redis cache instance on cachly.dev. ' +
723
1169
  'Free tier provisions in ~30 seconds. Paid tiers return a Stripe checkout URL. ' +
724
- 'Available tiers: free (30 MB), dev (256 MB, €8/mo), pro (1 GB, €25/mo), ' +
725
- 'speed (1 GB Dragonfly + Semantic Cache, €39/mo), business (8 GB, €99/mo).',
1170
+ 'Available tiers: free (25 MB), dev (200 MB, €19/mo), pro (900 MB, €49/mo), ' +
1171
+ 'speed (900 MB Dragonfly + Semantic Cache, €79/mo), business (7 GB, €199/mo).',
726
1172
  inputSchema: {
727
1173
  type: 'object',
728
1174
  properties: {
@@ -1078,10 +1524,10 @@ const TOOLS = [
1078
1524
  // ── Auth & API-Status ─────────────────────────────────────────────────────
1079
1525
  {
1080
1526
  name: 'get_api_status',
1081
- description: 'Check the cachly API health, auth service reachability, and your authentication status. ' +
1082
- 'Returns API health, whether the Keycloak auth service is reachable, JWT validity, ' +
1083
- 'user ID (sub claim), token expiry, and the auth provider. ' +
1084
- 'Use this to debug connection issues, verify your CACHLY_JWT, or diagnose "Auth Service Unavailable" errors.',
1527
+ description: 'Check the cachly API health and your authentication status. ' +
1528
+ 'Returns whether the JWT is valid, your user ID (sub claim), ' +
1529
+ 'token expiry, and the auth provider (keycloak). ' +
1530
+ 'Use this to debug connection issues or verify your CACHLY_JWT is correct.',
1085
1531
  inputSchema: { type: 'object', properties: {}, required: [] },
1086
1532
  },
1087
1533
  // ── Thinking/Context Cache (for AI assistants) ────────────────────────────
@@ -1167,7 +1613,9 @@ const TOOLS = [
1167
1613
  'Fields: topic (short slug like "deploy:web"), outcome ("success"|"failure"), ' +
1168
1614
  'what_worked (what solved it), what_failed (what did NOT work), context (extra details). ' +
1169
1615
  'Supports structured metadata: severity, file_paths (files involved), commands (working commands), tags. ' +
1170
- 'Deduplication: if a lesson for this topic already exists, it is updated instead of duplicated. ' +
1616
+ 'Deduplication: if a lesson for this topic already exists, it is updated with full audit trail. ' +
1617
+ 'Contradiction detection: warns if new outcome conflicts with existing lesson outcome. ' +
1618
+ 'Confidence: lesson starts at 1.0, decays after 5d (→0.7) and 10d (→0.5) without recall. ' +
1171
1619
  '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"])',
1172
1620
  inputSchema: {
1173
1621
  type: 'object',
@@ -1198,6 +1646,15 @@ const TOOLS = [
1198
1646
  items: { type: 'string' },
1199
1647
  description: 'Topic tags for filtering (e.g. ["bash", "deploy", "env"])',
1200
1648
  },
1649
+ depends_on: {
1650
+ type: 'array',
1651
+ items: { type: 'string' },
1652
+ 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.',
1653
+ },
1654
+ author: {
1655
+ type: 'string',
1656
+ 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.',
1657
+ },
1201
1658
  },
1202
1659
  required: ['instance_id', 'topic', 'outcome', 'what_worked'],
1203
1660
  },
@@ -1206,7 +1663,9 @@ const TOOLS = [
1206
1663
  name: 'recall_best_solution',
1207
1664
  description: 'Recall the best known solution for a topic from past lessons. ' +
1208
1665
  'Call this BEFORE attempting any task that might have been done before. ' +
1209
- 'Returns the most recent successful lesson for the topic, or a summary of attempts. ' +
1666
+ 'Returns the most recent successful lesson for the topic, with confidence indicator. ' +
1667
+ '⚠️ badge = lesson is >5d old (verify before applying). 🔴 = >10d old (likely stale!). ' +
1668
+ 'Recalling a lesson resets its confidence clock to 1.0 (marks as recently verified). ' +
1210
1669
  'Example: recall_best_solution(topic="deploy:web") → returns the working deploy command.',
1211
1670
  inputSchema: {
1212
1671
  type: 'object',
@@ -1217,33 +1676,6 @@ const TOOLS = [
1217
1676
  required: ['instance_id', 'topic'],
1218
1677
  },
1219
1678
  },
1220
- {
1221
- name: 'list_lessons',
1222
- description: '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. ' +
1223
- 'Returns topics sorted by most recently stored, with severity and recall count.',
1224
- inputSchema: {
1225
- type: 'object',
1226
- properties: {
1227
- instance_id: { type: 'string', description: 'UUID of the cache instance' },
1228
- filter: { type: 'string', description: 'Optional substring filter, e.g. "deploy" returns only deploy-related topics' },
1229
- },
1230
- required: ['instance_id'],
1231
- },
1232
- },
1233
- {
1234
- name: 'forget_lesson',
1235
- description: 'Delete a stored lesson from the Brain. Use this when a lesson is wrong, outdated, or was learned incorrectly. ' +
1236
- 'Removes both the best-solution entry and the attempt history for the topic. ' +
1237
- 'Example: forget_lesson(topic="deploy:web") → deletes the wrong deployment lesson so a correct one can be stored.',
1238
- inputSchema: {
1239
- type: 'object',
1240
- properties: {
1241
- instance_id: { type: 'string', description: 'UUID of the cache instance' },
1242
- topic: { type: 'string', description: 'Exact topic slug to delete, e.g. "deploy:web"' },
1243
- },
1244
- required: ['instance_id', 'topic'],
1245
- },
1246
- },
1247
1679
  {
1248
1680
  name: 'smart_recall',
1249
1681
  description: 'Semantically search cached context using natural language. ' +
@@ -1264,7 +1696,8 @@ const TOOLS = [
1264
1696
  name: 'session_start',
1265
1697
  description: 'Single-call session briefing. Call this at the START of every session INSTEAD of multiple separate smart_recall/recall_best_solution calls. ' +
1266
1698
  'Returns: last session summary, recent lessons sorted by recency, relevant lessons for your focus area, ' +
1267
- 'open failures (topics with only failure outcomes), and brain health stats (lesson count, context count). ' +
1699
+ 'open failures (topics with only failure outcomes), brain health stats, team telepathy (what teammates learned this week), ' +
1700
+ 'predictive pre-warnings (if your focus area has known failure patterns), and memory crystals (compressed wisdom from old sessions). ' +
1268
1701
  'Also saves a session start marker so session_end can compute duration.',
1269
1702
  inputSchema: {
1270
1703
  type: 'object',
@@ -1274,6 +1707,18 @@ const TOOLS = [
1274
1707
  type: 'string',
1275
1708
  description: 'Keywords for what you plan to work on today (e.g. "deploy infra api"). Used to surface relevant lessons at the top.',
1276
1709
  },
1710
+ author: {
1711
+ type: 'string',
1712
+ description: 'Your name or handle (e.g. "alice"). Enables Team Telepathy — filters YOUR lessons vs TEAM lessons from past 7 days.',
1713
+ },
1714
+ provider: {
1715
+ type: 'string',
1716
+ 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.',
1717
+ },
1718
+ workspace_path: {
1719
+ type: 'string',
1720
+ 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.',
1721
+ },
1277
1722
  },
1278
1723
  required: ['instance_id'],
1279
1724
  },
@@ -1283,7 +1728,8 @@ const TOOLS = [
1283
1728
  description: 'Save a session summary when you finish working. ' +
1284
1729
  'Records what was accomplished, files changed, and lesson count. ' +
1285
1730
  'The next session_start will show this summary as "Last session". ' +
1286
- 'Call this when ending a work session, before going idle, or before summarizing.',
1731
+ 'Call this when ending a work session, before going idle, or before summarizing. ' +
1732
+ 'Ambient Learning: if workspace_path is provided, reads git log since session start and auto-learns from commits.',
1287
1733
  inputSchema: {
1288
1734
  type: 'object',
1289
1735
  properties: {
@@ -1301,10 +1747,99 @@ const TOOLS = [
1301
1747
  type: 'number',
1302
1748
  description: 'Number of new lessons stored this session (optional)',
1303
1749
  },
1750
+ workspace_path: {
1751
+ type: 'string',
1752
+ 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.',
1753
+ },
1304
1754
  },
1305
1755
  required: ['instance_id', 'summary'],
1306
1756
  },
1307
1757
  },
1758
+ // ── Session Handoff — cross-window continuity ─────────────────────────────
1759
+ {
1760
+ name: 'session_handoff',
1761
+ description: 'Save a detailed handoff for the NEXT chat window / session. ' +
1762
+ 'Stores: current progress, TODO list (done + remaining), changed files with descriptions, ' +
1763
+ 'instructions for the next assistant, and any incomplete work. ' +
1764
+ 'The next session_start automatically includes this handoff so the new window knows EXACTLY what happened and what remains. ' +
1765
+ 'Call this BEFORE closing a chat window, especially if work is incomplete. ' +
1766
+ 'This prevents the "continue" problem where new windows lose context, skip tasks, or produce broken code.',
1767
+ inputSchema: {
1768
+ type: 'object',
1769
+ properties: {
1770
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
1771
+ completed_tasks: {
1772
+ type: 'array',
1773
+ items: { type: 'string' },
1774
+ description: 'Tasks that were fully completed (e.g. "Implemented brainSearch() in JS SDK")',
1775
+ },
1776
+ remaining_tasks: {
1777
+ type: 'array',
1778
+ items: { type: 'string' },
1779
+ description: 'Tasks NOT yet done — the next window MUST pick these up',
1780
+ },
1781
+ files_changed: {
1782
+ type: 'array',
1783
+ items: {
1784
+ type: 'object',
1785
+ properties: {
1786
+ path: { type: 'string', description: 'File path relative to project root' },
1787
+ status: { type: 'string', enum: ['complete', 'partial', 'broken'], description: 'State of this file' },
1788
+ description: { type: 'string', description: 'What was changed and what still needs work' },
1789
+ },
1790
+ required: ['path', 'status'],
1791
+ },
1792
+ description: 'Changed files with their current state — marks partial/broken files so next window knows to fix them',
1793
+ },
1794
+ instructions: {
1795
+ type: 'string',
1796
+ description: 'Free-form instructions for the next assistant. Be specific: what to do next, what to avoid, what broke.',
1797
+ },
1798
+ context_summary: {
1799
+ type: 'string',
1800
+ description: 'Brief summary of what happened this session (architecture decisions, key findings, blockers)',
1801
+ },
1802
+ blocked_on: {
1803
+ type: 'string',
1804
+ description: 'If work is blocked, describe what is needed to unblock (e.g. "waiting for API deploy", "needs user input on design")',
1805
+ },
1806
+ },
1807
+ required: ['instance_id', 'completed_tasks', 'remaining_tasks'],
1808
+ },
1809
+ },
1810
+ // ── session_ping — lightweight in-session checkpoint ─────────────────────
1811
+ {
1812
+ name: 'session_ping',
1813
+ description: 'Lightweight checkpoint — call this every ~5 tool calls or whenever you complete a significant step. ' +
1814
+ 'Stores the current task + files touched so session_start on the NEXT provider can reconstruct what happened ' +
1815
+ 'even if session_end was never called (e.g. Claude context limit hit, window crashed). ' +
1816
+ 'This solves the provider-switching problem: Claude → Copilot → Cursor all see the same last checkpoint. ' +
1817
+ 'Extremely fast — one Redis SET, no blocking operations.',
1818
+ inputSchema: {
1819
+ type: 'object',
1820
+ properties: {
1821
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
1822
+ task: {
1823
+ type: 'string',
1824
+ description: 'What you are currently working on (e.g. "Implementing invite handler in handler/invite.go")',
1825
+ },
1826
+ files_touched: {
1827
+ type: 'array',
1828
+ items: { type: 'string' },
1829
+ description: 'Files modified so far this session',
1830
+ },
1831
+ next_step: {
1832
+ type: 'string',
1833
+ description: 'What the NEXT step is after this checkpoint (helps next provider resume immediately)',
1834
+ },
1835
+ provider: {
1836
+ type: 'string',
1837
+ description: 'Current AI provider (e.g. "claude-code", "copilot", "cursor", "windsurf")',
1838
+ },
1839
+ },
1840
+ required: ['instance_id', 'task'],
1841
+ },
1842
+ },
1308
1843
  // ── AI Brain — Extended features ─────────────────────────────────────────
1309
1844
  {
1310
1845
  name: 'auto_learn_session',
@@ -1392,47 +1927,129 @@ const TOOLS = [
1392
1927
  },
1393
1928
  },
1394
1929
  {
1395
- name: 'brain_stats',
1396
- description: 'Complete statistics dashboard for your AI Brain — lessons, recalls, time saved, top topics, team authors, memory usage. ' +
1397
- 'Returns a formatted report designed for sharing (screenshot-worthy). ' +
1398
- 'Use this when you want a full overview or want to share your brain health with others.',
1930
+ name: 'team_synthesize',
1931
+ description: 'Team Brain Synthesis — merge multiple contributors\' lessons on the same topic into one canonical version. ' +
1932
+ 'When 2+ developers store lessons for the same topic with different details, this proposes the best merged version. ' +
1933
+ 'Shows: all contributions by author, what worked (consensus), what failed (union), canonical lesson to store. ' +
1934
+ 'Use this when onboarding new team members or before documenting a process.',
1935
+ inputSchema: {
1936
+ type: 'object',
1937
+ properties: {
1938
+ instance_id: { type: 'string', description: 'UUID of the shared team brain instance' },
1939
+ topic: { type: 'string', description: 'Topic slug to synthesize (e.g. "deploy:api")' },
1940
+ },
1941
+ required: ['instance_id', 'topic'],
1942
+ },
1943
+ },
1944
+ {
1945
+ name: 'memory_crystalize',
1946
+ description: 'Compress the last 30-50 sessions and auto-learned lessons into a dense Memory Crystal. ' +
1947
+ 'A crystal is a compact, structured summary of everything the brain learned — grouped by category (deploy, fix, debug, …). ' +
1948
+ 'Crystals survive session cleanup and appear in session_start once enough sessions have accumulated. ' +
1949
+ 'Run this monthly or after a big milestone to preserve institutional knowledge. ' +
1950
+ 'Returns a digest of what was crystallized.',
1399
1951
  inputSchema: {
1400
1952
  type: 'object',
1401
1953
  properties: {
1402
1954
  instance_id: { type: 'string', description: 'UUID of the cache instance' },
1955
+ label: {
1956
+ type: 'string',
1957
+ description: 'Optional label for this crystal (e.g. "Q1 2026", "v2 launch"). Auto-generated from date if omitted.',
1958
+ },
1403
1959
  },
1404
1960
  required: ['instance_id'],
1405
1961
  },
1406
1962
  },
1963
+ // ── Roadmap — Persistent project plan tracker ───────────────────────────
1964
+ {
1965
+ name: 'roadmap_add',
1966
+ description: 'Add a new item to the persistent project roadmap stored in the Brain. ' +
1967
+ 'Items survive across sessions and editors — the roadmap is always up to date. ' +
1968
+ 'Use for features, bugs, refactors, or any planned work. ' +
1969
+ 'Call roadmap_list to see all open items, roadmap_next to get the next actionable item.',
1970
+ inputSchema: {
1971
+ type: 'object',
1972
+ properties: {
1973
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
1974
+ title: { type: 'string', description: 'Short title of the task/feature (3–10 words)' },
1975
+ description: { type: 'string', description: 'What needs to be done, acceptance criteria, context' },
1976
+ priority: {
1977
+ type: 'string',
1978
+ enum: ['critical', 'high', 'medium', 'low'],
1979
+ description: 'Priority level (default: medium)',
1980
+ },
1981
+ tags: {
1982
+ type: 'array',
1983
+ items: { type: 'string' },
1984
+ description: 'Tags for filtering (e.g. ["api", "web", "sdk", "infra"])',
1985
+ },
1986
+ milestone: { type: 'string', description: 'Milestone/epic this belongs to (optional)' },
1987
+ },
1988
+ required: ['instance_id', 'title'],
1989
+ },
1990
+ },
1991
+ {
1992
+ name: 'roadmap_update',
1993
+ description: 'Update the status, priority, or details of a roadmap item. ' +
1994
+ 'Use to move items through the lifecycle: planned → in-progress → done (or blocked/cancelled). ' +
1995
+ 'Also use to add notes/findings while working on an item.',
1996
+ inputSchema: {
1997
+ type: 'object',
1998
+ properties: {
1999
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
2000
+ id: { type: 'string', description: 'Item ID returned by roadmap_add or roadmap_list' },
2001
+ status: {
2002
+ type: 'string',
2003
+ enum: ['planned', 'in-progress', 'done', 'blocked', 'cancelled'],
2004
+ description: 'New status for the item',
2005
+ },
2006
+ priority: {
2007
+ type: 'string',
2008
+ enum: ['critical', 'high', 'medium', 'low'],
2009
+ description: 'Updated priority (optional)',
2010
+ },
2011
+ notes: { type: 'string', description: 'Progress notes, findings, or blockers (appended to existing notes)' },
2012
+ title: { type: 'string', description: 'Updated title (optional)' },
2013
+ description: { type: 'string', description: 'Updated description (optional)' },
2014
+ },
2015
+ required: ['instance_id', 'id'],
2016
+ },
2017
+ },
1407
2018
  {
1408
- name: 'export_brain',
1409
- description: 'Export your entire AI Brain as a portable Markdown file. ' +
1410
- 'Includes all lessons (topic, outcome, what worked, severity), context entries (keys + content), ' +
1411
- 'session history, and metadata. Perfect for sharing with teammates, archiving, posting to GitHub, ' +
1412
- 'or importing into a new instance. ' +
1413
- 'Example: export_brain() → returns full markdown string you can save to a .md file.',
2019
+ name: 'roadmap_list',
2020
+ description: 'List all roadmap items, optionally filtered by status, priority, tag, or milestone. ' +
2021
+ 'Returns items sorted by priority then creation date. ' +
2022
+ 'Called automatically by session_start to show open work.',
1414
2023
  inputSchema: {
1415
2024
  type: 'object',
1416
2025
  properties: {
1417
2026
  instance_id: { type: 'string', description: 'UUID of the cache instance' },
1418
- include_context: { type: 'boolean', description: 'Include context entries (default: true)' },
1419
- max_lessons: { type: 'number', description: 'Max lessons to include (default: all)' },
2027
+ status: {
2028
+ type: 'string',
2029
+ enum: ['planned', 'in-progress', 'done', 'blocked', 'cancelled', 'open'],
2030
+ description: 'Filter by status. Use \'open\' to see planned+in-progress+blocked (default: open)',
2031
+ },
2032
+ tag: { type: 'string', description: 'Filter by tag (optional)' },
2033
+ milestone: { type: 'string', description: 'Filter by milestone (optional)' },
2034
+ priority: {
2035
+ type: 'string',
2036
+ enum: ['critical', 'high', 'medium', 'low'],
2037
+ description: 'Filter by minimum priority (optional)',
2038
+ },
1420
2039
  },
1421
2040
  required: ['instance_id'],
1422
2041
  },
1423
2042
  },
1424
2043
  {
1425
- name: 'invite_link',
1426
- description: 'Generate a one-click team invite link for your Brain instance. ' +
1427
- 'Share the link (or the npx join command) with a teammate — they run one command and are instantly ' +
1428
- 'connected to the same shared Brain with all your lessons and context. ' +
1429
- 'No dashboard visit, no copy-paste of instance IDs required. ' +
1430
- 'Example: invite_link() → returns "npx @cachly-dev/mcp-server@latest join <token>"',
2044
+ name: 'roadmap_next',
2045
+ description: 'Get the single most important next actionable roadmap item. ' +
2046
+ 'Returns the highest-priority in-progress item first, then planned items, sorted by priority. ' +
2047
+ 'Call at session start to immediately know what to work on next.',
1431
2048
  inputSchema: {
1432
2049
  type: 'object',
1433
2050
  properties: {
1434
- instance_id: { type: 'string', description: 'UUID of the cache instance to share' },
1435
- label: { type: 'string', description: 'Optional label shown to the invitee (e.g. "my-api project")' },
2051
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
2052
+ tag: { type: 'string', description: 'Filter by tag (optional)' },
1436
2053
  },
1437
2054
  required: ['instance_id'],
1438
2055
  },
@@ -1440,12 +2057,16 @@ const TOOLS = [
1440
2057
  {
1441
2058
  name: 'brain_doctor',
1442
2059
  description: 'Check the health of your AI Brain and get actionable recommendations. ' +
1443
- 'Reports: lesson count, context entries, last session age, open failures, quality score. ' +
2060
+ 'Reports: lesson count, context entries, last session age, open failures, quality score, effective IQ boost, stale index. ' +
1444
2061
  'Returns a prioritized list of issues with fix instructions.',
1445
2062
  inputSchema: {
1446
2063
  type: 'object',
1447
2064
  properties: {
1448
2065
  instance_id: { type: 'string', description: 'UUID of the cache instance' },
2066
+ workspace_path: {
2067
+ type: 'string',
2068
+ description: 'Absolute path to workspace root — enables package.json analysis for openclaw cross-promo (optional)',
2069
+ },
1449
2070
  },
1450
2071
  required: ['instance_id'],
1451
2072
  },
@@ -1513,6 +2134,94 @@ const TOOLS = [
1513
2134
  required: ['instance_id', 'framework'],
1514
2135
  },
1515
2136
  },
2137
+ // ── Brain Archaeology + Causal Chain ────────────────────────────────────
2138
+ {
2139
+ name: 'recall_at',
2140
+ description: 'Brain Archaeology — see what a lesson looked like at a specific point in time. ' +
2141
+ '"What did we know about deployments 3 months ago?" ' +
2142
+ 'Returns the history of a topic filtered to entries before the given date. ' +
2143
+ 'Shows how the lesson evolved: failure → partial → success. ' +
2144
+ 'Also useful to understand WHY old code decisions were made.',
2145
+ inputSchema: {
2146
+ type: 'object',
2147
+ properties: {
2148
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
2149
+ topic: { type: 'string', description: 'Topic slug to look up, e.g. "deploy:api"' },
2150
+ date: { type: 'string', description: 'ISO date string (e.g. "2026-01-15") — returns entries stored BEFORE this date' },
2151
+ },
2152
+ required: ['instance_id', 'topic', 'date'],
2153
+ },
2154
+ },
2155
+ {
2156
+ name: 'trace_dependency',
2157
+ description: 'Causal Chain — find all lessons that depend on a given prerequisite. ' +
2158
+ '"What lessons are affected if node version changes?" ' +
2159
+ 'When a dependency changes (new version, different provider, new OS), call this to see which lessons need review. ' +
2160
+ 'Lessons store dependencies via the depends_on field in learn_from_attempts.',
2161
+ inputSchema: {
2162
+ type: 'object',
2163
+ properties: {
2164
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
2165
+ dependency: { type: 'string', description: 'Dependency to trace (e.g. "node:>=20", "docker:running", "wireguard:active")' },
2166
+ mark_review: { type: 'boolean', description: 'If true, marks all dependent lessons as needs_review (default: false)' },
2167
+ },
2168
+ required: ['instance_id', 'dependency'],
2169
+ },
2170
+ },
2171
+ // ── Team / Org Management ────────────────────────────────────────────────
2172
+ {
2173
+ name: 'list_orgs',
2174
+ description: 'List your Cachly organizations (team/org plans). ' +
2175
+ 'Returns each org with plan, seat count, and member info. ' +
2176
+ 'Org plans (Team €99, Business €299, Enterprise custom) are billed separately from cache tiers.',
2177
+ inputSchema: {
2178
+ type: 'object',
2179
+ properties: {},
2180
+ required: [],
2181
+ },
2182
+ },
2183
+ {
2184
+ name: 'create_org',
2185
+ description: 'Create a new Cachly organization for team collaboration. ' +
2186
+ 'After creation, invite team members with invite_member and upgrade the plan via the billing portal. ' +
2187
+ 'Org plans: Team (€99/mo, 10 seats), Business (€299/mo, 50 seats), Enterprise (custom).',
2188
+ inputSchema: {
2189
+ type: 'object',
2190
+ properties: {
2191
+ name: { type: 'string', description: 'Organization display name (e.g. "Acme Engineering")' },
2192
+ slug: { type: 'string', description: 'URL-safe slug (e.g. "acme-eng"). Auto-generated from name if omitted.' },
2193
+ },
2194
+ required: ['name'],
2195
+ },
2196
+ },
2197
+ {
2198
+ name: 'invite_member',
2199
+ description: 'Invite a team member to a Cachly organization by email. ' +
2200
+ 'They will receive an invite email and can join via the dashboard. ' +
2201
+ 'Roles: owner (full access), admin (manage members + instances), member (read + cache ops).',
2202
+ inputSchema: {
2203
+ type: 'object',
2204
+ properties: {
2205
+ org_id: { type: 'string', description: 'UUID of the organization' },
2206
+ email: { type: 'string', description: 'Email address to invite' },
2207
+ role: { type: 'string', enum: ['admin', 'member'], description: 'Role for the invited member (default: member)' },
2208
+ },
2209
+ required: ['org_id', 'email'],
2210
+ },
2211
+ },
2212
+ {
2213
+ name: 'get_org_plan',
2214
+ description: 'Get the current org plan, seat usage, and billing info for an organization. ' +
2215
+ 'Shows: plan name, price, seats used/max, next billing date. ' +
2216
+ 'To upgrade: use the billing portal URL returned by this tool.',
2217
+ inputSchema: {
2218
+ type: 'object',
2219
+ properties: {
2220
+ org_id: { type: 'string', description: 'UUID of the organization' },
2221
+ },
2222
+ required: ['org_id'],
2223
+ },
2224
+ },
1516
2225
  // ── Legacy / Setup ────────────────────────────────────────────────────────
1517
2226
  {
1518
2227
  name: 'setup_ai_memory',
@@ -1603,10 +2312,52 @@ function buildConnectionString(inst) {
1603
2312
  const pw = inst.password ? `:${inst.password}@` : '@';
1604
2313
  return `${scheme}://${pw}${inst.host}:${inst.port}`;
1605
2314
  }
2315
+ // Fires once per process when no JWT is set (anonymous, opt-out via CACHLY_NO_TELEMETRY=1)
2316
+ let _telemetryPingSent = false;
2317
+ async function sendAnonymousTelemetry(toolName) {
2318
+ if (_telemetryPingSent)
2319
+ return;
2320
+ if (process.env.CACHLY_NO_TELEMETRY === '1')
2321
+ return;
2322
+ _telemetryPingSent = true;
2323
+ // Detect editor from common env vars injected by IDE extensions
2324
+ const editor = process.env.CURSOR_TRACE_ID ? 'cursor'
2325
+ : process.env.WINDSURF_SESSION_ID ? 'windsurf'
2326
+ : process.env.GITHUB_COPILOT_WORKSPACE ? 'copilot'
2327
+ : process.env.CLAUDE_CODE_ENTRYPOINT ? 'claude'
2328
+ : 'unknown';
2329
+ try {
2330
+ await fetch(`${API_URL}/api/v1/telemetry/mcp`, {
2331
+ method: 'POST',
2332
+ headers: { 'Content-Type': 'application/json' },
2333
+ body: JSON.stringify({ event: 'first_call_no_jwt', version: CURRENT_VERSION, editor, tool: toolName }),
2334
+ signal: AbortSignal.timeout(3000),
2335
+ });
2336
+ }
2337
+ catch { /* fire-and-forget, never block the user */ }
2338
+ }
1606
2339
  async function handleTool(name, args) {
1607
- // Default instance_id from env so tools work even if AI omits it
1608
- if (!args.instance_id && process.env.CACHLY_BRAIN_INSTANCE_ID) {
1609
- args = { ...args, instance_id: process.env.CACHLY_BRAIN_INSTANCE_ID };
2340
+ // Guard: if no JWT, return actionable onboarding message instead of HTTP 401
2341
+ if (!JWT) {
2342
+ void sendAnonymousTelemetry(name);
2343
+ return [
2344
+ '🧠 **cachly AI Brain — Setup required**',
2345
+ '',
2346
+ 'You have no API credentials configured. Get started in 30 seconds:',
2347
+ '',
2348
+ '**Step 1:** Register for free (no credit card):',
2349
+ ' 👉 https://cachly.dev/setup-ai',
2350
+ '',
2351
+ '**Step 2:** Run the setup wizard in your terminal:',
2352
+ ' ```',
2353
+ ' npx @cachly-dev/mcp-server@latest setup',
2354
+ ' ```',
2355
+ '',
2356
+ 'The wizard auto-detects your editor (Claude Code, Cursor, Copilot, Windsurf)',
2357
+ 'and writes the config file with your credentials.',
2358
+ '',
2359
+ '✨ Free tier includes: 1 Brain instance, persistent memory, semantic search.',
2360
+ ].join('\n');
1610
2361
  }
1611
2362
  // Delegate v0.2 bulk/lock/stream tools first
1612
2363
  const bulkResult = await handleBulkLockStream(name, args);
@@ -1625,7 +2376,7 @@ async function handleTool(name, args) {
1625
2376
  const { name: instName, tier } = args;
1626
2377
  const res = await apiFetch('/api/v1/instances', {
1627
2378
  method: 'POST',
1628
- body: JSON.stringify({ name: instName, tier }),
2379
+ body: JSON.stringify({ name: instName, tier, created_via: 'api' }),
1629
2380
  });
1630
2381
  if (res.checkout_url) {
1631
2382
  return [
@@ -1682,6 +2433,63 @@ async function handleTool(name, args) {
1682
2433
  await apiFetch(`/api/v1/instances/${instance_id}`, { method: 'DELETE' });
1683
2434
  return `✅ Instance \`${instance_id}\` has been deleted and all data removed.`;
1684
2435
  }
2436
+ // ── Org / Team management ────────────────────────────────────────────
2437
+ case 'list_orgs': {
2438
+ const res = await apiFetch('/api/v1/orgs');
2439
+ const orgs = res.orgs ?? [];
2440
+ if (orgs.length === 0)
2441
+ 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.`;
2442
+ return [
2443
+ `🏢 **Your organizations (${orgs.length})**\n`,
2444
+ ...orgs.map(o => `• **${o.name}** (\`${o.slug}\`) — plan: ${o.plan} · seats: ${o.member_count ?? '?'}/${o.max_members}\n ID: \`${o.id}\``),
2445
+ `\n_Manage: \`get_org_plan\`, \`invite_member\`, dashboard → /team_`,
2446
+ ].join('\n');
2447
+ }
2448
+ case 'create_org': {
2449
+ const { name: orgName, slug } = args;
2450
+ const body = { name: orgName };
2451
+ if (slug)
2452
+ body.slug = slug;
2453
+ const res = await apiFetch('/api/v1/orgs', {
2454
+ method: 'POST',
2455
+ body: JSON.stringify(body),
2456
+ });
2457
+ return [
2458
+ `✅ **Organization created:** ${res.name}`,
2459
+ ` ID: \`${res.id}\` · Slug: \`${res.slug}\` · Plan: ${res.plan}`,
2460
+ ``,
2461
+ `**Next steps:**`,
2462
+ `1. Invite team members: \`invite_member(org_id="${res.id}", email="dev@example.com")\``,
2463
+ `2. Upgrade plan: open billing portal via dashboard → /team`,
2464
+ ` Team: €99/mo (10 seats) · Business: €299/mo (50 seats)`,
2465
+ ].join('\n');
2466
+ }
2467
+ case 'invite_member': {
2468
+ const { org_id, email, role = 'member' } = args;
2469
+ await apiFetch(`/api/v1/orgs/${org_id}/members`, {
2470
+ method: 'POST',
2471
+ body: JSON.stringify({ email, role }),
2472
+ });
2473
+ return `✅ Invite sent to **${email}** as \`${role}\` in org \`${org_id}\`.\n\nThey will receive an email to join the organization.`;
2474
+ }
2475
+ case 'get_org_plan': {
2476
+ const { org_id } = args;
2477
+ const org = await apiFetch(`/api/v1/orgs/${org_id}`);
2478
+ const accepted = (org.members ?? []).filter(m => m.accepted_at).length;
2479
+ const pending = (org.members ?? []).filter(m => !m.accepted_at).length;
2480
+ const planPrice = { free: '€0', team: '€99/mo', business: '€299/mo', enterprise: 'custom' };
2481
+ return [
2482
+ `🏢 **${org.name}** — Plan: **${org.plan}** (${planPrice[org.plan] ?? org.plan})`,
2483
+ ` Seats: ${accepted} active + ${pending} pending / ${org.max_members} max`,
2484
+ ``,
2485
+ `**Members:**`,
2486
+ ...(org.members ?? []).map(m => ` • ${m.invite_email} (${m.role})${m.accepted_at ? '' : ' — pending'}`),
2487
+ ``,
2488
+ org.stripe_customer_id
2489
+ ? `💳 Billing: managed via Stripe. Upgrade/cancel: dashboard → /billing`
2490
+ : `💳 No payment method yet. Upgrade: dashboard → /billing → Team Plans`,
2491
+ ].join('\n');
2492
+ }
1685
2493
  // ── Live cache operations ────────────────────────────────────────────
1686
2494
  case 'cache_get': {
1687
2495
  const { instance_id, key } = args;
@@ -2004,24 +2812,42 @@ async function handleTool(name, args) {
2004
2812
  let skipped = 0;
2005
2813
  let errors = 0;
2006
2814
  let semanticIndexed = 0;
2815
+ let unchanged = 0;
2007
2816
  const details = [];
2008
2817
  const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
2009
2818
  const redis = await getConnection(instance_id);
2010
2819
  for (const filePath of files) {
2011
2820
  const relPath = relative(dir, filePath);
2012
2821
  let content;
2822
+ let fileSize;
2013
2823
  try {
2014
2824
  const s = await stat(filePath);
2015
2825
  if (s.size > 200_000) {
2016
2826
  skipped++;
2017
2827
  continue;
2018
2828
  } // skip files >200 KB
2829
+ fileSize = s.size;
2019
2830
  content = await readFile(filePath, 'utf-8');
2020
2831
  }
2021
2832
  catch {
2022
2833
  errors++;
2023
2834
  continue;
2024
2835
  }
2836
+ // ── Smart invalidation: hash-based change detection ──
2837
+ // Compute a simple hash of file content to skip unchanged files
2838
+ const hashKey = `cachly:idx:hash:${relPath}`;
2839
+ const contentHash = `${fileSize}:${content.length}:${simpleHash(content)}`;
2840
+ const existingHash = await redis.get(hashKey);
2841
+ if (existingHash === contentHash) {
2842
+ // File unchanged — refresh TTL but skip re-indexing
2843
+ const idxKey = `cachly:idx:${relPath}`;
2844
+ if (ttl > 0)
2845
+ await redis.expire(idxKey, ttl);
2846
+ if (ttl > 0)
2847
+ await redis.expire(hashKey, ttl);
2848
+ unchanged++;
2849
+ continue;
2850
+ }
2025
2851
  const excerpt = content.slice(0, summary_chars).replace(/\s+/g, ' ').trim();
2026
2852
  // ── Layer 1: Keyword index in Valkey (always works, no embedding needed) ──
2027
2853
  const idxKey = `cachly:idx:${relPath}`;
@@ -2032,6 +2858,13 @@ async function handleTool(name, args) {
2032
2858
  else {
2033
2859
  await redis.set(idxKey, idxValue);
2034
2860
  }
2861
+ // Store content hash for smart invalidation on next run
2862
+ if (ttl > 0) {
2863
+ await redis.set(hashKey, contentHash, 'EX', ttl);
2864
+ }
2865
+ else {
2866
+ await redis.set(hashKey, contentHash);
2867
+ }
2035
2868
  indexed++;
2036
2869
  details.push(` ✅ ${relPath}`);
2037
2870
  // ── Layer 2: Semantic vector index (optional, only if embedding available) ──
@@ -2058,7 +2891,8 @@ async function handleTool(name, args) {
2058
2891
  `📂 **index_project Complete** — ${mode}`,
2059
2892
  ``,
2060
2893
  ` 📁 Dir: ${dir}`,
2061
- ` ✅ Indexed: **${indexed}** files (keyword-searchable via smart_recall)`,
2894
+ ` ✅ Indexed: **${indexed}** files (new/changed)`,
2895
+ ` ♻️ Unchanged: ${unchanged} files (hash match — skipped)`,
2062
2896
  ...(canEmbed ? [` 🎯 Semantic: **${semanticIndexed}** files (vector-searchable)`] : []),
2063
2897
  ` ⏭️ Skipped: ${skipped} (too large or filtered)`,
2064
2898
  ` ❌ Errors: ${errors}`,
@@ -2091,33 +2925,31 @@ async function handleTool(name, args) {
2091
2925
  await redis.set(cacheKey, content);
2092
2926
  await redis.set(`${cacheKey}:meta`, meta);
2093
2927
  }
2094
- // Also index semantically for smart_recall (if vector available and embedding is configured)
2095
- if (EMBED_PROVIDER !== 'none') {
2096
- const inst = await apiFetch(`/api/v1/instances/${instance_id}`);
2097
- if (inst.vector_token) {
2098
- try {
2099
- const embedding = await computeEmbedding(`${key}: ${content.slice(0, 500)}`);
2100
- const vectorUrl = `https://api.cachly.dev/v1/sem/${inst.vector_token}`;
2101
- const body = {
2102
- id: `ctx:${category}:${key}`,
2103
- prompt: key,
2104
- namespace: 'cachly:ctx',
2105
- embedding,
2106
- };
2107
- if (ttl && ttl > 0) {
2108
- body['expires_at'] = new Date(Date.now() + ttl * 1000).toISOString();
2109
- }
2110
- await fetch(`${vectorUrl}/entries`, {
2111
- method: 'POST',
2112
- headers: { 'Content-Type': 'application/json' },
2113
- body: JSON.stringify(body),
2114
- }).catch(() => undefined);
2115
- }
2116
- catch {
2117
- // Embedding optional — continue silently
2928
+ // Also index semantically for smart_recall (if vector available)
2929
+ const inst = await apiFetch(`/api/v1/instances/${instance_id}`);
2930
+ if (inst.vector_token) {
2931
+ try {
2932
+ const embedding = await computeEmbedding(`${key}: ${content.slice(0, 500)}`);
2933
+ const vectorUrl = `https://api.cachly.dev/v1/sem/${inst.vector_token}`;
2934
+ const body = {
2935
+ id: `ctx:${category}:${key}`,
2936
+ prompt: key,
2937
+ namespace: 'cachly:ctx',
2938
+ embedding,
2939
+ };
2940
+ if (ttl && ttl > 0) {
2941
+ body['expires_at'] = new Date(Date.now() + ttl * 1000).toISOString();
2118
2942
  }
2943
+ await fetch(`${vectorUrl}/entries`, {
2944
+ method: 'POST',
2945
+ headers: { 'Content-Type': 'application/json' },
2946
+ body: JSON.stringify(body),
2947
+ }).catch(() => undefined);
2948
+ }
2949
+ catch {
2950
+ // Embedding optional — continue silently
2119
2951
  }
2120
- } // end EMBED_PROVIDER !== 'none'
2952
+ }
2121
2953
  return [
2122
2954
  `🧠 **Context Saved**`,
2123
2955
  ``,
@@ -2242,24 +3074,92 @@ async function handleTool(name, args) {
2242
3074
  return `🗑️ **Forgot ${deleted} context entries.**\n\nKeys: ${keys.map(k => `\`${k}\``).join(', ')}`;
2243
3075
  }
2244
3076
  case 'learn_from_attempts': {
2245
- const { instance_id, topic, outcome, what_worked, what_failed = '', context: ctx = '', severity = 'major', file_paths = [], commands = [], tags = [], } = args;
3077
+ const { instance_id, topic, outcome, what_worked, what_failed = '', context: ctx = '', severity = 'major', file_paths = [], commands = [], tags = [], depends_on = [], author = '', } = args;
2246
3078
  const redis = await getConnection(instance_id);
2247
3079
  const ts = new Date().toISOString();
2248
- // ── Deduplication: check for existing lesson with same topic ─────────────
3080
+ // ── Structured template hints ──────────────────────────────────────────
3081
+ const category = topic.split(':')[0];
3082
+ const template = STRUCTURED_TEMPLATES[category];
3083
+ const templateWarnings = [];
3084
+ if (template) {
3085
+ for (const req of template.required) {
3086
+ if (req === 'commands' && commands.length === 0) {
3087
+ templateWarnings.push(`📋 ${template.hint}`);
3088
+ }
3089
+ }
3090
+ }
3091
+ // ── Deduplication + audit trail ────────────────────────────────────────
2249
3092
  let isUpdate = false;
2250
3093
  let recallCount = 0;
2251
- if (outcome === 'success') {
2252
- const existing = await redis.get(`cachly:lesson:best:${topic}`);
2253
- if (existing) {
3094
+ let auditTrail = [];
3095
+ const existingRaw = await redis.get(`cachly:lesson:best:${topic}`);
3096
+ if (existingRaw) {
3097
+ try {
3098
+ const prev = JSON.parse(existingRaw);
3099
+ recallCount = prev.recall_count ?? 0;
3100
+ auditTrail = prev.audit_trail ?? [];
3101
+ auditTrail.push({ ts, action: 'updated', prev_outcome: prev.outcome });
3102
+ if (auditTrail.length > 20)
3103
+ auditTrail = auditTrail.slice(-20);
3104
+ isUpdate = true;
3105
+ // ── Contradiction detection ─────────────────────────────────────────
3106
+ const contradictionWarning = [];
3107
+ if (prev.outcome === 'success' && outcome === 'failure') {
3108
+ contradictionWarning.push(`⚠️ **Contradiction detected!** Existing lesson has outcome: \`success\`, but you're storing \`failure\`.`, `The existing "success" lesson will be preserved. Only the audit trail is updated.`, `If you meant to mark this as failed permanently, store a new lesson with a distinct topic slug.`);
3109
+ }
3110
+ else if (prev.outcome === 'failure' && outcome === 'success') {
3111
+ contradictionWarning.push(`✅ **Conflict resolved!** Previous lesson was \`failure\` — now overwriting with \`success\`.`);
3112
+ }
3113
+ if (contradictionWarning.length > 0) {
3114
+ // Store contradiction audit but don't block
3115
+ auditTrail[auditTrail.length - 1].action = 'contradiction-resolved';
3116
+ }
3117
+ }
3118
+ catch { /* ignore parse error */ }
3119
+ }
3120
+ else {
3121
+ auditTrail = [{ ts, action: 'created' }];
3122
+ }
3123
+ // ── "I Was Wrong" Protocol — failure attribution ───────────────────────
3124
+ const iWasWrongWarning = [];
3125
+ if (outcome === 'failure') {
3126
+ // Search for related success lessons that might have prevented this failure
3127
+ const scanKeys = [];
3128
+ const scanStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 100 });
3129
+ await new Promise((res, rej) => {
3130
+ scanStream.on('data', (b) => scanKeys.push(...b));
3131
+ scanStream.on('end', res);
3132
+ scanStream.on('error', rej);
3133
+ });
3134
+ const topicWords = topic.split(/[:\-_]/).filter(w => w.length > 2);
3135
+ for (const k of scanKeys.slice(0, 50)) {
3136
+ const raw = await redis.get(k);
3137
+ if (!raw)
3138
+ continue;
2254
3139
  try {
2255
- const prev = JSON.parse(existing);
2256
- recallCount = (prev.recall_count ?? 0); // preserve existing recall count
2257
- isUpdate = true;
3140
+ const l = JSON.parse(raw);
3141
+ if (l.outcome !== 'success')
3142
+ continue;
3143
+ const lWords = (l.topic ?? '').split(/[:\-_]/).filter(w => w.length > 2);
3144
+ const overlap = topicWords.filter(w => lWords.includes(w)).length;
3145
+ if (overlap >= 1 && l.topic !== topic) {
3146
+ iWasWrongWarning.push(`⚠️ **"I Was Wrong"**: lesson \`${l.topic}\` (success, ${l.severity ?? 'major'}) might have prevented this failure.`, ` → Use \`recall_best_solution(topic="${l.topic}")\` before next attempt.`, ` → To mark it critical: \`learn_from_attempts(topic="${l.topic}", ..., severity="critical")\``);
3147
+ break; // only show the most relevant match
3148
+ }
2258
3149
  }
2259
- catch { /* ignore parse error */ }
3150
+ catch { /* skip */ }
2260
3151
  }
2261
3152
  }
2262
- const lesson = JSON.stringify({
3153
+ // ── Register dependency index for causal chain ─────────────────────────
3154
+ for (const dep of depends_on) {
3155
+ const depKey = `cachly:dep:${dep}`;
3156
+ const existing = await redis.get(depKey);
3157
+ const depTopics = existing ? JSON.parse(existing) : [];
3158
+ if (!depTopics.includes(topic))
3159
+ depTopics.push(topic);
3160
+ await redis.set(depKey, JSON.stringify(depTopics));
3161
+ }
3162
+ const lessonObj = {
2263
3163
  topic,
2264
3164
  outcome,
2265
3165
  what_worked,
@@ -2269,17 +3169,34 @@ async function handleTool(name, args) {
2269
3169
  file_paths,
2270
3170
  commands,
2271
3171
  tags,
3172
+ depends_on,
3173
+ ...(author ? { author } : {}),
2272
3174
  recall_count: recallCount,
2273
3175
  ts,
2274
- version: 2,
2275
- });
2276
- // Always append to the history list
3176
+ verified_at: outcome === 'success' || outcome === 'partial' ? ts : undefined,
3177
+ confidence: 1.0,
3178
+ audit_trail: auditTrail,
3179
+ version: 3,
3180
+ };
3181
+ const lesson = JSON.stringify(lessonObj);
3182
+ // Always append to the history list (audit log)
2277
3183
  const listKey = `cachly:lessons:${topic}`;
2278
3184
  await redis.rpush(listKey, lesson);
2279
- // Update best key only for success/partial (not for failures)
3185
+ // Update best key for success/partial; for failure only update if no success exists
2280
3186
  if (outcome === 'success' || outcome === 'partial') {
2281
3187
  await redis.set(`cachly:lesson:best:${topic}`, lesson);
2282
3188
  }
3189
+ else if (!existingRaw) {
3190
+ await redis.set(`cachly:lesson:best:${topic}`, lesson);
3191
+ }
3192
+ // Track in decision log for session replay
3193
+ try {
3194
+ const dlKey = 'cachly:session:decision-log';
3195
+ const dlEntry = JSON.stringify({ ts, topic, outcome, what_worked: what_worked.slice(0, 120) });
3196
+ await redis.rpush(dlKey, dlEntry);
3197
+ await redis.ltrim(dlKey, -50, -1);
3198
+ }
3199
+ catch { /* non-critical */ }
2283
3200
  const emoji = outcome === 'success' ? '✅' : outcome === 'partial' ? '⚠️' : '❌';
2284
3201
  const sevEmoji = severity === 'critical' ? '🔴' : severity === 'major' ? '🟡' : '🟢';
2285
3202
  const action = isUpdate ? 'updated' : 'stored';
@@ -2294,8 +3211,13 @@ async function handleTool(name, args) {
2294
3211
  tags.length > 0 ? `**Tags:** ${tags.map(t => `#${t}`).join(' ')}` : '',
2295
3212
  ``,
2296
3213
  isUpdate
2297
- ? `♻️ Updated existing lesson (preserved recall count: ${recallCount})`
3214
+ ? `♻️ Updated (recall count: ${recallCount} · audit entries: ${auditTrail.length})`
2298
3215
  : `💡 Recall later with \`recall_best_solution(topic="${topic}")\``,
3216
+ depends_on.length > 0
3217
+ ? `🔗 Depends on: ${depends_on.map(d => `\`${d}\``).join(', ')} → trace with \`trace_dependency\``
3218
+ : '',
3219
+ ...templateWarnings,
3220
+ ...iWasWrongWarning,
2299
3221
  ].filter(l => l !== '').join('\n');
2300
3222
  }
2301
3223
  case 'recall_best_solution': {
@@ -2304,40 +3226,36 @@ async function handleTool(name, args) {
2304
3226
  // Try exact best-solution key first
2305
3227
  const best = await redis.get(`cachly:lesson:best:${topic}`);
2306
3228
  if (best) {
2307
- let lesson;
2308
- try {
2309
- lesson = JSON.parse(best);
2310
- }
2311
- catch {
2312
- return `⚠️ Lesson for \`${topic}\` could not be read (corrupted data). Use \`learn_from_attempts\` to overwrite it.`;
2313
- }
2314
- // Increment recall_count
2315
- const updatedLesson = { ...lesson, recall_count: (lesson.recall_count ?? 0) + 1 };
3229
+ const lesson = JSON.parse(best);
3230
+ // ── Confidence decay check ───────────────────────────────────────────
3231
+ const confidence = calculateConfidence(lesson);
3232
+ const ref = lesson.verified_at ?? lesson.ts;
3233
+ const ageDays = (Date.now() - new Date(ref).getTime()) / 86400000;
3234
+ const badge = confidenceBadge(confidence, ageDays);
3235
+ // Recall resets verified_at (confidence clock restart)
3236
+ const updatedLesson = {
3237
+ ...lesson,
3238
+ recall_count: (lesson.recall_count ?? 0) + 1,
3239
+ verified_at: new Date().toISOString(),
3240
+ confidence: 1.0,
3241
+ };
2316
3242
  await redis.set(`cachly:lesson:best:${topic}`, JSON.stringify(updatedLesson));
2317
- // Fire-and-forget: track Magic Moment in Plausible + API engagement counter
2318
- const isFirstRecall = (lesson.recall_count ?? 0) === 0;
2319
- const recallEvent = isFirstRecall ? 'First Brain Recall' : 'Brain Recall Hit';
2320
- fetch('https://analytics.cachly.dev/api/event', {
2321
- method: 'POST',
2322
- headers: { 'Content-Type': 'application/json', 'User-Agent': 'cachly-mcp/recall' },
2323
- body: JSON.stringify({
2324
- domain: 'cachly.dev', name: recallEvent,
2325
- url: 'https://cachly.dev/mcp/recall', props: { topic },
2326
- }),
2327
- }).catch(() => { });
2328
- if (JWT) {
2329
- fetch(`${API_URL}/api/v1/telemetry/mcp`, {
2330
- method: 'POST',
2331
- headers: { 'Content-Type': 'application/json' },
2332
- body: JSON.stringify({ event: isFirstRecall ? 'first_recall' : 'recall', api_key: JWT }),
2333
- }).catch(() => { });
2334
- }
2335
3243
  const sevEmoji = lesson.severity === 'critical' ? '🔴' : lesson.severity === 'major' ? '🟡' : lesson.severity ? '🟢' : '';
2336
- const sessionAgeMs = Date.now() - new Date(lesson.ts).getTime();
2337
- const sessionAgeDays = Math.round(sessionAgeMs / 86_400_000);
2338
- const ageLabel = sessionAgeDays === 0 ? 'today' : sessionAgeDays === 1 ? 'yesterday' : `${sessionAgeDays} days ago`;
3244
+ const auditSummary = (lesson.audit_trail ?? []).length > 1
3245
+ ? `_Audit: ${(lesson.audit_trail ?? []).length} changes · stored ${new Date(lesson.ts).toLocaleDateString('de-DE')}_`
3246
+ : '';
3247
+ // "Remember when..." — emotional header for lessons > 60 days old
3248
+ const ageFromStoreDays = (Date.now() - new Date(lesson.ts).getTime()) / 86400000;
3249
+ const rememberWhen = ageFromStoreDays > 60
3250
+ ? `💭 _Remember when you solved this ${Math.round(ageFromStoreDays / 30)} months ago? Still works._`
3251
+ : '';
3252
+ // "Never Google This Again" — suggest pinning after 3rd recall
3253
+ const suggestPin = updatedLesson.recall_count === 3 && !lesson.pinned
3254
+ ? `📌 **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.`
3255
+ : '';
2339
3256
  return [
2340
- `✅ **Best solution for \`${topic}\`** ${sevEmoji}${lesson.severity ? ` (${lesson.severity})` : ''} · recalled ${updatedLesson.recall_count}× · stored ${ageLabel}`,
3257
+ rememberWhen,
3258
+ `${badge} **Best solution for \`${topic}\`** ${sevEmoji}${lesson.severity ? ` (${lesson.severity})` : ''} · recalled ${updatedLesson.recall_count}×`,
2341
3259
  ``,
2342
3260
  `**What worked:** ${lesson.what_worked}`,
2343
3261
  lesson.what_failed ? `**What failed (avoid this):** ${lesson.what_failed}` : '',
@@ -2345,8 +3263,8 @@ async function handleTool(name, args) {
2345
3263
  (lesson.file_paths ?? []).length > 0 ? `**Files:** ${(lesson.file_paths ?? []).map((f) => `\`${f}\``).join(', ')}` : '',
2346
3264
  (lesson.commands ?? []).length > 0 ? `**Commands:** ${(lesson.commands ?? []).map((c) => `\`${c}\``).join(', ')}` : '',
2347
3265
  (lesson.tags ?? []).length > 0 ? `**Tags:** ${(lesson.tags ?? []).map((t) => `#${t}`).join(' ')}` : '',
2348
- ``,
2349
- `> 🧠 *Your AI remembered this from ${ageLabel} — persistent memory by [cachly.dev](https://cachly.dev)*`,
3266
+ auditSummary,
3267
+ suggestPin,
2350
3268
  ].filter(l => l !== '').join('\n');
2351
3269
  }
2352
3270
  // Partial match: scan all lesson keys for topic substring
@@ -2363,12 +3281,7 @@ async function handleTool(name, args) {
2363
3281
  const histKey = `cachly:lessons:${topic}`;
2364
3282
  const all = await redis.lrange(histKey, -3, -1);
2365
3283
  if (all.length > 0) {
2366
- const parsed = all.flatMap(e => { try {
2367
- return [JSON.parse(e)];
2368
- }
2369
- catch {
2370
- return [];
2371
- } });
3284
+ const parsed = all.map(e => JSON.parse(e));
2372
3285
  const lines = parsed.map(p => `- ${p.outcome === 'success' ? '✅' : '❌'} ${p.what_worked.slice(0, 120)} (${new Date(p.ts).toLocaleDateString('de-DE')})`);
2373
3286
  return `⚠️ No successful solution for \`${topic}\` yet. Last attempts:\n\n${lines.join('\n')}`;
2374
3287
  }
@@ -2380,74 +3293,11 @@ async function handleTool(name, args) {
2380
3293
  const raw = await redis.get(k);
2381
3294
  if (!raw)
2382
3295
  continue;
2383
- let lesson;
2384
- try {
2385
- lesson = JSON.parse(raw);
2386
- }
2387
- catch {
2388
- continue;
2389
- }
3296
+ const lesson = JSON.parse(raw);
2390
3297
  results.push(`**\`${lesson.topic}\`** — ${lesson.what_worked.slice(0, 200)}`);
2391
3298
  }
2392
3299
  return `🔍 **Partial matches for \`${topic}\`:**\n\n${results.join('\n\n')}`;
2393
3300
  }
2394
- case 'list_lessons': {
2395
- const { instance_id, filter = '' } = args;
2396
- const redis = await getConnection(instance_id);
2397
- const keys = [];
2398
- const stream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
2399
- await new Promise((resolve, reject) => {
2400
- stream.on('data', (batch) => keys.push(...batch));
2401
- stream.on('end', resolve);
2402
- stream.on('error', reject);
2403
- });
2404
- if (keys.length === 0)
2405
- return `📭 No lessons stored yet. Use \`learn_from_attempts\` after solving any problem.`;
2406
- const values = await redis.mget(...keys);
2407
- const lessons = values.flatMap(raw => {
2408
- if (!raw)
2409
- return [];
2410
- try {
2411
- return [JSON.parse(raw)];
2412
- }
2413
- catch {
2414
- return [];
2415
- }
2416
- });
2417
- const filtered = filter
2418
- ? lessons.filter(l => l.topic.toLowerCase().includes(filter.toLowerCase()))
2419
- : lessons;
2420
- filtered.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
2421
- if (filtered.length === 0)
2422
- return `📭 No lessons matching \`${filter}\`.`;
2423
- const sevEmoji = (s) => s === 'critical' ? '🔴' : s === 'major' ? '🟡' : '🟢';
2424
- const lines = [
2425
- `🧠 **${filtered.length} lesson${filtered.length === 1 ? '' : 's'}${filter ? ` matching "${filter}"` : ''} in Brain:**`,
2426
- '',
2427
- ...filtered.slice(0, 30).map(l => {
2428
- const age = Math.round((Date.now() - new Date(l.ts).getTime()) / 86400000);
2429
- const ageStr = age === 0 ? 'today' : age === 1 ? 'yesterday' : `${age}d ago`;
2430
- const recalls = l.recall_count ? ` · recalled ${l.recall_count}×` : '';
2431
- return `${sevEmoji(l.severity)} \`${l.topic}\` — ${ageStr}${recalls}`;
2432
- }),
2433
- filtered.length > 30 ? `\n_…and ${filtered.length - 30} more. Use filter to narrow down._` : '',
2434
- ].filter(l => l !== '');
2435
- return lines.join('\n');
2436
- }
2437
- case 'forget_lesson': {
2438
- const { instance_id, topic } = args;
2439
- const redis = await getConnection(instance_id);
2440
- const bestKey = `cachly:lesson:best:${topic}`;
2441
- const histKey = `cachly:lessons:${topic}`;
2442
- const [delBest, delHist] = await Promise.all([
2443
- redis.del(bestKey),
2444
- redis.del(histKey),
2445
- ]);
2446
- if (delBest === 0 && delHist === 0) {
2447
- return `📭 No lesson found for \`${topic}\` — nothing to delete.`;
2448
- }
2449
- 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\`.`;
2450
- }
2451
3301
  case 'smart_recall': {
2452
3302
  const { instance_id, query, threshold = 0.78, } = args;
2453
3303
  const redis = await getConnection(instance_id);
@@ -2542,50 +3392,35 @@ async function handleTool(name, args) {
2542
3392
  }
2543
3393
  // ── get_api_status ────────────────────────────────────────────────────────
2544
3394
  case 'get_api_status': {
2545
- // Check API health and auth service in parallel
2546
- const [healthResult, authServiceResult] = await Promise.allSettled([
2547
- fetch(`${API_URL}/health`, { signal: AbortSignal.timeout(5000) }),
2548
- fetch(`${AUTH_URL}/.well-known/openid-configuration`, { signal: AbortSignal.timeout(5000) }),
2549
- ]);
3395
+ // Check health
2550
3396
  let healthStatus = 'unknown';
2551
- if (healthResult.status === 'fulfilled') {
2552
- const healthRes = healthResult.value;
3397
+ try {
3398
+ const healthRes = await fetch(`${API_URL}/health`);
2553
3399
  if (healthRes.ok) {
2554
- const body = await healthRes.json().catch(() => ({}));
2555
- healthStatus = `✅ ${body.status ?? 'ok'}${body.db ? ` (db: ${body.db})` : ''}`;
3400
+ const body = await healthRes.json();
3401
+ healthStatus = `${body.status ?? 'ok'} (db: ${body.db ?? '?'})`;
2556
3402
  }
2557
3403
  else {
2558
- healthStatus = `❌ HTTP ${healthRes.status}`;
3404
+ healthStatus = `HTTP ${healthRes.status}`;
2559
3405
  }
2560
3406
  }
2561
- else {
2562
- healthStatus = `❌ unreachable: ${healthResult.reason?.message ?? 'timeout'}`;
2563
- }
2564
- let authSvcStatus = 'unknown';
2565
- if (authServiceResult.status === 'fulfilled') {
2566
- authSvcStatus = authServiceResult.value.ok
2567
- ? `✅ reachable (${AUTH_URL})`
2568
- : `❌ HTTP ${authServiceResult.value.status} — auth service may be down`;
2569
- }
2570
- else {
2571
- authSvcStatus = `❌ unreachable: ${authServiceResult.reason?.message ?? 'timeout'} — auth service may be down`;
3407
+ catch (e) {
3408
+ healthStatus = `unreachable: ${e.message}`;
2572
3409
  }
2573
3410
  // Check JWT / auth
2574
3411
  if (!JWT) {
2575
3412
  return [
2576
3413
  `📡 **cachly API Status**`,
2577
3414
  ``,
2578
- ` 🌐 API: ${API_URL}`,
2579
- ` 💓 API Health: ${healthStatus}`,
2580
- ` 🔐 Auth Service: ${authSvcStatus}`,
2581
- ` 🔑 JWT: ❌ CACHLY_JWT not set`,
3415
+ ` 🌐 API: ${API_URL}`,
3416
+ ` 💓 Health: ${healthStatus}`,
3417
+ ` 🔑 Auth: ❌ CACHLY_JWT not set`,
2582
3418
  ``,
2583
- `💡 Get your API token at https://cachly.dev/settings`,
3419
+ `💡 Get your API token at https://cachly.dev/instances → Settings → API Token`,
2584
3420
  ].join('\n');
2585
3421
  }
2586
3422
  // Decode JWT claims (inspection only, no verification)
2587
3423
  let authInfo = '❌ invalid JWT format';
2588
- let isExpired = false;
2589
3424
  try {
2590
3425
  const parts = JWT.split('.');
2591
3426
  if (parts.length === 3) {
@@ -2594,41 +3429,32 @@ async function handleTool(name, args) {
2594
3429
  const iss = payload.iss ?? '(unknown)';
2595
3430
  const provider = iss.includes('keycloak') ? 'Keycloak' : 'OIDC';
2596
3431
  const expTs = payload.exp ? new Date(payload.exp * 1000) : null;
2597
- isExpired = expTs ? expTs < new Date() : false;
3432
+ const expired = expTs ? expTs < new Date() : false;
2598
3433
  authInfo = [
2599
- `${isExpired ? '⚠️ ' : '✅'} JWT decoded`,
3434
+ `✅ JWT decoded`,
2600
3435
  ` Sub (user ID): ${sub}`,
2601
3436
  ` Provider: ${provider}`,
2602
3437
  ` Issuer: ${iss}`,
2603
- ` Expires: ${expTs ? expTs.toISOString() : 'never'} ${isExpired ? '⚠️ EXPIRED' : '✅'}`,
3438
+ ` Expires: ${expTs ? expTs.toISOString() : 'never'} ${expired ? '⚠️ EXPIRED – get a new token!' : '✅'}`,
2604
3439
  ].join('\n');
2605
3440
  }
2606
3441
  }
2607
3442
  catch {
2608
3443
  authInfo = '❌ JWT decode failed – check CACHLY_JWT format';
2609
3444
  }
2610
- const lines = [
3445
+ return [
2611
3446
  `📡 **cachly API Status**`,
2612
3447
  ``,
2613
- ` 🌐 API: ${API_URL}`,
2614
- ` 💓 API Health: ${healthStatus}`,
2615
- ` 🔐 Auth Service: ${authSvcStatus}`,
3448
+ ` 🌐 API: ${API_URL}`,
3449
+ ` 💓 Health: ${healthStatus}`,
2616
3450
  ``,
2617
- `🔑 **Auth (JWT):**`,
3451
+ `🔑 **Auth:**`,
2618
3452
  authInfo,
2619
- ];
2620
- if (isExpired) {
2621
- lines.push(``, `⚠️ Your token is expired — get a new one at https://cachly.dev/settings`);
2622
- }
2623
- if (authSvcStatus.startsWith('❌')) {
2624
- lines.push(``, `⚠️ Auth service appears down. Registration and login on cachly.dev may be unavailable.`);
2625
- lines.push(` Contact support@cachly.dev if this persists.`);
2626
- }
2627
- return lines.join('\n');
3453
+ ].join('\n');
2628
3454
  }
2629
3455
  // ── session_start ─────────────────────────────────────────────────────────
2630
3456
  case 'session_start': {
2631
- const { instance_id, focus = '' } = args;
3457
+ const { instance_id, focus = '', author = '', provider = '', workspace_path = '' } = args;
2632
3458
  const redis = await getConnection(instance_id);
2633
3459
  // 1. Scan all best-solution lessons
2634
3460
  const lessonKeys = [];
@@ -2639,17 +3465,14 @@ async function handleTool(name, args) {
2639
3465
  lStream.on('error', reject);
2640
3466
  });
2641
3467
  const lessons = [];
2642
- if (lessonKeys.length > 0) {
2643
- // Batch fetch all lesson values in one round-trip instead of N sequential gets
2644
- const values = await redis.mget(...lessonKeys);
2645
- for (const raw of values) {
2646
- if (!raw)
2647
- continue;
2648
- try {
2649
- lessons.push(JSON.parse(raw));
2650
- }
2651
- catch { /* skip corrupt */ }
3468
+ for (const k of lessonKeys) {
3469
+ const raw = await redis.get(k);
3470
+ if (!raw)
3471
+ continue;
3472
+ try {
3473
+ lessons.push(JSON.parse(raw));
2652
3474
  }
3475
+ catch { /* skip corrupt */ }
2653
3476
  }
2654
3477
  lessons.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
2655
3478
  // 3. Count context entries (filter :meta keys)
@@ -2677,13 +3500,137 @@ async function handleTool(name, args) {
2677
3500
  ? lessons.filter(l => focusTerms.some(term => l.topic.toLowerCase().includes(term) ||
2678
3501
  (l.tags ?? []).some((t) => t.toLowerCase().includes(term))))
2679
3502
  : [];
2680
- // 6. Save session start marker
3503
+ // 6. Streak tracking
3504
+ let streakDays = 0;
3505
+ let streakRecord = 0;
3506
+ let streakMessage = '';
3507
+ try {
3508
+ const streakRaw = await redis.get('cachly:streak:current');
3509
+ const streak = streakRaw ? JSON.parse(streakRaw) : null;
3510
+ const today = new Date().toISOString().slice(0, 10);
3511
+ if (streak) {
3512
+ const lastDate = streak.last_date;
3513
+ const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
3514
+ if (lastDate === today) {
3515
+ // Already counted today
3516
+ streakDays = streak.days;
3517
+ streakRecord = streak.record;
3518
+ }
3519
+ else if (lastDate === yesterday) {
3520
+ // Continuing streak
3521
+ streakDays = streak.days + 1;
3522
+ streakRecord = Math.max(streakDays, streak.record);
3523
+ await redis.set('cachly:streak:current', JSON.stringify({ days: streakDays, last_date: today, record: streakRecord }));
3524
+ }
3525
+ else {
3526
+ // Streak broken
3527
+ streakDays = 1;
3528
+ streakRecord = streak.record;
3529
+ await redis.set('cachly:streak:current', JSON.stringify({ days: 1, last_date: today, record: streakRecord }));
3530
+ }
3531
+ }
3532
+ else {
3533
+ // First session ever
3534
+ streakDays = 1;
3535
+ streakRecord = 1;
3536
+ await redis.set('cachly:streak:current', JSON.stringify({ days: 1, last_date: today, record: 1 }));
3537
+ }
3538
+ if (streakDays >= 7)
3539
+ streakMessage = `🔥 **${streakDays}-day streak!** ${streakDays === streakRecord ? ' New record!' : `Best: ${streakRecord}d`}`;
3540
+ else if (streakDays > 1)
3541
+ streakMessage = `🔥 ${streakDays}-day streak`;
3542
+ }
3543
+ catch { /* non-critical */ }
3544
+ // 7. Save session start marker
2681
3545
  await redis.set('cachly:session:current', JSON.stringify({
2682
3546
  started: new Date().toISOString(),
2683
3547
  focus,
3548
+ provider,
2684
3549
  }), 'EX', 86400); // auto-expire after 24h if session_end never called
2685
3550
  // ── Build briefing ──────────────────────────────────────────────────────
2686
- const lines = ['🧠 **Session Briefing**', ''];
3551
+ const providerLabel = provider ? ` · ${provider}` : '';
3552
+ const lines = [`🧠 **Session Briefing**${providerLabel}`, ''];
3553
+ if (streakMessage)
3554
+ lines.push(streakMessage, '');
3555
+ // Handoff from previous window (if any)
3556
+ const handoffRaw = await redis.get('cachly:session:handoff');
3557
+ if (handoffRaw) {
3558
+ try {
3559
+ const handoff = JSON.parse(handoffRaw);
3560
+ const ago = Math.round((Date.now() - new Date(handoff.ts).getTime()) / 60000);
3561
+ const agoStr = ago < 60 ? `${ago}m ago` : ago < 1440 ? `${Math.round(ago / 60)}h ago` : `${Math.round(ago / 1440)}d ago`;
3562
+ lines.push(`🤝 **Handoff from previous window** (${agoStr}):`);
3563
+ if (handoff.context_summary)
3564
+ lines.push(` ${handoff.context_summary}`);
3565
+ if (handoff.remaining_tasks.length > 0) {
3566
+ lines.push(` ⏳ **Remaining tasks:**`);
3567
+ for (const t of handoff.remaining_tasks)
3568
+ lines.push(` - ${t}`);
3569
+ }
3570
+ if (handoff.completed_tasks.length > 0) {
3571
+ lines.push(` ✅ **Already done:** ${handoff.completed_tasks.join(', ')}`);
3572
+ }
3573
+ const brokenFiles = (handoff.files_changed ?? []).filter(f => f.status === 'broken' || f.status === 'partial');
3574
+ if (brokenFiles.length > 0) {
3575
+ lines.push(` ⚠️ **Needs fix:** ${brokenFiles.map(f => `\`${f.path}\` (${f.status}${f.description ? ': ' + f.description : ''})`).join(', ')}`);
3576
+ }
3577
+ if (handoff.blocked_on)
3578
+ lines.push(` 🚫 **Blocked on:** ${handoff.blocked_on}`);
3579
+ if (handoff.instructions)
3580
+ lines.push(` 📝 **Instructions:** ${handoff.instructions}`);
3581
+ lines.push('');
3582
+ }
3583
+ catch { /* ignore corrupt handoff */ }
3584
+ }
3585
+ // ── Last checkpoint (session_ping) — shown when no session_end found ────
3586
+ const checkpointRaw = await redis.get('cachly:session:checkpoint');
3587
+ if (checkpointRaw) {
3588
+ try {
3589
+ const cp = JSON.parse(checkpointRaw);
3590
+ // Only show checkpoint if it's more recent than last session_end
3591
+ const cpTime = new Date(cp.ts).getTime();
3592
+ const lastSessionTime = lastSession ? new Date(lastSession.ts).getTime() : 0;
3593
+ if (cpTime > lastSessionTime) {
3594
+ const ago = Math.round((Date.now() - cpTime) / 60000);
3595
+ const agoStr = ago < 60 ? `${ago}m ago` : ago < 1440 ? `${Math.round(ago / 60)}h ago` : `${Math.round(ago / 1440)}d ago`;
3596
+ const providerStr = cp.provider ? ` [${cp.provider}]` : '';
3597
+ lines.push(`📌 **Last checkpoint**${providerStr} (${agoStr}): ${cp.task}`);
3598
+ if (cp.files_touched.length > 0) {
3599
+ lines.push(` Files: ${cp.files_touched.slice(0, 5).map(f => `\`${f}\``).join(', ')}`);
3600
+ }
3601
+ if (cp.next_step)
3602
+ lines.push(` 📍 Next step was: ${cp.next_step}`);
3603
+ if (!lastSession || cpTime - lastSessionTime > 300_000) {
3604
+ lines.push(` ⚠️ No \`session_end\` found — reconstructed from last checkpoint`);
3605
+ }
3606
+ lines.push('');
3607
+ }
3608
+ }
3609
+ catch { /* ignore */ }
3610
+ }
3611
+ // ── Git reconstruction — when no session_end + workspace_path given ─────
3612
+ if (workspace_path && !lastSession) {
3613
+ try {
3614
+ const { execSync } = await import('node:child_process');
3615
+ const gitLog = execSync(`git -C "${workspace_path}" log --oneline --format="%h %s" -15 2>/dev/null`, { encoding: 'utf-8', timeout: 5000 }).trim();
3616
+ const gitDiff = execSync(`git -C "${workspace_path}" diff --stat HEAD~3 2>/dev/null || git -C "${workspace_path}" diff --stat 2>/dev/null`, { encoding: 'utf-8', timeout: 5000 }).trim();
3617
+ if (gitLog) {
3618
+ lines.push(`🔍 **Git reconstruction** (no session_end found — reconstructed from git):`);
3619
+ for (const l of gitLog.split('\n').slice(0, 8))
3620
+ lines.push(` ${l}`);
3621
+ if (gitDiff) {
3622
+ const diffLines = gitDiff.split('\n').filter(l => l.includes('|') || l.includes('changed'));
3623
+ if (diffLines.length > 0) {
3624
+ lines.push(` **Recent changes:**`);
3625
+ for (const dl of diffLines.slice(0, 5))
3626
+ lines.push(` ${dl.trim()}`);
3627
+ }
3628
+ }
3629
+ lines.push('');
3630
+ }
3631
+ }
3632
+ catch { /* git not available or no repo — silent */ }
3633
+ }
2687
3634
  // Last session
2688
3635
  if (lastSession) {
2689
3636
  const ago = Math.round((Date.now() - new Date(lastSession.ts).getTime()) / 60000);
@@ -2696,27 +3643,8 @@ async function handleTool(name, args) {
2696
3643
  }
2697
3644
  lines.push('');
2698
3645
  }
2699
- // Brain health + first-time onboarding hint
2700
- if (lessons.length === 0 && !lastSession) {
2701
- lines.push(`🆕 **Brain is empty — first session!**`);
2702
- lines.push(`After solving anything, call \`learn_from_attempts\` to store the lesson.`);
2703
- lines.push(`After this session, call \`session_end\` to save a summary.`);
2704
- lines.push('');
2705
- }
2706
- else {
2707
- const totalRecalls = lessons.reduce((s, l) => s + (l.recall_count ?? 0), 0);
2708
- const savedMin = totalRecalls * 15;
2709
- const savedStr = savedMin >= 60
2710
- ? `~${(savedMin / 60).toFixed(1)}h saved`
2711
- : savedMin > 0 ? `~${savedMin}min saved` : '';
2712
- const statsLine = [
2713
- `${lessons.length} lessons`,
2714
- ctxCount > 0 ? `${ctxCount} context entries` : '',
2715
- totalRecalls > 0 ? `${totalRecalls} recalls` : '',
2716
- savedStr,
2717
- ].filter(Boolean).join(' · ');
2718
- lines.push(`📊 **Brain:** ${statsLine}`, '');
2719
- }
3646
+ // Brain health
3647
+ lines.push(`📊 **Brain:** ${lessons.length} lessons · ${ctxCount} context entries`, '');
2720
3648
  // Focus-relevant lessons
2721
3649
  if (focusLessons.length > 0) {
2722
3650
  lines.push(`🎯 **Relevant for "${focus}":**`);
@@ -2750,11 +3678,137 @@ async function handleTool(name, args) {
2750
3678
  }
2751
3679
  lines.push('');
2752
3680
  }
3681
+ // ── Stale / low-confidence lessons (confidence decay) ─────────────────
3682
+ const staleSuccessLessons = lessons.filter(l => {
3683
+ if (l.outcome !== 'success' && l.outcome !== 'partial')
3684
+ return false;
3685
+ return calculateConfidence(l) < CONFIDENCE_WARN_VALUE;
3686
+ });
3687
+ if (staleSuccessLessons.length > 0) {
3688
+ lines.push(`🔴 **Stale lessons** (not recalled in >${CONFIDENCE_WARN_DAYS}d — verify before applying):`);
3689
+ for (const l of staleSuccessLessons.slice(0, 4)) {
3690
+ const conf = calculateConfidence(l);
3691
+ const ageDays = Math.round((Date.now() - new Date(l.verified_at ?? l.ts).getTime()) / 86400000);
3692
+ const flag = conf < CONFIDENCE_STALE_VALUE ? '🔴' : '⚠️';
3693
+ lines.push(` ${flag} \`${l.topic}\` — ${ageDays}d stale, ${(conf * 100).toFixed(0)}% confidence`);
3694
+ }
3695
+ lines.push(` _Run \`recall_best_solution\` on these to reset their confidence clock._`);
3696
+ lines.push('');
3697
+ }
3698
+ // ── Session Replay: show last session's decision log ──────────────────
3699
+ const lastSessionAny = lastSession;
3700
+ if (lastSessionAny?.decision_log?.length) {
3701
+ const dl = lastSessionAny.decision_log;
3702
+ const successes = dl.filter(d => d.outcome === 'success');
3703
+ const failures = dl.filter(d => d.outcome === 'failure');
3704
+ lines.push(`🎬 **Last session decisions** (${dl.length} lessons stored):`);
3705
+ if (successes.length > 0)
3706
+ lines.push(` ✅ Worked: ${successes.slice(0, 3).map(d => `\`${d.topic}\``).join(', ')}`);
3707
+ if (failures.length > 0)
3708
+ lines.push(` ❌ Failed: ${failures.slice(0, 3).map(d => `\`${d.topic}\``).join(', ')}`);
3709
+ lines.push('');
3710
+ }
3711
+ // ── 🔮 Predictive Pre-Warning — intent-based danger detection ────────────
3712
+ // Fires BEFORE work starts. If focus area has known failure patterns → warn loudly.
3713
+ if (focusTerms.length > 0) {
3714
+ const dangerLessons = lessons.filter(l => {
3715
+ if (l.outcome === 'success')
3716
+ return false;
3717
+ const topicCategory = l.topic.split(':')[0];
3718
+ return focusTerms.some(term => l.topic.toLowerCase().includes(term) ||
3719
+ topicCategory === term ||
3720
+ (l.tags ?? []).some((t) => t.toLowerCase() === term));
3721
+ });
3722
+ if (dangerLessons.length >= 1) {
3723
+ // Insert warning block right after the title line (index 1 = blank line after title)
3724
+ const warning = [
3725
+ `🚨 **PRE-WARNING** — Read this BEFORE starting:`,
3726
+ ` Known pitfalls for **"${focus}"** (${dangerLessons.length} past failure${dangerLessons.length > 1 ? 's' : ''}):`,
3727
+ ...dangerLessons.slice(0, 3).map(l => ` ❌ \`${l.topic}\` — ${(l.what_failed ?? l.what_worked).slice(0, 80)}`),
3728
+ '',
3729
+ ];
3730
+ lines.splice(2, 0, ...warning); // after '🧠 **Session Briefing**' + empty line
3731
+ }
3732
+ }
3733
+ // ── 👥 Team Telepathy — what teammates learned this week ─────────────────
3734
+ if (author) {
3735
+ const oneWeekAgo = Date.now() - 7 * 86_400_000;
3736
+ const teamLessons = lessons.filter(l => l.author && l.author !== author && new Date(l.ts).getTime() > oneWeekAgo);
3737
+ if (teamLessons.length > 0) {
3738
+ // Group by author
3739
+ const byAuthor = new Map();
3740
+ for (const l of teamLessons) {
3741
+ const a = l.author;
3742
+ if (!byAuthor.has(a))
3743
+ byAuthor.set(a, []);
3744
+ byAuthor.get(a).push(l);
3745
+ }
3746
+ lines.push(`👥 **Team this week** (${teamLessons.length} lesson${teamLessons.length > 1 ? 's' : ''} from teammates):`);
3747
+ for (const [teamAuthor, tls] of byAuthor) {
3748
+ lines.push(` 👤 **${teamAuthor}**:`);
3749
+ for (const l of tls.slice(0, 3)) {
3750
+ const emoji = l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
3751
+ lines.push(` ${emoji} \`${l.topic}\` — ${l.what_worked.slice(0, 80)}`);
3752
+ }
3753
+ if (tls.length > 3)
3754
+ lines.push(` … and ${tls.length - 3} more`);
3755
+ }
3756
+ lines.push('');
3757
+ }
3758
+ }
3759
+ // ── 💎 Memory Crystal — compressed wisdom from old sessions ──────────────
3760
+ try {
3761
+ const crystalRaw = await redis.get('cachly:crystal:latest');
3762
+ if (crystalRaw) {
3763
+ const crystal = JSON.parse(crystalRaw);
3764
+ const crystalAge = Math.round((Date.now() - new Date(crystal.ts).getTime()) / 86_400_000);
3765
+ if (crystalAge <= 90) {
3766
+ lines.push(`💎 **Memory Crystal** (${crystal.label} · ${crystal.session_count} sessions compressed):`);
3767
+ for (const p of crystal.top_patterns.slice(0, 3)) {
3768
+ lines.push(` • **${p.category}** (${p.count}×): ${p.insight.slice(0, 90)}`);
3769
+ }
3770
+ lines.push('');
3771
+ }
3772
+ }
3773
+ }
3774
+ catch { /* non-critical */ }
3775
+ // ── 🗺️ Roadmap — open items at session start ────────────────────────────
3776
+ try {
3777
+ const roadmapAll = await redis.hgetall(`cachly:roadmap:${instance_id}`);
3778
+ if (roadmapAll && Object.keys(roadmapAll).length > 0) {
3779
+ const PRIORITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
3780
+ const PRIORITY_ICON = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
3781
+ const openStatuses = new Set(['planned', 'in-progress', 'blocked']);
3782
+ const allItems = Object.values(roadmapAll).map(v => JSON.parse(v));
3783
+ const openItems = allItems
3784
+ .filter(i => openStatuses.has(i.status))
3785
+ .sort((a, b) => {
3786
+ if (a.status === 'in-progress' && b.status !== 'in-progress')
3787
+ return -1;
3788
+ if (b.status === 'in-progress' && a.status !== 'in-progress')
3789
+ return 1;
3790
+ return (PRIORITY_ORDER[a.priority] ?? 99) - (PRIORITY_ORDER[b.priority] ?? 99);
3791
+ });
3792
+ const doneCount = allItems.filter(i => i.status === 'done').length;
3793
+ if (openItems.length > 0) {
3794
+ lines.push(`🗺️ **Roadmap** (${openItems.length} open · ${doneCount} done):`);
3795
+ for (const it of openItems.slice(0, 5)) {
3796
+ const statusIcon = it.status === 'in-progress' ? '⚡' : it.status === 'blocked' ? '🚫' : '📋';
3797
+ lines.push(` ${statusIcon} ${PRIORITY_ICON[it.priority] ?? '⚪'} \`${it.id}\` **${it.title}**`);
3798
+ }
3799
+ if (openItems.length > 5)
3800
+ lines.push(` … and ${openItems.length - 5} more`);
3801
+ lines.push(` _Use \`roadmap_next\` for the top priority item · \`roadmap_list\` for full view_`);
3802
+ lines.push('');
3803
+ }
3804
+ }
3805
+ }
3806
+ catch { /* non-critical */ }
2753
3807
  return lines.join('\n');
2754
3808
  }
2755
3809
  // ── session_end ───────────────────────────────────────────────────────────
2756
3810
  case 'session_end': {
2757
- const { instance_id, summary, files_changed = [], lessons_learned, } = args;
3811
+ const { instance_id, summary, files_changed = [], lessons_learned, workspace_path = '', } = args;
2758
3812
  const redis = await getConnection(instance_id);
2759
3813
  const now = new Date();
2760
3814
  // Calculate duration from session_start marker
@@ -2767,12 +3821,21 @@ async function handleTool(name, args) {
2767
3821
  }
2768
3822
  catch { /* ignore */ }
2769
3823
  }
3824
+ // ── Session Replay: capture decision log ─────────────────────────────
3825
+ let decisionLog = [];
3826
+ try {
3827
+ const dlEntries = await redis.lrange('cachly:session:decision-log', 0, -1);
3828
+ decisionLog = dlEntries.map(e => JSON.parse(e));
3829
+ await redis.del('cachly:session:decision-log');
3830
+ }
3831
+ catch { /* non-critical */ }
2770
3832
  const sessionRecord = {
2771
3833
  ts: now.toISOString(),
2772
3834
  summary,
2773
3835
  files_changed,
2774
3836
  ...(lessons_learned !== undefined ? { lessons_learned } : {}),
2775
3837
  ...(durationMin !== undefined ? { duration_min: durationMin } : {}),
3838
+ ...(decisionLog.length > 0 ? { decision_log: decisionLog } : {}),
2776
3839
  };
2777
3840
  // Save as "last session"
2778
3841
  await redis.set('cachly:session:last', JSON.stringify(sessionRecord));
@@ -2782,6 +3845,131 @@ async function handleTool(name, args) {
2782
3845
  await redis.expire('cachly:session:history', 90 * 86400);
2783
3846
  // Clean up current session marker
2784
3847
  await redis.del('cachly:session:current');
3848
+ // ── AUTO-LEARN from session summary (no manual call needed) ─────────────
3849
+ // Parse the summary for actionable lessons and store them automatically.
3850
+ const autoLearned = [];
3851
+ try {
3852
+ // Extract key sentences from the summary that contain action verbs
3853
+ 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;
3854
+ const sentences = summary
3855
+ .split(/[.!\n]+/)
3856
+ .map(s => s.trim())
3857
+ .filter(s => s.length > 20 && actionVerbs.test(s));
3858
+ for (const sentence of sentences.slice(0, 6)) {
3859
+ // Build a topic slug from the first meaningful words
3860
+ const words = sentence.toLowerCase()
3861
+ .replace(/[^a-z0-9\s]/g, ' ')
3862
+ .split(/\s+/)
3863
+ .filter(w => w.length > 3 && !['that', 'this', 'with', 'from', 'have', 'been', 'were', 'they', 'then', 'when', 'also', 'into', 'will', 'would', 'could', 'should'].includes(w));
3864
+ const slug = words.slice(0, 4).join('-');
3865
+ if (!slug)
3866
+ continue;
3867
+ const topic = `auto:${slug}`;
3868
+ const key = `cachly:lesson:best:${topic}`;
3869
+ // Don't overwrite existing successful lessons
3870
+ const existing = await redis.get(key);
3871
+ if (existing) {
3872
+ try {
3873
+ const ex = JSON.parse(existing);
3874
+ if (ex.outcome === 'success')
3875
+ continue;
3876
+ }
3877
+ catch { /* ignore */ }
3878
+ }
3879
+ const lesson = {
3880
+ topic,
3881
+ outcome: 'success',
3882
+ what_worked: sentence,
3883
+ context: `Auto-learned from session summary. Full summary: ${summary.slice(0, 300)}`,
3884
+ severity: 'minor',
3885
+ ts: now.toISOString(),
3886
+ recall_count: 0,
3887
+ auto_learned: true,
3888
+ session_ts: now.toISOString(),
3889
+ version: 2,
3890
+ };
3891
+ await redis.set(key, JSON.stringify(lesson));
3892
+ // 90-day TTL for auto-learned lessons
3893
+ await redis.expire(key, 90 * 86400);
3894
+ autoLearned.push(topic);
3895
+ }
3896
+ // Also store a lesson per changed file area if files were changed
3897
+ if (files_changed.length > 0) {
3898
+ const areas = [...new Set(files_changed.map(f => f.split('/').slice(0, 2).join('/')))].slice(0, 3);
3899
+ for (const area of areas) {
3900
+ const slug = area.replace(/[^a-z0-9]/gi, '-').toLowerCase().replace(/-+/g, '-').slice(0, 30);
3901
+ const topic = `auto:changed:${slug}`;
3902
+ const key = `cachly:lesson:best:${topic}`;
3903
+ const lesson = {
3904
+ topic,
3905
+ outcome: 'success',
3906
+ what_worked: `Files changed in ${area}: ${files_changed.filter(f => f.startsWith(area.split('/')[0])).slice(0, 5).join(', ')}`,
3907
+ context: summary.slice(0, 200),
3908
+ severity: 'minor',
3909
+ ts: now.toISOString(),
3910
+ recall_count: 0,
3911
+ auto_learned: true,
3912
+ version: 2,
3913
+ };
3914
+ await redis.set(key, JSON.stringify(lesson));
3915
+ await redis.expire(key, 90 * 86400);
3916
+ autoLearned.push(topic);
3917
+ }
3918
+ }
3919
+ }
3920
+ catch { /* auto-learn errors must never break session_end */ }
3921
+ // ── 🌿 Ambient Git Learning ────────────────────────────────────────────────
3922
+ // Read git commits since session start → auto-learn each meaningful commit.
3923
+ const ambientLearned = [];
3924
+ if (workspace_path) {
3925
+ try {
3926
+ // Get the session start time (stored by session_start)
3927
+ const sessionStartTs = currentRaw
3928
+ ? (() => { try {
3929
+ return JSON.parse(currentRaw).started ?? '';
3930
+ }
3931
+ catch {
3932
+ return '';
3933
+ } })()
3934
+ : '';
3935
+ const sinceArg = sessionStartTs ? `--since="${sessionStartTs}"` : '--since="1 hour ago"';
3936
+ const gitOut = execSync(`git -C "${workspace_path}" log ${sinceArg} --oneline --format="%H|||%s|||%ai"`, { timeout: 5000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
3937
+ if (gitOut) {
3938
+ 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;
3939
+ for (const line of gitOut.split('\n').slice(0, 10)) {
3940
+ const [hash, msg, dateStr] = line.split('|||');
3941
+ if (!msg || !commitActionRe.test(msg))
3942
+ continue;
3943
+ const slug = msg
3944
+ .toLowerCase().replace(/^(fix|feat|chore|docs|test|ci|perf|refactor|build|revert)[:(\s]/i, '')
3945
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
3946
+ if (!slug)
3947
+ continue;
3948
+ const topic = `git:${slug}`;
3949
+ const key = `cachly:lesson:best:${topic}`;
3950
+ const existing = await redis.get(key);
3951
+ if (existing)
3952
+ continue; // don't overwrite existing
3953
+ const commitLesson = {
3954
+ topic,
3955
+ outcome: 'success',
3956
+ what_worked: msg.slice(0, 200),
3957
+ context: `Auto-learned from git commit ${(hash ?? '').slice(0, 7)} at ${dateStr ?? ''} in ${workspace_path}`,
3958
+ severity: 'minor',
3959
+ ts: now.toISOString(),
3960
+ recall_count: 0,
3961
+ auto_learned: true,
3962
+ source: 'ambient-git',
3963
+ version: 3,
3964
+ };
3965
+ await redis.set(key, JSON.stringify(commitLesson));
3966
+ await redis.expire(key, 60 * 86400); // 60 day TTL for git lessons
3967
+ ambientLearned.push(topic);
3968
+ }
3969
+ }
3970
+ }
3971
+ catch { /* git not available or not a repo — silent skip */ }
3972
+ }
2785
3973
  const durationStr = durationMin !== undefined ? ` · ${durationMin} min` : '';
2786
3974
  return [
2787
3975
  `✅ **Session saved**${durationStr}`,
@@ -2789,10 +3977,73 @@ async function handleTool(name, args) {
2789
3977
  `📋 **Summary:** ${summary}`,
2790
3978
  files_changed.length > 0 ? `📁 **Files changed:** ${files_changed.map(f => `\`${f}\``).join(', ')}` : '',
2791
3979
  lessons_learned !== undefined ? `🧠 **Lessons stored:** ${lessons_learned}` : '',
3980
+ autoLearned.length > 0 ? `🤖 **Auto-learned:** ${autoLearned.length} lessons extracted from summary (${autoLearned.slice(0, 3).map(t => `\`${t}\``).join(', ')}${autoLearned.length > 3 ? '…' : ''})` : '',
3981
+ 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 ? '…' : ''})` : '',
2792
3982
  ``,
2793
3983
  `💡 Next session: \`session_start(focus="...")\` to see this summary.`,
2794
3984
  ].filter(l => l !== '').join('\n');
2795
3985
  }
3986
+ // ── session_ping — lightweight checkpoint ─────────────────────────────────
3987
+ case 'session_ping': {
3988
+ const { instance_id, task, files_touched = [], next_step = '', provider = '', } = args;
3989
+ const redis = await getConnection(instance_id);
3990
+ const checkpoint = {
3991
+ ts: new Date().toISOString(),
3992
+ task,
3993
+ files_touched,
3994
+ next_step,
3995
+ provider,
3996
+ };
3997
+ // Store as the latest checkpoint — session_start reads this when no session_end found
3998
+ await redis.set('cachly:session:checkpoint', JSON.stringify(checkpoint), 'EX', 86400 * 3); // 3-day TTL
3999
+ // Also keep a short rolling log (last 20 checkpoints for history)
4000
+ await redis.lpush('cachly:session:checkpoint:log', JSON.stringify(checkpoint));
4001
+ await redis.ltrim('cachly:session:checkpoint:log', 0, 19);
4002
+ const providerStr = provider ? ` [${provider}]` : '';
4003
+ const filesStr = files_touched.length > 0 ? ` · ${files_touched.length} file${files_touched.length > 1 ? 's' : ''} touched` : '';
4004
+ const nextStr = next_step ? `\n📍 **Next step:** ${next_step}` : '';
4005
+ return [
4006
+ `📌 **Checkpoint saved**${providerStr} — ${new Date().toLocaleTimeString()}`,
4007
+ `🔨 **Working on:** ${task}${filesStr}`,
4008
+ nextStr,
4009
+ ``,
4010
+ `💡 If you switch providers, \`session_start\` will show this checkpoint automatically.`,
4011
+ ].filter(l => l !== '').join('\n');
4012
+ }
4013
+ // ── session_handoff — cross-window continuity ─────────────────────────────
4014
+ case 'session_handoff': {
4015
+ const { instance_id, completed_tasks = [], remaining_tasks = [], files_changed = [], instructions = '', context_summary = '', blocked_on = '', } = args;
4016
+ const redis = await getConnection(instance_id);
4017
+ const now = new Date();
4018
+ const handoff = {
4019
+ ts: now.toISOString(),
4020
+ completed_tasks,
4021
+ remaining_tasks,
4022
+ files_changed,
4023
+ instructions,
4024
+ context_summary,
4025
+ blocked_on,
4026
+ };
4027
+ // Store handoff — never expires until next handoff overwrites it
4028
+ await redis.set('cachly:session:handoff', JSON.stringify(handoff));
4029
+ // Also append to history
4030
+ await redis.lpush('cachly:session:handoff:history', JSON.stringify(handoff));
4031
+ await redis.ltrim('cachly:session:handoff:history', 0, 19);
4032
+ const totalTasks = completed_tasks.length + remaining_tasks.length;
4033
+ const pct = totalTasks > 0 ? Math.round((completed_tasks.length / totalTasks) * 100) : 0;
4034
+ const brokenFiles = files_changed.filter(f => f.status === 'broken' || f.status === 'partial');
4035
+ return [
4036
+ `🤝 **Handoff saved** — ${completed_tasks.length}/${totalTasks} tasks done (${pct}%)`,
4037
+ ``,
4038
+ completed_tasks.length > 0 ? `✅ **Completed:**\n${completed_tasks.map(t => ` - ${t}`).join('\n')}` : '',
4039
+ remaining_tasks.length > 0 ? `\n⏳ **Remaining for next window:**\n${remaining_tasks.map(t => ` - ${t}`).join('\n')}` : '',
4040
+ brokenFiles.length > 0 ? `\n⚠️ **Needs attention:** ${brokenFiles.map(f => `\`${f.path}\` (${f.status})`).join(', ')}` : '',
4041
+ blocked_on ? `\n🚫 **Blocked on:** ${blocked_on}` : '',
4042
+ instructions ? `\n📝 **Instructions:** ${instructions}` : '',
4043
+ ``,
4044
+ `💡 The next \`session_start\` will include this handoff automatically.`,
4045
+ ].filter(l => l !== '').join('\n');
4046
+ }
2796
4047
  // ── auto_learn_session ────────────────────────────────────────────────────
2797
4048
  case 'auto_learn_session': {
2798
4049
  const { instance_id, observations } = args;
@@ -2813,13 +4064,7 @@ async function handleTool(name, args) {
2813
4064
  // Only overwrite if this is a success and existing is failure, or topic is new
2814
4065
  const existing = await redis.get(key);
2815
4066
  if (existing) {
2816
- let existingLesson;
2817
- try {
2818
- existingLesson = JSON.parse(existing);
2819
- }
2820
- catch {
2821
- existingLesson = { outcome: 'failure' };
2822
- }
4067
+ const existingLesson = JSON.parse(existing);
2823
4068
  if (existingLesson.outcome === 'success' && obs.outcome !== 'success') {
2824
4069
  skipped.push(topic);
2825
4070
  continue;
@@ -2873,29 +4118,21 @@ async function handleTool(name, args) {
2873
4118
  lStream.on('error', reject);
2874
4119
  });
2875
4120
  const relevant = [];
2876
- if (lessonKeys.length > 0) {
2877
- const values = await redis.mget(...lessonKeys);
2878
- for (const raw of values) {
2879
- if (!raw)
2880
- continue;
2881
- let lesson;
2882
- try {
2883
- lesson = JSON.parse(raw);
2884
- }
2885
- catch {
2886
- continue;
2887
- }
2888
- // Match by file_paths stored in lesson OR by topic keywords matching file name
2889
- const topicWords = lesson.topic.toLowerCase().split(/[:\-_]/);
2890
- const fileMatches = changed_files.some(f => {
2891
- const fname = f.split('/').pop()?.replace(/\.[^.]+$/, '').toLowerCase() ?? '';
2892
- return topicWords.some(w => w.length > 3 && fname.includes(w))
2893
- || (lesson.file_paths ?? []).some(lf => f.includes(lf) || lf.includes(f));
2894
- });
2895
- if (fileMatches) {
2896
- const emoji = lesson.outcome === 'success' ? '✅' : '⚠️';
2897
- relevant.push(` ${emoji} \`${lesson.topic}\` — ${lesson.what_worked.slice(0, 80)}`);
2898
- }
4121
+ for (const k of lessonKeys) {
4122
+ const raw = await redis.get(k);
4123
+ if (!raw)
4124
+ continue;
4125
+ const lesson = JSON.parse(raw);
4126
+ // Match by file_paths stored in lesson OR by topic keywords matching file name
4127
+ const topicWords = lesson.topic.toLowerCase().split(/[:\-_]/);
4128
+ const fileMatches = changed_files.some(f => {
4129
+ const fname = f.split('/').pop()?.replace(/\.[^.]+$/, '').toLowerCase() ?? '';
4130
+ return topicWords.some(w => w.length > 3 && fname.includes(w))
4131
+ || (lesson.file_paths ?? []).some(lf => f.includes(lf) || lf.includes(f));
4132
+ });
4133
+ if (fileMatches) {
4134
+ const emoji = lesson.outcome === 'success' ? '✅' : '⚠️';
4135
+ relevant.push(` ${emoji} \`${lesson.topic}\` — ${lesson.what_worked.slice(0, 80)}`);
2899
4136
  }
2900
4137
  }
2901
4138
  const lines = [
@@ -2931,7 +4168,7 @@ async function handleTool(name, args) {
2931
4168
  file_paths: file_paths ?? [],
2932
4169
  commands: commands ?? [],
2933
4170
  tags: [...(tags ?? []), 'team'],
2934
- ts: new Date().toISOString(),
4171
+ timestamp: new Date().toISOString(),
2935
4172
  recall_count: 0,
2936
4173
  version: 2,
2937
4174
  };
@@ -2955,16 +4192,14 @@ async function handleTool(name, args) {
2955
4192
  lStream.on('error', reject);
2956
4193
  });
2957
4194
  let lessons = [];
2958
- if (lessonKeys.length > 0) {
2959
- const values = await redis.mget(...lessonKeys);
2960
- for (const raw of values) {
2961
- if (!raw)
2962
- continue;
2963
- try {
2964
- lessons.push(JSON.parse(raw));
2965
- }
2966
- catch { /* skip */ }
4195
+ for (const k of lessonKeys) {
4196
+ const raw = await redis.get(k);
4197
+ if (!raw)
4198
+ continue;
4199
+ try {
4200
+ lessons.push(JSON.parse(raw));
2967
4201
  }
4202
+ catch { /* skip */ }
2968
4203
  }
2969
4204
  // Filter
2970
4205
  if (topic) {
@@ -2998,278 +4233,142 @@ async function handleTool(name, args) {
2998
4233
  }
2999
4234
  return lines.join('\n');
3000
4235
  }
3001
- // ── brain_stats ───────────────────────────────────────────────────────────
3002
- case 'brain_stats': {
3003
- const { instance_id } = args;
4236
+ // ── team_synthesize — Team Brain Synthesis ────────────────────────────────
4237
+ case 'team_synthesize': {
4238
+ const { instance_id, topic } = args;
3004
4239
  const redis = await getConnection(instance_id);
3005
- // Scan all lesson keys
3006
- const lessonKeys = [];
3007
- const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
3008
- await new Promise((resolve, reject) => {
3009
- lStream.on('data', (batch) => lessonKeys.push(...batch));
3010
- lStream.on('end', resolve);
3011
- lStream.on('error', reject);
3012
- });
3013
- const lessons = [];
3014
- if (lessonKeys.length > 0) {
3015
- const vals = await redis.mget(...lessonKeys);
3016
- for (const raw of vals) {
3017
- if (!raw)
3018
- continue;
3019
- try {
3020
- lessons.push(JSON.parse(raw));
3021
- }
3022
- catch { /* skip */ }
3023
- }
3024
- }
3025
- // Context count
3026
- let ctxCount = 0;
3027
- const ctxStream = redis.scanStream({ match: 'cachly:ctx:*', count: 200 });
3028
- await new Promise((resolve, reject) => {
3029
- ctxStream.on('data', (batch) => { ctxCount += batch.filter((k) => !k.endsWith(':meta')).length; });
3030
- ctxStream.on('end', resolve);
3031
- ctxStream.on('error', reject);
3032
- });
3033
- const lastSessionRaw = await redis.get('cachly:session:last');
3034
- let lastSession = null;
3035
- if (lastSessionRaw) {
3036
- try {
3037
- lastSession = JSON.parse(lastSessionRaw);
3038
- }
3039
- catch { /* ignore */ }
4240
+ // Load history list for this topic (all authors' contributions)
4241
+ const listKey = `cachly:lessons:${topic}`;
4242
+ const all = await redis.lrange(listKey, 0, -1);
4243
+ if (all.length < 2) {
4244
+ 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}", ...)\`.`;
3040
4245
  }
3041
- const totalRecalls = lessons.reduce((s, l) => s + (l.recall_count ?? 0), 0);
3042
- const savedMin = totalRecalls * 15;
3043
- const savedHours = (savedMin / 60).toFixed(1);
3044
- const successCount = lessons.filter(l => l.outcome === 'success').length;
3045
- const failureCount = lessons.filter(l => l.outcome === 'failure' || l.outcome === 'partial').length;
3046
- const criticalCount = lessons.filter(l => l.severity === 'critical').length;
3047
- const majorCount = lessons.filter(l => l.severity === 'major').length;
3048
- const authors = [...new Set(lessons.map(l => l.author).filter(Boolean))];
3049
- const topByRecall = [...lessons].sort((a, b) => (b.recall_count ?? 0) - (a.recall_count ?? 0)).slice(0, 5);
3050
- const newestLesson = [...lessons].sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime())[0];
3051
- // Age of last session
3052
- let lastSessionStr = 'none yet';
3053
- if (lastSession) {
3054
- const ageMin = Math.round((Date.now() - new Date(lastSession.ts).getTime()) / 60000);
3055
- lastSessionStr = ageMin < 60 ? `${ageMin}m ago`
3056
- : ageMin < 1440 ? `${Math.round(ageMin / 60)}h ago`
3057
- : `${Math.round(ageMin / 1440)}d ago`;
4246
+ const entries = all.map(r => { try {
4247
+ return JSON.parse(r);
3058
4248
  }
3059
- const tokensSaved = (totalRecalls * 1200);
3060
- const tokenStr = tokensSaved >= 1000 ? `~${(tokensSaved / 1000).toFixed(0)}k tokens` : `~${tokensSaved} tokens`;
3061
- const costSaved = (tokensSaved * 0.000003).toFixed(2);
4249
+ catch {
4250
+ return null;
4251
+ } }).filter((e) => e !== null);
4252
+ // Group by outcome
4253
+ const successes = entries.filter(e => e.outcome === 'success');
4254
+ const failures = entries.filter(e => e.outcome === 'failure');
4255
+ const partials = entries.filter(e => e.outcome === 'partial');
4256
+ const authors = [...new Set(entries.map(e => e.author).filter(Boolean))];
4257
+ const hasMultiAuthor = authors.length > 1;
4258
+ // Build canonical merged version
4259
+ // what_worked: pick the most recent success, or longest for most detail
4260
+ const bestSuccess = successes.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime())[0];
4261
+ const whatWorkedCandidates = successes.map(e => e.what_worked).filter(w => w && w.length > 10);
4262
+ const canonicalWorked = whatWorkedCandidates.sort((a, b) => b.length - a.length)[0] ?? bestSuccess?.what_worked ?? '';
4263
+ // what_failed: union of all unique failure reasons
4264
+ const allFailed = [...new Set([...failures, ...partials].map(e => e.what_failed).filter((w) => !!w && w.length > 5))];
4265
+ const severities = entries.map(e => e.severity).filter(Boolean);
4266
+ const canonicalSeverity = severities.includes('critical') ? 'critical' : severities.includes('major') ? 'major' : 'minor';
3062
4267
  const lines = [
3063
- `🧠 **Brain Stats — Full Dashboard**`,
3064
- ``,
3065
- `╔══════════════════════════════════════╗`,
3066
- `║ 📚 Lessons: ${String(lessons.length).padEnd(18)}║`,
3067
- `║ 🔄 Total recalls: ${String(totalRecalls).padEnd(18)}║`,
3068
- `║ 🕐 Time saved: ~${savedHours}h${' '.repeat(Math.max(0, 16 - savedHours.length))}║`,
3069
- `║ 💎 Tokens saved: ${tokenStr.padEnd(18)}║`,
3070
- `║ 💵 Cost saved: ~$${costSaved.padEnd(17)}║`,
3071
- `║ 📁 Context keys: ${String(ctxCount).padEnd(18)}║`,
3072
- `╚══════════════════════════════════════╝`,
3073
- ``,
3074
- `**Lessons breakdown:**`,
3075
- ` ✅ Success: ${successCount} ❌ Failures/partial: ${failureCount}`,
3076
- ` 🔴 Critical: ${criticalCount} 🟡 Major: ${majorCount}`,
3077
- ``,
3078
- `**Last session:** ${lastSessionStr}${lastSession?.summary ? ` — ${lastSession.summary.slice(0, 80)}` : ''}`,
3079
- `**Newest lesson:** ${newestLesson ? `\`${newestLesson.topic}\`` : 'none'}`,
3080
- ``,
3081
- ];
3082
- if (topByRecall.length > 0) {
3083
- lines.push(`**Top recalled lessons (most reused):**`);
3084
- for (const l of topByRecall) {
3085
- const emoji = l.outcome === 'success' ? '✅' : '⚠️';
3086
- lines.push(` ${emoji} \`${l.topic}\` — recalled ${l.recall_count ?? 0}× · ${l.what_worked.slice(0, 60)}`);
3087
- }
3088
- lines.push('');
3089
- }
3090
- if (authors.length > 0) {
3091
- lines.push(`**Team contributors (${authors.length}):** ${authors.join(', ')}`);
3092
- lines.push('');
3093
- }
3094
- lines.push(`💡 Share your brain: \`invite_link()\` — generates a one-command team invite.`);
3095
- lines.push(`📤 Export to Markdown: \`export_brain()\` — portable snapshot you can save or share.`);
3096
- return lines.join('\n');
4268
+ `🧬 **Team Brain Synthesis: \`${topic}\`**`,
4269
+ `_${entries.length} entries from ${authors.length} author${authors.length === 1 ? '' : 's'}${hasMultiAuthor ? ` (${authors.join(', ')})` : ''} · ${successes.length} success · ${failures.length} failure · ${partials.length} partial_`,
4270
+ '',
4271
+ `**Canonical "what worked":**`,
4272
+ `> ${canonicalWorked}`,
4273
+ '',
4274
+ allFailed.length > 0 ? `**Avoid (combined failures):**` : '',
4275
+ ...allFailed.map(f => `> ❌ ${f}`),
4276
+ allFailed.length > 0 ? '' : '',
4277
+ `**Suggested canonical lesson:**`,
4278
+ '```',
4279
+ `learn_from_attempts(`,
4280
+ ` topic = "${topic}",`,
4281
+ ` outcome = "success",`,
4282
+ ` what_worked = "${canonicalWorked.replace(/"/g, "'")}",`,
4283
+ allFailed.length > 0 ? ` what_failed = "${allFailed[0].replace(/"/g, "'")}",` : '',
4284
+ ` severity = "${canonicalSeverity}",`,
4285
+ `)`,
4286
+ '```',
4287
+ '',
4288
+ hasMultiAuthor
4289
+ ? `💡 _${authors.length} team members contributed to this synthesis. Store the canonical version to replace individual entries._`
4290
+ : `💡 _Single author — more value when multiple team members contribute to the same topic._`,
4291
+ ].filter(l => l !== undefined).join('\n');
4292
+ return lines;
3097
4293
  }
3098
- // ── export_brain ──────────────────────────────────────────────────────────
3099
- case 'export_brain': {
3100
- const { instance_id, include_context = true, max_lessons } = args;
4294
+ // ── brain_doctor ──────────────────────────────────────────────────────────
4295
+ // ── memory_crystalize ─────────────────────────────────────────────────────
4296
+ case 'memory_crystalize': {
4297
+ const { instance_id, label: crystalLabel = '' } = args;
3101
4298
  const redis = await getConnection(instance_id);
3102
- // Scan all lessons
3103
- const lessonKeys = [];
3104
- const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
3105
- await new Promise((resolve, reject) => {
3106
- lStream.on('data', (batch) => lessonKeys.push(...batch));
3107
- lStream.on('end', resolve);
3108
- lStream.on('error', reject);
4299
+ const now = new Date();
4300
+ const week = `${now.getFullYear()}-W${String(Math.ceil((now.getDate() - now.getDay() + 10) / 7)).padStart(2, '0')}`;
4301
+ const effectiveLabel = crystalLabel || `${now.toISOString().slice(0, 7)} Crystal`;
4302
+ // Read session history
4303
+ const sessionHistory = await redis.lrange('cachly:session:history', 0, 49);
4304
+ // Read all auto-learned lessons
4305
+ const allLessonKeys = [];
4306
+ const ls = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
4307
+ await new Promise((res, rej) => {
4308
+ ls.on('data', (b) => allLessonKeys.push(...b));
4309
+ ls.on('end', res);
4310
+ ls.on('error', rej);
3109
4311
  });
3110
- let lessons = [];
3111
- if (lessonKeys.length > 0) {
3112
- const vals = await redis.mget(...lessonKeys);
3113
- for (const raw of vals) {
3114
- if (!raw)
3115
- continue;
3116
- try {
3117
- lessons.push(JSON.parse(raw));
3118
- }
3119
- catch { /* skip */ }
3120
- }
3121
- }
3122
- lessons.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
3123
- if (max_lessons && max_lessons > 0)
3124
- lessons = lessons.slice(0, max_lessons);
3125
- // Last session + crystal
3126
- const lastSessionRaw = await redis.get('cachly:session:last');
3127
- let lastSession = null;
3128
- if (lastSessionRaw) {
4312
+ const allLessons = [];
4313
+ for (const k of allLessonKeys) {
4314
+ const raw = await redis.get(k);
4315
+ if (!raw)
4316
+ continue;
3129
4317
  try {
3130
- lastSession = JSON.parse(lastSessionRaw);
4318
+ allLessons.push(JSON.parse(raw));
3131
4319
  }
3132
- catch { /* ignore */ }
4320
+ catch { /* skip */ }
3133
4321
  }
3134
- const crystalRaw = await redis.get('cachly:crystal');
3135
- let crystal = null;
3136
- if (crystalRaw) {
3137
- try {
3138
- crystal = JSON.parse(crystalRaw);
3139
- }
3140
- catch { /* ignore */ }
4322
+ // Group lessons by top-level category
4323
+ const categoryMap = new Map();
4324
+ for (const l of allLessons) {
4325
+ const cat = l.topic.split(':')[0] || 'misc';
4326
+ if (!categoryMap.has(cat))
4327
+ categoryMap.set(cat, []);
4328
+ categoryMap.get(cat).push(l);
3141
4329
  }
3142
- const ctxEntries = [];
3143
- if (include_context) {
3144
- const ctxKeys = [];
3145
- const ctxStream = redis.scanStream({ match: 'cachly:ctx:*', count: 200 });
3146
- await new Promise((resolve, reject) => {
3147
- ctxStream.on('data', (batch) => {
3148
- ctxKeys.push(...batch.filter((k) => !k.endsWith(':meta')));
3149
- });
3150
- ctxStream.on('end', resolve);
3151
- ctxStream.on('error', reject);
4330
+ // Build top patterns (most frequent categories with a representative insight)
4331
+ const topPatterns = [];
4332
+ for (const [cat, catLessons] of [...categoryMap.entries()].sort((a, b) => b[1].length - a[1].length).slice(0, 8)) {
4333
+ const successLessons = catLessons.filter(l => l.outcome === 'success');
4334
+ const best = successLessons[0] ?? catLessons[0];
4335
+ if (!best)
4336
+ continue;
4337
+ topPatterns.push({
4338
+ category: cat,
4339
+ insight: best.what_worked.slice(0, 120),
4340
+ count: catLessons.length,
3152
4341
  });
3153
- if (ctxKeys.length > 0) {
3154
- const ctxVals = await redis.mget(...ctxKeys);
3155
- for (let i = 0; i < ctxKeys.length; i++) {
3156
- const val = ctxVals[i];
3157
- if (val)
3158
- ctxEntries.push({ key: ctxKeys[i].replace('cachly:ctx:', ''), value: val });
3159
- }
3160
- }
3161
4342
  }
3162
- const exportedAt = new Date().toISOString();
3163
- const totalRecalls = lessons.reduce((s, l) => s + (l.recall_count ?? 0), 0);
3164
- const savedHours = (totalRecalls * 15 / 60).toFixed(1);
3165
- const md = [
3166
- `# 🧠 cachly Brain Export`,
3167
- ``,
3168
- `> Exported: ${exportedAt} `,
3169
- `> Instance: \`${instance_id}\` `,
3170
- `> Lessons: ${lessons.length} · Recalls: ${totalRecalls} · Time saved: ~${savedHours}h`,
3171
- ``,
3172
- ];
3173
- if (crystal) {
3174
- md.push(`## 💎 Memory Crystal`, ``, `> ${crystal.summary}`, ``, `_Generated: ${crystal.created_at}_`, ``);
3175
- }
3176
- if (lastSession) {
3177
- md.push(`## 📅 Last Session`, ``, `**${lastSession.ts.slice(0, 10)}** — ${lastSession.summary}`, ``);
3178
- }
3179
- // Group lessons by outcome
3180
- const successLessons = lessons.filter(l => l.outcome === 'success');
3181
- const failureLessons = lessons.filter(l => l.outcome !== 'success');
3182
- const formatLesson = (l) => {
3183
- const sev = l.severity === 'critical' ? ' 🔴' : l.severity === 'major' ? ' 🟡' : '';
3184
- const lines = [
3185
- `### \`${l.topic}\`${sev}`,
3186
- ``,
3187
- `**Outcome:** ${l.outcome} `,
3188
- `**Recalls:** ${l.recall_count ?? 0}× `,
3189
- `**Stored:** ${l.ts.slice(0, 10)}${l.author ? ` \n**Author:** ${l.author}` : ''}`,
3190
- ``,
3191
- `**What worked:** `,
3192
- l.what_worked,
3193
- ];
3194
- if (l.what_failed) {
3195
- lines.push(``, `**What failed:** `, l.what_failed);
3196
- }
3197
- if ((l.commands ?? []).length > 0) {
3198
- lines.push(``, `**Commands:**`);
3199
- for (const cmd of l.commands)
3200
- lines.push(`\`\`\`\n${cmd}\n\`\`\``);
3201
- }
3202
- if ((l.file_paths ?? []).length > 0) {
3203
- lines.push(``, `**Files:** ${l.file_paths.map(f => `\`${f}\``).join(', ')}`);
3204
- }
3205
- if ((l.tags ?? []).length > 0) {
3206
- lines.push(``, `**Tags:** ${l.tags.map(t => `\`${t}\``).join(', ')}`);
3207
- }
3208
- lines.push(``);
3209
- return lines.join('\n');
4343
+ const crystal = {
4344
+ label: effectiveLabel,
4345
+ ts: now.toISOString(),
4346
+ session_count: sessionHistory.length,
4347
+ lesson_count: allLessons.length,
4348
+ top_patterns: topPatterns,
4349
+ categories: [...categoryMap.keys()],
4350
+ created_from: `${sessionHistory.length} sessions, ${allLessons.length} lessons`,
3210
4351
  };
3211
- if (successLessons.length > 0) {
3212
- md.push(`## ✅ Successful Solutions (${successLessons.length})`, ``);
3213
- for (const l of successLessons)
3214
- md.push(formatLesson(l));
3215
- }
3216
- if (failureLessons.length > 0) {
3217
- md.push(`## ⚠️ Open / Partial Issues (${failureLessons.length})`, ``);
3218
- for (const l of failureLessons)
3219
- md.push(formatLesson(l));
3220
- }
3221
- if (ctxEntries.length > 0) {
3222
- md.push(`## 📁 Context Entries (${ctxEntries.length})`, ``);
3223
- for (const e of ctxEntries) {
3224
- md.push(`### \`${e.key}\``, ``, e.value.slice(0, 500) + (e.value.length > 500 ? '\n\n_[truncated]_' : ''), ``);
3225
- }
3226
- }
3227
- md.push(`---`, ``, `_Exported from [cachly AI Brain](https://cachly.dev) · Re-import with \`npx @cachly-dev/mcp-server@latest setup\`_`);
3228
- return md.join('\n');
3229
- }
3230
- // ── invite_link ───────────────────────────────────────────────────────────
3231
- case 'invite_link': {
3232
- const { instance_id, label = '' } = args;
3233
- // Generate a share token via API
3234
- let shareToken = '';
3235
- let inviteUrl = '';
3236
- try {
3237
- const res = await apiFetch(`/api/v1/instances/${instance_id}/invite`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label }) });
3238
- shareToken = res.token ?? '';
3239
- inviteUrl = res.url ?? '';
3240
- }
3241
- catch {
3242
- // API doesn't support invite yet — generate a local share snippet instead
3243
- }
3244
- // Build the join command
3245
- const joinCmd = shareToken
3246
- ? `npx @cachly-dev/mcp-server@latest join ${shareToken}`
3247
- : `npx @cachly-dev/mcp-server@latest setup`;
4352
+ const crystalJson = JSON.stringify(crystal);
4353
+ await redis.set('cachly:crystal:latest', crystalJson);
4354
+ await redis.expire('cachly:crystal:latest', 90 * 86400);
4355
+ await redis.set(`cachly:crystal:${week}`, crystalJson);
4356
+ await redis.expire(`cachly:crystal:${week}`, 365 * 86400);
3248
4357
  const lines = [
3249
- `🔗 **Team Invite — ${label || instance_id.slice(0, 8) + '…'}**`,
4358
+ `💎 **Memory Crystal created: ${effectiveLabel}**`,
3250
4359
  ``,
3251
- `Share this command with your teammate:`,
4360
+ `📊 Compressed: **${sessionHistory.length} sessions** + **${allLessons.length} lessons** → ${topPatterns.length} top patterns`,
3252
4361
  ``,
3253
- `\`\`\`bash`,
3254
- joinCmd,
3255
- `\`\`\``,
3256
- ``,
3257
- `**What happens when they run it:**`,
3258
- ` 1. Browser opens → they sign in (free, 30 seconds)`,
3259
- ` 2. Their Brain is auto-connected to this shared instance`,
3260
- ` 3. All ${label ? `"${label}" ` : ''}lessons and context are instantly available`,
3261
- ` 4. Editor configs written automatically — no copy-paste`,
4362
+ `**Top patterns by category:**`,
4363
+ ...topPatterns.slice(0, 6).map(p => ` • **${p.category}** (${p.count}×): ${p.insight.slice(0, 90)}`),
3262
4364
  ``,
4365
+ `💡 This crystal will appear in every future \`session_start\` briefing.`,
4366
+ `💡 Re-run \`memory_crystalize\` monthly to keep it fresh.`,
3263
4367
  ];
3264
- if (inviteUrl) {
3265
- lines.push(`**Or share the link directly:** ${inviteUrl}`, ``);
3266
- }
3267
- lines.push(`💡 Teammates can also run \`session_start\` immediately to see all your lessons.`, ` Use \`team_learn\` with an \`author\` param so everyone knows who learned what.`);
3268
4368
  return lines.join('\n');
3269
4369
  }
3270
- // ── brain_doctor ──────────────────────────────────────────────────────────
3271
4370
  case 'brain_doctor': {
3272
- const { instance_id } = args;
4371
+ const { instance_id, workspace_path: drWorkspacePath = '' } = args;
3273
4372
  const redis = await getConnection(instance_id);
3274
4373
  const issues = [];
3275
4374
  const checks = [];
@@ -3292,16 +4391,14 @@ async function handleTool(name, args) {
3292
4391
  ctxStream.on('error', reject);
3293
4392
  });
3294
4393
  const lessons = [];
3295
- if (lessonKeys.length > 0) {
3296
- const vals = await redis.mget(...lessonKeys);
3297
- for (const raw of vals) {
3298
- if (!raw)
3299
- continue;
3300
- try {
3301
- lessons.push(JSON.parse(raw));
3302
- }
3303
- catch { /* skip */ }
4394
+ for (const k of lessonKeys) {
4395
+ const raw = await redis.get(k);
4396
+ if (!raw)
4397
+ continue;
4398
+ try {
4399
+ lessons.push(JSON.parse(raw));
3304
4400
  }
4401
+ catch { /* skip */ }
3305
4402
  }
3306
4403
  // Last session
3307
4404
  const lastSessionRaw = await redis.get('cachly:session:last');
@@ -3318,6 +4415,15 @@ async function handleTool(name, args) {
3318
4415
  const unusedLessons = lessons.filter(l => (l.recall_count ?? 0) === 0);
3319
4416
  // Critical lessons
3320
4417
  const criticalLessons = lessons.filter(l => l.severity === 'critical');
4418
+ // Confidence decay analysis
4419
+ const staleLessons = lessons.filter(l => l.outcome === 'success' && calculateConfidence(l) < CONFIDENCE_STALE_VALUE);
4420
+ const warnLessons = lessons.filter(l => l.outcome === 'success' && calculateConfidence(l) >= CONFIDENCE_STALE_VALUE && calculateConfidence(l) < CONFIDENCE_WARN_VALUE);
4421
+ const withAudit = lessons.filter(l => (l.audit_trail ?? []).length > 1);
4422
+ const teamLessons = lessons.filter(l => l.author);
4423
+ const uniqueAuthors = new Set(lessons.map(l => l.author).filter(Boolean));
4424
+ // Effective IQ boost: total recalls / lessons (how much the brain actually helped)
4425
+ const totalRecalls = lessons.reduce((sum, l) => sum + (l.recall_count ?? 0), 0);
4426
+ const iqBoostPct = lessons.length > 0 ? Math.min(100, Math.round((totalRecalls / lessons.length) * 10)) : 0;
3321
4427
  // Quality score (0-100)
3322
4428
  let score = 50;
3323
4429
  if (lessonKeys.length >= 5)
@@ -3335,10 +4441,72 @@ async function handleTool(name, args) {
3335
4441
  const unusedRatio = lessons.length > 0 ? unusedLessons.length / lessons.length : 0;
3336
4442
  if (unusedRatio < 0.5)
3337
4443
  score += 10;
4444
+ if (staleLessons.length === 0)
4445
+ score += 5;
4446
+ if (uniqueAuthors.size >= 2)
4447
+ score += 5; // team collaboration bonus
3338
4448
  const scoreEmoji = score >= 80 ? '🟢' : score >= 50 ? '🟡' : '🔴';
4449
+ const iqEmoji = iqBoostPct >= 50 ? '🚀' : iqBoostPct >= 20 ? '📈' : '💤';
3339
4450
  checks.push(`${scoreEmoji} **Brain Quality Score: ${score}/100**`);
3340
- checks.push(`📚 **Lessons:** ${lessonKeys.length} (${criticalLessons.length} critical)`);
4451
+ checks.push(`${iqEmoji} **Effective IQ Boost: ${iqBoostPct}%** (${totalRecalls} recalls across ${lessons.length} lessons)`);
4452
+ checks.push(`📚 **Lessons:** ${lessonKeys.length} (${criticalLessons.length} critical · ${withAudit.length} with audit trail · ${teamLessons.length} from team)`);
3341
4453
  checks.push(`💾 **Context entries:** ${ctxCount}`);
4454
+ checks.push(`🎯 **Confidence:** ${lessons.length - staleLessons.length - warnLessons.length} fresh · ${warnLessons.length} warn · ${staleLessons.length} stale`);
4455
+ checks.push(`⏱️ **Decay config:** warn after ${CONFIDENCE_WARN_DAYS}d · stale after ${CONFIDENCE_STALE_DAYS}d`);
4456
+ if (uniqueAuthors.size >= 2) {
4457
+ checks.push(`👥 **Team:** ${uniqueAuthors.size} contributors (${[...uniqueAuthors].join(', ')})`);
4458
+ }
4459
+ // Stale index detection
4460
+ try {
4461
+ const lastIndexRaw = await redis.get('cachly:index:last_run');
4462
+ if (lastIndexRaw) {
4463
+ const lastIndexAge = Math.round((Date.now() - new Date(lastIndexRaw).getTime()) / 86_400_000);
4464
+ if (lastIndexAge > 7) {
4465
+ issues.push(`🔄 Index is ${lastIndexAge}d stale — run \`index_project\` to re-sync semantic search`);
4466
+ }
4467
+ else {
4468
+ checks.push(`🗂️ **Semantic index:** ${lastIndexAge}d old (fresh)`);
4469
+ }
4470
+ }
4471
+ else {
4472
+ issues.push(`💡 No semantic index — run \`index_project(dir="<your-src>")\` to enable semantic search`);
4473
+ }
4474
+ }
4475
+ catch { /* non-critical */ }
4476
+ // Memory crystal status
4477
+ try {
4478
+ const crystalRaw = await redis.get('cachly:crystal:latest');
4479
+ if (crystalRaw) {
4480
+ const crystal = JSON.parse(crystalRaw);
4481
+ const crystalAge = Math.round((Date.now() - new Date(crystal.ts).getTime()) / 86_400_000);
4482
+ checks.push(`💎 **Memory Crystal:** ${crystal.label} (${crystalAge}d ago)`);
4483
+ if (crystalAge > 30)
4484
+ issues.push(`💡 Memory Crystal is ${crystalAge}d old — re-run \`memory_crystalize\` to compress new sessions`);
4485
+ }
4486
+ else if (lessonKeys.length >= 10) {
4487
+ issues.push(`💡 ${lessonKeys.length} lessons but no Memory Crystal — run \`memory_crystalize\` to compress wisdom`);
4488
+ }
4489
+ }
4490
+ catch { /* non-critical */ }
4491
+ // openclaw cross-promo (check package.json in workspace)
4492
+ if (drWorkspacePath) {
4493
+ try {
4494
+ const pkgPath = drWorkspacePath.replace(/\/$/, '') + '/package.json';
4495
+ const pkgRaw = readFileSync(pkgPath, 'utf-8');
4496
+ const pkg = JSON.parse(pkgRaw);
4497
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
4498
+ const hasLLMDep = ['openai', '@anthropic-ai/sdk', '@google/generative-ai', 'mistralai', 'cohere-ai'].some(d => d in allDeps);
4499
+ const hasOpenclaw = '@cachly-dev/openclaw' in allDeps;
4500
+ if (hasLLMDep && !hasOpenclaw) {
4501
+ 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\``);
4502
+ issues.push(` → \`npm install @cachly-dev/openclaw\` cuts LLM costs 60–90% with 3 lines of code`);
4503
+ }
4504
+ else if (hasOpenclaw) {
4505
+ checks.push(`✅ **@cachly-dev/openclaw installed** (LLM cost caching active)`);
4506
+ }
4507
+ }
4508
+ catch { /* no package.json or unreadable */ }
4509
+ }
3342
4510
  if (lastSession) {
3343
4511
  const ageMin = Math.round((Date.now() - new Date(lastSession.ts).getTime()) / 60000);
3344
4512
  const ageStr = ageMin < 60 ? `${ageMin}m` : ageMin < 1440 ? `${Math.round(ageMin / 60)}h` : `${Math.round(ageMin / 1440)}d`;
@@ -3353,12 +4521,22 @@ async function handleTool(name, args) {
3353
4521
  else if (lessonKeys.length < 5) {
3354
4522
  issues.push(`💡 Only ${lessonKeys.length} lessons — add more after each problem solved`);
3355
4523
  }
4524
+ if (iqBoostPct === 0 && lessons.length >= 5) {
4525
+ issues.push(`💤 **IQ Boost is 0%** — lessons exist but are never recalled. Use \`recall_best_solution\` BEFORE tasks.`);
4526
+ }
3356
4527
  if (ctxCount === 0) {
3357
4528
  issues.push('💡 No context — use `remember_context` to cache architecture docs, ADRs, etc.');
3358
4529
  }
3359
4530
  if (openFailures.length > 0) {
3360
4531
  issues.push(`⚠️ ${openFailures.length} unresolved failure${openFailures.length > 1 ? 's' : ''}: ${openFailures.slice(0, 3).map(l => `\`${l.topic}\``).join(', ')}`);
3361
4532
  }
4533
+ if (staleLessons.length > 0) {
4534
+ 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(', ')}`);
4535
+ issues.push(` → Re-verify with \`recall_best_solution\` to reset confidence clock`);
4536
+ }
4537
+ if (warnLessons.length > 0) {
4538
+ issues.push(`⚠️ ${warnLessons.length} lesson${warnLessons.length > 1 ? 's' : ''} aging (>${CONFIDENCE_WARN_DAYS}d): ${warnLessons.slice(0, 3).map(l => `\`${l.topic}\``).join(', ')}`);
4539
+ }
3362
4540
  if (unusedRatio > 0.7 && lessons.length > 5) {
3363
4541
  issues.push(`💡 ${unusedLessons.length} lessons never recalled — verify topics match your workflow`);
3364
4542
  }
@@ -3374,6 +4552,86 @@ async function handleTool(name, args) {
3374
4552
  }
3375
4553
  return lines.join('\n');
3376
4554
  }
4555
+ // ── recall_at — Brain Archaeology ────────────────────────────────────────
4556
+ case 'recall_at': {
4557
+ const { instance_id, topic, date } = args;
4558
+ const redis = await getConnection(instance_id);
4559
+ const cutoff = new Date(date).getTime();
4560
+ if (isNaN(cutoff))
4561
+ return `❌ Invalid date "${date}". Use ISO format: "2026-01-15"`;
4562
+ const listKey = `cachly:lessons:${topic}`;
4563
+ const all = await redis.lrange(listKey, 0, -1);
4564
+ if (all.length === 0)
4565
+ return `📭 No history found for \`${topic}\`. Lessons are stored via \`learn_from_attempts\`.`;
4566
+ const before = all
4567
+ .map(raw => { try {
4568
+ return JSON.parse(raw);
4569
+ }
4570
+ catch {
4571
+ return null;
4572
+ } })
4573
+ .filter((l) => l !== null && new Date(l.ts).getTime() <= cutoff)
4574
+ .sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
4575
+ if (before.length === 0)
4576
+ return `📭 No entries for \`${topic}\` found before **${date}**. Earliest entry: ${new Date(JSON.parse(all[0]).ts).toLocaleDateString('de-DE')}.`;
4577
+ const lines = [
4578
+ `🏺 **Brain Archaeology: \`${topic}\` before ${date}**`,
4579
+ `_${before.length} of ${all.length} total entries shown_`,
4580
+ '',
4581
+ ];
4582
+ for (const l of before.slice(-10)) {
4583
+ const emoji = l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
4584
+ const d = new Date(l.ts).toLocaleDateString('de-DE');
4585
+ lines.push(`**${d}** ${emoji} ${l.outcome}`);
4586
+ if (l.what_worked)
4587
+ lines.push(` → ${l.what_worked.slice(0, 100)}`);
4588
+ lines.push('');
4589
+ }
4590
+ lines.push(`_Full evolution: ${all.map(r => { try {
4591
+ const l = JSON.parse(r);
4592
+ return l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
4593
+ }
4594
+ catch {
4595
+ return '?';
4596
+ } }).join(' → ')}_`);
4597
+ return lines.join('\n');
4598
+ }
4599
+ // ── trace_dependency — Causal Chain ──────────────────────────────────────
4600
+ case 'trace_dependency': {
4601
+ const { instance_id, dependency, mark_review = false } = args;
4602
+ const redis = await getConnection(instance_id);
4603
+ const depKey = `cachly:dep:${dependency}`;
4604
+ const raw = await redis.get(depKey);
4605
+ if (!raw)
4606
+ return `📭 No lessons found that depend on \`${dependency}\`.\n\nAdd dependencies via: \`learn_from_attempts(..., depends_on=["${dependency}"])\``;
4607
+ const topics = JSON.parse(raw);
4608
+ const lines = [
4609
+ `🔗 **Causal Chain: \`${dependency}\`** — ${topics.length} dependent lesson${topics.length === 1 ? '' : 's'}`,
4610
+ '',
4611
+ ];
4612
+ for (const t of topics) {
4613
+ const lessonRaw = await redis.get(`cachly:lesson:best:${t}`);
4614
+ if (!lessonRaw) {
4615
+ lines.push(` • \`${t}\` _(lesson deleted)_`);
4616
+ continue;
4617
+ }
4618
+ const lesson = JSON.parse(lessonRaw);
4619
+ const emoji = lesson.outcome === 'success' ? '✅' : lesson.outcome === 'partial' ? '⚠️' : '❌';
4620
+ const reviewBadge = lesson.needs_review ? ' 🔍 **needs_review**' : '';
4621
+ lines.push(` ${emoji} \`${t}\` (${lesson.severity ?? 'major'})${reviewBadge}`);
4622
+ if (mark_review) {
4623
+ const updated = { ...lesson, needs_review: true };
4624
+ await redis.set(`cachly:lesson:best:${t}`, JSON.stringify(updated));
4625
+ }
4626
+ }
4627
+ if (mark_review) {
4628
+ lines.push('', `🔍 All ${topics.length} lessons marked as **needs_review** — verify they still work with the changed dependency.`);
4629
+ }
4630
+ else {
4631
+ lines.push('', `_Run with \`mark_review: true\` to flag all dependent lessons for re-verification._`);
4632
+ }
4633
+ return lines.join('\n');
4634
+ }
3377
4635
  // ── global_learn ──────────────────────────────────────────────────────────
3378
4636
  case 'global_learn': {
3379
4637
  const { instance_id, topic, lesson, severity = 'minor', tags = [] } = args;
@@ -3609,62 +4867,144 @@ async function handleTool(name, args) {
3609
4867
  // Generate copilot-instructions.md content
3610
4868
  const tier = inst.tier.toUpperCase();
3611
4869
  const smartRecallNote = hasVector
3612
- ? '3. Call `smart_recall` with keywords for semantic context search'
3613
- : '3. Call `recall_context` with relevant key patterns like "arch:*" or "file:*"';
4870
+ ? '- `smart_recall("natural language query")` — semantic search (finds by meaning)'
4871
+ : '- `recall_context("arch:*")` — exact/glob key lookup (upgrade to Speed/Business for smart_recall)';
3614
4872
  const layerNote = hasVector
3615
- ? `Layer 3 (Semantic): smart_recall uses pgvector HNSW on your ${tier} instance for meaning-based search`
3616
- : `Layer 3 (Autopilot): This file — upgrade to Speed/Business tier to unlock smart_recall`;
3617
- const copilotInstructions = `# cachly AI Memory — ${project_description}
4873
+ ? `Layer 3 (Semantic): smart_recall uses pgvector HNSW on your ${tier} instance`
4874
+ : `Layer 3 (Autopilot): this file — upgrade to Speed/Business tier to unlock smart_recall`;
4875
+ const copilotInstructions = `# cachly AI Brain — ${project_description}
4876
+
4877
+ > 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.
4878
+
4879
+ ---
4880
+
4881
+ ## 🔴 MANDATORY RULES — Always enforced, no exceptions
4882
+
4883
+ ### Rule 1 — Session Start (FIRST thing in every window)
4884
+ Call \`session_start\` **before reading any source files or making any changes**:
4885
+
4886
+ \`\`\`
4887
+ session_start(
4888
+ instance_id = "${instance_id}",
4889
+ focus = "<what you plan to work on today>",
4890
+ )
4891
+ \`\`\`
4892
+
4893
+ Returns: last session summary, handoff from previous window, recent lessons, open failures, brain health.
4894
+
4895
+ **If \`session_start\` is unavailable**, fall back:
4896
+ 1. \`smart_recall("recent lessons <topic>")\`
4897
+ 2. \`recall_best_solution("<topic>")\`
4898
+
4899
+ ### Rule 2 — Learn After Every Fix/Deploy/Discovery (AUTOMATIC — no prompting needed)
4900
+ After **every** bug fix, deployment, failed attempt, or discovery — call \`learn_from_attempts\` immediately:
4901
+
4902
+ \`\`\`
4903
+ learn_from_attempts(
4904
+ instance_id = "${instance_id}",
4905
+ topic = "category:keyword", # deploy:web | fix:auth | debug:db | infra:docker
4906
+ outcome = "success" | "failure" | "partial",
4907
+ what_worked = "exact approach or command that worked",
4908
+ what_failed = "what did NOT work (prevents future wasted time)",
4909
+ severity = "critical" | "major" | "minor",
4910
+ commands = ["exact command that worked"],
4911
+ file_paths = ["path/to/changed/file"],
4912
+ )
4913
+ \`\`\`
4914
+
4915
+ **These events MUST automatically trigger \`learn_from_attempts\`:**
4916
+ - ✅ Successfully deployed → \`topic="deploy:<component>", outcome="success"\`
4917
+ - ✅ Fixed a bug → \`topic="fix:<description>", outcome="success"\`
4918
+ - ✅ Discovered root cause → \`topic="debug:<area>", outcome="partial"\`
4919
+ - ✅ Tried something that failed → \`topic="<area>", outcome="failure", what_failed="..."\`
4920
+ - ✅ Found a config quirk → \`topic="infra:<topic>", outcome="success"\`
4921
+ - ✅ Ran a working command → store it in \`commands=[...]\`
4922
+
4923
+ ### Rule 3 — Session Handoff (when closing window or task list > 3 items)
4924
+ \`\`\`
4925
+ session_handoff(
4926
+ instance_id = "${instance_id}",
4927
+ completed_tasks = ["what was done"],
4928
+ remaining_tasks = ["what is still to do"],
4929
+ files_changed = [{"path": "src/...", "status": "modified", "description": "what changed"}],
4930
+ instructions = "IMPORTANT: what the next window MUST know to not break things",
4931
+ context_summary = "current state of the system",
4932
+ )
4933
+ \`\`\`
3618
4934
 
3619
- ## 3-Layer AI Memory System
4935
+ The next \`session_start\` automatically includes this handoff. The new window sees exactly what's done, what remains, and what NOT to break.
4936
+
4937
+ ### Rule 4 — Session End (LAST thing before closing)
4938
+ \`\`\`
4939
+ session_end(
4940
+ instance_id = "${instance_id}",
4941
+ summary = "What was accomplished (2-3 sentences with specifics)",
4942
+ files_changed = ["list", "of", "changed", "files"],
4943
+ lessons_learned = <number of learn_from_attempts calls made>,
4944
+ )
4945
+ \`\`\`
3620
4946
 
3621
- - **Layer 1 — Storage:** cachly instance \`${inst.name}\` (${tier}) — persistent Valkey brain, survives restarts
3622
- - **Layer 2 — Tools:** learn_from_attempts · recall_best_solution · smart_recall — the memory API
3623
- - **Layer 3 — Autopilot (this file):** instructs the AI to run memory tools automatically
4947
+ > \`session_end\` **automatically extracts and stores lessons** from the summary text — no manual calls needed.
3624
4948
 
3625
- ## BEFORE every task — check memory first
4949
+ ### Rule 5 — Code Index Invalidation (AUTOMATIC)
4950
+ - \`index_project\` tracks MD5 hashes per file — **skips unchanged files automatically**
4951
+ - After code changes: next \`index_project\` run re-indexes only modified files
4952
+ - After delete/rename: run \`index_project\` again to sync
4953
+ - No manual invalidation needed
3626
4954
 
3627
- 1. Call \`recall_best_solution\` with the task topic to get known solutions
3628
- - \`recall_best_solution("deploy:web")\` → returns working deploy command
3629
- - \`recall_best_solution("debug:redis-tls")\` → returns root cause + fix
3630
- - If a solution exists, use it — do NOT re-discover from scratch
4955
+ ---
3631
4956
 
3632
- 2. Call \`recall_context\` with the area you're working in
3633
- - \`recall_context("arch:*")\` → cached architecture decisions
3634
- - \`recall_context("file:src/auth*")\` → cached file summaries
4957
+ ## Task-type trigger table
3635
4958
 
3636
- ${smartRecallNote}
3637
- - \`smart_recall("how does authentication work")\` → returns cached auth notes
4959
+ | You're about to... | Call BEFORE | Call AFTER |
4960
+ |---|---|---|
4961
+ | Deploy anything | \`recall_best_solution("deploy:<component>")\` | \`learn_from_attempts(topic="deploy:...")\` |
4962
+ | Fix a bug | \`recall_best_solution("fix:<area>")\` | \`learn_from_attempts(topic="fix:...")\` |
4963
+ | Add a feature | \`session_start(focus="feat:<area>")\` | \`learn_from_attempts(topic="feat:...")\` |
4964
+ | Infra/server work | \`recall_best_solution("infra:<topic>")\` | \`learn_from_attempts(topic="infra:...")\` |
4965
+ | Debug an issue | \`smart_recall("<error message or symptom>")\` | \`learn_from_attempts(topic="debug:...")\` |
3638
4966
 
3639
- 4. If nothing is found, proceed normally — then save (see below)
4967
+ ---
3640
4968
 
3641
- ## AFTER every non-trivial task — save to memory
4969
+ ## Available Brain Tools
3642
4970
 
3643
- 1. Call \`learn_from_attempts\` for deploy/debug/fix tasks:
3644
- - Always set outcome = "success" | "failure" | "partial"
3645
- - Always include what_failed — it prevents future wasted time
3646
- - 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")\`
4971
+ | Tool | When to use |
4972
+ |------|-------------|
4973
+ | \`session_start\` | **FIRST** — mandatory at start of every session |
4974
+ | \`session_end\` | **LAST** — mandatory at end, auto-learns from summary |
4975
+ | \`session_handoff\` | When closing window with remaining tasks |
4976
+ | \`learn_from_attempts\` | **AUTOMATIC** after every fix/deploy/discovery |
4977
+ | \`recall_best_solution\` | Before any non-trivial task |
4978
+ | \`remember_context\` | After analyzing code — save findings for future sessions |
4979
+ ${smartRecallNote ? `| \`smart_recall\` | Search brain by meaning/keywords |\n` : ''}\
4980
+ | \`recall_context\` | Get exact key (supports glob: \`arch:*\`, \`file:*\`) |
4981
+ | \`brain_search\` | BM25+ full-text search over all brain data |
4982
+ | \`auto_learn_session\` | Batch-learn from a list of observations (optional) |
4983
+ | \`index_project\` | Index source files (smart hash, skips unchanged files) |
4984
+ | \`list_remembered\` | See what's cached in the brain |
4985
+ | \`forget_context\` | Remove stale context |
3647
4986
 
3648
- 2. Call \`remember_context\` for code/architecture findings:
3649
- - \`remember_context("arch:auth", "Uses Keycloak with JWT...", category="architecture")\`
3650
- - \`remember_context("file:src/index.ts", "Entry point, registers routes...", category="file_summary")\`
4987
+ ---
3651
4988
 
3652
4989
  ## Instance Details
3653
4990
 
3654
- - Instance ID: \`${instance_id}\`
3655
- - Instance name: ${inst.name}
3656
- - Tier: ${tier}
3657
- - ${layerNote}
3658
- - Embedding provider: ${providerArg}
4991
+ - **Instance ID:** \`${instance_id}\`
4992
+ - **Instance name:** ${inst.name}
4993
+ - **Tier:** ${tier}
4994
+ - **${layerNote}**
4995
+ - **Embedding provider:** ${providerArg}
3659
4996
 
3660
- ## Quick reference
4997
+ ---
4998
+
4999
+ ## How the 3-layer system works
3661
5000
 
3662
5001
  \`\`\`
3663
- recall_best_solution("topic") # before task
3664
- smart_recall("natural language") # find by meaning
3665
- learn_from_attempts(...) # after task
3666
- remember_context("key", "content") # save analysis
5002
+ Layer 1 — Storage: Your cachly Valkey instance (${inst.name}) — persists forever
5003
+ Layer 2 — Tools: learn_from_attempts · recall_best_solution · brain_search · session_start/end
5004
+ Layer 3 — Autopilot: This file — AI reads it and runs tools automatically every session
3667
5005
  \`\`\`
5006
+
5007
+ Result: Your AI **never solves the same problem twice** and always picks up exactly where it left off. 🚀
3668
5008
  `;
3669
5009
  const lines = [
3670
5010
  `🧠 **cachly AI Memory Setup Complete**`,
@@ -3689,8 +5029,8 @@ remember_context("key", "content") # save analysis
3689
5029
  `**Step 3 — copilot-instructions.md (Layer 3 Autopilot):**`,
3690
5030
  ``,
3691
5031
  ...(project_dir
3692
- ? [`Writing to \`${project_dir}/.github/copilot-instructions.md\`…`]
3693
- : [`Copy this to \`.github/copilot-instructions.md\` in your project:`]),
5032
+ ? [`🔍 Detecting IDEs in \`${project_dir}\`…`]
5033
+ : [`Copy this to \`.github/copilot-instructions.md\` (Copilot), \`CLAUDE.md\` (Claude Code), or \`.cursor/rules\` (Cursor) in your project:`]),
3694
5034
  ``,
3695
5035
  `\`\`\`markdown`,
3696
5036
  copilotInstructions,
@@ -3704,14 +5044,88 @@ remember_context("key", "content") # save analysis
3704
5044
  ``,
3705
5045
  `Result: Your AI never solves the same problem twice. 🚀`,
3706
5046
  ];
3707
- // Optionally write the file
5047
+ // ── IDE auto-detection + file writing ────────────────────────────────
3708
5048
  if (project_dir) {
3709
- const { mkdir, writeFile } = await import('node:fs/promises');
3710
- const githubDir = join(project_dir, '.github');
3711
- await mkdir(githubDir, { recursive: true });
3712
- const filePath = join(githubDir, 'copilot-instructions.md');
3713
- await writeFile(filePath, copilotInstructions, 'utf-8');
3714
- lines.splice(lines.indexOf(`Writing to \`${project_dir}/.github/copilot-instructions.md\`…`) + 1, 0, `✅ Written to \`${project_dir}/.github/copilot-instructions.md\``);
5049
+ const { mkdir, writeFile, access } = await import('node:fs/promises');
5050
+ const { constants } = await import('node:fs');
5051
+ const exists = async (p) => access(p, constants.F_OK).then(() => true).catch(() => false);
5052
+ const targets = [];
5053
+ let stopHookWritten = false;
5054
+ // GitHub Copilot — always write (universal fallback)
5055
+ targets.push({
5056
+ ide: 'GitHub Copilot',
5057
+ path: join(project_dir, '.github', 'copilot-instructions.md'),
5058
+ content: copilotInstructions,
5059
+ });
5060
+ // Claude Code — CLAUDE.md in project root
5061
+ if (await exists(join(project_dir, 'CLAUDE.md')) || await exists(join(project_dir, '.claude'))) {
5062
+ targets.push({
5063
+ ide: 'Claude Code',
5064
+ path: join(project_dir, 'CLAUDE.md'),
5065
+ content: copilotInstructions,
5066
+ });
5067
+ // Claude Code Stop-Hook — auto-saves checkpoint when Claude stops responding
5068
+ const claudeDir = join(project_dir, '.claude');
5069
+ await mkdir(claudeDir, { recursive: true });
5070
+ const stopHook = {
5071
+ hooks: {
5072
+ Stop: [
5073
+ {
5074
+ matcher: '',
5075
+ hooks: [
5076
+ {
5077
+ type: 'command',
5078
+ command: `npx --yes @cachly-dev/mcp-server@latest checkpoint --instance-id ${instance_id}`,
5079
+ },
5080
+ ],
5081
+ },
5082
+ ],
5083
+ },
5084
+ };
5085
+ const settingsPath = join(claudeDir, 'settings.json');
5086
+ let existingSettings = {};
5087
+ try {
5088
+ const { readFile: rf } = await import('node:fs/promises');
5089
+ existingSettings = JSON.parse(await rf(settingsPath, 'utf-8'));
5090
+ }
5091
+ catch { /* new file */ }
5092
+ const merged = { ...existingSettings, hooks: stopHook.hooks };
5093
+ await writeFile(settingsPath, JSON.stringify(merged, null, 2), 'utf-8');
5094
+ stopHookWritten = true;
5095
+ }
5096
+ // Cursor — .cursor/rules (new format) or .cursorrules (legacy)
5097
+ if (await exists(join(project_dir, '.cursor')) ||
5098
+ await exists(join(project_dir, '.cursorrules'))) {
5099
+ const cursorDir = join(project_dir, '.cursor');
5100
+ await mkdir(cursorDir, { recursive: true });
5101
+ targets.push({
5102
+ ide: 'Cursor',
5103
+ path: join(cursorDir, 'rules'),
5104
+ content: copilotInstructions,
5105
+ });
5106
+ }
5107
+ // Windsurf — .windsurfrules
5108
+ if (await exists(join(project_dir, '.windsurfrules')) ||
5109
+ await exists(join(project_dir, '.windsurf'))) {
5110
+ targets.push({
5111
+ ide: 'Windsurf',
5112
+ path: join(project_dir, '.windsurfrules'),
5113
+ content: copilotInstructions,
5114
+ });
5115
+ }
5116
+ // VS Code (Copilot) — already covered by .github/copilot-instructions.md above
5117
+ // Continue.dev — .continue/config.json is JSON, not markdown — skip, copilot-instructions handles it
5118
+ const written = [];
5119
+ for (const target of targets) {
5120
+ const dir = target.path.substring(0, target.path.lastIndexOf('/'));
5121
+ await mkdir(dir, { recursive: true });
5122
+ await writeFile(target.path, target.content, 'utf-8');
5123
+ written.push(`✅ [${target.ide}] → \`${target.path.replace(project_dir, '.')}\``);
5124
+ }
5125
+ if (stopHookWritten) {
5126
+ written.push(`✅ [Claude Code Stop-Hook] → \`.claude/settings.json\` (auto-checkpoint on stop)`);
5127
+ }
5128
+ lines.push(...written);
3715
5129
  }
3716
5130
  return lines.join('\n');
3717
5131
  }
@@ -3835,15 +5249,236 @@ async function handleBulkLockStream(name, args) {
3835
5249
  return (`✅ **cache_stream_get** – ${len} chunk(s) retrieved for \`${key}\`.\n\n` +
3836
5250
  `**Preview** (first 500 chars):\n\`\`\`\n${preview}${preview.length < chunks.join('').length ? '…' : ''}\n\`\`\``);
3837
5251
  }
5252
+ // ── Roadmap ──────────────────────────────────────────────────────────────
5253
+ case 'roadmap_add': {
5254
+ const { instance_id: rid, title, description: desc = '', priority = 'medium', tags: rtags = [], milestone = '', } = args;
5255
+ const redis = await getConnection(rid);
5256
+ const id = `rm_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
5257
+ const item = {
5258
+ id, title, description: desc, priority, tags: rtags, milestone,
5259
+ status: 'planned',
5260
+ created: new Date().toISOString(),
5261
+ updated: new Date().toISOString(),
5262
+ notes: '',
5263
+ };
5264
+ await redis.hset(`cachly:roadmap:${rid}`, id, JSON.stringify(item));
5265
+ const PRIORITY_ICON = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
5266
+ return [
5267
+ `📋 **Roadmap item added**`,
5268
+ ``,
5269
+ ` ID: \`${id}\``,
5270
+ ` Title: ${title}`,
5271
+ ` Priority: ${PRIORITY_ICON[priority] ?? '⚪'} ${priority}`,
5272
+ ` Status: planned`,
5273
+ milestone ? ` Milestone: ${milestone}` : '',
5274
+ rtags.length ? ` Tags: ${rtags.join(', ')}` : '',
5275
+ ``,
5276
+ `💡 Use \`roadmap_update(id: "${id}", status: "in-progress")\` when you start working on it.`,
5277
+ ].filter(Boolean).join('\n');
5278
+ }
5279
+ case 'roadmap_update': {
5280
+ const { instance_id: rid, id: itemId, status: newStatus, priority: newPriority, notes: newNotes, title: newTitle, description: newDesc, } = args;
5281
+ const redis = await getConnection(rid);
5282
+ const raw = await redis.hget(`cachly:roadmap:${rid}`, itemId);
5283
+ if (!raw)
5284
+ return `⚠️ **roadmap_update** – Item \`${itemId}\` not found. Use \`roadmap_list\` to see all items.`;
5285
+ const item = JSON.parse(raw);
5286
+ const oldStatus = item.status;
5287
+ if (newStatus)
5288
+ item.status = newStatus;
5289
+ if (newPriority)
5290
+ item.priority = newPriority;
5291
+ if (newTitle)
5292
+ item.title = newTitle;
5293
+ if (newDesc)
5294
+ item.description = newDesc;
5295
+ if (newNotes)
5296
+ item.notes = item.notes ? `${item.notes}\n[${new Date().toISOString().slice(0, 10)}] ${newNotes}` : `[${new Date().toISOString().slice(0, 10)}] ${newNotes}`;
5297
+ item.updated = new Date().toISOString();
5298
+ await redis.hset(`cachly:roadmap:${rid}`, itemId, JSON.stringify(item));
5299
+ const statusEmoji = { planned: '📋', 'in-progress': '⚡', done: '✅', blocked: '🚫', cancelled: '🗑️' };
5300
+ return [
5301
+ `${statusEmoji[newStatus ?? oldStatus] ?? '📋'} **Roadmap updated** \`${itemId}\``,
5302
+ ``,
5303
+ ` Title: ${item.title}`,
5304
+ oldStatus !== newStatus ? ` Status: ${oldStatus} → ${newStatus}` : ` Status: ${item.status}`,
5305
+ newNotes ? ` Notes: ${newNotes}` : '',
5306
+ ].filter(Boolean).join('\n');
5307
+ }
5308
+ case 'roadmap_list': {
5309
+ const { instance_id: rid, status: filterStatus = 'open', tag: filterTag, milestone: filterMilestone, priority: filterPriority, } = args;
5310
+ const redis = await getConnection(rid);
5311
+ const all = await redis.hgetall(`cachly:roadmap:${rid}`);
5312
+ if (!all || Object.keys(all).length === 0) {
5313
+ return '📋 **Roadmap is empty.**\n\nUse `roadmap_add` to create your first item.';
5314
+ }
5315
+ const PRIORITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
5316
+ const PRIORITY_ICON = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
5317
+ const STATUS_ICON = { planned: '📋', 'in-progress': '⚡', done: '✅', blocked: '🚫', cancelled: '🗑️' };
5318
+ const openStatuses = new Set(['planned', 'in-progress', 'blocked']);
5319
+ let items = Object.values(all).map(v => JSON.parse(v));
5320
+ // Filter
5321
+ if (filterStatus === 'open')
5322
+ items = items.filter(i => openStatuses.has(i.status));
5323
+ else if (filterStatus)
5324
+ items = items.filter(i => i.status === filterStatus);
5325
+ if (filterTag)
5326
+ items = items.filter(i => i.tags.includes(filterTag));
5327
+ if (filterMilestone)
5328
+ items = items.filter(i => i.milestone === filterMilestone);
5329
+ if (filterPriority)
5330
+ items = items.filter(i => (PRIORITY_ORDER[i.priority] ?? 99) <= (PRIORITY_ORDER[filterPriority] ?? 99));
5331
+ // Sort: priority asc, then created asc
5332
+ items.sort((a, b) => {
5333
+ const pa = PRIORITY_ORDER[a.priority] ?? 99;
5334
+ const pb = PRIORITY_ORDER[b.priority] ?? 99;
5335
+ return pa !== pb ? pa - pb : a.created.localeCompare(b.created);
5336
+ });
5337
+ if (items.length === 0)
5338
+ return `📋 **No roadmap items** match the current filter (status: ${filterStatus}).`;
5339
+ const grouped = {};
5340
+ for (const it of items) {
5341
+ const st = it.status;
5342
+ if (!grouped[st])
5343
+ grouped[st] = [];
5344
+ grouped[st].push(it);
5345
+ }
5346
+ const lines = [`📋 **Roadmap** (${items.length} item${items.length !== 1 ? 's' : ''})`, ''];
5347
+ for (const [st, grp] of Object.entries(grouped)) {
5348
+ lines.push(`**${STATUS_ICON[st] ?? '•'} ${st.toUpperCase()}** (${grp.length})`);
5349
+ for (const it of grp) {
5350
+ const tags = it.tags.length ? ` [${it.tags.join(', ')}]` : '';
5351
+ const milestone = it.milestone ? ` · ${it.milestone}` : '';
5352
+ lines.push(` ${PRIORITY_ICON[it.priority] ?? '⚪'} \`${it.id}\` **${it.title}**${tags}${milestone}`);
5353
+ if (it.description)
5354
+ lines.push(` ${it.description.slice(0, 120)}`);
5355
+ if (it.notes)
5356
+ lines.push(` 📝 ${it.notes.split('\n').pop()?.slice(0, 100)}`);
5357
+ }
5358
+ lines.push('');
5359
+ }
5360
+ lines.push(`💡 \`roadmap_update(id, status: "in-progress")\` to start · \`roadmap_next\` for top priority item`);
5361
+ return lines.join('\n');
5362
+ }
5363
+ case 'roadmap_next': {
5364
+ const { instance_id: rid, tag: filterTag } = args;
5365
+ const redis = await getConnection(rid);
5366
+ const all = await redis.hgetall(`cachly:roadmap:${rid}`);
5367
+ if (!all || Object.keys(all).length === 0) {
5368
+ return '📋 **Roadmap is empty.** Use `roadmap_add` to plan your first task.';
5369
+ }
5370
+ const PRIORITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
5371
+ const PRIORITY_ICON = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
5372
+ let items = Object.values(all)
5373
+ .map(v => JSON.parse(v))
5374
+ .filter(i => i.status === 'in-progress' || i.status === 'planned')
5375
+ .filter(i => !filterTag || i.tags.includes(filterTag));
5376
+ if (items.length === 0)
5377
+ return '🎉 **No open roadmap items!** All tasks are done (or use `roadmap_list` to check).';
5378
+ // in-progress first, then by priority
5379
+ items.sort((a, b) => {
5380
+ if (a.status === 'in-progress' && b.status !== 'in-progress')
5381
+ return -1;
5382
+ if (b.status === 'in-progress' && a.status !== 'in-progress')
5383
+ return 1;
5384
+ return (PRIORITY_ORDER[a.priority] ?? 99) - (PRIORITY_ORDER[b.priority] ?? 99);
5385
+ });
5386
+ const next = items[0];
5387
+ const remaining = items.length - 1;
5388
+ const tags = next.tags.length ? `\nTags: ${next.tags.join(', ')}` : '';
5389
+ const milestone = next.milestone ? `\nMilestone: ${next.milestone}` : '';
5390
+ const notes = next.notes ? `\nNotes: ${next.notes.split('\n').pop()?.slice(0, 120)}` : '';
5391
+ return [
5392
+ `${next.status === 'in-progress' ? '⚡' : '📋'} **Next up: ${next.title}**`,
5393
+ ``,
5394
+ `ID: \`${next.id}\``,
5395
+ `Priority: ${PRIORITY_ICON[next.priority] ?? '⚪'} ${next.priority}`,
5396
+ `Status: ${next.status}`,
5397
+ next.description ? `\nWhat to do:\n${next.description}` : '',
5398
+ tags, milestone, notes,
5399
+ ``,
5400
+ remaining > 0 ? `(+${remaining} more open item${remaining !== 1 ? 's' : ''} in roadmap)` : '(last open item)',
5401
+ ``,
5402
+ next.status === 'planned'
5403
+ ? `💡 Start with: \`roadmap_update(id: "${next.id}", status: "in-progress")\``
5404
+ : `💡 Finish with: \`roadmap_update(id: "${next.id}", status: "done", notes: "...")\``,
5405
+ ].filter(s => s !== undefined).join('\n');
5406
+ }
3838
5407
  default:
3839
5408
  return null;
3840
5409
  }
3841
5410
  }
3842
5411
  // ── Server setup ──────────────────────────────────────────────────────────────
3843
- const server = new Server({ name: 'cachly-mcp', version: '0.4.0' }, { capabilities: { tools: {} } });
5412
+ const server = new Server({ name: 'cachly-mcp', version: CURRENT_VERSION }, { capabilities: { tools: {} } });
3844
5413
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
5414
+ // ── Auto-session management ───────────────────────────────────────────────────
5415
+ // Transparently starts a Brain session on the first tool call of a process
5416
+ // and saves a session_end summary on SIGTERM/exit so users never have to call
5417
+ // session_start/session_end manually.
5418
+ let _autoSessionInstanceId = null;
5419
+ let _autoSessionStarted = false;
5420
+ let _autoSessionToolCount = 0;
5421
+ async function autoStartSession(instanceId) {
5422
+ if (_autoSessionStarted)
5423
+ return;
5424
+ _autoSessionStarted = true;
5425
+ _autoSessionInstanceId = instanceId;
5426
+ try {
5427
+ await handleTool('session_start', { instance_id: instanceId, focus: 'auto (MCP session)' });
5428
+ }
5429
+ catch { /* non-fatal — session tracking is a best-effort feature */ }
5430
+ // Auto-index the project if it hasn't been indexed in the last 24h.
5431
+ // This is the main lever for growing token savings: more indexed code →
5432
+ // more semantic cache hits → fewer LLM calls for repeated questions.
5433
+ if (process.env.CACHLY_AUTO_INDEX !== 'false') {
5434
+ try {
5435
+ const redis = await getConnection(instanceId);
5436
+ const lastIndexed = await redis.get(`cachly:index:last_indexed:${instanceId}`);
5437
+ const staleMs = 24 * 60 * 60 * 1000;
5438
+ const isStale = !lastIndexed || (Date.now() - parseInt(lastIndexed, 10)) > staleMs;
5439
+ if (isStale) {
5440
+ // Mark as indexing now to prevent concurrent re-runs.
5441
+ await redis.set(`cachly:index:last_indexed:${instanceId}`, String(Date.now()), 'EX', 90000);
5442
+ // Run in background — don't block the first tool call.
5443
+ handleTool('index_project', {
5444
+ instance_id: instanceId,
5445
+ dir: process.cwd(),
5446
+ max_files: 150,
5447
+ ttl: 86400 * 7, // cache for 7 days
5448
+ namespace: 'cachly:sem:code',
5449
+ }).catch(() => undefined);
5450
+ }
5451
+ }
5452
+ catch { /* never block the session on indexing errors */ }
5453
+ }
5454
+ }
5455
+ async function autoEndSession() {
5456
+ if (!_autoSessionStarted || !_autoSessionInstanceId)
5457
+ return;
5458
+ _autoSessionStarted = false;
5459
+ try {
5460
+ await handleTool('session_end', {
5461
+ instance_id: _autoSessionInstanceId,
5462
+ summary: `Auto-ended after ${_autoSessionToolCount} tool call(s). Session was started automatically.`,
5463
+ files_changed: [],
5464
+ lessons_learned: 0,
5465
+ });
5466
+ }
5467
+ catch { /* non-fatal */ }
5468
+ }
3845
5469
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
3846
5470
  const { name, arguments: args } = request.params;
5471
+ // Auto-start brain session on first tool call that has an instance_id.
5472
+ // Skip session management tools to avoid recursion.
5473
+ const sessionTools = new Set(['session_start', 'session_end', 'auto_learn_session']);
5474
+ if (!sessionTools.has(name) && !_autoSessionStarted) {
5475
+ const instanceId = args?.instance_id;
5476
+ if (instanceId) {
5477
+ await autoStartSession(instanceId).catch(() => undefined);
5478
+ }
5479
+ }
5480
+ if (!sessionTools.has(name))
5481
+ _autoSessionToolCount++;
3847
5482
  try {
3848
5483
  const text = await handleTool(name, (args ?? {}));
3849
5484
  return { content: [{ type: 'text', text }] };
@@ -3854,8 +5489,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3854
5489
  throw new McpError(ErrorCode.InternalError, err.message);
3855
5490
  }
3856
5491
  });
3857
- // Graceful shutdown – close all Redis connections
5492
+ // Graceful shutdown – close all Redis connections + auto-end brain session
3858
5493
  process.on('SIGTERM', async () => {
5494
+ await autoEndSession().catch(() => undefined);
5495
+ for (const [, client] of pool)
5496
+ await client.quit().catch(() => undefined);
5497
+ process.exit(0);
5498
+ });
5499
+ process.on('SIGINT', async () => {
5500
+ await autoEndSession().catch(() => undefined);
3859
5501
  for (const [, client] of pool)
3860
5502
  await client.quit().catch(() => undefined);
3861
5503
  process.exit(0);
@@ -3874,6 +5516,8 @@ const EDITOR_FILES = {
3874
5516
  windsurf: '.windsurf/mcp.json',
3875
5517
  copilot: '.vscode/mcp.json',
3876
5518
  continue: '.continue/config.json',
5519
+ cline: '.vscode/mcp.json', // Cline respects VS Code project MCP config
5520
+ zed: '.zed/settings.json', // Zed project-level context_servers
3877
5521
  };
3878
5522
  const CLAUDE_MD_MARKER_START = '<!-- cachly-brain-start -->';
3879
5523
  const CLAUDE_MD_MARKER_END = '<!-- cachly-brain-end -->';
@@ -3888,6 +5532,20 @@ function buildMcpConfig(apiKey, instanceId, editor) {
3888
5532
  },
3889
5533
  }, null, 2);
3890
5534
  }
5535
+ if (editor === 'zed') {
5536
+ return JSON.stringify({
5537
+ context_servers: {
5538
+ cachly: {
5539
+ command: {
5540
+ path: 'npx',
5541
+ args: ['-y', '@cachly-dev/mcp-server@latest'],
5542
+ env: { CACHLY_API_URL: 'https://api.cachly.dev', CACHLY_JWT: apiKey, CACHLY_BRAIN_INSTANCE_ID: instanceId },
5543
+ },
5544
+ settings: {},
5545
+ },
5546
+ },
5547
+ }, null, 2);
5548
+ }
3891
5549
  return JSON.stringify({
3892
5550
  mcpServers: {
3893
5551
  cachly: {
@@ -3900,48 +5558,54 @@ function buildMcpConfig(apiKey, instanceId, editor) {
3900
5558
  }
3901
5559
  function buildClaudeMdBlock(instanceId) {
3902
5560
  return `${CLAUDE_MD_MARKER_START}
3903
- ## Cachly AI Brain — Persistent Memory
5561
+ ## Cachly AI Brain
3904
5562
 
3905
- Your AI Brain instance ID: \`${instanceId}\`
5563
+ The Cachly MCP server is available as tool \`cachly\`.
5564
+ Your instance ID: \`${instanceId}\`
3906
5565
 
3907
- > The Brain gives you persistent memory across sessions. Every lesson you learn,
3908
- > every bug you fix, every architecture decision — recalled instantly next time.
3909
- > instance_id is pre-filled below. Do NOT ask the user for it.
5566
+ > **Sessions are fully automatic** (MCP ≥ 0.5.3).
5567
+ > 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.
3910
5568
 
3911
- ### MANDATORY: Call at the START of every session
3912
- \`\`\`
3913
- session_start(instance_id="${instanceId}", focus="<what you're working on today>")
3914
- \`\`\`
3915
- This loads your previous context, recent lessons, and open tasks automatically.
5569
+ ---
3916
5570
 
3917
- ### MANDATORY: Call at the END of every session
5571
+ ### Before editing any file — WIP-registry (prevents half-finished code across sessions):
3918
5572
  \`\`\`
3919
- session_end(instance_id="${instanceId}", summary="<2-3 sentences on what was done>", files_changed=["src/..."], lessons_learned=1)
5573
+ remember_context(
5574
+ instance_id = "${instanceId}",
5575
+ key = "wip-registry",
5576
+ content = "EDITING: <file:line> — goal: <what you are implementing>",
5577
+ category = "bug",
5578
+ ttl = 86400,
5579
+ )
3920
5580
  \`\`\`
5581
+ After the edit is complete, update \`content\` to \`"DONE: <file> — <what was completed>"\`.
3921
5582
 
3922
- ### After fixing ANY bug or solving a hard problem
5583
+ ### After fixing any bug or solving a tricky problem:
3923
5584
  \`\`\`
3924
5585
  learn_from_attempts(
3925
- instance_id="${instanceId}",
3926
- topic="category:keyword", # e.g. "deploy:web", "bug:redis-timeout", "arch:auth"
3927
- outcome="success",
3928
- what_worked="exact solution",
3929
- what_failed="what didn't work (optional)",
3930
- commands=["the command that worked"],
3931
- file_paths=["relevant/file.ts"],
3932
- severity="critical"|"major"|"minor",
5586
+ instance_id = "${instanceId}",
5587
+ topic = "category:keyword",
5588
+ outcome = "success",
5589
+ what_worked = "...",
5590
+ what_failed = "...",
5591
+ severity = "critical" | "major" | "minor",
5592
+ file_paths = ["path/to/file"],
5593
+ commands = ["the command that worked"],
5594
+ tags = ["tag1"],
3933
5595
  )
3934
5596
  \`\`\`
3935
5597
 
3936
- ### Before starting any non-trivial task
5598
+ ### Before starting any task — recall relevant lessons first:
3937
5599
  \`\`\`
3938
- recall_best_solution(instance_id="${instanceId}", topic="relevant:topic")
5600
+ smart_recall(
5601
+ instance_id = "${instanceId}",
5602
+ query = "<describe what you are about to do>",
5603
+ )
3939
5604
  \`\`\`
3940
5605
 
3941
- ### If a stored lesson is wrong
3942
- \`\`\`
3943
- forget_lesson(instance_id="${instanceId}", topic="wrong:topic")
3944
- \`\`\`
5606
+ ### Half-finished code rule:
5607
+ Never commit code that does not compile. Run \`tsc --noEmit\` / \`go build ./...\` before every commit.
5608
+ If a session ends mid-task, save the WIP-registry entry so the next session picks up exactly where you left off.
3945
5609
  ${CLAUDE_MD_MARKER_END}`;
3946
5610
  }
3947
5611
  async function writeClaudeMd(projectDir, instanceId) {
@@ -4013,7 +5677,8 @@ if (process.argv[2] === 'setup') {
4013
5677
  else {
4014
5678
  const AUTH_BASE = 'https://auth.cachly.dev/realms/cachly/protocol/openid-connect';
4015
5679
  const CLIENT_ID = 'cachly-cli';
4016
- console.log('Step 1: Sign in to cachly (free — no credit card required)\n');
5680
+ console.log('Step 1: Sign in to cachly (free, no credit card)\n');
5681
+ // Start device flow
4017
5682
  let deviceCode = '', userCode = '', verifyUri = '', pollInterval = 5000;
4018
5683
  try {
4019
5684
  const deviceRes = await fetch(`${AUTH_BASE}/auth/device`, {
@@ -4022,7 +5687,7 @@ if (process.argv[2] === 'setup') {
4022
5687
  body: `client_id=${CLIENT_ID}&scope=openid`,
4023
5688
  });
4024
5689
  if (!deviceRes.ok)
4025
- throw new Error(`HTTP ${deviceRes.status}`);
5690
+ throw new Error(`Device flow error: HTTP ${deviceRes.status}`);
4026
5691
  const data = await deviceRes.json();
4027
5692
  deviceCode = data.device_code;
4028
5693
  userCode = data.user_code;
@@ -4030,17 +5695,19 @@ if (process.argv[2] === 'setup') {
4030
5695
  pollInterval = (data.interval ?? 5) * 1000;
4031
5696
  }
4032
5697
  catch (e) {
4033
- console.error(`\nCould not reach auth service: ${e.message}`);
4034
- console.error('Fallback: sign in at https://cachly.dev and paste your API token.\n');
5698
+ console.error(`\nFailed to start device flow: ${e.message}`);
5699
+ console.error('Falling back: sign in at https://cachly.dev/setup-ai and paste your API token.\n');
4035
5700
  token = await ask(' Paste API token (cky_live_...): ');
4036
5701
  if (!token) {
4037
- console.error('\nToken required. Aborting.\n');
5702
+ console.error('\nToken is required. Aborting.\n');
4038
5703
  rl.close();
4039
5704
  process.exit(1);
4040
5705
  }
4041
- deviceCode = '';
5706
+ console.log('');
5707
+ deviceCode = ''; // mark as fallback so we skip polling
4042
5708
  }
4043
5709
  if (deviceCode) {
5710
+ // Open browser
4044
5711
  console.log(` Code: \x1b[1;33m${userCode}\x1b[0m`);
4045
5712
  console.log(` URL: ${verifyUri}\n`);
4046
5713
  try {
@@ -4050,10 +5717,11 @@ if (process.argv[2] === 'setup') {
4050
5717
  console.log(' ✓ Browser opened — confirm the code above to continue...\n');
4051
5718
  }
4052
5719
  catch {
4053
- console.log(' 👉 Open the URL above in your browser and confirm the code.\n');
5720
+ console.log(' 👉 Open the URL above in your browser and enter the code.\n');
4054
5721
  }
5722
+ // Poll for token
4055
5723
  process.stdout.write(' Waiting for authorization');
4056
- const deadline = Date.now() + 10 * 60 * 1000;
5724
+ const deadline = Date.now() + 10 * 60 * 1000; // 10 min
4057
5725
  while (Date.now() < deadline) {
4058
5726
  await new Promise(r => setTimeout(r, pollInterval));
4059
5727
  process.stdout.write('.');
@@ -4063,16 +5731,17 @@ if (process.argv[2] === 'setup') {
4063
5731
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
4064
5732
  body: `client_id=${CLIENT_ID}&grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=${deviceCode}`,
4065
5733
  });
4066
- const td = await tokenRes.json();
4067
- if (td.access_token) {
4068
- token = td.access_token;
5734
+ const tokenData = await tokenRes.json();
5735
+ if (tokenData.access_token) {
5736
+ token = tokenData.access_token;
4069
5737
  console.log(' \x1b[32m✓ Authorized!\x1b[0m\n');
4070
5738
  break;
4071
5739
  }
4072
- if (td.error === 'slow_down')
5740
+ // authorization_pending = keep polling; slow_down = increase interval
5741
+ if (tokenData.error === 'slow_down')
4073
5742
  pollInterval = Math.min(pollInterval + 2000, 15000);
4074
- else if (td.error && td.error !== 'authorization_pending') {
4075
- console.error(`\nAuth error: ${td.error}. Aborting.\n`);
5743
+ else if (tokenData.error && tokenData.error !== 'authorization_pending') {
5744
+ console.error(`\nAuth error: ${tokenData.error}. Aborting.\n`);
4076
5745
  rl.close();
4077
5746
  process.exit(1);
4078
5747
  }
@@ -4080,47 +5749,38 @@ if (process.argv[2] === 'setup') {
4080
5749
  catch { /* network hiccup — keep polling */ }
4081
5750
  }
4082
5751
  if (!token) {
4083
- console.error('\nTimed out. Aborting.\n');
5752
+ console.error('\nTimed out waiting for authorization. Aborting.\n');
4084
5753
  rl.close();
4085
5754
  process.exit(1);
4086
5755
  }
4087
- }
4088
- // Exchange short-lived Keycloak JWT → long-lived cky_live_ API key
4089
- if (token.startsWith('eyJ')) {
4090
- process.stdout.write('⏳ Generating your API key...');
4091
- try {
4092
- const keyRes = await fetch(`${API_URL}/api/v1/api-keys`, {
4093
- method: 'POST',
4094
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
4095
- body: JSON.stringify({ name: 'cachly-mcp-setup', scope: 'read_write' }),
4096
- });
4097
- if (!keyRes.ok)
4098
- throw new Error(`HTTP ${keyRes.status}`);
4099
- const keyBody = await keyRes.json();
4100
- if (keyBody.key) {
4101
- token = keyBody.key;
4102
- console.log(' ✓\n');
4103
- }
4104
- else
4105
- throw new Error('no key in response');
4106
- }
4107
- catch (e) {
4108
- console.log(` (skipped — JWT will be used directly)\n`);
4109
- }
5756
+ console.log('');
4110
5757
  }
4111
5758
  }
4112
- // ── Step 1b: Auto-provision a Brain instance if none exist ─────────────────
4113
- // POST /api/v1/instances/auto is idempotent — returns existing or creates free Brain
4114
- let autoProvisioned = false;
4115
- try {
4116
- const autoRes = await fetch(`${API_URL}/api/v1/instances/auto`, {
4117
- method: 'POST',
4118
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
4119
- });
4120
- if (autoRes.ok)
4121
- autoProvisioned = true;
5759
+ // ── Step 1b: Exchange Keycloak JWT → long-lived cky_live_ API key ──────────
5760
+ // Only do this when the token looks like a Keycloak JWT (starts with "eyJ"),
5761
+ // not when the user already pasted a cky_live_ key directly.
5762
+ if (token.startsWith('eyJ')) {
5763
+ process.stdout.write('⏳ Generating your API key...');
5764
+ try {
5765
+ const keyRes = await fetch(`${API_URL}/api/v1/api-keys`, {
5766
+ method: 'POST',
5767
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
5768
+ body: JSON.stringify({ name: 'cachly-mcp-setup', scope: 'read_write' }),
5769
+ });
5770
+ if (!keyRes.ok)
5771
+ throw new Error(`HTTP ${keyRes.status}`);
5772
+ const keyBody = await keyRes.json();
5773
+ if (!keyBody.key)
5774
+ throw new Error('no key in response');
5775
+ token = keyBody.key; // swap JWT → cky_live_...
5776
+ console.log(' ✓\n');
5777
+ }
5778
+ catch (e) {
5779
+ console.log(' (skipped)\n');
5780
+ // Non-fatal: fall back to using the Keycloak JWT directly.
5781
+ // It will expire but setup still works for now.
5782
+ }
4122
5783
  }
4123
- catch { /* non-fatal */ }
4124
5784
  // ── Step 2: Fetch & pick instance ─────────────────────────────────────────
4125
5785
  process.stdout.write('⏳ Fetching your instances...');
4126
5786
  let instances = [];
@@ -4134,32 +5794,70 @@ if (process.argv[2] === 'setup') {
4134
5794
  instances = (body.data ?? []).filter(i => i.status === 'running');
4135
5795
  }
4136
5796
  catch (e) {
4137
- console.error(`\n\nFailed to fetch instances: ${e.message}`);
4138
- console.error('Check your token and try again.\n');
5797
+ const msg = e.message;
5798
+ console.error(`\n\nFailed to fetch instances: ${msg}`);
5799
+ if (msg.includes('401')) {
5800
+ console.error('Token rejected. Get a valid token at https://cachly.dev/setup-ai\n');
5801
+ try {
5802
+ const { execSync } = await import('node:child_process');
5803
+ const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
5804
+ execSync(`${openCmd} https://cachly.dev/setup-ai`, { stdio: 'ignore' });
5805
+ }
5806
+ catch { /* ignore */ }
5807
+ }
4139
5808
  rl.close();
4140
5809
  process.exit(1);
4141
5810
  }
4142
5811
  console.log(` found ${instances.length}\n`);
4143
5812
  if (instances.length === 0) {
4144
- if (autoProvisioned) {
4145
- // Poll up to 10× every 3s (30s total) waiting for the Brain to start
4146
- process.stdout.write(' waiting for Brain to start');
4147
- for (let attempt = 0; attempt < 10 && instances.length === 0; attempt++) {
4148
- await new Promise(r => setTimeout(r, 3000));
4149
- process.stdout.write('.');
4150
- try {
4151
- const retry = await fetch(`${API_URL}/api/v1/instances`, { headers: { Authorization: `Bearer ${token}` } });
4152
- if (retry.ok) {
4153
- const b = await retry.json();
4154
- instances = (b.data ?? []).filter(i => i.status === 'running');
5813
+ // Auto-provision a free Brain instance so users don't have to visit the website.
5814
+ process.stdout.write('⏳ Creating your free Brain instance...');
5815
+ try {
5816
+ const autoRes = await fetch(`${API_URL}/api/v1/instances/auto`, {
5817
+ method: 'POST',
5818
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
5819
+ });
5820
+ if (!autoRes.ok)
5821
+ throw new Error(`HTTP ${autoRes.status}`);
5822
+ const autoBody = await autoRes.json();
5823
+ if (autoBody.instance) {
5824
+ // Returned an existing instance.
5825
+ instances = [autoBody.instance];
5826
+ }
5827
+ else if (autoBody.instance_id) {
5828
+ // Newly created — poll until running or give up after 30 s.
5829
+ const newId = autoBody.instance_id;
5830
+ console.log(` ✓ created (${newId.slice(0, 8)}…)\n`);
5831
+ process.stdout.write('⏳ Waiting for instance to start');
5832
+ for (let attempt = 0; attempt < 15; attempt++) {
5833
+ await new Promise(r => setTimeout(r, 2000));
5834
+ process.stdout.write('.');
5835
+ try {
5836
+ const checkRes = await fetch(`${API_URL}/api/v1/instances/${newId}`, { headers: { Authorization: `Bearer ${token}` } });
5837
+ if (checkRes.ok) {
5838
+ const inst = await checkRes.json();
5839
+ if (inst.status === 'running') {
5840
+ instances = [inst];
5841
+ break;
5842
+ }
5843
+ }
4155
5844
  }
5845
+ catch { /* keep polling */ }
4156
5846
  }
4157
- catch { /* ignore */ }
5847
+ console.log('');
4158
5848
  }
4159
- process.stdout.write(instances.length > 0 ? ' ready!\n' : '\n');
5849
+ }
5850
+ catch (e) {
5851
+ console.log(` failed: ${e.message}\n`);
4160
5852
  }
4161
5853
  if (instances.length === 0) {
4162
- console.error('\nBrain provisioning is taking longer than expected. Run setup again in a minute.\n');
5854
+ console.error('\nCould not create an instance automatically. Opening https://cachly.dev/instances …\n');
5855
+ try {
5856
+ const { execSync } = await import('node:child_process');
5857
+ const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
5858
+ execSync(`${openCmd} https://cachly.dev/instances`, { stdio: 'ignore' });
5859
+ }
5860
+ catch { /* ignore */ }
4163
5861
  rl.close();
4164
5862
  process.exit(1);
4165
5863
  }
@@ -4176,6 +5874,8 @@ if (process.argv[2] === 'setup') {
4176
5874
  // ── Step 3: Detect editors ────────────────────────────────────────────────
4177
5875
  const cwd = process.cwd();
4178
5876
  const detected = [];
5877
+ const { homedir } = await import('node:os');
5878
+ const home = homedir();
4179
5879
  // Claude Code: always include (CLAUDE.md is universal)
4180
5880
  detected.push('claude');
4181
5881
  if (existsSync(resolve(cwd, '.cursor')))
@@ -4186,14 +5886,47 @@ if (process.argv[2] === 'setup') {
4186
5886
  detected.push('copilot');
4187
5887
  if (existsSync(resolve(cwd, '.continue')))
4188
5888
  detected.push('continue');
4189
- const editorLabel = (e) => ({ claude: 'Claude Code', cursor: 'Cursor', windsurf: 'Windsurf', copilot: 'GitHub Copilot', continue: 'Continue.dev' })[e] ?? e;
5889
+ // Cline: VS Code extension — detect via globalStorage on the machine
5890
+ const clineGlobalDir = process.platform === 'darwin'
5891
+ ? resolve(home, 'Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev')
5892
+ : resolve(home, '.vscode/extensions');
5893
+ if (existsSync(clineGlobalDir) && !detected.includes('copilot'))
5894
+ detected.push('cline');
5895
+ else if (existsSync(clineGlobalDir))
5896
+ detected.push('cline'); // both copilot + cline share .vscode/mcp.json — fine
5897
+ // Zed editor — detect via app data dir
5898
+ const zedDir = process.platform === 'darwin'
5899
+ ? resolve(home, 'Library/Application Support/Zed')
5900
+ : resolve(home, '.config/zed');
5901
+ if (existsSync(zedDir))
5902
+ detected.push('zed');
5903
+ const editorLabel = (e) => ({ claude: 'Claude Code', cursor: 'Cursor', windsurf: 'Windsurf', copilot: 'GitHub Copilot', continue: 'Continue.dev', cline: 'Cline (VSCode)', zed: 'Zed' })[e] ?? e;
4190
5904
  console.log(`Step 3: Detected editors: ${detected.map(editorLabel).join(', ')}`);
4191
5905
  const editorsRaw = await ask(` Configure for which? [all / ${detected.join('/')}] (Enter = all): `);
4192
5906
  const editorsToSetup = !editorsRaw || editorsRaw.toLowerCase() === 'all'
4193
5907
  ? detected
4194
5908
  : editorsRaw.split(/[,\s]+/).map(s => s.toLowerCase()).filter(s => EDITOR_FILES[s]);
4195
5909
  console.log('');
4196
- // ── Step 4: Write editor configs ──────────────────────────────────────────
5910
+ // ── Step 4: Email opt-in ──────────────────────────────────────────────────
5911
+ console.log('Step 4: Stay in the loop (optional)');
5912
+ const email = await ask(' Email for release notes & tips? [Enter to skip]: ');
5913
+ if (email && email.includes('@')) {
5914
+ try {
5915
+ await fetch(`${API_URL}/api/newsletter/subscribe`, {
5916
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
5917
+ body: JSON.stringify({ email: email.trim().toLowerCase(), source: 'mcp-setup' }),
5918
+ signal: AbortSignal.timeout(5000),
5919
+ });
5920
+ console.log('✅ Subscribed — you\'ll only hear from us when it matters.\n');
5921
+ }
5922
+ catch {
5923
+ console.log('⚠️ Could not subscribe (no internet?). Continuing...\n');
5924
+ }
5925
+ }
5926
+ else {
5927
+ console.log(' Skipped.\n');
5928
+ }
5929
+ // ── Step 5: Write editor configs ──────────────────────────────────────────
4197
5930
  for (const editor of editorsToSetup) {
4198
5931
  const configFile = EDITOR_FILES[editor] ?? '.mcp.json';
4199
5932
  const configPath = resolve(cwd, configFile);
@@ -4201,174 +5934,163 @@ if (process.argv[2] === 'setup') {
4201
5934
  await writeFile(configPath, buildMcpConfig(token, instance.id, editor), 'utf-8');
4202
5935
  console.log(`✅ Written: ${configFile}`);
4203
5936
  }
4204
- // ── Step 5: CLAUDE.md (always — idempotent) ───────────────────────────────
5937
+ // ── Step 6: CLAUDE.md (always — idempotent) ───────────────────────────────
4205
5938
  const mdResult = await writeClaudeMd(cwd, instance.id);
4206
5939
  const mdLabel = mdResult === 'updated' ? '✅ Updated' : mdResult === 'appended' ? '✅ Appended to' : '✅ Written';
4207
5940
  console.log(`${mdLabel}: CLAUDE.md`);
4208
5941
  rl.close();
4209
5942
  console.log(`\n🧠 Done! Restart your editor — the \`cachly\` MCP tools will appear.`);
4210
5943
  console.log(` Your AI now has persistent memory across every session.\n`);
5944
+ // ── Step 7: Show first session_start preview (Aha moment) ────────────────
5945
+ // Fetch brain health from the API to show the user what their agent will see.
5946
+ process.stdout.write('\n⏳ Fetching your Brain health preview...');
5947
+ try {
5948
+ const brainRes = await fetch(`${API_URL}/api/v1/instances/${instance.id}/brain/stats`, {
5949
+ headers: { Authorization: `Bearer ${token}` },
5950
+ signal: AbortSignal.timeout(6000),
5951
+ });
5952
+ if (brainRes.ok) {
5953
+ const brainData = await brainRes.json();
5954
+ const lessons = brainData.lesson_count ?? 0;
5955
+ const contexts = brainData.context_count ?? 0;
5956
+ const score = brainData.quality_score ?? 0;
5957
+ const level = lessons === 0 ? 'Intern 🌱' :
5958
+ lessons < 10 ? 'Junior Dev 🔧' :
5959
+ lessons < 30 ? 'Mid Dev ⚡' :
5960
+ lessons < 60 ? 'Senior Dev 🧠' :
5961
+ lessons < 100 ? 'Staff Eng 🚀' : 'Principal Eng 🏆';
5962
+ console.log(' ✓\n');
5963
+ console.log('┌──────────────────────────────────────────────────────┐');
5964
+ console.log(`│ 🧠 Brain Health Report │`);
5965
+ console.log('├──────────────────────────────────────────────────────┤');
5966
+ console.log(`│ Lessons stored : ${String(lessons).padEnd(6)} │ Level: ${level.padEnd(20)}│`);
5967
+ console.log(`│ Context entries : ${String(contexts).padEnd(6)} │ Quality score: ${String(Math.round(score * 100)).padEnd(3)}% │`);
5968
+ if (lessons === 0) {
5969
+ console.log('├──────────────────────────────────────────────────────┤');
5970
+ console.log('│ Your Brain is empty and ready to learn. │');
5971
+ console.log('│ After your first coding session it will contain: │');
5972
+ console.log('│ • Lessons from every bug you fixed │');
5973
+ console.log('│ • Your project\'s indexed files │');
5974
+ console.log('│ • A session summary for next time │');
5975
+ }
5976
+ console.log('└──────────────────────────────────────────────────────┘');
5977
+ }
5978
+ else {
5979
+ console.log(' (skipped)');
5980
+ }
5981
+ }
5982
+ catch {
5983
+ console.log(' (skipped)');
5984
+ }
5985
+ console.log(`\n Your AI will call session_start automatically when it uses the first tool.`);
5986
+ console.log(` To see your Brain at any time: https://cachly.dev/instances/${instance.id}\n`);
4211
5987
  process.exit(0);
4212
5988
  }
4213
- // ── CLI: cachly join <token> (team invite — zero friction) ────────────────────
4214
- // Usage: npx @cachly-dev/mcp-server join <token>
4215
- //
4216
- // Connects the user to a shared Brain instance via an invite link.
4217
- // Flow: resolve token → show instance info → Device Code auth → write configs.
4218
- if (process.argv[2] === 'join') {
4219
- const { writeFile, mkdir } = await import('node:fs/promises');
4220
- const { existsSync } = await import('node:fs');
4221
- const { resolve, dirname } = await import('node:path');
4222
- const inviteToken = process.argv[3] ?? '';
4223
- if (!inviteToken) {
4224
- console.error('\n❌ Usage: npx @cachly-dev/mcp-server@latest join <token>\n');
4225
- console.error(' Get a token from a teammate: invite_link() in their AI assistant.\n');
5989
+ // ── CLI: cachly index <dir> ───────────────────────────────────────────────────
5990
+ // Usage: npx @cachly-dev/mcp-server@latest index [./path/to/project]
5991
+ // Indexes the project directory into the Brain — perfect for CI/CD cron jobs.
5992
+ if (process.argv[2] === 'index') {
5993
+ const { resolve } = await import('node:path');
5994
+ const argv = process.argv.slice(3);
5995
+ const flag = (name) => { const i = argv.indexOf(`--${name}`); return i !== -1 ? argv[i + 1] : undefined; };
5996
+ const dir = resolve(flag('dir') ?? argv.find(a => !a.startsWith('--')) ?? '.');
5997
+ const instanceId = flag('instance-id') ?? process.env.CACHLY_BRAIN_INSTANCE_ID;
5998
+ const maxFiles = parseInt(flag('max-files') ?? '500', 10);
5999
+ const namespace = flag('namespace') ?? 'cachly:sem:code';
6000
+ if (!instanceId || !JWT) {
6001
+ console.error('\n❌ CACHLY_BRAIN_INSTANCE_ID and CACHLY_JWT must be set\n');
6002
+ console.error(' export CACHLY_BRAIN_INSTANCE_ID=<uuid>');
6003
+ console.error(' export CACHLY_JWT=<cky_live_...>');
6004
+ console.error(' npx @cachly-dev/mcp-server@latest index ./my-project\n');
4226
6005
  process.exit(1);
4227
6006
  }
4228
- console.log('\n🧠 cachly AI Brain — Team Join');
4229
- console.log('──────────────────────────────────\n');
4230
- // Step 1: Resolve the invite token (public, no auth)
4231
- process.stdout.write('⏳ Resolving invite...');
4232
- let instanceId = '', instanceName = '', instanceTier = '';
6007
+ console.log(`\n📂 Indexing: ${dir}`);
6008
+ console.log(` Instance: ${instanceId.slice(0, 8)}… Max files: ${maxFiles}\n`);
4233
6009
  try {
4234
- const infoRes = await fetch(`${API_URL}/api/invite/${inviteToken}`, {
4235
- signal: AbortSignal.timeout(10000),
6010
+ const result = await handleTool('index_project', {
6011
+ instance_id: instanceId,
6012
+ dir,
6013
+ max_files: maxFiles,
6014
+ ttl: 86400 * 7,
6015
+ namespace,
4236
6016
  });
4237
- if (!infoRes.ok) {
4238
- console.error(`\n\n❌ Invite not found or expired (HTTP ${infoRes.status}).`);
4239
- console.error(' Ask your teammate to generate a new one with: invite_link()\n');
4240
- process.exit(1);
4241
- }
4242
- const info = await infoRes.json();
4243
- instanceId = info.instance_id;
4244
- instanceName = info.instance_name;
4245
- instanceTier = info.tier ?? 'free';
4246
- console.log(` ✓\n`);
4247
- const label = info.label ? ` ("${info.label}")` : '';
4248
- console.log(` Brain instance: \x1b[1m${instanceName}\x1b[0m${label} [${instanceTier}]`);
4249
- console.log(` Instance ID: ${instanceId}\n`);
6017
+ console.log(result);
6018
+ console.log('\n✅ Indexing complete.\n');
4250
6019
  }
4251
- catch (e) {
4252
- console.error(`\n\n❌ Could not reach API: ${e.message}\n`);
6020
+ catch (err) {
6021
+ console.error(`\n❌ Indexing failed: ${err.message}\n`);
4253
6022
  process.exit(1);
4254
6023
  }
4255
- // Step 2: Authenticate via Device Code Flow
4256
- const AUTH_BASE = 'https://auth.cachly.dev/realms/cachly/protocol/openid-connect';
4257
- const CLIENT_ID = 'cachly-cli';
4258
- console.log('Step 1: Sign in to cachly (your own account — free, no credit card)\n');
4259
- let token = process.env.CACHLY_JWT ?? '';
4260
- if (token) {
4261
- console.log('✓ Using token from CACHLY_JWT env var\n');
4262
- }
4263
- else {
4264
- let deviceCode = '', userCode = '', verifyUri = '', pollInterval = 5000;
4265
- try {
4266
- const deviceRes = await fetch(`${AUTH_BASE}/auth/device`, {
4267
- method: 'POST',
4268
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
4269
- body: `client_id=${CLIENT_ID}&scope=openid`,
4270
- signal: AbortSignal.timeout(10000),
4271
- });
4272
- if (!deviceRes.ok)
4273
- throw new Error(`HTTP ${deviceRes.status}`);
4274
- const data = await deviceRes.json();
4275
- deviceCode = data.device_code;
4276
- userCode = data.user_code;
4277
- verifyUri = data.verification_uri_complete;
4278
- pollInterval = (data.interval ?? 5) * 1000;
4279
- }
4280
- catch (e) {
4281
- console.error(`\n❌ Auth service unreachable: ${e.message}`);
4282
- process.exit(1);
4283
- }
4284
- console.log(` Code: \x1b[1;33m${userCode}\x1b[0m`);
4285
- console.log(` URL: ${verifyUri}\n`);
6024
+ process.exit(0);
6025
+ }
6026
+ // ── Start ─────────────────────────────────────────────────────────────────────
6027
+ // Warn on stderr when credentials are missing so the user sees a clear
6028
+ // actionable message in their editor's MCP log instead of silent failures.
6029
+ if (!JWT) {
6030
+ process.stderr.write('\n' +
6031
+ '╔══════════════════════════════════════════════════════════════════╗\n' +
6032
+ '║ 🧠 cachly AI Brain — Setup required ║\n' +
6033
+ '╠══════════════════════════════════════════════════════════════════╣\n' +
6034
+ '║ ║\n' +
6035
+ '║ CACHLY_JWT is not set. Get your free credentials at: ║\n' +
6036
+ '║ ║\n' +
6037
+ '║ 👉 https://cachly.dev/setup-ai ║\n' +
6038
+ '║ ║\n' +
6039
+ '║ Then run the interactive setup wizard: ║\n' +
6040
+ '║ ║\n' +
6041
+ '║ npx @cachly-dev/mcp-server@latest setup ║\n' +
6042
+ '║ ║\n' +
6043
+ '║ Free tier — no credit card required. ║\n' +
6044
+ '╚══════════════════════════════════════════════════════════════════╝\n' +
6045
+ '\n');
6046
+ }
6047
+ // ── Update nudge (non-blocking, fire-and-forget) ─────────────────────────────
6048
+ // Check npm registry once per process start; if outdated, log to stderr so
6049
+ // the editor's MCP log shows an actionable one-liner. Skipped if opted out.
6050
+ if (!process.env.CACHLY_NO_UPDATE_CHECK) {
6051
+ (async () => {
4286
6052
  try {
4287
- const { execSync } = await import('node:child_process');
4288
- const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
4289
- execSync(`${openCmd} "${verifyUri}"`, { stdio: 'ignore' });
4290
- console.log(' ✓ Browser opened — confirm the code above to continue...\n');
4291
- }
4292
- catch {
4293
- console.log(' 👉 Open the URL above in your browser and confirm the code.\n');
4294
- }
4295
- process.stdout.write(' Waiting for authorization');
4296
- const deadline = Date.now() + 10 * 60 * 1000;
4297
- while (Date.now() < deadline) {
4298
- await new Promise(r => setTimeout(r, pollInterval));
4299
- process.stdout.write('.');
4300
- try {
4301
- const td = await (await fetch(`${AUTH_BASE}/token`, {
4302
- method: 'POST',
4303
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
4304
- body: `client_id=${CLIENT_ID}&grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=${deviceCode}`,
4305
- })).json();
4306
- if (td.access_token) {
4307
- token = td.access_token;
4308
- console.log(' \x1b[32m✓ Authorized!\x1b[0m\n');
4309
- break;
4310
- }
4311
- if (td.error === 'slow_down')
4312
- pollInterval = Math.min(pollInterval + 2000, 15000);
4313
- else if (td.error && td.error !== 'authorization_pending') {
4314
- console.error(`\n\n❌ Auth error: ${td.error}\n`);
4315
- process.exit(1);
6053
+ const res = await fetch(`https://registry.npmjs.org/@cachly-dev/mcp-server/latest`, { signal: AbortSignal.timeout(4000) });
6054
+ if (res.ok) {
6055
+ const data = await res.json();
6056
+ const latest = data?.version ?? '';
6057
+ if (latest && latest !== CURRENT_VERSION) {
6058
+ process.stderr.write(`\n⚡ cachly update available: ${CURRENT_VERSION} → ${latest}\n` +
6059
+ ` Run: npx @cachly-dev/mcp-server@latest setup\n\n`);
4316
6060
  }
4317
6061
  }
4318
- catch { /* hiccup — keep polling */ }
4319
6062
  }
4320
- if (!token) {
4321
- console.error('\n\n❌ Timed out. Run the command again.\n');
4322
- process.exit(1);
6063
+ catch { /* ignore – network unavailable or timeout */ }
6064
+ })();
6065
+ }
6066
+ const httpPort = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined;
6067
+ if (httpPort) {
6068
+ // ── HTTP mode (Streamable HTTP transport) ───────────────────────────────
6069
+ // Used for Smithery URL deployment: PORT=3000 node dist/index.js
6070
+ const { createServer } = await import('node:http');
6071
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
6072
+ await server.connect(transport);
6073
+ const httpServer = createServer((req, res) => {
6074
+ const url = new URL(req.url ?? '/', `http://localhost:${httpPort}`);
6075
+ if (url.pathname === '/health') {
6076
+ res.writeHead(200, { 'Content-Type': 'application/json' });
6077
+ res.end(JSON.stringify({ status: 'ok', version: CURRENT_VERSION }));
6078
+ return;
4323
6079
  }
4324
- // Exchange Keycloak JWT → long-lived cky_live_ API key
4325
- if (token.startsWith('eyJ')) {
4326
- process.stdout.write('⏳ Generating your API key...');
4327
- try {
4328
- const keyBody = await (await fetch(`${API_URL}/api/v1/api-keys`, {
4329
- method: 'POST',
4330
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
4331
- body: JSON.stringify({ name: 'cachly-mcp-join', scope: 'read_write' }),
4332
- })).json();
4333
- if (keyBody.key) {
4334
- token = keyBody.key;
4335
- console.log(' ✓\n');
4336
- }
4337
- else
4338
- throw new Error('no key in response');
4339
- }
4340
- catch {
4341
- console.log(' (skipped — JWT used directly)\n');
4342
- }
6080
+ if (url.pathname === '/mcp' || url.pathname === '/') {
6081
+ transport.handleRequest(req, res);
6082
+ return;
4343
6083
  }
4344
- }
4345
- // Step 3: Detect editors and write configs
4346
- const cwd = process.cwd();
4347
- const detected = ['claude'];
4348
- if (existsSync(resolve(cwd, '.cursor')))
4349
- detected.push('cursor');
4350
- if (existsSync(resolve(cwd, '.windsurf')))
4351
- detected.push('windsurf');
4352
- if (existsSync(resolve(cwd, '.vscode')))
4353
- detected.push('copilot');
4354
- if (existsSync(resolve(cwd, '.continue')))
4355
- detected.push('continue');
4356
- console.log(`Step 2: Writing configs for: ${detected.join(', ')}\n`);
4357
- for (const editor of detected) {
4358
- const configFile = EDITOR_FILES[editor] ?? '.mcp.json';
4359
- const configPath = resolve(cwd, configFile);
4360
- await mkdir(dirname(configPath), { recursive: true });
4361
- await writeFile(configPath, buildMcpConfig(token, instanceId, editor), 'utf-8');
4362
- console.log(`✅ Written: ${configFile}`);
4363
- }
4364
- const mdResult = await writeClaudeMd(cwd, instanceId);
4365
- const mdLabel = mdResult === 'updated' ? '✅ Updated' : mdResult === 'appended' ? '✅ Appended to' : '✅ Written';
4366
- console.log(`${mdLabel}: CLAUDE.md`);
4367
- console.log(`\n🧠 Joined! You're now connected to "${instanceName}".`);
4368
- console.log(` Restart your editor — all ${instanceName} lessons are now available via session_start.\n`);
4369
- process.exit(0);
6084
+ res.writeHead(404);
6085
+ res.end('not found');
6086
+ });
6087
+ httpServer.listen(httpPort, () => {
6088
+ process.stderr.write(`cachly-mcp HTTP server listening on :${httpPort}\n`);
6089
+ });
6090
+ }
6091
+ else {
6092
+ // ── stdio mode (default for local editor use) ───────────────────────────
6093
+ const transport = new StdioServerTransport();
6094
+ await server.connect(transport);
4370
6095
  }
4371
- // ── Start ─────────────────────────────────────────────────────────────────────
4372
- const transport = new StdioServerTransport();
4373
- await server.connect(transport);
4374
6096
  //# sourceMappingURL=index.js.map