@cachly-dev/mcp-server 0.5.35 → 0.5.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -301,8 +301,11 @@ function embedProviderHint(): string {
301
301
  // • Recency boost — newer entries rank higher (exponential decay, 7-day half-life)
302
302
  // • Multi-query splitting — numbered lists, semicolons, conjunctions
303
303
  // • Fuzzy matching with Levenshtein distance ≤ 2
304
- // • Multilingual stopwords (EN, DE, FR, ES, IT, PT, ZH, JA, KO)
304
+ // • Multilingual stopwords (EN, DE, FR, ES, IT, PT, ZH, JA, KO, AR, HE)
305
305
  // • CJK character bigram extraction — handles Chinese, Japanese, Korean
306
+ // • RTL language support (Arabic, Hebrew) — word tokenization + light stemming
307
+ // • Romanization matching — katakana → romaji tokens for romaji queries
308
+ // • Cross-language retrieval — tech term synonyms EN↔JA↔ZH↔KO↔AR↔HE
306
309
  // • Pipeline Redis reads for performance
307
310
  //
308
311
 
@@ -400,16 +403,331 @@ const STOPWORDS = new Set([
400
403
  '이', '가', '은', '는', '을', '를', '의', '에', '와', '과', '도',
401
404
  '로', '에서', '한', '하', '있', '없', '것', '수', '않', '들',
402
405
  '이다', '하다', '이고', '이며', '라고', '에게', '에서', '으로',
406
+ // ── Arabic — high-frequency function words & particles ──
407
+ 'في', 'من', 'إلى', 'على', 'عن', 'مع', 'هذا', 'هذه', 'ذلك', 'تلك',
408
+ 'الذي', 'التي', 'الذين', 'اللواتي', 'هو', 'هي', 'هم', 'هن', 'نحن',
409
+ 'أنت', 'أنتم', 'أنا', 'كان', 'كانت', 'كانوا', 'يكون', 'تكون',
410
+ 'أن', 'إن', 'لأن', 'لكن', 'أو', 'بل', 'ثم', 'حتى', 'إذا', 'كي',
411
+ 'قد', 'لقد', 'لم', 'لن', 'ما', 'لا', 'ليس', 'غير', 'بعض', 'كل',
412
+ 'جميع', 'أي', 'كيف', 'متى', 'أين', 'لماذا', 'ماذا', 'هل', 'ال',
413
+ 'و', 'ف', 'ب', 'ل', 'ك', 'يا', 'أم', 'إلا', 'عند', 'بين',
414
+ // ── Hebrew — common particles, pronouns, conjunctions ──
415
+ 'של', 'את', 'אל', 'על', 'עם', 'לא', 'הוא', 'היא', 'הם', 'הן',
416
+ 'אני', 'אתה', 'אנחנו', 'אתם', 'כי', 'אם', 'אבל', 'גם', 'כבר',
417
+ 'רק', 'עוד', 'יש', 'אין', 'מה', 'זה', 'זאת', 'אלה', 'כל', 'כן',
418
+ 'לו', 'לה', 'להם', 'בו', 'בה', 'בהם', 'שם', 'כך', 'כן', 'מי',
419
+ 'אשר', 'אחרי', 'לפני', 'תחת', 'בין', 'מאז', 'עד', 'כמו', 'אז',
403
420
  ]);
404
421
 
405
422
  // CJK Unicode ranges used for bigram extraction
406
423
  const CJK_RE = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]/;
407
424
  const SEGMENT_RE = /([\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]+|[^\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]+)/g;
408
425
 
