@cachly-dev/mcp-server 0.5.37 → 0.5.39
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/.github/workflows/publish.yml +28 -0
- package/Dockerfile +15 -12
- package/LICENSE +202 -0
- package/README.md +2 -8
- package/dist/index.d.ts +3 -31
- package/dist/index.js +410 -2493
- package/dist/index.js.map +1 -1
- package/dist/search.test.js +3 -173
- package/dist/search.test.js.map +1 -1
- package/glama.json +6 -0
- package/package.json +1 -1
- package/smithery.yaml +5 -10
- package/src/index.ts +423 -2600
- package/src/search.test.ts +3 -217
- package/CHANGELOG.md +0 -65
- package/dist/__tests__/namespace.test.d.ts +0 -1
- package/dist/__tests__/namespace.test.js +0 -114
- package/dist/__tests__/namespace.test.js.map +0 -1
- package/run-mcp.sh +0 -57
- package/scripts/postinstall.js +0 -36
- package/src/__tests__/namespace.test.ts +0 -126
- package/test-features.cjs +0 -23
package/dist/index.js
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { createHash } from 'node:crypto';
|
|
4
3
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
5
|
-
import { readFileSync } from 'node:fs';
|
|
6
|
-
import { execSync } from 'node:child_process';
|
|
7
4
|
import { join, relative, extname } from 'node:path';
|
|
8
5
|
/**
|
|
9
|
-
* cachly MCP Server v0.
|
|
6
|
+
* cachly MCP Server v0.5.39
|
|
10
7
|
*
|
|
11
8
|
* Exposes cachly.dev as MCP tools so any AI assistant
|
|
12
9
|
* (GitHub Copilot, Claude, Cursor, Windsurf, Continue.dev …) can:
|
|
@@ -34,20 +31,20 @@ import { join, relative, extname } from 'node:path';
|
|
|
34
31
|
*
|
|
35
32
|
* Configuration (env vars):
|
|
36
33
|
* CACHLY_API_URL – default https://api.cachly.dev
|
|
34
|
+
* CACHLY_AUTH_URL – default https://auth.cachly.dev (Keycloak base URL for health checks)
|
|
37
35
|
* CACHLY_JWT – your JWT (Keycloak access token)
|
|
38
36
|
* CACHLY_EMBED_PROVIDER – embedding backend: openai (default), gemini, mistral, cohere, ollama, cachly (server fallback)
|
|
39
37
|
* CACHLY_EMBED_MODEL – override embedding model (optional)
|
|
40
38
|
*/
|
|
41
39
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
42
40
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
43
|
-
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
44
41
|
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
|
|
45
42
|
import { Redis } from 'ioredis';
|
|
46
43
|
// ── Config ────────────────────────────────────────────────────────────────────
|
|
47
44
|
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';
|
|
48
46
|
const JWT = process.env.CACHLY_JWT ?? '';
|
|
49
47
|
const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
|
|
50
|
-
const CURRENT_VERSION = '0.5.26';
|
|
51
48
|
/**
|
|
52
49
|
* ┌──────────────────────────────────────────────────────────────────────┐
|
|
53
50
|
* │ EMBEDDING PROVIDER — pluggable, client-side first │
|
|
@@ -66,43 +63,6 @@ const CURRENT_VERSION = '0.5.26';
|
|
|
66
63
|
* └──────────────────────────────────────────────────────────────────────┘
|
|
67
64
|
*/
|
|
68
65
|
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
|
-
}
|
|
106
66
|
function detectEmbedProvider() {
|
|
107
67
|
// Client-side keys first (direct, fast, no server roundtrip)
|
|
108
68
|
if (process.env.OPENAI_API_KEY)
|
|
@@ -288,17 +248,12 @@ function embedProviderHint() {
|
|
|
288
248
|
// • Recency boost — newer entries rank higher (exponential decay, 7-day half-life)
|
|
289
249
|
// • Multi-query splitting — numbered lists, semicolons, conjunctions
|
|
290
250
|
// • Fuzzy matching with Levenshtein distance ≤ 2
|
|
291
|
-
// • Multilingual stopwords (EN, DE, FR, ES, IT, PT
|
|
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
|
|
251
|
+
// • Multilingual stopwords (EN, DE, FR, ES, IT, PT)
|
|
296
252
|
// • Pipeline Redis reads for performance
|
|
297
253
|
//
|
|
298
254
|
/**
|
|
299
255
|
* Stopwords — filtered out during tokenization.
|
|
300
|
-
* Covers: English, German, French, Spanish, Italian, Portuguese
|
|
301
|
-
* Chinese (Simplified + Traditional), Japanese, Korean.
|
|
256
|
+
* Covers: English, German, French, Spanish, Italian, Portuguese.
|
|
302
257
|
* Keeps the index small and scores meaningful.
|
|
303
258
|
*/
|
|
304
259
|
const STOPWORDS = new Set([
|
|
@@ -372,443 +327,14 @@ const STOPWORDS = new Set([
|
|
|
372
327
|
'eu', 'tu', 'ele', 'ela', 'nós', 'eles', 'elas', 'você', 'vocês',
|
|
373
328
|
'não', 'já', 'sim', 'bem', 'muito', 'também', 'ainda', 'sempre',
|
|
374
329
|
'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']],
|
|
698
330
|
]);
|
|
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
|
-
*/
|
|
331
|
+
/** Tokenize text into meaningful keywords. Handles unicode (accented chars). */
|
|
717
332
|
function tokenize(text) {
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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;
|
|
333
|
+
return text
|
|
334
|
+
.toLowerCase()
|
|
335
|
+
.replace(/[^a-záàâãäåæçéèêëíìîïñóòôõöúùûüýÿßœ0-9:_\-./]/g, ' ')
|
|
336
|
+
.split(/\s+/)
|
|
337
|
+
.filter(w => w.length > 1 && !STOPWORDS.has(w));
|
|
812
338
|
}
|
|
813
339
|
/**
|
|
814
340
|
* Split a multi-topic query into sub-queries.
|
|
@@ -1081,7 +607,7 @@ async function keywordSearch(redis, patterns, query, topK = 10) {
|
|
|
1081
607
|
return sorted.slice(0, topK);
|
|
1082
608
|
}
|
|
1083
609
|
// ── Exported for testing ──────────────────────────────────────────────────────
|
|
1084
|
-
export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS
|
|
610
|
+
export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS };
|
|
1085
611
|
// ── Connection pool ───────────────────────────────────────────────────────────
|
|
1086
612
|
/** Reuse Redis connections across tool calls (keyed by instance_id). */
|
|
1087
613
|
const pool = new Map();
|
|
@@ -1140,13 +666,27 @@ async function apiFetch(path, options = {}) {
|
|
|
1140
666
|
}
|
|
1141
667
|
const res = await fetch(`${API_URL}${path}`, {
|
|
1142
668
|
...options,
|
|
669
|
+
signal: AbortSignal.timeout(15000),
|
|
1143
670
|
headers: {
|
|
1144
671
|
'Content-Type': 'application/json',
|
|
1145
672
|
Authorization: `Bearer ${JWT}`,
|
|
1146
673
|
...options.headers,
|
|
1147
674
|
},
|
|
675
|
+
}).catch((err) => {
|
|
676
|
+
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
|
|
677
|
+
throw new McpError(ErrorCode.InternalError, `cachly API timed out after 15s — check your connection or try again.`);
|
|
678
|
+
}
|
|
679
|
+
throw new McpError(ErrorCode.InternalError, `cachly API unreachable: ${err.message}`);
|
|
1148
680
|
});
|
|
1149
681
|
if (!res.ok) {
|
|
682
|
+
if (res.status === 401 || res.status === 403) {
|
|
683
|
+
throw new McpError(ErrorCode.InvalidRequest, `cachly API: authentication failed (${res.status}) — your JWT may be expired or invalid.\n` +
|
|
684
|
+
`Get a new API token at https://cachly.dev/settings or run \`get_api_status\` to check token expiry.`);
|
|
685
|
+
}
|
|
686
|
+
if (res.status === 503 || res.status === 502) {
|
|
687
|
+
throw new McpError(ErrorCode.InternalError, `cachly API: auth service unavailable (${res.status}) — the authentication service may be temporarily down.\n` +
|
|
688
|
+
`Please try again in a moment or contact support@cachly.dev`);
|
|
689
|
+
}
|
|
1150
690
|
const body = await res.json().catch(() => ({ error: res.statusText }));
|
|
1151
691
|
throw new McpError(ErrorCode.InternalError, `cachly API error ${res.status}: ${body.error ?? res.statusText}`);
|
|
1152
692
|
}
|
|
@@ -1167,8 +707,8 @@ const TOOLS = [
|
|
|
1167
707
|
name: 'create_instance',
|
|
1168
708
|
description: 'Create a new managed Valkey/Redis cache instance on cachly.dev. ' +
|
|
1169
709
|
'Free tier provisions in ~30 seconds. Paid tiers return a Stripe checkout URL. ' +
|
|
1170
|
-
'Available tiers: free (
|
|
1171
|
-
'speed (
|
|
710
|
+
'Available tiers: free (30 MB), dev (256 MB, €8/mo), pro (1 GB, €25/mo), ' +
|
|
711
|
+
'speed (1 GB Dragonfly + Semantic Cache, €39/mo), business (8 GB, €99/mo).',
|
|
1172
712
|
inputSchema: {
|
|
1173
713
|
type: 'object',
|
|
1174
714
|
properties: {
|
|
@@ -1524,10 +1064,10 @@ const TOOLS = [
|
|
|
1524
1064
|
// ── Auth & API-Status ─────────────────────────────────────────────────────
|
|
1525
1065
|
{
|
|
1526
1066
|
name: 'get_api_status',
|
|
1527
|
-
description: 'Check the cachly API health and your authentication status. ' +
|
|
1528
|
-
'Returns whether the
|
|
1529
|
-
'token expiry, and the auth provider
|
|
1530
|
-
'Use this to debug connection issues
|
|
1067
|
+
description: 'Check the cachly API health, auth service reachability, and your authentication status. ' +
|
|
1068
|
+
'Returns API health, whether the Keycloak auth service is reachable, JWT validity, ' +
|
|
1069
|
+
'user ID (sub claim), token expiry, and the auth provider. ' +
|
|
1070
|
+
'Use this to debug connection issues, verify your CACHLY_JWT, or diagnose "Auth Service Unavailable" errors.',
|
|
1531
1071
|
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
1532
1072
|
},
|
|
1533
1073
|
// ── Thinking/Context Cache (for AI assistants) ────────────────────────────
|
|
@@ -1613,9 +1153,7 @@ const TOOLS = [
|
|
|
1613
1153
|
'Fields: topic (short slug like "deploy:web"), outcome ("success"|"failure"), ' +
|
|
1614
1154
|
'what_worked (what solved it), what_failed (what did NOT work), context (extra details). ' +
|
|
1615
1155
|
'Supports structured metadata: severity, file_paths (files involved), commands (working commands), tags. ' +
|
|
1616
|
-
'Deduplication: if a lesson for this topic already exists, it is updated
|
|
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. ' +
|
|
1156
|
+
'Deduplication: if a lesson for this topic already exists, it is updated instead of duplicated. ' +
|
|
1619
1157
|
'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"])',
|
|
1620
1158
|
inputSchema: {
|
|
1621
1159
|
type: 'object',
|
|
@@ -1646,15 +1184,6 @@ const TOOLS = [
|
|
|
1646
1184
|
items: { type: 'string' },
|
|
1647
1185
|
description: 'Topic tags for filtering (e.g. ["bash", "deploy", "env"])',
|
|
1648
1186
|
},
|
|
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
|
-
},
|
|
1658
1187
|
},
|
|
1659
1188
|
required: ['instance_id', 'topic', 'outcome', 'what_worked'],
|
|
1660
1189
|
},
|
|
@@ -1663,9 +1192,7 @@ const TOOLS = [
|
|
|
1663
1192
|
name: 'recall_best_solution',
|
|
1664
1193
|
description: 'Recall the best known solution for a topic from past lessons. ' +
|
|
1665
1194
|
'Call this BEFORE attempting any task that might have been done before. ' +
|
|
1666
|
-
'Returns the most recent successful lesson for the topic,
|
|
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). ' +
|
|
1195
|
+
'Returns the most recent successful lesson for the topic, or a summary of attempts. ' +
|
|
1669
1196
|
'Example: recall_best_solution(topic="deploy:web") → returns the working deploy command.',
|
|
1670
1197
|
inputSchema: {
|
|
1671
1198
|
type: 'object',
|
|
@@ -1676,6 +1203,33 @@ const TOOLS = [
|
|
|
1676
1203
|
required: ['instance_id', 'topic'],
|
|
1677
1204
|
},
|
|
1678
1205
|
},
|
|
1206
|
+
{
|
|
1207
|
+
name: 'list_lessons',
|
|
1208
|
+
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. ' +
|
|
1209
|
+
'Returns topics sorted by most recently stored, with severity and recall count.',
|
|
1210
|
+
inputSchema: {
|
|
1211
|
+
type: 'object',
|
|
1212
|
+
properties: {
|
|
1213
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1214
|
+
filter: { type: 'string', description: 'Optional substring filter, e.g. "deploy" returns only deploy-related topics' },
|
|
1215
|
+
},
|
|
1216
|
+
required: ['instance_id'],
|
|
1217
|
+
},
|
|
1218
|
+
},
|
|
1219
|
+
{
|
|
1220
|
+
name: 'forget_lesson',
|
|
1221
|
+
description: 'Delete a stored lesson from the Brain. Use this when a lesson is wrong, outdated, or was learned incorrectly. ' +
|
|
1222
|
+
'Removes both the best-solution entry and the attempt history for the topic. ' +
|
|
1223
|
+
'Example: forget_lesson(topic="deploy:web") → deletes the wrong deployment lesson so a correct one can be stored.',
|
|
1224
|
+
inputSchema: {
|
|
1225
|
+
type: 'object',
|
|
1226
|
+
properties: {
|
|
1227
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1228
|
+
topic: { type: 'string', description: 'Exact topic slug to delete, e.g. "deploy:web"' },
|
|
1229
|
+
},
|
|
1230
|
+
required: ['instance_id', 'topic'],
|
|
1231
|
+
},
|
|
1232
|
+
},
|
|
1679
1233
|
{
|
|
1680
1234
|
name: 'smart_recall',
|
|
1681
1235
|
description: 'Semantically search cached context using natural language. ' +
|
|
@@ -1696,8 +1250,7 @@ const TOOLS = [
|
|
|
1696
1250
|
name: 'session_start',
|
|
1697
1251
|
description: 'Single-call session briefing. Call this at the START of every session INSTEAD of multiple separate smart_recall/recall_best_solution calls. ' +
|
|
1698
1252
|
'Returns: last session summary, recent lessons sorted by recency, relevant lessons for your focus area, ' +
|
|
1699
|
-
'open failures (topics with only failure outcomes), brain health stats
|
|
1700
|
-
'predictive pre-warnings (if your focus area has known failure patterns), and memory crystals (compressed wisdom from old sessions). ' +
|
|
1253
|
+
'open failures (topics with only failure outcomes), and brain health stats (lesson count, context count). ' +
|
|
1701
1254
|
'Also saves a session start marker so session_end can compute duration.',
|
|
1702
1255
|
inputSchema: {
|
|
1703
1256
|
type: 'object',
|
|
@@ -1707,10 +1260,6 @@ const TOOLS = [
|
|
|
1707
1260
|
type: 'string',
|
|
1708
1261
|
description: 'Keywords for what you plan to work on today (e.g. "deploy infra api"). Used to surface relevant lessons at the top.',
|
|
1709
1262
|
},
|
|
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
1263
|
},
|
|
1715
1264
|
required: ['instance_id'],
|
|
1716
1265
|
},
|
|
@@ -1720,8 +1269,7 @@ const TOOLS = [
|
|
|
1720
1269
|
description: 'Save a session summary when you finish working. ' +
|
|
1721
1270
|
'Records what was accomplished, files changed, and lesson count. ' +
|
|
1722
1271
|
'The next session_start will show this summary as "Last session". ' +
|
|
1723
|
-
'Call this when ending a work session, before going idle, or before summarizing.
|
|
1724
|
-
'Ambient Learning: if workspace_path is provided, reads git log since session start and auto-learns from commits.',
|
|
1272
|
+
'Call this when ending a work session, before going idle, or before summarizing.',
|
|
1725
1273
|
inputSchema: {
|
|
1726
1274
|
type: 'object',
|
|
1727
1275
|
properties: {
|
|
@@ -1739,66 +1287,10 @@ const TOOLS = [
|
|
|
1739
1287
|
type: 'number',
|
|
1740
1288
|
description: 'Number of new lessons stored this session (optional)',
|
|
1741
1289
|
},
|
|
1742
|
-
workspace_path: {
|
|
1743
|
-
type: 'string',
|
|
1744
|
-
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.',
|
|
1745
|
-
},
|
|
1746
1290
|
},
|
|
1747
1291
|
required: ['instance_id', 'summary'],
|
|
1748
1292
|
},
|
|
1749
1293
|
},
|
|
1750
|
-
// ── Session Handoff — cross-window continuity ─────────────────────────────
|
|
1751
|
-
{
|
|
1752
|
-
name: 'session_handoff',
|
|
1753
|
-
description: 'Save a detailed handoff for the NEXT chat window / session. ' +
|
|
1754
|
-
'Stores: current progress, TODO list (done + remaining), changed files with descriptions, ' +
|
|
1755
|
-
'instructions for the next assistant, and any incomplete work. ' +
|
|
1756
|
-
'The next session_start automatically includes this handoff so the new window knows EXACTLY what happened and what remains. ' +
|
|
1757
|
-
'Call this BEFORE closing a chat window, especially if work is incomplete. ' +
|
|
1758
|
-
'This prevents the "continue" problem where new windows lose context, skip tasks, or produce broken code.',
|
|
1759
|
-
inputSchema: {
|
|
1760
|
-
type: 'object',
|
|
1761
|
-
properties: {
|
|
1762
|
-
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1763
|
-
completed_tasks: {
|
|
1764
|
-
type: 'array',
|
|
1765
|
-
items: { type: 'string' },
|
|
1766
|
-
description: 'Tasks that were fully completed (e.g. "Implemented brainSearch() in JS SDK")',
|
|
1767
|
-
},
|
|
1768
|
-
remaining_tasks: {
|
|
1769
|
-
type: 'array',
|
|
1770
|
-
items: { type: 'string' },
|
|
1771
|
-
description: 'Tasks NOT yet done — the next window MUST pick these up',
|
|
1772
|
-
},
|
|
1773
|
-
files_changed: {
|
|
1774
|
-
type: 'array',
|
|
1775
|
-
items: {
|
|
1776
|
-
type: 'object',
|
|
1777
|
-
properties: {
|
|
1778
|
-
path: { type: 'string', description: 'File path relative to project root' },
|
|
1779
|
-
status: { type: 'string', enum: ['complete', 'partial', 'broken'], description: 'State of this file' },
|
|
1780
|
-
description: { type: 'string', description: 'What was changed and what still needs work' },
|
|
1781
|
-
},
|
|
1782
|
-
required: ['path', 'status'],
|
|
1783
|
-
},
|
|
1784
|
-
description: 'Changed files with their current state — marks partial/broken files so next window knows to fix them',
|
|
1785
|
-
},
|
|
1786
|
-
instructions: {
|
|
1787
|
-
type: 'string',
|
|
1788
|
-
description: 'Free-form instructions for the next assistant. Be specific: what to do next, what to avoid, what broke.',
|
|
1789
|
-
},
|
|
1790
|
-
context_summary: {
|
|
1791
|
-
type: 'string',
|
|
1792
|
-
description: 'Brief summary of what happened this session (architecture decisions, key findings, blockers)',
|
|
1793
|
-
},
|
|
1794
|
-
blocked_on: {
|
|
1795
|
-
type: 'string',
|
|
1796
|
-
description: 'If work is blocked, describe what is needed to unblock (e.g. "waiting for API deploy", "needs user input on design")',
|
|
1797
|
-
},
|
|
1798
|
-
},
|
|
1799
|
-
required: ['instance_id', 'completed_tasks', 'remaining_tasks'],
|
|
1800
|
-
},
|
|
1801
|
-
},
|
|
1802
1294
|
// ── AI Brain — Extended features ─────────────────────────────────────────
|
|
1803
1295
|
{
|
|
1804
1296
|
name: 'auto_learn_session',
|
|
@@ -1885,147 +1377,15 @@ const TOOLS = [
|
|
|
1885
1377
|
required: ['instance_id'],
|
|
1886
1378
|
},
|
|
1887
1379
|
},
|
|
1888
|
-
{
|
|
1889
|
-
name: 'team_synthesize',
|
|
1890
|
-
description: 'Team Brain Synthesis — merge multiple contributors\' lessons on the same topic into one canonical version. ' +
|
|
1891
|
-
'When 2+ developers store lessons for the same topic with different details, this proposes the best merged version. ' +
|
|
1892
|
-
'Shows: all contributions by author, what worked (consensus), what failed (union), canonical lesson to store. ' +
|
|
1893
|
-
'Use this when onboarding new team members or before documenting a process.',
|
|
1894
|
-
inputSchema: {
|
|
1895
|
-
type: 'object',
|
|
1896
|
-
properties: {
|
|
1897
|
-
instance_id: { type: 'string', description: 'UUID of the shared team brain instance' },
|
|
1898
|
-
topic: { type: 'string', description: 'Topic slug to synthesize (e.g. "deploy:api")' },
|
|
1899
|
-
},
|
|
1900
|
-
required: ['instance_id', 'topic'],
|
|
1901
|
-
},
|
|
1902
|
-
},
|
|
1903
|
-
{
|
|
1904
|
-
name: 'memory_crystalize',
|
|
1905
|
-
description: 'Compress the last 30-50 sessions and auto-learned lessons into a dense Memory Crystal. ' +
|
|
1906
|
-
'A crystal is a compact, structured summary of everything the brain learned — grouped by category (deploy, fix, debug, …). ' +
|
|
1907
|
-
'Crystals survive session cleanup and appear in session_start once enough sessions have accumulated. ' +
|
|
1908
|
-
'Run this monthly or after a big milestone to preserve institutional knowledge. ' +
|
|
1909
|
-
'Returns a digest of what was crystallized.',
|
|
1910
|
-
inputSchema: {
|
|
1911
|
-
type: 'object',
|
|
1912
|
-
properties: {
|
|
1913
|
-
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1914
|
-
label: {
|
|
1915
|
-
type: 'string',
|
|
1916
|
-
description: 'Optional label for this crystal (e.g. "Q1 2026", "v2 launch"). Auto-generated from date if omitted.',
|
|
1917
|
-
},
|
|
1918
|
-
},
|
|
1919
|
-
required: ['instance_id'],
|
|
1920
|
-
},
|
|
1921
|
-
},
|
|
1922
|
-
// ── Roadmap — Persistent project plan tracker ───────────────────────────
|
|
1923
|
-
{
|
|
1924
|
-
name: 'roadmap_add',
|
|
1925
|
-
description: 'Add a new item to the persistent project roadmap stored in the Brain. ' +
|
|
1926
|
-
'Items survive across sessions and editors — the roadmap is always up to date. ' +
|
|
1927
|
-
'Use for features, bugs, refactors, or any planned work. ' +
|
|
1928
|
-
'Call roadmap_list to see all open items, roadmap_next to get the next actionable item.',
|
|
1929
|
-
inputSchema: {
|
|
1930
|
-
type: 'object',
|
|
1931
|
-
properties: {
|
|
1932
|
-
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1933
|
-
title: { type: 'string', description: 'Short title of the task/feature (3–10 words)' },
|
|
1934
|
-
description: { type: 'string', description: 'What needs to be done, acceptance criteria, context' },
|
|
1935
|
-
priority: {
|
|
1936
|
-
type: 'string',
|
|
1937
|
-
enum: ['critical', 'high', 'medium', 'low'],
|
|
1938
|
-
description: 'Priority level (default: medium)',
|
|
1939
|
-
},
|
|
1940
|
-
tags: {
|
|
1941
|
-
type: 'array',
|
|
1942
|
-
items: { type: 'string' },
|
|
1943
|
-
description: 'Tags for filtering (e.g. ["api", "web", "sdk", "infra"])',
|
|
1944
|
-
},
|
|
1945
|
-
milestone: { type: 'string', description: 'Milestone/epic this belongs to (optional)' },
|
|
1946
|
-
},
|
|
1947
|
-
required: ['instance_id', 'title'],
|
|
1948
|
-
},
|
|
1949
|
-
},
|
|
1950
|
-
{
|
|
1951
|
-
name: 'roadmap_update',
|
|
1952
|
-
description: 'Update the status, priority, or details of a roadmap item. ' +
|
|
1953
|
-
'Use to move items through the lifecycle: planned → in-progress → done (or blocked/cancelled). ' +
|
|
1954
|
-
'Also use to add notes/findings while working on an item.',
|
|
1955
|
-
inputSchema: {
|
|
1956
|
-
type: 'object',
|
|
1957
|
-
properties: {
|
|
1958
|
-
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1959
|
-
id: { type: 'string', description: 'Item ID returned by roadmap_add or roadmap_list' },
|
|
1960
|
-
status: {
|
|
1961
|
-
type: 'string',
|
|
1962
|
-
enum: ['planned', 'in-progress', 'done', 'blocked', 'cancelled'],
|
|
1963
|
-
description: 'New status for the item',
|
|
1964
|
-
},
|
|
1965
|
-
priority: {
|
|
1966
|
-
type: 'string',
|
|
1967
|
-
enum: ['critical', 'high', 'medium', 'low'],
|
|
1968
|
-
description: 'Updated priority (optional)',
|
|
1969
|
-
},
|
|
1970
|
-
notes: { type: 'string', description: 'Progress notes, findings, or blockers (appended to existing notes)' },
|
|
1971
|
-
title: { type: 'string', description: 'Updated title (optional)' },
|
|
1972
|
-
description: { type: 'string', description: 'Updated description (optional)' },
|
|
1973
|
-
},
|
|
1974
|
-
required: ['instance_id', 'id'],
|
|
1975
|
-
},
|
|
1976
|
-
},
|
|
1977
|
-
{
|
|
1978
|
-
name: 'roadmap_list',
|
|
1979
|
-
description: 'List all roadmap items, optionally filtered by status, priority, tag, or milestone. ' +
|
|
1980
|
-
'Returns items sorted by priority then creation date. ' +
|
|
1981
|
-
'Called automatically by session_start to show open work.',
|
|
1982
|
-
inputSchema: {
|
|
1983
|
-
type: 'object',
|
|
1984
|
-
properties: {
|
|
1985
|
-
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1986
|
-
status: {
|
|
1987
|
-
type: 'string',
|
|
1988
|
-
enum: ['planned', 'in-progress', 'done', 'blocked', 'cancelled', 'open'],
|
|
1989
|
-
description: 'Filter by status. Use \'open\' to see planned+in-progress+blocked (default: open)',
|
|
1990
|
-
},
|
|
1991
|
-
tag: { type: 'string', description: 'Filter by tag (optional)' },
|
|
1992
|
-
milestone: { type: 'string', description: 'Filter by milestone (optional)' },
|
|
1993
|
-
priority: {
|
|
1994
|
-
type: 'string',
|
|
1995
|
-
enum: ['critical', 'high', 'medium', 'low'],
|
|
1996
|
-
description: 'Filter by minimum priority (optional)',
|
|
1997
|
-
},
|
|
1998
|
-
},
|
|
1999
|
-
required: ['instance_id'],
|
|
2000
|
-
},
|
|
2001
|
-
},
|
|
2002
|
-
{
|
|
2003
|
-
name: 'roadmap_next',
|
|
2004
|
-
description: 'Get the single most important next actionable roadmap item. ' +
|
|
2005
|
-
'Returns the highest-priority in-progress item first, then planned items, sorted by priority. ' +
|
|
2006
|
-
'Call at session start to immediately know what to work on next.',
|
|
2007
|
-
inputSchema: {
|
|
2008
|
-
type: 'object',
|
|
2009
|
-
properties: {
|
|
2010
|
-
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
2011
|
-
tag: { type: 'string', description: 'Filter by tag (optional)' },
|
|
2012
|
-
},
|
|
2013
|
-
required: ['instance_id'],
|
|
2014
|
-
},
|
|
2015
|
-
},
|
|
2016
1380
|
{
|
|
2017
1381
|
name: 'brain_doctor',
|
|
2018
1382
|
description: 'Check the health of your AI Brain and get actionable recommendations. ' +
|
|
2019
|
-
'Reports: lesson count, context entries, last session age, open failures, quality score
|
|
1383
|
+
'Reports: lesson count, context entries, last session age, open failures, quality score. ' +
|
|
2020
1384
|
'Returns a prioritized list of issues with fix instructions.',
|
|
2021
1385
|
inputSchema: {
|
|
2022
1386
|
type: 'object',
|
|
2023
1387
|
properties: {
|
|
2024
1388
|
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
2025
|
-
workspace_path: {
|
|
2026
|
-
type: 'string',
|
|
2027
|
-
description: 'Absolute path to workspace root — enables package.json analysis for openclaw cross-promo (optional)',
|
|
2028
|
-
},
|
|
2029
1389
|
},
|
|
2030
1390
|
required: ['instance_id'],
|
|
2031
1391
|
},
|
|
@@ -2093,94 +1453,6 @@ const TOOLS = [
|
|
|
2093
1453
|
required: ['instance_id', 'framework'],
|
|
2094
1454
|
},
|
|
2095
1455
|
},
|
|
2096
|
-
// ── Brain Archaeology + Causal Chain ────────────────────────────────────
|
|
2097
|
-
{
|
|
2098
|
-
name: 'recall_at',
|
|
2099
|
-
description: 'Brain Archaeology — see what a lesson looked like at a specific point in time. ' +
|
|
2100
|
-
'"What did we know about deployments 3 months ago?" ' +
|
|
2101
|
-
'Returns the history of a topic filtered to entries before the given date. ' +
|
|
2102
|
-
'Shows how the lesson evolved: failure → partial → success. ' +
|
|
2103
|
-
'Also useful to understand WHY old code decisions were made.',
|
|
2104
|
-
inputSchema: {
|
|
2105
|
-
type: 'object',
|
|
2106
|
-
properties: {
|
|
2107
|
-
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
2108
|
-
topic: { type: 'string', description: 'Topic slug to look up, e.g. "deploy:api"' },
|
|
2109
|
-
date: { type: 'string', description: 'ISO date string (e.g. "2026-01-15") — returns entries stored BEFORE this date' },
|
|
2110
|
-
},
|
|
2111
|
-
required: ['instance_id', 'topic', 'date'],
|
|
2112
|
-
},
|
|
2113
|
-
},
|
|
2114
|
-
{
|
|
2115
|
-
name: 'trace_dependency',
|
|
2116
|
-
description: 'Causal Chain — find all lessons that depend on a given prerequisite. ' +
|
|
2117
|
-
'"What lessons are affected if node version changes?" ' +
|
|
2118
|
-
'When a dependency changes (new version, different provider, new OS), call this to see which lessons need review. ' +
|
|
2119
|
-
'Lessons store dependencies via the depends_on field in learn_from_attempts.',
|
|
2120
|
-
inputSchema: {
|
|
2121
|
-
type: 'object',
|
|
2122
|
-
properties: {
|
|
2123
|
-
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
2124
|
-
dependency: { type: 'string', description: 'Dependency to trace (e.g. "node:>=20", "docker:running", "wireguard:active")' },
|
|
2125
|
-
mark_review: { type: 'boolean', description: 'If true, marks all dependent lessons as needs_review (default: false)' },
|
|
2126
|
-
},
|
|
2127
|
-
required: ['instance_id', 'dependency'],
|
|
2128
|
-
},
|
|
2129
|
-
},
|
|
2130
|
-
// ── Team / Org Management ────────────────────────────────────────────────
|
|
2131
|
-
{
|
|
2132
|
-
name: 'list_orgs',
|
|
2133
|
-
description: 'List your Cachly organizations (team/org plans). ' +
|
|
2134
|
-
'Returns each org with plan, seat count, and member info. ' +
|
|
2135
|
-
'Org plans (Team €99, Business €299, Enterprise custom) are billed separately from cache tiers.',
|
|
2136
|
-
inputSchema: {
|
|
2137
|
-
type: 'object',
|
|
2138
|
-
properties: {},
|
|
2139
|
-
required: [],
|
|
2140
|
-
},
|
|
2141
|
-
},
|
|
2142
|
-
{
|
|
2143
|
-
name: 'create_org',
|
|
2144
|
-
description: 'Create a new Cachly organization for team collaboration. ' +
|
|
2145
|
-
'After creation, invite team members with invite_member and upgrade the plan via the billing portal. ' +
|
|
2146
|
-
'Org plans: Team (€99/mo, 10 seats), Business (€299/mo, 50 seats), Enterprise (custom).',
|
|
2147
|
-
inputSchema: {
|
|
2148
|
-
type: 'object',
|
|
2149
|
-
properties: {
|
|
2150
|
-
name: { type: 'string', description: 'Organization display name (e.g. "Acme Engineering")' },
|
|
2151
|
-
slug: { type: 'string', description: 'URL-safe slug (e.g. "acme-eng"). Auto-generated from name if omitted.' },
|
|
2152
|
-
},
|
|
2153
|
-
required: ['name'],
|
|
2154
|
-
},
|
|
2155
|
-
},
|
|
2156
|
-
{
|
|
2157
|
-
name: 'invite_member',
|
|
2158
|
-
description: 'Invite a team member to a Cachly organization by email. ' +
|
|
2159
|
-
'They will receive an invite email and can join via the dashboard. ' +
|
|
2160
|
-
'Roles: owner (full access), admin (manage members + instances), member (read + cache ops).',
|
|
2161
|
-
inputSchema: {
|
|
2162
|
-
type: 'object',
|
|
2163
|
-
properties: {
|
|
2164
|
-
org_id: { type: 'string', description: 'UUID of the organization' },
|
|
2165
|
-
email: { type: 'string', description: 'Email address to invite' },
|
|
2166
|
-
role: { type: 'string', enum: ['admin', 'member'], description: 'Role for the invited member (default: member)' },
|
|
2167
|
-
},
|
|
2168
|
-
required: ['org_id', 'email'],
|
|
2169
|
-
},
|
|
2170
|
-
},
|
|
2171
|
-
{
|
|
2172
|
-
name: 'get_org_plan',
|
|
2173
|
-
description: 'Get the current org plan, seat usage, and billing info for an organization. ' +
|
|
2174
|
-
'Shows: plan name, price, seats used/max, next billing date. ' +
|
|
2175
|
-
'To upgrade: use the billing portal URL returned by this tool.',
|
|
2176
|
-
inputSchema: {
|
|
2177
|
-
type: 'object',
|
|
2178
|
-
properties: {
|
|
2179
|
-
org_id: { type: 'string', description: 'UUID of the organization' },
|
|
2180
|
-
},
|
|
2181
|
-
required: ['org_id'],
|
|
2182
|
-
},
|
|
2183
|
-
},
|
|
2184
1456
|
// ── Legacy / Setup ────────────────────────────────────────────────────────
|
|
2185
1457
|
{
|
|
2186
1458
|
name: 'setup_ai_memory',
|
|
@@ -2271,52 +1543,10 @@ function buildConnectionString(inst) {
|
|
|
2271
1543
|
const pw = inst.password ? `:${inst.password}@` : '@';
|
|
2272
1544
|
return `${scheme}://${pw}${inst.host}:${inst.port}`;
|
|
2273
1545
|
}
|
|
2274
|
-
// Fires once per process when no JWT is set (anonymous, opt-out via CACHLY_NO_TELEMETRY=1)
|
|
2275
|
-
let _telemetryPingSent = false;
|
|
2276
|
-
async function sendAnonymousTelemetry(toolName) {
|
|
2277
|
-
if (_telemetryPingSent)
|
|
2278
|
-
return;
|
|
2279
|
-
if (process.env.CACHLY_NO_TELEMETRY === '1')
|
|
2280
|
-
return;
|
|
2281
|
-
_telemetryPingSent = true;
|
|
2282
|
-
// Detect editor from common env vars injected by IDE extensions
|
|
2283
|
-
const editor = process.env.CURSOR_TRACE_ID ? 'cursor'
|
|
2284
|
-
: process.env.WINDSURF_SESSION_ID ? 'windsurf'
|
|
2285
|
-
: process.env.GITHUB_COPILOT_WORKSPACE ? 'copilot'
|
|
2286
|
-
: process.env.CLAUDE_CODE_ENTRYPOINT ? 'claude'
|
|
2287
|
-
: 'unknown';
|
|
2288
|
-
try {
|
|
2289
|
-
await fetch(`${API_URL}/api/v1/telemetry/mcp`, {
|
|
2290
|
-
method: 'POST',
|
|
2291
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2292
|
-
body: JSON.stringify({ event: 'first_call_no_jwt', version: CURRENT_VERSION, editor, tool: toolName }),
|
|
2293
|
-
signal: AbortSignal.timeout(3000),
|
|
2294
|
-
});
|
|
2295
|
-
}
|
|
2296
|
-
catch { /* fire-and-forget, never block the user */ }
|
|
2297
|
-
}
|
|
2298
1546
|
async function handleTool(name, args) {
|
|
2299
|
-
//
|
|
2300
|
-
if (!
|
|
2301
|
-
|
|
2302
|
-
return [
|
|
2303
|
-
'🧠 **cachly AI Brain — Setup required**',
|
|
2304
|
-
'',
|
|
2305
|
-
'You have no API credentials configured. Get started in 30 seconds:',
|
|
2306
|
-
'',
|
|
2307
|
-
'**Step 1:** Register for free (no credit card):',
|
|
2308
|
-
' 👉 https://cachly.dev/setup-ai',
|
|
2309
|
-
'',
|
|
2310
|
-
'**Step 2:** Run the setup wizard in your terminal:',
|
|
2311
|
-
' ```',
|
|
2312
|
-
' npx @cachly-dev/mcp-server@latest setup',
|
|
2313
|
-
' ```',
|
|
2314
|
-
'',
|
|
2315
|
-
'The wizard auto-detects your editor (Claude Code, Cursor, Copilot, Windsurf)',
|
|
2316
|
-
'and writes the config file with your credentials.',
|
|
2317
|
-
'',
|
|
2318
|
-
'✨ Free tier includes: 1 Brain instance, persistent memory, semantic search.',
|
|
2319
|
-
].join('\n');
|
|
1547
|
+
// Default instance_id from env so tools work even if AI omits it
|
|
1548
|
+
if (!args.instance_id && process.env.CACHLY_BRAIN_INSTANCE_ID) {
|
|
1549
|
+
args = { ...args, instance_id: process.env.CACHLY_BRAIN_INSTANCE_ID };
|
|
2320
1550
|
}
|
|
2321
1551
|
// Delegate v0.2 bulk/lock/stream tools first
|
|
2322
1552
|
const bulkResult = await handleBulkLockStream(name, args);
|
|
@@ -2335,7 +1565,7 @@ async function handleTool(name, args) {
|
|
|
2335
1565
|
const { name: instName, tier } = args;
|
|
2336
1566
|
const res = await apiFetch('/api/v1/instances', {
|
|
2337
1567
|
method: 'POST',
|
|
2338
|
-
body: JSON.stringify({ name: instName, tier
|
|
1568
|
+
body: JSON.stringify({ name: instName, tier }),
|
|
2339
1569
|
});
|
|
2340
1570
|
if (res.checkout_url) {
|
|
2341
1571
|
return [
|
|
@@ -2392,63 +1622,6 @@ async function handleTool(name, args) {
|
|
|
2392
1622
|
await apiFetch(`/api/v1/instances/${instance_id}`, { method: 'DELETE' });
|
|
2393
1623
|
return `✅ Instance \`${instance_id}\` has been deleted and all data removed.`;
|
|
2394
1624
|
}
|
|
2395
|
-
// ── Org / Team management ────────────────────────────────────────────
|
|
2396
|
-
case 'list_orgs': {
|
|
2397
|
-
const res = await apiFetch('/api/v1/orgs');
|
|
2398
|
-
const orgs = res.orgs ?? [];
|
|
2399
|
-
if (orgs.length === 0)
|
|
2400
|
-
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.`;
|
|
2401
|
-
return [
|
|
2402
|
-
`🏢 **Your organizations (${orgs.length})**\n`,
|
|
2403
|
-
...orgs.map(o => `• **${o.name}** (\`${o.slug}\`) — plan: ${o.plan} · seats: ${o.member_count ?? '?'}/${o.max_members}\n ID: \`${o.id}\``),
|
|
2404
|
-
`\n_Manage: \`get_org_plan\`, \`invite_member\`, dashboard → /team_`,
|
|
2405
|
-
].join('\n');
|
|
2406
|
-
}
|
|
2407
|
-
case 'create_org': {
|
|
2408
|
-
const { name: orgName, slug } = args;
|
|
2409
|
-
const body = { name: orgName };
|
|
2410
|
-
if (slug)
|
|
2411
|
-
body.slug = slug;
|
|
2412
|
-
const res = await apiFetch('/api/v1/orgs', {
|
|
2413
|
-
method: 'POST',
|
|
2414
|
-
body: JSON.stringify(body),
|
|
2415
|
-
});
|
|
2416
|
-
return [
|
|
2417
|
-
`✅ **Organization created:** ${res.name}`,
|
|
2418
|
-
` ID: \`${res.id}\` · Slug: \`${res.slug}\` · Plan: ${res.plan}`,
|
|
2419
|
-
``,
|
|
2420
|
-
`**Next steps:**`,
|
|
2421
|
-
`1. Invite team members: \`invite_member(org_id="${res.id}", email="dev@example.com")\``,
|
|
2422
|
-
`2. Upgrade plan: open billing portal via dashboard → /team`,
|
|
2423
|
-
` Team: €99/mo (10 seats) · Business: €299/mo (50 seats)`,
|
|
2424
|
-
].join('\n');
|
|
2425
|
-
}
|
|
2426
|
-
case 'invite_member': {
|
|
2427
|
-
const { org_id, email, role = 'member' } = args;
|
|
2428
|
-
await apiFetch(`/api/v1/orgs/${org_id}/members`, {
|
|
2429
|
-
method: 'POST',
|
|
2430
|
-
body: JSON.stringify({ email, role }),
|
|
2431
|
-
});
|
|
2432
|
-
return `✅ Invite sent to **${email}** as \`${role}\` in org \`${org_id}\`.\n\nThey will receive an email to join the organization.`;
|
|
2433
|
-
}
|
|
2434
|
-
case 'get_org_plan': {
|
|
2435
|
-
const { org_id } = args;
|
|
2436
|
-
const org = await apiFetch(`/api/v1/orgs/${org_id}`);
|
|
2437
|
-
const accepted = (org.members ?? []).filter(m => m.accepted_at).length;
|
|
2438
|
-
const pending = (org.members ?? []).filter(m => !m.accepted_at).length;
|
|
2439
|
-
const planPrice = { free: '€0', team: '€99/mo', business: '€299/mo', enterprise: 'custom' };
|
|
2440
|
-
return [
|
|
2441
|
-
`🏢 **${org.name}** — Plan: **${org.plan}** (${planPrice[org.plan] ?? org.plan})`,
|
|
2442
|
-
` Seats: ${accepted} active + ${pending} pending / ${org.max_members} max`,
|
|
2443
|
-
``,
|
|
2444
|
-
`**Members:**`,
|
|
2445
|
-
...(org.members ?? []).map(m => ` • ${m.invite_email} (${m.role})${m.accepted_at ? '' : ' — pending'}`),
|
|
2446
|
-
``,
|
|
2447
|
-
org.stripe_customer_id
|
|
2448
|
-
? `💳 Billing: managed via Stripe. Upgrade/cancel: dashboard → /billing`
|
|
2449
|
-
: `💳 No payment method yet. Upgrade: dashboard → /billing → Team Plans`,
|
|
2450
|
-
].join('\n');
|
|
2451
|
-
}
|
|
2452
1625
|
// ── Live cache operations ────────────────────────────────────────────
|
|
2453
1626
|
case 'cache_get': {
|
|
2454
1627
|
const { instance_id, key } = args;
|
|
@@ -2771,42 +1944,24 @@ async function handleTool(name, args) {
|
|
|
2771
1944
|
let skipped = 0;
|
|
2772
1945
|
let errors = 0;
|
|
2773
1946
|
let semanticIndexed = 0;
|
|
2774
|
-
let unchanged = 0;
|
|
2775
1947
|
const details = [];
|
|
2776
1948
|
const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
|
|
2777
1949
|
const redis = await getConnection(instance_id);
|
|
2778
1950
|
for (const filePath of files) {
|
|
2779
1951
|
const relPath = relative(dir, filePath);
|
|
2780
1952
|
let content;
|
|
2781
|
-
let fileSize;
|
|
2782
1953
|
try {
|
|
2783
1954
|
const s = await stat(filePath);
|
|
2784
1955
|
if (s.size > 200_000) {
|
|
2785
1956
|
skipped++;
|
|
2786
1957
|
continue;
|
|
2787
1958
|
} // skip files >200 KB
|
|
2788
|
-
fileSize = s.size;
|
|
2789
1959
|
content = await readFile(filePath, 'utf-8');
|
|
2790
1960
|
}
|
|
2791
1961
|
catch {
|
|
2792
1962
|
errors++;
|
|
2793
1963
|
continue;
|
|
2794
1964
|
}
|
|
2795
|
-
// ── Smart invalidation: hash-based change detection ──
|
|
2796
|
-
// Compute a simple hash of file content to skip unchanged files
|
|
2797
|
-
const hashKey = `cachly:idx:hash:${relPath}`;
|
|
2798
|
-
const contentHash = `${fileSize}:${content.length}:${simpleHash(content)}`;
|
|
2799
|
-
const existingHash = await redis.get(hashKey);
|
|
2800
|
-
if (existingHash === contentHash) {
|
|
2801
|
-
// File unchanged — refresh TTL but skip re-indexing
|
|
2802
|
-
const idxKey = `cachly:idx:${relPath}`;
|
|
2803
|
-
if (ttl > 0)
|
|
2804
|
-
await redis.expire(idxKey, ttl);
|
|
2805
|
-
if (ttl > 0)
|
|
2806
|
-
await redis.expire(hashKey, ttl);
|
|
2807
|
-
unchanged++;
|
|
2808
|
-
continue;
|
|
2809
|
-
}
|
|
2810
1965
|
const excerpt = content.slice(0, summary_chars).replace(/\s+/g, ' ').trim();
|
|
2811
1966
|
// ── Layer 1: Keyword index in Valkey (always works, no embedding needed) ──
|
|
2812
1967
|
const idxKey = `cachly:idx:${relPath}`;
|
|
@@ -2817,13 +1972,6 @@ async function handleTool(name, args) {
|
|
|
2817
1972
|
else {
|
|
2818
1973
|
await redis.set(idxKey, idxValue);
|
|
2819
1974
|
}
|
|
2820
|
-
// Store content hash for smart invalidation on next run
|
|
2821
|
-
if (ttl > 0) {
|
|
2822
|
-
await redis.set(hashKey, contentHash, 'EX', ttl);
|
|
2823
|
-
}
|
|
2824
|
-
else {
|
|
2825
|
-
await redis.set(hashKey, contentHash);
|
|
2826
|
-
}
|
|
2827
1975
|
indexed++;
|
|
2828
1976
|
details.push(` ✅ ${relPath}`);
|
|
2829
1977
|
// ── Layer 2: Semantic vector index (optional, only if embedding available) ──
|
|
@@ -2850,8 +1998,7 @@ async function handleTool(name, args) {
|
|
|
2850
1998
|
`📂 **index_project Complete** — ${mode}`,
|
|
2851
1999
|
``,
|
|
2852
2000
|
` 📁 Dir: ${dir}`,
|
|
2853
|
-
` ✅ Indexed: **${indexed}** files (
|
|
2854
|
-
` ♻️ Unchanged: ${unchanged} files (hash match — skipped)`,
|
|
2001
|
+
` ✅ Indexed: **${indexed}** files (keyword-searchable via smart_recall)`,
|
|
2855
2002
|
...(canEmbed ? [` 🎯 Semantic: **${semanticIndexed}** files (vector-searchable)`] : []),
|
|
2856
2003
|
` ⏭️ Skipped: ${skipped} (too large or filtered)`,
|
|
2857
2004
|
` ❌ Errors: ${errors}`,
|
|
@@ -3033,92 +2180,24 @@ async function handleTool(name, args) {
|
|
|
3033
2180
|
return `🗑️ **Forgot ${deleted} context entries.**\n\nKeys: ${keys.map(k => `\`${k}\``).join(', ')}`;
|
|
3034
2181
|
}
|
|
3035
2182
|
case 'learn_from_attempts': {
|
|
3036
|
-
const { instance_id, topic, outcome, what_worked, what_failed = '', context: ctx = '', severity = 'major', file_paths = [], commands = [], tags = [],
|
|
2183
|
+
const { instance_id, topic, outcome, what_worked, what_failed = '', context: ctx = '', severity = 'major', file_paths = [], commands = [], tags = [], } = args;
|
|
3037
2184
|
const redis = await getConnection(instance_id);
|
|
3038
2185
|
const ts = new Date().toISOString();
|
|
3039
|
-
// ──
|
|
3040
|
-
const category = topic.split(':')[0];
|
|
3041
|
-
const template = STRUCTURED_TEMPLATES[category];
|
|
3042
|
-
const templateWarnings = [];
|
|
3043
|
-
if (template) {
|
|
3044
|
-
for (const req of template.required) {
|
|
3045
|
-
if (req === 'commands' && commands.length === 0) {
|
|
3046
|
-
templateWarnings.push(`📋 ${template.hint}`);
|
|
3047
|
-
}
|
|
3048
|
-
}
|
|
3049
|
-
}
|
|
3050
|
-
// ── Deduplication + audit trail ────────────────────────────────────────
|
|
2186
|
+
// ── Deduplication: check for existing lesson with same topic ─────────────
|
|
3051
2187
|
let isUpdate = false;
|
|
3052
2188
|
let recallCount = 0;
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
auditTrail.push({ ts, action: 'updated', prev_outcome: prev.outcome });
|
|
3061
|
-
if (auditTrail.length > 20)
|
|
3062
|
-
auditTrail = auditTrail.slice(-20);
|
|
3063
|
-
isUpdate = true;
|
|
3064
|
-
// ── Contradiction detection ─────────────────────────────────────────
|
|
3065
|
-
const contradictionWarning = [];
|
|
3066
|
-
if (prev.outcome === 'success' && outcome === 'failure') {
|
|
3067
|
-
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.`);
|
|
3068
|
-
}
|
|
3069
|
-
else if (prev.outcome === 'failure' && outcome === 'success') {
|
|
3070
|
-
contradictionWarning.push(`✅ **Conflict resolved!** Previous lesson was \`failure\` — now overwriting with \`success\`.`);
|
|
3071
|
-
}
|
|
3072
|
-
if (contradictionWarning.length > 0) {
|
|
3073
|
-
// Store contradiction audit but don't block
|
|
3074
|
-
auditTrail[auditTrail.length - 1].action = 'contradiction-resolved';
|
|
2189
|
+
if (outcome === 'success') {
|
|
2190
|
+
const existing = await redis.get(`cachly:lesson:best:${topic}`);
|
|
2191
|
+
if (existing) {
|
|
2192
|
+
try {
|
|
2193
|
+
const prev = JSON.parse(existing);
|
|
2194
|
+
recallCount = (prev.recall_count ?? 0); // preserve existing recall count
|
|
2195
|
+
isUpdate = true;
|
|
3075
2196
|
}
|
|
2197
|
+
catch { /* ignore parse error */ }
|
|
3076
2198
|
}
|
|
3077
|
-
catch { /* ignore parse error */ }
|
|
3078
|
-
}
|
|
3079
|
-
else {
|
|
3080
|
-
auditTrail = [{ ts, action: 'created' }];
|
|
3081
2199
|
}
|
|
3082
|
-
|
|
3083
|
-
const iWasWrongWarning = [];
|
|
3084
|
-
if (outcome === 'failure') {
|
|
3085
|
-
// Search for related success lessons that might have prevented this failure
|
|
3086
|
-
const scanKeys = [];
|
|
3087
|
-
const scanStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 100 });
|
|
3088
|
-
await new Promise((res, rej) => {
|
|
3089
|
-
scanStream.on('data', (b) => scanKeys.push(...b));
|
|
3090
|
-
scanStream.on('end', res);
|
|
3091
|
-
scanStream.on('error', rej);
|
|
3092
|
-
});
|
|
3093
|
-
const topicWords = topic.split(/[:\-_]/).filter(w => w.length > 2);
|
|
3094
|
-
for (const k of scanKeys.slice(0, 50)) {
|
|
3095
|
-
const raw = await redis.get(k);
|
|
3096
|
-
if (!raw)
|
|
3097
|
-
continue;
|
|
3098
|
-
try {
|
|
3099
|
-
const l = JSON.parse(raw);
|
|
3100
|
-
if (l.outcome !== 'success')
|
|
3101
|
-
continue;
|
|
3102
|
-
const lWords = (l.topic ?? '').split(/[:\-_]/).filter(w => w.length > 2);
|
|
3103
|
-
const overlap = topicWords.filter(w => lWords.includes(w)).length;
|
|
3104
|
-
if (overlap >= 1 && l.topic !== topic) {
|
|
3105
|
-
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")\``);
|
|
3106
|
-
break; // only show the most relevant match
|
|
3107
|
-
}
|
|
3108
|
-
}
|
|
3109
|
-
catch { /* skip */ }
|
|
3110
|
-
}
|
|
3111
|
-
}
|
|
3112
|
-
// ── Register dependency index for causal chain ─────────────────────────
|
|
3113
|
-
for (const dep of depends_on) {
|
|
3114
|
-
const depKey = `cachly:dep:${dep}`;
|
|
3115
|
-
const existing = await redis.get(depKey);
|
|
3116
|
-
const depTopics = existing ? JSON.parse(existing) : [];
|
|
3117
|
-
if (!depTopics.includes(topic))
|
|
3118
|
-
depTopics.push(topic);
|
|
3119
|
-
await redis.set(depKey, JSON.stringify(depTopics));
|
|
3120
|
-
}
|
|
3121
|
-
const lessonObj = {
|
|
2200
|
+
const lesson = JSON.stringify({
|
|
3122
2201
|
topic,
|
|
3123
2202
|
outcome,
|
|
3124
2203
|
what_worked,
|
|
@@ -3128,34 +2207,17 @@ async function handleTool(name, args) {
|
|
|
3128
2207
|
file_paths,
|
|
3129
2208
|
commands,
|
|
3130
2209
|
tags,
|
|
3131
|
-
depends_on,
|
|
3132
|
-
...(author ? { author } : {}),
|
|
3133
2210
|
recall_count: recallCount,
|
|
3134
2211
|
ts,
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
version: 3,
|
|
3139
|
-
};
|
|
3140
|
-
const lesson = JSON.stringify(lessonObj);
|
|
3141
|
-
// Always append to the history list (audit log)
|
|
2212
|
+
version: 2,
|
|
2213
|
+
});
|
|
2214
|
+
// Always append to the history list
|
|
3142
2215
|
const listKey = `cachly:lessons:${topic}`;
|
|
3143
2216
|
await redis.rpush(listKey, lesson);
|
|
3144
|
-
// Update best key for success/partial
|
|
2217
|
+
// Update best key only for success/partial (not for failures)
|
|
3145
2218
|
if (outcome === 'success' || outcome === 'partial') {
|
|
3146
2219
|
await redis.set(`cachly:lesson:best:${topic}`, lesson);
|
|
3147
2220
|
}
|
|
3148
|
-
else if (!existingRaw) {
|
|
3149
|
-
await redis.set(`cachly:lesson:best:${topic}`, lesson);
|
|
3150
|
-
}
|
|
3151
|
-
// Track in decision log for session replay
|
|
3152
|
-
try {
|
|
3153
|
-
const dlKey = 'cachly:session:decision-log';
|
|
3154
|
-
const dlEntry = JSON.stringify({ ts, topic, outcome, what_worked: what_worked.slice(0, 120) });
|
|
3155
|
-
await redis.rpush(dlKey, dlEntry);
|
|
3156
|
-
await redis.ltrim(dlKey, -50, -1);
|
|
3157
|
-
}
|
|
3158
|
-
catch { /* non-critical */ }
|
|
3159
2221
|
const emoji = outcome === 'success' ? '✅' : outcome === 'partial' ? '⚠️' : '❌';
|
|
3160
2222
|
const sevEmoji = severity === 'critical' ? '🔴' : severity === 'major' ? '🟡' : '🟢';
|
|
3161
2223
|
const action = isUpdate ? 'updated' : 'stored';
|
|
@@ -3170,13 +2232,8 @@ async function handleTool(name, args) {
|
|
|
3170
2232
|
tags.length > 0 ? `**Tags:** ${tags.map(t => `#${t}`).join(' ')}` : '',
|
|
3171
2233
|
``,
|
|
3172
2234
|
isUpdate
|
|
3173
|
-
? `♻️ Updated (recall count: ${recallCount}
|
|
2235
|
+
? `♻️ Updated existing lesson (preserved recall count: ${recallCount})`
|
|
3174
2236
|
: `💡 Recall later with \`recall_best_solution(topic="${topic}")\``,
|
|
3175
|
-
depends_on.length > 0
|
|
3176
|
-
? `🔗 Depends on: ${depends_on.map(d => `\`${d}\``).join(', ')} → trace with \`trace_dependency\``
|
|
3177
|
-
: '',
|
|
3178
|
-
...templateWarnings,
|
|
3179
|
-
...iWasWrongWarning,
|
|
3180
2237
|
].filter(l => l !== '').join('\n');
|
|
3181
2238
|
}
|
|
3182
2239
|
case 'recall_best_solution': {
|
|
@@ -3185,36 +2242,40 @@ async function handleTool(name, args) {
|
|
|
3185
2242
|
// Try exact best-solution key first
|
|
3186
2243
|
const best = await redis.get(`cachly:lesson:best:${topic}`);
|
|
3187
2244
|
if (best) {
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
recall_count: (lesson.recall_count ?? 0) + 1,
|
|
3198
|
-
verified_at: new Date().toISOString(),
|
|
3199
|
-
confidence: 1.0,
|
|
3200
|
-
};
|
|
2245
|
+
let lesson;
|
|
2246
|
+
try {
|
|
2247
|
+
lesson = JSON.parse(best);
|
|
2248
|
+
}
|
|
2249
|
+
catch {
|
|
2250
|
+
return `⚠️ Lesson for \`${topic}\` could not be read (corrupted data). Use \`learn_from_attempts\` to overwrite it.`;
|
|
2251
|
+
}
|
|
2252
|
+
// Increment recall_count
|
|
2253
|
+
const updatedLesson = { ...lesson, recall_count: (lesson.recall_count ?? 0) + 1 };
|
|
3201
2254
|
await redis.set(`cachly:lesson:best:${topic}`, JSON.stringify(updatedLesson));
|
|
2255
|
+
// Fire-and-forget: track Magic Moment in Plausible + API engagement counter
|
|
2256
|
+
const isFirstRecall = (lesson.recall_count ?? 0) === 0;
|
|
2257
|
+
const recallEvent = isFirstRecall ? 'First Brain Recall' : 'Brain Recall Hit';
|
|
2258
|
+
fetch('https://analytics.cachly.dev/api/event', {
|
|
2259
|
+
method: 'POST',
|
|
2260
|
+
headers: { 'Content-Type': 'application/json', 'User-Agent': 'cachly-mcp/recall' },
|
|
2261
|
+
body: JSON.stringify({
|
|
2262
|
+
domain: 'cachly.dev', name: recallEvent,
|
|
2263
|
+
url: 'https://cachly.dev/mcp/recall', props: { topic },
|
|
2264
|
+
}),
|
|
2265
|
+
}).catch(() => { });
|
|
2266
|
+
if (JWT) {
|
|
2267
|
+
fetch(`${API_URL}/api/v1/telemetry/mcp`, {
|
|
2268
|
+
method: 'POST',
|
|
2269
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2270
|
+
body: JSON.stringify({ event: isFirstRecall ? 'first_recall' : 'recall', api_key: JWT }),
|
|
2271
|
+
}).catch(() => { });
|
|
2272
|
+
}
|
|
3202
2273
|
const sevEmoji = lesson.severity === 'critical' ? '🔴' : lesson.severity === 'major' ? '🟡' : lesson.severity ? '🟢' : '';
|
|
3203
|
-
const
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
// "Remember when..." — emotional header for lessons > 60 days old
|
|
3207
|
-
const ageFromStoreDays = (Date.now() - new Date(lesson.ts).getTime()) / 86400000;
|
|
3208
|
-
const rememberWhen = ageFromStoreDays > 60
|
|
3209
|
-
? `💭 _Remember when you solved this ${Math.round(ageFromStoreDays / 30)} months ago? Still works._`
|
|
3210
|
-
: '';
|
|
3211
|
-
// "Never Google This Again" — suggest pinning after 3rd recall
|
|
3212
|
-
const suggestPin = updatedLesson.recall_count === 3 && !lesson.pinned
|
|
3213
|
-
? `📌 **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.`
|
|
3214
|
-
: '';
|
|
2274
|
+
const sessionAgeMs = Date.now() - new Date(lesson.ts).getTime();
|
|
2275
|
+
const sessionAgeDays = Math.round(sessionAgeMs / 86_400_000);
|
|
2276
|
+
const ageLabel = sessionAgeDays === 0 ? 'today' : sessionAgeDays === 1 ? 'yesterday' : `${sessionAgeDays} days ago`;
|
|
3215
2277
|
return [
|
|
3216
|
-
|
|
3217
|
-
`${badge} **Best solution for \`${topic}\`** ${sevEmoji}${lesson.severity ? ` (${lesson.severity})` : ''} · recalled ${updatedLesson.recall_count}×`,
|
|
2278
|
+
`✅ **Best solution for \`${topic}\`** ${sevEmoji}${lesson.severity ? ` (${lesson.severity})` : ''} · recalled ${updatedLesson.recall_count}× · stored ${ageLabel}`,
|
|
3218
2279
|
``,
|
|
3219
2280
|
`**What worked:** ${lesson.what_worked}`,
|
|
3220
2281
|
lesson.what_failed ? `**What failed (avoid this):** ${lesson.what_failed}` : '',
|
|
@@ -3222,8 +2283,8 @@ async function handleTool(name, args) {
|
|
|
3222
2283
|
(lesson.file_paths ?? []).length > 0 ? `**Files:** ${(lesson.file_paths ?? []).map((f) => `\`${f}\``).join(', ')}` : '',
|
|
3223
2284
|
(lesson.commands ?? []).length > 0 ? `**Commands:** ${(lesson.commands ?? []).map((c) => `\`${c}\``).join(', ')}` : '',
|
|
3224
2285
|
(lesson.tags ?? []).length > 0 ? `**Tags:** ${(lesson.tags ?? []).map((t) => `#${t}`).join(' ')}` : '',
|
|
3225
|
-
|
|
3226
|
-
|
|
2286
|
+
``,
|
|
2287
|
+
`> 🧠 *Your AI remembered this from ${ageLabel} — persistent memory by [cachly.dev](https://cachly.dev)*`,
|
|
3227
2288
|
].filter(l => l !== '').join('\n');
|
|
3228
2289
|
}
|
|
3229
2290
|
// Partial match: scan all lesson keys for topic substring
|
|
@@ -3240,7 +2301,12 @@ async function handleTool(name, args) {
|
|
|
3240
2301
|
const histKey = `cachly:lessons:${topic}`;
|
|
3241
2302
|
const all = await redis.lrange(histKey, -3, -1);
|
|
3242
2303
|
if (all.length > 0) {
|
|
3243
|
-
const parsed = all.
|
|
2304
|
+
const parsed = all.flatMap(e => { try {
|
|
2305
|
+
return [JSON.parse(e)];
|
|
2306
|
+
}
|
|
2307
|
+
catch {
|
|
2308
|
+
return [];
|
|
2309
|
+
} });
|
|
3244
2310
|
const lines = parsed.map(p => `- ${p.outcome === 'success' ? '✅' : '❌'} ${p.what_worked.slice(0, 120)} (${new Date(p.ts).toLocaleDateString('de-DE')})`);
|
|
3245
2311
|
return `⚠️ No successful solution for \`${topic}\` yet. Last attempts:\n\n${lines.join('\n')}`;
|
|
3246
2312
|
}
|
|
@@ -3252,11 +2318,74 @@ async function handleTool(name, args) {
|
|
|
3252
2318
|
const raw = await redis.get(k);
|
|
3253
2319
|
if (!raw)
|
|
3254
2320
|
continue;
|
|
3255
|
-
|
|
2321
|
+
let lesson;
|
|
2322
|
+
try {
|
|
2323
|
+
lesson = JSON.parse(raw);
|
|
2324
|
+
}
|
|
2325
|
+
catch {
|
|
2326
|
+
continue;
|
|
2327
|
+
}
|
|
3256
2328
|
results.push(`**\`${lesson.topic}\`** — ${lesson.what_worked.slice(0, 200)}`);
|
|
3257
2329
|
}
|
|
3258
2330
|
return `🔍 **Partial matches for \`${topic}\`:**\n\n${results.join('\n\n')}`;
|
|
3259
2331
|
}
|
|
2332
|
+
case 'list_lessons': {
|
|
2333
|
+
const { instance_id, filter = '' } = args;
|
|
2334
|
+
const redis = await getConnection(instance_id);
|
|
2335
|
+
const keys = [];
|
|
2336
|
+
const stream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
|
|
2337
|
+
await new Promise((resolve, reject) => {
|
|
2338
|
+
stream.on('data', (batch) => keys.push(...batch));
|
|
2339
|
+
stream.on('end', resolve);
|
|
2340
|
+
stream.on('error', reject);
|
|
2341
|
+
});
|
|
2342
|
+
if (keys.length === 0)
|
|
2343
|
+
return `📭 No lessons stored yet. Use \`learn_from_attempts\` after solving any problem.`;
|
|
2344
|
+
const values = await redis.mget(...keys);
|
|
2345
|
+
const lessons = values.flatMap(raw => {
|
|
2346
|
+
if (!raw)
|
|
2347
|
+
return [];
|
|
2348
|
+
try {
|
|
2349
|
+
return [JSON.parse(raw)];
|
|
2350
|
+
}
|
|
2351
|
+
catch {
|
|
2352
|
+
return [];
|
|
2353
|
+
}
|
|
2354
|
+
});
|
|
2355
|
+
const filtered = filter
|
|
2356
|
+
? lessons.filter(l => l.topic.toLowerCase().includes(filter.toLowerCase()))
|
|
2357
|
+
: lessons;
|
|
2358
|
+
filtered.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
|
|
2359
|
+
if (filtered.length === 0)
|
|
2360
|
+
return `📭 No lessons matching \`${filter}\`.`;
|
|
2361
|
+
const sevEmoji = (s) => s === 'critical' ? '🔴' : s === 'major' ? '🟡' : '🟢';
|
|
2362
|
+
const lines = [
|
|
2363
|
+
`🧠 **${filtered.length} lesson${filtered.length === 1 ? '' : 's'}${filter ? ` matching "${filter}"` : ''} in Brain:**`,
|
|
2364
|
+
'',
|
|
2365
|
+
...filtered.slice(0, 30).map(l => {
|
|
2366
|
+
const age = Math.round((Date.now() - new Date(l.ts).getTime()) / 86400000);
|
|
2367
|
+
const ageStr = age === 0 ? 'today' : age === 1 ? 'yesterday' : `${age}d ago`;
|
|
2368
|
+
const recalls = l.recall_count ? ` · recalled ${l.recall_count}×` : '';
|
|
2369
|
+
return `${sevEmoji(l.severity)} \`${l.topic}\` — ${ageStr}${recalls}`;
|
|
2370
|
+
}),
|
|
2371
|
+
filtered.length > 30 ? `\n_…and ${filtered.length - 30} more. Use filter to narrow down._` : '',
|
|
2372
|
+
].filter(l => l !== '');
|
|
2373
|
+
return lines.join('\n');
|
|
2374
|
+
}
|
|
2375
|
+
case 'forget_lesson': {
|
|
2376
|
+
const { instance_id, topic } = args;
|
|
2377
|
+
const redis = await getConnection(instance_id);
|
|
2378
|
+
const bestKey = `cachly:lesson:best:${topic}`;
|
|
2379
|
+
const histKey = `cachly:lessons:${topic}`;
|
|
2380
|
+
const [delBest, delHist] = await Promise.all([
|
|
2381
|
+
redis.del(bestKey),
|
|
2382
|
+
redis.del(histKey),
|
|
2383
|
+
]);
|
|
2384
|
+
if (delBest === 0 && delHist === 0) {
|
|
2385
|
+
return `📭 No lesson found for \`${topic}\` — nothing to delete.`;
|
|
2386
|
+
}
|
|
2387
|
+
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\`.`;
|
|
2388
|
+
}
|
|
3260
2389
|
case 'smart_recall': {
|
|
3261
2390
|
const { instance_id, query, threshold = 0.78, } = args;
|
|
3262
2391
|
const redis = await getConnection(instance_id);
|
|
@@ -3351,35 +2480,50 @@ async function handleTool(name, args) {
|
|
|
3351
2480
|
}
|
|
3352
2481
|
// ── get_api_status ────────────────────────────────────────────────────────
|
|
3353
2482
|
case 'get_api_status': {
|
|
3354
|
-
// Check health
|
|
2483
|
+
// Check API health and auth service in parallel
|
|
2484
|
+
const [healthResult, authServiceResult] = await Promise.allSettled([
|
|
2485
|
+
fetch(`${API_URL}/health`, { signal: AbortSignal.timeout(5000) }),
|
|
2486
|
+
fetch(`${AUTH_URL}/.well-known/openid-configuration`, { signal: AbortSignal.timeout(5000) }),
|
|
2487
|
+
]);
|
|
3355
2488
|
let healthStatus = 'unknown';
|
|
3356
|
-
|
|
3357
|
-
const healthRes =
|
|
2489
|
+
if (healthResult.status === 'fulfilled') {
|
|
2490
|
+
const healthRes = healthResult.value;
|
|
3358
2491
|
if (healthRes.ok) {
|
|
3359
|
-
const body = await healthRes.json();
|
|
3360
|
-
healthStatus =
|
|
2492
|
+
const body = await healthRes.json().catch(() => ({}));
|
|
2493
|
+
healthStatus = `✅ ${body.status ?? 'ok'}${body.db ? ` (db: ${body.db})` : ''}`;
|
|
3361
2494
|
}
|
|
3362
2495
|
else {
|
|
3363
|
-
healthStatus =
|
|
2496
|
+
healthStatus = `❌ HTTP ${healthRes.status}`;
|
|
3364
2497
|
}
|
|
3365
2498
|
}
|
|
3366
|
-
|
|
3367
|
-
healthStatus =
|
|
2499
|
+
else {
|
|
2500
|
+
healthStatus = `❌ unreachable: ${healthResult.reason?.message ?? 'timeout'}`;
|
|
2501
|
+
}
|
|
2502
|
+
let authSvcStatus = 'unknown';
|
|
2503
|
+
if (authServiceResult.status === 'fulfilled') {
|
|
2504
|
+
authSvcStatus = authServiceResult.value.ok
|
|
2505
|
+
? `✅ reachable (${AUTH_URL})`
|
|
2506
|
+
: `❌ HTTP ${authServiceResult.value.status} — auth service may be down`;
|
|
2507
|
+
}
|
|
2508
|
+
else {
|
|
2509
|
+
authSvcStatus = `❌ unreachable: ${authServiceResult.reason?.message ?? 'timeout'} — auth service may be down`;
|
|
3368
2510
|
}
|
|
3369
2511
|
// Check JWT / auth
|
|
3370
2512
|
if (!JWT) {
|
|
3371
2513
|
return [
|
|
3372
2514
|
`📡 **cachly API Status**`,
|
|
3373
2515
|
``,
|
|
3374
|
-
` 🌐 API:
|
|
3375
|
-
` 💓 Health: ${healthStatus}`,
|
|
3376
|
-
`
|
|
2516
|
+
` 🌐 API: ${API_URL}`,
|
|
2517
|
+
` 💓 API Health: ${healthStatus}`,
|
|
2518
|
+
` 🔐 Auth Service: ${authSvcStatus}`,
|
|
2519
|
+
` 🔑 JWT: ❌ CACHLY_JWT not set`,
|
|
3377
2520
|
``,
|
|
3378
|
-
`💡 Get your API token at https://cachly.dev/
|
|
2521
|
+
`💡 Get your API token at https://cachly.dev/settings`,
|
|
3379
2522
|
].join('\n');
|
|
3380
2523
|
}
|
|
3381
2524
|
// Decode JWT claims (inspection only, no verification)
|
|
3382
2525
|
let authInfo = '❌ invalid JWT format';
|
|
2526
|
+
let isExpired = false;
|
|
3383
2527
|
try {
|
|
3384
2528
|
const parts = JWT.split('.');
|
|
3385
2529
|
if (parts.length === 3) {
|
|
@@ -3388,32 +2532,41 @@ async function handleTool(name, args) {
|
|
|
3388
2532
|
const iss = payload.iss ?? '(unknown)';
|
|
3389
2533
|
const provider = iss.includes('keycloak') ? 'Keycloak' : 'OIDC';
|
|
3390
2534
|
const expTs = payload.exp ? new Date(payload.exp * 1000) : null;
|
|
3391
|
-
|
|
2535
|
+
isExpired = expTs ? expTs < new Date() : false;
|
|
3392
2536
|
authInfo = [
|
|
3393
|
-
|
|
2537
|
+
`${isExpired ? '⚠️ ' : '✅'} JWT decoded`,
|
|
3394
2538
|
` Sub (user ID): ${sub}`,
|
|
3395
2539
|
` Provider: ${provider}`,
|
|
3396
2540
|
` Issuer: ${iss}`,
|
|
3397
|
-
` Expires: ${expTs ? expTs.toISOString() : 'never'} ${
|
|
2541
|
+
` Expires: ${expTs ? expTs.toISOString() : 'never'} ${isExpired ? '⚠️ EXPIRED' : '✅'}`,
|
|
3398
2542
|
].join('\n');
|
|
3399
2543
|
}
|
|
3400
2544
|
}
|
|
3401
2545
|
catch {
|
|
3402
2546
|
authInfo = '❌ JWT decode failed – check CACHLY_JWT format';
|
|
3403
2547
|
}
|
|
3404
|
-
|
|
2548
|
+
const lines = [
|
|
3405
2549
|
`📡 **cachly API Status**`,
|
|
3406
2550
|
``,
|
|
3407
|
-
` 🌐 API:
|
|
3408
|
-
` 💓 Health:
|
|
2551
|
+
` 🌐 API: ${API_URL}`,
|
|
2552
|
+
` 💓 API Health: ${healthStatus}`,
|
|
2553
|
+
` 🔐 Auth Service: ${authSvcStatus}`,
|
|
3409
2554
|
``,
|
|
3410
|
-
`🔑 **Auth:**`,
|
|
2555
|
+
`🔑 **Auth (JWT):**`,
|
|
3411
2556
|
authInfo,
|
|
3412
|
-
]
|
|
2557
|
+
];
|
|
2558
|
+
if (isExpired) {
|
|
2559
|
+
lines.push(``, `⚠️ Your token is expired — get a new one at https://cachly.dev/settings`);
|
|
2560
|
+
}
|
|
2561
|
+
if (authSvcStatus.startsWith('❌')) {
|
|
2562
|
+
lines.push(``, `⚠️ Auth service appears down. Registration and login on cachly.dev may be unavailable.`);
|
|
2563
|
+
lines.push(` Contact support@cachly.dev if this persists.`);
|
|
2564
|
+
}
|
|
2565
|
+
return lines.join('\n');
|
|
3413
2566
|
}
|
|
3414
2567
|
// ── session_start ─────────────────────────────────────────────────────────
|
|
3415
2568
|
case 'session_start': {
|
|
3416
|
-
const { instance_id, focus = ''
|
|
2569
|
+
const { instance_id, focus = '' } = args;
|
|
3417
2570
|
const redis = await getConnection(instance_id);
|
|
3418
2571
|
// 1. Scan all best-solution lessons
|
|
3419
2572
|
const lessonKeys = [];
|
|
@@ -3424,14 +2577,17 @@ async function handleTool(name, args) {
|
|
|
3424
2577
|
lStream.on('error', reject);
|
|
3425
2578
|
});
|
|
3426
2579
|
const lessons = [];
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
2580
|
+
if (lessonKeys.length > 0) {
|
|
2581
|
+
// Batch fetch all lesson values in one round-trip instead of N sequential gets
|
|
2582
|
+
const values = await redis.mget(...lessonKeys);
|
|
2583
|
+
for (const raw of values) {
|
|
2584
|
+
if (!raw)
|
|
2585
|
+
continue;
|
|
2586
|
+
try {
|
|
2587
|
+
lessons.push(JSON.parse(raw));
|
|
2588
|
+
}
|
|
2589
|
+
catch { /* skip corrupt */ }
|
|
3433
2590
|
}
|
|
3434
|
-
catch { /* skip corrupt */ }
|
|
3435
2591
|
}
|
|
3436
2592
|
lessons.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
|
|
3437
2593
|
// 3. Count context entries (filter :meta keys)
|
|
@@ -3459,86 +2615,13 @@ async function handleTool(name, args) {
|
|
|
3459
2615
|
? lessons.filter(l => focusTerms.some(term => l.topic.toLowerCase().includes(term) ||
|
|
3460
2616
|
(l.tags ?? []).some((t) => t.toLowerCase().includes(term))))
|
|
3461
2617
|
: [];
|
|
3462
|
-
// 6.
|
|
3463
|
-
let streakDays = 0;
|
|
3464
|
-
let streakRecord = 0;
|
|
3465
|
-
let streakMessage = '';
|
|
3466
|
-
try {
|
|
3467
|
-
const streakRaw = await redis.get('cachly:streak:current');
|
|
3468
|
-
const streak = streakRaw ? JSON.parse(streakRaw) : null;
|
|
3469
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
3470
|
-
if (streak) {
|
|
3471
|
-
const lastDate = streak.last_date;
|
|
3472
|
-
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
|
3473
|
-
if (lastDate === today) {
|
|
3474
|
-
// Already counted today
|
|
3475
|
-
streakDays = streak.days;
|
|
3476
|
-
streakRecord = streak.record;
|
|
3477
|
-
}
|
|
3478
|
-
else if (lastDate === yesterday) {
|
|
3479
|
-
// Continuing streak
|
|
3480
|
-
streakDays = streak.days + 1;
|
|
3481
|
-
streakRecord = Math.max(streakDays, streak.record);
|
|
3482
|
-
await redis.set('cachly:streak:current', JSON.stringify({ days: streakDays, last_date: today, record: streakRecord }));
|
|
3483
|
-
}
|
|
3484
|
-
else {
|
|
3485
|
-
// Streak broken
|
|
3486
|
-
streakDays = 1;
|
|
3487
|
-
streakRecord = streak.record;
|
|
3488
|
-
await redis.set('cachly:streak:current', JSON.stringify({ days: 1, last_date: today, record: streakRecord }));
|
|
3489
|
-
}
|
|
3490
|
-
}
|
|
3491
|
-
else {
|
|
3492
|
-
// First session ever
|
|
3493
|
-
streakDays = 1;
|
|
3494
|
-
streakRecord = 1;
|
|
3495
|
-
await redis.set('cachly:streak:current', JSON.stringify({ days: 1, last_date: today, record: 1 }));
|
|
3496
|
-
}
|
|
3497
|
-
if (streakDays >= 7)
|
|
3498
|
-
streakMessage = `🔥 **${streakDays}-day streak!** ${streakDays === streakRecord ? ' New record!' : `Best: ${streakRecord}d`}`;
|
|
3499
|
-
else if (streakDays > 1)
|
|
3500
|
-
streakMessage = `🔥 ${streakDays}-day streak`;
|
|
3501
|
-
}
|
|
3502
|
-
catch { /* non-critical */ }
|
|
3503
|
-
// 7. Save session start marker
|
|
2618
|
+
// 6. Save session start marker
|
|
3504
2619
|
await redis.set('cachly:session:current', JSON.stringify({
|
|
3505
2620
|
started: new Date().toISOString(),
|
|
3506
2621
|
focus,
|
|
3507
2622
|
}), 'EX', 86400); // auto-expire after 24h if session_end never called
|
|
3508
2623
|
// ── Build briefing ──────────────────────────────────────────────────────
|
|
3509
2624
|
const lines = ['🧠 **Session Briefing**', ''];
|
|
3510
|
-
if (streakMessage)
|
|
3511
|
-
lines.push(streakMessage, '');
|
|
3512
|
-
// Handoff from previous window (if any)
|
|
3513
|
-
const handoffRaw = await redis.get('cachly:session:handoff');
|
|
3514
|
-
if (handoffRaw) {
|
|
3515
|
-
try {
|
|
3516
|
-
const handoff = JSON.parse(handoffRaw);
|
|
3517
|
-
const ago = Math.round((Date.now() - new Date(handoff.ts).getTime()) / 60000);
|
|
3518
|
-
const agoStr = ago < 60 ? `${ago}m ago` : ago < 1440 ? `${Math.round(ago / 60)}h ago` : `${Math.round(ago / 1440)}d ago`;
|
|
3519
|
-
lines.push(`🤝 **Handoff from previous window** (${agoStr}):`);
|
|
3520
|
-
if (handoff.context_summary)
|
|
3521
|
-
lines.push(` ${handoff.context_summary}`);
|
|
3522
|
-
if (handoff.remaining_tasks.length > 0) {
|
|
3523
|
-
lines.push(` ⏳ **Remaining tasks:**`);
|
|
3524
|
-
for (const t of handoff.remaining_tasks)
|
|
3525
|
-
lines.push(` - ${t}`);
|
|
3526
|
-
}
|
|
3527
|
-
if (handoff.completed_tasks.length > 0) {
|
|
3528
|
-
lines.push(` ✅ **Already done:** ${handoff.completed_tasks.join(', ')}`);
|
|
3529
|
-
}
|
|
3530
|
-
const brokenFiles = (handoff.files_changed ?? []).filter(f => f.status === 'broken' || f.status === 'partial');
|
|
3531
|
-
if (brokenFiles.length > 0) {
|
|
3532
|
-
lines.push(` ⚠️ **Needs fix:** ${brokenFiles.map(f => `\`${f.path}\` (${f.status}${f.description ? ': ' + f.description : ''})`).join(', ')}`);
|
|
3533
|
-
}
|
|
3534
|
-
if (handoff.blocked_on)
|
|
3535
|
-
lines.push(` 🚫 **Blocked on:** ${handoff.blocked_on}`);
|
|
3536
|
-
if (handoff.instructions)
|
|
3537
|
-
lines.push(` 📝 **Instructions:** ${handoff.instructions}`);
|
|
3538
|
-
lines.push('');
|
|
3539
|
-
}
|
|
3540
|
-
catch { /* ignore corrupt handoff */ }
|
|
3541
|
-
}
|
|
3542
2625
|
// Last session
|
|
3543
2626
|
if (lastSession) {
|
|
3544
2627
|
const ago = Math.round((Date.now() - new Date(lastSession.ts).getTime()) / 60000);
|
|
@@ -3551,8 +2634,16 @@ async function handleTool(name, args) {
|
|
|
3551
2634
|
}
|
|
3552
2635
|
lines.push('');
|
|
3553
2636
|
}
|
|
3554
|
-
// Brain health
|
|
3555
|
-
|
|
2637
|
+
// Brain health + first-time onboarding hint
|
|
2638
|
+
if (lessons.length === 0 && !lastSession) {
|
|
2639
|
+
lines.push(`🆕 **Brain is empty — first session!**`);
|
|
2640
|
+
lines.push(`After solving anything, call \`learn_from_attempts\` to store the lesson.`);
|
|
2641
|
+
lines.push(`After this session, call \`session_end\` to save a summary.`);
|
|
2642
|
+
lines.push('');
|
|
2643
|
+
}
|
|
2644
|
+
else {
|
|
2645
|
+
lines.push(`📊 **Brain:** ${lessons.length} lessons · ${ctxCount} context entries`, '');
|
|
2646
|
+
}
|
|
3556
2647
|
// Focus-relevant lessons
|
|
3557
2648
|
if (focusLessons.length > 0) {
|
|
3558
2649
|
lines.push(`🎯 **Relevant for "${focus}":**`);
|
|
@@ -3586,137 +2677,11 @@ async function handleTool(name, args) {
|
|
|
3586
2677
|
}
|
|
3587
2678
|
lines.push('');
|
|
3588
2679
|
}
|
|
3589
|
-
// ── Stale / low-confidence lessons (confidence decay) ─────────────────
|
|
3590
|
-
const staleSuccessLessons = lessons.filter(l => {
|
|
3591
|
-
if (l.outcome !== 'success' && l.outcome !== 'partial')
|
|
3592
|
-
return false;
|
|
3593
|
-
return calculateConfidence(l) < CONFIDENCE_WARN_VALUE;
|
|
3594
|
-
});
|
|
3595
|
-
if (staleSuccessLessons.length > 0) {
|
|
3596
|
-
lines.push(`🔴 **Stale lessons** (not recalled in >${CONFIDENCE_WARN_DAYS}d — verify before applying):`);
|
|
3597
|
-
for (const l of staleSuccessLessons.slice(0, 4)) {
|
|
3598
|
-
const conf = calculateConfidence(l);
|
|
3599
|
-
const ageDays = Math.round((Date.now() - new Date(l.verified_at ?? l.ts).getTime()) / 86400000);
|
|
3600
|
-
const flag = conf < CONFIDENCE_STALE_VALUE ? '🔴' : '⚠️';
|
|
3601
|
-
lines.push(` ${flag} \`${l.topic}\` — ${ageDays}d stale, ${(conf * 100).toFixed(0)}% confidence`);
|
|
3602
|
-
}
|
|
3603
|
-
lines.push(` _Run \`recall_best_solution\` on these to reset their confidence clock._`);
|
|
3604
|
-
lines.push('');
|
|
3605
|
-
}
|
|
3606
|
-
// ── Session Replay: show last session's decision log ──────────────────
|
|
3607
|
-
const lastSessionAny = lastSession;
|
|
3608
|
-
if (lastSessionAny?.decision_log?.length) {
|
|
3609
|
-
const dl = lastSessionAny.decision_log;
|
|
3610
|
-
const successes = dl.filter(d => d.outcome === 'success');
|
|
3611
|
-
const failures = dl.filter(d => d.outcome === 'failure');
|
|
3612
|
-
lines.push(`🎬 **Last session decisions** (${dl.length} lessons stored):`);
|
|
3613
|
-
if (successes.length > 0)
|
|
3614
|
-
lines.push(` ✅ Worked: ${successes.slice(0, 3).map(d => `\`${d.topic}\``).join(', ')}`);
|
|
3615
|
-
if (failures.length > 0)
|
|
3616
|
-
lines.push(` ❌ Failed: ${failures.slice(0, 3).map(d => `\`${d.topic}\``).join(', ')}`);
|
|
3617
|
-
lines.push('');
|
|
3618
|
-
}
|
|
3619
|
-
// ── 🔮 Predictive Pre-Warning — intent-based danger detection ────────────
|
|
3620
|
-
// Fires BEFORE work starts. If focus area has known failure patterns → warn loudly.
|
|
3621
|
-
if (focusTerms.length > 0) {
|
|
3622
|
-
const dangerLessons = lessons.filter(l => {
|
|
3623
|
-
if (l.outcome === 'success')
|
|
3624
|
-
return false;
|
|
3625
|
-
const topicCategory = l.topic.split(':')[0];
|
|
3626
|
-
return focusTerms.some(term => l.topic.toLowerCase().includes(term) ||
|
|
3627
|
-
topicCategory === term ||
|
|
3628
|
-
(l.tags ?? []).some((t) => t.toLowerCase() === term));
|
|
3629
|
-
});
|
|
3630
|
-
if (dangerLessons.length >= 1) {
|
|
3631
|
-
// Insert warning block right after the title line (index 1 = blank line after title)
|
|
3632
|
-
const warning = [
|
|
3633
|
-
`🚨 **PRE-WARNING** — Read this BEFORE starting:`,
|
|
3634
|
-
` Known pitfalls for **"${focus}"** (${dangerLessons.length} past failure${dangerLessons.length > 1 ? 's' : ''}):`,
|
|
3635
|
-
...dangerLessons.slice(0, 3).map(l => ` ❌ \`${l.topic}\` — ${(l.what_failed ?? l.what_worked).slice(0, 80)}`),
|
|
3636
|
-
'',
|
|
3637
|
-
];
|
|
3638
|
-
lines.splice(2, 0, ...warning); // after '🧠 **Session Briefing**' + empty line
|
|
3639
|
-
}
|
|
3640
|
-
}
|
|
3641
|
-
// ── 👥 Team Telepathy — what teammates learned this week ─────────────────
|
|
3642
|
-
if (author) {
|
|
3643
|
-
const oneWeekAgo = Date.now() - 7 * 86_400_000;
|
|
3644
|
-
const teamLessons = lessons.filter(l => l.author && l.author !== author && new Date(l.ts).getTime() > oneWeekAgo);
|
|
3645
|
-
if (teamLessons.length > 0) {
|
|
3646
|
-
// Group by author
|
|
3647
|
-
const byAuthor = new Map();
|
|
3648
|
-
for (const l of teamLessons) {
|
|
3649
|
-
const a = l.author;
|
|
3650
|
-
if (!byAuthor.has(a))
|
|
3651
|
-
byAuthor.set(a, []);
|
|
3652
|
-
byAuthor.get(a).push(l);
|
|
3653
|
-
}
|
|
3654
|
-
lines.push(`👥 **Team this week** (${teamLessons.length} lesson${teamLessons.length > 1 ? 's' : ''} from teammates):`);
|
|
3655
|
-
for (const [teamAuthor, tls] of byAuthor) {
|
|
3656
|
-
lines.push(` 👤 **${teamAuthor}**:`);
|
|
3657
|
-
for (const l of tls.slice(0, 3)) {
|
|
3658
|
-
const emoji = l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
|
|
3659
|
-
lines.push(` ${emoji} \`${l.topic}\` — ${l.what_worked.slice(0, 80)}`);
|
|
3660
|
-
}
|
|
3661
|
-
if (tls.length > 3)
|
|
3662
|
-
lines.push(` … and ${tls.length - 3} more`);
|
|
3663
|
-
}
|
|
3664
|
-
lines.push('');
|
|
3665
|
-
}
|
|
3666
|
-
}
|
|
3667
|
-
// ── 💎 Memory Crystal — compressed wisdom from old sessions ──────────────
|
|
3668
|
-
try {
|
|
3669
|
-
const crystalRaw = await redis.get('cachly:crystal:latest');
|
|
3670
|
-
if (crystalRaw) {
|
|
3671
|
-
const crystal = JSON.parse(crystalRaw);
|
|
3672
|
-
const crystalAge = Math.round((Date.now() - new Date(crystal.ts).getTime()) / 86_400_000);
|
|
3673
|
-
if (crystalAge <= 90) {
|
|
3674
|
-
lines.push(`💎 **Memory Crystal** (${crystal.label} · ${crystal.session_count} sessions compressed):`);
|
|
3675
|
-
for (const p of crystal.top_patterns.slice(0, 3)) {
|
|
3676
|
-
lines.push(` • **${p.category}** (${p.count}×): ${p.insight.slice(0, 90)}`);
|
|
3677
|
-
}
|
|
3678
|
-
lines.push('');
|
|
3679
|
-
}
|
|
3680
|
-
}
|
|
3681
|
-
}
|
|
3682
|
-
catch { /* non-critical */ }
|
|
3683
|
-
// ── 🗺️ Roadmap — open items at session start ────────────────────────────
|
|
3684
|
-
try {
|
|
3685
|
-
const roadmapAll = await redis.hgetall(`cachly:roadmap:${instance_id}`);
|
|
3686
|
-
if (roadmapAll && Object.keys(roadmapAll).length > 0) {
|
|
3687
|
-
const PRIORITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
3688
|
-
const PRIORITY_ICON = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
|
|
3689
|
-
const openStatuses = new Set(['planned', 'in-progress', 'blocked']);
|
|
3690
|
-
const allItems = Object.values(roadmapAll).map(v => JSON.parse(v));
|
|
3691
|
-
const openItems = allItems
|
|
3692
|
-
.filter(i => openStatuses.has(i.status))
|
|
3693
|
-
.sort((a, b) => {
|
|
3694
|
-
if (a.status === 'in-progress' && b.status !== 'in-progress')
|
|
3695
|
-
return -1;
|
|
3696
|
-
if (b.status === 'in-progress' && a.status !== 'in-progress')
|
|
3697
|
-
return 1;
|
|
3698
|
-
return (PRIORITY_ORDER[a.priority] ?? 99) - (PRIORITY_ORDER[b.priority] ?? 99);
|
|
3699
|
-
});
|
|
3700
|
-
const doneCount = allItems.filter(i => i.status === 'done').length;
|
|
3701
|
-
if (openItems.length > 0) {
|
|
3702
|
-
lines.push(`🗺️ **Roadmap** (${openItems.length} open · ${doneCount} done):`);
|
|
3703
|
-
for (const it of openItems.slice(0, 5)) {
|
|
3704
|
-
const statusIcon = it.status === 'in-progress' ? '⚡' : it.status === 'blocked' ? '🚫' : '📋';
|
|
3705
|
-
lines.push(` ${statusIcon} ${PRIORITY_ICON[it.priority] ?? '⚪'} \`${it.id}\` **${it.title}**`);
|
|
3706
|
-
}
|
|
3707
|
-
if (openItems.length > 5)
|
|
3708
|
-
lines.push(` … and ${openItems.length - 5} more`);
|
|
3709
|
-
lines.push(` _Use \`roadmap_next\` for the top priority item · \`roadmap_list\` for full view_`);
|
|
3710
|
-
lines.push('');
|
|
3711
|
-
}
|
|
3712
|
-
}
|
|
3713
|
-
}
|
|
3714
|
-
catch { /* non-critical */ }
|
|
3715
2680
|
return lines.join('\n');
|
|
3716
2681
|
}
|
|
3717
2682
|
// ── session_end ───────────────────────────────────────────────────────────
|
|
3718
2683
|
case 'session_end': {
|
|
3719
|
-
const { instance_id, summary, files_changed = [], lessons_learned,
|
|
2684
|
+
const { instance_id, summary, files_changed = [], lessons_learned, } = args;
|
|
3720
2685
|
const redis = await getConnection(instance_id);
|
|
3721
2686
|
const now = new Date();
|
|
3722
2687
|
// Calculate duration from session_start marker
|
|
@@ -3729,21 +2694,12 @@ async function handleTool(name, args) {
|
|
|
3729
2694
|
}
|
|
3730
2695
|
catch { /* ignore */ }
|
|
3731
2696
|
}
|
|
3732
|
-
// ── Session Replay: capture decision log ─────────────────────────────
|
|
3733
|
-
let decisionLog = [];
|
|
3734
|
-
try {
|
|
3735
|
-
const dlEntries = await redis.lrange('cachly:session:decision-log', 0, -1);
|
|
3736
|
-
decisionLog = dlEntries.map(e => JSON.parse(e));
|
|
3737
|
-
await redis.del('cachly:session:decision-log');
|
|
3738
|
-
}
|
|
3739
|
-
catch { /* non-critical */ }
|
|
3740
2697
|
const sessionRecord = {
|
|
3741
2698
|
ts: now.toISOString(),
|
|
3742
2699
|
summary,
|
|
3743
2700
|
files_changed,
|
|
3744
2701
|
...(lessons_learned !== undefined ? { lessons_learned } : {}),
|
|
3745
2702
|
...(durationMin !== undefined ? { duration_min: durationMin } : {}),
|
|
3746
|
-
...(decisionLog.length > 0 ? { decision_log: decisionLog } : {}),
|
|
3747
2703
|
};
|
|
3748
2704
|
// Save as "last session"
|
|
3749
2705
|
await redis.set('cachly:session:last', JSON.stringify(sessionRecord));
|
|
@@ -3753,131 +2709,6 @@ async function handleTool(name, args) {
|
|
|
3753
2709
|
await redis.expire('cachly:session:history', 90 * 86400);
|
|
3754
2710
|
// Clean up current session marker
|
|
3755
2711
|
await redis.del('cachly:session:current');
|
|
3756
|
-
// ── AUTO-LEARN from session summary (no manual call needed) ─────────────
|
|
3757
|
-
// Parse the summary for actionable lessons and store them automatically.
|
|
3758
|
-
const autoLearned = [];
|
|
3759
|
-
try {
|
|
3760
|
-
// Extract key sentences from the summary that contain action verbs
|
|
3761
|
-
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;
|
|
3762
|
-
const sentences = summary
|
|
3763
|
-
.split(/[.!\n]+/)
|
|
3764
|
-
.map(s => s.trim())
|
|
3765
|
-
.filter(s => s.length > 20 && actionVerbs.test(s));
|
|
3766
|
-
for (const sentence of sentences.slice(0, 6)) {
|
|
3767
|
-
// Build a topic slug from the first meaningful words
|
|
3768
|
-
const words = sentence.toLowerCase()
|
|
3769
|
-
.replace(/[^a-z0-9\s]/g, ' ')
|
|
3770
|
-
.split(/\s+/)
|
|
3771
|
-
.filter(w => w.length > 3 && !['that', 'this', 'with', 'from', 'have', 'been', 'were', 'they', 'then', 'when', 'also', 'into', 'will', 'would', 'could', 'should'].includes(w));
|
|
3772
|
-
const slug = words.slice(0, 4).join('-');
|
|
3773
|
-
if (!slug)
|
|
3774
|
-
continue;
|
|
3775
|
-
const topic = `auto:${slug}`;
|
|
3776
|
-
const key = `cachly:lesson:best:${topic}`;
|
|
3777
|
-
// Don't overwrite existing successful lessons
|
|
3778
|
-
const existing = await redis.get(key);
|
|
3779
|
-
if (existing) {
|
|
3780
|
-
try {
|
|
3781
|
-
const ex = JSON.parse(existing);
|
|
3782
|
-
if (ex.outcome === 'success')
|
|
3783
|
-
continue;
|
|
3784
|
-
}
|
|
3785
|
-
catch { /* ignore */ }
|
|
3786
|
-
}
|
|
3787
|
-
const lesson = {
|
|
3788
|
-
topic,
|
|
3789
|
-
outcome: 'success',
|
|
3790
|
-
what_worked: sentence,
|
|
3791
|
-
context: `Auto-learned from session summary. Full summary: ${summary.slice(0, 300)}`,
|
|
3792
|
-
severity: 'minor',
|
|
3793
|
-
ts: now.toISOString(),
|
|
3794
|
-
recall_count: 0,
|
|
3795
|
-
auto_learned: true,
|
|
3796
|
-
session_ts: now.toISOString(),
|
|
3797
|
-
version: 2,
|
|
3798
|
-
};
|
|
3799
|
-
await redis.set(key, JSON.stringify(lesson));
|
|
3800
|
-
// 90-day TTL for auto-learned lessons
|
|
3801
|
-
await redis.expire(key, 90 * 86400);
|
|
3802
|
-
autoLearned.push(topic);
|
|
3803
|
-
}
|
|
3804
|
-
// Also store a lesson per changed file area if files were changed
|
|
3805
|
-
if (files_changed.length > 0) {
|
|
3806
|
-
const areas = [...new Set(files_changed.map(f => f.split('/').slice(0, 2).join('/')))].slice(0, 3);
|
|
3807
|
-
for (const area of areas) {
|
|
3808
|
-
const slug = area.replace(/[^a-z0-9]/gi, '-').toLowerCase().replace(/-+/g, '-').slice(0, 30);
|
|
3809
|
-
const topic = `auto:changed:${slug}`;
|
|
3810
|
-
const key = `cachly:lesson:best:${topic}`;
|
|
3811
|
-
const lesson = {
|
|
3812
|
-
topic,
|
|
3813
|
-
outcome: 'success',
|
|
3814
|
-
what_worked: `Files changed in ${area}: ${files_changed.filter(f => f.startsWith(area.split('/')[0])).slice(0, 5).join(', ')}`,
|
|
3815
|
-
context: summary.slice(0, 200),
|
|
3816
|
-
severity: 'minor',
|
|
3817
|
-
ts: now.toISOString(),
|
|
3818
|
-
recall_count: 0,
|
|
3819
|
-
auto_learned: true,
|
|
3820
|
-
version: 2,
|
|
3821
|
-
};
|
|
3822
|
-
await redis.set(key, JSON.stringify(lesson));
|
|
3823
|
-
await redis.expire(key, 90 * 86400);
|
|
3824
|
-
autoLearned.push(topic);
|
|
3825
|
-
}
|
|
3826
|
-
}
|
|
3827
|
-
}
|
|
3828
|
-
catch { /* auto-learn errors must never break session_end */ }
|
|
3829
|
-
// ── 🌿 Ambient Git Learning ────────────────────────────────────────────────
|
|
3830
|
-
// Read git commits since session start → auto-learn each meaningful commit.
|
|
3831
|
-
const ambientLearned = [];
|
|
3832
|
-
if (workspace_path) {
|
|
3833
|
-
try {
|
|
3834
|
-
// Get the session start time (stored by session_start)
|
|
3835
|
-
const sessionStartTs = currentRaw
|
|
3836
|
-
? (() => { try {
|
|
3837
|
-
return JSON.parse(currentRaw).started ?? '';
|
|
3838
|
-
}
|
|
3839
|
-
catch {
|
|
3840
|
-
return '';
|
|
3841
|
-
} })()
|
|
3842
|
-
: '';
|
|
3843
|
-
const sinceArg = sessionStartTs ? `--since="${sessionStartTs}"` : '--since="1 hour ago"';
|
|
3844
|
-
const gitOut = execSync(`git -C "${workspace_path}" log ${sinceArg} --oneline --format="%H|||%s|||%ai"`, { timeout: 5000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
3845
|
-
if (gitOut) {
|
|
3846
|
-
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;
|
|
3847
|
-
for (const line of gitOut.split('\n').slice(0, 10)) {
|
|
3848
|
-
const [hash, msg, dateStr] = line.split('|||');
|
|
3849
|
-
if (!msg || !commitActionRe.test(msg))
|
|
3850
|
-
continue;
|
|
3851
|
-
const slug = msg
|
|
3852
|
-
.toLowerCase().replace(/^(fix|feat|chore|docs|test|ci|perf|refactor|build|revert)[:(\s]/i, '')
|
|
3853
|
-
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
|
|
3854
|
-
if (!slug)
|
|
3855
|
-
continue;
|
|
3856
|
-
const topic = `git:${slug}`;
|
|
3857
|
-
const key = `cachly:lesson:best:${topic}`;
|
|
3858
|
-
const existing = await redis.get(key);
|
|
3859
|
-
if (existing)
|
|
3860
|
-
continue; // don't overwrite existing
|
|
3861
|
-
const commitLesson = {
|
|
3862
|
-
topic,
|
|
3863
|
-
outcome: 'success',
|
|
3864
|
-
what_worked: msg.slice(0, 200),
|
|
3865
|
-
context: `Auto-learned from git commit ${(hash ?? '').slice(0, 7)} at ${dateStr ?? ''} in ${workspace_path}`,
|
|
3866
|
-
severity: 'minor',
|
|
3867
|
-
ts: now.toISOString(),
|
|
3868
|
-
recall_count: 0,
|
|
3869
|
-
auto_learned: true,
|
|
3870
|
-
source: 'ambient-git',
|
|
3871
|
-
version: 3,
|
|
3872
|
-
};
|
|
3873
|
-
await redis.set(key, JSON.stringify(commitLesson));
|
|
3874
|
-
await redis.expire(key, 60 * 86400); // 60 day TTL for git lessons
|
|
3875
|
-
ambientLearned.push(topic);
|
|
3876
|
-
}
|
|
3877
|
-
}
|
|
3878
|
-
}
|
|
3879
|
-
catch { /* git not available or not a repo — silent skip */ }
|
|
3880
|
-
}
|
|
3881
2712
|
const durationStr = durationMin !== undefined ? ` · ${durationMin} min` : '';
|
|
3882
2713
|
return [
|
|
3883
2714
|
`✅ **Session saved**${durationStr}`,
|
|
@@ -3885,46 +2716,10 @@ async function handleTool(name, args) {
|
|
|
3885
2716
|
`📋 **Summary:** ${summary}`,
|
|
3886
2717
|
files_changed.length > 0 ? `📁 **Files changed:** ${files_changed.map(f => `\`${f}\``).join(', ')}` : '',
|
|
3887
2718
|
lessons_learned !== undefined ? `🧠 **Lessons stored:** ${lessons_learned}` : '',
|
|
3888
|
-
autoLearned.length > 0 ? `🤖 **Auto-learned:** ${autoLearned.length} lessons extracted from summary (${autoLearned.slice(0, 3).map(t => `\`${t}\``).join(', ')}${autoLearned.length > 3 ? '…' : ''})` : '',
|
|
3889
|
-
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 ? '…' : ''})` : '',
|
|
3890
2719
|
``,
|
|
3891
2720
|
`💡 Next session: \`session_start(focus="...")\` to see this summary.`,
|
|
3892
2721
|
].filter(l => l !== '').join('\n');
|
|
3893
2722
|
}
|
|
3894
|
-
// ── session_handoff — cross-window continuity ─────────────────────────────
|
|
3895
|
-
case 'session_handoff': {
|
|
3896
|
-
const { instance_id, completed_tasks = [], remaining_tasks = [], files_changed = [], instructions = '', context_summary = '', blocked_on = '', } = args;
|
|
3897
|
-
const redis = await getConnection(instance_id);
|
|
3898
|
-
const now = new Date();
|
|
3899
|
-
const handoff = {
|
|
3900
|
-
ts: now.toISOString(),
|
|
3901
|
-
completed_tasks,
|
|
3902
|
-
remaining_tasks,
|
|
3903
|
-
files_changed,
|
|
3904
|
-
instructions,
|
|
3905
|
-
context_summary,
|
|
3906
|
-
blocked_on,
|
|
3907
|
-
};
|
|
3908
|
-
// Store handoff — never expires until next handoff overwrites it
|
|
3909
|
-
await redis.set('cachly:session:handoff', JSON.stringify(handoff));
|
|
3910
|
-
// Also append to history
|
|
3911
|
-
await redis.lpush('cachly:session:handoff:history', JSON.stringify(handoff));
|
|
3912
|
-
await redis.ltrim('cachly:session:handoff:history', 0, 19);
|
|
3913
|
-
const totalTasks = completed_tasks.length + remaining_tasks.length;
|
|
3914
|
-
const pct = totalTasks > 0 ? Math.round((completed_tasks.length / totalTasks) * 100) : 0;
|
|
3915
|
-
const brokenFiles = files_changed.filter(f => f.status === 'broken' || f.status === 'partial');
|
|
3916
|
-
return [
|
|
3917
|
-
`🤝 **Handoff saved** — ${completed_tasks.length}/${totalTasks} tasks done (${pct}%)`,
|
|
3918
|
-
``,
|
|
3919
|
-
completed_tasks.length > 0 ? `✅ **Completed:**\n${completed_tasks.map(t => ` - ${t}`).join('\n')}` : '',
|
|
3920
|
-
remaining_tasks.length > 0 ? `\n⏳ **Remaining for next window:**\n${remaining_tasks.map(t => ` - ${t}`).join('\n')}` : '',
|
|
3921
|
-
brokenFiles.length > 0 ? `\n⚠️ **Needs attention:** ${brokenFiles.map(f => `\`${f.path}\` (${f.status})`).join(', ')}` : '',
|
|
3922
|
-
blocked_on ? `\n🚫 **Blocked on:** ${blocked_on}` : '',
|
|
3923
|
-
instructions ? `\n📝 **Instructions:** ${instructions}` : '',
|
|
3924
|
-
``,
|
|
3925
|
-
`💡 The next \`session_start\` will include this handoff automatically.`,
|
|
3926
|
-
].filter(l => l !== '').join('\n');
|
|
3927
|
-
}
|
|
3928
2723
|
// ── auto_learn_session ────────────────────────────────────────────────────
|
|
3929
2724
|
case 'auto_learn_session': {
|
|
3930
2725
|
const { instance_id, observations } = args;
|
|
@@ -3945,7 +2740,13 @@ async function handleTool(name, args) {
|
|
|
3945
2740
|
// Only overwrite if this is a success and existing is failure, or topic is new
|
|
3946
2741
|
const existing = await redis.get(key);
|
|
3947
2742
|
if (existing) {
|
|
3948
|
-
|
|
2743
|
+
let existingLesson;
|
|
2744
|
+
try {
|
|
2745
|
+
existingLesson = JSON.parse(existing);
|
|
2746
|
+
}
|
|
2747
|
+
catch {
|
|
2748
|
+
existingLesson = { outcome: 'failure' };
|
|
2749
|
+
}
|
|
3949
2750
|
if (existingLesson.outcome === 'success' && obs.outcome !== 'success') {
|
|
3950
2751
|
skipped.push(topic);
|
|
3951
2752
|
continue;
|
|
@@ -4114,142 +2915,9 @@ async function handleTool(name, args) {
|
|
|
4114
2915
|
}
|
|
4115
2916
|
return lines.join('\n');
|
|
4116
2917
|
}
|
|
4117
|
-
// ── team_synthesize — Team Brain Synthesis ────────────────────────────────
|
|
4118
|
-
case 'team_synthesize': {
|
|
4119
|
-
const { instance_id, topic } = args;
|
|
4120
|
-
const redis = await getConnection(instance_id);
|
|
4121
|
-
// Load history list for this topic (all authors' contributions)
|
|
4122
|
-
const listKey = `cachly:lessons:${topic}`;
|
|
4123
|
-
const all = await redis.lrange(listKey, 0, -1);
|
|
4124
|
-
if (all.length < 2) {
|
|
4125
|
-
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}", ...)\`.`;
|
|
4126
|
-
}
|
|
4127
|
-
const entries = all.map(r => { try {
|
|
4128
|
-
return JSON.parse(r);
|
|
4129
|
-
}
|
|
4130
|
-
catch {
|
|
4131
|
-
return null;
|
|
4132
|
-
} }).filter((e) => e !== null);
|
|
4133
|
-
// Group by outcome
|
|
4134
|
-
const successes = entries.filter(e => e.outcome === 'success');
|
|
4135
|
-
const failures = entries.filter(e => e.outcome === 'failure');
|
|
4136
|
-
const partials = entries.filter(e => e.outcome === 'partial');
|
|
4137
|
-
const authors = [...new Set(entries.map(e => e.author).filter(Boolean))];
|
|
4138
|
-
const hasMultiAuthor = authors.length > 1;
|
|
4139
|
-
// Build canonical merged version
|
|
4140
|
-
// what_worked: pick the most recent success, or longest for most detail
|
|
4141
|
-
const bestSuccess = successes.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime())[0];
|
|
4142
|
-
const whatWorkedCandidates = successes.map(e => e.what_worked).filter(w => w && w.length > 10);
|
|
4143
|
-
const canonicalWorked = whatWorkedCandidates.sort((a, b) => b.length - a.length)[0] ?? bestSuccess?.what_worked ?? '';
|
|
4144
|
-
// what_failed: union of all unique failure reasons
|
|
4145
|
-
const allFailed = [...new Set([...failures, ...partials].map(e => e.what_failed).filter((w) => !!w && w.length > 5))];
|
|
4146
|
-
const severities = entries.map(e => e.severity).filter(Boolean);
|
|
4147
|
-
const canonicalSeverity = severities.includes('critical') ? 'critical' : severities.includes('major') ? 'major' : 'minor';
|
|
4148
|
-
const lines = [
|
|
4149
|
-
`🧬 **Team Brain Synthesis: \`${topic}\`**`,
|
|
4150
|
-
`_${entries.length} entries from ${authors.length} author${authors.length === 1 ? '' : 's'}${hasMultiAuthor ? ` (${authors.join(', ')})` : ''} · ${successes.length} success · ${failures.length} failure · ${partials.length} partial_`,
|
|
4151
|
-
'',
|
|
4152
|
-
`**Canonical "what worked":**`,
|
|
4153
|
-
`> ${canonicalWorked}`,
|
|
4154
|
-
'',
|
|
4155
|
-
allFailed.length > 0 ? `**Avoid (combined failures):**` : '',
|
|
4156
|
-
...allFailed.map(f => `> ❌ ${f}`),
|
|
4157
|
-
allFailed.length > 0 ? '' : '',
|
|
4158
|
-
`**Suggested canonical lesson:**`,
|
|
4159
|
-
'```',
|
|
4160
|
-
`learn_from_attempts(`,
|
|
4161
|
-
` topic = "${topic}",`,
|
|
4162
|
-
` outcome = "success",`,
|
|
4163
|
-
` what_worked = "${canonicalWorked.replace(/"/g, "'")}",`,
|
|
4164
|
-
allFailed.length > 0 ? ` what_failed = "${allFailed[0].replace(/"/g, "'")}",` : '',
|
|
4165
|
-
` severity = "${canonicalSeverity}",`,
|
|
4166
|
-
`)`,
|
|
4167
|
-
'```',
|
|
4168
|
-
'',
|
|
4169
|
-
hasMultiAuthor
|
|
4170
|
-
? `💡 _${authors.length} team members contributed to this synthesis. Store the canonical version to replace individual entries._`
|
|
4171
|
-
: `💡 _Single author — more value when multiple team members contribute to the same topic._`,
|
|
4172
|
-
].filter(l => l !== undefined).join('\n');
|
|
4173
|
-
return lines;
|
|
4174
|
-
}
|
|
4175
2918
|
// ── brain_doctor ──────────────────────────────────────────────────────────
|
|
4176
|
-
// ── memory_crystalize ─────────────────────────────────────────────────────
|
|
4177
|
-
case 'memory_crystalize': {
|
|
4178
|
-
const { instance_id, label: crystalLabel = '' } = args;
|
|
4179
|
-
const redis = await getConnection(instance_id);
|
|
4180
|
-
const now = new Date();
|
|
4181
|
-
const week = `${now.getFullYear()}-W${String(Math.ceil((now.getDate() - now.getDay() + 10) / 7)).padStart(2, '0')}`;
|
|
4182
|
-
const effectiveLabel = crystalLabel || `${now.toISOString().slice(0, 7)} Crystal`;
|
|
4183
|
-
// Read session history
|
|
4184
|
-
const sessionHistory = await redis.lrange('cachly:session:history', 0, 49);
|
|
4185
|
-
// Read all auto-learned lessons
|
|
4186
|
-
const allLessonKeys = [];
|
|
4187
|
-
const ls = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
|
|
4188
|
-
await new Promise((res, rej) => {
|
|
4189
|
-
ls.on('data', (b) => allLessonKeys.push(...b));
|
|
4190
|
-
ls.on('end', res);
|
|
4191
|
-
ls.on('error', rej);
|
|
4192
|
-
});
|
|
4193
|
-
const allLessons = [];
|
|
4194
|
-
for (const k of allLessonKeys) {
|
|
4195
|
-
const raw = await redis.get(k);
|
|
4196
|
-
if (!raw)
|
|
4197
|
-
continue;
|
|
4198
|
-
try {
|
|
4199
|
-
allLessons.push(JSON.parse(raw));
|
|
4200
|
-
}
|
|
4201
|
-
catch { /* skip */ }
|
|
4202
|
-
}
|
|
4203
|
-
// Group lessons by top-level category
|
|
4204
|
-
const categoryMap = new Map();
|
|
4205
|
-
for (const l of allLessons) {
|
|
4206
|
-
const cat = l.topic.split(':')[0] || 'misc';
|
|
4207
|
-
if (!categoryMap.has(cat))
|
|
4208
|
-
categoryMap.set(cat, []);
|
|
4209
|
-
categoryMap.get(cat).push(l);
|
|
4210
|
-
}
|
|
4211
|
-
// Build top patterns (most frequent categories with a representative insight)
|
|
4212
|
-
const topPatterns = [];
|
|
4213
|
-
for (const [cat, catLessons] of [...categoryMap.entries()].sort((a, b) => b[1].length - a[1].length).slice(0, 8)) {
|
|
4214
|
-
const successLessons = catLessons.filter(l => l.outcome === 'success');
|
|
4215
|
-
const best = successLessons[0] ?? catLessons[0];
|
|
4216
|
-
if (!best)
|
|
4217
|
-
continue;
|
|
4218
|
-
topPatterns.push({
|
|
4219
|
-
category: cat,
|
|
4220
|
-
insight: best.what_worked.slice(0, 120),
|
|
4221
|
-
count: catLessons.length,
|
|
4222
|
-
});
|
|
4223
|
-
}
|
|
4224
|
-
const crystal = {
|
|
4225
|
-
label: effectiveLabel,
|
|
4226
|
-
ts: now.toISOString(),
|
|
4227
|
-
session_count: sessionHistory.length,
|
|
4228
|
-
lesson_count: allLessons.length,
|
|
4229
|
-
top_patterns: topPatterns,
|
|
4230
|
-
categories: [...categoryMap.keys()],
|
|
4231
|
-
created_from: `${sessionHistory.length} sessions, ${allLessons.length} lessons`,
|
|
4232
|
-
};
|
|
4233
|
-
const crystalJson = JSON.stringify(crystal);
|
|
4234
|
-
await redis.set('cachly:crystal:latest', crystalJson);
|
|
4235
|
-
await redis.expire('cachly:crystal:latest', 90 * 86400);
|
|
4236
|
-
await redis.set(`cachly:crystal:${week}`, crystalJson);
|
|
4237
|
-
await redis.expire(`cachly:crystal:${week}`, 365 * 86400);
|
|
4238
|
-
const lines = [
|
|
4239
|
-
`💎 **Memory Crystal created: ${effectiveLabel}**`,
|
|
4240
|
-
``,
|
|
4241
|
-
`📊 Compressed: **${sessionHistory.length} sessions** + **${allLessons.length} lessons** → ${topPatterns.length} top patterns`,
|
|
4242
|
-
``,
|
|
4243
|
-
`**Top patterns by category:**`,
|
|
4244
|
-
...topPatterns.slice(0, 6).map(p => ` • **${p.category}** (${p.count}×): ${p.insight.slice(0, 90)}`),
|
|
4245
|
-
``,
|
|
4246
|
-
`💡 This crystal will appear in every future \`session_start\` briefing.`,
|
|
4247
|
-
`💡 Re-run \`memory_crystalize\` monthly to keep it fresh.`,
|
|
4248
|
-
];
|
|
4249
|
-
return lines.join('\n');
|
|
4250
|
-
}
|
|
4251
2919
|
case 'brain_doctor': {
|
|
4252
|
-
const { instance_id
|
|
2920
|
+
const { instance_id } = args;
|
|
4253
2921
|
const redis = await getConnection(instance_id);
|
|
4254
2922
|
const issues = [];
|
|
4255
2923
|
const checks = [];
|
|
@@ -4296,15 +2964,6 @@ async function handleTool(name, args) {
|
|
|
4296
2964
|
const unusedLessons = lessons.filter(l => (l.recall_count ?? 0) === 0);
|
|
4297
2965
|
// Critical lessons
|
|
4298
2966
|
const criticalLessons = lessons.filter(l => l.severity === 'critical');
|
|
4299
|
-
// Confidence decay analysis
|
|
4300
|
-
const staleLessons = lessons.filter(l => l.outcome === 'success' && calculateConfidence(l) < CONFIDENCE_STALE_VALUE);
|
|
4301
|
-
const warnLessons = lessons.filter(l => l.outcome === 'success' && calculateConfidence(l) >= CONFIDENCE_STALE_VALUE && calculateConfidence(l) < CONFIDENCE_WARN_VALUE);
|
|
4302
|
-
const withAudit = lessons.filter(l => (l.audit_trail ?? []).length > 1);
|
|
4303
|
-
const teamLessons = lessons.filter(l => l.author);
|
|
4304
|
-
const uniqueAuthors = new Set(lessons.map(l => l.author).filter(Boolean));
|
|
4305
|
-
// Effective IQ boost: total recalls / lessons (how much the brain actually helped)
|
|
4306
|
-
const totalRecalls = lessons.reduce((sum, l) => sum + (l.recall_count ?? 0), 0);
|
|
4307
|
-
const iqBoostPct = lessons.length > 0 ? Math.min(100, Math.round((totalRecalls / lessons.length) * 10)) : 0;
|
|
4308
2967
|
// Quality score (0-100)
|
|
4309
2968
|
let score = 50;
|
|
4310
2969
|
if (lessonKeys.length >= 5)
|
|
@@ -4322,72 +2981,10 @@ async function handleTool(name, args) {
|
|
|
4322
2981
|
const unusedRatio = lessons.length > 0 ? unusedLessons.length / lessons.length : 0;
|
|
4323
2982
|
if (unusedRatio < 0.5)
|
|
4324
2983
|
score += 10;
|
|
4325
|
-
if (staleLessons.length === 0)
|
|
4326
|
-
score += 5;
|
|
4327
|
-
if (uniqueAuthors.size >= 2)
|
|
4328
|
-
score += 5; // team collaboration bonus
|
|
4329
2984
|
const scoreEmoji = score >= 80 ? '🟢' : score >= 50 ? '🟡' : '🔴';
|
|
4330
|
-
const iqEmoji = iqBoostPct >= 50 ? '🚀' : iqBoostPct >= 20 ? '📈' : '💤';
|
|
4331
2985
|
checks.push(`${scoreEmoji} **Brain Quality Score: ${score}/100**`);
|
|
4332
|
-
checks.push(
|
|
4333
|
-
checks.push(`📚 **Lessons:** ${lessonKeys.length} (${criticalLessons.length} critical · ${withAudit.length} with audit trail · ${teamLessons.length} from team)`);
|
|
2986
|
+
checks.push(`📚 **Lessons:** ${lessonKeys.length} (${criticalLessons.length} critical)`);
|
|
4334
2987
|
checks.push(`💾 **Context entries:** ${ctxCount}`);
|
|
4335
|
-
checks.push(`🎯 **Confidence:** ${lessons.length - staleLessons.length - warnLessons.length} fresh · ${warnLessons.length} warn · ${staleLessons.length} stale`);
|
|
4336
|
-
checks.push(`⏱️ **Decay config:** warn after ${CONFIDENCE_WARN_DAYS}d · stale after ${CONFIDENCE_STALE_DAYS}d`);
|
|
4337
|
-
if (uniqueAuthors.size >= 2) {
|
|
4338
|
-
checks.push(`👥 **Team:** ${uniqueAuthors.size} contributors (${[...uniqueAuthors].join(', ')})`);
|
|
4339
|
-
}
|
|
4340
|
-
// Stale index detection
|
|
4341
|
-
try {
|
|
4342
|
-
const lastIndexRaw = await redis.get('cachly:index:last_run');
|
|
4343
|
-
if (lastIndexRaw) {
|
|
4344
|
-
const lastIndexAge = Math.round((Date.now() - new Date(lastIndexRaw).getTime()) / 86_400_000);
|
|
4345
|
-
if (lastIndexAge > 7) {
|
|
4346
|
-
issues.push(`🔄 Index is ${lastIndexAge}d stale — run \`index_project\` to re-sync semantic search`);
|
|
4347
|
-
}
|
|
4348
|
-
else {
|
|
4349
|
-
checks.push(`🗂️ **Semantic index:** ${lastIndexAge}d old (fresh)`);
|
|
4350
|
-
}
|
|
4351
|
-
}
|
|
4352
|
-
else {
|
|
4353
|
-
issues.push(`💡 No semantic index — run \`index_project(dir="<your-src>")\` to enable semantic search`);
|
|
4354
|
-
}
|
|
4355
|
-
}
|
|
4356
|
-
catch { /* non-critical */ }
|
|
4357
|
-
// Memory crystal status
|
|
4358
|
-
try {
|
|
4359
|
-
const crystalRaw = await redis.get('cachly:crystal:latest');
|
|
4360
|
-
if (crystalRaw) {
|
|
4361
|
-
const crystal = JSON.parse(crystalRaw);
|
|
4362
|
-
const crystalAge = Math.round((Date.now() - new Date(crystal.ts).getTime()) / 86_400_000);
|
|
4363
|
-
checks.push(`💎 **Memory Crystal:** ${crystal.label} (${crystalAge}d ago)`);
|
|
4364
|
-
if (crystalAge > 30)
|
|
4365
|
-
issues.push(`💡 Memory Crystal is ${crystalAge}d old — re-run \`memory_crystalize\` to compress new sessions`);
|
|
4366
|
-
}
|
|
4367
|
-
else if (lessonKeys.length >= 10) {
|
|
4368
|
-
issues.push(`💡 ${lessonKeys.length} lessons but no Memory Crystal — run \`memory_crystalize\` to compress wisdom`);
|
|
4369
|
-
}
|
|
4370
|
-
}
|
|
4371
|
-
catch { /* non-critical */ }
|
|
4372
|
-
// openclaw cross-promo (check package.json in workspace)
|
|
4373
|
-
if (drWorkspacePath) {
|
|
4374
|
-
try {
|
|
4375
|
-
const pkgPath = drWorkspacePath.replace(/\/$/, '') + '/package.json';
|
|
4376
|
-
const pkgRaw = readFileSync(pkgPath, 'utf-8');
|
|
4377
|
-
const pkg = JSON.parse(pkgRaw);
|
|
4378
|
-
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
4379
|
-
const hasLLMDep = ['openai', '@anthropic-ai/sdk', '@google/generative-ai', 'mistralai', 'cohere-ai'].some(d => d in allDeps);
|
|
4380
|
-
const hasOpenclaw = '@cachly-dev/openclaw' in allDeps;
|
|
4381
|
-
if (hasLLMDep && !hasOpenclaw) {
|
|
4382
|
-
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\``);
|
|
4383
|
-
issues.push(` → \`npm install @cachly-dev/openclaw\` cuts LLM costs 60–90% with 3 lines of code`);
|
|
4384
|
-
}
|
|
4385
|
-
else if (hasOpenclaw) {
|
|
4386
|
-
checks.push(`✅ **@cachly-dev/openclaw installed** (LLM cost caching active)`);
|
|
4387
|
-
}
|
|
4388
|
-
}
|
|
4389
|
-
catch { /* no package.json or unreadable */ }
|
|
4390
|
-
}
|
|
4391
2988
|
if (lastSession) {
|
|
4392
2989
|
const ageMin = Math.round((Date.now() - new Date(lastSession.ts).getTime()) / 60000);
|
|
4393
2990
|
const ageStr = ageMin < 60 ? `${ageMin}m` : ageMin < 1440 ? `${Math.round(ageMin / 60)}h` : `${Math.round(ageMin / 1440)}d`;
|
|
@@ -4402,22 +2999,12 @@ async function handleTool(name, args) {
|
|
|
4402
2999
|
else if (lessonKeys.length < 5) {
|
|
4403
3000
|
issues.push(`💡 Only ${lessonKeys.length} lessons — add more after each problem solved`);
|
|
4404
3001
|
}
|
|
4405
|
-
if (iqBoostPct === 0 && lessons.length >= 5) {
|
|
4406
|
-
issues.push(`💤 **IQ Boost is 0%** — lessons exist but are never recalled. Use \`recall_best_solution\` BEFORE tasks.`);
|
|
4407
|
-
}
|
|
4408
3002
|
if (ctxCount === 0) {
|
|
4409
3003
|
issues.push('💡 No context — use `remember_context` to cache architecture docs, ADRs, etc.');
|
|
4410
3004
|
}
|
|
4411
3005
|
if (openFailures.length > 0) {
|
|
4412
3006
|
issues.push(`⚠️ ${openFailures.length} unresolved failure${openFailures.length > 1 ? 's' : ''}: ${openFailures.slice(0, 3).map(l => `\`${l.topic}\``).join(', ')}`);
|
|
4413
3007
|
}
|
|
4414
|
-
if (staleLessons.length > 0) {
|
|
4415
|
-
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(', ')}`);
|
|
4416
|
-
issues.push(` → Re-verify with \`recall_best_solution\` to reset confidence clock`);
|
|
4417
|
-
}
|
|
4418
|
-
if (warnLessons.length > 0) {
|
|
4419
|
-
issues.push(`⚠️ ${warnLessons.length} lesson${warnLessons.length > 1 ? 's' : ''} aging (>${CONFIDENCE_WARN_DAYS}d): ${warnLessons.slice(0, 3).map(l => `\`${l.topic}\``).join(', ')}`);
|
|
4420
|
-
}
|
|
4421
3008
|
if (unusedRatio > 0.7 && lessons.length > 5) {
|
|
4422
3009
|
issues.push(`💡 ${unusedLessons.length} lessons never recalled — verify topics match your workflow`);
|
|
4423
3010
|
}
|
|
@@ -4433,86 +3020,6 @@ async function handleTool(name, args) {
|
|
|
4433
3020
|
}
|
|
4434
3021
|
return lines.join('\n');
|
|
4435
3022
|
}
|
|
4436
|
-
// ── recall_at — Brain Archaeology ────────────────────────────────────────
|
|
4437
|
-
case 'recall_at': {
|
|
4438
|
-
const { instance_id, topic, date } = args;
|
|
4439
|
-
const redis = await getConnection(instance_id);
|
|
4440
|
-
const cutoff = new Date(date).getTime();
|
|
4441
|
-
if (isNaN(cutoff))
|
|
4442
|
-
return `❌ Invalid date "${date}". Use ISO format: "2026-01-15"`;
|
|
4443
|
-
const listKey = `cachly:lessons:${topic}`;
|
|
4444
|
-
const all = await redis.lrange(listKey, 0, -1);
|
|
4445
|
-
if (all.length === 0)
|
|
4446
|
-
return `📭 No history found for \`${topic}\`. Lessons are stored via \`learn_from_attempts\`.`;
|
|
4447
|
-
const before = all
|
|
4448
|
-
.map(raw => { try {
|
|
4449
|
-
return JSON.parse(raw);
|
|
4450
|
-
}
|
|
4451
|
-
catch {
|
|
4452
|
-
return null;
|
|
4453
|
-
} })
|
|
4454
|
-
.filter((l) => l !== null && new Date(l.ts).getTime() <= cutoff)
|
|
4455
|
-
.sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
|
|
4456
|
-
if (before.length === 0)
|
|
4457
|
-
return `📭 No entries for \`${topic}\` found before **${date}**. Earliest entry: ${new Date(JSON.parse(all[0]).ts).toLocaleDateString('de-DE')}.`;
|
|
4458
|
-
const lines = [
|
|
4459
|
-
`🏺 **Brain Archaeology: \`${topic}\` before ${date}**`,
|
|
4460
|
-
`_${before.length} of ${all.length} total entries shown_`,
|
|
4461
|
-
'',
|
|
4462
|
-
];
|
|
4463
|
-
for (const l of before.slice(-10)) {
|
|
4464
|
-
const emoji = l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
|
|
4465
|
-
const d = new Date(l.ts).toLocaleDateString('de-DE');
|
|
4466
|
-
lines.push(`**${d}** ${emoji} ${l.outcome}`);
|
|
4467
|
-
if (l.what_worked)
|
|
4468
|
-
lines.push(` → ${l.what_worked.slice(0, 100)}`);
|
|
4469
|
-
lines.push('');
|
|
4470
|
-
}
|
|
4471
|
-
lines.push(`_Full evolution: ${all.map(r => { try {
|
|
4472
|
-
const l = JSON.parse(r);
|
|
4473
|
-
return l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
|
|
4474
|
-
}
|
|
4475
|
-
catch {
|
|
4476
|
-
return '?';
|
|
4477
|
-
} }).join(' → ')}_`);
|
|
4478
|
-
return lines.join('\n');
|
|
4479
|
-
}
|
|
4480
|
-
// ── trace_dependency — Causal Chain ──────────────────────────────────────
|
|
4481
|
-
case 'trace_dependency': {
|
|
4482
|
-
const { instance_id, dependency, mark_review = false } = args;
|
|
4483
|
-
const redis = await getConnection(instance_id);
|
|
4484
|
-
const depKey = `cachly:dep:${dependency}`;
|
|
4485
|
-
const raw = await redis.get(depKey);
|
|
4486
|
-
if (!raw)
|
|
4487
|
-
return `📭 No lessons found that depend on \`${dependency}\`.\n\nAdd dependencies via: \`learn_from_attempts(..., depends_on=["${dependency}"])\``;
|
|
4488
|
-
const topics = JSON.parse(raw);
|
|
4489
|
-
const lines = [
|
|
4490
|
-
`🔗 **Causal Chain: \`${dependency}\`** — ${topics.length} dependent lesson${topics.length === 1 ? '' : 's'}`,
|
|
4491
|
-
'',
|
|
4492
|
-
];
|
|
4493
|
-
for (const t of topics) {
|
|
4494
|
-
const lessonRaw = await redis.get(`cachly:lesson:best:${t}`);
|
|
4495
|
-
if (!lessonRaw) {
|
|
4496
|
-
lines.push(` • \`${t}\` _(lesson deleted)_`);
|
|
4497
|
-
continue;
|
|
4498
|
-
}
|
|
4499
|
-
const lesson = JSON.parse(lessonRaw);
|
|
4500
|
-
const emoji = lesson.outcome === 'success' ? '✅' : lesson.outcome === 'partial' ? '⚠️' : '❌';
|
|
4501
|
-
const reviewBadge = lesson.needs_review ? ' 🔍 **needs_review**' : '';
|
|
4502
|
-
lines.push(` ${emoji} \`${t}\` (${lesson.severity ?? 'major'})${reviewBadge}`);
|
|
4503
|
-
if (mark_review) {
|
|
4504
|
-
const updated = { ...lesson, needs_review: true };
|
|
4505
|
-
await redis.set(`cachly:lesson:best:${t}`, JSON.stringify(updated));
|
|
4506
|
-
}
|
|
4507
|
-
}
|
|
4508
|
-
if (mark_review) {
|
|
4509
|
-
lines.push('', `🔍 All ${topics.length} lessons marked as **needs_review** — verify they still work with the changed dependency.`);
|
|
4510
|
-
}
|
|
4511
|
-
else {
|
|
4512
|
-
lines.push('', `_Run with \`mark_review: true\` to flag all dependent lessons for re-verification._`);
|
|
4513
|
-
}
|
|
4514
|
-
return lines.join('\n');
|
|
4515
|
-
}
|
|
4516
3023
|
// ── global_learn ──────────────────────────────────────────────────────────
|
|
4517
3024
|
case 'global_learn': {
|
|
4518
3025
|
const { instance_id, topic, lesson, severity = 'minor', tags = [] } = args;
|
|
@@ -4748,144 +3255,62 @@ async function handleTool(name, args) {
|
|
|
4748
3255
|
// Generate copilot-instructions.md content
|
|
4749
3256
|
const tier = inst.tier.toUpperCase();
|
|
4750
3257
|
const smartRecallNote = hasVector
|
|
4751
|
-
? '
|
|
4752
|
-
: '
|
|
3258
|
+
? '3. Call `smart_recall` with keywords for semantic context search'
|
|
3259
|
+
: '3. Call `recall_context` with relevant key patterns like "arch:*" or "file:*"';
|
|
4753
3260
|
const layerNote = hasVector
|
|
4754
|
-
? `Layer 3 (Semantic): smart_recall uses pgvector HNSW on your ${tier} instance`
|
|
4755
|
-
: `Layer 3 (Autopilot):
|
|
4756
|
-
const copilotInstructions = `# cachly AI
|
|
4757
|
-
|
|
4758
|
-
> 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.
|
|
4759
|
-
|
|
4760
|
-
---
|
|
4761
|
-
|
|
4762
|
-
## 🔴 MANDATORY RULES — Always enforced, no exceptions
|
|
4763
|
-
|
|
4764
|
-
### Rule 1 — Session Start (FIRST thing in every window)
|
|
4765
|
-
Call \`session_start\` **before reading any source files or making any changes**:
|
|
4766
|
-
|
|
4767
|
-
\`\`\`
|
|
4768
|
-
session_start(
|
|
4769
|
-
instance_id = "${instance_id}",
|
|
4770
|
-
focus = "<what you plan to work on today>",
|
|
4771
|
-
)
|
|
4772
|
-
\`\`\`
|
|
4773
|
-
|
|
4774
|
-
Returns: last session summary, handoff from previous window, recent lessons, open failures, brain health.
|
|
4775
|
-
|
|
4776
|
-
**If \`session_start\` is unavailable**, fall back:
|
|
4777
|
-
1. \`smart_recall("recent lessons <topic>")\`
|
|
4778
|
-
2. \`recall_best_solution("<topic>")\`
|
|
4779
|
-
|
|
4780
|
-
### Rule 2 — Learn After Every Fix/Deploy/Discovery (AUTOMATIC — no prompting needed)
|
|
4781
|
-
After **every** bug fix, deployment, failed attempt, or discovery — call \`learn_from_attempts\` immediately:
|
|
4782
|
-
|
|
4783
|
-
\`\`\`
|
|
4784
|
-
learn_from_attempts(
|
|
4785
|
-
instance_id = "${instance_id}",
|
|
4786
|
-
topic = "category:keyword", # deploy:web | fix:auth | debug:db | infra:docker
|
|
4787
|
-
outcome = "success" | "failure" | "partial",
|
|
4788
|
-
what_worked = "exact approach or command that worked",
|
|
4789
|
-
what_failed = "what did NOT work (prevents future wasted time)",
|
|
4790
|
-
severity = "critical" | "major" | "minor",
|
|
4791
|
-
commands = ["exact command that worked"],
|
|
4792
|
-
file_paths = ["path/to/changed/file"],
|
|
4793
|
-
)
|
|
4794
|
-
\`\`\`
|
|
4795
|
-
|
|
4796
|
-
**These events MUST automatically trigger \`learn_from_attempts\`:**
|
|
4797
|
-
- ✅ Successfully deployed → \`topic="deploy:<component>", outcome="success"\`
|
|
4798
|
-
- ✅ Fixed a bug → \`topic="fix:<description>", outcome="success"\`
|
|
4799
|
-
- ✅ Discovered root cause → \`topic="debug:<area>", outcome="partial"\`
|
|
4800
|
-
- ✅ Tried something that failed → \`topic="<area>", outcome="failure", what_failed="..."\`
|
|
4801
|
-
- ✅ Found a config quirk → \`topic="infra:<topic>", outcome="success"\`
|
|
4802
|
-
- ✅ Ran a working command → store it in \`commands=[...]\`
|
|
3261
|
+
? `Layer 3 (Semantic): smart_recall uses pgvector HNSW on your ${tier} instance for meaning-based search`
|
|
3262
|
+
: `Layer 3 (Autopilot): This file — upgrade to Speed/Business tier to unlock smart_recall`;
|
|
3263
|
+
const copilotInstructions = `# cachly AI Memory — ${project_description}
|
|
4803
3264
|
|
|
4804
|
-
|
|
4805
|
-
\`\`\`
|
|
4806
|
-
session_handoff(
|
|
4807
|
-
instance_id = "${instance_id}",
|
|
4808
|
-
completed_tasks = ["what was done"],
|
|
4809
|
-
remaining_tasks = ["what is still to do"],
|
|
4810
|
-
files_changed = [{"path": "src/...", "status": "modified", "description": "what changed"}],
|
|
4811
|
-
instructions = "IMPORTANT: what the next window MUST know to not break things",
|
|
4812
|
-
context_summary = "current state of the system",
|
|
4813
|
-
)
|
|
4814
|
-
\`\`\`
|
|
4815
|
-
|
|
4816
|
-
The next \`session_start\` automatically includes this handoff. The new window sees exactly what's done, what remains, and what NOT to break.
|
|
3265
|
+
## 3-Layer AI Memory System
|
|
4817
3266
|
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
instance_id = "${instance_id}",
|
|
4822
|
-
summary = "What was accomplished (2-3 sentences with specifics)",
|
|
4823
|
-
files_changed = ["list", "of", "changed", "files"],
|
|
4824
|
-
lessons_learned = <number of learn_from_attempts calls made>,
|
|
4825
|
-
)
|
|
4826
|
-
\`\`\`
|
|
3267
|
+
- **Layer 1 — Storage:** cachly instance \`${inst.name}\` (${tier}) — persistent Valkey brain, survives restarts
|
|
3268
|
+
- **Layer 2 — Tools:** learn_from_attempts · recall_best_solution · smart_recall — the memory API
|
|
3269
|
+
- **Layer 3 — Autopilot (this file):** instructs the AI to run memory tools automatically
|
|
4827
3270
|
|
|
4828
|
-
|
|
3271
|
+
## BEFORE every task — check memory first
|
|
4829
3272
|
|
|
4830
|
-
|
|
4831
|
-
- \`
|
|
4832
|
-
-
|
|
4833
|
-
-
|
|
4834
|
-
- No manual invalidation needed
|
|
3273
|
+
1. Call \`recall_best_solution\` with the task topic to get known solutions
|
|
3274
|
+
- \`recall_best_solution("deploy:web")\` → returns working deploy command
|
|
3275
|
+
- \`recall_best_solution("debug:redis-tls")\` → returns root cause + fix
|
|
3276
|
+
- If a solution exists, use it — do NOT re-discover from scratch
|
|
4835
3277
|
|
|
4836
|
-
|
|
3278
|
+
2. Call \`recall_context\` with the area you're working in
|
|
3279
|
+
- \`recall_context("arch:*")\` → cached architecture decisions
|
|
3280
|
+
- \`recall_context("file:src/auth*")\` → cached file summaries
|
|
4837
3281
|
|
|
4838
|
-
|
|
3282
|
+
${smartRecallNote}
|
|
3283
|
+
- \`smart_recall("how does authentication work")\` → returns cached auth notes
|
|
4839
3284
|
|
|
4840
|
-
|
|
4841
|
-
|---|---|---|
|
|
4842
|
-
| Deploy anything | \`recall_best_solution("deploy:<component>")\` | \`learn_from_attempts(topic="deploy:...")\` |
|
|
4843
|
-
| Fix a bug | \`recall_best_solution("fix:<area>")\` | \`learn_from_attempts(topic="fix:...")\` |
|
|
4844
|
-
| Add a feature | \`session_start(focus="feat:<area>")\` | \`learn_from_attempts(topic="feat:...")\` |
|
|
4845
|
-
| Infra/server work | \`recall_best_solution("infra:<topic>")\` | \`learn_from_attempts(topic="infra:...")\` |
|
|
4846
|
-
| Debug an issue | \`smart_recall("<error message or symptom>")\` | \`learn_from_attempts(topic="debug:...")\` |
|
|
3285
|
+
4. If nothing is found, proceed normally — then save (see below)
|
|
4847
3286
|
|
|
4848
|
-
|
|
3287
|
+
## AFTER every non-trivial task — save to memory
|
|
4849
3288
|
|
|
4850
|
-
|
|
3289
|
+
1. Call \`learn_from_attempts\` for deploy/debug/fix tasks:
|
|
3290
|
+
- Always set outcome = "success" | "failure" | "partial"
|
|
3291
|
+
- Always include what_failed — it prevents future wasted time
|
|
3292
|
+
- 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")\`
|
|
4851
3293
|
|
|
4852
|
-
|
|
4853
|
-
|
|
4854
|
-
|
|
4855
|
-
| \`session_end\` | **LAST** — mandatory at end, auto-learns from summary |
|
|
4856
|
-
| \`session_handoff\` | When closing window with remaining tasks |
|
|
4857
|
-
| \`learn_from_attempts\` | **AUTOMATIC** after every fix/deploy/discovery |
|
|
4858
|
-
| \`recall_best_solution\` | Before any non-trivial task |
|
|
4859
|
-
| \`remember_context\` | After analyzing code — save findings for future sessions |
|
|
4860
|
-
${smartRecallNote ? `| \`smart_recall\` | Search brain by meaning/keywords |\n` : ''}\
|
|
4861
|
-
| \`recall_context\` | Get exact key (supports glob: \`arch:*\`, \`file:*\`) |
|
|
4862
|
-
| \`brain_search\` | BM25+ full-text search over all brain data |
|
|
4863
|
-
| \`auto_learn_session\` | Batch-learn from a list of observations (optional) |
|
|
4864
|
-
| \`index_project\` | Index source files (smart hash, skips unchanged files) |
|
|
4865
|
-
| \`list_remembered\` | See what's cached in the brain |
|
|
4866
|
-
| \`forget_context\` | Remove stale context |
|
|
4867
|
-
|
|
4868
|
-
---
|
|
3294
|
+
2. Call \`remember_context\` for code/architecture findings:
|
|
3295
|
+
- \`remember_context("arch:auth", "Uses Keycloak with JWT...", category="architecture")\`
|
|
3296
|
+
- \`remember_context("file:src/index.ts", "Entry point, registers routes...", category="file_summary")\`
|
|
4869
3297
|
|
|
4870
3298
|
## Instance Details
|
|
4871
3299
|
|
|
4872
|
-
-
|
|
4873
|
-
-
|
|
4874
|
-
-
|
|
4875
|
-
-
|
|
4876
|
-
-
|
|
4877
|
-
|
|
4878
|
-
---
|
|
3300
|
+
- Instance ID: \`${instance_id}\`
|
|
3301
|
+
- Instance name: ${inst.name}
|
|
3302
|
+
- Tier: ${tier}
|
|
3303
|
+
- ${layerNote}
|
|
3304
|
+
- Embedding provider: ${providerArg}
|
|
4879
3305
|
|
|
4880
|
-
##
|
|
3306
|
+
## Quick reference
|
|
4881
3307
|
|
|
4882
3308
|
\`\`\`
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
|
|
3309
|
+
recall_best_solution("topic") # before task
|
|
3310
|
+
smart_recall("natural language") # find by meaning
|
|
3311
|
+
learn_from_attempts(...) # after task
|
|
3312
|
+
remember_context("key", "content") # save analysis
|
|
4886
3313
|
\`\`\`
|
|
4887
|
-
|
|
4888
|
-
Result: Your AI **never solves the same problem twice** and always picks up exactly where it left off. 🚀
|
|
4889
3314
|
`;
|
|
4890
3315
|
const lines = [
|
|
4891
3316
|
`🧠 **cachly AI Memory Setup Complete**`,
|
|
@@ -4910,8 +3335,8 @@ Result: Your AI **never solves the same problem twice** and always picks up exac
|
|
|
4910
3335
|
`**Step 3 — copilot-instructions.md (Layer 3 Autopilot):**`,
|
|
4911
3336
|
``,
|
|
4912
3337
|
...(project_dir
|
|
4913
|
-
? [
|
|
4914
|
-
: [`Copy this to \`.github/copilot-instructions.md\`
|
|
3338
|
+
? [`Writing to \`${project_dir}/.github/copilot-instructions.md\`…`]
|
|
3339
|
+
: [`Copy this to \`.github/copilot-instructions.md\` in your project:`]),
|
|
4915
3340
|
``,
|
|
4916
3341
|
`\`\`\`markdown`,
|
|
4917
3342
|
copilotInstructions,
|
|
@@ -4925,56 +3350,14 @@ Result: Your AI **never solves the same problem twice** and always picks up exac
|
|
|
4925
3350
|
``,
|
|
4926
3351
|
`Result: Your AI never solves the same problem twice. 🚀`,
|
|
4927
3352
|
];
|
|
4928
|
-
//
|
|
3353
|
+
// Optionally write the file
|
|
4929
3354
|
if (project_dir) {
|
|
4930
|
-
const { mkdir, writeFile
|
|
4931
|
-
const
|
|
4932
|
-
|
|
4933
|
-
const
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
ide: 'GitHub Copilot',
|
|
4937
|
-
path: join(project_dir, '.github', 'copilot-instructions.md'),
|
|
4938
|
-
content: copilotInstructions,
|
|
4939
|
-
});
|
|
4940
|
-
// Claude Code — CLAUDE.md in project root
|
|
4941
|
-
if (await exists(join(project_dir, 'CLAUDE.md')) || await exists(join(project_dir, '.claude'))) {
|
|
4942
|
-
targets.push({
|
|
4943
|
-
ide: 'Claude Code',
|
|
4944
|
-
path: join(project_dir, 'CLAUDE.md'),
|
|
4945
|
-
content: copilotInstructions,
|
|
4946
|
-
});
|
|
4947
|
-
}
|
|
4948
|
-
// Cursor — .cursor/rules (new format) or .cursorrules (legacy)
|
|
4949
|
-
if (await exists(join(project_dir, '.cursor')) ||
|
|
4950
|
-
await exists(join(project_dir, '.cursorrules'))) {
|
|
4951
|
-
const cursorDir = join(project_dir, '.cursor');
|
|
4952
|
-
await mkdir(cursorDir, { recursive: true });
|
|
4953
|
-
targets.push({
|
|
4954
|
-
ide: 'Cursor',
|
|
4955
|
-
path: join(cursorDir, 'rules'),
|
|
4956
|
-
content: copilotInstructions,
|
|
4957
|
-
});
|
|
4958
|
-
}
|
|
4959
|
-
// Windsurf — .windsurfrules
|
|
4960
|
-
if (await exists(join(project_dir, '.windsurfrules')) ||
|
|
4961
|
-
await exists(join(project_dir, '.windsurf'))) {
|
|
4962
|
-
targets.push({
|
|
4963
|
-
ide: 'Windsurf',
|
|
4964
|
-
path: join(project_dir, '.windsurfrules'),
|
|
4965
|
-
content: copilotInstructions,
|
|
4966
|
-
});
|
|
4967
|
-
}
|
|
4968
|
-
// VS Code (Copilot) — already covered by .github/copilot-instructions.md above
|
|
4969
|
-
// Continue.dev — .continue/config.json is JSON, not markdown — skip, copilot-instructions handles it
|
|
4970
|
-
const written = [];
|
|
4971
|
-
for (const target of targets) {
|
|
4972
|
-
const dir = target.path.substring(0, target.path.lastIndexOf('/'));
|
|
4973
|
-
await mkdir(dir, { recursive: true });
|
|
4974
|
-
await writeFile(target.path, target.content, 'utf-8');
|
|
4975
|
-
written.push(`✅ [${target.ide}] → \`${target.path.replace(project_dir, '.')}\``);
|
|
4976
|
-
}
|
|
4977
|
-
lines.push(...written);
|
|
3355
|
+
const { mkdir, writeFile } = await import('node:fs/promises');
|
|
3356
|
+
const githubDir = join(project_dir, '.github');
|
|
3357
|
+
await mkdir(githubDir, { recursive: true });
|
|
3358
|
+
const filePath = join(githubDir, 'copilot-instructions.md');
|
|
3359
|
+
await writeFile(filePath, copilotInstructions, 'utf-8');
|
|
3360
|
+
lines.splice(lines.indexOf(`Writing to \`${project_dir}/.github/copilot-instructions.md\`…`) + 1, 0, `✅ Written to \`${project_dir}/.github/copilot-instructions.md\``);
|
|
4978
3361
|
}
|
|
4979
3362
|
return lines.join('\n');
|
|
4980
3363
|
}
|
|
@@ -5098,236 +3481,15 @@ async function handleBulkLockStream(name, args) {
|
|
|
5098
3481
|
return (`✅ **cache_stream_get** – ${len} chunk(s) retrieved for \`${key}\`.\n\n` +
|
|
5099
3482
|
`**Preview** (first 500 chars):\n\`\`\`\n${preview}${preview.length < chunks.join('').length ? '…' : ''}\n\`\`\``);
|
|
5100
3483
|
}
|
|
5101
|
-
// ── Roadmap ──────────────────────────────────────────────────────────────
|
|
5102
|
-
case 'roadmap_add': {
|
|
5103
|
-
const { instance_id: rid, title, description: desc = '', priority = 'medium', tags: rtags = [], milestone = '', } = args;
|
|
5104
|
-
const redis = await getConnection(rid);
|
|
5105
|
-
const id = `rm_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
5106
|
-
const item = {
|
|
5107
|
-
id, title, description: desc, priority, tags: rtags, milestone,
|
|
5108
|
-
status: 'planned',
|
|
5109
|
-
created: new Date().toISOString(),
|
|
5110
|
-
updated: new Date().toISOString(),
|
|
5111
|
-
notes: '',
|
|
5112
|
-
};
|
|
5113
|
-
await redis.hset(`cachly:roadmap:${rid}`, id, JSON.stringify(item));
|
|
5114
|
-
const PRIORITY_ICON = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
|
|
5115
|
-
return [
|
|
5116
|
-
`📋 **Roadmap item added**`,
|
|
5117
|
-
``,
|
|
5118
|
-
` ID: \`${id}\``,
|
|
5119
|
-
` Title: ${title}`,
|
|
5120
|
-
` Priority: ${PRIORITY_ICON[priority] ?? '⚪'} ${priority}`,
|
|
5121
|
-
` Status: planned`,
|
|
5122
|
-
milestone ? ` Milestone: ${milestone}` : '',
|
|
5123
|
-
rtags.length ? ` Tags: ${rtags.join(', ')}` : '',
|
|
5124
|
-
``,
|
|
5125
|
-
`💡 Use \`roadmap_update(id: "${id}", status: "in-progress")\` when you start working on it.`,
|
|
5126
|
-
].filter(Boolean).join('\n');
|
|
5127
|
-
}
|
|
5128
|
-
case 'roadmap_update': {
|
|
5129
|
-
const { instance_id: rid, id: itemId, status: newStatus, priority: newPriority, notes: newNotes, title: newTitle, description: newDesc, } = args;
|
|
5130
|
-
const redis = await getConnection(rid);
|
|
5131
|
-
const raw = await redis.hget(`cachly:roadmap:${rid}`, itemId);
|
|
5132
|
-
if (!raw)
|
|
5133
|
-
return `⚠️ **roadmap_update** – Item \`${itemId}\` not found. Use \`roadmap_list\` to see all items.`;
|
|
5134
|
-
const item = JSON.parse(raw);
|
|
5135
|
-
const oldStatus = item.status;
|
|
5136
|
-
if (newStatus)
|
|
5137
|
-
item.status = newStatus;
|
|
5138
|
-
if (newPriority)
|
|
5139
|
-
item.priority = newPriority;
|
|
5140
|
-
if (newTitle)
|
|
5141
|
-
item.title = newTitle;
|
|
5142
|
-
if (newDesc)
|
|
5143
|
-
item.description = newDesc;
|
|
5144
|
-
if (newNotes)
|
|
5145
|
-
item.notes = item.notes ? `${item.notes}\n[${new Date().toISOString().slice(0, 10)}] ${newNotes}` : `[${new Date().toISOString().slice(0, 10)}] ${newNotes}`;
|
|
5146
|
-
item.updated = new Date().toISOString();
|
|
5147
|
-
await redis.hset(`cachly:roadmap:${rid}`, itemId, JSON.stringify(item));
|
|
5148
|
-
const statusEmoji = { planned: '📋', 'in-progress': '⚡', done: '✅', blocked: '🚫', cancelled: '🗑️' };
|
|
5149
|
-
return [
|
|
5150
|
-
`${statusEmoji[newStatus ?? oldStatus] ?? '📋'} **Roadmap updated** \`${itemId}\``,
|
|
5151
|
-
``,
|
|
5152
|
-
` Title: ${item.title}`,
|
|
5153
|
-
oldStatus !== newStatus ? ` Status: ${oldStatus} → ${newStatus}` : ` Status: ${item.status}`,
|
|
5154
|
-
newNotes ? ` Notes: ${newNotes}` : '',
|
|
5155
|
-
].filter(Boolean).join('\n');
|
|
5156
|
-
}
|
|
5157
|
-
case 'roadmap_list': {
|
|
5158
|
-
const { instance_id: rid, status: filterStatus = 'open', tag: filterTag, milestone: filterMilestone, priority: filterPriority, } = args;
|
|
5159
|
-
const redis = await getConnection(rid);
|
|
5160
|
-
const all = await redis.hgetall(`cachly:roadmap:${rid}`);
|
|
5161
|
-
if (!all || Object.keys(all).length === 0) {
|
|
5162
|
-
return '📋 **Roadmap is empty.**\n\nUse `roadmap_add` to create your first item.';
|
|
5163
|
-
}
|
|
5164
|
-
const PRIORITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
5165
|
-
const PRIORITY_ICON = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
|
|
5166
|
-
const STATUS_ICON = { planned: '📋', 'in-progress': '⚡', done: '✅', blocked: '🚫', cancelled: '🗑️' };
|
|
5167
|
-
const openStatuses = new Set(['planned', 'in-progress', 'blocked']);
|
|
5168
|
-
let items = Object.values(all).map(v => JSON.parse(v));
|
|
5169
|
-
// Filter
|
|
5170
|
-
if (filterStatus === 'open')
|
|
5171
|
-
items = items.filter(i => openStatuses.has(i.status));
|
|
5172
|
-
else if (filterStatus)
|
|
5173
|
-
items = items.filter(i => i.status === filterStatus);
|
|
5174
|
-
if (filterTag)
|
|
5175
|
-
items = items.filter(i => i.tags.includes(filterTag));
|
|
5176
|
-
if (filterMilestone)
|
|
5177
|
-
items = items.filter(i => i.milestone === filterMilestone);
|
|
5178
|
-
if (filterPriority)
|
|
5179
|
-
items = items.filter(i => (PRIORITY_ORDER[i.priority] ?? 99) <= (PRIORITY_ORDER[filterPriority] ?? 99));
|
|
5180
|
-
// Sort: priority asc, then created asc
|
|
5181
|
-
items.sort((a, b) => {
|
|
5182
|
-
const pa = PRIORITY_ORDER[a.priority] ?? 99;
|
|
5183
|
-
const pb = PRIORITY_ORDER[b.priority] ?? 99;
|
|
5184
|
-
return pa !== pb ? pa - pb : a.created.localeCompare(b.created);
|
|
5185
|
-
});
|
|
5186
|
-
if (items.length === 0)
|
|
5187
|
-
return `📋 **No roadmap items** match the current filter (status: ${filterStatus}).`;
|
|
5188
|
-
const grouped = {};
|
|
5189
|
-
for (const it of items) {
|
|
5190
|
-
const st = it.status;
|
|
5191
|
-
if (!grouped[st])
|
|
5192
|
-
grouped[st] = [];
|
|
5193
|
-
grouped[st].push(it);
|
|
5194
|
-
}
|
|
5195
|
-
const lines = [`📋 **Roadmap** (${items.length} item${items.length !== 1 ? 's' : ''})`, ''];
|
|
5196
|
-
for (const [st, grp] of Object.entries(grouped)) {
|
|
5197
|
-
lines.push(`**${STATUS_ICON[st] ?? '•'} ${st.toUpperCase()}** (${grp.length})`);
|
|
5198
|
-
for (const it of grp) {
|
|
5199
|
-
const tags = it.tags.length ? ` [${it.tags.join(', ')}]` : '';
|
|
5200
|
-
const milestone = it.milestone ? ` · ${it.milestone}` : '';
|
|
5201
|
-
lines.push(` ${PRIORITY_ICON[it.priority] ?? '⚪'} \`${it.id}\` **${it.title}**${tags}${milestone}`);
|
|
5202
|
-
if (it.description)
|
|
5203
|
-
lines.push(` ${it.description.slice(0, 120)}`);
|
|
5204
|
-
if (it.notes)
|
|
5205
|
-
lines.push(` 📝 ${it.notes.split('\n').pop()?.slice(0, 100)}`);
|
|
5206
|
-
}
|
|
5207
|
-
lines.push('');
|
|
5208
|
-
}
|
|
5209
|
-
lines.push(`💡 \`roadmap_update(id, status: "in-progress")\` to start · \`roadmap_next\` for top priority item`);
|
|
5210
|
-
return lines.join('\n');
|
|
5211
|
-
}
|
|
5212
|
-
case 'roadmap_next': {
|
|
5213
|
-
const { instance_id: rid, tag: filterTag } = args;
|
|
5214
|
-
const redis = await getConnection(rid);
|
|
5215
|
-
const all = await redis.hgetall(`cachly:roadmap:${rid}`);
|
|
5216
|
-
if (!all || Object.keys(all).length === 0) {
|
|
5217
|
-
return '📋 **Roadmap is empty.** Use `roadmap_add` to plan your first task.';
|
|
5218
|
-
}
|
|
5219
|
-
const PRIORITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
5220
|
-
const PRIORITY_ICON = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
|
|
5221
|
-
let items = Object.values(all)
|
|
5222
|
-
.map(v => JSON.parse(v))
|
|
5223
|
-
.filter(i => i.status === 'in-progress' || i.status === 'planned')
|
|
5224
|
-
.filter(i => !filterTag || i.tags.includes(filterTag));
|
|
5225
|
-
if (items.length === 0)
|
|
5226
|
-
return '🎉 **No open roadmap items!** All tasks are done (or use `roadmap_list` to check).';
|
|
5227
|
-
// in-progress first, then by priority
|
|
5228
|
-
items.sort((a, b) => {
|
|
5229
|
-
if (a.status === 'in-progress' && b.status !== 'in-progress')
|
|
5230
|
-
return -1;
|
|
5231
|
-
if (b.status === 'in-progress' && a.status !== 'in-progress')
|
|
5232
|
-
return 1;
|
|
5233
|
-
return (PRIORITY_ORDER[a.priority] ?? 99) - (PRIORITY_ORDER[b.priority] ?? 99);
|
|
5234
|
-
});
|
|
5235
|
-
const next = items[0];
|
|
5236
|
-
const remaining = items.length - 1;
|
|
5237
|
-
const tags = next.tags.length ? `\nTags: ${next.tags.join(', ')}` : '';
|
|
5238
|
-
const milestone = next.milestone ? `\nMilestone: ${next.milestone}` : '';
|
|
5239
|
-
const notes = next.notes ? `\nNotes: ${next.notes.split('\n').pop()?.slice(0, 120)}` : '';
|
|
5240
|
-
return [
|
|
5241
|
-
`${next.status === 'in-progress' ? '⚡' : '📋'} **Next up: ${next.title}**`,
|
|
5242
|
-
``,
|
|
5243
|
-
`ID: \`${next.id}\``,
|
|
5244
|
-
`Priority: ${PRIORITY_ICON[next.priority] ?? '⚪'} ${next.priority}`,
|
|
5245
|
-
`Status: ${next.status}`,
|
|
5246
|
-
next.description ? `\nWhat to do:\n${next.description}` : '',
|
|
5247
|
-
tags, milestone, notes,
|
|
5248
|
-
``,
|
|
5249
|
-
remaining > 0 ? `(+${remaining} more open item${remaining !== 1 ? 's' : ''} in roadmap)` : '(last open item)',
|
|
5250
|
-
``,
|
|
5251
|
-
next.status === 'planned'
|
|
5252
|
-
? `💡 Start with: \`roadmap_update(id: "${next.id}", status: "in-progress")\``
|
|
5253
|
-
: `💡 Finish with: \`roadmap_update(id: "${next.id}", status: "done", notes: "...")\``,
|
|
5254
|
-
].filter(s => s !== undefined).join('\n');
|
|
5255
|
-
}
|
|
5256
3484
|
default:
|
|
5257
3485
|
return null;
|
|
5258
3486
|
}
|
|
5259
3487
|
}
|
|
5260
3488
|
// ── Server setup ──────────────────────────────────────────────────────────────
|
|
5261
|
-
const server = new Server({ name: 'cachly-mcp', version:
|
|
3489
|
+
const server = new Server({ name: 'cachly-mcp', version: '0.4.0' }, { capabilities: { tools: {} } });
|
|
5262
3490
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
5263
|
-
// ── Auto-session management ───────────────────────────────────────────────────
|
|
5264
|
-
// Transparently starts a Brain session on the first tool call of a process
|
|
5265
|
-
// and saves a session_end summary on SIGTERM/exit so users never have to call
|
|
5266
|
-
// session_start/session_end manually.
|
|
5267
|
-
let _autoSessionInstanceId = null;
|
|
5268
|
-
let _autoSessionStarted = false;
|
|
5269
|
-
let _autoSessionToolCount = 0;
|
|
5270
|
-
async function autoStartSession(instanceId) {
|
|
5271
|
-
if (_autoSessionStarted)
|
|
5272
|
-
return;
|
|
5273
|
-
_autoSessionStarted = true;
|
|
5274
|
-
_autoSessionInstanceId = instanceId;
|
|
5275
|
-
try {
|
|
5276
|
-
await handleTool('session_start', { instance_id: instanceId, focus: 'auto (MCP session)' });
|
|
5277
|
-
}
|
|
5278
|
-
catch { /* non-fatal — session tracking is a best-effort feature */ }
|
|
5279
|
-
// Auto-index the project if it hasn't been indexed in the last 24h.
|
|
5280
|
-
// This is the main lever for growing token savings: more indexed code →
|
|
5281
|
-
// more semantic cache hits → fewer LLM calls for repeated questions.
|
|
5282
|
-
if (process.env.CACHLY_AUTO_INDEX !== 'false') {
|
|
5283
|
-
try {
|
|
5284
|
-
const redis = await getConnection(instanceId);
|
|
5285
|
-
const lastIndexed = await redis.get(`cachly:index:last_indexed:${instanceId}`);
|
|
5286
|
-
const staleMs = 24 * 60 * 60 * 1000;
|
|
5287
|
-
const isStale = !lastIndexed || (Date.now() - parseInt(lastIndexed, 10)) > staleMs;
|
|
5288
|
-
if (isStale) {
|
|
5289
|
-
// Mark as indexing now to prevent concurrent re-runs.
|
|
5290
|
-
await redis.set(`cachly:index:last_indexed:${instanceId}`, String(Date.now()), 'EX', 90000);
|
|
5291
|
-
// Run in background — don't block the first tool call.
|
|
5292
|
-
handleTool('index_project', {
|
|
5293
|
-
instance_id: instanceId,
|
|
5294
|
-
dir: process.cwd(),
|
|
5295
|
-
max_files: 150,
|
|
5296
|
-
ttl: 86400 * 7, // cache for 7 days
|
|
5297
|
-
namespace: 'cachly:sem:code',
|
|
5298
|
-
}).catch(() => undefined);
|
|
5299
|
-
}
|
|
5300
|
-
}
|
|
5301
|
-
catch { /* never block the session on indexing errors */ }
|
|
5302
|
-
}
|
|
5303
|
-
}
|
|
5304
|
-
async function autoEndSession() {
|
|
5305
|
-
if (!_autoSessionStarted || !_autoSessionInstanceId)
|
|
5306
|
-
return;
|
|
5307
|
-
_autoSessionStarted = false;
|
|
5308
|
-
try {
|
|
5309
|
-
await handleTool('session_end', {
|
|
5310
|
-
instance_id: _autoSessionInstanceId,
|
|
5311
|
-
summary: `Auto-ended after ${_autoSessionToolCount} tool call(s). Session was started automatically.`,
|
|
5312
|
-
files_changed: [],
|
|
5313
|
-
lessons_learned: 0,
|
|
5314
|
-
});
|
|
5315
|
-
}
|
|
5316
|
-
catch { /* non-fatal */ }
|
|
5317
|
-
}
|
|
5318
3491
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
5319
3492
|
const { name, arguments: args } = request.params;
|
|
5320
|
-
// Auto-start brain session on first tool call that has an instance_id.
|
|
5321
|
-
// Skip session management tools to avoid recursion.
|
|
5322
|
-
const sessionTools = new Set(['session_start', 'session_end', 'auto_learn_session']);
|
|
5323
|
-
if (!sessionTools.has(name) && !_autoSessionStarted) {
|
|
5324
|
-
const instanceId = args?.instance_id;
|
|
5325
|
-
if (instanceId) {
|
|
5326
|
-
await autoStartSession(instanceId).catch(() => undefined);
|
|
5327
|
-
}
|
|
5328
|
-
}
|
|
5329
|
-
if (!sessionTools.has(name))
|
|
5330
|
-
_autoSessionToolCount++;
|
|
5331
3493
|
try {
|
|
5332
3494
|
const text = await handleTool(name, (args ?? {}));
|
|
5333
3495
|
return { content: [{ type: 'text', text }] };
|
|
@@ -5338,15 +3500,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
5338
3500
|
throw new McpError(ErrorCode.InternalError, err.message);
|
|
5339
3501
|
}
|
|
5340
3502
|
});
|
|
5341
|
-
// Graceful shutdown – close all Redis connections
|
|
3503
|
+
// Graceful shutdown – close all Redis connections
|
|
5342
3504
|
process.on('SIGTERM', async () => {
|
|
5343
|
-
await autoEndSession().catch(() => undefined);
|
|
5344
|
-
for (const [, client] of pool)
|
|
5345
|
-
await client.quit().catch(() => undefined);
|
|
5346
|
-
process.exit(0);
|
|
5347
|
-
});
|
|
5348
|
-
process.on('SIGINT', async () => {
|
|
5349
|
-
await autoEndSession().catch(() => undefined);
|
|
5350
3505
|
for (const [, client] of pool)
|
|
5351
3506
|
await client.quit().catch(() => undefined);
|
|
5352
3507
|
process.exit(0);
|
|
@@ -5365,8 +3520,6 @@ const EDITOR_FILES = {
|
|
|
5365
3520
|
windsurf: '.windsurf/mcp.json',
|
|
5366
3521
|
copilot: '.vscode/mcp.json',
|
|
5367
3522
|
continue: '.continue/config.json',
|
|
5368
|
-
cline: '.vscode/mcp.json', // Cline respects VS Code project MCP config
|
|
5369
|
-
zed: '.zed/settings.json', // Zed project-level context_servers
|
|
5370
3523
|
};
|
|
5371
3524
|
const CLAUDE_MD_MARKER_START = '<!-- cachly-brain-start -->';
|
|
5372
3525
|
const CLAUDE_MD_MARKER_END = '<!-- cachly-brain-end -->';
|
|
@@ -5381,20 +3534,6 @@ function buildMcpConfig(apiKey, instanceId, editor) {
|
|
|
5381
3534
|
},
|
|
5382
3535
|
}, null, 2);
|
|
5383
3536
|
}
|
|
5384
|
-
if (editor === 'zed') {
|
|
5385
|
-
return JSON.stringify({
|
|
5386
|
-
context_servers: {
|
|
5387
|
-
cachly: {
|
|
5388
|
-
command: {
|
|
5389
|
-
path: 'npx',
|
|
5390
|
-
args: ['-y', '@cachly-dev/mcp-server@latest'],
|
|
5391
|
-
env: { CACHLY_API_URL: 'https://api.cachly.dev', CACHLY_JWT: apiKey, CACHLY_BRAIN_INSTANCE_ID: instanceId },
|
|
5392
|
-
},
|
|
5393
|
-
settings: {},
|
|
5394
|
-
},
|
|
5395
|
-
},
|
|
5396
|
-
}, null, 2);
|
|
5397
|
-
}
|
|
5398
3537
|
return JSON.stringify({
|
|
5399
3538
|
mcpServers: {
|
|
5400
3539
|
cachly: {
|
|
@@ -5407,54 +3546,48 @@ function buildMcpConfig(apiKey, instanceId, editor) {
|
|
|
5407
3546
|
}
|
|
5408
3547
|
function buildClaudeMdBlock(instanceId) {
|
|
5409
3548
|
return `${CLAUDE_MD_MARKER_START}
|
|
5410
|
-
## Cachly AI Brain
|
|
3549
|
+
## Cachly AI Brain — Persistent Memory
|
|
5411
3550
|
|
|
5412
|
-
|
|
5413
|
-
Your instance ID: \`${instanceId}\`
|
|
3551
|
+
Your AI Brain instance ID: \`${instanceId}\`
|
|
5414
3552
|
|
|
5415
|
-
>
|
|
5416
|
-
>
|
|
3553
|
+
> The Brain gives you persistent memory across sessions. Every lesson you learn,
|
|
3554
|
+
> every bug you fix, every architecture decision — recalled instantly next time.
|
|
3555
|
+
> instance_id is pre-filled below. Do NOT ask the user for it.
|
|
5417
3556
|
|
|
5418
|
-
|
|
3557
|
+
### MANDATORY: Call at the START of every session
|
|
3558
|
+
\`\`\`
|
|
3559
|
+
session_start(instance_id="${instanceId}", focus="<what you're working on today>")
|
|
3560
|
+
\`\`\`
|
|
3561
|
+
This loads your previous context, recent lessons, and open tasks automatically.
|
|
5419
3562
|
|
|
5420
|
-
###
|
|
3563
|
+
### MANDATORY: Call at the END of every session
|
|
5421
3564
|
\`\`\`
|
|
5422
|
-
|
|
5423
|
-
instance_id = "${instanceId}",
|
|
5424
|
-
key = "wip-registry",
|
|
5425
|
-
content = "EDITING: <file:line> — goal: <what you are implementing>",
|
|
5426
|
-
category = "bug",
|
|
5427
|
-
ttl = 86400,
|
|
5428
|
-
)
|
|
3565
|
+
session_end(instance_id="${instanceId}", summary="<2-3 sentences on what was done>", files_changed=["src/..."], lessons_learned=1)
|
|
5429
3566
|
\`\`\`
|
|
5430
|
-
After the edit is complete, update \`content\` to \`"DONE: <file> — <what was completed>"\`.
|
|
5431
3567
|
|
|
5432
|
-
### After fixing
|
|
3568
|
+
### After fixing ANY bug or solving a hard problem
|
|
5433
3569
|
\`\`\`
|
|
5434
3570
|
learn_from_attempts(
|
|
5435
|
-
instance_id
|
|
5436
|
-
topic
|
|
5437
|
-
outcome
|
|
5438
|
-
what_worked
|
|
5439
|
-
what_failed
|
|
5440
|
-
|
|
5441
|
-
file_paths
|
|
5442
|
-
|
|
5443
|
-
tags = ["tag1"],
|
|
3571
|
+
instance_id="${instanceId}",
|
|
3572
|
+
topic="category:keyword", # e.g. "deploy:web", "bug:redis-timeout", "arch:auth"
|
|
3573
|
+
outcome="success",
|
|
3574
|
+
what_worked="exact solution",
|
|
3575
|
+
what_failed="what didn't work (optional)",
|
|
3576
|
+
commands=["the command that worked"],
|
|
3577
|
+
file_paths=["relevant/file.ts"],
|
|
3578
|
+
severity="critical"|"major"|"minor",
|
|
5444
3579
|
)
|
|
5445
3580
|
\`\`\`
|
|
5446
3581
|
|
|
5447
|
-
### Before starting any task
|
|
3582
|
+
### Before starting any non-trivial task
|
|
5448
3583
|
\`\`\`
|
|
5449
|
-
|
|
5450
|
-
instance_id = "${instanceId}",
|
|
5451
|
-
query = "<describe what you are about to do>",
|
|
5452
|
-
)
|
|
3584
|
+
recall_best_solution(instance_id="${instanceId}", topic="relevant:topic")
|
|
5453
3585
|
\`\`\`
|
|
5454
3586
|
|
|
5455
|
-
###
|
|
5456
|
-
|
|
5457
|
-
|
|
3587
|
+
### If a stored lesson is wrong
|
|
3588
|
+
\`\`\`
|
|
3589
|
+
forget_lesson(instance_id="${instanceId}", topic="wrong:topic")
|
|
3590
|
+
\`\`\`
|
|
5458
3591
|
${CLAUDE_MD_MARKER_END}`;
|
|
5459
3592
|
}
|
|
5460
3593
|
async function writeClaudeMd(projectDir, instanceId) {
|
|
@@ -5526,8 +3659,7 @@ if (process.argv[2] === 'setup') {
|
|
|
5526
3659
|
else {
|
|
5527
3660
|
const AUTH_BASE = 'https://auth.cachly.dev/realms/cachly/protocol/openid-connect';
|
|
5528
3661
|
const CLIENT_ID = 'cachly-cli';
|
|
5529
|
-
console.log('Step 1: Sign in to cachly (free
|
|
5530
|
-
// Start device flow
|
|
3662
|
+
console.log('Step 1: Sign in to cachly (free — no credit card required)\n');
|
|
5531
3663
|
let deviceCode = '', userCode = '', verifyUri = '', pollInterval = 5000;
|
|
5532
3664
|
try {
|
|
5533
3665
|
const deviceRes = await fetch(`${AUTH_BASE}/auth/device`, {
|
|
@@ -5536,7 +3668,7 @@ if (process.argv[2] === 'setup') {
|
|
|
5536
3668
|
body: `client_id=${CLIENT_ID}&scope=openid`,
|
|
5537
3669
|
});
|
|
5538
3670
|
if (!deviceRes.ok)
|
|
5539
|
-
throw new Error(`
|
|
3671
|
+
throw new Error(`HTTP ${deviceRes.status}`);
|
|
5540
3672
|
const data = await deviceRes.json();
|
|
5541
3673
|
deviceCode = data.device_code;
|
|
5542
3674
|
userCode = data.user_code;
|
|
@@ -5544,19 +3676,17 @@ if (process.argv[2] === 'setup') {
|
|
|
5544
3676
|
pollInterval = (data.interval ?? 5) * 1000;
|
|
5545
3677
|
}
|
|
5546
3678
|
catch (e) {
|
|
5547
|
-
console.error(`\
|
|
5548
|
-
console.error('
|
|
3679
|
+
console.error(`\nCould not reach auth service: ${e.message}`);
|
|
3680
|
+
console.error('Fallback: sign in at https://cachly.dev and paste your API token.\n');
|
|
5549
3681
|
token = await ask(' Paste API token (cky_live_...): ');
|
|
5550
3682
|
if (!token) {
|
|
5551
|
-
console.error('\nToken
|
|
3683
|
+
console.error('\nToken required. Aborting.\n');
|
|
5552
3684
|
rl.close();
|
|
5553
3685
|
process.exit(1);
|
|
5554
3686
|
}
|
|
5555
|
-
|
|
5556
|
-
deviceCode = ''; // mark as fallback so we skip polling
|
|
3687
|
+
deviceCode = '';
|
|
5557
3688
|
}
|
|
5558
3689
|
if (deviceCode) {
|
|
5559
|
-
// Open browser
|
|
5560
3690
|
console.log(` Code: \x1b[1;33m${userCode}\x1b[0m`);
|
|
5561
3691
|
console.log(` URL: ${verifyUri}\n`);
|
|
5562
3692
|
try {
|
|
@@ -5566,11 +3696,10 @@ if (process.argv[2] === 'setup') {
|
|
|
5566
3696
|
console.log(' ✓ Browser opened — confirm the code above to continue...\n');
|
|
5567
3697
|
}
|
|
5568
3698
|
catch {
|
|
5569
|
-
console.log(' 👉 Open the URL above in your browser and
|
|
3699
|
+
console.log(' 👉 Open the URL above in your browser and confirm the code.\n');
|
|
5570
3700
|
}
|
|
5571
|
-
// Poll for token
|
|
5572
3701
|
process.stdout.write(' Waiting for authorization');
|
|
5573
|
-
const deadline = Date.now() + 10 * 60 * 1000;
|
|
3702
|
+
const deadline = Date.now() + 10 * 60 * 1000;
|
|
5574
3703
|
while (Date.now() < deadline) {
|
|
5575
3704
|
await new Promise(r => setTimeout(r, pollInterval));
|
|
5576
3705
|
process.stdout.write('.');
|
|
@@ -5580,17 +3709,16 @@ if (process.argv[2] === 'setup') {
|
|
|
5580
3709
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
5581
3710
|
body: `client_id=${CLIENT_ID}&grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=${deviceCode}`,
|
|
5582
3711
|
});
|
|
5583
|
-
const
|
|
5584
|
-
if (
|
|
5585
|
-
token =
|
|
3712
|
+
const td = await tokenRes.json();
|
|
3713
|
+
if (td.access_token) {
|
|
3714
|
+
token = td.access_token;
|
|
5586
3715
|
console.log(' \x1b[32m✓ Authorized!\x1b[0m\n');
|
|
5587
3716
|
break;
|
|
5588
3717
|
}
|
|
5589
|
-
|
|
5590
|
-
if (tokenData.error === 'slow_down')
|
|
3718
|
+
if (td.error === 'slow_down')
|
|
5591
3719
|
pollInterval = Math.min(pollInterval + 2000, 15000);
|
|
5592
|
-
else if (
|
|
5593
|
-
console.error(`\nAuth error: ${
|
|
3720
|
+
else if (td.error && td.error !== 'authorization_pending') {
|
|
3721
|
+
console.error(`\nAuth error: ${td.error}. Aborting.\n`);
|
|
5594
3722
|
rl.close();
|
|
5595
3723
|
process.exit(1);
|
|
5596
3724
|
}
|
|
@@ -5598,38 +3726,47 @@ if (process.argv[2] === 'setup') {
|
|
|
5598
3726
|
catch { /* network hiccup — keep polling */ }
|
|
5599
3727
|
}
|
|
5600
3728
|
if (!token) {
|
|
5601
|
-
console.error('\nTimed out
|
|
3729
|
+
console.error('\nTimed out. Aborting.\n');
|
|
5602
3730
|
rl.close();
|
|
5603
3731
|
process.exit(1);
|
|
5604
3732
|
}
|
|
5605
|
-
console.log('');
|
|
5606
|
-
}
|
|
5607
|
-
}
|
|
5608
|
-
// ── Step 1b: Exchange Keycloak JWT → long-lived cky_live_ API key ──────────
|
|
5609
|
-
// Only do this when the token looks like a Keycloak JWT (starts with "eyJ"),
|
|
5610
|
-
// not when the user already pasted a cky_live_ key directly.
|
|
5611
|
-
if (token.startsWith('eyJ')) {
|
|
5612
|
-
process.stdout.write('⏳ Generating your API key...');
|
|
5613
|
-
try {
|
|
5614
|
-
const keyRes = await fetch(`${API_URL}/api/v1/api-keys`, {
|
|
5615
|
-
method: 'POST',
|
|
5616
|
-
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
5617
|
-
body: JSON.stringify({ name: 'cachly-mcp-setup', scope: 'read_write' }),
|
|
5618
|
-
});
|
|
5619
|
-
if (!keyRes.ok)
|
|
5620
|
-
throw new Error(`HTTP ${keyRes.status}`);
|
|
5621
|
-
const keyBody = await keyRes.json();
|
|
5622
|
-
if (!keyBody.key)
|
|
5623
|
-
throw new Error('no key in response');
|
|
5624
|
-
token = keyBody.key; // swap JWT → cky_live_...
|
|
5625
|
-
console.log(' ✓\n');
|
|
5626
3733
|
}
|
|
5627
|
-
|
|
5628
|
-
|
|
5629
|
-
|
|
5630
|
-
|
|
3734
|
+
// Exchange short-lived Keycloak JWT → long-lived cky_live_ API key
|
|
3735
|
+
if (token.startsWith('eyJ')) {
|
|
3736
|
+
process.stdout.write('⏳ Generating your API key...');
|
|
3737
|
+
try {
|
|
3738
|
+
const keyRes = await fetch(`${API_URL}/api/v1/api-keys`, {
|
|
3739
|
+
method: 'POST',
|
|
3740
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
3741
|
+
body: JSON.stringify({ name: 'cachly-mcp-setup', scope: 'read_write' }),
|
|
3742
|
+
});
|
|
3743
|
+
if (!keyRes.ok)
|
|
3744
|
+
throw new Error(`HTTP ${keyRes.status}`);
|
|
3745
|
+
const keyBody = await keyRes.json();
|
|
3746
|
+
if (keyBody.key) {
|
|
3747
|
+
token = keyBody.key;
|
|
3748
|
+
console.log(' ✓\n');
|
|
3749
|
+
}
|
|
3750
|
+
else
|
|
3751
|
+
throw new Error('no key in response');
|
|
3752
|
+
}
|
|
3753
|
+
catch (e) {
|
|
3754
|
+
console.log(` (skipped — JWT will be used directly)\n`);
|
|
3755
|
+
}
|
|
5631
3756
|
}
|
|
5632
3757
|
}
|
|
3758
|
+
// ── Step 1b: Auto-provision a Brain instance if none exist ─────────────────
|
|
3759
|
+
// POST /api/v1/instances/auto is idempotent — returns existing or creates free Brain
|
|
3760
|
+
let autoProvisioned = false;
|
|
3761
|
+
try {
|
|
3762
|
+
const autoRes = await fetch(`${API_URL}/api/v1/instances/auto`, {
|
|
3763
|
+
method: 'POST',
|
|
3764
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
3765
|
+
});
|
|
3766
|
+
if (autoRes.ok)
|
|
3767
|
+
autoProvisioned = true;
|
|
3768
|
+
}
|
|
3769
|
+
catch { /* non-fatal */ }
|
|
5633
3770
|
// ── Step 2: Fetch & pick instance ─────────────────────────────────────────
|
|
5634
3771
|
process.stdout.write('⏳ Fetching your instances...');
|
|
5635
3772
|
let instances = [];
|
|
@@ -5643,70 +3780,32 @@ if (process.argv[2] === 'setup') {
|
|
|
5643
3780
|
instances = (body.data ?? []).filter(i => i.status === 'running');
|
|
5644
3781
|
}
|
|
5645
3782
|
catch (e) {
|
|
5646
|
-
|
|
5647
|
-
console.error(
|
|
5648
|
-
if (msg.includes('401')) {
|
|
5649
|
-
console.error('Token rejected. Get a valid token at https://cachly.dev/setup-ai\n');
|
|
5650
|
-
try {
|
|
5651
|
-
const { execSync } = await import('node:child_process');
|
|
5652
|
-
const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
5653
|
-
execSync(`${openCmd} https://cachly.dev/setup-ai`, { stdio: 'ignore' });
|
|
5654
|
-
}
|
|
5655
|
-
catch { /* ignore */ }
|
|
5656
|
-
}
|
|
3783
|
+
console.error(`\n\nFailed to fetch instances: ${e.message}`);
|
|
3784
|
+
console.error('Check your token and try again.\n');
|
|
5657
3785
|
rl.close();
|
|
5658
3786
|
process.exit(1);
|
|
5659
3787
|
}
|
|
5660
3788
|
console.log(` found ${instances.length}\n`);
|
|
5661
3789
|
if (instances.length === 0) {
|
|
5662
|
-
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
|
|
5671
|
-
|
|
5672
|
-
|
|
5673
|
-
// Returned an existing instance.
|
|
5674
|
-
instances = [autoBody.instance];
|
|
5675
|
-
}
|
|
5676
|
-
else if (autoBody.instance_id) {
|
|
5677
|
-
// Newly created — poll until running or give up after 30 s.
|
|
5678
|
-
const newId = autoBody.instance_id;
|
|
5679
|
-
console.log(` ✓ created (${newId.slice(0, 8)}…)\n`);
|
|
5680
|
-
process.stdout.write('⏳ Waiting for instance to start');
|
|
5681
|
-
for (let attempt = 0; attempt < 15; attempt++) {
|
|
5682
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
5683
|
-
process.stdout.write('.');
|
|
5684
|
-
try {
|
|
5685
|
-
const checkRes = await fetch(`${API_URL}/api/v1/instances/${newId}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
5686
|
-
if (checkRes.ok) {
|
|
5687
|
-
const inst = await checkRes.json();
|
|
5688
|
-
if (inst.status === 'running') {
|
|
5689
|
-
instances = [inst];
|
|
5690
|
-
break;
|
|
5691
|
-
}
|
|
5692
|
-
}
|
|
3790
|
+
if (autoProvisioned) {
|
|
3791
|
+
// Poll up to 10× every 3s (30s total) waiting for the Brain to start
|
|
3792
|
+
process.stdout.write(' waiting for Brain to start');
|
|
3793
|
+
for (let attempt = 0; attempt < 10 && instances.length === 0; attempt++) {
|
|
3794
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
3795
|
+
process.stdout.write('.');
|
|
3796
|
+
try {
|
|
3797
|
+
const retry = await fetch(`${API_URL}/api/v1/instances`, { headers: { Authorization: `Bearer ${token}` } });
|
|
3798
|
+
if (retry.ok) {
|
|
3799
|
+
const b = await retry.json();
|
|
3800
|
+
instances = (b.data ?? []).filter(i => i.status === 'running');
|
|
5693
3801
|
}
|
|
5694
|
-
catch { /* keep polling */ }
|
|
5695
3802
|
}
|
|
5696
|
-
|
|
3803
|
+
catch { /* ignore */ }
|
|
5697
3804
|
}
|
|
5698
|
-
|
|
5699
|
-
catch (e) {
|
|
5700
|
-
console.log(` failed: ${e.message}\n`);
|
|
3805
|
+
process.stdout.write(instances.length > 0 ? ' ready!\n' : '\n');
|
|
5701
3806
|
}
|
|
5702
3807
|
if (instances.length === 0) {
|
|
5703
|
-
console.error('\
|
|
5704
|
-
try {
|
|
5705
|
-
const { execSync } = await import('node:child_process');
|
|
5706
|
-
const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
5707
|
-
execSync(`${openCmd} https://cachly.dev/instances`, { stdio: 'ignore' });
|
|
5708
|
-
}
|
|
5709
|
-
catch { /* ignore */ }
|
|
3808
|
+
console.error('\nBrain provisioning is taking longer than expected. Run setup again in a minute.\n');
|
|
5710
3809
|
rl.close();
|
|
5711
3810
|
process.exit(1);
|
|
5712
3811
|
}
|
|
@@ -5723,8 +3822,6 @@ if (process.argv[2] === 'setup') {
|
|
|
5723
3822
|
// ── Step 3: Detect editors ────────────────────────────────────────────────
|
|
5724
3823
|
const cwd = process.cwd();
|
|
5725
3824
|
const detected = [];
|
|
5726
|
-
const { homedir } = await import('node:os');
|
|
5727
|
-
const home = homedir();
|
|
5728
3825
|
// Claude Code: always include (CLAUDE.md is universal)
|
|
5729
3826
|
detected.push('claude');
|
|
5730
3827
|
if (existsSync(resolve(cwd, '.cursor')))
|
|
@@ -5735,47 +3832,14 @@ if (process.argv[2] === 'setup') {
|
|
|
5735
3832
|
detected.push('copilot');
|
|
5736
3833
|
if (existsSync(resolve(cwd, '.continue')))
|
|
5737
3834
|
detected.push('continue');
|
|
5738
|
-
|
|
5739
|
-
const clineGlobalDir = process.platform === 'darwin'
|
|
5740
|
-
? resolve(home, 'Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev')
|
|
5741
|
-
: resolve(home, '.vscode/extensions');
|
|
5742
|
-
if (existsSync(clineGlobalDir) && !detected.includes('copilot'))
|
|
5743
|
-
detected.push('cline');
|
|
5744
|
-
else if (existsSync(clineGlobalDir))
|
|
5745
|
-
detected.push('cline'); // both copilot + cline share .vscode/mcp.json — fine
|
|
5746
|
-
// Zed editor — detect via app data dir
|
|
5747
|
-
const zedDir = process.platform === 'darwin'
|
|
5748
|
-
? resolve(home, 'Library/Application Support/Zed')
|
|
5749
|
-
: resolve(home, '.config/zed');
|
|
5750
|
-
if (existsSync(zedDir))
|
|
5751
|
-
detected.push('zed');
|
|
5752
|
-
const editorLabel = (e) => ({ claude: 'Claude Code', cursor: 'Cursor', windsurf: 'Windsurf', copilot: 'GitHub Copilot', continue: 'Continue.dev', cline: 'Cline (VSCode)', zed: 'Zed' })[e] ?? e;
|
|
3835
|
+
const editorLabel = (e) => ({ claude: 'Claude Code', cursor: 'Cursor', windsurf: 'Windsurf', copilot: 'GitHub Copilot', continue: 'Continue.dev' })[e] ?? e;
|
|
5753
3836
|
console.log(`Step 3: Detected editors: ${detected.map(editorLabel).join(', ')}`);
|
|
5754
3837
|
const editorsRaw = await ask(` Configure for which? [all / ${detected.join('/')}] (Enter = all): `);
|
|
5755
3838
|
const editorsToSetup = !editorsRaw || editorsRaw.toLowerCase() === 'all'
|
|
5756
3839
|
? detected
|
|
5757
3840
|
: editorsRaw.split(/[,\s]+/).map(s => s.toLowerCase()).filter(s => EDITOR_FILES[s]);
|
|
5758
3841
|
console.log('');
|
|
5759
|
-
// ── Step 4:
|
|
5760
|
-
console.log('Step 4: Stay in the loop (optional)');
|
|
5761
|
-
const email = await ask(' Email for release notes & tips? [Enter to skip]: ');
|
|
5762
|
-
if (email && email.includes('@')) {
|
|
5763
|
-
try {
|
|
5764
|
-
await fetch(`${API_URL}/api/newsletter/subscribe`, {
|
|
5765
|
-
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
5766
|
-
body: JSON.stringify({ email: email.trim().toLowerCase(), source: 'mcp-setup' }),
|
|
5767
|
-
signal: AbortSignal.timeout(5000),
|
|
5768
|
-
});
|
|
5769
|
-
console.log('✅ Subscribed — you\'ll only hear from us when it matters.\n');
|
|
5770
|
-
}
|
|
5771
|
-
catch {
|
|
5772
|
-
console.log('⚠️ Could not subscribe (no internet?). Continuing...\n');
|
|
5773
|
-
}
|
|
5774
|
-
}
|
|
5775
|
-
else {
|
|
5776
|
-
console.log(' Skipped.\n');
|
|
5777
|
-
}
|
|
5778
|
-
// ── Step 5: Write editor configs ──────────────────────────────────────────
|
|
3842
|
+
// ── Step 4: Write editor configs ──────────────────────────────────────────
|
|
5779
3843
|
for (const editor of editorsToSetup) {
|
|
5780
3844
|
const configFile = EDITOR_FILES[editor] ?? '.mcp.json';
|
|
5781
3845
|
const configPath = resolve(cwd, configFile);
|
|
@@ -5783,163 +3847,16 @@ if (process.argv[2] === 'setup') {
|
|
|
5783
3847
|
await writeFile(configPath, buildMcpConfig(token, instance.id, editor), 'utf-8');
|
|
5784
3848
|
console.log(`✅ Written: ${configFile}`);
|
|
5785
3849
|
}
|
|
5786
|
-
// ── Step
|
|
3850
|
+
// ── Step 5: CLAUDE.md (always — idempotent) ───────────────────────────────
|
|
5787
3851
|
const mdResult = await writeClaudeMd(cwd, instance.id);
|
|
5788
3852
|
const mdLabel = mdResult === 'updated' ? '✅ Updated' : mdResult === 'appended' ? '✅ Appended to' : '✅ Written';
|
|
5789
3853
|
console.log(`${mdLabel}: CLAUDE.md`);
|
|
5790
3854
|
rl.close();
|
|
5791
3855
|
console.log(`\n🧠 Done! Restart your editor — the \`cachly\` MCP tools will appear.`);
|
|
5792
3856
|
console.log(` Your AI now has persistent memory across every session.\n`);
|
|
5793
|
-
// ── Step 7: Show first session_start preview (Aha moment) ────────────────
|
|
5794
|
-
// Fetch brain health from the API to show the user what their agent will see.
|
|
5795
|
-
process.stdout.write('\n⏳ Fetching your Brain health preview...');
|
|
5796
|
-
try {
|
|
5797
|
-
const brainRes = await fetch(`${API_URL}/api/v1/instances/${instance.id}/brain/stats`, {
|
|
5798
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
5799
|
-
signal: AbortSignal.timeout(6000),
|
|
5800
|
-
});
|
|
5801
|
-
if (brainRes.ok) {
|
|
5802
|
-
const brainData = await brainRes.json();
|
|
5803
|
-
const lessons = brainData.lesson_count ?? 0;
|
|
5804
|
-
const contexts = brainData.context_count ?? 0;
|
|
5805
|
-
const score = brainData.quality_score ?? 0;
|
|
5806
|
-
const level = lessons === 0 ? 'Intern 🌱' :
|
|
5807
|
-
lessons < 10 ? 'Junior Dev 🔧' :
|
|
5808
|
-
lessons < 30 ? 'Mid Dev ⚡' :
|
|
5809
|
-
lessons < 60 ? 'Senior Dev 🧠' :
|
|
5810
|
-
lessons < 100 ? 'Staff Eng 🚀' : 'Principal Eng 🏆';
|
|
5811
|
-
console.log(' ✓\n');
|
|
5812
|
-
console.log('┌──────────────────────────────────────────────────────┐');
|
|
5813
|
-
console.log(`│ 🧠 Brain Health Report │`);
|
|
5814
|
-
console.log('├──────────────────────────────────────────────────────┤');
|
|
5815
|
-
console.log(`│ Lessons stored : ${String(lessons).padEnd(6)} │ Level: ${level.padEnd(20)}│`);
|
|
5816
|
-
console.log(`│ Context entries : ${String(contexts).padEnd(6)} │ Quality score: ${String(Math.round(score * 100)).padEnd(3)}% │`);
|
|
5817
|
-
if (lessons === 0) {
|
|
5818
|
-
console.log('├──────────────────────────────────────────────────────┤');
|
|
5819
|
-
console.log('│ Your Brain is empty and ready to learn. │');
|
|
5820
|
-
console.log('│ After your first coding session it will contain: │');
|
|
5821
|
-
console.log('│ • Lessons from every bug you fixed │');
|
|
5822
|
-
console.log('│ • Your project\'s indexed files │');
|
|
5823
|
-
console.log('│ • A session summary for next time │');
|
|
5824
|
-
}
|
|
5825
|
-
console.log('└──────────────────────────────────────────────────────┘');
|
|
5826
|
-
}
|
|
5827
|
-
else {
|
|
5828
|
-
console.log(' (skipped)');
|
|
5829
|
-
}
|
|
5830
|
-
}
|
|
5831
|
-
catch {
|
|
5832
|
-
console.log(' (skipped)');
|
|
5833
|
-
}
|
|
5834
|
-
console.log(`\n Your AI will call session_start automatically when it uses the first tool.`);
|
|
5835
|
-
console.log(` To see your Brain at any time: https://cachly.dev/instances/${instance.id}\n`);
|
|
5836
|
-
process.exit(0);
|
|
5837
|
-
}
|
|
5838
|
-
// ── CLI: cachly index <dir> ───────────────────────────────────────────────────
|
|
5839
|
-
// Usage: npx @cachly-dev/mcp-server@latest index [./path/to/project]
|
|
5840
|
-
// Indexes the project directory into the Brain — perfect for CI/CD cron jobs.
|
|
5841
|
-
if (process.argv[2] === 'index') {
|
|
5842
|
-
const { resolve } = await import('node:path');
|
|
5843
|
-
const argv = process.argv.slice(3);
|
|
5844
|
-
const flag = (name) => { const i = argv.indexOf(`--${name}`); return i !== -1 ? argv[i + 1] : undefined; };
|
|
5845
|
-
const dir = resolve(flag('dir') ?? argv.find(a => !a.startsWith('--')) ?? '.');
|
|
5846
|
-
const instanceId = flag('instance-id') ?? process.env.CACHLY_BRAIN_INSTANCE_ID;
|
|
5847
|
-
const maxFiles = parseInt(flag('max-files') ?? '500', 10);
|
|
5848
|
-
const namespace = flag('namespace') ?? 'cachly:sem:code';
|
|
5849
|
-
if (!instanceId || !JWT) {
|
|
5850
|
-
console.error('\n❌ CACHLY_BRAIN_INSTANCE_ID and CACHLY_JWT must be set\n');
|
|
5851
|
-
console.error(' export CACHLY_BRAIN_INSTANCE_ID=<uuid>');
|
|
5852
|
-
console.error(' export CACHLY_JWT=<cky_live_...>');
|
|
5853
|
-
console.error(' npx @cachly-dev/mcp-server@latest index ./my-project\n');
|
|
5854
|
-
process.exit(1);
|
|
5855
|
-
}
|
|
5856
|
-
console.log(`\n📂 Indexing: ${dir}`);
|
|
5857
|
-
console.log(` Instance: ${instanceId.slice(0, 8)}… Max files: ${maxFiles}\n`);
|
|
5858
|
-
try {
|
|
5859
|
-
const result = await handleTool('index_project', {
|
|
5860
|
-
instance_id: instanceId,
|
|
5861
|
-
dir,
|
|
5862
|
-
max_files: maxFiles,
|
|
5863
|
-
ttl: 86400 * 7,
|
|
5864
|
-
namespace,
|
|
5865
|
-
});
|
|
5866
|
-
console.log(result);
|
|
5867
|
-
console.log('\n✅ Indexing complete.\n');
|
|
5868
|
-
}
|
|
5869
|
-
catch (err) {
|
|
5870
|
-
console.error(`\n❌ Indexing failed: ${err.message}\n`);
|
|
5871
|
-
process.exit(1);
|
|
5872
|
-
}
|
|
5873
3857
|
process.exit(0);
|
|
5874
3858
|
}
|
|
5875
3859
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
5876
|
-
|
|
5877
|
-
|
|
5878
|
-
if (!JWT) {
|
|
5879
|
-
process.stderr.write('\n' +
|
|
5880
|
-
'╔══════════════════════════════════════════════════════════════════╗\n' +
|
|
5881
|
-
'║ 🧠 cachly AI Brain — Setup required ║\n' +
|
|
5882
|
-
'╠══════════════════════════════════════════════════════════════════╣\n' +
|
|
5883
|
-
'║ ║\n' +
|
|
5884
|
-
'║ CACHLY_JWT is not set. Get your free credentials at: ║\n' +
|
|
5885
|
-
'║ ║\n' +
|
|
5886
|
-
'║ 👉 https://cachly.dev/setup-ai ║\n' +
|
|
5887
|
-
'║ ║\n' +
|
|
5888
|
-
'║ Then run the interactive setup wizard: ║\n' +
|
|
5889
|
-
'║ ║\n' +
|
|
5890
|
-
'║ npx @cachly-dev/mcp-server@latest setup ║\n' +
|
|
5891
|
-
'║ ║\n' +
|
|
5892
|
-
'║ Free tier — no credit card required. ║\n' +
|
|
5893
|
-
'╚══════════════════════════════════════════════════════════════════╝\n' +
|
|
5894
|
-
'\n');
|
|
5895
|
-
}
|
|
5896
|
-
// ── Update nudge (non-blocking, fire-and-forget) ─────────────────────────────
|
|
5897
|
-
// Check npm registry once per process start; if outdated, log to stderr so
|
|
5898
|
-
// the editor's MCP log shows an actionable one-liner. Skipped if opted out.
|
|
5899
|
-
if (!process.env.CACHLY_NO_UPDATE_CHECK) {
|
|
5900
|
-
(async () => {
|
|
5901
|
-
try {
|
|
5902
|
-
const res = await fetch(`https://registry.npmjs.org/@cachly-dev/mcp-server/latest`, { signal: AbortSignal.timeout(4000) });
|
|
5903
|
-
if (res.ok) {
|
|
5904
|
-
const data = await res.json();
|
|
5905
|
-
const latest = data?.version ?? '';
|
|
5906
|
-
if (latest && latest !== CURRENT_VERSION) {
|
|
5907
|
-
process.stderr.write(`\n⚡ cachly update available: ${CURRENT_VERSION} → ${latest}\n` +
|
|
5908
|
-
` Run: npx @cachly-dev/mcp-server@latest setup\n\n`);
|
|
5909
|
-
}
|
|
5910
|
-
}
|
|
5911
|
-
}
|
|
5912
|
-
catch { /* ignore – network unavailable or timeout */ }
|
|
5913
|
-
})();
|
|
5914
|
-
}
|
|
5915
|
-
const httpPort = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined;
|
|
5916
|
-
if (httpPort) {
|
|
5917
|
-
// ── HTTP mode (Streamable HTTP transport) ───────────────────────────────
|
|
5918
|
-
// Used for Smithery URL deployment: PORT=3000 node dist/index.js
|
|
5919
|
-
const { createServer } = await import('node:http');
|
|
5920
|
-
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
5921
|
-
await server.connect(transport);
|
|
5922
|
-
const httpServer = createServer((req, res) => {
|
|
5923
|
-
const url = new URL(req.url ?? '/', `http://localhost:${httpPort}`);
|
|
5924
|
-
if (url.pathname === '/health') {
|
|
5925
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
5926
|
-
res.end(JSON.stringify({ status: 'ok', version: CURRENT_VERSION }));
|
|
5927
|
-
return;
|
|
5928
|
-
}
|
|
5929
|
-
if (url.pathname === '/mcp' || url.pathname === '/') {
|
|
5930
|
-
transport.handleRequest(req, res);
|
|
5931
|
-
return;
|
|
5932
|
-
}
|
|
5933
|
-
res.writeHead(404);
|
|
5934
|
-
res.end('not found');
|
|
5935
|
-
});
|
|
5936
|
-
httpServer.listen(httpPort, () => {
|
|
5937
|
-
process.stderr.write(`cachly-mcp HTTP server listening on :${httpPort}\n`);
|
|
5938
|
-
});
|
|
5939
|
-
}
|
|
5940
|
-
else {
|
|
5941
|
-
// ── stdio mode (default for local editor use) ───────────────────────────
|
|
5942
|
-
const transport = new StdioServerTransport();
|
|
5943
|
-
await server.connect(transport);
|
|
5944
|
-
}
|
|
3860
|
+
const transport = new StdioServerTransport();
|
|
3861
|
+
await server.connect(transport);
|
|
5945
3862
|
//# sourceMappingURL=index.js.map
|