@cachly-dev/mcp-server 0.5.70 → 0.5.80

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
@@ -47,7 +47,7 @@ import { Redis } from 'ioredis';
47
47
  const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
48
48
  const JWT = process.env.CACHLY_JWT ?? '';
49
49
  const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
50
- const CURRENT_VERSION = '0.5.70';
50
+ const CURRENT_VERSION = '0.5.80';
51
51
  /**
52
52
  * ┌──────────────────────────────────────────────────────────────────────┐
53
53
  * │ EMBEDDING PROVIDER — pluggable, client-side first │
@@ -454,6 +454,13 @@ const STOPWORDS = new Set([
454
454
  'এবং', 'কিন্তু', 'বা', 'না', 'হ্যাঁ', 'যে', 'কি', 'কে', 'কী', 'থেকে',
455
455
  'দিয়ে', 'জন্য', 'সাথে', 'মধ্যে', 'উপর', 'নিচে', 'আছে', 'ছিল', 'হয়',
456
456
  'করা', 'করে', 'করেছে', 'হবে', 'হয়েছে', 'একটি', 'একটা', 'অনেক', 'সব',
457
+ // ── Vietnamese — common particles, pronouns, auxiliary words ─────────────
458
+ 'tôi', 'bạn', 'anh', 'chị', 'em', 'họ', 'chúng', 'ta', 'mình',
459
+ 'là', 'có', 'không', 'và', 'với', 'của', 'cho', 'trong', 'về',
460
+ 'từ', 'đến', 'để', 'khi', 'nếu', 'thì', 'mà', 'nhưng', 'vì',
461
+ 'đây', 'đó', 'này', 'kia', 'rất', 'cũng', 'đã', 'sẽ', 'đang',
462
+ 'được', 'bị', 'những', 'các', 'một', 'hai', 'ba', 'nhiều', 'ít',
463
+ 'nào', 'ai', 'gì', 'đâu', 'sao', 'bao', 'giờ', 'lúc', 'sau',
457
464
  ]);
458
465
  // CJK Unicode ranges used for bigram extraction
459
466
  const CJK_RE = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]/;
@@ -473,6 +480,9 @@ const CYRILLIC_RE = /[\u0400-\u04ff]/;
473
480
  const TURKISH_CHAR_RE = /[\u011f\u015f\u0131\u0130\u00e7]/; // ğ ş ı İ ç
474
481
  // Bengali (Bangla) — U+0980–U+09FF
475
482
  const BENGALI_RE = /[\u0980-\u09ff]/;
483
+ // Vietnamese uses Latin + Latin Extended Additional (U+1EA0–U+1EF9) for tone marks
484
+ // e.g. ắ ặ ầ ổ ợ ụ ừ — detected by chars exclusive to Vietnamese diacritics
485
+ const VIETNAMESE_CHAR_RE = /[\u1ea0-\u1ef9]/;
476
486
  // ── Katakana → Romaji conversion table (Hepburn system) ──────────────────────
477
487
  // Digraphs must be listed before single chars so they match first.
478
488
  const KATA_ROMAJI_TABLE = [
@@ -928,13 +938,13 @@ const CROSS_LINGUAL_MAP = new Map([
928
938
  ['pip', ['python', 'package', 'pypi']],
929
939
  ['brew', ['homebrew', 'macos', 'package']],
930
940
  // English → all
931
- ['deploy', ['デプロイ', '部署', '배포', 'deployment', 'deploying', 'نشر', 'פריסה', 'استقرار', 'तैनाती', 'bereitstellen', 'bereitstellung', 'déployer', 'déploiement', 'desplegar', 'despliegue', 'distribuire', 'distribuzione', 'implantar', 'implantação', 'развёртывание', 'развертывание', 'dağıtım', 'dağıtmak']],
932
- ['deployment', ['デプロイ', '部署', '배포', 'deploy', 'نشر', 'פריסה', 'استقرار', 'तैनाती', 'bereitstellung', 'déploiement', 'despliegue', 'distribuzione', 'implantação', 'развёртывание', 'dağıtım']],
941
+ ['deploy', ['デプロイ', '部署', '배포', 'deployment', 'deploying', 'نشر', 'פריסה', 'استقرار', 'तैनाती', 'bereitstellen', 'bereitstellung', 'déployer', 'déploiement', 'desplegar', 'despliegue', 'distribuire', 'distribuzione', 'implantar', 'implantação', 'развёртывание', 'развертывание', 'dağıtım', 'dağıtmak', 'triển khai']],
942
+ ['deployment', ['デプロイ', '部署', '배포', 'deploy', 'نشر', 'פריסה', 'استقرار', 'तैनाती', 'bereitstellung', 'déploiement', 'despliegue', 'distribuzione', 'implantação', 'развёртывание', 'dağıtım', 'triển khai']],
933
943
  ['container', ['コンテナ', '容器', '컨테이너', 'docker']],
934
- ['server', ['サーバー', 'サーバ', '服务器', '서버', 'سيرفر', 'שרת', 'سرور', 'सर्वर', 'сервер', 'sunucu']],
944
+ ['server', ['サーバー', 'サーバ', '服务器', '서버', 'سيرفر', 'שרת', 'سرور', 'सर्वर', 'сервер', 'sunucu', 'máy chủ']],
935
945
  ['database', ['データベース', '数据库', '데이터베이스', 'db', 'قاعدة البيانات', 'datenbank', 'پایگاه داده', 'डेटाबेس', 'base de données', 'base de datos', 'banco de dados']],
936
- ['cache', ['キャッシュ', '缓存', '캐시', 'كاش', 'מטמון', 'کش', 'कैश', 'кэш', 'önbellek']],
937
- ['error', ['エラー', '错误', '오류', 'خطأ', 'שגיאה', 'خطا', 'त्रुटि', 'गलती', 'exception', 'err', 'fehler', 'erreur', 'fallo', 'errore', 'erro', 'ошибка', 'hata', 'hatalı']],
946
+ ['cache', ['キャッシュ', '缓存', '캐시', 'كاش', 'מטמון', 'کش', 'कैश', 'кэш', 'önbellek', 'bộ đệm']],
947
+ ['error', ['エラー', '错误', '오류', 'خطأ', 'שגיאה', 'خطا', 'त्रुटि', 'गलती', 'exception', 'err', 'fehler', 'erreur', 'fallo', 'errore', 'erro', 'ошибка', 'hata', 'hatalı', 'lỗi']],
938
948
  ['bug', ['バグ', '缺陷', '버그', 'issue', 'defect', 'خلل', 'اشکال', 'बग']],
939
949
  ['fix', ['修正', '修复', '수정', 'bugfix', 'patch', 'hotfix', 'إصلاح', 'תיקון', 'رفع', 'सुधार', 'beheben', 'behoben', 'réparer', 'correction', 'arreglar', 'corrección', 'correggere', 'corrigir', 'исправить', 'исправление', 'düzeltmek', 'düzeltme']],
940
950
  ['build', ['ビルド', '构建', '빌드', 'بناء', 'בנייה', 'ساخت', 'निर्माण', 'bauen', 'construire', 'construir', 'costruire', 'сборка', 'собрать', 'derleme', 'derlemek']],
@@ -1509,6 +1519,37 @@ const CROSS_LINGUAL_MAP = new Map([
1509
1519
  ['şifre', ['password']],
1510
1520
  ['izleme', ['monitor', 'monitoring']],
1511
1521
  ['göç', ['migrate', 'migration']],
1522
+ // ── Vietnamese (Latin + Latin Extended Additional U+1EA0–U+1EF9) → English ─
1523
+ ['triển khai', ['deploy', 'deployment']],
1524
+ ['lỗi', ['error', 'err']],
1525
+ ['sửa lỗi', ['fix', 'bugfix', 'debug']],
1526
+ ['xây dựng', ['build']],
1527
+ ['kiểm tra', ['test']],
1528
+ ['xác thực', ['auth', 'authentication']],
1529
+ ['cấu hình', ['config', 'configuration']],
1530
+ ['cài đặt', ['install']],
1531
+ ['cập nhật', ['update', 'upgrade']],
1532
+ ['nhật ký', ['log']],
1533
+ ['máy chủ', ['server']],
1534
+ ['cơ sở dữ liệu', ['database', 'db']],
1535
+ ['mạng', ['network']],
1536
+ ['bộ nhớ', ['memory', 'ram']],
1537
+ ['bộ đệm', ['cache']],
1538
+ ['gỡ lỗi', ['debug', 'debugging']],
1539
+ ['sự cố', ['crash', 'failure', 'error']],
1540
+ ['khởi động lại', ['restart', 'reboot']],
1541
+ ['quyền', ['permission', 'access']],
1542
+ ['sao lưu', ['backup']],
1543
+ ['hiệu suất', ['performance']],
1544
+ ['vấn đề', ['problem', 'issue', 'error']],
1545
+ ['dịch vụ', ['service']],
1546
+ ['kết nối', ['connection', 'conn']],
1547
+ ['vùng chứa', ['container', 'docker']],
1548
+ ['nút', ['node']],
1549
+ ['khóa', ['key', 'token', 'secret']],
1550
+ ['mật khẩu', ['password']],
1551
+ ['giám sát', ['monitor', 'monitoring']],
1552
+ ['di chuyển', ['migrate', 'migration']],
1512
1553
  ]);
1513
1554
  /**
1514
1555
  * Expand a token to its cross-lingual synonyms.
@@ -1624,9 +1665,9 @@ function tokenize(text) {
1624
1665
  }
1625
1666
  else {
1626
1667
  // Latin / other — includes Turkish (ğ/ş/ı), Polish (ą/ę/ł/ń/ó/ś/ź/ż/ć),
1627
- // Czech (č/š/ž/ě/ů/ř), Hungarian (ő/ű), Romanian (ș/ț)
1668
+ // Czech (č/š/ž/ě/ů/ř), Hungarian (ő/ű), Romanian (ș/ț), Vietnamese (ắặầổợụừ…)
1628
1669
  const words = seg
1629
- .replace(/[^a-záàâãäåæçéèêëíìîïñóòôõöúùûüýÿßœğşıİąćęłńśźżčšžěůřőűșț0-9:\-./]/g, ' ')
1670
+ .replace(/[^a-záàâãäåæçéèêëíìîïñóòôõöúùûüýÿßœğşıİąćęłńśźżčšžěůřőűșț\u1ea0-\u1ef90-9:\-./]/g, ' ')
1630
1671
  .split(/\s+/)
1631
1672
  .filter(w => w.length > 1 && !STOPWORDS.has(w));
1632
1673
  // Turkish: apply light stemmer to words with Turkish-specific chars
@@ -1763,6 +1804,17 @@ const BM25_B = 0.75; // length normalization
1763
1804
  const BM25_DELTA = 1.0; // BM25+ lower-bound guarantee (0 = classic BM25)
1764
1805
  /** Recency boost: half-life in days. Entry from 7 days ago gets 0.5× boost. */