426
+ // RTL Unicode ranges: Arabic (U+0600–U+06FF + extended), Hebrew (U+0590–U+05FF)
427
+ const RTL_RE = /[\u0590-\u05ff\u0600-\u06ff\u0750-\u077f\u08a0-\u08ff\ufb1d-\ufb4f\ufb50-\ufdff\ufe70-\ufeff]/;
428
+
429
+ // ── Katakana → Romaji conversion table (Hepburn system) ──────────────────────
430
+ // Digraphs must be listed before single chars so they match first.
431
+ const KATA_ROMAJI_TABLE: [string, string][] = [
432
+ // Special combinations for loanwords
433
+ ['ファ', 'fa'], ['フィ', 'fi'], ['フェ', 'fe'], ['フォ', 'fo'],
434
+ ['ティ', 'ti'], ['ディ', 'di'], ['トゥ', 'tu'], ['ドゥ', 'du'],
435
+ ['ウィ', 'wi'], ['ウェ', 'we'], ['ウォ', 'wo'],
436
+ ['チェ', 'che'], ['ジェ', 'je'], ['シェ', 'she'],
437
+ ['イェ', 'ye'], ['ヴァ', 'va'], ['ヴィ', 'vi'], ['ヴェ', 've'], ['ヴォ', 'vo'],
438
+ // Digraphs (2-char → romaji)
439
+ ['キャ', 'kya'], ['キュ', 'kyu'], ['キョ', 'kyo'],
440
+ ['シャ', 'sha'], ['シュ', 'shu'], ['ショ', 'sho'],
441
+ ['チャ', 'cha'], ['チュ', 'chu'], ['チョ', 'cho'],
442
+ ['ニャ', 'nya'], ['ニュ', 'nyu'], ['ニョ', 'nyo'],
443
+ ['ヒャ', 'hya'], ['ヒュ', 'hyu'], ['ヒョ', 'hyo'],
444
+ ['ミャ', 'mya'], ['ミュ', 'myu'], ['ミョ', 'myo'],
445
+ ['リャ', 'rya'], ['リュ', 'ryu'], ['リョ', 'ryo'],
446
+ ['ギャ', 'gya'], ['ギュ', 'gyu'], ['ギョ', 'gyo'],
447
+ ['ジャ', 'ja'], ['ジュ', 'ju'], ['ジョ', 'jo'],
448
+ ['ビャ', 'bya'], ['ビュ', 'byu'], ['ビョ', 'byo'],
449
+ ['ピャ', 'pya'], ['ピュ', 'pyu'], ['ピョ', 'pyo'],
450
+ // Single chars
451
+ ['ア', 'a'], ['イ', 'i'], ['ウ', 'u'], ['エ', 'e'], ['オ', 'o'],
452
+ ['カ', 'ka'], ['キ', 'ki'], ['ク', 'ku'], ['ケ', 'ke'], ['コ', 'ko'],
453
+ ['サ', 'sa'], ['シ', 'shi'], ['ス', 'su'], ['セ', 'se'], ['ソ', 'so'],
454
+ ['タ', 'ta'], ['チ', 'chi'], ['ツ', 'tsu'], ['テ', 'te'], ['ト', 'to'],
455
+ ['ナ', 'na'], ['ニ', 'ni'], ['ヌ', 'nu'], ['ネ', 'ne'], ['ノ', 'no'],
456
+ ['ハ', 'ha'], ['ヒ', 'hi'], ['フ', 'fu'], ['ヘ', 'he'], ['ホ', 'ho'],
457
+ ['マ', 'ma'], ['ミ', 'mi'], ['ム', 'mu'], ['メ', 'me'], ['モ', 'mo'],
458
+ ['ヤ', 'ya'], ['ユ', 'yu'], ['ヨ', 'yo'],
459
+ ['ラ', 'ra'], ['リ', 'ri'], ['ル', 'ru'], ['レ', 're'], ['ロ', 'ro'],
460
+ ['ワ', 'wa'], ['ヲ', 'wo'], ['ン', 'n'],
461
+ // Voiced
462
+ ['ガ', 'ga'], ['ギ', 'gi'], ['グ', 'gu'], ['ゲ', 'ge'], ['ゴ', 'go'],
463
+ ['ザ', 'za'], ['ジ', 'ji'], ['ズ', 'zu'], ['ゼ', 'ze'], ['ゾ', 'zo'],
464
+ ['ダ', 'da'], ['ヂ', 'ji'], ['ヅ', 'zu'], ['デ', 'de'], ['ド', 'do'],
465
+ ['バ', 'ba'], ['ビ', 'bi'], ['ブ', 'bu'], ['ベ', 'be'], ['ボ', 'bo'],
466
+ ['パ', 'pa'], ['ピ', 'pi'], ['プ', 'pu'], ['ペ', 'pe'], ['ポ', 'po'],
467
+ ['ヴ', 'v'],
468
+ // Long vowel / small chars
469
+ ['ー', ''], ['ァ', 'a'], ['ィ', 'i'], ['ゥ', 'u'], ['ェ', 'e'], ['ォ', 'o'],
470
+ ['ッ', ''], // handled separately (doubles next consonant)
471
+ ];
472
+
473
+ const KATA_MAP = new Map<string, string>(KATA_ROMAJI_TABLE);
474
+
475
+ /**
476
+ * Convert a katakana string to Hepburn romaji.
477
+ * Handles digraphs, geminate consonants (ッ), and long vowel marks (ー).
478
+ */
479
+ function katakanaToRomaji(kata: string): string {
480
+ let result = '';
481
+ let i = 0;
482
+ while (i < kata.length) {
483
+ // Geminate consonant: ッ doubles the following consonant
484
+ if (kata[i] === 'ッ' && i + 1 < kata.length) {
485
+ const next = KATA_MAP.get(kata[i + 1]) ?? KATA_MAP.get(kata[i + 1] + kata[i + 2]) ?? '';
486
+ if (next.length > 0) result += next[0]; // double first consonant
487
+ i++;
488
+ continue;
489
+ }
490
+ // Try 2-char digraph first
491
+ if (i + 1 < kata.length) {
492
+ const two = kata[i] + kata[i + 1];
493
+ const r2 = KATA_MAP.get(two);
494
+ if (r2 !== undefined) { result += r2; i += 2; continue; }
495
+ }
496
+ // Single char
497
+ const r1 = KATA_MAP.get(kata[i]);
498
+ if (r1 !== undefined) result += r1;
499
+ i++;
500
+ }
501
+ return result;
502
+ }
503
+
504
+ /**
505
+ * Arabic light stemmer — strips definite article and common prefix particles.
506
+ * Handles: ال (al-), و (wa-), ب (bi-), ل (li-), ف (fa-), ك (ka-).
507
+ * Only strips when the result is still ≥ 3 chars to avoid over-stemming.
508
+ */
509
+ function arabicLightStem(word: string): string {
510
+ if (word.startsWith('ال') && word.length > 4) return word.slice(2);
511
+ if (word.length > 3 && 'وبلفك'.includes(word[0]) && RTL_RE.test(word[1])) return word.slice(1);
512
+ return word;
513
+ }
514
+
515
+ // ── Cross-lingual tech term dictionary ────────────────────────────────────────
516
+ // Maps tokens (English, Japanese katakana, Chinese, Korean, Arabic, Hebrew)
517
+ // to their equivalents in other languages. Used to expand tokens at search time
518
+ // so "deploy" finds documents containing "デプロイ" / "部署" / "배포", and vice versa.
519
+ //
520
+ // Keys must be lowercase. CJK values will be bigram-expanded at use time.
521
+ const CROSS_LINGUAL_MAP = new Map<string, string[]>([
522
+ // English → all
523
+ ['deploy', ['デプロイ', '部署', '배포', 'deployment', 'deploying']],
524
+ ['deployment', ['デプロイ', '部署', '배포', 'deploy']],
525
+ ['container', ['コンテナ', '容器', '컨테이너', 'docker']],
526
+ ['server', ['サーバー', 'サーバ', '服务器', '서버', 'سيرفر', 'שרת']],
527
+ ['database', ['データベース', '数据库', '데이터베이스', 'db', 'قاعدة البيانات']],
528
+ ['cache', ['キャッシュ', '缓存', '캐시', 'كاش', 'מטמון']],
529
+ ['error', ['エラー', '错误', '오류', 'خطأ', 'שגיאה', 'exception', 'err']],
530
+ ['bug', ['バグ', '缺陷', '버그', 'issue', 'defect', 'خلل']],
531
+ ['fix', ['修正', '修复', '수정', 'bugfix', 'patch', 'hotfix', 'إصلاح', 'תיקון']],
532
+ ['build', ['ビルド', '构建', '빌드', 'بناء', 'בנייה']],
533
+ ['test', ['テスト', '测试', '테스트', 'اختبار', 'בדיקה']],
534
+ ['auth', ['認証', '认证', '인증', 'authentication', 'login', 'oauth', 'مصادقة', 'אימות']],
535
+ ['kubernetes', ['クベルネテス', 'k8s', 'kube']],
536
+ ['network', ['ネットワーク', '网络', '네트워크', 'شبكة', 'רשת']],
537
+ ['timeout', ['タイムアウト', '超时', '타임아웃', 'مهلة']],
538
+ ['memory', ['メモリ', '内存', '메모리', 'ram', 'ذاكرة', 'זיכרון']],
539
+ ['config', ['設定', '配置', '설정', 'configuration', 'settings', 'conf', 'إعدادات', 'הגדרות']],
540
+ ['install', ['インストール', '安装', '설치', 'تثبيت', 'התקנה']],
541
+ ['update', ['アップデート', '更新', '업데이트', 'upgrade', 'تحديث', 'עדכון']],
542
+ ['log', ['ログ', '日志', '로그', 'logging', 'سجل', 'יומן']],
543
+ ['port', ['ポート', '端口', '포트', 'منفذ', 'פורט']],
544
+ ['file', ['ファイル', '文件', '파일', 'ملف', 'קובץ']],
545
+ ['image', ['イメージ', '镜像', '이미지', 'صورة', 'תמונה']],
546
+ ['volume', ['ボリューム', '卷', '볼륨']],
547
+ ['cluster', ['クラスター', '集群', '클러스터']],
548
+ ['node', ['ノード', '节点', '노드']],
549
+ ['service', ['サービス', '服务', '서비스', 'svc', 'خدمة', 'שירות']],
550
+ ['certificate', ['証明書', '证书', '인증서', 'cert', 'ssl', 'tls', 'شهادة', 'תעודה']],
551
+ ['password', ['パスワード', '密码', '비밀번호', 'passwd', 'pwd', 'كلمة المرور', 'סיסמה']],
552
+ ['token', ['トークン', '令牌', '토큰', 'jwt', 'secret', 'رمز', 'אסימון']],
553
+ ['health', ['ヘルス', '健康', '헬스', 'healthcheck', 'probe', 'صحة']],
554
+ ['migration', ['マイグレーション', '迁移', '마이그레이션', 'migrate', 'هجرة']],
555
+ ['backup', ['バックアップ', '备份', '백업', 'نسخ احتياطي', 'גיבוי']],
556
+ ['monitor', ['モニター', '监控', '모니터링', 'monitoring', 'metrics', 'alert', 'مراقبة', 'ניטור']],
557
+ ['performance', ['パフォーマンス', '性能', '성능', 'latency', 'throughput']],
558
+ ['connection', ['接続', '连接', '연결', 'conn', 'socket', 'اتصال', 'חיבור']],
559
+ ['queue', ['キュー', '队列', '큐', 'طابور']],
560
+ ['redis', ['レディス', '레디스']],
561
+ ['nginx', ['エンジンエックス']],
562
+ ['linux', ['リナックス', 'لينكس']],
563
+ ['api', ['エーピーアイ', '接口', 'endpoint', 'واجهة برمجية', 'ממשק']],
564
+ ['healthcheck', ['ヘルスチェック', 'health', 'probe', 'فحص الصحة']],
565
+ ['loadbalancer',['ロードバランサー', '负载均衡', '로드밸런서', 'lb']],
566
+ ['ssl', ['tls', 'https', 'certificate', 'cert']],
567
+ ['tls', ['ssl', 'https', 'certificate', 'cert']],
568
+ ['docker', ['container', 'コンテナ', '容器', '컨테이너', 'دوكر']],
569
+ ['git', ['version control', 'repo', 'repository', 'commit', 'push', 'pull']],
570
+ ['ci', ['pipeline', 'github actions', 'gitlab', 'jenkins', 'build']],
571
+ ['cd', ['deploy', 'deployment', 'release']],
572
+ ['debug', ['デバッグ', '调试', '디버그', 'debugging', 'breakpoint', 'تصحيح', 'איתור באגים']],
573
+ ['crash', ['クラッシュ', '崩溃', '크래시', 'panic', 'segfault', 'انهيار']],
574
+ ['restart', ['再起動', '重启', '재시작', 'reboot', 'إعادة تشغيل', 'הפעלה מחדש']],
575
+ ['permission', ['権限', '权限', '권한', 'access', 'acl', 'chmod', 'صلاحية', 'הרשאה']],
576
+ // Japanese katakana → English
577
+ ['デプロイ', ['deploy', 'deployment']],
578
+ ['コンテナ', ['container', 'docker']],
579
+ ['サーバー', ['server']],
580
+ ['サーバ', ['server']],
581
+ ['データベース',['database', 'db']],
582
+ ['キャッシュ', ['cache']],
583
+ ['エラー', ['error', 'err']],
584
+ ['バグ', ['bug', 'issue']],
585
+ ['ビルド', ['build']],
586
+ ['テスト', ['test']],
587
+ ['認証', ['auth', 'authentication']],
588
+ ['設定', ['config', 'configuration']],
589
+ ['インストール',['install']],
590
+ ['アップデート',['update', 'upgrade']],
591
+ ['ログ', ['log', 'logs']],
592
+ ['ポート', ['port']],
593
+ ['ファイル', ['file']],
594
+ ['イメージ', ['image']],
595
+ ['クラスター', ['cluster']],
596
+ ['ノード', ['node']],
597
+ ['サービス', ['service']],
598
+ ['パスワード', ['password']],
599
+ ['トークン', ['token']],
600
+ ['バックアップ',['backup']],
601
+ ['モニター', ['monitor', 'monitoring']],
602
+ ['接続', ['connection']],
603
+ ['キュー', ['queue']],
604
+ ['ヘルスチェック',['healthcheck', 'health check', 'health']],
605
+ ['ロードバランサー',['loadbalancer', 'load balancer']],
606
+ ['デバッグ', ['debug']],
607
+ ['クラッシュ', ['crash', 'panic']],
608
+ ['再起動', ['restart', 'reboot']],
609
+ ['権限', ['permission', 'access']],
610
+ // Chinese → English
611
+ ['部署', ['deploy', 'deployment']],
612
+ ['容器', ['container', 'docker']],
613
+ ['服务器', ['server']],
614
+ ['数据库', ['database']],
615
+ ['缓存', ['cache']],
616
+ ['错误', ['error']],
617
+ ['修复', ['fix', 'bugfix']],
618
+ ['构建', ['build']],
619
+ ['测试', ['test']],
620
+ ['认证', ['auth']],
621
+ ['配置', ['config']],
622
+ ['安装', ['install']],
623
+ ['更新', ['update']],
624
+ ['日志', ['log']],
625
+ ['端口', ['port']],
626
+ ['镜像', ['image']],
627
+ ['集群', ['cluster']],
628
+ ['节点', ['node']],
629
+ ['服务', ['service']],
630
+ ['密码', ['password']],
631
+ ['令牌', ['token']],
632
+ ['备份', ['backup']],
633
+ ['监控', ['monitor']],
634
+ ['连接', ['connection']],
635
+ ['队列', ['queue']],
636
+ ['性能', ['performance']],
637
+ ['内存', ['memory']],
638
+ ['调试', ['debug']],
639
+ ['崩溃', ['crash']],
640
+ ['重启', ['restart']],
641
+ ['权限', ['permission']],
642
+ // Korean → English
643
+ ['배포', ['deploy', 'deployment']],
644
+ ['컨테이너', ['container']],
645
+ ['서버', ['server']],
646
+ ['데이터베이스',['database']],
647
+ ['캐시', ['cache']],
648
+ ['오류', ['error']],
649
+ ['수정', ['fix']],
650
+ ['빌드', ['build']],
651
+ ['테스트', ['test']],
652
+ ['인증', ['auth']],
653
+ ['설정', ['config']],
654
+ ['설치', ['install']],
655
+ ['업데이트', ['update']],
656
+ ['로그', ['log']],
657
+ ['포트', ['port']],
658
+ ['이미지', ['image']],
659
+ ['클러스터', ['cluster']],
660
+ ['노드', ['node']],
661
+ ['서비스', ['service']],
662
+ ['토큰', ['token']],
663
+ ['백업', ['backup']],
664
+ ['모니터링', ['monitor']],
665
+ ['연결', ['connection']],
666
+ ['큐', ['queue']],
667
+ ['성능', ['performance']],
668
+ ['메모리', ['memory']],
669
+ ['디버그', ['debug']],
670
+ ['크래시', ['crash']],
671
+ ['재시작', ['restart']],
672
+ ['권한', ['permission']],
673
+ // Arabic → English
674
+ ['خطأ', ['error']],
675
+ ['إصلاح', ['fix']],
676
+ ['بناء', ['build']],
677
+ ['اختبار', ['test']],
678
+ ['مصادقة', ['auth']],
679
+ ['إعدادات', ['config']],
680
+ ['تثبيت', ['install']],
681
+ ['تحديث', ['update']],
682
+ ['سجل', ['log']],
683
+ ['صورة', ['image']],
684
+ ['خدمة', ['service']],
685
+ ['نسخ احتياطي', ['backup']],
686
+ ['مراقبة', ['monitor']],
687
+ ['اتصال', ['connection']],
688
+ ['ذاكرة', ['memory']],
689
+ ['تصحيح', ['debug']],
690
+ ['شبكة', ['network']],
691
+ // Hebrew → English
692
+ ['שגיאה', ['error']],
693
+ ['תיקון', ['fix']],
694
+ ['בנייה', ['build']],
695
+ ['בדיקה', ['test']],
696
+ ['אימות', ['auth']],
697
+ ['הגדרות', ['config']],
698
+ ['התקנה', ['install']],
699
+ ['עדכון', ['update']],
700
+ ['יומן', ['log']],
701
+ ['שרת', ['server']],
702
+ ['שירות', ['service']],
703
+ ['גיבוי', ['backup']],
704
+ ['ניטור', ['monitor']],
705
+ ['חיבור', ['connection']],
706
+ ['זיכרון', ['memory']],
707
+ ['רשת', ['network']],
708
+ ['תמונה', ['image']],
709
+ ['סיסמה', ['password']],
710
+ ['הרשאה', ['permission']],
711
+ ]);
712
+
713
+ /**
714
+ * Expand a token to its cross-lingual synonyms.
715
+ * Returns synonyms to be added to the token stream (pre-tokenized).
716
+ * CJK synonyms are NOT pre-bigram'd here — caller handles that.
717
+ */
718
+ function expandCrossLingual(token: string): string[] {
719
+ return CROSS_LINGUAL_MAP.get(token) ?? [];
720
+ }
721
+
409
722
  /**
410
723
  * Tokenize text into meaningful keywords.
411
- * Handles accented Latin characters and CJK (Chinese, Japanese, Korean).
412
- * CJK text is split into character bigrams (the standard for indexing logographic languages).
724
+ * Handles accented Latin, RTL (Arabic, Hebrew), and CJK (Chinese, Japanese, Korean).
725
+ *
726
+ * Features:
727
+ * • CJK → character bigrams
728
+ * • Katakana → additionally emits Hepburn romaji tokens (enables romaji queries)
729
+ * • Arabic/Hebrew → word tokenization with light Arabic stemming
730
+ * • All scripts → cross-lingual synonym expansion (EN↔JA↔ZH↔KO↔AR↔HE)
413
731
  */
414
732
  function tokenize(text: string): string[] {
415
733
  const tokens: string[] = [];
@@ -429,8 +747,33 @@ function tokenize(text: string): string[] {
429
747
  const last = seg[seg.length - 1];
430
748
  if (!STOPWORDS.has(last)) tokens.push(last);
431
749
  }
750
+ // Whole-segment cross-lingual lookup (for known tech terms like デプロイ, 部署, 배포)
751
+ // This ensures CJK full-words map to their English equivalents
752
+ const segSyns = expandCrossLingual(seg);
753
+ for (const syn of segSyns) {
754
+ if (!CJK_RE.test(syn)) {
755
+ syn.split(/\s+/).filter(w => w.length > 1 && !STOPWORDS.has(w)).forEach(w => tokens.push(w));
756
+ }
757
+ }
758
+ // Romanization: extract katakana-only portion and emit romaji tokens.
759
+ // This allows users to search "kontena" or "depuroi" to find katakana docs.
760
+ const kataOnly = seg.replace(/[^\u30a0-\u30ff]/g, '');
761
+ if (kataOnly.length >= 2) {
762
+ const romaji = katakanaToRomaji(kataOnly);
763
+ if (romaji.length >= 2 && romaji.length <= 40 && !STOPWORDS.has(romaji)) {
764
+ tokens.push(romaji);
765
+ }
766
+ }
767
+ } else if (RTL_RE.test(seg)) {
768
+ // RTL segment (Arabic / Hebrew) — space-separated words with light stemming
769
+ const words = seg.trim().split(/\s+/);
770
+ for (const w of words) {
771
+ if (w.length <= 1 || STOPWORDS.has(w)) continue;
772
+ const stemmed = arabicLightStem(w);
773
+ if (stemmed.length > 1 && !STOPWORDS.has(stemmed)) tokens.push(stemmed);
774
+ }
432
775
  } else {
433
- // Latin / other — existing logic
776
+ // Latin / other — existing logic preserved exactly
434
777
  const words = seg
435
778
  .replace(/[^a-záàâãäåæçéèêëíìîïñóòôõöúùûüýÿßœ0-9:_\-./]/g, ' ')
436
779
  .split(/\s+/)
@@ -439,6 +782,39 @@ function tokenize(text: string): string[] {
439
782
  }
440
783
  }