1765
1806
  const RECENCY_HALF_LIFE_DAYS = 7;
1807
+ const ZERO_RESULTS_LOG = [];
1808
+ const ZERO_RESULTS_MAX = 500;
1809
+ let zeroResultsTotal = 0;
1810
+ function logZeroResult(query) {
1811
+ zeroResultsTotal++;
1812
+ if (ZERO_RESULTS_LOG.length >= ZERO_RESULTS_MAX)
1813
+ ZERO_RESULTS_LOG.shift();
1814
+ ZERO_RESULTS_LOG.push({ query, ts: Date.now() });
1815
+ }
1816
+ /** Global index vocabulary — rebuilt on each keywordSearch call from the doc set. */
1817
+ let _indexVocab = new Set();
1766
1818
  /**
1767
1819
  * Levenshtein distance — edit distance between two strings.
1768
1820
  * Used for typo-tolerant fuzzy matching (distance ≤ 2 = match).
@@ -1903,13 +1955,34 @@ async function keywordSearch(redis, patterns, query, topK = 10) {
1903
1955
  return idf(term) * (tfNorm + BM25_DELTA);
1904
1956
  }
1905
1957
  /**
1906
- * Fuzzy match: tries exact → substring → Levenshtein ≤ 2.
1958
+ * Fuzzy match: tries exact → prefix → substring → Levenshtein ≤ 2.
1907
1959
  * Returns [matchedTerm, weight] or null.
1960
+ *
1961
+ * Weights:
1962
+ * 1.0 — exact match
1963
+ * 0.85 — doc token starts with query (e.g. "dockerf" → "dockerfile")
1964
+ * 0.75 — query starts with doc token (e.g. "kubernetes" → "kube")
1965
+ * 0.6 — substring (either direction)
1966
+ * 0.4 — Levenshtein ≤ 2 (typo tolerance)
1908
1967
  */