441
784
 
785
+ // ── Cross-lingual expansion ──────────────────────────────────────────────
786
+ // For each token, look up synonyms in other languages and add them.
787
+ // CJK synonyms are bigram-expanded inline; Latin/RTL synonyms added as-is.
788
+ const expansions: string[] = [];
789
+ const seen = new Set(tokens);
790
+ for (const tok of tokens) {
791
+ const syns = expandCrossLingual(tok);
792
+ for (const syn of syns) {
793
+ if (seen.has(syn)) continue;
794
+ seen.add(syn);
795
+ if (CJK_RE.test(syn)) {
796
+ // CJK synonym → expand to character bigrams
797
+ for (let i = 0; i < syn.length - 1; i++) {
798
+ const bg = syn[i] + syn[i + 1];
799
+ if (!STOPWORDS.has(syn[i]) && !STOPWORDS.has(bg)) expansions.push(bg);
800
+ }
801
+ if (syn.length >= 1) {
802
+ const last = syn[syn.length - 1];
803
+ if (!STOPWORDS.has(last)) expansions.push(last);
804
+ }
805
+ } else if (RTL_RE.test(syn)) {
806
+ // RTL synonym
807
+ const stemmed = arabicLightStem(syn);
808
+ if (stemmed.length > 1 && !STOPWORDS.has(stemmed)) expansions.push(stemmed);
809
+ } else {
810
+ // Latin synonym — split in case of multi-word (e.g. "load balancer")
811
+ const parts = syn.split(/\s+/).filter(w => w.length > 1 && !STOPWORDS.has(w));
812
+ expansions.push(...parts);
813
+ }
814
+ }
815
+ }
816
+ tokens.push(...expansions);
817
+
442
818
  return tokens;
443
819
  }
444
820
 
@@ -752,7 +1128,8 @@ async function keywordSearch(
752
1128
  }
753
1129
 
754
1130
  // ── Exported for testing ──────────────────────────────────────────────────────
755
- export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS };
1131
+ export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS,
1132
+ katakanaToRomaji, arabicLightStem, expandCrossLingual, CROSS_LINGUAL_MAP };
756
1133
 
757
1134
  // ── Types ─────────────────────────────────────────────────────────────────────
758
1135
 
@@ -1681,6 +2058,104 @@ const TOOLS = [
1681
2058
  required: ['instance_id'],
1682
2059
  },
1683
2060
  },
2061
+ // ── Roadmap — Persistent project plan tracker ───────────────────────────
2062
+ {
2063
+ name: 'roadmap_add',
2064
+ description:
2065
+ 'Add a new item to the persistent project roadmap stored in the Brain. ' +
2066
+ 'Items survive across sessions and editors — the roadmap is always up to date. ' +
2067
+ 'Use for features, bugs, refactors, or any planned work. ' +
2068
+ 'Call roadmap_list to see all open items, roadmap_next to get the next actionable item.',
2069
+ inputSchema: {
2070
+ type: 'object',
2071
+ properties: {
2072
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
2073
+ title: { type: 'string', description: 'Short title of the task/feature (3–10 words)' },
2074
+ description: { type: 'string', description: 'What needs to be done, acceptance criteria, context' },
2075
+ priority: {
2076
+ type: 'string',
2077
+ enum: ['critical', 'high', 'medium', 'low'],
2078
+ description: 'Priority level (default: medium)',
2079
+ },
2080
+ tags: {
2081
+ type: 'array',
2082
+ items: { type: 'string' },
2083
+ description: 'Tags for filtering (e.g. ["api", "web", "sdk", "infra"])',
2084
+ },
2085
+ milestone: { type: 'string', description: 'Milestone/epic this belongs to (optional)' },
2086
+ },
2087
+ required: ['instance_id', 'title'],
2088
+ },
2089
+ },
2090
+ {
2091
+ name: 'roadmap_update',
2092
+ description:
2093
+ 'Update the status, priority, or details of a roadmap item. ' +
2094
+ 'Use to move items through the lifecycle: planned → in-progress → done (or blocked/cancelled). ' +
2095
+ 'Also use to add notes/findings while working on an item.',
2096
+ inputSchema: {
2097
+ type: 'object',
2098
+ properties: {
2099
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
2100
+ id: { type: 'string', description: 'Item ID returned by roadmap_add or roadmap_list' },
2101
+ status: {
2102
+ type: 'string',
2103
+ enum: ['planned', 'in-progress', 'done', 'blocked', 'cancelled'],
2104
+ description: 'New status for the item',
2105
+ },
2106
+ priority: {
2107
+ type: 'string',
2108
+ enum: ['critical', 'high', 'medium', 'low'],
2109
+ description: 'Updated priority (optional)',
2110
+ },
2111
+ notes: { type: 'string', description: 'Progress notes, findings, or blockers (appended to existing notes)' },
2112
+ title: { type: 'string', description: 'Updated title (optional)' },
2113
+ description: { type: 'string', description: 'Updated description (optional)' },
2114
+ },
2115
+ required: ['instance_id', 'id'],
2116
+ },
2117
+ },
2118
+ {
2119
+ name: 'roadmap_list',
2120
+ description:
2121
+ 'List all roadmap items, optionally filtered by status, priority, tag, or milestone. ' +
2122
+ 'Returns items sorted by priority then creation date. ' +
2123
+ 'Called automatically by session_start to show open work.',
2124
+ inputSchema: {
2125
+ type: 'object',
2126
+ properties: {
2127
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
2128
+ status: {
2129
+ type: 'string',
2130
+ enum: ['planned', 'in-progress', 'done', 'blocked', 'cancelled', 'open'],
2131
+ description: 'Filter by status. Use \'open\' to see planned+in-progress+blocked (default: open)',
2132
+ },
2133
+ tag: { type: 'string', description: 'Filter by tag (optional)' },
2134
+ milestone: { type: 'string', description: 'Filter by milestone (optional)' },
2135
+ priority: {
2136
+ type: 'string',
2137
+ enum: ['critical', 'high', 'medium', 'low'],
2138
+ description: 'Filter by minimum priority (optional)',
2139
+ },
2140
+ },
2141
+ required: ['instance_id'],
2142
+ },
2143
+ },
2144
+ {
2145
+ name: 'roadmap_next',
2146
+ description:
2147
+ 'Get the single most important next actionable roadmap item. ' +
2148
+ 'Returns the highest-priority in-progress item first, then planned items, sorted by priority. ' +
2149
+ 'Call at session start to immediately know what to work on next.',
2150
+ inputSchema: {
2151
+ type: 'object',
2152
+ properties: {
2153
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
2154
+ tag: { type: 'string', description: 'Filter by tag (optional)' },
2155
+ },
2156
+ required: ['instance_id'],
2157
+ },
2158
+ },
1684
2159
  {
1685
2160
  name: 'brain_doctor',
1686
2161
  description:
@@ -3603,6 +4078,35 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
3603
4078
  }