1909
1968
  function fuzzyMatch(qt, docTermSet) {
1910
1969
  // Exact
1911
1970
  if (docTermSet.has(qt))
1912
1971
  return [qt, 1.0];
1972
+ // Prefix: query is prefix of a doc token (user typed partial word)
1973
+ if (qt.length >= 4) {
1974
+ for (const dt of docTermSet) {
1975
+ if (dt.length > qt.length && dt.startsWith(qt))
1976
+ return [dt, 0.85];
1977
+ }
1978
+ }
1979
+ // Reverse-prefix: doc token is prefix of query (doc has abbreviated form)
1980
+ if (qt.length >= 4) {
1981
+ for (const dt of docTermSet) {
1982
+ if (dt.length >= 4 && dt.length < qt.length && qt.startsWith(dt))
1983
+ return [dt, 0.75];
1984
+ }
1985
+ }
1913
1986
  // Substring (partial)
1914
1987
  for (const dt of docTermSet) {
1915
1988
  if (dt.length > 3 && qt.length > 3 && (dt.includes(qt) || qt.includes(dt))) {
@@ -2003,7 +2076,17 @@ async function keywordSearch(redis, patterns, query, topK = 10) {
2003
2076
  }
2004
2077
  // ── Step 4: Sort by score, return top-K ──
2005
2078
  const sorted = [...allMatches.values()].sort((a, b) => b.score - a.score);
2006
- return sorted.slice(0, topK);
2079
+ const topResults = sorted.slice(0, topK);
2080
+ // ── Step 5: Zero-results logging + Did-You-Mean ───────────────────────────
2081
+ if (topResults.length === 0) {
2082
+ logZeroResult(query);
2083
+ // Rebuild index vocab for Did-You-Mean suggestions
2084
+ _indexVocab = new Set();
2085
+ for (const doc of docs)
2086
+ for (const t of doc.tokens)
2087
+ _indexVocab.add(t);
2088
+ }
2089
+ return topResults;
2007
2090
  }
2008
2091
  // ── Exported for testing ──────────────────────────────────────────────────────
2009
2092
  export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS, katakanaToRomaji, arabicLightStem, expandCrossLingual, CROSS_LINGUAL_MAP };
@@ -4308,10 +4391,37 @@ async function handleTool(name, args) {
4308
4391
  }
4309
4392
  if (kwMatches.length === 0) {
4310
4393
  lines.push(`⚠️ No matches found for: "${query}"`);
4311
- lines.push(`\n💡 Tips:`);
4312
- lines.push(` • Try different keywords`);
4313
- lines.push(` • Use \`list_remembered\` to see available context`);
4314
- lines.push(` • Use \`recall_best_solution("topic")\` for exact topic lookup`);
4394
+ // Did-You-Mean: find nearest token in index vocab
4395
+ const queryTokens = tokenize(query);
4396
+ const suggestions = [];
4397
+ if (_indexVocab.size > 0 && queryTokens.length > 0) {
4398
+ for (const qt of queryTokens.slice(0, 3)) {
4399
+ if (qt.length < 4)
4400
+ continue;
4401
+ let bestDist = 3;
4402
+ let bestTok = '';
4403
+ for (const v of _indexVocab) {
4404
+ if (v.length < 3 || Math.abs(v.length - qt.length) > 4)
4405
+ continue;
4406
+ const d = levenshtein(qt, v);
4407
+ if (d > 0 && d < bestDist) {
4408
+ bestDist = d;
4409
+ bestTok = v;
4410
+ }
4411
+ }
4412
+ if (bestTok)
4413
+ suggestions.push(`"${bestTok}" (instead of "${qt}")`);
4414
+ }
4415
+ }
4416
+ if (suggestions.length > 0) {
4417
+ lines.push(`💡 **Did you mean:** ${suggestions.join(', ')}?`);
4418
+ }
4419
+ else {
4420
+ lines.push(`\n💡 Tips:`);
4421
+ lines.push(` • Try different keywords`);
4422
+ lines.push(` • Use \`list_remembered\` to see available context`);
4423
+ lines.push(` • Use \`recall_best_solution("topic")\` for exact topic lookup`);
4424
+ }
4315
4425
  }
4316
4426
  return lines.join('\n');
4317
4427
  }
@@ -6999,7 +7109,14 @@ if (httpPort) {
6999
7109
  const url = new URL(req.url ?? '/', `http://localhost:${httpPort}`);
7000
7110
  if (url.pathname === '/health') {
7001
7111
  res.writeHead(200, { 'Content-Type': 'application/json' });
7002
- res.end(JSON.stringify({ status: 'ok', version: CURRENT_VERSION }));
7112
+ res.end(JSON.stringify({
7113
+ status: 'ok',
7114
+ version: CURRENT_VERSION,
7115
+ zeroResults: {
7116
+ total: zeroResultsTotal,
7117
+ last10: ZERO_RESULTS_LOG.slice(-10).map(e => ({ query: e.query, ts: new Date(e.ts).toISOString() })),
7118
+ },
7119
+ }));
7003
7120
  return;
7004
7121
  }
7005
7122
  if (url.pathname === '/mcp' || url.pathname === '/') {