3604
4079
  } catch { /* non-critical */ }
3605
4080
 
4081
+ // ── 🗺️ Roadmap — open items at session start ────────────────────────────
4082
+ try {
4083
+ const roadmapAll = await redis.hgetall(`cachly:roadmap:${instance_id}`);
4084
+ if (roadmapAll && Object.keys(roadmapAll).length > 0) {
4085
+ const PRIORITY_ORDER: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
4086
+ const PRIORITY_ICON: Record<string, string> = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
4087
+ const openStatuses = new Set(['planned', 'in-progress', 'blocked']);
4088
+ const allItems = Object.values(roadmapAll).map(v => JSON.parse(v as string) as Record<string, unknown>);
4089
+ const openItems = allItems
4090
+ .filter(i => openStatuses.has(i.status as string))
4091
+ .sort((a, b) => {
4092
+ if (a.status === 'in-progress' && b.status !== 'in-progress') return -1;
4093
+ if (b.status === 'in-progress' && a.status !== 'in-progress') return 1;
4094
+ return (PRIORITY_ORDER[a.priority as string] ?? 99) - (PRIORITY_ORDER[b.priority as string] ?? 99);
4095
+ });
4096
+ const doneCount = allItems.filter(i => i.status === 'done').length;
4097
+ if (openItems.length > 0) {
4098
+ lines.push(`🗺️ **Roadmap** (${openItems.length} open · ${doneCount} done):`);
4099
+ for (const it of openItems.slice(0, 5)) {
4100
+ const statusIcon = it.status === 'in-progress' ? '⚡' : it.status === 'blocked' ? '🚫' : '📋';
4101
+ lines.push(` ${statusIcon} ${PRIORITY_ICON[it.priority as string] ?? '⚪'} \`${it.id}\` **${it.title}**`);
4102
+ }
4103
+ if (openItems.length > 5) lines.push(` … and ${openItems.length - 5} more`);
4104
+ lines.push(` _Use \`roadmap_next\` for the top priority item · \`roadmap_list\` for full view_`);
4105
+ lines.push('');
4106
+ }
4107
+ }
4108
+ } catch { /* non-critical */ }
4109
+
3606
4110
  return lines.join('\n');
3607
4111
  }
3608
4112
 
@@ -5137,6 +5641,178 @@ async function handleBulkLockStream(name: string, args: Record<string, unknown>)
5137
5641
  );
5138
5642
  }
5139
5643
 
5644
+ // ── Roadmap ──────────────────────────────────────────────────────────────
5645
+
5646
+ case 'roadmap_add': {
5647
+ const {
5648
+ instance_id: rid,
5649
+ title,
5650
+ description: desc = '',
5651
+ priority = 'medium',
5652
+ tags: rtags = [],
5653
+ milestone = '',
5654
+ } = args as {
5655
+ instance_id: string; title: string; description?: string;
5656
+ priority?: string; tags?: string[]; milestone?: string;
5657
+ };
5658
+ const redis = await getConnection(rid);
5659
+ const id = `rm_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
5660
+ const item = {
5661
+ id, title, description: desc, priority, tags: rtags, milestone,
5662
+ status: 'planned',
5663
+ created: new Date().toISOString(),
5664
+ updated: new Date().toISOString(),
5665
+ notes: '',
5666
+ };
5667
+ await redis.hset(`cachly:roadmap:${rid}`, id, JSON.stringify(item));
5668
+ const PRIORITY_ICON: Record<string, string> = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
5669
+ return [
5670
+ `📋 **Roadmap item added**`,
5671
+ ``,
5672
+ ` ID: \`${id}\``,
5673
+ ` Title: ${title}`,
5674
+ ` Priority: ${PRIORITY_ICON[priority] ?? '⚪'} ${priority}`,
5675
+ ` Status: planned`,
5676
+ milestone ? ` Milestone: ${milestone}` : '',
5677
+ rtags.length ? ` Tags: ${rtags.join(', ')}` : '',
5678
+ ``,
5679
+ `💡 Use \`roadmap_update(id: "${id}", status: "in-progress")\` when you start working on it.`,
5680
+ ].filter(Boolean).join('\n');
5681
+ }
5682
+
5683
+ case 'roadmap_update': {
5684
+ const {
5685
+ instance_id: rid,
5686
+ id: itemId,
5687
+ status: newStatus,
5688
+ priority: newPriority,
5689
+ notes: newNotes,
5690
+ title: newTitle,
5691
+ description: newDesc,
5692
+ } = args as {
5693
+ instance_id: string; id: string; status?: string; priority?: string;
5694
+ notes?: string; title?: string; description?: string;
5695
+ };
5696
+ const redis = await getConnection(rid);
5697
+ const raw = await redis.hget(`cachly:roadmap:${rid}`, itemId);
5698
+ if (!raw) return `⚠️ **roadmap_update** – Item \`${itemId}\` not found. Use \`roadmap_list\` to see all items.`;
5699
+ const item = JSON.parse(raw) as Record<string, unknown>;
5700
+ const oldStatus = item.status as string;
5701
+ if (newStatus) item.status = newStatus;
5702
+ if (newPriority) item.priority = newPriority;
5703
+ if (newTitle) item.title = newTitle;
5704
+ if (newDesc) item.description = newDesc;
5705
+ if (newNotes) item.notes = item.notes ? `${item.notes}\n[${new Date().toISOString().slice(0, 10)}] ${newNotes}` : `[${new Date().toISOString().slice(0, 10)}] ${newNotes}`;
5706
+ item.updated = new Date().toISOString();
5707
+ await redis.hset(`cachly:roadmap:${rid}`, itemId, JSON.stringify(item));
5708
+ const statusEmoji: Record<string, string> = { planned: '📋', 'in-progress': '⚡', done: '✅', blocked: '🚫', cancelled: '🗑️' };
5709
+ return [
5710
+ `${statusEmoji[newStatus ?? oldStatus] ?? '📋'} **Roadmap updated** \`${itemId}\``,
5711
+ ``,
5712
+ ` Title: ${item.title}`,
5713
+ oldStatus !== newStatus ? ` Status: ${oldStatus} → ${newStatus}` : ` Status: ${item.status}`,
5714
+ newNotes ? ` Notes: ${newNotes}` : '',
5715
+ ].filter(Boolean).join('\n');
5716
+ }
5717
+
5718
+ case 'roadmap_list': {
5719
+ const {
5720
+ instance_id: rid,
5721
+ status: filterStatus = 'open',
5722
+ tag: filterTag,
5723
+ milestone: filterMilestone,
5724
+ priority: filterPriority,
5725
+ } = args as {
5726
+ instance_id: string; status?: string; tag?: string;
5727
+ milestone?: string; priority?: string;
5728
+ };
5729
+ const redis = await getConnection(rid);
5730
+ const all = await redis.hgetall(`cachly:roadmap:${rid}`);
5731
+ if (!all || Object.keys(all).length === 0) {
5732
+ return '📋 **Roadmap is empty.**\n\nUse `roadmap_add` to create your first item.';
5733
+ }
5734
+ const PRIORITY_ORDER: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
5735
+ const PRIORITY_ICON: Record<string, string> = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
5736
+ const STATUS_ICON: Record<string, string> = { planned: '📋', 'in-progress': '⚡', done: '✅', blocked: '🚫', cancelled: '🗑️' };
5737
+ const openStatuses = new Set(['planned', 'in-progress', 'blocked']);
5738
+ let items = Object.values(all).map(v => JSON.parse(v as string) as Record<string, string | string[]>);
5739
+ // Filter
5740
+ if (filterStatus === 'open') items = items.filter(i => openStatuses.has(i.status as string));
5741
+ else if (filterStatus) items = items.filter(i => i.status === filterStatus);
5742
+ if (filterTag) items = items.filter(i => (i.tags as string[]).includes(filterTag));
5743
+ if (filterMilestone) items = items.filter(i => i.milestone === filterMilestone);
5744
+ if (filterPriority) items = items.filter(i => (PRIORITY_ORDER[i.priority as string] ?? 99) <= (PRIORITY_ORDER[filterPriority] ?? 99));
5745
+ // Sort: priority asc, then created asc
5746
+ items.sort((a, b) => {
5747
+ const pa = PRIORITY_ORDER[a.priority as string] ?? 99;
5748
+ const pb = PRIORITY_ORDER[b.priority as string] ?? 99;
5749
+ return pa !== pb ? pa - pb : (a.created as string).localeCompare(b.created as string);
5750
+ });
5751
+ if (items.length === 0) return `📋 **No roadmap items** match the current filter (status: ${filterStatus}).`;
5752
+ const grouped: Record<string, typeof items> = {};
5753
+ for (const it of items) {
5754
+ const st = it.status as string;
5755
+ if (!grouped[st]) grouped[st] = [];
5756
+ grouped[st].push(it);
5757
+ }
5758
+ const lines: string[] = [`📋 **Roadmap** (${items.length} item${items.length !== 1 ? 's' : ''})`, ''];
5759
+ for (const [st, grp] of Object.entries(grouped)) {
5760
+ lines.push(`**${STATUS_ICON[st] ?? '•'} ${st.toUpperCase()}** (${grp.length})`);
5761
+ for (const it of grp) {
5762
+ const tags = (it.tags as string[]).length ? ` [${(it.tags as string[]).join(', ')}]` : '';
5763
+ const milestone = it.milestone ? ` · ${it.milestone}` : '';
5764
+ lines.push(` ${PRIORITY_ICON[it.priority as string] ?? '⚪'} \`${it.id}\` **${it.title}**${tags}${milestone}`);
5765
+ if (it.description) lines.push(` ${(it.description as string).slice(0, 120)}`);
5766
+ if (it.notes) lines.push(` 📝 ${(it.notes as string).split('\n').pop()?.slice(0, 100)}`);
5767
+ }
5768
+ lines.push('');
5769
+ }
5770
+ lines.push(`💡 \`roadmap_update(id, status: "in-progress")\` to start · \`roadmap_next\` for top priority item`);
5771
+ return lines.join('\n');
5772
+ }
5773
+
5774
+ case 'roadmap_next': {
5775
+ const { instance_id: rid, tag: filterTag } = args as { instance_id: string; tag?: string };
5776
+ const redis = await getConnection(rid);
5777
+ const all = await redis.hgetall(`cachly:roadmap:${rid}`);
5778
+ if (!all || Object.keys(all).length === 0) {
5779
+ return '📋 **Roadmap is empty.** Use `roadmap_add` to plan your first task.';
5780
+ }
5781
+ const PRIORITY_ORDER: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
5782
+ const PRIORITY_ICON: Record<string, string> = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
5783
+ let items = Object.values(all)
5784
+ .map(v => JSON.parse(v as string) as Record<string, unknown>)
5785
+ .filter(i => i.status === 'in-progress' || i.status === 'planned')
5786
+ .filter(i => !filterTag || (i.tags as string[]).includes(filterTag));
5787
+ if (items.length === 0) return '🎉 **No open roadmap items!** All tasks are done (or use `roadmap_list` to check).';
5788
+ // in-progress first, then by priority
5789
+ items.sort((a, b) => {
5790
+ if (a.status === 'in-progress' && b.status !== 'in-progress') return -1;
5791
+ if (b.status === 'in-progress' && a.status !== 'in-progress') return 1;
5792
+ return (PRIORITY_ORDER[a.priority as string] ?? 99) - (PRIORITY_ORDER[b.priority as string] ?? 99);
5793
+ });
5794
+ const next = items[0];
5795
+ const remaining = items.length - 1;
5796
+ const tags = (next.tags as string[]).length ? `\nTags: ${(next.tags as string[]).join(', ')}` : '';
5797
+ const milestone = next.milestone ? `\nMilestone: ${next.milestone}` : '';
5798
+ const notes = next.notes ? `\nNotes: ${(next.notes as string).split('\n').pop()?.slice(0, 120)}` : '';
5799
+ return [
5800
+ `${next.status === 'in-progress' ? '⚡' : '📋'} **Next up: ${next.title}**`,
5801
+ ``,
5802
+ `ID: \`${next.id}\``,
5803
+ `Priority: ${PRIORITY_ICON[next.priority as string] ?? '⚪'} ${next.priority}`,
5804
+ `Status: ${next.status}`,
5805
+ next.description ? `\nWhat to do:\n${next.description}` : '',
5806
+ tags, milestone, notes,
5807
+ ``,
5808
+ remaining > 0 ? `(+${remaining} more open item${remaining !== 1 ? 's' : ''} in roadmap)` : '(last open item)',
5809
+ ``,
5810
+ next.status === 'planned'
5811
+ ? `💡 Start with: \`roadmap_update(id: "${next.id}", status: "in-progress")\``
5812
+ : `💡 Finish with: \`roadmap_update(id: "${next.id}", status: "done", notes: "...")\``,
5813
+ ].filter(s => s !== undefined).join('\n');
5814
+ }
5815
+
5140
5816
  default:
5141
5817
  return null;
5142
5818
  }