@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/src/index.ts
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,6 +31,7 @@ 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)
|
|
@@ -41,7 +39,6 @@ import { join, relative, extname } from 'node:path';
|
|
|
41
39
|
|
|
42
40
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
43
41
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
44
|
-
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
45
42
|
import {
|
|
46
43
|
CallToolRequestSchema,
|
|
47
44
|
ErrorCode,
|
|
@@ -53,9 +50,9 @@ import { Redis } from 'ioredis';
|
|
|
53
50
|
// ── Config ────────────────────────────────────────────────────────────────────
|
|
54
51
|
|
|
55
52
|
const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
|
|
53
|
+
const AUTH_URL = process.env.CACHLY_AUTH_URL ?? 'https://auth.cachly.dev';
|
|
56
54
|
const JWT = process.env.CACHLY_JWT ?? '';
|
|
57
55
|
const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
|
|
58
|
-
const CURRENT_VERSION = '0.5.26';
|
|
59
56
|
/**
|
|
60
57
|
* ┌──────────────────────────────────────────────────────────────────────┐
|
|
61
58
|
* │ EMBEDDING PROVIDER — pluggable, client-side first │
|
|
@@ -75,44 +72,6 @@ const CURRENT_VERSION = '0.5.26';
|
|
|
75
72
|
*/
|
|
76
73
|
const EMBED_PROVIDER = (process.env.CACHLY_EMBED_PROVIDER ?? detectEmbedProvider()).toLowerCase();
|
|
77
74
|
|
|
78
|
-
// ── Confidence Decay Config (all values configurable via env) ─────────────────
|
|
79
|
-
const CONFIDENCE_WARN_DAYS = Number(process.env.CACHLY_CONFIDENCE_WARN_DAYS ?? 5); // → 0.7
|
|
80
|
-
const CONFIDENCE_STALE_DAYS = Number(process.env.CACHLY_CONFIDENCE_STALE_DAYS ?? 10); // → 0.5
|
|
81
|
-
const CONFIDENCE_WARN_VALUE = 0.7;
|
|
82
|
-
const CONFIDENCE_STALE_VALUE = 0.5;
|
|
83
|
-
|
|
84
|
-
/** Calculate current confidence for a lesson based on how long since last verified. */
|
|
85
|
-
function calculateConfidence(lesson: { verified_at?: string; ts: string; recall_count?: number }): number {
|
|
86
|
-
const ref = lesson.verified_at ?? lesson.ts;
|
|
87
|
-
const ageMs = Date.now() - new Date(ref).getTime();
|
|
88
|
-
const ageDays = ageMs / 86400000;
|
|
89
|
-
if (ageDays >= CONFIDENCE_STALE_DAYS) return CONFIDENCE_STALE_VALUE;
|
|
90
|
-
if (ageDays >= CONFIDENCE_WARN_DAYS) return CONFIDENCE_WARN_VALUE;
|
|
91
|
-
// Linear interpolation between fresh (1.0) and warn threshold
|
|
92
|
-
return 1.0 - (ageDays / CONFIDENCE_WARN_DAYS) * (1.0 - CONFIDENCE_WARN_VALUE);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Render a confidence badge string. */
|
|
96
|
-
function confidenceBadge(confidence: number, ageDays: number): string {
|
|
97
|
-
if (confidence >= 0.9) return '✅';
|
|
98
|
-
if (confidence >= 0.7) return `⚠️ (${Math.round(ageDays)}d old, confidence ${(confidence * 100).toFixed(0)}% — verify before applying)`;
|
|
99
|
-
return `🔴 STALE (${Math.round(ageDays)}d old, confidence ${(confidence * 100).toFixed(0)}% — likely outdated!)`;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** Category-specific required fields for structured lessons. */
|
|
103
|
-
const STRUCTURED_TEMPLATES: Record<string, { required: string[]; hint: string }> = {
|
|
104
|
-
'deploy': { required: ['commands'], hint: 'deploy:* needs commands[]' },
|
|
105
|
-
'bash': { required: ['commands'], hint: 'bash:* needs commands[]' },
|
|
106
|
-
'infra': { required: ['commands'], hint: 'infra:* needs commands[] + verified on real system' },
|
|
107
|
-
'pricing': { required: [], hint: 'pricing:* — add context with source (e.g. "Stripe Dashboard Apr 2026")' },
|
|
108
|
-
'stripe': { required: [], hint: 'stripe:* — add context with Stripe API version' },
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
/** Fast content hash for index invalidation (not cryptographic, just change detection) */
|
|
112
|
-
function simpleHash(text: string): string {
|
|
113
|
-
return createHash('md5').update(text).digest('hex').slice(0, 12);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
75
|
function detectEmbedProvider(): string {
|
|
117
76
|
// Client-side keys first (direct, fast, no server roundtrip)
|
|
118
77
|
if (process.env.OPENAI_API_KEY) return 'openai';
|
|
@@ -301,18 +260,13 @@ function embedProviderHint(): string {
|
|
|
301
260
|
// • Recency boost — newer entries rank higher (exponential decay, 7-day half-life)
|
|
302
261
|
// • Multi-query splitting — numbered lists, semicolons, conjunctions
|
|
303
262
|
// • Fuzzy matching with Levenshtein distance ≤ 2
|
|
304
|
-
// • Multilingual stopwords (EN, DE, FR, ES, IT, PT
|
|
305
|
-
// • CJK character bigram extraction — handles Chinese, Japanese, Korean
|
|
306
|
-
// • RTL language support (Arabic, Hebrew) — word tokenization + light stemming
|
|
307
|
-
// • Romanization matching — katakana → romaji tokens for romaji queries
|
|
308
|
-
// • Cross-language retrieval — tech term synonyms EN↔JA↔ZH↔KO↔AR↔HE
|
|
263
|
+
// • Multilingual stopwords (EN, DE, FR, ES, IT, PT)
|
|
309
264
|
// • Pipeline Redis reads for performance
|
|
310
265
|
//
|
|
311
266
|
|
|
312
267
|
/**
|
|
313
268
|
* Stopwords — filtered out during tokenization.
|
|
314
|
-
* Covers: English, German, French, Spanish, Italian, Portuguese
|
|
315
|
-
* Chinese (Simplified + Traditional), Japanese, Korean.
|
|
269
|
+
* Covers: English, German, French, Spanish, Italian, Portuguese.
|
|
316
270
|
* Keeps the index small and scores meaningful.
|
|
317
271
|
*/
|
|
318
272
|
const STOPWORDS = new Set([
|
|
@@ -386,436 +340,15 @@ const STOPWORDS = new Set([
|
|
|
386
340
|
'eu', 'tu', 'ele', 'ela', 'nós', 'eles', 'elas', 'você', 'vocês',
|
|
387
341
|
'não', 'já', 'sim', 'bem', 'muito', 'também', 'ainda', 'sempre',
|
|
388
342
|
'todo', 'toda', 'todos', 'cada', 'outro', 'outra', 'quando', 'porque',
|
|
389
|
-
// ── Chinese (Simplified + Traditional) — high-frequency function characters ──
|
|
390
|
-
'的', '了', '是', '在', '不', '和', '我', '他', '这', '中', '大', '为',
|
|
391
|
-
'上', '个', '国', '以', '要', '就', '出', '说', '们', '有', '来', '到',
|
|
392
|
-
'时', '地', '年', '得', '着', '那', '过', '后', '还', '与', '也', '可',
|
|
393
|
-
'于', '从', '但', '而', '被', '把', '让', '使', '对', '很', '都', '一',
|
|
394
|
-
'会', '没', '人', '它', '这个', '那个', '什么', '如果', '因为', '所以',
|
|
395
|
-
'已经', '可以', '这些', '那些', '我们', '他们', '她们', '它们',
|
|
396
|
-
// ── Japanese — common hiragana particles and auxiliary verbs ──
|
|
397
|
-
'の', 'は', 'が', 'を', 'に', 'で', 'と', 'も', 'や', 'か', 'な', 'ね',
|
|
398
|
-
'よ', 'わ', 'て', 'い', 'う', 'え', 'お', 'き', 'く', 'け', 'こ', 'さ',
|
|
399
|
-
'し', 'す', 'せ', 'そ', 'た', 'ち', 'つ', 'ぬ', 'ん', 'から', 'まで',
|
|
400
|
-
'より', 'へ', 'です', 'ます', 'ない', 'ある', 'いる', 'する', 'こと',
|
|
401
|
-
'もの', 'ので', 'では', 'には', 'との', 'への', 'から', 'まで',
|
|
402
|
-
// ── Korean — common particles and auxiliary forms ──
|
|
403
|
-
'이', '가', '은', '는', '을', '를', '의', '에', '와', '과', '도',
|
|
404
|
-
'로', '에서', '한', '하', '있', '없', '것', '수', '않', '들',
|
|
405
|
-
'이다', '하다', '이고', '이며', '라고', '에게', '에서', '으로',
|
|
406
|
-
// ── Arabic — high-frequency function words & particles ──
|
|
407
|
-
'في', 'من', 'إلى', 'على', 'عن', 'مع', 'هذا', 'هذه', 'ذلك', 'تلك',
|
|
408
|
-
'الذي', 'التي', 'الذين', 'اللواتي', 'هو', 'هي', 'هم', 'هن', 'نحن',
|
|
409
|
-
'أنت', 'أنتم', 'أنا', 'كان', 'كانت', 'كانوا', 'يكون', 'تكون',
|
|
410
|
-
'أن', 'إن', 'لأن', 'لكن', 'أو', 'بل', 'ثم', 'حتى', 'إذا', 'كي',
|
|
411
|
-
'قد', 'لقد', 'لم', 'لن', 'ما', 'لا', 'ليس', 'غير', 'بعض', 'كل',
|
|
412
|
-
'جميع', 'أي', 'كيف', 'متى', 'أين', 'لماذا', 'ماذا', 'هل', 'ال',
|
|
413
|
-
'و', 'ف', 'ب', 'ل', 'ك', 'يا', 'أم', 'إلا', 'عند', 'بين',
|
|
414
|
-
// ── Hebrew — common particles, pronouns, conjunctions ──
|
|
415
|
-
'של', 'את', 'אל', 'על', 'עם', 'לא', 'הוא', 'היא', 'הם', 'הן',
|
|
416
|
-
'אני', 'אתה', 'אנחנו', 'אתם', 'כי', 'אם', 'אבל', 'גם', 'כבר',
|
|
417
|
-
'רק', 'עוד', 'יש', 'אין', 'מה', 'זה', 'זאת', 'אלה', 'כל', 'כן',
|
|
418
|
-
'לו', 'לה', 'להם', 'בו', 'בה', 'בהם', 'שם', 'כך', 'כן', 'מי',
|
|
419
|
-
'אשר', 'אחרי', 'לפני', 'תחת', 'בין', 'מאז', 'עד', 'כמו', 'אז',
|
|
420
|
-
]);
|
|
421
|
-
|
|
422
|
-
// CJK Unicode ranges used for bigram extraction
|
|
423
|
-
const CJK_RE = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]/;
|
|
424
|
-
const SEGMENT_RE = /([\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]+|[^\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]+)/g;
|
|
425
|
-
|
|
426
|
-
// RTL Unicode ranges: Arabic (U+0600–U+06FF + extended), Hebrew (U+0590–U+05FF)
|
|
427
|
-
const RTL_RE = /[\u0590-\u05ff\u0600-\u06ff\u0750-\u077f\u08a0-\u08ff\ufb1d-\ufb4f\ufb50-\ufdff\ufe70-\ufeff]/;
|
|
428
|
-
|
|
429
|
-
// ── Katakana → Romaji conversion table (Hepburn system) ──────────────────────
|
|
430
|
-
// Digraphs must be listed before single chars so they match first.
|
|
431
|
-
const KATA_ROMAJI_TABLE: [string, string][] = [
|
|
432
|
-
// Special combinations for loanwords
|
|
433
|
-
['ファ', 'fa'], ['フィ', 'fi'], ['フェ', 'fe'], ['フォ', 'fo'],
|
|
434
|
-
['ティ', 'ti'], ['ディ', 'di'], ['トゥ', 'tu'], ['ドゥ', 'du'],
|
|
435
|
-
['ウィ', 'wi'], ['ウェ', 'we'], ['ウォ', 'wo'],
|
|
436
|
-
['チェ', 'che'], ['ジェ', 'je'], ['シェ', 'she'],
|
|
437
|
-
['イェ', 'ye'], ['ヴァ', 'va'], ['ヴィ', 'vi'], ['ヴェ', 've'], ['ヴォ', 'vo'],
|
|
438
|
-
// Digraphs (2-char → romaji)
|
|
439
|
-
['キャ', 'kya'], ['キュ', 'kyu'], ['キョ', 'kyo'],
|
|
440
|
-
['シャ', 'sha'], ['シュ', 'shu'], ['ショ', 'sho'],
|
|
441
|
-
['チャ', 'cha'], ['チュ', 'chu'], ['チョ', 'cho'],
|
|
442
|
-
['ニャ', 'nya'], ['ニュ', 'nyu'], ['ニョ', 'nyo'],
|
|
443
|
-
['ヒャ', 'hya'], ['ヒュ', 'hyu'], ['ヒョ', 'hyo'],
|
|
444
|
-
['ミャ', 'mya'], ['ミュ', 'myu'], ['ミョ', 'myo'],
|
|
445
|
-
['リャ', 'rya'], ['リュ', 'ryu'], ['リョ', 'ryo'],
|
|
446
|
-
['ギャ', 'gya'], ['ギュ', 'gyu'], ['ギョ', 'gyo'],
|
|
447
|
-
['ジャ', 'ja'], ['ジュ', 'ju'], ['ジョ', 'jo'],
|
|
448
|
-
['ビャ', 'bya'], ['ビュ', 'byu'], ['ビョ', 'byo'],
|
|
449
|
-
['ピャ', 'pya'], ['ピュ', 'pyu'], ['ピョ', 'pyo'],
|
|
450
|
-
// Single chars
|
|
451
|
-
['ア', 'a'], ['イ', 'i'], ['ウ', 'u'], ['エ', 'e'], ['オ', 'o'],
|
|
452
|
-
['カ', 'ka'], ['キ', 'ki'], ['ク', 'ku'], ['ケ', 'ke'], ['コ', 'ko'],
|
|
453
|
-
['サ', 'sa'], ['シ', 'shi'], ['ス', 'su'], ['セ', 'se'], ['ソ', 'so'],
|
|
454
|
-
['タ', 'ta'], ['チ', 'chi'], ['ツ', 'tsu'], ['テ', 'te'], ['ト', 'to'],
|
|
455
|
-
['ナ', 'na'], ['ニ', 'ni'], ['ヌ', 'nu'], ['ネ', 'ne'], ['ノ', 'no'],
|
|
456
|
-
['ハ', 'ha'], ['ヒ', 'hi'], ['フ', 'fu'], ['ヘ', 'he'], ['ホ', 'ho'],
|
|
457
|
-
['マ', 'ma'], ['ミ', 'mi'], ['ム', 'mu'], ['メ', 'me'], ['モ', 'mo'],
|
|
458
|
-
['ヤ', 'ya'], ['ユ', 'yu'], ['ヨ', 'yo'],
|
|
459
|
-
['ラ', 'ra'], ['リ', 'ri'], ['ル', 'ru'], ['レ', 're'], ['ロ', 'ro'],
|
|
460
|
-
['ワ', 'wa'], ['ヲ', 'wo'], ['ン', 'n'],
|
|
461
|
-
// Voiced
|
|
462
|
-
['ガ', 'ga'], ['ギ', 'gi'], ['グ', 'gu'], ['ゲ', 'ge'], ['ゴ', 'go'],
|
|
463
|
-
['ザ', 'za'], ['ジ', 'ji'], ['ズ', 'zu'], ['ゼ', 'ze'], ['ゾ', 'zo'],
|
|
464
|
-
['ダ', 'da'], ['ヂ', 'ji'], ['ヅ', 'zu'], ['デ', 'de'], ['ド', 'do'],
|
|
465
|
-
['バ', 'ba'], ['ビ', 'bi'], ['ブ', 'bu'], ['ベ', 'be'], ['ボ', 'bo'],
|
|
466
|
-
['パ', 'pa'], ['ピ', 'pi'], ['プ', 'pu'], ['ペ', 'pe'], ['ポ', 'po'],
|
|
467
|
-
['ヴ', 'v'],
|
|
468
|
-
// Long vowel / small chars
|
|
469
|
-
['ー', ''], ['ァ', 'a'], ['ィ', 'i'], ['ゥ', 'u'], ['ェ', 'e'], ['ォ', 'o'],
|
|
470
|
-
['ッ', ''], // handled separately (doubles next consonant)
|
|
471
|
-
];
|
|
472
|
-
|
|
473
|
-
const KATA_MAP = new Map<string, string>(KATA_ROMAJI_TABLE);
|
|
474
|
-
|
|
475
|
-
/**
|
|
476
|
-
* Convert a katakana string to Hepburn romaji.
|
|
477
|
-
* Handles digraphs, geminate consonants (ッ), and long vowel marks (ー).
|
|
478
|
-
*/
|
|
479
|
-
function katakanaToRomaji(kata: string): string {
|
|
480
|
-
let result = '';
|
|
481
|
-
let i = 0;
|
|
482
|
-
while (i < kata.length) {
|
|
483
|
-
// Geminate consonant: ッ doubles the following consonant
|
|
484
|
-
if (kata[i] === 'ッ' && i + 1 < kata.length) {
|
|
485
|
-
const next = KATA_MAP.get(kata[i + 1]) ?? KATA_MAP.get(kata[i + 1] + kata[i + 2]) ?? '';
|
|
486
|
-
if (next.length > 0) result += next[0]; // double first consonant
|
|
487
|
-
i++;
|
|
488
|
-
continue;
|
|
489
|
-
}
|
|
490
|
-
// Try 2-char digraph first
|
|
491
|
-
if (i + 1 < kata.length) {
|
|
492
|
-
const two = kata[i] + kata[i + 1];
|
|
493
|
-
const r2 = KATA_MAP.get(two);
|
|
494
|
-
if (r2 !== undefined) { result += r2; i += 2; continue; }
|
|
495
|
-
}
|
|
496
|
-
// Single char
|
|
497
|
-
const r1 = KATA_MAP.get(kata[i]);
|
|
498
|
-
if (r1 !== undefined) result += r1;
|
|
499
|
-
i++;
|
|
500
|
-
}
|
|
501
|
-
return result;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
/**
|
|
505
|
-
* Arabic light stemmer — strips definite article and common prefix particles.
|
|
506
|
-
* Handles: ال (al-), و (wa-), ب (bi-), ل (li-), ف (fa-), ك (ka-).
|
|
507
|
-
* Only strips when the result is still ≥ 3 chars to avoid over-stemming.
|
|
508
|
-
*/
|
|
509
|
-
function arabicLightStem(word: string): string {
|
|
510
|
-
if (word.startsWith('ال') && word.length > 4) return word.slice(2);
|
|
511
|
-
if (word.length > 3 && 'وبلفك'.includes(word[0]) && RTL_RE.test(word[1])) return word.slice(1);
|
|
512
|
-
return word;
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
// ── Cross-lingual tech term dictionary ────────────────────────────────────────
|
|
516
|
-
// Maps tokens (English, Japanese katakana, Chinese, Korean, Arabic, Hebrew)
|
|
517
|
-
// to their equivalents in other languages. Used to expand tokens at search time
|
|
518
|
-
// so "deploy" finds documents containing "デプロイ" / "部署" / "배포", and vice versa.
|
|
519
|
-
//
|
|
520
|
-
// Keys must be lowercase. CJK values will be bigram-expanded at use time.
|
|
521
|
-
const CROSS_LINGUAL_MAP = new Map<string, string[]>([
|
|
522
|
-
// English → all
|
|
523
|
-
['deploy', ['デプロイ', '部署', '배포', 'deployment', 'deploying']],
|
|
524
|
-
['deployment', ['デプロイ', '部署', '배포', 'deploy']],
|
|
525
|
-
['container', ['コンテナ', '容器', '컨테이너', 'docker']],
|
|
526
|
-
['server', ['サーバー', 'サーバ', '服务器', '서버', 'سيرفر', 'שרת']],
|
|
527
|
-
['database', ['データベース', '数据库', '데이터베이스', 'db', 'قاعدة البيانات']],
|
|
528
|
-
['cache', ['キャッシュ', '缓存', '캐시', 'كاش', 'מטמון']],
|
|
529
|
-
['error', ['エラー', '错误', '오류', 'خطأ', 'שגיאה', 'exception', 'err']],
|
|
530
|
-
['bug', ['バグ', '缺陷', '버그', 'issue', 'defect', 'خلل']],
|
|
531
|
-
['fix', ['修正', '修复', '수정', 'bugfix', 'patch', 'hotfix', 'إصلاح', 'תיקון']],
|
|
532
|
-
['build', ['ビルド', '构建', '빌드', 'بناء', 'בנייה']],
|
|
533
|
-
['test', ['テスト', '测试', '테스트', 'اختبار', 'בדיקה']],
|
|
534
|
-
['auth', ['認証', '认证', '인증', 'authentication', 'login', 'oauth', 'مصادقة', 'אימות']],
|
|
535
|
-
['kubernetes', ['クベルネテス', 'k8s', 'kube']],
|
|
536
|
-
['network', ['ネットワーク', '网络', '네트워크', 'شبكة', 'רשת']],
|
|
537
|
-
['timeout', ['タイムアウト', '超时', '타임아웃', 'مهلة']],
|
|
538
|
-
['memory', ['メモリ', '内存', '메모리', 'ram', 'ذاكرة', 'זיכרון']],
|
|
539
|
-
['config', ['設定', '配置', '설정', 'configuration', 'settings', 'conf', 'إعدادات', 'הגדרות']],
|
|
540
|
-
['install', ['インストール', '安装', '설치', 'تثبيت', 'התקנה']],
|
|
541
|
-
['update', ['アップデート', '更新', '업데이트', 'upgrade', 'تحديث', 'עדכון']],
|
|
542
|
-
['log', ['ログ', '日志', '로그', 'logging', 'سجل', 'יומן']],
|
|
543
|
-
['port', ['ポート', '端口', '포트', 'منفذ', 'פורט']],
|
|
544
|
-
['file', ['ファイル', '文件', '파일', 'ملف', 'קובץ']],
|
|
545
|
-
['image', ['イメージ', '镜像', '이미지', 'صورة', 'תמונה']],
|
|
546
|
-
['volume', ['ボリューム', '卷', '볼륨']],
|
|
547
|
-
['cluster', ['クラスター', '集群', '클러스터']],
|
|
548
|
-
['node', ['ノード', '节点', '노드']],
|
|
549
|
-
['service', ['サービス', '服务', '서비스', 'svc', 'خدمة', 'שירות']],
|
|
550
|
-
['certificate', ['証明書', '证书', '인증서', 'cert', 'ssl', 'tls', 'شهادة', 'תעודה']],
|
|
551
|
-
['password', ['パスワード', '密码', '비밀번호', 'passwd', 'pwd', 'كلمة المرور', 'סיסמה']],
|
|
552
|
-
['token', ['トークン', '令牌', '토큰', 'jwt', 'secret', 'رمز', 'אסימון']],
|
|
553
|
-
['health', ['ヘルス', '健康', '헬스', 'healthcheck', 'probe', 'صحة']],
|
|
554
|
-
['migration', ['マイグレーション', '迁移', '마이그레이션', 'migrate', 'هجرة']],
|
|
555
|
-
['backup', ['バックアップ', '备份', '백업', 'نسخ احتياطي', 'גיבוי']],
|
|
556
|
-
['monitor', ['モニター', '监控', '모니터링', 'monitoring', 'metrics', 'alert', 'مراقبة', 'ניטור']],
|
|
557
|
-
['performance', ['パフォーマンス', '性能', '성능', 'latency', 'throughput']],
|
|
558
|
-
['connection', ['接続', '连接', '연결', 'conn', 'socket', 'اتصال', 'חיבור']],
|
|
559
|
-
['queue', ['キュー', '队列', '큐', 'طابور']],
|
|
560
|
-
['redis', ['レディス', '레디스']],
|
|
561
|
-
['nginx', ['エンジンエックス']],
|
|
562
|
-
['linux', ['リナックス', 'لينكس']],
|
|
563
|
-
['api', ['エーピーアイ', '接口', 'endpoint', 'واجهة برمجية', 'ממשק']],
|
|
564
|
-
['healthcheck', ['ヘルスチェック', 'health', 'probe', 'فحص الصحة']],
|
|
565
|
-
['loadbalancer',['ロードバランサー', '负载均衡', '로드밸런서', 'lb']],
|
|
566
|
-
['ssl', ['tls', 'https', 'certificate', 'cert']],
|
|
567
|
-
['tls', ['ssl', 'https', 'certificate', 'cert']],
|
|
568
|
-
['docker', ['container', 'コンテナ', '容器', '컨테이너', 'دوكر']],
|
|
569
|
-
['git', ['version control', 'repo', 'repository', 'commit', 'push', 'pull']],
|
|
570
|
-
['ci', ['pipeline', 'github actions', 'gitlab', 'jenkins', 'build']],
|
|
571
|
-
['cd', ['deploy', 'deployment', 'release']],
|
|
572
|
-
['debug', ['デバッグ', '调试', '디버그', 'debugging', 'breakpoint', 'تصحيح', 'איתור באגים']],
|
|
573
|
-
['crash', ['クラッシュ', '崩溃', '크래시', 'panic', 'segfault', 'انهيار']],
|
|
574
|
-
['restart', ['再起動', '重启', '재시작', 'reboot', 'إعادة تشغيل', 'הפעלה מחדש']],
|
|
575
|
-
['permission', ['権限', '权限', '권한', 'access', 'acl', 'chmod', 'صلاحية', 'הרשאה']],
|
|
576
|
-
// Japanese katakana → English
|
|
577
|
-
['デプロイ', ['deploy', 'deployment']],
|
|
578
|
-
['コンテナ', ['container', 'docker']],
|
|
579
|
-
['サーバー', ['server']],
|
|
580
|
-
['サーバ', ['server']],
|
|
581
|
-
['データベース',['database', 'db']],
|
|
582
|
-
['キャッシュ', ['cache']],
|
|
583
|
-
['エラー', ['error', 'err']],
|
|
584
|
-
['バグ', ['bug', 'issue']],
|
|
585
|
-
['ビルド', ['build']],
|
|
586
|
-
['テスト', ['test']],
|
|
587
|
-
['認証', ['auth', 'authentication']],
|
|
588
|
-
['設定', ['config', 'configuration']],
|
|
589
|
-
['インストール',['install']],
|
|
590
|
-
['アップデート',['update', 'upgrade']],
|
|
591
|
-
['ログ', ['log', 'logs']],
|
|
592
|
-
['ポート', ['port']],
|
|
593
|
-
['ファイル', ['file']],
|
|
594
|
-
['イメージ', ['image']],
|
|
595
|
-
['クラスター', ['cluster']],
|
|
596
|
-
['ノード', ['node']],
|
|
597
|
-
['サービス', ['service']],
|
|
598
|
-
['パスワード', ['password']],
|
|
599
|
-
['トークン', ['token']],
|
|
600
|
-
['バックアップ',['backup']],
|
|
601
|
-
['モニター', ['monitor', 'monitoring']],
|
|
602
|
-
['接続', ['connection']],
|
|
603
|
-
['キュー', ['queue']],
|
|
604
|
-
['ヘルスチェック',['healthcheck', 'health check', 'health']],
|
|
605
|
-
['ロードバランサー',['loadbalancer', 'load balancer']],
|
|
606
|
-
['デバッグ', ['debug']],
|
|
607
|
-
['クラッシュ', ['crash', 'panic']],
|
|
608
|
-
['再起動', ['restart', 'reboot']],
|
|
609
|
-
['権限', ['permission', 'access']],
|
|
610
|
-
// Chinese → English
|
|
611
|
-
['部署', ['deploy', 'deployment']],
|
|
612
|
-
['容器', ['container', 'docker']],
|
|
613
|
-
['服务器', ['server']],
|
|
614
|
-
['数据库', ['database']],
|
|
615
|
-
['缓存', ['cache']],
|
|
616
|
-
['错误', ['error']],
|
|
617
|
-
['修复', ['fix', 'bugfix']],
|
|
618
|
-
['构建', ['build']],
|
|
619
|
-
['测试', ['test']],
|
|
620
|
-
['认证', ['auth']],
|
|
621
|
-
['配置', ['config']],
|
|
622
|
-
['安装', ['install']],
|
|
623
|
-
['更新', ['update']],
|
|
624
|
-
['日志', ['log']],
|
|
625
|
-
['端口', ['port']],
|
|
626
|
-
['镜像', ['image']],
|
|
627
|
-
['集群', ['cluster']],
|
|
628
|
-
['节点', ['node']],
|
|
629
|
-
['服务', ['service']],
|
|
630
|
-
['密码', ['password']],
|
|
631
|
-
['令牌', ['token']],
|
|
632
|
-
['备份', ['backup']],
|
|
633
|
-
['监控', ['monitor']],
|
|
634
|
-
['连接', ['connection']],
|
|
635
|
-
['队列', ['queue']],
|
|
636
|
-
['性能', ['performance']],
|
|
637
|
-
['内存', ['memory']],
|
|
638
|
-
['调试', ['debug']],
|
|
639
|
-
['崩溃', ['crash']],
|
|
640
|
-
['重启', ['restart']],
|
|
641
|
-
['权限', ['permission']],
|
|
642
|
-
// Korean → English
|
|
643
|
-
['배포', ['deploy', 'deployment']],
|
|
644
|
-
['컨테이너', ['container']],
|
|
645
|
-
['서버', ['server']],
|
|
646
|
-
['데이터베이스',['database']],
|
|
647
|
-
['캐시', ['cache']],
|
|
648
|
-
['오류', ['error']],
|
|
649
|
-
['수정', ['fix']],
|
|
650
|
-
['빌드', ['build']],
|
|
651
|
-
['테스트', ['test']],
|
|
652
|
-
['인증', ['auth']],
|
|
653
|
-
['설정', ['config']],
|
|
654
|
-
['설치', ['install']],
|
|
655
|
-
['업데이트', ['update']],
|
|
656
|
-
['로그', ['log']],
|
|
657
|
-
['포트', ['port']],
|
|
658
|
-
['이미지', ['image']],
|
|
659
|
-
['클러스터', ['cluster']],
|
|
660
|
-
['노드', ['node']],
|
|
661
|
-
['서비스', ['service']],
|
|
662
|
-
['토큰', ['token']],
|
|
663
|
-
['백업', ['backup']],
|
|
664
|
-
['모니터링', ['monitor']],
|
|
665
|
-
['연결', ['connection']],
|
|
666
|
-
['큐', ['queue']],
|
|
667
|
-
['성능', ['performance']],
|
|
668
|
-
['메모리', ['memory']],
|
|
669
|
-
['디버그', ['debug']],
|
|
670
|
-
['크래시', ['crash']],
|
|
671
|
-
['재시작', ['restart']],
|
|
672
|
-
['권한', ['permission']],
|
|
673
|
-
// Arabic → English
|
|
674
|
-
['خطأ', ['error']],
|
|
675
|
-
['إصلاح', ['fix']],
|
|
676
|
-
['بناء', ['build']],
|
|
677
|
-
['اختبار', ['test']],
|
|
678
|
-
['مصادقة', ['auth']],
|
|
679
|
-
['إعدادات', ['config']],
|
|
680
|
-
['تثبيت', ['install']],
|
|
681
|
-
['تحديث', ['update']],
|
|
682
|
-
['سجل', ['log']],
|
|
683
|
-
['صورة', ['image']],
|
|
684
|
-
['خدمة', ['service']],
|
|
685
|
-
['نسخ احتياطي', ['backup']],
|
|
686
|
-
['مراقبة', ['monitor']],
|
|
687
|
-
['اتصال', ['connection']],
|
|
688
|
-
['ذاكرة', ['memory']],
|
|
689
|
-
['تصحيح', ['debug']],
|
|
690
|
-
['شبكة', ['network']],
|
|
691
|
-
// Hebrew → English
|
|
692
|
-
['שגיאה', ['error']],
|
|
693
|
-
['תיקון', ['fix']],
|
|
694
|
-
['בנייה', ['build']],
|
|
695
|
-
['בדיקה', ['test']],
|
|
696
|
-
['אימות', ['auth']],
|
|
697
|
-
['הגדרות', ['config']],
|
|
698
|
-
['התקנה', ['install']],
|
|
699
|
-
['עדכון', ['update']],
|
|
700
|
-
['יומן', ['log']],
|
|
701
|
-
['שרת', ['server']],
|
|
702
|
-
['שירות', ['service']],
|
|
703
|
-
['גיבוי', ['backup']],
|
|
704
|
-
['ניטור', ['monitor']],
|
|
705
|
-
['חיבור', ['connection']],
|
|
706
|
-
['זיכרון', ['memory']],
|
|
707
|
-
['רשת', ['network']],
|
|
708
|
-
['תמונה', ['image']],
|
|
709
|
-
['סיסמה', ['password']],
|
|
710
|
-
['הרשאה', ['permission']],
|
|
711
343
|
]);
|
|
712
344
|
|
|
713
|
-
/**
|
|
714
|
-
* Expand a token to its cross-lingual synonyms.
|
|
715
|
-
* Returns synonyms to be added to the token stream (pre-tokenized).
|
|
716
|
-
* CJK synonyms are NOT pre-bigram'd here — caller handles that.
|
|
717
|
-
*/
|
|
718
|
-
function expandCrossLingual(token: string): string[] {
|
|
719
|
-
return CROSS_LINGUAL_MAP.get(token) ?? [];
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
/**
|
|
723
|
-
* Tokenize text into meaningful keywords.
|
|
724
|
-
* Handles accented Latin, RTL (Arabic, Hebrew), and CJK (Chinese, Japanese, Korean).
|
|
725
|
-
*
|
|
726
|
-
* Features:
|
|
727
|
-
* • CJK → character bigrams
|
|
728
|
-
* • Katakana → additionally emits Hepburn romaji tokens (enables romaji queries)
|
|
729
|
-
* • Arabic/Hebrew → word tokenization with light Arabic stemming
|
|
730
|
-
* • All scripts → cross-lingual synonym expansion (EN↔JA↔ZH↔KO↔AR↔HE)
|
|
731
|
-
*/
|
|
345
|
+
/** Tokenize text into meaningful keywords. Handles unicode (accented chars). */
|
|
732
346
|
function tokenize(text: string): string[] {
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
// CJK segment — extract overlapping character bigrams
|
|
739
|
-
for (let i = 0; i < seg.length - 1; i++) {
|
|
740
|
-
const bigram = seg[i] + seg[i + 1];
|
|
741
|
-
if (!STOPWORDS.has(seg[i]) && !STOPWORDS.has(bigram)) {
|
|
742
|
-
tokens.push(bigram);
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
// Trailing single character (for 1-char CJK terms)
|
|
746
|
-
if (seg.length >= 1) {
|
|
747
|
-
const last = seg[seg.length - 1];
|
|
748
|
-
if (!STOPWORDS.has(last)) tokens.push(last);
|
|
749
|
-
}
|
|
750
|
-
// Whole-segment cross-lingual lookup (for known tech terms like デプロイ, 部署, 배포)
|
|
751
|
-
// This ensures CJK full-words map to their English equivalents
|
|
752
|
-
const segSyns = expandCrossLingual(seg);
|
|
753
|
-
for (const syn of segSyns) {
|
|
754
|
-
if (!CJK_RE.test(syn)) {
|
|
755
|
-
syn.split(/\s+/).filter(w => w.length > 1 && !STOPWORDS.has(w)).forEach(w => tokens.push(w));
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
// Romanization: extract katakana-only portion and emit romaji tokens.
|
|
759
|
-
// This allows users to search "kontena" or "depuroi" to find katakana docs.
|
|
760
|
-
const kataOnly = seg.replace(/[^\u30a0-\u30ff]/g, '');
|
|
761
|
-
if (kataOnly.length >= 2) {
|
|
762
|
-
const romaji = katakanaToRomaji(kataOnly);
|
|
763
|
-
if (romaji.length >= 2 && romaji.length <= 40 && !STOPWORDS.has(romaji)) {
|
|
764
|
-
tokens.push(romaji);
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
} else if (RTL_RE.test(seg)) {
|
|
768
|
-
// RTL segment (Arabic / Hebrew) — space-separated words with light stemming
|
|
769
|
-
const words = seg.trim().split(/\s+/);
|
|
770
|
-
for (const w of words) {
|
|
771
|
-
if (w.length <= 1 || STOPWORDS.has(w)) continue;
|
|
772
|
-
const stemmed = arabicLightStem(w);
|
|
773
|
-
if (stemmed.length > 1 && !STOPWORDS.has(stemmed)) tokens.push(stemmed);
|
|
774
|
-
}
|
|
775
|
-
} else {
|
|
776
|
-
// Latin / other — existing logic preserved exactly
|
|
777
|
-
const words = seg
|
|
778
|
-
.replace(/[^a-záàâãäåæçéèêëíìîïñóòôõöúùûüýÿßœ0-9:_\-./]/g, ' ')
|
|
779
|
-
.split(/\s+/)
|
|
780
|
-
.filter(w => w.length > 1 && !STOPWORDS.has(w));
|
|
781
|
-
tokens.push(...words);
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
// ── Cross-lingual expansion ──────────────────────────────────────────────
|
|
786
|
-
// For each token, look up synonyms in other languages and add them.
|
|
787
|
-
// CJK synonyms are bigram-expanded inline; Latin/RTL synonyms added as-is.
|
|
788
|
-
const expansions: string[] = [];
|
|
789
|
-
const seen = new Set(tokens);
|
|
790
|
-
for (const tok of tokens) {
|
|
791
|
-
const syns = expandCrossLingual(tok);
|
|
792
|
-
for (const syn of syns) {
|
|
793
|
-
if (seen.has(syn)) continue;
|
|
794
|
-
seen.add(syn);
|
|
795
|
-
if (CJK_RE.test(syn)) {
|
|
796
|
-
// CJK synonym → expand to character bigrams
|
|
797
|
-
for (let i = 0; i < syn.length - 1; i++) {
|
|
798
|
-
const bg = syn[i] + syn[i + 1];
|
|
799
|
-
if (!STOPWORDS.has(syn[i]) && !STOPWORDS.has(bg)) expansions.push(bg);
|
|
800
|
-
}
|
|
801
|
-
if (syn.length >= 1) {
|
|
802
|
-
const last = syn[syn.length - 1];
|
|
803
|
-
if (!STOPWORDS.has(last)) expansions.push(last);
|
|
804
|
-
}
|
|
805
|
-
} else if (RTL_RE.test(syn)) {
|
|
806
|
-
// RTL synonym
|
|
807
|
-
const stemmed = arabicLightStem(syn);
|
|
808
|
-
if (stemmed.length > 1 && !STOPWORDS.has(stemmed)) expansions.push(stemmed);
|
|
809
|
-
} else {
|
|
810
|
-
// Latin synonym — split in case of multi-word (e.g. "load balancer")
|
|
811
|
-
const parts = syn.split(/\s+/).filter(w => w.length > 1 && !STOPWORDS.has(w));
|
|
812
|
-
expansions.push(...parts);
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
tokens.push(...expansions);
|
|
817
|
-
|
|
818
|
-
return tokens;
|
|
347
|
+
return text
|
|
348
|
+
.toLowerCase()
|
|
349
|
+
.replace(/[^a-záàâãäåæçéèêëíìîïñóòôõöúùûüýÿßœ0-9:_\-./]/g, ' ')
|
|
350
|
+
.split(/\s+/)
|
|
351
|
+
.filter(w => w.length > 1 && !STOPWORDS.has(w));
|
|
819
352
|
}
|
|
820
353
|
|
|
821
354
|
/**
|
|
@@ -1128,8 +661,7 @@ async function keywordSearch(
|
|
|
1128
661
|
}
|
|
1129
662
|
|
|
1130
663
|
// ── Exported for testing ──────────────────────────────────────────────────────
|
|
1131
|
-
export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS
|
|
1132
|
-
katakanaToRomaji, arabicLightStem, expandCrossLingual, CROSS_LINGUAL_MAP };
|
|
664
|
+
export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS };
|
|
1133
665
|
|
|
1134
666
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
1135
667
|
|
|
@@ -1238,13 +770,34 @@ async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T>
|
|
|
1238
770
|
}
|
|
1239
771
|
const res = await fetch(`${API_URL}${path}`, {
|
|
1240
772
|
...options,
|
|
773
|
+
signal: AbortSignal.timeout(15000),
|
|
1241
774
|
headers: {
|
|
1242
775
|
'Content-Type': 'application/json',
|
|
1243
776
|
Authorization: `Bearer ${JWT}`,
|
|
1244
777
|
...options.headers,
|
|
1245
778
|
},
|
|
779
|
+
}).catch((err: Error) => {
|
|
780
|
+
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
|
|
781
|
+
throw new McpError(ErrorCode.InternalError,
|
|
782
|
+
`cachly API timed out after 15s — check your connection or try again.`);
|
|
783
|
+
}
|
|
784
|
+
throw new McpError(ErrorCode.InternalError, `cachly API unreachable: ${err.message}`);
|
|
1246
785
|
});
|
|
1247
786
|
if (!res.ok) {
|
|
787
|
+
if (res.status === 401 || res.status === 403) {
|
|
788
|
+
throw new McpError(
|
|
789
|
+
ErrorCode.InvalidRequest,
|
|
790
|
+
`cachly API: authentication failed (${res.status}) — your JWT may be expired or invalid.\n` +
|
|
791
|
+
`Get a new API token at https://cachly.dev/settings or run \`get_api_status\` to check token expiry.`
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
if (res.status === 503 || res.status === 502) {
|
|
795
|
+
throw new McpError(
|
|
796
|
+
ErrorCode.InternalError,
|
|
797
|
+
`cachly API: auth service unavailable (${res.status}) — the authentication service may be temporarily down.\n` +
|
|
798
|
+
`Please try again in a moment or contact support@cachly.dev`
|
|
799
|
+
);
|
|
800
|
+
}
|
|
1248
801
|
const body = await res.json().catch(() => ({ error: res.statusText }));
|
|
1249
802
|
throw new McpError(
|
|
1250
803
|
ErrorCode.InternalError,
|
|
@@ -1271,8 +824,8 @@ const TOOLS = [
|
|
|
1271
824
|
description:
|
|
1272
825
|
'Create a new managed Valkey/Redis cache instance on cachly.dev. ' +
|
|
1273
826
|
'Free tier provisions in ~30 seconds. Paid tiers return a Stripe checkout URL. ' +
|
|
1274
|
-
'Available tiers: free (
|
|
1275
|
-
'speed (
|
|
827
|
+
'Available tiers: free (30 MB), dev (256 MB, €8/mo), pro (1 GB, €25/mo), ' +
|
|
828
|
+
'speed (1 GB Dragonfly + Semantic Cache, €39/mo), business (8 GB, €99/mo).',
|
|
1276
829
|
inputSchema: {
|
|
1277
830
|
type: 'object',
|
|
1278
831
|
properties: {
|
|
@@ -1647,10 +1200,10 @@ const TOOLS = [
|
|
|
1647
1200
|
{
|
|
1648
1201
|
name: 'get_api_status',
|
|
1649
1202
|
description:
|
|
1650
|
-
'Check the cachly API health and your authentication status. ' +
|
|
1651
|
-
'Returns whether the
|
|
1652
|
-
'token expiry, and the auth provider
|
|
1653
|
-
'Use this to debug connection issues
|
|
1203
|
+
'Check the cachly API health, auth service reachability, and your authentication status. ' +
|
|
1204
|
+
'Returns API health, whether the Keycloak auth service is reachable, JWT validity, ' +
|
|
1205
|
+
'user ID (sub claim), token expiry, and the auth provider. ' +
|
|
1206
|
+
'Use this to debug connection issues, verify your CACHLY_JWT, or diagnose "Auth Service Unavailable" errors.',
|
|
1654
1207
|
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
1655
1208
|
},
|
|
1656
1209
|
// ── Thinking/Context Cache (for AI assistants) ────────────────────────────
|
|
@@ -1741,9 +1294,7 @@ const TOOLS = [
|
|
|
1741
1294
|
'Fields: topic (short slug like "deploy:web"), outcome ("success"|"failure"), ' +
|
|
1742
1295
|
'what_worked (what solved it), what_failed (what did NOT work), context (extra details). ' +
|
|
1743
1296
|
'Supports structured metadata: severity, file_paths (files involved), commands (working commands), tags. ' +
|
|
1744
|
-
'Deduplication: if a lesson for this topic already exists, it is updated
|
|
1745
|
-
'Contradiction detection: warns if new outcome conflicts with existing lesson outcome. ' +
|
|
1746
|
-
'Confidence: lesson starts at 1.0, decays after 5d (→0.7) and 10d (→0.5) without recall. ' +
|
|
1297
|
+
'Deduplication: if a lesson for this topic already exists, it is updated instead of duplicated. ' +
|
|
1747
1298
|
'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"])',
|
|
1748
1299
|
inputSchema: {
|
|
1749
1300
|
type: 'object',
|
|
@@ -1774,15 +1325,6 @@ const TOOLS = [
|
|
|
1774
1325
|
items: { type: 'string' },
|
|
1775
1326
|
description: 'Topic tags for filtering (e.g. ["bash", "deploy", "env"])',
|
|
1776
1327
|
},
|
|
1777
|
-
depends_on: {
|
|
1778
|
-
type: 'array',
|
|
1779
|
-
items: { type: 'string' },
|
|
1780
|
-
description: 'Prerequisites this lesson depends on (e.g. ["node:>=20", "docker:running", "wireguard:active"]). When a dependency is marked stale, all dependent lessons get needs_review.',
|
|
1781
|
-
},
|
|
1782
|
-
author: {
|
|
1783
|
-
type: 'string',
|
|
1784
|
-
description: 'Name or handle of the person storing this lesson (e.g. "alice", "bob"). Used for Team Telepathy — teammates see each other\'s lessons in session_start.',
|
|
1785
|
-
},
|
|
1786
1328
|
},
|
|
1787
1329
|
required: ['instance_id', 'topic', 'outcome', 'what_worked'],
|
|
1788
1330
|
},
|
|
@@ -1792,9 +1334,7 @@ const TOOLS = [
|
|
|
1792
1334
|
description:
|
|
1793
1335
|
'Recall the best known solution for a topic from past lessons. ' +
|
|
1794
1336
|
'Call this BEFORE attempting any task that might have been done before. ' +
|
|
1795
|
-
'Returns the most recent successful lesson for the topic,
|
|
1796
|
-
'⚠️ badge = lesson is >5d old (verify before applying). 🔴 = >10d old (likely stale!). ' +
|
|
1797
|
-
'Recalling a lesson resets its confidence clock to 1.0 (marks as recently verified). ' +
|
|
1337
|
+
'Returns the most recent successful lesson for the topic, or a summary of attempts. ' +
|
|
1798
1338
|
'Example: recall_best_solution(topic="deploy:web") → returns the working deploy command.',
|
|
1799
1339
|
inputSchema: {
|
|
1800
1340
|
type: 'object',
|
|
@@ -1805,6 +1345,35 @@ const TOOLS = [
|
|
|
1805
1345
|
required: ['instance_id', 'topic'],
|
|
1806
1346
|
},
|
|
1807
1347
|
},
|
|
1348
|
+
{
|
|
1349
|
+
name: 'list_lessons',
|
|
1350
|
+
description:
|
|
1351
|
+
'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. ' +
|
|
1352
|
+
'Returns topics sorted by most recently stored, with severity and recall count.',
|
|
1353
|
+
inputSchema: {
|
|
1354
|
+
type: 'object',
|
|
1355
|
+
properties: {
|
|
1356
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1357
|
+
filter: { type: 'string', description: 'Optional substring filter, e.g. "deploy" returns only deploy-related topics' },
|
|
1358
|
+
},
|
|
1359
|
+
required: ['instance_id'],
|
|
1360
|
+
},
|
|
1361
|
+
},
|
|
1362
|
+
{
|
|
1363
|
+
name: 'forget_lesson',
|
|
1364
|
+
description:
|
|
1365
|
+
'Delete a stored lesson from the Brain. Use this when a lesson is wrong, outdated, or was learned incorrectly. ' +
|
|
1366
|
+
'Removes both the best-solution entry and the attempt history for the topic. ' +
|
|
1367
|
+
'Example: forget_lesson(topic="deploy:web") → deletes the wrong deployment lesson so a correct one can be stored.',
|
|
1368
|
+
inputSchema: {
|
|
1369
|
+
type: 'object',
|
|
1370
|
+
properties: {
|
|
1371
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1372
|
+
topic: { type: 'string', description: 'Exact topic slug to delete, e.g. "deploy:web"' },
|
|
1373
|
+
},
|
|
1374
|
+
required: ['instance_id', 'topic'],
|
|
1375
|
+
},
|
|
1376
|
+
},
|
|
1808
1377
|
{
|
|
1809
1378
|
name: 'smart_recall',
|
|
1810
1379
|
description:
|
|
@@ -1827,8 +1396,7 @@ const TOOLS = [
|
|
|
1827
1396
|
description:
|
|
1828
1397
|
'Single-call session briefing. Call this at the START of every session INSTEAD of multiple separate smart_recall/recall_best_solution calls. ' +
|
|
1829
1398
|
'Returns: last session summary, recent lessons sorted by recency, relevant lessons for your focus area, ' +
|
|
1830
|
-
'open failures (topics with only failure outcomes), brain health stats
|
|
1831
|
-
'predictive pre-warnings (if your focus area has known failure patterns), and memory crystals (compressed wisdom from old sessions). ' +
|
|
1399
|
+
'open failures (topics with only failure outcomes), and brain health stats (lesson count, context count). ' +
|
|
1832
1400
|
'Also saves a session start marker so session_end can compute duration.',
|
|
1833
1401
|
inputSchema: {
|
|
1834
1402
|
type: 'object',
|
|
@@ -1838,10 +1406,6 @@ const TOOLS = [
|
|
|
1838
1406
|
type: 'string',
|
|
1839
1407
|
description: 'Keywords for what you plan to work on today (e.g. "deploy infra api"). Used to surface relevant lessons at the top.',
|
|
1840
1408
|
},
|
|
1841
|
-
author: {
|
|
1842
|
-
type: 'string',
|
|
1843
|
-
description: 'Your name or handle (e.g. "alice"). Enables Team Telepathy — filters YOUR lessons vs TEAM lessons from past 7 days.',
|
|
1844
|
-
},
|
|
1845
1409
|
},
|
|
1846
1410
|
required: ['instance_id'],
|
|
1847
1411
|
},
|
|
@@ -1852,8 +1416,7 @@ const TOOLS = [
|
|
|
1852
1416
|
'Save a session summary when you finish working. ' +
|
|
1853
1417
|
'Records what was accomplished, files changed, and lesson count. ' +
|
|
1854
1418
|
'The next session_start will show this summary as "Last session". ' +
|
|
1855
|
-
'Call this when ending a work session, before going idle, or before summarizing.
|
|
1856
|
-
'Ambient Learning: if workspace_path is provided, reads git log since session start and auto-learns from commits.',
|
|
1419
|
+
'Call this when ending a work session, before going idle, or before summarizing.',
|
|
1857
1420
|
inputSchema: {
|
|
1858
1421
|
type: 'object',
|
|
1859
1422
|
properties: {
|
|
@@ -1871,67 +1434,10 @@ const TOOLS = [
|
|
|
1871
1434
|
type: 'number',
|
|
1872
1435
|
description: 'Number of new lessons stored this session (optional)',
|
|
1873
1436
|
},
|
|
1874
|
-
workspace_path: {
|
|
1875
|
-
type: 'string',
|
|
1876
|
-
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.',
|
|
1877
|
-
},
|
|
1878
1437
|
},
|
|
1879
1438
|
required: ['instance_id', 'summary'],
|
|
1880
1439
|
},
|
|
1881
1440
|
},
|
|
1882
|
-
// ── Session Handoff — cross-window continuity ─────────────────────────────
|
|
1883
|
-
{
|
|
1884
|
-
name: 'session_handoff',
|
|
1885
|
-
description:
|
|
1886
|
-
'Save a detailed handoff for the NEXT chat window / session. ' +
|
|
1887
|
-
'Stores: current progress, TODO list (done + remaining), changed files with descriptions, ' +
|
|
1888
|
-
'instructions for the next assistant, and any incomplete work. ' +
|
|
1889
|
-
'The next session_start automatically includes this handoff so the new window knows EXACTLY what happened and what remains. ' +
|
|
1890
|
-
'Call this BEFORE closing a chat window, especially if work is incomplete. ' +
|
|
1891
|
-
'This prevents the "continue" problem where new windows lose context, skip tasks, or produce broken code.',
|
|
1892
|
-
inputSchema: {
|
|
1893
|
-
type: 'object',
|
|
1894
|
-
properties: {
|
|
1895
|
-
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1896
|
-
completed_tasks: {
|
|
1897
|
-
type: 'array',
|
|
1898
|
-
items: { type: 'string' },
|
|
1899
|
-
description: 'Tasks that were fully completed (e.g. "Implemented brainSearch() in JS SDK")',
|
|
1900
|
-
},
|
|
1901
|
-
remaining_tasks: {
|
|
1902
|
-
type: 'array',
|
|
1903
|
-
items: { type: 'string' },
|
|
1904
|
-
description: 'Tasks NOT yet done — the next window MUST pick these up',
|
|
1905
|
-
},
|
|
1906
|
-
files_changed: {
|
|
1907
|
-
type: 'array',
|
|
1908
|
-
items: {
|
|
1909
|
-
type: 'object',
|
|
1910
|
-
properties: {
|
|
1911
|
-
path: { type: 'string', description: 'File path relative to project root' },
|
|
1912
|
-
status: { type: 'string', enum: ['complete', 'partial', 'broken'], description: 'State of this file' },
|
|
1913
|
-
description: { type: 'string', description: 'What was changed and what still needs work' },
|
|
1914
|
-
},
|
|
1915
|
-
required: ['path', 'status'],
|
|
1916
|
-
},
|
|
1917
|
-
description: 'Changed files with their current state — marks partial/broken files so next window knows to fix them',
|
|
1918
|
-
},
|
|
1919
|
-
instructions: {
|
|
1920
|
-
type: 'string',
|
|
1921
|
-
description: 'Free-form instructions for the next assistant. Be specific: what to do next, what to avoid, what broke.',
|
|
1922
|
-
},
|
|
1923
|
-
context_summary: {
|
|
1924
|
-
type: 'string',
|
|
1925
|
-
description: 'Brief summary of what happened this session (architecture decisions, key findings, blockers)',
|
|
1926
|
-
},
|
|
1927
|
-
blocked_on: {
|
|
1928
|
-
type: 'string',
|
|
1929
|
-
description: 'If work is blocked, describe what is needed to unblock (e.g. "waiting for API deploy", "needs user input on design")',
|
|
1930
|
-
},
|
|
1931
|
-
},
|
|
1932
|
-
required: ['instance_id', 'completed_tasks', 'remaining_tasks'],
|
|
1933
|
-
},
|
|
1934
|
-
},
|
|
1935
1441
|
// ── AI Brain — Extended features ─────────────────────────────────────────
|
|
1936
1442
|
{
|
|
1937
1443
|
name: 'auto_learn_session',
|
|
@@ -2022,154 +1528,16 @@ const TOOLS = [
|
|
|
2022
1528
|
required: ['instance_id'],
|
|
2023
1529
|
},
|
|
2024
1530
|
},
|
|
2025
|
-
{
|
|
2026
|
-
name: 'team_synthesize',
|
|
2027
|
-
description:
|
|
2028
|
-
'Team Brain Synthesis — merge multiple contributors\' lessons on the same topic into one canonical version. ' +
|
|
2029
|
-
'When 2+ developers store lessons for the same topic with different details, this proposes the best merged version. ' +
|
|
2030
|
-
'Shows: all contributions by author, what worked (consensus), what failed (union), canonical lesson to store. ' +
|
|
2031
|
-
'Use this when onboarding new team members or before documenting a process.',
|
|
2032
|
-
inputSchema: {
|
|
2033
|
-
type: 'object',
|
|
2034
|
-
properties: {
|
|
2035
|
-
instance_id: { type: 'string', description: 'UUID of the shared team brain instance' },
|
|
2036
|
-
topic: { type: 'string', description: 'Topic slug to synthesize (e.g. "deploy:api")' },
|
|
2037
|
-
},
|
|
2038
|
-
required: ['instance_id', 'topic'],
|
|
2039
|
-
},
|
|
2040
|
-
},
|
|
2041
|
-
{
|
|
2042
|
-
name: 'memory_crystalize',
|
|
2043
|
-
description:
|
|
2044
|
-
'Compress the last 30-50 sessions and auto-learned lessons into a dense Memory Crystal. ' +
|
|
2045
|
-
'A crystal is a compact, structured summary of everything the brain learned — grouped by category (deploy, fix, debug, …). ' +
|
|
2046
|
-
'Crystals survive session cleanup and appear in session_start once enough sessions have accumulated. ' +
|
|
2047
|
-
'Run this monthly or after a big milestone to preserve institutional knowledge. ' +
|
|
2048
|
-
'Returns a digest of what was crystallized.',
|
|
2049
|
-
inputSchema: {
|
|
2050
|
-
type: 'object',
|
|
2051
|
-
properties: {
|
|
2052
|
-
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
2053
|
-
label: {
|
|
2054
|
-
type: 'string',
|
|
2055
|
-
description: 'Optional label for this crystal (e.g. "Q1 2026", "v2 launch"). Auto-generated from date if omitted.',
|
|
2056
|
-
},
|
|
2057
|
-
},
|
|
2058
|
-
required: ['instance_id'],
|
|
2059
|
-
},
|
|
2060
|
-
},
|
|
2061
|
-
// ── Roadmap — Persistent project plan tracker ───────────────────────────
|
|
2062
|
-
{
|
|
2063
|
-
name: 'roadmap_add',
|
|
2064
|
-
description:
|
|
2065
|
-
'Add a new item to the persistent project roadmap stored in the Brain. ' +
|
|
2066
|
-
'Items survive across sessions and editors — the roadmap is always up to date. ' +
|
|
2067
|
-
'Use for features, bugs, refactors, or any planned work. ' +
|
|
2068
|
-
'Call roadmap_list to see all open items, roadmap_next to get the next actionable item.',
|
|
2069
|
-
inputSchema: {
|
|
2070
|
-
type: 'object',
|
|
2071
|
-
properties: {
|
|
2072
|
-
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
2073
|
-
title: { type: 'string', description: 'Short title of the task/feature (3–10 words)' },
|
|
2074
|
-
description: { type: 'string', description: 'What needs to be done, acceptance criteria, context' },
|
|
2075
|
-
priority: {
|
|
2076
|
-
type: 'string',
|
|
2077
|
-
enum: ['critical', 'high', 'medium', 'low'],
|
|
2078
|
-
description: 'Priority level (default: medium)',
|
|
2079
|
-
},
|
|
2080
|
-
tags: {
|
|
2081
|
-
type: 'array',
|
|
2082
|
-
items: { type: 'string' },
|
|
2083
|
-
description: 'Tags for filtering (e.g. ["api", "web", "sdk", "infra"])',
|
|
2084
|
-
},
|
|
2085
|
-
milestone: { type: 'string', description: 'Milestone/epic this belongs to (optional)' },
|
|
2086
|
-
},
|
|
2087
|
-
required: ['instance_id', 'title'],
|
|
2088
|
-
},
|
|
2089
|
-
},
|
|
2090
|
-
{
|
|
2091
|
-
name: 'roadmap_update',
|
|
2092
|
-
description:
|
|
2093
|
-
'Update the status, priority, or details of a roadmap item. ' +
|
|
2094
|
-
'Use to move items through the lifecycle: planned → in-progress → done (or blocked/cancelled). ' +
|
|
2095
|
-
'Also use to add notes/findings while working on an item.',
|
|
2096
|
-
inputSchema: {
|
|
2097
|
-
type: 'object',
|
|
2098
|
-
properties: {
|
|
2099
|
-
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
2100
|
-
id: { type: 'string', description: 'Item ID returned by roadmap_add or roadmap_list' },
|
|
2101
|
-
status: {
|
|
2102
|
-
type: 'string',
|
|
2103
|
-
enum: ['planned', 'in-progress', 'done', 'blocked', 'cancelled'],
|
|
2104
|
-
description: 'New status for the item',
|
|
2105
|
-
},
|
|
2106
|
-
priority: {
|
|
2107
|
-
type: 'string',
|
|
2108
|
-
enum: ['critical', 'high', 'medium', 'low'],
|
|
2109
|
-
description: 'Updated priority (optional)',
|
|
2110
|
-
},
|
|
2111
|
-
notes: { type: 'string', description: 'Progress notes, findings, or blockers (appended to existing notes)' },
|
|
2112
|
-
title: { type: 'string', description: 'Updated title (optional)' },
|
|
2113
|
-
description: { type: 'string', description: 'Updated description (optional)' },
|
|
2114
|
-
},
|
|
2115
|
-
required: ['instance_id', 'id'],
|
|
2116
|
-
},
|
|
2117
|
-
},
|
|
2118
|
-
{
|
|
2119
|
-
name: 'roadmap_list',
|
|
2120
|
-
description:
|
|
2121
|
-
'List all roadmap items, optionally filtered by status, priority, tag, or milestone. ' +
|
|
2122
|
-
'Returns items sorted by priority then creation date. ' +
|
|
2123
|
-
'Called automatically by session_start to show open work.',
|
|
2124
|
-
inputSchema: {
|
|
2125
|
-
type: 'object',
|
|
2126
|
-
properties: {
|
|
2127
|
-
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
2128
|
-
status: {
|
|
2129
|
-
type: 'string',
|
|
2130
|
-
enum: ['planned', 'in-progress', 'done', 'blocked', 'cancelled', 'open'],
|
|
2131
|
-
description: 'Filter by status. Use \'open\' to see planned+in-progress+blocked (default: open)',
|
|
2132
|
-
},
|
|
2133
|
-
tag: { type: 'string', description: 'Filter by tag (optional)' },
|
|
2134
|
-
milestone: { type: 'string', description: 'Filter by milestone (optional)' },
|
|
2135
|
-
priority: {
|
|
2136
|
-
type: 'string',
|
|
2137
|
-
enum: ['critical', 'high', 'medium', 'low'],
|
|
2138
|
-
description: 'Filter by minimum priority (optional)',
|
|
2139
|
-
},
|
|
2140
|
-
},
|
|
2141
|
-
required: ['instance_id'],
|
|
2142
|
-
},
|
|
2143
|
-
},
|
|
2144
|
-
{
|
|
2145
|
-
name: 'roadmap_next',
|
|
2146
|
-
description:
|
|
2147
|
-
'Get the single most important next actionable roadmap item. ' +
|
|
2148
|
-
'Returns the highest-priority in-progress item first, then planned items, sorted by priority. ' +
|
|
2149
|
-
'Call at session start to immediately know what to work on next.',
|
|
2150
|
-
inputSchema: {
|
|
2151
|
-
type: 'object',
|
|
2152
|
-
properties: {
|
|
2153
|
-
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
2154
|
-
tag: { type: 'string', description: 'Filter by tag (optional)' },
|
|
2155
|
-
},
|
|
2156
|
-
required: ['instance_id'],
|
|
2157
|
-
},
|
|
2158
|
-
},
|
|
2159
1531
|
{
|
|
2160
1532
|
name: 'brain_doctor',
|
|
2161
1533
|
description:
|
|
2162
1534
|
'Check the health of your AI Brain and get actionable recommendations. ' +
|
|
2163
|
-
'Reports: lesson count, context entries, last session age, open failures, quality score
|
|
1535
|
+
'Reports: lesson count, context entries, last session age, open failures, quality score. ' +
|
|
2164
1536
|
'Returns a prioritized list of issues with fix instructions.',
|
|
2165
1537
|
inputSchema: {
|
|
2166
1538
|
type: 'object',
|
|
2167
1539
|
properties: {
|
|
2168
1540
|
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
2169
|
-
workspace_path: {
|
|
2170
|
-
type: 'string',
|
|
2171
|
-
description: 'Absolute path to workspace root — enables package.json analysis for openclaw cross-promo (optional)',
|
|
2172
|
-
},
|
|
2173
1541
|
},
|
|
2174
1542
|
required: ['instance_id'],
|
|
2175
1543
|
},
|
|
@@ -2241,100 +1609,6 @@ const TOOLS = [
|
|
|
2241
1609
|
required: ['instance_id', 'framework'],
|
|
2242
1610
|
},
|
|
2243
1611
|
},
|
|
2244
|
-
// ── Brain Archaeology + Causal Chain ────────────────────────────────────
|
|
2245
|
-
{
|
|
2246
|
-
name: 'recall_at',
|
|
2247
|
-
description:
|
|
2248
|
-
'Brain Archaeology — see what a lesson looked like at a specific point in time. ' +
|
|
2249
|
-
'"What did we know about deployments 3 months ago?" ' +
|
|
2250
|
-
'Returns the history of a topic filtered to entries before the given date. ' +
|
|
2251
|
-
'Shows how the lesson evolved: failure → partial → success. ' +
|
|
2252
|
-
'Also useful to understand WHY old code decisions were made.',
|
|
2253
|
-
inputSchema: {
|
|
2254
|
-
type: 'object',
|
|
2255
|
-
properties: {
|
|
2256
|
-
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
2257
|
-
topic: { type: 'string', description: 'Topic slug to look up, e.g. "deploy:api"' },
|
|
2258
|
-
date: { type: 'string', description: 'ISO date string (e.g. "2026-01-15") — returns entries stored BEFORE this date' },
|
|
2259
|
-
},
|
|
2260
|
-
required: ['instance_id', 'topic', 'date'],
|
|
2261
|
-
},
|
|
2262
|
-
},
|
|
2263
|
-
{
|
|
2264
|
-
name: 'trace_dependency',
|
|
2265
|
-
description:
|
|
2266
|
-
'Causal Chain — find all lessons that depend on a given prerequisite. ' +
|
|
2267
|
-
'"What lessons are affected if node version changes?" ' +
|
|
2268
|
-
'When a dependency changes (new version, different provider, new OS), call this to see which lessons need review. ' +
|
|
2269
|
-
'Lessons store dependencies via the depends_on field in learn_from_attempts.',
|
|
2270
|
-
inputSchema: {
|
|
2271
|
-
type: 'object',
|
|
2272
|
-
properties: {
|
|
2273
|
-
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
2274
|
-
dependency: { type: 'string', description: 'Dependency to trace (e.g. "node:>=20", "docker:running", "wireguard:active")' },
|
|
2275
|
-
mark_review: { type: 'boolean', description: 'If true, marks all dependent lessons as needs_review (default: false)' },
|
|
2276
|
-
},
|
|
2277
|
-
required: ['instance_id', 'dependency'],
|
|
2278
|
-
},
|
|
2279
|
-
},
|
|
2280
|
-
// ── Team / Org Management ────────────────────────────────────────────────
|
|
2281
|
-
{
|
|
2282
|
-
name: 'list_orgs',
|
|
2283
|
-
description:
|
|
2284
|
-
'List your Cachly organizations (team/org plans). ' +
|
|
2285
|
-
'Returns each org with plan, seat count, and member info. ' +
|
|
2286
|
-
'Org plans (Team €99, Business €299, Enterprise custom) are billed separately from cache tiers.',
|
|
2287
|
-
inputSchema: {
|
|
2288
|
-
type: 'object',
|
|
2289
|
-
properties: {},
|
|
2290
|
-
required: [],
|
|
2291
|
-
},
|
|
2292
|
-
},
|
|
2293
|
-
{
|
|
2294
|
-
name: 'create_org',
|
|
2295
|
-
description:
|
|
2296
|
-
'Create a new Cachly organization for team collaboration. ' +
|
|
2297
|
-
'After creation, invite team members with invite_member and upgrade the plan via the billing portal. ' +
|
|
2298
|
-
'Org plans: Team (€99/mo, 10 seats), Business (€299/mo, 50 seats), Enterprise (custom).',
|
|
2299
|
-
inputSchema: {
|
|
2300
|
-
type: 'object',
|
|
2301
|
-
properties: {
|
|
2302
|
-
name: { type: 'string', description: 'Organization display name (e.g. "Acme Engineering")' },
|
|
2303
|
-
slug: { type: 'string', description: 'URL-safe slug (e.g. "acme-eng"). Auto-generated from name if omitted.' },
|
|
2304
|
-
},
|
|
2305
|
-
required: ['name'],
|
|
2306
|
-
},
|
|
2307
|
-
},
|
|
2308
|
-
{
|
|
2309
|
-
name: 'invite_member',
|
|
2310
|
-
description:
|
|
2311
|
-
'Invite a team member to a Cachly organization by email. ' +
|
|
2312
|
-
'They will receive an invite email and can join via the dashboard. ' +
|
|
2313
|
-
'Roles: owner (full access), admin (manage members + instances), member (read + cache ops).',
|
|
2314
|
-
inputSchema: {
|
|
2315
|
-
type: 'object',
|
|
2316
|
-
properties: {
|
|
2317
|
-
org_id: { type: 'string', description: 'UUID of the organization' },
|
|
2318
|
-
email: { type: 'string', description: 'Email address to invite' },
|
|
2319
|
-
role: { type: 'string', enum: ['admin', 'member'], description: 'Role for the invited member (default: member)' },
|
|
2320
|
-
},
|
|
2321
|
-
required: ['org_id', 'email'],
|
|
2322
|
-
},
|
|
2323
|
-
},
|
|
2324
|
-
{
|
|
2325
|
-
name: 'get_org_plan',
|
|
2326
|
-
description:
|
|
2327
|
-
'Get the current org plan, seat usage, and billing info for an organization. ' +
|
|
2328
|
-
'Shows: plan name, price, seats used/max, next billing date. ' +
|
|
2329
|
-
'To upgrade: use the billing portal URL returned by this tool.',
|
|
2330
|
-
inputSchema: {
|
|
2331
|
-
type: 'object',
|
|
2332
|
-
properties: {
|
|
2333
|
-
org_id: { type: 'string', description: 'UUID of the organization' },
|
|
2334
|
-
},
|
|
2335
|
-
required: ['org_id'],
|
|
2336
|
-
},
|
|
2337
|
-
},
|
|
2338
1612
|
// ── Legacy / Setup ────────────────────────────────────────────────────────
|
|
2339
1613
|
{
|
|
2340
1614
|
name: 'setup_ai_memory',
|
|
@@ -2432,50 +1706,10 @@ function buildConnectionString(inst: Instance): string {
|
|
|
2432
1706
|
return `${scheme}://${pw}${inst.host}:${inst.port}`;
|
|
2433
1707
|
}
|
|
2434
1708
|
|
|
2435
|
-
// Fires once per process when no JWT is set (anonymous, opt-out via CACHLY_NO_TELEMETRY=1)
|
|
2436
|
-
let _telemetryPingSent = false;
|
|
2437
|
-
async function sendAnonymousTelemetry(toolName: string): Promise<void> {
|
|
2438
|
-
if (_telemetryPingSent) return;
|
|
2439
|
-
if (process.env.CACHLY_NO_TELEMETRY === '1') return;
|
|
2440
|
-
_telemetryPingSent = true;
|
|
2441
|
-
// Detect editor from common env vars injected by IDE extensions
|
|
2442
|
-
const editor = process.env.CURSOR_TRACE_ID ? 'cursor'
|
|
2443
|
-
: process.env.WINDSURF_SESSION_ID ? 'windsurf'
|
|
2444
|
-
: process.env.GITHUB_COPILOT_WORKSPACE ? 'copilot'
|
|
2445
|
-
: process.env.CLAUDE_CODE_ENTRYPOINT ? 'claude'
|
|
2446
|
-
: 'unknown';
|
|
2447
|
-
try {
|
|
2448
|
-
await fetch(`${API_URL}/api/v1/telemetry/mcp`, {
|
|
2449
|
-
method: 'POST',
|
|
2450
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2451
|
-
body: JSON.stringify({ event: 'first_call_no_jwt', version: CURRENT_VERSION, editor, tool: toolName }),
|
|
2452
|
-
signal: AbortSignal.timeout(3000),
|
|
2453
|
-
});
|
|
2454
|
-
} catch { /* fire-and-forget, never block the user */ }
|
|
2455
|
-
}
|
|
2456
|
-
|
|
2457
1709
|
async function handleTool(name: string, args: Record<string, unknown>): Promise<string> {
|
|
2458
|
-
//
|
|
2459
|
-
if (!
|
|
2460
|
-
|
|
2461
|
-
return [
|
|
2462
|
-
'🧠 **cachly AI Brain — Setup required**',
|
|
2463
|
-
'',
|
|
2464
|
-
'You have no API credentials configured. Get started in 30 seconds:',
|
|
2465
|
-
'',
|
|
2466
|
-
'**Step 1:** Register for free (no credit card):',
|
|
2467
|
-
' 👉 https://cachly.dev/setup-ai',
|
|
2468
|
-
'',
|
|
2469
|
-
'**Step 2:** Run the setup wizard in your terminal:',
|
|
2470
|
-
' ```',
|
|
2471
|
-
' npx @cachly-dev/mcp-server@latest setup',
|
|
2472
|
-
' ```',
|
|
2473
|
-
'',
|
|
2474
|
-
'The wizard auto-detects your editor (Claude Code, Cursor, Copilot, Windsurf)',
|
|
2475
|
-
'and writes the config file with your credentials.',
|
|
2476
|
-
'',
|
|
2477
|
-
'✨ Free tier includes: 1 Brain instance, persistent memory, semantic search.',
|
|
2478
|
-
].join('\n');
|
|
1710
|
+
// Default instance_id from env so tools work even if AI omits it
|
|
1711
|
+
if (!args.instance_id && process.env.CACHLY_BRAIN_INSTANCE_ID) {
|
|
1712
|
+
args = { ...args, instance_id: process.env.CACHLY_BRAIN_INSTANCE_ID };
|
|
2479
1713
|
}
|
|
2480
1714
|
|
|
2481
1715
|
// Delegate v0.2 bulk/lock/stream tools first
|
|
@@ -2496,7 +1730,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
2496
1730
|
const { name: instName, tier } = args as { name: string; tier: string };
|
|
2497
1731
|
const res = await apiFetch<CreateResponse>('/api/v1/instances', {
|
|
2498
1732
|
method: 'POST',
|
|
2499
|
-
body: JSON.stringify({ name: instName, tier
|
|
1733
|
+
body: JSON.stringify({ name: instName, tier }),
|
|
2500
1734
|
});
|
|
2501
1735
|
if (res.checkout_url) {
|
|
2502
1736
|
return [
|
|
@@ -2556,82 +1790,19 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
2556
1790
|
return `✅ Instance \`${instance_id}\` has been deleted and all data removed.`;
|
|
2557
1791
|
}
|
|
2558
1792
|
|
|
2559
|
-
// ──
|
|
2560
|
-
case '
|
|
2561
|
-
const
|
|
2562
|
-
const
|
|
2563
|
-
|
|
2564
|
-
return
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
const { name: orgName, slug } = args as { name: string; slug?: string };
|
|
2573
|
-
const body: Record<string, string> = { name: orgName };
|
|
2574
|
-
if (slug) body.slug = slug;
|
|
2575
|
-
const res = await apiFetch<{ id: string; name: string; slug: string; plan: string }>('/api/v1/orgs', {
|
|
2576
|
-
method: 'POST',
|
|
2577
|
-
body: JSON.stringify(body),
|
|
2578
|
-
});
|
|
2579
|
-
return [
|
|
2580
|
-
`✅ **Organization created:** ${res.name}`,
|
|
2581
|
-
` ID: \`${res.id}\` · Slug: \`${res.slug}\` · Plan: ${res.plan}`,
|
|
2582
|
-
``,
|
|
2583
|
-
`**Next steps:**`,
|
|
2584
|
-
`1. Invite team members: \`invite_member(org_id="${res.id}", email="dev@example.com")\``,
|
|
2585
|
-
`2. Upgrade plan: open billing portal via dashboard → /team`,
|
|
2586
|
-
` Team: €99/mo (10 seats) · Business: €299/mo (50 seats)`,
|
|
2587
|
-
].join('\n');
|
|
2588
|
-
}
|
|
2589
|
-
|
|
2590
|
-
case 'invite_member': {
|
|
2591
|
-
const { org_id, email, role = 'member' } = args as { org_id: string; email: string; role?: string };
|
|
2592
|
-
await apiFetch(`/api/v1/orgs/${org_id}/members`, {
|
|
2593
|
-
method: 'POST',
|
|
2594
|
-
body: JSON.stringify({ email, role }),
|
|
2595
|
-
});
|
|
2596
|
-
return `✅ Invite sent to **${email}** as \`${role}\` in org \`${org_id}\`.\n\nThey will receive an email to join the organization.`;
|
|
2597
|
-
}
|
|
2598
|
-
|
|
2599
|
-
case 'get_org_plan': {
|
|
2600
|
-
const { org_id } = args as { org_id: string };
|
|
2601
|
-
const org = await apiFetch<{
|
|
2602
|
-
id: string; name: string; plan: string; max_members: number;
|
|
2603
|
-
members: Array<{ role: string; invite_email: string; accepted_at?: string }>;
|
|
2604
|
-
stripe_customer_id?: string;
|
|
2605
|
-
}>(`/api/v1/orgs/${org_id}`);
|
|
2606
|
-
const accepted = (org.members ?? []).filter(m => m.accepted_at).length;
|
|
2607
|
-
const pending = (org.members ?? []).filter(m => !m.accepted_at).length;
|
|
2608
|
-
const planPrice: Record<string, string> = { free: '€0', team: '€99/mo', business: '€299/mo', enterprise: 'custom' };
|
|
2609
|
-
return [
|
|
2610
|
-
`🏢 **${org.name}** — Plan: **${org.plan}** (${planPrice[org.plan] ?? org.plan})`,
|
|
2611
|
-
` Seats: ${accepted} active + ${pending} pending / ${org.max_members} max`,
|
|
2612
|
-
``,
|
|
2613
|
-
`**Members:**`,
|
|
2614
|
-
...(org.members ?? []).map(m => ` • ${m.invite_email} (${m.role})${m.accepted_at ? '' : ' — pending'}`),
|
|
2615
|
-
``,
|
|
2616
|
-
org.stripe_customer_id
|
|
2617
|
-
? `💳 Billing: managed via Stripe. Upgrade/cancel: dashboard → /billing`
|
|
2618
|
-
: `💳 No payment method yet. Upgrade: dashboard → /billing → Team Plans`,
|
|
2619
|
-
].join('\n');
|
|
2620
|
-
}
|
|
2621
|
-
|
|
2622
|
-
// ── Live cache operations ────────────────────────────────────────────
|
|
2623
|
-
case 'cache_get': {
|
|
2624
|
-
const { instance_id, key } = args as { instance_id: string; key: string };
|
|
2625
|
-
const redis = await getConnection(instance_id);
|
|
2626
|
-
const value = await redis.get(key);
|
|
2627
|
-
if (value === null) return `Key \`${key}\` → **not found** (null)`;
|
|
2628
|
-
let pretty = value;
|
|
2629
|
-
try {
|
|
2630
|
-
pretty = JSON.stringify(JSON.parse(value), null, 2);
|
|
2631
|
-
} catch {
|
|
2632
|
-
// not JSON — return raw
|
|
2633
|
-
}
|
|
2634
|
-
return `Key \`${key}\`:\n\`\`\`\n${pretty}\n\`\`\``;
|
|
1793
|
+
// ── Live cache operations ────────────────────────────────────────────
|
|
1794
|
+
case 'cache_get': {
|
|
1795
|
+
const { instance_id, key } = args as { instance_id: string; key: string };
|
|
1796
|
+
const redis = await getConnection(instance_id);
|
|
1797
|
+
const value = await redis.get(key);
|
|
1798
|
+
if (value === null) return `Key \`${key}\` → **not found** (null)`;
|
|
1799
|
+
let pretty = value;
|
|
1800
|
+
try {
|
|
1801
|
+
pretty = JSON.stringify(JSON.parse(value), null, 2);
|
|
1802
|
+
} catch {
|
|
1803
|
+
// not JSON — return raw
|
|
1804
|
+
}
|
|
1805
|
+
return `Key \`${key}\`:\n\`\`\`\n${pretty}\n\`\`\``;
|
|
2635
1806
|
}
|
|
2636
1807
|
|
|
2637
1808
|
case 'cache_set': {
|
|
@@ -3045,7 +2216,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3045
2216
|
let skipped = 0;
|
|
3046
2217
|
let errors = 0;
|
|
3047
2218
|
let semanticIndexed = 0;
|
|
3048
|
-
let unchanged = 0;
|
|
3049
2219
|
const details: string[] = [];
|
|
3050
2220
|
const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
|
|
3051
2221
|
const redis = await getConnection(instance_id);
|
|
@@ -3053,31 +2223,15 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3053
2223
|
for (const filePath of files) {
|
|
3054
2224
|
const relPath = relative(dir, filePath);
|
|
3055
2225
|
let content: string;
|
|
3056
|
-
let fileSize: number;
|
|
3057
2226
|
try {
|
|
3058
2227
|
const s = await stat(filePath);
|
|
3059
2228
|
if (s.size > 200_000) { skipped++; continue; } // skip files >200 KB
|
|
3060
|
-
fileSize = s.size;
|
|
3061
2229
|
content = await readFile(filePath, 'utf-8');
|
|
3062
2230
|
} catch {
|
|
3063
2231
|
errors++;
|
|
3064
2232
|
continue;
|
|
3065
2233
|
}
|
|
3066
2234
|
|
|
3067
|
-
// ── Smart invalidation: hash-based change detection ──
|
|
3068
|
-
// Compute a simple hash of file content to skip unchanged files
|
|
3069
|
-
const hashKey = `cachly:idx:hash:${relPath}`;
|
|
3070
|
-
const contentHash = `${fileSize}:${content.length}:${simpleHash(content)}`;
|
|
3071
|
-
const existingHash = await redis.get(hashKey);
|
|
3072
|
-
if (existingHash === contentHash) {
|
|
3073
|
-
// File unchanged — refresh TTL but skip re-indexing
|
|
3074
|
-
const idxKey = `cachly:idx:${relPath}`;
|
|
3075
|
-
if (ttl > 0) await redis.expire(idxKey, ttl);
|
|
3076
|
-
if (ttl > 0) await redis.expire(hashKey, ttl);
|
|
3077
|
-
unchanged++;
|
|
3078
|
-
continue;
|
|
3079
|
-
}
|
|
3080
|
-
|
|
3081
2235
|
const excerpt = content.slice(0, summary_chars).replace(/\s+/g, ' ').trim();
|
|
3082
2236
|
|
|
3083
2237
|
// ── Layer 1: Keyword index in Valkey (always works, no embedding needed) ──
|
|
@@ -3088,12 +2242,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3088
2242
|
} else {
|
|
3089
2243
|
await redis.set(idxKey, idxValue);
|
|
3090
2244
|
}
|
|
3091
|
-
// Store content hash for smart invalidation on next run
|
|
3092
|
-
if (ttl > 0) {
|
|
3093
|
-
await redis.set(hashKey, contentHash, 'EX', ttl);
|
|
3094
|
-
} else {
|
|
3095
|
-
await redis.set(hashKey, contentHash);
|
|
3096
|
-
}
|
|
3097
2245
|
indexed++;
|
|
3098
2246
|
details.push(` ✅ ${relPath}`);
|
|
3099
2247
|
|
|
@@ -3122,8 +2270,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3122
2270
|
`📂 **index_project Complete** — ${mode}`,
|
|
3123
2271
|
``,
|
|
3124
2272
|
` 📁 Dir: ${dir}`,
|
|
3125
|
-
` ✅ Indexed: **${indexed}** files (
|
|
3126
|
-
` ♻️ Unchanged: ${unchanged} files (hash match — skipped)`,
|
|
2273
|
+
` ✅ Indexed: **${indexed}** files (keyword-searchable via smart_recall)`,
|
|
3127
2274
|
...(canEmbed ? [` 🎯 Semantic: **${semanticIndexed}** files (vector-searchable)`] : []),
|
|
3128
2275
|
` ⏭️ Skipped: ${skipped} (too large or filtered)`,
|
|
3129
2276
|
` ❌ Errors: ${errors}`,
|
|
@@ -3346,8 +2493,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3346
2493
|
file_paths = [],
|
|
3347
2494
|
commands = [],
|
|
3348
2495
|
tags = [],
|
|
3349
|
-
depends_on = [],
|
|
3350
|
-
author = '',
|
|
3351
2496
|
} = args as {
|
|
3352
2497
|
instance_id: string;
|
|
3353
2498
|
topic: string;
|
|
@@ -3359,107 +2504,26 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3359
2504
|
file_paths?: string[];
|
|
3360
2505
|
commands?: string[];
|
|
3361
2506
|
tags?: string[];
|
|
3362
|
-
depends_on?: string[];
|
|
3363
|
-
author?: string;
|
|
3364
2507
|
};
|
|
3365
2508
|
|
|
3366
2509
|
const redis = await getConnection(instance_id);
|
|
3367
2510
|
const ts = new Date().toISOString();
|
|
3368
2511
|
|
|
3369
|
-
// ──
|
|
3370
|
-
const category = topic.split(':')[0];
|
|
3371
|
-
const template = STRUCTURED_TEMPLATES[category];
|
|
3372
|
-
const templateWarnings: string[] = [];
|
|
3373
|
-
if (template) {
|
|
3374
|
-
for (const req of template.required) {
|
|
3375
|
-
if (req === 'commands' && commands.length === 0) {
|
|
3376
|
-
templateWarnings.push(`📋 ${template.hint}`);
|
|
3377
|
-
}
|
|
3378
|
-
}
|
|
3379
|
-
}
|
|
3380
|
-
|
|
3381
|
-
// ── Deduplication + audit trail ────────────────────────────────────────
|
|
2512
|
+
// ── Deduplication: check for existing lesson with same topic ─────────────
|
|
3382
2513
|
let isUpdate = false;
|
|
3383
2514
|
let recallCount = 0;
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
try {
|
|
3388
|
-
const prev = JSON.parse(existingRaw) as {
|
|
3389
|
-
recall_count?: number;
|
|
3390
|
-
outcome?: string;
|
|
3391
|
-
audit_trail?: Array<{ ts: string; action: string; prev_outcome?: string }>;
|
|
3392
|
-
};
|
|
3393
|
-
recallCount = prev.recall_count ?? 0;
|
|
3394
|
-
auditTrail = prev.audit_trail ?? [];
|
|
3395
|
-
auditTrail.push({ ts, action: 'updated', prev_outcome: prev.outcome });
|
|
3396
|
-
if (auditTrail.length > 20) auditTrail = auditTrail.slice(-20);
|
|
3397
|
-
isUpdate = true;
|
|
3398
|
-
|
|
3399
|
-
// ── Contradiction detection ─────────────────────────────────────────
|
|
3400
|
-
const contradictionWarning: string[] = [];
|
|
3401
|
-
if (prev.outcome === 'success' && outcome === 'failure') {
|
|
3402
|
-
contradictionWarning.push(
|
|
3403
|
-
`⚠️ **Contradiction detected!** Existing lesson has outcome: \`success\`, but you're storing \`failure\`.`,
|
|
3404
|
-
`The existing "success" lesson will be preserved. Only the audit trail is updated.`,
|
|
3405
|
-
`If you meant to mark this as failed permanently, store a new lesson with a distinct topic slug.`,
|
|
3406
|
-
);
|
|
3407
|
-
} else if (prev.outcome === 'failure' && outcome === 'success') {
|
|
3408
|
-
contradictionWarning.push(
|
|
3409
|
-
`✅ **Conflict resolved!** Previous lesson was \`failure\` — now overwriting with \`success\`.`,
|
|
3410
|
-
);
|
|
3411
|
-
}
|
|
3412
|
-
if (contradictionWarning.length > 0) {
|
|
3413
|
-
// Store contradiction audit but don't block
|
|
3414
|
-
auditTrail[auditTrail.length - 1].action = 'contradiction-resolved';
|
|
3415
|
-
}
|
|
3416
|
-
} catch { /* ignore parse error */ }
|
|
3417
|
-
} else {
|
|
3418
|
-
auditTrail = [{ ts, action: 'created' }];
|
|
3419
|
-
}
|
|
3420
|
-
|
|
3421
|
-
// ── "I Was Wrong" Protocol — failure attribution ───────────────────────
|
|
3422
|
-
const iWasWrongWarning: string[] = [];
|
|
3423
|
-
if (outcome === 'failure') {
|
|
3424
|
-
// Search for related success lessons that might have prevented this failure
|
|
3425
|
-
const scanKeys: string[] = [];
|
|
3426
|
-
const scanStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 100 });
|
|
3427
|
-
await new Promise<void>((res, rej) => {
|
|
3428
|
-
scanStream.on('data', (b: string[]) => scanKeys.push(...b));
|
|
3429
|
-
scanStream.on('end', res);
|
|
3430
|
-
scanStream.on('error', rej);
|
|
3431
|
-
});
|
|
3432
|
-
const topicWords = topic.split(/[:\-_]/).filter(w => w.length > 2);
|
|
3433
|
-
for (const k of scanKeys.slice(0, 50)) {
|
|
3434
|
-
const raw = await redis.get(k);
|
|
3435
|
-
if (!raw) continue;
|
|
2515
|
+
if (outcome === 'success') {
|
|
2516
|
+
const existing = await redis.get(`cachly:lesson:best:${topic}`);
|
|
2517
|
+
if (existing) {
|
|
3436
2518
|
try {
|
|
3437
|
-
const
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
if (overlap >= 1 && l.topic !== topic) {
|
|
3442
|
-
iWasWrongWarning.push(
|
|
3443
|
-
`⚠️ **"I Was Wrong"**: lesson \`${l.topic}\` (success, ${l.severity ?? 'major'}) might have prevented this failure.`,
|
|
3444
|
-
` → Use \`recall_best_solution(topic="${l.topic}")\` before next attempt.`,
|
|
3445
|
-
` → To mark it critical: \`learn_from_attempts(topic="${l.topic}", ..., severity="critical")\``,
|
|
3446
|
-
);
|
|
3447
|
-
break; // only show the most relevant match
|
|
3448
|
-
}
|
|
3449
|
-
} catch { /* skip */ }
|
|
2519
|
+
const prev = JSON.parse(existing) as { recall_count?: number };
|
|
2520
|
+
recallCount = (prev.recall_count ?? 0); // preserve existing recall count
|
|
2521
|
+
isUpdate = true;
|
|
2522
|
+
} catch { /* ignore parse error */ }
|
|
3450
2523
|
}
|
|
3451
2524
|
}
|
|
3452
2525
|
|
|
3453
|
-
|
|
3454
|
-
for (const dep of depends_on) {
|
|
3455
|
-
const depKey = `cachly:dep:${dep}`;
|
|
3456
|
-
const existing = await redis.get(depKey);
|
|
3457
|
-
const depTopics: string[] = existing ? JSON.parse(existing) : [];
|
|
3458
|
-
if (!depTopics.includes(topic)) depTopics.push(topic);
|
|
3459
|
-
await redis.set(depKey, JSON.stringify(depTopics));
|
|
3460
|
-
}
|
|
3461
|
-
|
|
3462
|
-
const lessonObj = {
|
|
2526
|
+
const lesson = JSON.stringify({
|
|
3463
2527
|
topic,
|
|
3464
2528
|
outcome,
|
|
3465
2529
|
what_worked,
|
|
@@ -3469,36 +2533,20 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3469
2533
|
file_paths,
|
|
3470
2534
|
commands,
|
|
3471
2535
|
tags,
|
|
3472
|
-
depends_on,
|
|
3473
|
-
...(author ? { author } : {}),
|
|
3474
2536
|
recall_count: recallCount,
|
|
3475
2537
|
ts,
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
audit_trail: auditTrail,
|
|
3479
|
-
version: 3,
|
|
3480
|
-
};
|
|
3481
|
-
const lesson = JSON.stringify(lessonObj);
|
|
2538
|
+
version: 2,
|
|
2539
|
+
});
|
|
3482
2540
|
|
|
3483
|
-
// Always append to the history list
|
|
2541
|
+
// Always append to the history list
|
|
3484
2542
|
const listKey = `cachly:lessons:${topic}`;
|
|
3485
2543
|
await redis.rpush(listKey, lesson);
|
|
3486
2544
|
|
|
3487
|
-
// Update best key for success/partial
|
|
2545
|
+
// Update best key only for success/partial (not for failures)
|
|
3488
2546
|
if (outcome === 'success' || outcome === 'partial') {
|
|
3489
2547
|
await redis.set(`cachly:lesson:best:${topic}`, lesson);
|
|
3490
|
-
} else if (!existingRaw) {
|
|
3491
|
-
await redis.set(`cachly:lesson:best:${topic}`, lesson);
|
|
3492
2548
|
}
|
|
3493
2549
|
|
|
3494
|
-
// Track in decision log for session replay
|
|
3495
|
-
try {
|
|
3496
|
-
const dlKey = 'cachly:session:decision-log';
|
|
3497
|
-
const dlEntry = JSON.stringify({ ts, topic, outcome, what_worked: what_worked.slice(0, 120) });
|
|
3498
|
-
await redis.rpush(dlKey, dlEntry);
|
|
3499
|
-
await redis.ltrim(dlKey, -50, -1);
|
|
3500
|
-
} catch { /* non-critical */ }
|
|
3501
|
-
|
|
3502
2550
|
const emoji = outcome === 'success' ? '✅' : outcome === 'partial' ? '⚠️' : '❌';
|
|
3503
2551
|
const sevEmoji = severity === 'critical' ? '🔴' : severity === 'major' ? '🟡' : '🟢';
|
|
3504
2552
|
const action = isUpdate ? 'updated' : 'stored';
|
|
@@ -3513,13 +2561,8 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3513
2561
|
tags.length > 0 ? `**Tags:** ${tags.map(t => `#${t}`).join(' ')}` : '',
|
|
3514
2562
|
``,
|
|
3515
2563
|
isUpdate
|
|
3516
|
-
? `♻️ Updated (recall count: ${recallCount}
|
|
2564
|
+
? `♻️ Updated existing lesson (preserved recall count: ${recallCount})`
|
|
3517
2565
|
: `💡 Recall later with \`recall_best_solution(topic="${topic}")\``,
|
|
3518
|
-
depends_on.length > 0
|
|
3519
|
-
? `🔗 Depends on: ${depends_on.map(d => `\`${d}\``).join(', ')} → trace with \`trace_dependency\``
|
|
3520
|
-
: '',
|
|
3521
|
-
...templateWarnings,
|
|
3522
|
-
...iWasWrongWarning,
|
|
3523
2566
|
].filter(l => l !== '').join('\n');
|
|
3524
2567
|
}
|
|
3525
2568
|
|
|
@@ -3530,47 +2573,43 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3530
2573
|
// Try exact best-solution key first
|
|
3531
2574
|
const best = await redis.get(`cachly:lesson:best:${topic}`);
|
|
3532
2575
|
if (best) {
|
|
3533
|
-
|
|
2576
|
+
let lesson: {
|
|
3534
2577
|
topic: string; outcome: string; what_worked: string; what_failed?: string;
|
|
3535
|
-
context?: string; ts: string;
|
|
3536
|
-
|
|
3537
|
-
recall_count?: number; audit_trail?: unknown[];
|
|
3538
|
-
};
|
|
3539
|
-
|
|
3540
|
-
// ── Confidence decay check ───────────────────────────────────────────
|
|
3541
|
-
const confidence = calculateConfidence(lesson);
|
|
3542
|
-
const ref = lesson.verified_at ?? lesson.ts;
|
|
3543
|
-
const ageDays = (Date.now() - new Date(ref).getTime()) / 86400000;
|
|
3544
|
-
const badge = confidenceBadge(confidence, ageDays);
|
|
3545
|
-
|
|
3546
|
-
// Recall resets verified_at (confidence clock restart)
|
|
3547
|
-
const updatedLesson = {
|
|
3548
|
-
...lesson,
|
|
3549
|
-
recall_count: (lesson.recall_count ?? 0) + 1,
|
|
3550
|
-
verified_at: new Date().toISOString(),
|
|
3551
|
-
confidence: 1.0,
|
|
2578
|
+
context?: string; ts: string; severity?: string; file_paths?: string[];
|
|
2579
|
+
commands?: string[]; tags?: string[]; recall_count?: number;
|
|
3552
2580
|
};
|
|
2581
|
+
try { lesson = JSON.parse(best); } catch {
|
|
2582
|
+
return `⚠️ Lesson for \`${topic}\` could not be read (corrupted data). Use \`learn_from_attempts\` to overwrite it.`;
|
|
2583
|
+
}
|
|
2584
|
+
// Increment recall_count
|
|
2585
|
+
const updatedLesson = { ...lesson, recall_count: (lesson.recall_count ?? 0) + 1 };
|
|
3553
2586
|
await redis.set(`cachly:lesson:best:${topic}`, JSON.stringify(updatedLesson));
|
|
3554
2587
|
|
|
3555
|
-
|
|
3556
|
-
const
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
2588
|
+
// Fire-and-forget: track Magic Moment in Plausible + API engagement counter
|
|
2589
|
+
const isFirstRecall = (lesson.recall_count ?? 0) === 0;
|
|
2590
|
+
const recallEvent = isFirstRecall ? 'First Brain Recall' : 'Brain Recall Hit';
|
|
2591
|
+
fetch('https://analytics.cachly.dev/api/event', {
|
|
2592
|
+
method: 'POST',
|
|
2593
|
+
headers: { 'Content-Type': 'application/json', 'User-Agent': 'cachly-mcp/recall' },
|
|
2594
|
+
body: JSON.stringify({
|
|
2595
|
+
domain: 'cachly.dev', name: recallEvent,
|
|
2596
|
+
url: 'https://cachly.dev/mcp/recall', props: { topic },
|
|
2597
|
+
}),
|
|
2598
|
+
}).catch(() => { /* non-critical */ });
|
|
2599
|
+
if (JWT) {
|
|
2600
|
+
fetch(`${API_URL}/api/v1/telemetry/mcp`, {
|
|
2601
|
+
method: 'POST',
|
|
2602
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2603
|
+
body: JSON.stringify({ event: isFirstRecall ? 'first_recall' : 'recall', api_key: JWT }),
|
|
2604
|
+
}).catch(() => { /* non-critical */ });
|
|
2605
|
+
}
|
|
3570
2606
|
|
|
2607
|
+
const sevEmoji = lesson.severity === 'critical' ? '🔴' : lesson.severity === 'major' ? '🟡' : lesson.severity ? '🟢' : '';
|
|
2608
|
+
const sessionAgeMs = Date.now() - new Date(lesson.ts).getTime();
|
|
2609
|
+
const sessionAgeDays = Math.round(sessionAgeMs / 86_400_000);
|
|
2610
|
+
const ageLabel = sessionAgeDays === 0 ? 'today' : sessionAgeDays === 1 ? 'yesterday' : `${sessionAgeDays} days ago`;
|
|
3571
2611
|
return [
|
|
3572
|
-
|
|
3573
|
-
`${badge} **Best solution for \`${topic}\`** ${sevEmoji}${lesson.severity ? ` (${lesson.severity})` : ''} · recalled ${updatedLesson.recall_count}×`,
|
|
2612
|
+
`✅ **Best solution for \`${topic}\`** ${sevEmoji}${lesson.severity ? ` (${lesson.severity})` : ''} · recalled ${updatedLesson.recall_count}× · stored ${ageLabel}`,
|
|
3574
2613
|
``,
|
|
3575
2614
|
`**What worked:** ${lesson.what_worked}`,
|
|
3576
2615
|
lesson.what_failed ? `**What failed (avoid this):** ${lesson.what_failed}` : '',
|
|
@@ -3578,8 +2617,8 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3578
2617
|
(lesson.file_paths ?? []).length > 0 ? `**Files:** ${(lesson.file_paths ?? []).map((f: string) => `\`${f}\``).join(', ')}` : '',
|
|
3579
2618
|
(lesson.commands ?? []).length > 0 ? `**Commands:** ${(lesson.commands ?? []).map((c: string) => `\`${c}\``).join(', ')}` : '',
|
|
3580
2619
|
(lesson.tags ?? []).length > 0 ? `**Tags:** ${(lesson.tags ?? []).map((t: string) => `#${t}`).join(' ')}` : '',
|
|
3581
|
-
|
|
3582
|
-
|
|
2620
|
+
``,
|
|
2621
|
+
`> 🧠 *Your AI remembered this from ${ageLabel} — persistent memory by [cachly.dev](https://cachly.dev)*`,
|
|
3583
2622
|
].filter(l => l !== '').join('\n');
|
|
3584
2623
|
}
|
|
3585
2624
|
|
|
@@ -3598,7 +2637,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3598
2637
|
const histKey = `cachly:lessons:${topic}`;
|
|
3599
2638
|
const all = await redis.lrange(histKey, -3, -1);
|
|
3600
2639
|
if (all.length > 0) {
|
|
3601
|
-
const parsed = all.
|
|
2640
|
+
const parsed = all.flatMap(e => { try { return [JSON.parse(e) as { outcome: string; what_worked: string; ts: string }]; } catch { return []; } });
|
|
3602
2641
|
const lines = parsed.map(p => `- ${p.outcome === 'success' ? '✅' : '❌'} ${p.what_worked.slice(0, 120)} (${new Date(p.ts).toLocaleDateString('de-DE')})`);
|
|
3603
2642
|
return `⚠️ No successful solution for \`${topic}\` yet. Last attempts:\n\n${lines.join('\n')}`;
|
|
3604
2643
|
}
|
|
@@ -3610,12 +2649,70 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3610
2649
|
for (const k of matching.slice(0, 5)) {
|
|
3611
2650
|
const raw = await redis.get(k);
|
|
3612
2651
|
if (!raw) continue;
|
|
3613
|
-
|
|
2652
|
+
let lesson: { topic: string; what_worked: string; context?: string; ts: string };
|
|
2653
|
+
try { lesson = JSON.parse(raw); } catch { continue; }
|
|
3614
2654
|
results.push(`**\`${lesson.topic}\`** — ${lesson.what_worked.slice(0, 200)}`);
|
|
3615
2655
|
}
|
|
3616
2656
|
return `🔍 **Partial matches for \`${topic}\`:**\n\n${results.join('\n\n')}`;
|
|
3617
2657
|
}
|
|
3618
2658
|
|
|
2659
|
+
case 'list_lessons': {
|
|
2660
|
+
const { instance_id, filter = '' } = args as { instance_id: string; filter?: string };
|
|
2661
|
+
const redis = await getConnection(instance_id);
|
|
2662
|
+
const keys: string[] = [];
|
|
2663
|
+
const stream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
|
|
2664
|
+
await new Promise<void>((resolve, reject) => {
|
|
2665
|
+
stream.on('data', (batch: string[]) => keys.push(...batch));
|
|
2666
|
+
stream.on('end', resolve);
|
|
2667
|
+
stream.on('error', reject);
|
|
2668
|
+
});
|
|
2669
|
+
if (keys.length === 0) return `📭 No lessons stored yet. Use \`learn_from_attempts\` after solving any problem.`;
|
|
2670
|
+
|
|
2671
|
+
const values = await redis.mget(...keys);
|
|
2672
|
+
type LessonMeta = { topic: string; ts: string; severity?: string; recall_count?: number; outcome?: string };
|
|
2673
|
+
const lessons: LessonMeta[] = values.flatMap(raw => {
|
|
2674
|
+
if (!raw) return [];
|
|
2675
|
+
try { return [JSON.parse(raw) as LessonMeta]; } catch { return []; }
|
|
2676
|
+
});
|
|
2677
|
+
|
|
2678
|
+
const filtered = filter
|
|
2679
|
+
? lessons.filter(l => l.topic.toLowerCase().includes(filter.toLowerCase()))
|
|
2680
|
+
: lessons;
|
|
2681
|
+
|
|
2682
|
+
filtered.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
|
|
2683
|
+
|
|
2684
|
+
if (filtered.length === 0) return `📭 No lessons matching \`${filter}\`.`;
|
|
2685
|
+
|
|
2686
|
+
const sevEmoji = (s?: string) => s === 'critical' ? '🔴' : s === 'major' ? '🟡' : '🟢';
|
|
2687
|
+
const lines = [
|
|
2688
|
+
`🧠 **${filtered.length} lesson${filtered.length === 1 ? '' : 's'}${filter ? ` matching "${filter}"` : ''} in Brain:**`,
|
|
2689
|
+
'',
|
|
2690
|
+
...filtered.slice(0, 30).map(l => {
|
|
2691
|
+
const age = Math.round((Date.now() - new Date(l.ts).getTime()) / 86400000);
|
|
2692
|
+
const ageStr = age === 0 ? 'today' : age === 1 ? 'yesterday' : `${age}d ago`;
|
|
2693
|
+
const recalls = l.recall_count ? ` · recalled ${l.recall_count}×` : '';
|
|
2694
|
+
return `${sevEmoji(l.severity)} \`${l.topic}\` — ${ageStr}${recalls}`;
|
|
2695
|
+
}),
|
|
2696
|
+
filtered.length > 30 ? `\n_…and ${filtered.length - 30} more. Use filter to narrow down._` : '',
|
|
2697
|
+
].filter(l => l !== '');
|
|
2698
|
+
return lines.join('\n');
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
case 'forget_lesson': {
|
|
2702
|
+
const { instance_id, topic } = args as { instance_id: string; topic: string };
|
|
2703
|
+
const redis = await getConnection(instance_id);
|
|
2704
|
+
const bestKey = `cachly:lesson:best:${topic}`;
|
|
2705
|
+
const histKey = `cachly:lessons:${topic}`;
|
|
2706
|
+
const [delBest, delHist] = await Promise.all([
|
|
2707
|
+
redis.del(bestKey),
|
|
2708
|
+
redis.del(histKey),
|
|
2709
|
+
]);
|
|
2710
|
+
if (delBest === 0 && delHist === 0) {
|
|
2711
|
+
return `📭 No lesson found for \`${topic}\` — nothing to delete.`;
|
|
2712
|
+
}
|
|
2713
|
+
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\`.`;
|
|
2714
|
+
}
|
|
2715
|
+
|
|
3619
2716
|
case 'smart_recall': {
|
|
3620
2717
|
const {
|
|
3621
2718
|
instance_id,
|
|
@@ -3730,18 +2827,32 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3730
2827
|
|
|
3731
2828
|
// ── get_api_status ────────────────────────────────────────────────────────
|
|
3732
2829
|
case 'get_api_status': {
|
|
3733
|
-
// Check health
|
|
2830
|
+
// Check API health and auth service in parallel
|
|
2831
|
+
const [healthResult, authServiceResult] = await Promise.allSettled([
|
|
2832
|
+
fetch(`${API_URL}/health`, { signal: AbortSignal.timeout(5000) }),
|
|
2833
|
+
fetch(`${AUTH_URL}/.well-known/openid-configuration`, { signal: AbortSignal.timeout(5000) }),
|
|
2834
|
+
]);
|
|
2835
|
+
|
|
3734
2836
|
let healthStatus = 'unknown';
|
|
3735
|
-
|
|
3736
|
-
const healthRes =
|
|
2837
|
+
if (healthResult.status === 'fulfilled') {
|
|
2838
|
+
const healthRes = healthResult.value;
|
|
3737
2839
|
if (healthRes.ok) {
|
|
3738
|
-
const body = await healthRes.json() as { status?: string; db?: string };
|
|
3739
|
-
healthStatus =
|
|
2840
|
+
const body = await healthRes.json().catch(() => ({})) as { status?: string; db?: string };
|
|
2841
|
+
healthStatus = `✅ ${body.status ?? 'ok'}${body.db ? ` (db: ${body.db})` : ''}`;
|
|
3740
2842
|
} else {
|
|
3741
|
-
healthStatus =
|
|
2843
|
+
healthStatus = `❌ HTTP ${healthRes.status}`;
|
|
3742
2844
|
}
|
|
3743
|
-
}
|
|
3744
|
-
healthStatus =
|
|
2845
|
+
} else {
|
|
2846
|
+
healthStatus = `❌ unreachable: ${healthResult.reason?.message ?? 'timeout'}`;
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
let authSvcStatus = 'unknown';
|
|
2850
|
+
if (authServiceResult.status === 'fulfilled') {
|
|
2851
|
+
authSvcStatus = authServiceResult.value.ok
|
|
2852
|
+
? `✅ reachable (${AUTH_URL})`
|
|
2853
|
+
: `❌ HTTP ${authServiceResult.value.status} — auth service may be down`;
|
|
2854
|
+
} else {
|
|
2855
|
+
authSvcStatus = `❌ unreachable: ${authServiceResult.reason?.message ?? 'timeout'} — auth service may be down`;
|
|
3745
2856
|
}
|
|
3746
2857
|
|
|
3747
2858
|
// Check JWT / auth
|
|
@@ -3749,16 +2860,18 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3749
2860
|
return [
|
|
3750
2861
|
`📡 **cachly API Status**`,
|
|
3751
2862
|
``,
|
|
3752
|
-
` 🌐 API:
|
|
3753
|
-
` 💓 Health: ${healthStatus}`,
|
|
3754
|
-
`
|
|
2863
|
+
` 🌐 API: ${API_URL}`,
|
|
2864
|
+
` 💓 API Health: ${healthStatus}`,
|
|
2865
|
+
` 🔐 Auth Service: ${authSvcStatus}`,
|
|
2866
|
+
` 🔑 JWT: ❌ CACHLY_JWT not set`,
|
|
3755
2867
|
``,
|
|
3756
|
-
`💡 Get your API token at https://cachly.dev/
|
|
2868
|
+
`💡 Get your API token at https://cachly.dev/settings`,
|
|
3757
2869
|
].join('\n');
|
|
3758
2870
|
}
|
|
3759
2871
|
|
|
3760
2872
|
// Decode JWT claims (inspection only, no verification)
|
|
3761
2873
|
let authInfo = '❌ invalid JWT format';
|
|
2874
|
+
let isExpired = false;
|
|
3762
2875
|
try {
|
|
3763
2876
|
const parts = JWT.split('.');
|
|
3764
2877
|
if (parts.length === 3) {
|
|
@@ -3769,33 +2882,44 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3769
2882
|
const iss = payload.iss ?? '(unknown)';
|
|
3770
2883
|
const provider = iss.includes('keycloak') ? 'Keycloak' : 'OIDC';
|
|
3771
2884
|
const expTs = payload.exp ? new Date(payload.exp * 1000) : null;
|
|
3772
|
-
|
|
2885
|
+
isExpired = expTs ? expTs < new Date() : false;
|
|
3773
2886
|
authInfo = [
|
|
3774
|
-
|
|
2887
|
+
`${isExpired ? '⚠️ ' : '✅'} JWT decoded`,
|
|
3775
2888
|
` Sub (user ID): ${sub}`,
|
|
3776
2889
|
` Provider: ${provider}`,
|
|
3777
2890
|
` Issuer: ${iss}`,
|
|
3778
|
-
` Expires: ${expTs ? expTs.toISOString() : 'never'} ${
|
|
2891
|
+
` Expires: ${expTs ? expTs.toISOString() : 'never'} ${isExpired ? '⚠️ EXPIRED' : '✅'}`,
|
|
3779
2892
|
].join('\n');
|
|
3780
2893
|
}
|
|
3781
2894
|
} catch {
|
|
3782
2895
|
authInfo = '❌ JWT decode failed – check CACHLY_JWT format';
|
|
3783
2896
|
}
|
|
3784
2897
|
|
|
3785
|
-
|
|
2898
|
+
const lines = [
|
|
3786
2899
|
`📡 **cachly API Status**`,
|
|
3787
2900
|
``,
|
|
3788
|
-
` 🌐 API:
|
|
3789
|
-
` 💓 Health:
|
|
2901
|
+
` 🌐 API: ${API_URL}`,
|
|
2902
|
+
` 💓 API Health: ${healthStatus}`,
|
|
2903
|
+
` 🔐 Auth Service: ${authSvcStatus}`,
|
|
3790
2904
|
``,
|
|
3791
|
-
`🔑 **Auth:**`,
|
|
2905
|
+
`🔑 **Auth (JWT):**`,
|
|
3792
2906
|
authInfo,
|
|
3793
|
-
]
|
|
2907
|
+
];
|
|
2908
|
+
|
|
2909
|
+
if (isExpired) {
|
|
2910
|
+
lines.push(``, `⚠️ Your token is expired — get a new one at https://cachly.dev/settings`);
|
|
2911
|
+
}
|
|
2912
|
+
if (authSvcStatus.startsWith('❌')) {
|
|
2913
|
+
lines.push(``, `⚠️ Auth service appears down. Registration and login on cachly.dev may be unavailable.`);
|
|
2914
|
+
lines.push(` Contact support@cachly.dev if this persists.`);
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
return lines.join('\n');
|
|
3794
2918
|
}
|
|
3795
2919
|
|
|
3796
2920
|
// ── session_start ─────────────────────────────────────────────────────────
|
|
3797
2921
|
case 'session_start': {
|
|
3798
|
-
const { instance_id, focus = ''
|
|
2922
|
+
const { instance_id, focus = '' } = args as { instance_id: string; focus?: string };
|
|
3799
2923
|
const redis = await getConnection(instance_id);
|
|
3800
2924
|
|
|
3801
2925
|
// 1. Scan all best-solution lessons
|
|
@@ -3810,14 +2934,16 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3810
2934
|
// 2. Fetch all lesson values for recency sorting + focus matching
|
|
3811
2935
|
type Lesson = {
|
|
3812
2936
|
topic: string; outcome: string; what_worked: string; what_failed?: string;
|
|
3813
|
-
ts: string;
|
|
3814
|
-
tags?: string[]; confidence?: number; audit_trail?: unknown[];
|
|
2937
|
+
ts: string; severity?: string; recall_count?: number; tags?: string[];
|
|
3815
2938
|
};
|
|
3816
2939
|
const lessons: Lesson[] = [];
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
2940
|
+
if (lessonKeys.length > 0) {
|
|
2941
|
+
// Batch fetch all lesson values in one round-trip instead of N sequential gets
|
|
2942
|
+
const values = await redis.mget(...lessonKeys);
|
|
2943
|
+
for (const raw of values) {
|
|
2944
|
+
if (!raw) continue;
|
|
2945
|
+
try { lessons.push(JSON.parse(raw) as Lesson); } catch { /* skip corrupt */ }
|
|
2946
|
+
}
|
|
3821
2947
|
}
|
|
3822
2948
|
lessons.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
|
|
3823
2949
|
|
|
@@ -3850,43 +2976,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3850
2976
|
)
|
|
3851
2977
|
: [];
|
|
3852
2978
|
|
|
3853
|
-
// 6.
|
|
3854
|
-
let streakDays = 0;
|
|
3855
|
-
let streakRecord = 0;
|
|
3856
|
-
let streakMessage = '';
|
|
3857
|
-
try {
|
|
3858
|
-
const streakRaw = await redis.get('cachly:streak:current');
|
|
3859
|
-
const streak = streakRaw ? JSON.parse(streakRaw) as { days: number; last_date: string; record: number } : null;
|
|
3860
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
3861
|
-
if (streak) {
|
|
3862
|
-
const lastDate = streak.last_date;
|
|
3863
|
-
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
|
3864
|
-
if (lastDate === today) {
|
|
3865
|
-
// Already counted today
|
|
3866
|
-
streakDays = streak.days;
|
|
3867
|
-
streakRecord = streak.record;
|
|
3868
|
-
} else if (lastDate === yesterday) {
|
|
3869
|
-
// Continuing streak
|
|
3870
|
-
streakDays = streak.days + 1;
|
|
3871
|
-
streakRecord = Math.max(streakDays, streak.record);
|
|
3872
|
-
await redis.set('cachly:streak:current', JSON.stringify({ days: streakDays, last_date: today, record: streakRecord }));
|
|
3873
|
-
} else {
|
|
3874
|
-
// Streak broken
|
|
3875
|
-
streakDays = 1;
|
|
3876
|
-
streakRecord = streak.record;
|
|
3877
|
-
await redis.set('cachly:streak:current', JSON.stringify({ days: 1, last_date: today, record: streakRecord }));
|
|
3878
|
-
}
|
|
3879
|
-
} else {
|
|
3880
|
-
// First session ever
|
|
3881
|
-
streakDays = 1;
|
|
3882
|
-
streakRecord = 1;
|
|
3883
|
-
await redis.set('cachly:streak:current', JSON.stringify({ days: 1, last_date: today, record: 1 }));
|
|
3884
|
-
}
|
|
3885
|
-
if (streakDays >= 7) streakMessage = `🔥 **${streakDays}-day streak!** ${streakDays === streakRecord ? ' New record!' : `Best: ${streakRecord}d`}`;
|
|
3886
|
-
else if (streakDays > 1) streakMessage = `🔥 ${streakDays}-day streak`;
|
|
3887
|
-
} catch { /* non-critical */ }
|
|
3888
|
-
|
|
3889
|
-
// 7. Save session start marker
|
|
2979
|
+
// 6. Save session start marker
|
|
3890
2980
|
await redis.set('cachly:session:current', JSON.stringify({
|
|
3891
2981
|
started: new Date().toISOString(),
|
|
3892
2982
|
focus,
|
|
@@ -3894,38 +2984,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3894
2984
|
|
|
3895
2985
|
// ── Build briefing ──────────────────────────────────────────────────────
|
|
3896
2986
|
const lines: string[] = ['🧠 **Session Briefing**', ''];
|
|
3897
|
-
if (streakMessage) lines.push(streakMessage, '');
|
|
3898
|
-
|
|
3899
|
-
// Handoff from previous window (if any)
|
|
3900
|
-
const handoffRaw = await redis.get('cachly:session:handoff');
|
|
3901
|
-
if (handoffRaw) {
|
|
3902
|
-
try {
|
|
3903
|
-
const handoff = JSON.parse(handoffRaw) as {
|
|
3904
|
-
ts: string; completed_tasks: string[]; remaining_tasks: string[];
|
|
3905
|
-
files_changed?: { path: string; status: string; description?: string }[];
|
|
3906
|
-
instructions?: string; context_summary?: string; blocked_on?: string;
|
|
3907
|
-
};
|
|
3908
|
-
const ago = Math.round((Date.now() - new Date(handoff.ts).getTime()) / 60000);
|
|
3909
|
-
const agoStr = ago < 60 ? `${ago}m ago` : ago < 1440 ? `${Math.round(ago / 60)}h ago` : `${Math.round(ago / 1440)}d ago`;
|
|
3910
|
-
|
|
3911
|
-
lines.push(`🤝 **Handoff from previous window** (${agoStr}):`);
|
|
3912
|
-
if (handoff.context_summary) lines.push(` ${handoff.context_summary}`);
|
|
3913
|
-
if (handoff.remaining_tasks.length > 0) {
|
|
3914
|
-
lines.push(` ⏳ **Remaining tasks:**`);
|
|
3915
|
-
for (const t of handoff.remaining_tasks) lines.push(` - ${t}`);
|
|
3916
|
-
}
|
|
3917
|
-
if (handoff.completed_tasks.length > 0) {
|
|
3918
|
-
lines.push(` ✅ **Already done:** ${handoff.completed_tasks.join(', ')}`);
|
|
3919
|
-
}
|
|
3920
|
-
const brokenFiles = (handoff.files_changed ?? []).filter(f => f.status === 'broken' || f.status === 'partial');
|
|
3921
|
-
if (brokenFiles.length > 0) {
|
|
3922
|
-
lines.push(` ⚠️ **Needs fix:** ${brokenFiles.map(f => `\`${f.path}\` (${f.status}${f.description ? ': ' + f.description : ''})`).join(', ')}`);
|
|
3923
|
-
}
|
|
3924
|
-
if (handoff.blocked_on) lines.push(` 🚫 **Blocked on:** ${handoff.blocked_on}`);
|
|
3925
|
-
if (handoff.instructions) lines.push(` 📝 **Instructions:** ${handoff.instructions}`);
|
|
3926
|
-
lines.push('');
|
|
3927
|
-
} catch { /* ignore corrupt handoff */ }
|
|
3928
|
-
}
|
|
3929
2987
|
|
|
3930
2988
|
// Last session
|
|
3931
2989
|
if (lastSession) {
|
|
@@ -3939,8 +2997,15 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3939
2997
|
lines.push('');
|
|
3940
2998
|
}
|
|
3941
2999
|
|
|
3942
|
-
// Brain health
|
|
3943
|
-
|
|
3000
|
+
// Brain health + first-time onboarding hint
|
|
3001
|
+
if (lessons.length === 0 && !lastSession) {
|
|
3002
|
+
lines.push(`🆕 **Brain is empty — first session!**`);
|
|
3003
|
+
lines.push(`After solving anything, call \`learn_from_attempts\` to store the lesson.`);
|
|
3004
|
+
lines.push(`After this session, call \`session_end\` to save a summary.`);
|
|
3005
|
+
lines.push('');
|
|
3006
|
+
} else {
|
|
3007
|
+
lines.push(`📊 **Brain:** ${lessons.length} lessons · ${ctxCount} context entries`, '');
|
|
3008
|
+
}
|
|
3944
3009
|
|
|
3945
3010
|
// Focus-relevant lessons
|
|
3946
3011
|
if (focusLessons.length > 0) {
|
|
@@ -3977,136 +3042,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3977
3042
|
lines.push('');
|
|
3978
3043
|
}
|
|
3979
3044
|
|
|
3980
|
-
// ── Stale / low-confidence lessons (confidence decay) ─────────────────
|
|
3981
|
-
const staleSuccessLessons = lessons.filter(l => {
|
|
3982
|
-
if (l.outcome !== 'success' && l.outcome !== 'partial') return false;
|
|
3983
|
-
return calculateConfidence(l) < CONFIDENCE_WARN_VALUE;
|
|
3984
|
-
});
|
|
3985
|
-
if (staleSuccessLessons.length > 0) {
|
|
3986
|
-
lines.push(`🔴 **Stale lessons** (not recalled in >${CONFIDENCE_WARN_DAYS}d — verify before applying):`);
|
|
3987
|
-
for (const l of staleSuccessLessons.slice(0, 4)) {
|
|
3988
|
-
const conf = calculateConfidence(l);
|
|
3989
|
-
const ageDays = Math.round((Date.now() - new Date(l.verified_at ?? l.ts).getTime()) / 86400000);
|
|
3990
|
-
const flag = conf < CONFIDENCE_STALE_VALUE ? '🔴' : '⚠️';
|
|
3991
|
-
lines.push(` ${flag} \`${l.topic}\` — ${ageDays}d stale, ${(conf * 100).toFixed(0)}% confidence`);
|
|
3992
|
-
}
|
|
3993
|
-
lines.push(` _Run \`recall_best_solution\` on these to reset their confidence clock._`);
|
|
3994
|
-
lines.push('');
|
|
3995
|
-
}
|
|
3996
|
-
|
|
3997
|
-
// ── Session Replay: show last session's decision log ──────────────────
|
|
3998
|
-
const lastSessionAny = lastSession as unknown as { decision_log?: Array<{ topic: string; outcome: string; what_worked: string }> } | null;
|
|
3999
|
-
if (lastSessionAny?.decision_log?.length) {
|
|
4000
|
-
const dl = lastSessionAny.decision_log;
|
|
4001
|
-
const successes = dl.filter(d => d.outcome === 'success');
|
|
4002
|
-
const failures = dl.filter(d => d.outcome === 'failure');
|
|
4003
|
-
lines.push(`🎬 **Last session decisions** (${dl.length} lessons stored):`);
|
|
4004
|
-
if (successes.length > 0) lines.push(` ✅ Worked: ${successes.slice(0, 3).map(d => `\`${d.topic}\``).join(', ')}`);
|
|
4005
|
-
if (failures.length > 0) lines.push(` ❌ Failed: ${failures.slice(0, 3).map(d => `\`${d.topic}\``).join(', ')}`);
|
|
4006
|
-
lines.push('');
|
|
4007
|
-
}
|
|
4008
|
-
|
|
4009
|
-
// ── 🔮 Predictive Pre-Warning — intent-based danger detection ────────────
|
|
4010
|
-
// Fires BEFORE work starts. If focus area has known failure patterns → warn loudly.
|
|
4011
|
-
if (focusTerms.length > 0) {
|
|
4012
|
-
type LessonAny = typeof lessons[0] & { author?: string; tags?: string[] };
|
|
4013
|
-
const dangerLessons = (lessons as LessonAny[]).filter(l => {
|
|
4014
|
-
if (l.outcome === 'success') return false;
|
|
4015
|
-
const topicCategory = l.topic.split(':')[0];
|
|
4016
|
-
return focusTerms.some(term =>
|
|
4017
|
-
l.topic.toLowerCase().includes(term) ||
|
|
4018
|
-
topicCategory === term ||
|
|
4019
|
-
(l.tags ?? []).some((t: string) => t.toLowerCase() === term),
|
|
4020
|
-
);
|
|
4021
|
-
});
|
|
4022
|
-
if (dangerLessons.length >= 1) {
|
|
4023
|
-
// Insert warning block right after the title line (index 1 = blank line after title)
|
|
4024
|
-
const warning = [
|
|
4025
|
-
`🚨 **PRE-WARNING** — Read this BEFORE starting:`,
|
|
4026
|
-
` Known pitfalls for **"${focus}"** (${dangerLessons.length} past failure${dangerLessons.length > 1 ? 's' : ''}):`,
|
|
4027
|
-
...dangerLessons.slice(0, 3).map(l => ` ❌ \`${l.topic}\` — ${(l.what_failed ?? l.what_worked).slice(0, 80)}`),
|
|
4028
|
-
'',
|
|
4029
|
-
];
|
|
4030
|
-
lines.splice(2, 0, ...warning); // after '🧠 **Session Briefing**' + empty line
|
|
4031
|
-
}
|
|
4032
|
-
}
|
|
4033
|
-
|
|
4034
|
-
// ── 👥 Team Telepathy — what teammates learned this week ─────────────────
|
|
4035
|
-
if (author) {
|
|
4036
|
-
type LessonAny = typeof lessons[0] & { author?: string };
|
|
4037
|
-
const oneWeekAgo = Date.now() - 7 * 86_400_000;
|
|
4038
|
-
const teamLessons = (lessons as LessonAny[]).filter(l =>
|
|
4039
|
-
l.author && l.author !== author && new Date(l.ts).getTime() > oneWeekAgo,
|
|
4040
|
-
);
|
|
4041
|
-
if (teamLessons.length > 0) {
|
|
4042
|
-
// Group by author
|
|
4043
|
-
const byAuthor = new Map<string, LessonAny[]>();
|
|
4044
|
-
for (const l of teamLessons) {
|
|
4045
|
-
const a = l.author!;
|
|
4046
|
-
if (!byAuthor.has(a)) byAuthor.set(a, []);
|
|
4047
|
-
byAuthor.get(a)!.push(l);
|
|
4048
|
-
}
|
|
4049
|
-
lines.push(`👥 **Team this week** (${teamLessons.length} lesson${teamLessons.length > 1 ? 's' : ''} from teammates):`);
|
|
4050
|
-
for (const [teamAuthor, tls] of byAuthor) {
|
|
4051
|
-
lines.push(` 👤 **${teamAuthor}**:`);
|
|
4052
|
-
for (const l of tls.slice(0, 3)) {
|
|
4053
|
-
const emoji = l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
|
|
4054
|
-
lines.push(` ${emoji} \`${l.topic}\` — ${l.what_worked.slice(0, 80)}`);
|
|
4055
|
-
}
|
|
4056
|
-
if (tls.length > 3) lines.push(` … and ${tls.length - 3} more`);
|
|
4057
|
-
}
|
|
4058
|
-
lines.push('');
|
|
4059
|
-
}
|
|
4060
|
-
}
|
|
4061
|
-
|
|
4062
|
-
// ── 💎 Memory Crystal — compressed wisdom from old sessions ──────────────
|
|
4063
|
-
try {
|
|
4064
|
-
const crystalRaw = await redis.get('cachly:crystal:latest');
|
|
4065
|
-
if (crystalRaw) {
|
|
4066
|
-
const crystal = JSON.parse(crystalRaw) as {
|
|
4067
|
-
label: string; ts: string; session_count: number;
|
|
4068
|
-
top_patterns: Array<{ category: string; insight: string; count: number }>;
|
|
4069
|
-
};
|
|
4070
|
-
const crystalAge = Math.round((Date.now() - new Date(crystal.ts).getTime()) / 86_400_000);
|
|
4071
|
-
if (crystalAge <= 90) {
|
|
4072
|
-
lines.push(`💎 **Memory Crystal** (${crystal.label} · ${crystal.session_count} sessions compressed):`);
|
|
4073
|
-
for (const p of crystal.top_patterns.slice(0, 3)) {
|
|
4074
|
-
lines.push(` • **${p.category}** (${p.count}×): ${p.insight.slice(0, 90)}`);
|
|
4075
|
-
}
|
|
4076
|
-
lines.push('');
|
|
4077
|
-
}
|
|
4078
|
-
}
|
|
4079
|
-
} catch { /* non-critical */ }
|
|
4080
|
-
|
|
4081
|
-
// ── 🗺️ Roadmap — open items at session start ────────────────────────────
|
|
4082
|
-
try {
|
|
4083
|
-
const roadmapAll = await redis.hgetall(`cachly:roadmap:${instance_id}`);
|
|
4084
|
-
if (roadmapAll && Object.keys(roadmapAll).length > 0) {
|
|
4085
|
-
const PRIORITY_ORDER: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
4086
|
-
const PRIORITY_ICON: Record<string, string> = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
|
|
4087
|
-
const openStatuses = new Set(['planned', 'in-progress', 'blocked']);
|
|
4088
|
-
const allItems = Object.values(roadmapAll).map(v => JSON.parse(v as string) as Record<string, unknown>);
|
|
4089
|
-
const openItems = allItems
|
|
4090
|
-
.filter(i => openStatuses.has(i.status as string))
|
|
4091
|
-
.sort((a, b) => {
|
|
4092
|
-
if (a.status === 'in-progress' && b.status !== 'in-progress') return -1;
|
|
4093
|
-
if (b.status === 'in-progress' && a.status !== 'in-progress') return 1;
|
|
4094
|
-
return (PRIORITY_ORDER[a.priority as string] ?? 99) - (PRIORITY_ORDER[b.priority as string] ?? 99);
|
|
4095
|
-
});
|
|
4096
|
-
const doneCount = allItems.filter(i => i.status === 'done').length;
|
|
4097
|
-
if (openItems.length > 0) {
|
|
4098
|
-
lines.push(`🗺️ **Roadmap** (${openItems.length} open · ${doneCount} done):`);
|
|
4099
|
-
for (const it of openItems.slice(0, 5)) {
|
|
4100
|
-
const statusIcon = it.status === 'in-progress' ? '⚡' : it.status === 'blocked' ? '🚫' : '📋';
|
|
4101
|
-
lines.push(` ${statusIcon} ${PRIORITY_ICON[it.priority as string] ?? '⚪'} \`${it.id}\` **${it.title}**`);
|
|
4102
|
-
}
|
|
4103
|
-
if (openItems.length > 5) lines.push(` … and ${openItems.length - 5} more`);
|
|
4104
|
-
lines.push(` _Use \`roadmap_next\` for the top priority item · \`roadmap_list\` for full view_`);
|
|
4105
|
-
lines.push('');
|
|
4106
|
-
}
|
|
4107
|
-
}
|
|
4108
|
-
} catch { /* non-critical */ }
|
|
4109
|
-
|
|
4110
3045
|
return lines.join('\n');
|
|
4111
3046
|
}
|
|
4112
3047
|
|
|
@@ -4117,13 +3052,11 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
4117
3052
|
summary,
|
|
4118
3053
|
files_changed = [],
|
|
4119
3054
|
lessons_learned,
|
|
4120
|
-
workspace_path = '',
|
|
4121
3055
|
} = args as {
|
|
4122
3056
|
instance_id: string;
|
|
4123
3057
|
summary: string;
|
|
4124
3058
|
files_changed?: string[];
|
|
4125
3059
|
lessons_learned?: number;
|
|
4126
|
-
workspace_path?: string;
|
|
4127
3060
|
};
|
|
4128
3061
|
|
|
4129
3062
|
const redis = await getConnection(instance_id);
|
|
@@ -4139,21 +3072,12 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
4139
3072
|
} catch { /* ignore */ }
|
|
4140
3073
|
}
|
|
4141
3074
|
|
|
4142
|
-
// ── Session Replay: capture decision log ─────────────────────────────
|
|
4143
|
-
let decisionLog: Array<{ ts: string; topic: string; outcome: string; what_worked: string }> = [];
|
|
4144
|
-
try {
|
|
4145
|
-
const dlEntries = await redis.lrange('cachly:session:decision-log', 0, -1);
|
|
4146
|
-
decisionLog = dlEntries.map(e => JSON.parse(e) as { ts: string; topic: string; outcome: string; what_worked: string });
|
|
4147
|
-
await redis.del('cachly:session:decision-log');
|
|
4148
|
-
} catch { /* non-critical */ }
|
|
4149
|
-
|
|
4150
3075
|
const sessionRecord = {
|
|
4151
3076
|
ts: now.toISOString(),
|
|
4152
3077
|
summary,
|
|
4153
3078
|
files_changed,
|
|
4154
3079
|
...(lessons_learned !== undefined ? { lessons_learned } : {}),
|
|
4155
3080
|
...(durationMin !== undefined ? { duration_min: durationMin } : {}),
|
|
4156
|
-
...(decisionLog.length > 0 ? { decision_log: decisionLog } : {}),
|
|
4157
3081
|
};
|
|
4158
3082
|
|
|
4159
3083
|
// Save as "last session"
|
|
@@ -4167,127 +3091,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
4167
3091
|
// Clean up current session marker
|
|
4168
3092
|
await redis.del('cachly:session:current');
|
|
4169
3093
|
|
|
4170
|
-
// ── AUTO-LEARN from session summary (no manual call needed) ─────────────
|
|
4171
|
-
// Parse the summary for actionable lessons and store them automatically.
|
|
4172
|
-
const autoLearned: string[] = [];
|
|
4173
|
-
try {
|
|
4174
|
-
// Extract key sentences from the summary that contain action verbs
|
|
4175
|
-
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;
|
|
4176
|
-
const sentences = summary
|
|
4177
|
-
.split(/[.!\n]+/)
|
|
4178
|
-
.map(s => s.trim())
|
|
4179
|
-
.filter(s => s.length > 20 && actionVerbs.test(s));
|
|
4180
|
-
|
|
4181
|
-
for (const sentence of sentences.slice(0, 6)) {
|
|
4182
|
-
// Build a topic slug from the first meaningful words
|
|
4183
|
-
const words = sentence.toLowerCase()
|
|
4184
|
-
.replace(/[^a-z0-9\s]/g, ' ')
|
|
4185
|
-
.split(/\s+/)
|
|
4186
|
-
.filter(w => w.length > 3 && !['that', 'this', 'with', 'from', 'have', 'been', 'were', 'they', 'then', 'when', 'also', 'into', 'will', 'would', 'could', 'should'].includes(w));
|
|
4187
|
-
const slug = words.slice(0, 4).join('-');
|
|
4188
|
-
if (!slug) continue;
|
|
4189
|
-
const topic = `auto:${slug}`;
|
|
4190
|
-
const key = `cachly:lesson:best:${topic}`;
|
|
4191
|
-
|
|
4192
|
-
// Don't overwrite existing successful lessons
|
|
4193
|
-
const existing = await redis.get(key);
|
|
4194
|
-
if (existing) {
|
|
4195
|
-
try {
|
|
4196
|
-
const ex = JSON.parse(existing) as { outcome: string };
|
|
4197
|
-
if (ex.outcome === 'success') continue;
|
|
4198
|
-
} catch { /* ignore */ }
|
|
4199
|
-
}
|
|
4200
|
-
|
|
4201
|
-
const lesson = {
|
|
4202
|
-
topic,
|
|
4203
|
-
outcome: 'success',
|
|
4204
|
-
what_worked: sentence,
|
|
4205
|
-
context: `Auto-learned from session summary. Full summary: ${summary.slice(0, 300)}`,
|
|
4206
|
-
severity: 'minor',
|
|
4207
|
-
ts: now.toISOString(),
|
|
4208
|
-
recall_count: 0,
|
|
4209
|
-
auto_learned: true,
|
|
4210
|
-
session_ts: now.toISOString(),
|
|
4211
|
-
version: 2,
|
|
4212
|
-
};
|
|
4213
|
-
await redis.set(key, JSON.stringify(lesson));
|
|
4214
|
-
// 90-day TTL for auto-learned lessons
|
|
4215
|
-
await redis.expire(key, 90 * 86400);
|
|
4216
|
-
autoLearned.push(topic);
|
|
4217
|
-
}
|
|
4218
|
-
|
|
4219
|
-
// Also store a lesson per changed file area if files were changed
|
|
4220
|
-
if (files_changed.length > 0) {
|
|
4221
|
-
const areas = [...new Set(files_changed.map(f => f.split('/').slice(0, 2).join('/')))].slice(0, 3);
|
|
4222
|
-
for (const area of areas) {
|
|
4223
|
-
const slug = area.replace(/[^a-z0-9]/gi, '-').toLowerCase().replace(/-+/g, '-').slice(0, 30);
|
|
4224
|
-
const topic = `auto:changed:${slug}`;
|
|
4225
|
-
const key = `cachly:lesson:best:${topic}`;
|
|
4226
|
-
const lesson = {
|
|
4227
|
-
topic,
|
|
4228
|
-
outcome: 'success',
|
|
4229
|
-
what_worked: `Files changed in ${area}: ${files_changed.filter(f => f.startsWith(area.split('/')[0])).slice(0, 5).join(', ')}`,
|
|
4230
|
-
context: summary.slice(0, 200),
|
|
4231
|
-
severity: 'minor',
|
|
4232
|
-
ts: now.toISOString(),
|
|
4233
|
-
recall_count: 0,
|
|
4234
|
-
auto_learned: true,
|
|
4235
|
-
version: 2,
|
|
4236
|
-
};
|
|
4237
|
-
await redis.set(key, JSON.stringify(lesson));
|
|
4238
|
-
await redis.expire(key, 90 * 86400);
|
|
4239
|
-
autoLearned.push(topic);
|
|
4240
|
-
}
|
|
4241
|
-
}
|
|
4242
|
-
} catch { /* auto-learn errors must never break session_end */ }
|
|
4243
|
-
|
|
4244
|
-
// ── 🌿 Ambient Git Learning ────────────────────────────────────────────────
|
|
4245
|
-
// Read git commits since session start → auto-learn each meaningful commit.
|
|
4246
|
-
const ambientLearned: string[] = [];
|
|
4247
|
-
if (workspace_path) {
|
|
4248
|
-
try {
|
|
4249
|
-
// Get the session start time (stored by session_start)
|
|
4250
|
-
const sessionStartTs = currentRaw
|
|
4251
|
-
? (() => { try { return (JSON.parse(currentRaw) as { started?: string }).started ?? ''; } catch { return ''; } })()
|
|
4252
|
-
: '';
|
|
4253
|
-
const sinceArg = sessionStartTs ? `--since="${sessionStartTs}"` : '--since="1 hour ago"';
|
|
4254
|
-
const gitOut = execSync(
|
|
4255
|
-
`git -C "${workspace_path}" log ${sinceArg} --oneline --format="%H|||%s|||%ai"`,
|
|
4256
|
-
{ timeout: 5000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
4257
|
-
).trim();
|
|
4258
|
-
if (gitOut) {
|
|
4259
|
-
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;
|
|
4260
|
-
for (const line of gitOut.split('\n').slice(0, 10)) {
|
|
4261
|
-
const [hash, msg, dateStr] = line.split('|||');
|
|
4262
|
-
if (!msg || !commitActionRe.test(msg)) continue;
|
|
4263
|
-
const slug = msg
|
|
4264
|
-
.toLowerCase().replace(/^(fix|feat|chore|docs|test|ci|perf|refactor|build|revert)[:(\s]/i, '')
|
|
4265
|
-
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
|
|
4266
|
-
if (!slug) continue;
|
|
4267
|
-
const topic = `git:${slug}`;
|
|
4268
|
-
const key = `cachly:lesson:best:${topic}`;
|
|
4269
|
-
const existing = await redis.get(key);
|
|
4270
|
-
if (existing) continue; // don't overwrite existing
|
|
4271
|
-
const commitLesson = {
|
|
4272
|
-
topic,
|
|
4273
|
-
outcome: 'success' as const,
|
|
4274
|
-
what_worked: msg.slice(0, 200),
|
|
4275
|
-
context: `Auto-learned from git commit ${(hash ?? '').slice(0, 7)} at ${dateStr ?? ''} in ${workspace_path}`,
|
|
4276
|
-
severity: 'minor' as const,
|
|
4277
|
-
ts: now.toISOString(),
|
|
4278
|
-
recall_count: 0,
|
|
4279
|
-
auto_learned: true,
|
|
4280
|
-
source: 'ambient-git',
|
|
4281
|
-
version: 3,
|
|
4282
|
-
};
|
|
4283
|
-
await redis.set(key, JSON.stringify(commitLesson));
|
|
4284
|
-
await redis.expire(key, 60 * 86400); // 60 day TTL for git lessons
|
|
4285
|
-
ambientLearned.push(topic);
|
|
4286
|
-
}
|
|
4287
|
-
}
|
|
4288
|
-
} catch { /* git not available or not a repo — silent skip */ }
|
|
4289
|
-
}
|
|
4290
|
-
|
|
4291
3094
|
const durationStr = durationMin !== undefined ? ` · ${durationMin} min` : '';
|
|
4292
3095
|
return [
|
|
4293
3096
|
`✅ **Session saved**${durationStr}`,
|
|
@@ -4295,70 +3098,11 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
4295
3098
|
`📋 **Summary:** ${summary}`,
|
|
4296
3099
|
files_changed.length > 0 ? `📁 **Files changed:** ${files_changed.map(f => `\`${f}\``).join(', ')}` : '',
|
|
4297
3100
|
lessons_learned !== undefined ? `🧠 **Lessons stored:** ${lessons_learned}` : '',
|
|
4298
|
-
autoLearned.length > 0 ? `🤖 **Auto-learned:** ${autoLearned.length} lessons extracted from summary (${autoLearned.slice(0, 3).map(t => `\`${t}\``).join(', ')}${autoLearned.length > 3 ? '…' : ''})` : '',
|
|
4299
|
-
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 ? '…' : ''})` : '',
|
|
4300
3101
|
``,
|
|
4301
3102
|
`💡 Next session: \`session_start(focus="...")\` to see this summary.`,
|
|
4302
3103
|
].filter(l => l !== '').join('\n');
|
|
4303
3104
|
}
|
|
4304
3105
|
|
|
4305
|
-
// ── session_handoff — cross-window continuity ─────────────────────────────
|
|
4306
|
-
case 'session_handoff': {
|
|
4307
|
-
const {
|
|
4308
|
-
instance_id,
|
|
4309
|
-
completed_tasks = [],
|
|
4310
|
-
remaining_tasks = [],
|
|
4311
|
-
files_changed = [],
|
|
4312
|
-
instructions = '',
|
|
4313
|
-
context_summary = '',
|
|
4314
|
-
blocked_on = '',
|
|
4315
|
-
} = args as {
|
|
4316
|
-
instance_id: string;
|
|
4317
|
-
completed_tasks: string[];
|
|
4318
|
-
remaining_tasks: string[];
|
|
4319
|
-
files_changed?: { path: string; status: string; description?: string }[];
|
|
4320
|
-
instructions?: string;
|
|
4321
|
-
context_summary?: string;
|
|
4322
|
-
blocked_on?: string;
|
|
4323
|
-
};
|
|
4324
|
-
|
|
4325
|
-
const redis = await getConnection(instance_id);
|
|
4326
|
-
const now = new Date();
|
|
4327
|
-
|
|
4328
|
-
const handoff = {
|
|
4329
|
-
ts: now.toISOString(),
|
|
4330
|
-
completed_tasks,
|
|
4331
|
-
remaining_tasks,
|
|
4332
|
-
files_changed,
|
|
4333
|
-
instructions,
|
|
4334
|
-
context_summary,
|
|
4335
|
-
blocked_on,
|
|
4336
|
-
};
|
|
4337
|
-
|
|
4338
|
-
// Store handoff — never expires until next handoff overwrites it
|
|
4339
|
-
await redis.set('cachly:session:handoff', JSON.stringify(handoff));
|
|
4340
|
-
|
|
4341
|
-
// Also append to history
|
|
4342
|
-
await redis.lpush('cachly:session:handoff:history', JSON.stringify(handoff));
|
|
4343
|
-
await redis.ltrim('cachly:session:handoff:history', 0, 19);
|
|
4344
|
-
|
|
4345
|
-
const totalTasks = completed_tasks.length + remaining_tasks.length;
|
|
4346
|
-
const pct = totalTasks > 0 ? Math.round((completed_tasks.length / totalTasks) * 100) : 0;
|
|
4347
|
-
const brokenFiles = files_changed.filter(f => f.status === 'broken' || f.status === 'partial');
|
|
4348
|
-
|
|
4349
|
-
return [
|
|
4350
|
-
`🤝 **Handoff saved** — ${completed_tasks.length}/${totalTasks} tasks done (${pct}%)`,
|
|
4351
|
-
``,
|
|
4352
|
-
completed_tasks.length > 0 ? `✅ **Completed:**\n${completed_tasks.map(t => ` - ${t}`).join('\n')}` : '',
|
|
4353
|
-
remaining_tasks.length > 0 ? `\n⏳ **Remaining for next window:**\n${remaining_tasks.map(t => ` - ${t}`).join('\n')}` : '',
|
|
4354
|
-
brokenFiles.length > 0 ? `\n⚠️ **Needs attention:** ${brokenFiles.map(f => `\`${f.path}\` (${f.status})`).join(', ')}` : '',
|
|
4355
|
-
blocked_on ? `\n🚫 **Blocked on:** ${blocked_on}` : '',
|
|
4356
|
-
instructions ? `\n📝 **Instructions:** ${instructions}` : '',
|
|
4357
|
-
``,
|
|
4358
|
-
`💡 The next \`session_start\` will include this handoff automatically.`,
|
|
4359
|
-
].filter(l => l !== '').join('\n');
|
|
4360
|
-
}
|
|
4361
|
-
|
|
4362
3106
|
// ── auto_learn_session ────────────────────────────────────────────────────
|
|
4363
3107
|
case 'auto_learn_session': {
|
|
4364
3108
|
const { instance_id, observations } = args as {
|
|
@@ -4384,7 +3128,8 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
4384
3128
|
// Only overwrite if this is a success and existing is failure, or topic is new
|
|
4385
3129
|
const existing = await redis.get(key);
|
|
4386
3130
|
if (existing) {
|
|
4387
|
-
|
|
3131
|
+
let existingLesson: { outcome: string };
|
|
3132
|
+
try { existingLesson = JSON.parse(existing); } catch { existingLesson = { outcome: 'failure' }; }
|
|
4388
3133
|
if (existingLesson.outcome === 'success' && obs.outcome !== 'success') {
|
|
4389
3134
|
skipped.push(topic);
|
|
4390
3135
|
continue;
|
|
@@ -4586,153 +3331,9 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
4586
3331
|
return lines.join('\n');
|
|
4587
3332
|
}
|
|
4588
3333
|
|
|
4589
|
-
// ── team_synthesize — Team Brain Synthesis ────────────────────────────────
|
|
4590
|
-
case 'team_synthesize': {
|
|
4591
|
-
const { instance_id, topic } = args as { instance_id: string; topic: string };
|
|
4592
|
-
const redis = await getConnection(instance_id);
|
|
4593
|
-
|
|
4594
|
-
// Load history list for this topic (all authors' contributions)
|
|
4595
|
-
const listKey = `cachly:lessons:${topic}`;
|
|
4596
|
-
const all = await redis.lrange(listKey, 0, -1);
|
|
4597
|
-
if (all.length < 2) {
|
|
4598
|
-
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}", ...)\`.`;
|
|
4599
|
-
}
|
|
4600
|
-
|
|
4601
|
-
type Entry = { outcome: string; what_worked: string; what_failed?: string; author?: string; ts: string; severity?: string };
|
|
4602
|
-
const entries: Entry[] = all.map(r => { try { return JSON.parse(r) as Entry; } catch { return null; } }).filter((e): e is Entry => e !== null);
|
|
4603
|
-
|
|
4604
|
-
// Group by outcome
|
|
4605
|
-
const successes = entries.filter(e => e.outcome === 'success');
|
|
4606
|
-
const failures = entries.filter(e => e.outcome === 'failure');
|
|
4607
|
-
const partials = entries.filter(e => e.outcome === 'partial');
|
|
4608
|
-
|
|
4609
|
-
const authors = [...new Set(entries.map(e => e.author).filter(Boolean))];
|
|
4610
|
-
const hasMultiAuthor = authors.length > 1;
|
|
4611
|
-
|
|
4612
|
-
// Build canonical merged version
|
|
4613
|
-
// what_worked: pick the most recent success, or longest for most detail
|
|
4614
|
-
const bestSuccess = successes.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime())[0];
|
|
4615
|
-
const whatWorkedCandidates = successes.map(e => e.what_worked).filter(w => w && w.length > 10);
|
|
4616
|
-
const canonicalWorked = whatWorkedCandidates.sort((a, b) => b.length - a.length)[0] ?? bestSuccess?.what_worked ?? '';
|
|
4617
|
-
|
|
4618
|
-
// what_failed: union of all unique failure reasons
|
|
4619
|
-
const allFailed = [...new Set(
|
|
4620
|
-
[...failures, ...partials].map(e => e.what_failed).filter((w): w is string => !!w && w.length > 5)
|
|
4621
|
-
)];
|
|
4622
|
-
|
|
4623
|
-
const severities = entries.map(e => e.severity).filter(Boolean);
|
|
4624
|
-
const canonicalSeverity = severities.includes('critical') ? 'critical' : severities.includes('major') ? 'major' : 'minor';
|
|
4625
|
-
|
|
4626
|
-
const lines = [
|
|
4627
|
-
`🧬 **Team Brain Synthesis: \`${topic}\`**`,
|
|
4628
|
-
`_${entries.length} entries from ${authors.length} author${authors.length === 1 ? '' : 's'}${hasMultiAuthor ? ` (${authors.join(', ')})` : ''} · ${successes.length} success · ${failures.length} failure · ${partials.length} partial_`,
|
|
4629
|
-
'',
|
|
4630
|
-
`**Canonical "what worked":**`,
|
|
4631
|
-
`> ${canonicalWorked}`,
|
|
4632
|
-
'',
|
|
4633
|
-
allFailed.length > 0 ? `**Avoid (combined failures):**` : '',
|
|
4634
|
-
...allFailed.map(f => `> ❌ ${f}`),
|
|
4635
|
-
allFailed.length > 0 ? '' : '',
|
|
4636
|
-
`**Suggested canonical lesson:**`,
|
|
4637
|
-
'```',
|
|
4638
|
-
`learn_from_attempts(`,
|
|
4639
|
-
` topic = "${topic}",`,
|
|
4640
|
-
` outcome = "success",`,
|
|
4641
|
-
` what_worked = "${canonicalWorked.replace(/"/g, "'")}",`,
|
|
4642
|
-
allFailed.length > 0 ? ` what_failed = "${allFailed[0].replace(/"/g, "'")}",` : '',
|
|
4643
|
-
` severity = "${canonicalSeverity}",`,
|
|
4644
|
-
`)`,
|
|
4645
|
-
'```',
|
|
4646
|
-
'',
|
|
4647
|
-
hasMultiAuthor
|
|
4648
|
-
? `💡 _${authors.length} team members contributed to this synthesis. Store the canonical version to replace individual entries._`
|
|
4649
|
-
: `💡 _Single author — more value when multiple team members contribute to the same topic._`,
|
|
4650
|
-
].filter(l => l !== undefined).join('\n');
|
|
4651
|
-
return lines;
|
|
4652
|
-
}
|
|
4653
|
-
|
|
4654
3334
|
// ── brain_doctor ──────────────────────────────────────────────────────────
|
|
4655
|
-
// ── memory_crystalize ─────────────────────────────────────────────────────
|
|
4656
|
-
case 'memory_crystalize': {
|
|
4657
|
-
const { instance_id, label: crystalLabel = '' } = args as { instance_id: string; label?: string };
|
|
4658
|
-
const redis = await getConnection(instance_id);
|
|
4659
|
-
const now = new Date();
|
|
4660
|
-
const week = `${now.getFullYear()}-W${String(Math.ceil((now.getDate() - now.getDay() + 10) / 7)).padStart(2, '0')}`;
|
|
4661
|
-
const effectiveLabel = crystalLabel || `${now.toISOString().slice(0, 7)} Crystal`;
|
|
4662
|
-
|
|
4663
|
-
// Read session history
|
|
4664
|
-
const sessionHistory = await redis.lrange('cachly:session:history', 0, 49);
|
|
4665
|
-
|
|
4666
|
-
// Read all auto-learned lessons
|
|
4667
|
-
const allLessonKeys: string[] = [];
|
|
4668
|
-
const ls = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
|
|
4669
|
-
await new Promise<void>((res, rej) => {
|
|
4670
|
-
ls.on('data', (b: string[]) => allLessonKeys.push(...b));
|
|
4671
|
-
ls.on('end', res);
|
|
4672
|
-
ls.on('error', rej);
|
|
4673
|
-
});
|
|
4674
|
-
|
|
4675
|
-
type RawLesson = { topic: string; outcome: string; what_worked: string; severity?: string; ts: string; auto_learned?: boolean };
|
|
4676
|
-
const allLessons: RawLesson[] = [];
|
|
4677
|
-
for (const k of allLessonKeys) {
|
|
4678
|
-
const raw = await redis.get(k);
|
|
4679
|
-
if (!raw) continue;
|
|
4680
|
-
try { allLessons.push(JSON.parse(raw) as RawLesson); } catch { /* skip */ }
|
|
4681
|
-
}
|
|
4682
|
-
|
|
4683
|
-
// Group lessons by top-level category
|
|
4684
|
-
const categoryMap = new Map<string, RawLesson[]>();
|
|
4685
|
-
for (const l of allLessons) {
|
|
4686
|
-
const cat = l.topic.split(':')[0] || 'misc';
|
|
4687
|
-
if (!categoryMap.has(cat)) categoryMap.set(cat, []);
|
|
4688
|
-
categoryMap.get(cat)!.push(l);
|
|
4689
|
-
}
|
|
4690
|
-
|
|
4691
|
-
// Build top patterns (most frequent categories with a representative insight)
|
|
4692
|
-
const topPatterns: Array<{ category: string; insight: string; count: number }> = [];
|
|
4693
|
-
for (const [cat, catLessons] of [...categoryMap.entries()].sort((a, b) => b[1].length - a[1].length).slice(0, 8)) {
|
|
4694
|
-
const successLessons = catLessons.filter(l => l.outcome === 'success');
|
|
4695
|
-
const best = successLessons[0] ?? catLessons[0];
|
|
4696
|
-
if (!best) continue;
|
|
4697
|
-
topPatterns.push({
|
|
4698
|
-
category: cat,
|
|
4699
|
-
insight: best.what_worked.slice(0, 120),
|
|
4700
|
-
count: catLessons.length,
|
|
4701
|
-
});
|
|
4702
|
-
}
|
|
4703
|
-
|
|
4704
|
-
const crystal = {
|
|
4705
|
-
label: effectiveLabel,
|
|
4706
|
-
ts: now.toISOString(),
|
|
4707
|
-
session_count: sessionHistory.length,
|
|
4708
|
-
lesson_count: allLessons.length,
|
|
4709
|
-
top_patterns: topPatterns,
|
|
4710
|
-
categories: [...categoryMap.keys()],
|
|
4711
|
-
created_from: `${sessionHistory.length} sessions, ${allLessons.length} lessons`,
|
|
4712
|
-
};
|
|
4713
|
-
|
|
4714
|
-
const crystalJson = JSON.stringify(crystal);
|
|
4715
|
-
await redis.set('cachly:crystal:latest', crystalJson);
|
|
4716
|
-
await redis.expire('cachly:crystal:latest', 90 * 86400);
|
|
4717
|
-
await redis.set(`cachly:crystal:${week}`, crystalJson);
|
|
4718
|
-
await redis.expire(`cachly:crystal:${week}`, 365 * 86400);
|
|
4719
|
-
|
|
4720
|
-
const lines = [
|
|
4721
|
-
`💎 **Memory Crystal created: ${effectiveLabel}**`,
|
|
4722
|
-
``,
|
|
4723
|
-
`📊 Compressed: **${sessionHistory.length} sessions** + **${allLessons.length} lessons** → ${topPatterns.length} top patterns`,
|
|
4724
|
-
``,
|
|
4725
|
-
`**Top patterns by category:**`,
|
|
4726
|
-
...topPatterns.slice(0, 6).map(p => ` • **${p.category}** (${p.count}×): ${p.insight.slice(0, 90)}`),
|
|
4727
|
-
``,
|
|
4728
|
-
`💡 This crystal will appear in every future \`session_start\` briefing.`,
|
|
4729
|
-
`💡 Re-run \`memory_crystalize\` monthly to keep it fresh.`,
|
|
4730
|
-
];
|
|
4731
|
-
return lines.join('\n');
|
|
4732
|
-
}
|
|
4733
|
-
|
|
4734
3335
|
case 'brain_doctor': {
|
|
4735
|
-
const { instance_id
|
|
3336
|
+
const { instance_id } = args as { instance_id: string };
|
|
4736
3337
|
const redis = await getConnection(instance_id);
|
|
4737
3338
|
const issues: string[] = [];
|
|
4738
3339
|
const checks: string[] = [];
|
|
@@ -4758,15 +3359,12 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
4758
3359
|
});
|
|
4759
3360
|
|
|
4760
3361
|
// Load lessons for analysis
|
|
4761
|
-
type
|
|
4762
|
-
|
|
4763
|
-
verified_at?: string; severity?: string; audit_trail?: unknown[];
|
|
4764
|
-
};
|
|
4765
|
-
const lessons: DrLesson[] = [];
|
|
3362
|
+
type Lesson = { topic: string; outcome: string; recall_count?: number; ts: string; severity?: string };
|
|
3363
|
+
const lessons: Lesson[] = [];
|
|
4766
3364
|
for (const k of lessonKeys) {
|
|
4767
3365
|
const raw = await redis.get(k);
|
|
4768
3366
|
if (!raw) continue;
|
|
4769
|
-
try { lessons.push(JSON.parse(raw) as
|
|
3367
|
+
try { lessons.push(JSON.parse(raw) as Lesson); } catch { /* skip */ }
|
|
4770
3368
|
}
|
|
4771
3369
|
|
|
4772
3370
|
// Last session
|
|
@@ -4782,17 +3380,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
4782
3380
|
const unusedLessons = lessons.filter(l => (l.recall_count ?? 0) === 0);
|
|
4783
3381
|
// Critical lessons
|
|
4784
3382
|
const criticalLessons = lessons.filter(l => l.severity === 'critical');
|
|
4785
|
-
// Confidence decay analysis
|
|
4786
|
-
const staleLessons = lessons.filter(l => l.outcome === 'success' && calculateConfidence(l) < CONFIDENCE_STALE_VALUE);
|
|
4787
|
-
const warnLessons = lessons.filter(l => l.outcome === 'success' && calculateConfidence(l) >= CONFIDENCE_STALE_VALUE && calculateConfidence(l) < CONFIDENCE_WARN_VALUE);
|
|
4788
|
-
const withAudit = lessons.filter(l => (l.audit_trail ?? []).length > 1);
|
|
4789
|
-
// Team lessons
|
|
4790
|
-
type DrLessonWithAuthor = DrLesson & { author?: string };
|
|
4791
|
-
const teamLessons = (lessons as DrLessonWithAuthor[]).filter(l => l.author);
|
|
4792
|
-
const uniqueAuthors = new Set((lessons as DrLessonWithAuthor[]).map(l => l.author).filter(Boolean));
|
|
4793
|
-
// Effective IQ boost: total recalls / lessons (how much the brain actually helped)
|
|
4794
|
-
const totalRecalls = lessons.reduce((sum, l) => sum + (l.recall_count ?? 0), 0);
|
|
4795
|
-
const iqBoostPct = lessons.length > 0 ? Math.min(100, Math.round((totalRecalls / lessons.length) * 10)) : 0;
|
|
4796
3383
|
|
|
4797
3384
|
// Quality score (0-100)
|
|
4798
3385
|
let score = 50;
|
|
@@ -4804,67 +3391,12 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
4804
3391
|
if (openFailures.length === 0) score += 5;
|
|
4805
3392
|
const unusedRatio = lessons.length > 0 ? unusedLessons.length / lessons.length : 0;
|
|
4806
3393
|
if (unusedRatio < 0.5) score += 10;
|
|
4807
|
-
if (staleLessons.length === 0) score += 5;
|
|
4808
|
-
if (uniqueAuthors.size >= 2) score += 5; // team collaboration bonus
|
|
4809
3394
|
|
|
4810
3395
|
const scoreEmoji = score >= 80 ? '🟢' : score >= 50 ? '🟡' : '🔴';
|
|
4811
|
-
const iqEmoji = iqBoostPct >= 50 ? '🚀' : iqBoostPct >= 20 ? '📈' : '💤';
|
|
4812
3396
|
|
|
4813
3397
|
checks.push(`${scoreEmoji} **Brain Quality Score: ${score}/100**`);
|
|
4814
|
-
checks.push(
|
|
4815
|
-
checks.push(`📚 **Lessons:** ${lessonKeys.length} (${criticalLessons.length} critical · ${withAudit.length} with audit trail · ${teamLessons.length} from team)`);
|
|
3398
|
+
checks.push(`📚 **Lessons:** ${lessonKeys.length} (${criticalLessons.length} critical)`);
|
|
4816
3399
|
checks.push(`💾 **Context entries:** ${ctxCount}`);
|
|
4817
|
-
checks.push(`🎯 **Confidence:** ${lessons.length - staleLessons.length - warnLessons.length} fresh · ${warnLessons.length} warn · ${staleLessons.length} stale`);
|
|
4818
|
-
checks.push(`⏱️ **Decay config:** warn after ${CONFIDENCE_WARN_DAYS}d · stale after ${CONFIDENCE_STALE_DAYS}d`);
|
|
4819
|
-
if (uniqueAuthors.size >= 2) {
|
|
4820
|
-
checks.push(`👥 **Team:** ${uniqueAuthors.size} contributors (${[...uniqueAuthors].join(', ')})`);
|
|
4821
|
-
}
|
|
4822
|
-
|
|
4823
|
-
// Stale index detection
|
|
4824
|
-
try {
|
|
4825
|
-
const lastIndexRaw = await redis.get('cachly:index:last_run');
|
|
4826
|
-
if (lastIndexRaw) {
|
|
4827
|
-
const lastIndexAge = Math.round((Date.now() - new Date(lastIndexRaw).getTime()) / 86_400_000);
|
|
4828
|
-
if (lastIndexAge > 7) {
|
|
4829
|
-
issues.push(`🔄 Index is ${lastIndexAge}d stale — run \`index_project\` to re-sync semantic search`);
|
|
4830
|
-
} else {
|
|
4831
|
-
checks.push(`🗂️ **Semantic index:** ${lastIndexAge}d old (fresh)`);
|
|
4832
|
-
}
|
|
4833
|
-
} else {
|
|
4834
|
-
issues.push(`💡 No semantic index — run \`index_project(dir="<your-src>")\` to enable semantic search`);
|
|
4835
|
-
}
|
|
4836
|
-
} catch { /* non-critical */ }
|
|
4837
|
-
|
|
4838
|
-
// Memory crystal status
|
|
4839
|
-
try {
|
|
4840
|
-
const crystalRaw = await redis.get('cachly:crystal:latest');
|
|
4841
|
-
if (crystalRaw) {
|
|
4842
|
-
const crystal = JSON.parse(crystalRaw) as { ts: string; label: string };
|
|
4843
|
-
const crystalAge = Math.round((Date.now() - new Date(crystal.ts).getTime()) / 86_400_000);
|
|
4844
|
-
checks.push(`💎 **Memory Crystal:** ${crystal.label} (${crystalAge}d ago)`);
|
|
4845
|
-
if (crystalAge > 30) issues.push(`💡 Memory Crystal is ${crystalAge}d old — re-run \`memory_crystalize\` to compress new sessions`);
|
|
4846
|
-
} else if (lessonKeys.length >= 10) {
|
|
4847
|
-
issues.push(`💡 ${lessonKeys.length} lessons but no Memory Crystal — run \`memory_crystalize\` to compress wisdom`);
|
|
4848
|
-
}
|
|
4849
|
-
} catch { /* non-critical */ }
|
|
4850
|
-
|
|
4851
|
-
// openclaw cross-promo (check package.json in workspace)
|
|
4852
|
-
if (drWorkspacePath) {
|
|
4853
|
-
try {
|
|
4854
|
-
const pkgPath = drWorkspacePath.replace(/\/$/, '') + '/package.json';
|
|
4855
|
-
const pkgRaw = readFileSync(pkgPath, 'utf-8');
|
|
4856
|
-
const pkg = JSON.parse(pkgRaw) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
|
|
4857
|
-
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
4858
|
-
const hasLLMDep = ['openai', '@anthropic-ai/sdk', '@google/generative-ai', 'mistralai', 'cohere-ai'].some(d => d in allDeps);
|
|
4859
|
-
const hasOpenclaw = '@cachly-dev/openclaw' in allDeps;
|
|
4860
|
-
if (hasLLMDep && !hasOpenclaw) {
|
|
4861
|
-
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\``);
|
|
4862
|
-
issues.push(` → \`npm install @cachly-dev/openclaw\` cuts LLM costs 60–90% with 3 lines of code`);
|
|
4863
|
-
} else if (hasOpenclaw) {
|
|
4864
|
-
checks.push(`✅ **@cachly-dev/openclaw installed** (LLM cost caching active)`);
|
|
4865
|
-
}
|
|
4866
|
-
} catch { /* no package.json or unreadable */ }
|
|
4867
|
-
}
|
|
4868
3400
|
|
|
4869
3401
|
if (lastSession) {
|
|
4870
3402
|
const ageMin = Math.round((Date.now() - new Date(lastSession.ts).getTime()) / 60000);
|
|
@@ -4880,10 +3412,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
4880
3412
|
issues.push(`💡 Only ${lessonKeys.length} lessons — add more after each problem solved`);
|
|
4881
3413
|
}
|
|
4882
3414
|
|
|
4883
|
-
if (iqBoostPct === 0 && lessons.length >= 5) {
|
|
4884
|
-
issues.push(`💤 **IQ Boost is 0%** — lessons exist but are never recalled. Use \`recall_best_solution\` BEFORE tasks.`);
|
|
4885
|
-
}
|
|
4886
|
-
|
|
4887
3415
|
if (ctxCount === 0) {
|
|
4888
3416
|
issues.push('💡 No context — use `remember_context` to cache architecture docs, ADRs, etc.');
|
|
4889
3417
|
}
|
|
@@ -4892,15 +3420,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
4892
3420
|
issues.push(`⚠️ ${openFailures.length} unresolved failure${openFailures.length > 1 ? 's' : ''}: ${openFailures.slice(0, 3).map(l => `\`${l.topic}\``).join(', ')}`);
|
|
4893
3421
|
}
|
|
4894
3422
|
|
|
4895
|
-
if (staleLessons.length > 0) {
|
|
4896
|
-
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(', ')}`);
|
|
4897
|
-
issues.push(` → Re-verify with \`recall_best_solution\` to reset confidence clock`);
|
|
4898
|
-
}
|
|
4899
|
-
|
|
4900
|
-
if (warnLessons.length > 0) {
|
|
4901
|
-
issues.push(`⚠️ ${warnLessons.length} lesson${warnLessons.length > 1 ? 's' : ''} aging (>${CONFIDENCE_WARN_DAYS}d): ${warnLessons.slice(0, 3).map(l => `\`${l.topic}\``).join(', ')}`);
|
|
4902
|
-
}
|
|
4903
|
-
|
|
4904
3423
|
if (unusedRatio > 0.7 && lessons.length > 5) {
|
|
4905
3424
|
issues.push(`💡 ${unusedLessons.length} lessons never recalled — verify topics match your workflow`);
|
|
4906
3425
|
}
|
|
@@ -4916,77 +3435,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
4916
3435
|
return lines.join('\n');
|
|
4917
3436
|
}
|
|
4918
3437
|
|
|
4919
|
-
// ── recall_at — Brain Archaeology ────────────────────────────────────────
|
|
4920
|
-
case 'recall_at': {
|
|
4921
|
-
const { instance_id, topic, date } = args as { instance_id: string; topic: string; date: string };
|
|
4922
|
-
const redis = await getConnection(instance_id);
|
|
4923
|
-
const cutoff = new Date(date).getTime();
|
|
4924
|
-
if (isNaN(cutoff)) return `❌ Invalid date "${date}". Use ISO format: "2026-01-15"`;
|
|
4925
|
-
|
|
4926
|
-
const listKey = `cachly:lessons:${topic}`;
|
|
4927
|
-
const all = await redis.lrange(listKey, 0, -1);
|
|
4928
|
-
if (all.length === 0) return `📭 No history found for \`${topic}\`. Lessons are stored via \`learn_from_attempts\`.`;
|
|
4929
|
-
|
|
4930
|
-
const before = all
|
|
4931
|
-
.map(raw => { try { return JSON.parse(raw) as { ts: string; outcome?: string; what_worked?: string; what_failed?: string }; } catch { return null; } })
|
|
4932
|
-
.filter((l): l is NonNullable<typeof l> => l !== null && new Date(l.ts).getTime() <= cutoff)
|
|
4933
|
-
.sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
|
|
4934
|
-
|
|
4935
|
-
if (before.length === 0) return `📭 No entries for \`${topic}\` found before **${date}**. Earliest entry: ${new Date(JSON.parse(all[0]).ts).toLocaleDateString('de-DE')}.`;
|
|
4936
|
-
|
|
4937
|
-
const lines = [
|
|
4938
|
-
`🏺 **Brain Archaeology: \`${topic}\` before ${date}**`,
|
|
4939
|
-
`_${before.length} of ${all.length} total entries shown_`,
|
|
4940
|
-
'',
|
|
4941
|
-
];
|
|
4942
|
-
for (const l of before.slice(-10)) {
|
|
4943
|
-
const emoji = l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
|
|
4944
|
-
const d = new Date(l.ts).toLocaleDateString('de-DE');
|
|
4945
|
-
lines.push(`**${d}** ${emoji} ${l.outcome}`);
|
|
4946
|
-
if (l.what_worked) lines.push(` → ${l.what_worked.slice(0, 100)}`);
|
|
4947
|
-
lines.push('');
|
|
4948
|
-
}
|
|
4949
|
-
lines.push(`_Full evolution: ${all.map(r => { try { const l = JSON.parse(r) as { outcome?: string }; return l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌'; } catch { return '?'; } }).join(' → ')}_`);
|
|
4950
|
-
return lines.join('\n');
|
|
4951
|
-
}
|
|
4952
|
-
|
|
4953
|
-
// ── trace_dependency — Causal Chain ──────────────────────────────────────
|
|
4954
|
-
case 'trace_dependency': {
|
|
4955
|
-
const { instance_id, dependency, mark_review = false } = args as { instance_id: string; dependency: string; mark_review?: boolean };
|
|
4956
|
-
const redis = await getConnection(instance_id);
|
|
4957
|
-
|
|
4958
|
-
const depKey = `cachly:dep:${dependency}`;
|
|
4959
|
-
const raw = await redis.get(depKey);
|
|
4960
|
-
if (!raw) return `📭 No lessons found that depend on \`${dependency}\`.\n\nAdd dependencies via: \`learn_from_attempts(..., depends_on=["${dependency}"])\``;
|
|
4961
|
-
|
|
4962
|
-
const topics: string[] = JSON.parse(raw);
|
|
4963
|
-
const lines = [
|
|
4964
|
-
`🔗 **Causal Chain: \`${dependency}\`** — ${topics.length} dependent lesson${topics.length === 1 ? '' : 's'}`,
|
|
4965
|
-
'',
|
|
4966
|
-
];
|
|
4967
|
-
|
|
4968
|
-
for (const t of topics) {
|
|
4969
|
-
const lessonRaw = await redis.get(`cachly:lesson:best:${t}`);
|
|
4970
|
-
if (!lessonRaw) { lines.push(` • \`${t}\` _(lesson deleted)_`); continue; }
|
|
4971
|
-
const lesson = JSON.parse(lessonRaw) as { outcome?: string; severity?: string; needs_review?: boolean };
|
|
4972
|
-
const emoji = lesson.outcome === 'success' ? '✅' : lesson.outcome === 'partial' ? '⚠️' : '❌';
|
|
4973
|
-
const reviewBadge = lesson.needs_review ? ' 🔍 **needs_review**' : '';
|
|
4974
|
-
lines.push(` ${emoji} \`${t}\` (${lesson.severity ?? 'major'})${reviewBadge}`);
|
|
4975
|
-
|
|
4976
|
-
if (mark_review) {
|
|
4977
|
-
const updated = { ...lesson, needs_review: true };
|
|
4978
|
-
await redis.set(`cachly:lesson:best:${t}`, JSON.stringify(updated));
|
|
4979
|
-
}
|
|
4980
|
-
}
|
|
4981
|
-
|
|
4982
|
-
if (mark_review) {
|
|
4983
|
-
lines.push('', `🔍 All ${topics.length} lessons marked as **needs_review** — verify they still work with the changed dependency.`);
|
|
4984
|
-
} else {
|
|
4985
|
-
lines.push('', `_Run with \`mark_review: true\` to flag all dependent lessons for re-verification._`);
|
|
4986
|
-
}
|
|
4987
|
-
return lines.join('\n');
|
|
4988
|
-
}
|
|
4989
|
-
|
|
4990
3438
|
// ── global_learn ──────────────────────────────────────────────────────────
|
|
4991
3439
|
case 'global_learn': {
|
|
4992
3440
|
const { instance_id, topic, lesson, severity = 'minor', tags = [] } = args as {
|
|
@@ -5268,148 +3716,65 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
5268
3716
|
// Generate copilot-instructions.md content
|
|
5269
3717
|
const tier = inst.tier.toUpperCase();
|
|
5270
3718
|
const smartRecallNote = hasVector
|
|
5271
|
-
? '
|
|
5272
|
-
: '
|
|
3719
|
+
? '3. Call `smart_recall` with keywords for semantic context search'
|
|
3720
|
+
: '3. Call `recall_context` with relevant key patterns like "arch:*" or "file:*"';
|
|
5273
3721
|
const layerNote = hasVector
|
|
5274
|
-
? `Layer 3 (Semantic): smart_recall uses pgvector HNSW on your ${tier} instance`
|
|
5275
|
-
: `Layer 3 (Autopilot):
|
|
3722
|
+
? `Layer 3 (Semantic): smart_recall uses pgvector HNSW on your ${tier} instance for meaning-based search`
|
|
3723
|
+
: `Layer 3 (Autopilot): This file — upgrade to Speed/Business tier to unlock smart_recall`;
|
|
5276
3724
|
|
|
5277
|
-
const copilotInstructions = `# cachly AI
|
|
3725
|
+
const copilotInstructions = `# cachly AI Memory — ${project_description}
|
|
5278
3726
|
|
|
5279
|
-
|
|
3727
|
+
## 3-Layer AI Memory System
|
|
5280
3728
|
|
|
5281
|
-
|
|
3729
|
+
- **Layer 1 — Storage:** cachly instance \`${inst.name}\` (${tier}) — persistent Valkey brain, survives restarts
|
|
3730
|
+
- **Layer 2 — Tools:** learn_from_attempts · recall_best_solution · smart_recall — the memory API
|
|
3731
|
+
- **Layer 3 — Autopilot (this file):** instructs the AI to run memory tools automatically
|
|
5282
3732
|
|
|
5283
|
-
##
|
|
3733
|
+
## BEFORE every task — check memory first
|
|
5284
3734
|
|
|
5285
|
-
|
|
5286
|
-
|
|
3735
|
+
1. Call \`recall_best_solution\` with the task topic to get known solutions
|
|
3736
|
+
- \`recall_best_solution("deploy:web")\` → returns working deploy command
|
|
3737
|
+
- \`recall_best_solution("debug:redis-tls")\` → returns root cause + fix
|
|
3738
|
+
- If a solution exists, use it — do NOT re-discover from scratch
|
|
5287
3739
|
|
|
5288
|
-
|
|
5289
|
-
|
|
5290
|
-
|
|
5291
|
-
focus = "<what you plan to work on today>",
|
|
5292
|
-
)
|
|
5293
|
-
\`\`\`
|
|
3740
|
+
2. Call \`recall_context\` with the area you're working in
|
|
3741
|
+
- \`recall_context("arch:*")\` → cached architecture decisions
|
|
3742
|
+
- \`recall_context("file:src/auth*")\` → cached file summaries
|
|
5294
3743
|
|
|
5295
|
-
|
|
3744
|
+
${smartRecallNote}
|
|
3745
|
+
- \`smart_recall("how does authentication work")\` → returns cached auth notes
|
|
5296
3746
|
|
|
5297
|
-
|
|
5298
|
-
1. \`smart_recall("recent lessons <topic>")\`
|
|
5299
|
-
2. \`recall_best_solution("<topic>")\`
|
|
3747
|
+
4. If nothing is found, proceed normally — then save (see below)
|
|
5300
3748
|
|
|
5301
|
-
|
|
5302
|
-
After **every** bug fix, deployment, failed attempt, or discovery — call \`learn_from_attempts\` immediately:
|
|
3749
|
+
## AFTER every non-trivial task — save to memory
|
|
5303
3750
|
|
|
5304
|
-
|
|
5305
|
-
|
|
5306
|
-
|
|
5307
|
-
|
|
5308
|
-
outcome = "success" | "failure" | "partial",
|
|
5309
|
-
what_worked = "exact approach or command that worked",
|
|
5310
|
-
what_failed = "what did NOT work (prevents future wasted time)",
|
|
5311
|
-
severity = "critical" | "major" | "minor",
|
|
5312
|
-
commands = ["exact command that worked"],
|
|
5313
|
-
file_paths = ["path/to/changed/file"],
|
|
5314
|
-
)
|
|
5315
|
-
\`\`\`
|
|
3751
|
+
1. Call \`learn_from_attempts\` for deploy/debug/fix tasks:
|
|
3752
|
+
- Always set outcome = "success" | "failure" | "partial"
|
|
3753
|
+
- Always include what_failed — it prevents future wasted time
|
|
3754
|
+
- 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")\`
|
|
5316
3755
|
|
|
5317
|
-
|
|
5318
|
-
-
|
|
5319
|
-
-
|
|
5320
|
-
- ✅ Discovered root cause → \`topic="debug:<area>", outcome="partial"\`
|
|
5321
|
-
- ✅ Tried something that failed → \`topic="<area>", outcome="failure", what_failed="..."\`
|
|
5322
|
-
- ✅ Found a config quirk → \`topic="infra:<topic>", outcome="success"\`
|
|
5323
|
-
- ✅ Ran a working command → store it in \`commands=[...]\`
|
|
5324
|
-
|
|
5325
|
-
### Rule 3 — Session Handoff (when closing window or task list > 3 items)
|
|
5326
|
-
\`\`\`
|
|
5327
|
-
session_handoff(
|
|
5328
|
-
instance_id = "${instance_id}",
|
|
5329
|
-
completed_tasks = ["what was done"],
|
|
5330
|
-
remaining_tasks = ["what is still to do"],
|
|
5331
|
-
files_changed = [{"path": "src/...", "status": "modified", "description": "what changed"}],
|
|
5332
|
-
instructions = "IMPORTANT: what the next window MUST know to not break things",
|
|
5333
|
-
context_summary = "current state of the system",
|
|
5334
|
-
)
|
|
5335
|
-
\`\`\`
|
|
5336
|
-
|
|
5337
|
-
The next \`session_start\` automatically includes this handoff. The new window sees exactly what's done, what remains, and what NOT to break.
|
|
5338
|
-
|
|
5339
|
-
### Rule 4 — Session End (LAST thing before closing)
|
|
5340
|
-
\`\`\`
|
|
5341
|
-
session_end(
|
|
5342
|
-
instance_id = "${instance_id}",
|
|
5343
|
-
summary = "What was accomplished (2-3 sentences with specifics)",
|
|
5344
|
-
files_changed = ["list", "of", "changed", "files"],
|
|
5345
|
-
lessons_learned = <number of learn_from_attempts calls made>,
|
|
5346
|
-
)
|
|
5347
|
-
\`\`\`
|
|
5348
|
-
|
|
5349
|
-
> \`session_end\` **automatically extracts and stores lessons** from the summary text — no manual calls needed.
|
|
5350
|
-
|
|
5351
|
-
### Rule 5 — Code Index Invalidation (AUTOMATIC)
|
|
5352
|
-
- \`index_project\` tracks MD5 hashes per file — **skips unchanged files automatically**
|
|
5353
|
-
- After code changes: next \`index_project\` run re-indexes only modified files
|
|
5354
|
-
- After delete/rename: run \`index_project\` again to sync
|
|
5355
|
-
- No manual invalidation needed
|
|
5356
|
-
|
|
5357
|
-
---
|
|
5358
|
-
|
|
5359
|
-
## Task-type trigger table
|
|
5360
|
-
|
|
5361
|
-
| You're about to... | Call BEFORE | Call AFTER |
|
|
5362
|
-
|---|---|---|
|
|
5363
|
-
| Deploy anything | \`recall_best_solution("deploy:<component>")\` | \`learn_from_attempts(topic="deploy:...")\` |
|
|
5364
|
-
| Fix a bug | \`recall_best_solution("fix:<area>")\` | \`learn_from_attempts(topic="fix:...")\` |
|
|
5365
|
-
| Add a feature | \`session_start(focus="feat:<area>")\` | \`learn_from_attempts(topic="feat:...")\` |
|
|
5366
|
-
| Infra/server work | \`recall_best_solution("infra:<topic>")\` | \`learn_from_attempts(topic="infra:...")\` |
|
|
5367
|
-
| Debug an issue | \`smart_recall("<error message or symptom>")\` | \`learn_from_attempts(topic="debug:...")\` |
|
|
5368
|
-
|
|
5369
|
-
---
|
|
5370
|
-
|
|
5371
|
-
## Available Brain Tools
|
|
5372
|
-
|
|
5373
|
-
| Tool | When to use |
|
|
5374
|
-
|------|-------------|
|
|
5375
|
-
| \`session_start\` | **FIRST** — mandatory at start of every session |
|
|
5376
|
-
| \`session_end\` | **LAST** — mandatory at end, auto-learns from summary |
|
|
5377
|
-
| \`session_handoff\` | When closing window with remaining tasks |
|
|
5378
|
-
| \`learn_from_attempts\` | **AUTOMATIC** after every fix/deploy/discovery |
|
|
5379
|
-
| \`recall_best_solution\` | Before any non-trivial task |
|
|
5380
|
-
| \`remember_context\` | After analyzing code — save findings for future sessions |
|
|
5381
|
-
${smartRecallNote ? `| \`smart_recall\` | Search brain by meaning/keywords |\n` : ''}\
|
|
5382
|
-
| \`recall_context\` | Get exact key (supports glob: \`arch:*\`, \`file:*\`) |
|
|
5383
|
-
| \`brain_search\` | BM25+ full-text search over all brain data |
|
|
5384
|
-
| \`auto_learn_session\` | Batch-learn from a list of observations (optional) |
|
|
5385
|
-
| \`index_project\` | Index source files (smart hash, skips unchanged files) |
|
|
5386
|
-
| \`list_remembered\` | See what's cached in the brain |
|
|
5387
|
-
| \`forget_context\` | Remove stale context |
|
|
5388
|
-
|
|
5389
|
-
---
|
|
3756
|
+
2. Call \`remember_context\` for code/architecture findings:
|
|
3757
|
+
- \`remember_context("arch:auth", "Uses Keycloak with JWT...", category="architecture")\`
|
|
3758
|
+
- \`remember_context("file:src/index.ts", "Entry point, registers routes...", category="file_summary")\`
|
|
5390
3759
|
|
|
5391
3760
|
## Instance Details
|
|
5392
3761
|
|
|
5393
|
-
-
|
|
5394
|
-
-
|
|
5395
|
-
-
|
|
5396
|
-
-
|
|
5397
|
-
-
|
|
3762
|
+
- Instance ID: \`${instance_id}\`
|
|
3763
|
+
- Instance name: ${inst.name}
|
|
3764
|
+
- Tier: ${tier}
|
|
3765
|
+
- ${layerNote}
|
|
3766
|
+
- Embedding provider: ${providerArg}
|
|
5398
3767
|
|
|
5399
|
-
|
|
5400
|
-
|
|
5401
|
-
## How the 3-layer system works
|
|
3768
|
+
## Quick reference
|
|
5402
3769
|
|
|
5403
3770
|
\`\`\`
|
|
5404
|
-
|
|
5405
|
-
|
|
5406
|
-
|
|
3771
|
+
recall_best_solution("topic") # before task
|
|
3772
|
+
smart_recall("natural language") # find by meaning
|
|
3773
|
+
learn_from_attempts(...) # after task
|
|
3774
|
+
remember_context("key", "content") # save analysis
|
|
5407
3775
|
\`\`\`
|
|
5408
|
-
|
|
5409
|
-
Result: Your AI **never solves the same problem twice** and always picks up exactly where it left off. 🚀
|
|
5410
3776
|
`;
|
|
5411
3777
|
|
|
5412
|
-
|
|
5413
3778
|
const lines: string[] = [
|
|
5414
3779
|
`🧠 **cachly AI Memory Setup Complete**`,
|
|
5415
3780
|
``,
|
|
@@ -5433,8 +3798,8 @@ Result: Your AI **never solves the same problem twice** and always picks up exac
|
|
|
5433
3798
|
`**Step 3 — copilot-instructions.md (Layer 3 Autopilot):**`,
|
|
5434
3799
|
``,
|
|
5435
3800
|
...(project_dir
|
|
5436
|
-
? [
|
|
5437
|
-
: [`Copy this to \`.github/copilot-instructions.md\`
|
|
3801
|
+
? [`Writing to \`${project_dir}/.github/copilot-instructions.md\`…`]
|
|
3802
|
+
: [`Copy this to \`.github/copilot-instructions.md\` in your project:`]),
|
|
5438
3803
|
``,
|
|
5439
3804
|
`\`\`\`markdown`,
|
|
5440
3805
|
copilotInstructions,
|
|
@@ -5449,71 +3814,16 @@ Result: Your AI **never solves the same problem twice** and always picks up exac
|
|
|
5449
3814
|
`Result: Your AI never solves the same problem twice. 🚀`,
|
|
5450
3815
|
];
|
|
5451
3816
|
|
|
5452
|
-
//
|
|
3817
|
+
// Optionally write the file
|
|
5453
3818
|
if (project_dir) {
|
|
5454
|
-
const { mkdir, writeFile
|
|
5455
|
-
const
|
|
5456
|
-
|
|
5457
|
-
const
|
|
5458
|
-
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
|
|
5462
|
-
|
|
5463
|
-
// GitHub Copilot — always write (universal fallback)
|
|
5464
|
-
targets.push({
|
|
5465
|
-
ide: 'GitHub Copilot',
|
|
5466
|
-
path: join(project_dir, '.github', 'copilot-instructions.md'),
|
|
5467
|
-
content: copilotInstructions,
|
|
5468
|
-
});
|
|
5469
|
-
|
|
5470
|
-
// Claude Code — CLAUDE.md in project root
|
|
5471
|
-
if (await exists(join(project_dir, 'CLAUDE.md')) || await exists(join(project_dir, '.claude'))) {
|
|
5472
|
-
targets.push({
|
|
5473
|
-
ide: 'Claude Code',
|
|
5474
|
-
path: join(project_dir, 'CLAUDE.md'),
|
|
5475
|
-
content: copilotInstructions,
|
|
5476
|
-
});
|
|
5477
|
-
}
|
|
5478
|
-
|
|
5479
|
-
// Cursor — .cursor/rules (new format) or .cursorrules (legacy)
|
|
5480
|
-
if (
|
|
5481
|
-
await exists(join(project_dir, '.cursor')) ||
|
|
5482
|
-
await exists(join(project_dir, '.cursorrules'))
|
|
5483
|
-
) {
|
|
5484
|
-
const cursorDir = join(project_dir, '.cursor');
|
|
5485
|
-
await mkdir(cursorDir, { recursive: true });
|
|
5486
|
-
targets.push({
|
|
5487
|
-
ide: 'Cursor',
|
|
5488
|
-
path: join(cursorDir, 'rules'),
|
|
5489
|
-
content: copilotInstructions,
|
|
5490
|
-
});
|
|
5491
|
-
}
|
|
5492
|
-
|
|
5493
|
-
// Windsurf — .windsurfrules
|
|
5494
|
-
if (
|
|
5495
|
-
await exists(join(project_dir, '.windsurfrules')) ||
|
|
5496
|
-
await exists(join(project_dir, '.windsurf'))
|
|
5497
|
-
) {
|
|
5498
|
-
targets.push({
|
|
5499
|
-
ide: 'Windsurf',
|
|
5500
|
-
path: join(project_dir, '.windsurfrules'),
|
|
5501
|
-
content: copilotInstructions,
|
|
5502
|
-
});
|
|
5503
|
-
}
|
|
5504
|
-
|
|
5505
|
-
// VS Code (Copilot) — already covered by .github/copilot-instructions.md above
|
|
5506
|
-
// Continue.dev — .continue/config.json is JSON, not markdown — skip, copilot-instructions handles it
|
|
5507
|
-
|
|
5508
|
-
const written: string[] = [];
|
|
5509
|
-
for (const target of targets) {
|
|
5510
|
-
const dir = target.path.substring(0, target.path.lastIndexOf('/'));
|
|
5511
|
-
await mkdir(dir, { recursive: true });
|
|
5512
|
-
await writeFile(target.path, target.content, 'utf-8');
|
|
5513
|
-
written.push(`✅ [${target.ide}] → \`${target.path.replace(project_dir, '.')}\``);
|
|
5514
|
-
}
|
|
5515
|
-
|
|
5516
|
-
lines.push(...written);
|
|
3819
|
+
const { mkdir, writeFile } = await import('node:fs/promises');
|
|
3820
|
+
const githubDir = join(project_dir, '.github');
|
|
3821
|
+
await mkdir(githubDir, { recursive: true });
|
|
3822
|
+
const filePath = join(githubDir, 'copilot-instructions.md');
|
|
3823
|
+
await writeFile(filePath, copilotInstructions, 'utf-8');
|
|
3824
|
+
lines.splice(lines.indexOf(`Writing to \`${project_dir}/.github/copilot-instructions.md\`…`) + 1, 0,
|
|
3825
|
+
`✅ Written to \`${project_dir}/.github/copilot-instructions.md\``
|
|
3826
|
+
);
|
|
5517
3827
|
}
|
|
5518
3828
|
|
|
5519
3829
|
return lines.join('\n');
|
|
@@ -5641,178 +3951,6 @@ async function handleBulkLockStream(name: string, args: Record<string, unknown>)
|
|
|
5641
3951
|
);
|
|
5642
3952
|
}
|
|
5643
3953
|
|
|
5644
|
-
// ── Roadmap ──────────────────────────────────────────────────────────────
|
|
5645
|
-
|
|
5646
|
-
case 'roadmap_add': {
|
|
5647
|
-
const {
|
|
5648
|
-
instance_id: rid,
|
|
5649
|
-
title,
|
|
5650
|
-
description: desc = '',
|
|
5651
|
-
priority = 'medium',
|
|
5652
|
-
tags: rtags = [],
|
|
5653
|
-
milestone = '',
|
|
5654
|
-
} = args as {
|
|
5655
|
-
instance_id: string; title: string; description?: string;
|
|
5656
|
-
priority?: string; tags?: string[]; milestone?: string;
|
|
5657
|
-
};
|
|
5658
|
-
const redis = await getConnection(rid);
|
|
5659
|
-
const id = `rm_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
5660
|
-
const item = {
|
|
5661
|
-
id, title, description: desc, priority, tags: rtags, milestone,
|
|
5662
|
-
status: 'planned',
|
|
5663
|
-
created: new Date().toISOString(),
|
|
5664
|
-
updated: new Date().toISOString(),
|
|
5665
|
-
notes: '',
|
|
5666
|
-
};
|
|
5667
|
-
await redis.hset(`cachly:roadmap:${rid}`, id, JSON.stringify(item));
|
|
5668
|
-
const PRIORITY_ICON: Record<string, string> = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
|
|
5669
|
-
return [
|
|
5670
|
-
`📋 **Roadmap item added**`,
|
|
5671
|
-
``,
|
|
5672
|
-
` ID: \`${id}\``,
|
|
5673
|
-
` Title: ${title}`,
|
|
5674
|
-
` Priority: ${PRIORITY_ICON[priority] ?? '⚪'} ${priority}`,
|
|
5675
|
-
` Status: planned`,
|
|
5676
|
-
milestone ? ` Milestone: ${milestone}` : '',
|
|
5677
|
-
rtags.length ? ` Tags: ${rtags.join(', ')}` : '',
|
|
5678
|
-
``,
|
|
5679
|
-
`💡 Use \`roadmap_update(id: "${id}", status: "in-progress")\` when you start working on it.`,
|
|
5680
|
-
].filter(Boolean).join('\n');
|
|
5681
|
-
}
|
|
5682
|
-
|
|
5683
|
-
case 'roadmap_update': {
|
|
5684
|
-
const {
|
|
5685
|
-
instance_id: rid,
|
|
5686
|
-
id: itemId,
|
|
5687
|
-
status: newStatus,
|
|
5688
|
-
priority: newPriority,
|
|
5689
|
-
notes: newNotes,
|
|
5690
|
-
title: newTitle,
|
|
5691
|
-
description: newDesc,
|
|
5692
|
-
} = args as {
|
|
5693
|
-
instance_id: string; id: string; status?: string; priority?: string;
|
|
5694
|
-
notes?: string; title?: string; description?: string;
|
|
5695
|
-
};
|
|
5696
|
-
const redis = await getConnection(rid);
|
|
5697
|
-
const raw = await redis.hget(`cachly:roadmap:${rid}`, itemId);
|
|
5698
|
-
if (!raw) return `⚠️ **roadmap_update** – Item \`${itemId}\` not found. Use \`roadmap_list\` to see all items.`;
|
|
5699
|
-
const item = JSON.parse(raw) as Record<string, unknown>;
|
|
5700
|
-
const oldStatus = item.status as string;
|
|
5701
|
-
if (newStatus) item.status = newStatus;
|
|
5702
|
-
if (newPriority) item.priority = newPriority;
|
|
5703
|
-
if (newTitle) item.title = newTitle;
|
|
5704
|
-
if (newDesc) item.description = newDesc;
|
|
5705
|
-
if (newNotes) item.notes = item.notes ? `${item.notes}\n[${new Date().toISOString().slice(0, 10)}] ${newNotes}` : `[${new Date().toISOString().slice(0, 10)}] ${newNotes}`;
|
|
5706
|
-
item.updated = new Date().toISOString();
|
|
5707
|
-
await redis.hset(`cachly:roadmap:${rid}`, itemId, JSON.stringify(item));
|
|
5708
|
-
const statusEmoji: Record<string, string> = { planned: '📋', 'in-progress': '⚡', done: '✅', blocked: '🚫', cancelled: '🗑️' };
|
|
5709
|
-
return [
|
|
5710
|
-
`${statusEmoji[newStatus ?? oldStatus] ?? '📋'} **Roadmap updated** \`${itemId}\``,
|
|
5711
|
-
``,
|
|
5712
|
-
` Title: ${item.title}`,
|
|
5713
|
-
oldStatus !== newStatus ? ` Status: ${oldStatus} → ${newStatus}` : ` Status: ${item.status}`,
|
|
5714
|
-
newNotes ? ` Notes: ${newNotes}` : '',
|
|
5715
|
-
].filter(Boolean).join('\n');
|
|
5716
|
-
}
|
|
5717
|
-
|
|
5718
|
-
case 'roadmap_list': {
|
|
5719
|
-
const {
|
|
5720
|
-
instance_id: rid,
|
|
5721
|
-
status: filterStatus = 'open',
|
|
5722
|
-
tag: filterTag,
|
|
5723
|
-
milestone: filterMilestone,
|
|
5724
|
-
priority: filterPriority,
|
|
5725
|
-
} = args as {
|
|
5726
|
-
instance_id: string; status?: string; tag?: string;
|
|
5727
|
-
milestone?: string; priority?: string;
|
|
5728
|
-
};
|
|
5729
|
-
const redis = await getConnection(rid);
|
|
5730
|
-
const all = await redis.hgetall(`cachly:roadmap:${rid}`);
|
|
5731
|
-
if (!all || Object.keys(all).length === 0) {
|
|
5732
|
-
return '📋 **Roadmap is empty.**\n\nUse `roadmap_add` to create your first item.';
|
|
5733
|
-
}
|
|
5734
|
-
const PRIORITY_ORDER: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
5735
|
-
const PRIORITY_ICON: Record<string, string> = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
|
|
5736
|
-
const STATUS_ICON: Record<string, string> = { planned: '📋', 'in-progress': '⚡', done: '✅', blocked: '🚫', cancelled: '🗑️' };
|
|
5737
|
-
const openStatuses = new Set(['planned', 'in-progress', 'blocked']);
|
|
5738
|
-
let items = Object.values(all).map(v => JSON.parse(v as string) as Record<string, string | string[]>);
|
|
5739
|
-
// Filter
|
|
5740
|
-
if (filterStatus === 'open') items = items.filter(i => openStatuses.has(i.status as string));
|
|
5741
|
-
else if (filterStatus) items = items.filter(i => i.status === filterStatus);
|
|
5742
|
-
if (filterTag) items = items.filter(i => (i.tags as string[]).includes(filterTag));
|
|
5743
|
-
if (filterMilestone) items = items.filter(i => i.milestone === filterMilestone);
|
|
5744
|
-
if (filterPriority) items = items.filter(i => (PRIORITY_ORDER[i.priority as string] ?? 99) <= (PRIORITY_ORDER[filterPriority] ?? 99));
|
|
5745
|
-
// Sort: priority asc, then created asc
|
|
5746
|
-
items.sort((a, b) => {
|
|
5747
|
-
const pa = PRIORITY_ORDER[a.priority as string] ?? 99;
|
|
5748
|
-
const pb = PRIORITY_ORDER[b.priority as string] ?? 99;
|
|
5749
|
-
return pa !== pb ? pa - pb : (a.created as string).localeCompare(b.created as string);
|
|
5750
|
-
});
|
|
5751
|
-
if (items.length === 0) return `📋 **No roadmap items** match the current filter (status: ${filterStatus}).`;
|
|
5752
|
-
const grouped: Record<string, typeof items> = {};
|
|
5753
|
-
for (const it of items) {
|
|
5754
|
-
const st = it.status as string;
|
|
5755
|
-
if (!grouped[st]) grouped[st] = [];
|
|
5756
|
-
grouped[st].push(it);
|
|
5757
|
-
}
|
|
5758
|
-
const lines: string[] = [`📋 **Roadmap** (${items.length} item${items.length !== 1 ? 's' : ''})`, ''];
|
|
5759
|
-
for (const [st, grp] of Object.entries(grouped)) {
|
|
5760
|
-
lines.push(`**${STATUS_ICON[st] ?? '•'} ${st.toUpperCase()}** (${grp.length})`);
|
|
5761
|
-
for (const it of grp) {
|
|
5762
|
-
const tags = (it.tags as string[]).length ? ` [${(it.tags as string[]).join(', ')}]` : '';
|
|
5763
|
-
const milestone = it.milestone ? ` · ${it.milestone}` : '';
|
|
5764
|
-
lines.push(` ${PRIORITY_ICON[it.priority as string] ?? '⚪'} \`${it.id}\` **${it.title}**${tags}${milestone}`);
|
|
5765
|
-
if (it.description) lines.push(` ${(it.description as string).slice(0, 120)}`);
|
|
5766
|
-
if (it.notes) lines.push(` 📝 ${(it.notes as string).split('\n').pop()?.slice(0, 100)}`);
|
|
5767
|
-
}
|
|
5768
|
-
lines.push('');
|
|
5769
|
-
}
|
|
5770
|
-
lines.push(`💡 \`roadmap_update(id, status: "in-progress")\` to start · \`roadmap_next\` for top priority item`);
|
|
5771
|
-
return lines.join('\n');
|
|
5772
|
-
}
|
|
5773
|
-
|
|
5774
|
-
case 'roadmap_next': {
|
|
5775
|
-
const { instance_id: rid, tag: filterTag } = args as { instance_id: string; tag?: string };
|
|
5776
|
-
const redis = await getConnection(rid);
|
|
5777
|
-
const all = await redis.hgetall(`cachly:roadmap:${rid}`);
|
|
5778
|
-
if (!all || Object.keys(all).length === 0) {
|
|
5779
|
-
return '📋 **Roadmap is empty.** Use `roadmap_add` to plan your first task.';
|
|
5780
|
-
}
|
|
5781
|
-
const PRIORITY_ORDER: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
5782
|
-
const PRIORITY_ICON: Record<string, string> = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
|
|
5783
|
-
let items = Object.values(all)
|
|
5784
|
-
.map(v => JSON.parse(v as string) as Record<string, unknown>)
|
|
5785
|
-
.filter(i => i.status === 'in-progress' || i.status === 'planned')
|
|
5786
|
-
.filter(i => !filterTag || (i.tags as string[]).includes(filterTag));
|
|
5787
|
-
if (items.length === 0) return '🎉 **No open roadmap items!** All tasks are done (or use `roadmap_list` to check).';
|
|
5788
|
-
// in-progress first, then by priority
|
|
5789
|
-
items.sort((a, b) => {
|
|
5790
|
-
if (a.status === 'in-progress' && b.status !== 'in-progress') return -1;
|
|
5791
|
-
if (b.status === 'in-progress' && a.status !== 'in-progress') return 1;
|
|
5792
|
-
return (PRIORITY_ORDER[a.priority as string] ?? 99) - (PRIORITY_ORDER[b.priority as string] ?? 99);
|
|
5793
|
-
});
|
|
5794
|
-
const next = items[0];
|
|
5795
|
-
const remaining = items.length - 1;
|
|
5796
|
-
const tags = (next.tags as string[]).length ? `\nTags: ${(next.tags as string[]).join(', ')}` : '';
|
|
5797
|
-
const milestone = next.milestone ? `\nMilestone: ${next.milestone}` : '';
|
|
5798
|
-
const notes = next.notes ? `\nNotes: ${(next.notes as string).split('\n').pop()?.slice(0, 120)}` : '';
|
|
5799
|
-
return [
|
|
5800
|
-
`${next.status === 'in-progress' ? '⚡' : '📋'} **Next up: ${next.title}**`,
|
|
5801
|
-
``,
|
|
5802
|
-
`ID: \`${next.id}\``,
|
|
5803
|
-
`Priority: ${PRIORITY_ICON[next.priority as string] ?? '⚪'} ${next.priority}`,
|
|
5804
|
-
`Status: ${next.status}`,
|
|
5805
|
-
next.description ? `\nWhat to do:\n${next.description}` : '',
|
|
5806
|
-
tags, milestone, notes,
|
|
5807
|
-
``,
|
|
5808
|
-
remaining > 0 ? `(+${remaining} more open item${remaining !== 1 ? 's' : ''} in roadmap)` : '(last open item)',
|
|
5809
|
-
``,
|
|
5810
|
-
next.status === 'planned'
|
|
5811
|
-
? `💡 Start with: \`roadmap_update(id: "${next.id}", status: "in-progress")\``
|
|
5812
|
-
: `💡 Finish with: \`roadmap_update(id: "${next.id}", status: "done", notes: "...")\``,
|
|
5813
|
-
].filter(s => s !== undefined).join('\n');
|
|
5814
|
-
}
|
|
5815
|
-
|
|
5816
3954
|
default:
|
|
5817
3955
|
return null;
|
|
5818
3956
|
}
|
|
@@ -5821,81 +3959,14 @@ async function handleBulkLockStream(name: string, args: Record<string, unknown>)
|
|
|
5821
3959
|
// ── Server setup ──────────────────────────────────────────────────────────────
|
|
5822
3960
|
|
|
5823
3961
|
const server = new Server(
|
|
5824
|
-
{ name: 'cachly-mcp', version:
|
|
3962
|
+
{ name: 'cachly-mcp', version: '0.4.0' },
|
|
5825
3963
|
{ capabilities: { tools: {} } }
|
|
5826
3964
|
);
|
|
5827
3965
|
|
|
5828
3966
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
5829
3967
|
|
|
5830
|
-
// ── Auto-session management ───────────────────────────────────────────────────
|
|
5831
|
-
// Transparently starts a Brain session on the first tool call of a process
|
|
5832
|
-
// and saves a session_end summary on SIGTERM/exit so users never have to call
|
|
5833
|
-
// session_start/session_end manually.
|
|
5834
|
-
|
|
5835
|
-
let _autoSessionInstanceId: string | null = null;
|
|
5836
|
-
let _autoSessionStarted = false;
|
|
5837
|
-
let _autoSessionToolCount = 0;
|
|
5838
|
-
|
|
5839
|
-
async function autoStartSession(instanceId: string): Promise<void> {
|
|
5840
|
-
if (_autoSessionStarted) return;
|
|
5841
|
-
_autoSessionStarted = true;
|
|
5842
|
-
_autoSessionInstanceId = instanceId;
|
|
5843
|
-
try {
|
|
5844
|
-
await handleTool('session_start', { instance_id: instanceId, focus: 'auto (MCP session)' });
|
|
5845
|
-
} catch { /* non-fatal — session tracking is a best-effort feature */ }
|
|
5846
|
-
|
|
5847
|
-
// Auto-index the project if it hasn't been indexed in the last 24h.
|
|
5848
|
-
// This is the main lever for growing token savings: more indexed code →
|
|
5849
|
-
// more semantic cache hits → fewer LLM calls for repeated questions.
|
|
5850
|
-
if (process.env.CACHLY_AUTO_INDEX !== 'false') {
|
|
5851
|
-
try {
|
|
5852
|
-
const redis = await getConnection(instanceId);
|
|
5853
|
-
const lastIndexed = await redis.get(`cachly:index:last_indexed:${instanceId}`);
|
|
5854
|
-
const staleMs = 24 * 60 * 60 * 1000;
|
|
5855
|
-
const isStale = !lastIndexed || (Date.now() - parseInt(lastIndexed, 10)) > staleMs;
|
|
5856
|
-
if (isStale) {
|
|
5857
|
-
// Mark as indexing now to prevent concurrent re-runs.
|
|
5858
|
-
await redis.set(`cachly:index:last_indexed:${instanceId}`, String(Date.now()), 'EX', 90000);
|
|
5859
|
-
// Run in background — don't block the first tool call.
|
|
5860
|
-
handleTool('index_project', {
|
|
5861
|
-
instance_id: instanceId,
|
|
5862
|
-
dir: process.cwd(),
|
|
5863
|
-
max_files: 150,
|
|
5864
|
-
ttl: 86400 * 7, // cache for 7 days
|
|
5865
|
-
namespace: 'cachly:sem:code',
|
|
5866
|
-
}).catch(() => undefined);
|
|
5867
|
-
}
|
|
5868
|
-
} catch { /* never block the session on indexing errors */ }
|
|
5869
|
-
}
|
|
5870
|
-
}
|
|
5871
|
-
|
|
5872
|
-
async function autoEndSession(): Promise<void> {
|
|
5873
|
-
if (!_autoSessionStarted || !_autoSessionInstanceId) return;
|
|
5874
|
-
_autoSessionStarted = false;
|
|
5875
|
-
try {
|
|
5876
|
-
await handleTool('session_end', {
|
|
5877
|
-
instance_id: _autoSessionInstanceId,
|
|
5878
|
-
summary: `Auto-ended after ${_autoSessionToolCount} tool call(s). Session was started automatically.`,
|
|
5879
|
-
files_changed: [],
|
|
5880
|
-
lessons_learned: 0,
|
|
5881
|
-
});
|
|
5882
|
-
} catch { /* non-fatal */ }
|
|
5883
|
-
}
|
|
5884
|
-
|
|
5885
3968
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
5886
3969
|
const { name, arguments: args } = request.params;
|
|
5887
|
-
|
|
5888
|
-
// Auto-start brain session on first tool call that has an instance_id.
|
|
5889
|
-
// Skip session management tools to avoid recursion.
|
|
5890
|
-
const sessionTools = new Set(['session_start', 'session_end', 'auto_learn_session']);
|
|
5891
|
-
if (!sessionTools.has(name) && !_autoSessionStarted) {
|
|
5892
|
-
const instanceId = (args as Record<string, unknown>)?.instance_id as string | undefined;
|
|
5893
|
-
if (instanceId) {
|
|
5894
|
-
await autoStartSession(instanceId).catch(() => undefined);
|
|
5895
|
-
}
|
|
5896
|
-
}
|
|
5897
|
-
if (!sessionTools.has(name)) _autoSessionToolCount++;
|
|
5898
|
-
|
|
5899
3970
|
try {
|
|
5900
3971
|
const text = await handleTool(name, (args ?? {}) as Record<string, unknown>);
|
|
5901
3972
|
return { content: [{ type: 'text', text }] };
|
|
@@ -5905,15 +3976,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
5905
3976
|
}
|
|
5906
3977
|
});
|
|
5907
3978
|
|
|
5908
|
-
// Graceful shutdown – close all Redis connections
|
|
3979
|
+
// Graceful shutdown – close all Redis connections
|
|
5909
3980
|
process.on('SIGTERM', async () => {
|
|
5910
|
-
await autoEndSession().catch(() => undefined);
|
|
5911
|
-
for (const [, client] of pool) await client.quit().catch(() => undefined);
|
|
5912
|
-
process.exit(0);
|
|
5913
|
-
});
|
|
5914
|
-
|
|
5915
|
-
process.on('SIGINT', async () => {
|
|
5916
|
-
await autoEndSession().catch(() => undefined);
|
|
5917
3981
|
for (const [, client] of pool) await client.quit().catch(() => undefined);
|
|
5918
3982
|
process.exit(0);
|
|
5919
3983
|
});
|
|
@@ -5934,8 +3998,6 @@ const EDITOR_FILES: Record<string, string> = {
|
|
|
5934
3998
|
windsurf: '.windsurf/mcp.json',
|
|
5935
3999
|
copilot: '.vscode/mcp.json',
|
|
5936
4000
|
continue: '.continue/config.json',
|
|
5937
|
-
cline: '.vscode/mcp.json', // Cline respects VS Code project MCP config
|
|
5938
|
-
zed: '.zed/settings.json', // Zed project-level context_servers
|
|
5939
4001
|
};
|
|
5940
4002
|
|
|
5941
4003
|
const CLAUDE_MD_MARKER_START = '<!-- cachly-brain-start -->';
|
|
@@ -5952,20 +4014,6 @@ function buildMcpConfig(apiKey: string, instanceId: string, editor: string): str
|
|
|
5952
4014
|
},
|
|
5953
4015
|
}, null, 2);
|
|
5954
4016
|
}
|
|
5955
|
-
if (editor === 'zed') {
|
|
5956
|
-
return JSON.stringify({
|
|
5957
|
-
context_servers: {
|
|
5958
|
-
cachly: {
|
|
5959
|
-
command: {
|
|
5960
|
-
path: 'npx',
|
|
5961
|
-
args: ['-y', '@cachly-dev/mcp-server@latest'],
|
|
5962
|
-
env: { CACHLY_API_URL: 'https://api.cachly.dev', CACHLY_JWT: apiKey, CACHLY_BRAIN_INSTANCE_ID: instanceId },
|
|
5963
|
-
},
|
|
5964
|
-
settings: {},
|
|
5965
|
-
},
|
|
5966
|
-
},
|
|
5967
|
-
}, null, 2);
|
|
5968
|
-
}
|
|
5969
4017
|
return JSON.stringify({
|
|
5970
4018
|
mcpServers: {
|
|
5971
4019
|
cachly: {
|
|
@@ -5979,54 +4027,48 @@ function buildMcpConfig(apiKey: string, instanceId: string, editor: string): str
|
|
|
5979
4027
|
|
|
5980
4028
|
function buildClaudeMdBlock(instanceId: string): string {
|
|
5981
4029
|
return `${CLAUDE_MD_MARKER_START}
|
|
5982
|
-
## Cachly AI Brain
|
|
4030
|
+
## Cachly AI Brain — Persistent Memory
|
|
5983
4031
|
|
|
5984
|
-
|
|
5985
|
-
Your instance ID: \`${instanceId}\`
|
|
4032
|
+
Your AI Brain instance ID: \`${instanceId}\`
|
|
5986
4033
|
|
|
5987
|
-
>
|
|
5988
|
-
>
|
|
4034
|
+
> The Brain gives you persistent memory across sessions. Every lesson you learn,
|
|
4035
|
+
> every bug you fix, every architecture decision — recalled instantly next time.
|
|
4036
|
+
> instance_id is pre-filled below. Do NOT ask the user for it.
|
|
5989
4037
|
|
|
5990
|
-
|
|
4038
|
+
### MANDATORY: Call at the START of every session
|
|
4039
|
+
\`\`\`
|
|
4040
|
+
session_start(instance_id="${instanceId}", focus="<what you're working on today>")
|
|
4041
|
+
\`\`\`
|
|
4042
|
+
This loads your previous context, recent lessons, and open tasks automatically.
|
|
5991
4043
|
|
|
5992
|
-
###
|
|
4044
|
+
### MANDATORY: Call at the END of every session
|
|
5993
4045
|
\`\`\`
|
|
5994
|
-
|
|
5995
|
-
instance_id = "${instanceId}",
|
|
5996
|
-
key = "wip-registry",
|
|
5997
|
-
content = "EDITING: <file:line> — goal: <what you are implementing>",
|
|
5998
|
-
category = "bug",
|
|
5999
|
-
ttl = 86400,
|
|
6000
|
-
)
|
|
4046
|
+
session_end(instance_id="${instanceId}", summary="<2-3 sentences on what was done>", files_changed=["src/..."], lessons_learned=1)
|
|
6001
4047
|
\`\`\`
|
|
6002
|
-
After the edit is complete, update \`content\` to \`"DONE: <file> — <what was completed>"\`.
|
|
6003
4048
|
|
|
6004
|
-
### After fixing
|
|
4049
|
+
### After fixing ANY bug or solving a hard problem
|
|
6005
4050
|
\`\`\`
|
|
6006
4051
|
learn_from_attempts(
|
|
6007
|
-
instance_id
|
|
6008
|
-
topic
|
|
6009
|
-
outcome
|
|
6010
|
-
what_worked
|
|
6011
|
-
what_failed
|
|
6012
|
-
|
|
6013
|
-
file_paths
|
|
6014
|
-
|
|
6015
|
-
tags = ["tag1"],
|
|
4052
|
+
instance_id="${instanceId}",
|
|
4053
|
+
topic="category:keyword", # e.g. "deploy:web", "bug:redis-timeout", "arch:auth"
|
|
4054
|
+
outcome="success",
|
|
4055
|
+
what_worked="exact solution",
|
|
4056
|
+
what_failed="what didn't work (optional)",
|
|
4057
|
+
commands=["the command that worked"],
|
|
4058
|
+
file_paths=["relevant/file.ts"],
|
|
4059
|
+
severity="critical"|"major"|"minor",
|
|
6016
4060
|
)
|
|
6017
4061
|
\`\`\`
|
|
6018
4062
|
|
|
6019
|
-
### Before starting any task
|
|
4063
|
+
### Before starting any non-trivial task
|
|
6020
4064
|
\`\`\`
|
|
6021
|
-
|
|
6022
|
-
instance_id = "${instanceId}",
|
|
6023
|
-
query = "<describe what you are about to do>",
|
|
6024
|
-
)
|
|
4065
|
+
recall_best_solution(instance_id="${instanceId}", topic="relevant:topic")
|
|
6025
4066
|
\`\`\`
|
|
6026
4067
|
|
|
6027
|
-
###
|
|
6028
|
-
|
|
6029
|
-
|
|
4068
|
+
### If a stored lesson is wrong
|
|
4069
|
+
\`\`\`
|
|
4070
|
+
forget_lesson(instance_id="${instanceId}", topic="wrong:topic")
|
|
4071
|
+
\`\`\`
|
|
6030
4072
|
${CLAUDE_MD_MARKER_END}`;
|
|
6031
4073
|
}
|
|
6032
4074
|
|
|
@@ -6117,9 +4159,8 @@ if (process.argv[2] === 'setup') {
|
|
|
6117
4159
|
const AUTH_BASE = 'https://auth.cachly.dev/realms/cachly/protocol/openid-connect';
|
|
6118
4160
|
const CLIENT_ID = 'cachly-cli';
|
|
6119
4161
|
|
|
6120
|
-
console.log('Step 1: Sign in to cachly (free
|
|
4162
|
+
console.log('Step 1: Sign in to cachly (free — no credit card required)\n');
|
|
6121
4163
|
|
|
6122
|
-
// Start device flow
|
|
6123
4164
|
let deviceCode = '', userCode = '', verifyUri = '', pollInterval = 5000;
|
|
6124
4165
|
try {
|
|
6125
4166
|
const deviceRes = await fetch(`${AUTH_BASE}/auth/device`, {
|
|
@@ -6127,91 +4168,92 @@ if (process.argv[2] === 'setup') {
|
|
|
6127
4168
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
6128
4169
|
body: `client_id=${CLIENT_ID}&scope=openid`,
|
|
6129
4170
|
});
|
|
6130
|
-
if (!deviceRes.ok) throw new Error(`
|
|
4171
|
+
if (!deviceRes.ok) throw new Error(`HTTP ${deviceRes.status}`);
|
|
6131
4172
|
const data = await deviceRes.json() as {
|
|
6132
4173
|
device_code: string; user_code: string;
|
|
6133
4174
|
verification_uri_complete: string; interval: number;
|
|
6134
4175
|
};
|
|
6135
|
-
deviceCode
|
|
6136
|
-
userCode
|
|
6137
|
-
verifyUri
|
|
6138
|
-
pollInterval
|
|
4176
|
+
deviceCode = data.device_code;
|
|
4177
|
+
userCode = data.user_code;
|
|
4178
|
+
verifyUri = data.verification_uri_complete;
|
|
4179
|
+
pollInterval = (data.interval ?? 5) * 1000;
|
|
6139
4180
|
} catch (e) {
|
|
6140
|
-
console.error(`\
|
|
6141
|
-
console.error('
|
|
4181
|
+
console.error(`\nCould not reach auth service: ${(e as Error).message}`);
|
|
4182
|
+
console.error('Fallback: sign in at https://cachly.dev and paste your API token.\n');
|
|
6142
4183
|
token = await ask(' Paste API token (cky_live_...): ');
|
|
6143
|
-
if (!token) { console.error('\nToken
|
|
6144
|
-
|
|
6145
|
-
deviceCode = ''; // mark as fallback so we skip polling
|
|
4184
|
+
if (!token) { console.error('\nToken required. Aborting.\n'); rl.close(); process.exit(1); }
|
|
4185
|
+
deviceCode = '';
|
|
6146
4186
|
}
|
|
6147
4187
|
|
|
6148
|
-
if (deviceCode
|
|
6149
|
-
|
|
6150
|
-
console.log(`
|
|
6151
|
-
console.log(` URL: ${verifyUri!}\n`);
|
|
4188
|
+
if (deviceCode) {
|
|
4189
|
+
console.log(` Code: \x1b[1;33m${userCode}\x1b[0m`);
|
|
4190
|
+
console.log(` URL: ${verifyUri}\n`);
|
|
6152
4191
|
try {
|
|
6153
4192
|
const { execSync } = await import('node:child_process');
|
|
6154
4193
|
const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
6155
|
-
execSync(`${openCmd} "${verifyUri
|
|
4194
|
+
execSync(`${openCmd} "${verifyUri}"`, { stdio: 'ignore' });
|
|
6156
4195
|
console.log(' ✓ Browser opened — confirm the code above to continue...\n');
|
|
6157
4196
|
} catch {
|
|
6158
|
-
console.log(' 👉 Open the URL above in your browser and
|
|
4197
|
+
console.log(' 👉 Open the URL above in your browser and confirm the code.\n');
|
|
6159
4198
|
}
|
|
6160
4199
|
|
|
6161
|
-
// Poll for token
|
|
6162
4200
|
process.stdout.write(' Waiting for authorization');
|
|
6163
|
-
const deadline = Date.now() + 10 * 60 * 1000;
|
|
4201
|
+
const deadline = Date.now() + 10 * 60 * 1000;
|
|
6164
4202
|
while (Date.now() < deadline) {
|
|
6165
|
-
await new Promise(r => setTimeout(r, pollInterval
|
|
4203
|
+
await new Promise(r => setTimeout(r, pollInterval));
|
|
6166
4204
|
process.stdout.write('.');
|
|
6167
4205
|
try {
|
|
6168
4206
|
const tokenRes = await fetch(`${AUTH_BASE}/token`, {
|
|
6169
4207
|
method: 'POST',
|
|
6170
4208
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
6171
|
-
body: `client_id=${CLIENT_ID}&grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=${deviceCode
|
|
4209
|
+
body: `client_id=${CLIENT_ID}&grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=${deviceCode}`,
|
|
6172
4210
|
});
|
|
6173
|
-
const
|
|
6174
|
-
if (
|
|
6175
|
-
token =
|
|
4211
|
+
const td = await tokenRes.json() as { access_token?: string; error?: string };
|
|
4212
|
+
if (td.access_token) {
|
|
4213
|
+
token = td.access_token;
|
|
6176
4214
|
console.log(' \x1b[32m✓ Authorized!\x1b[0m\n');
|
|
6177
4215
|
break;
|
|
6178
4216
|
}
|
|
6179
|
-
|
|
6180
|
-
if (
|
|
6181
|
-
|
|
6182
|
-
console.error(`\nAuth error: ${tokenData.error}. Aborting.\n`);
|
|
4217
|
+
if (td.error === 'slow_down') pollInterval = Math.min(pollInterval + 2000, 15000);
|
|
4218
|
+
else if (td.error && td.error !== 'authorization_pending') {
|
|
4219
|
+
console.error(`\nAuth error: ${td.error}. Aborting.\n`);
|
|
6183
4220
|
rl.close(); process.exit(1);
|
|
6184
4221
|
}
|
|
6185
4222
|
} catch { /* network hiccup — keep polling */ }
|
|
6186
4223
|
}
|
|
6187
|
-
if (!token) { console.error('\nTimed out
|
|
6188
|
-
console.log('');
|
|
4224
|
+
if (!token) { console.error('\nTimed out. Aborting.\n'); rl.close(); process.exit(1); }
|
|
6189
4225
|
}
|
|
6190
|
-
}
|
|
6191
4226
|
|
|
6192
|
-
|
|
6193
|
-
|
|
6194
|
-
|
|
6195
|
-
|
|
6196
|
-
|
|
6197
|
-
|
|
6198
|
-
|
|
6199
|
-
|
|
6200
|
-
|
|
6201
|
-
|
|
6202
|
-
|
|
6203
|
-
|
|
6204
|
-
|
|
6205
|
-
|
|
6206
|
-
|
|
6207
|
-
|
|
6208
|
-
} catch (e) {
|
|
6209
|
-
console.log(' (skipped)\n');
|
|
6210
|
-
// Non-fatal: fall back to using the Keycloak JWT directly.
|
|
6211
|
-
// It will expire but setup still works for now.
|
|
4227
|
+
// Exchange short-lived Keycloak JWT → long-lived cky_live_ API key
|
|
4228
|
+
if (token.startsWith('eyJ')) {
|
|
4229
|
+
process.stdout.write('⏳ Generating your API key...');
|
|
4230
|
+
try {
|
|
4231
|
+
const keyRes = await fetch(`${API_URL}/api/v1/api-keys`, {
|
|
4232
|
+
method: 'POST',
|
|
4233
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
4234
|
+
body: JSON.stringify({ name: 'cachly-mcp-setup', scope: 'read_write' }),
|
|
4235
|
+
});
|
|
4236
|
+
if (!keyRes.ok) throw new Error(`HTTP ${keyRes.status}`);
|
|
4237
|
+
const keyBody = await keyRes.json() as { key?: string };
|
|
4238
|
+
if (keyBody.key) { token = keyBody.key; console.log(' ✓\n'); }
|
|
4239
|
+
else throw new Error('no key in response');
|
|
4240
|
+
} catch (e) {
|
|
4241
|
+
console.log(` (skipped — JWT will be used directly)\n`);
|
|
4242
|
+
}
|
|
6212
4243
|
}
|
|
6213
4244
|
}
|
|
6214
4245
|
|
|
4246
|
+
// ── Step 1b: Auto-provision a Brain instance if none exist ─────────────────
|
|
4247
|
+
// POST /api/v1/instances/auto is idempotent — returns existing or creates free Brain
|
|
4248
|
+
let autoProvisioned = false;
|
|
4249
|
+
try {
|
|
4250
|
+
const autoRes = await fetch(`${API_URL}/api/v1/instances/auto`, {
|
|
4251
|
+
method: 'POST',
|
|
4252
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
4253
|
+
});
|
|
4254
|
+
if (autoRes.ok) autoProvisioned = true;
|
|
4255
|
+
} catch { /* non-fatal */ }
|
|
4256
|
+
|
|
6215
4257
|
// ── Step 2: Fetch & pick instance ─────────────────────────────────────────
|
|
6216
4258
|
process.stdout.write('⏳ Fetching your instances...');
|
|
6217
4259
|
let instances: Array<{ id: string; name: string; status: string; tier: string; region: string }> = [];
|
|
@@ -6223,62 +4265,31 @@ if (process.argv[2] === 'setup') {
|
|
|
6223
4265
|
const body = await res.json() as { data: typeof instances };
|
|
6224
4266
|
instances = (body.data ?? []).filter(i => i.status === 'running');
|
|
6225
4267
|
} catch (e) {
|
|
6226
|
-
|
|
6227
|
-
console.error(
|
|
6228
|
-
if (msg.includes('401')) {
|
|
6229
|
-
console.error('Token rejected. Get a valid token at https://cachly.dev/setup-ai\n');
|
|
6230
|
-
try {
|
|
6231
|
-
const { execSync } = await import('node:child_process');
|
|
6232
|
-
const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
6233
|
-
execSync(`${openCmd} https://cachly.dev/setup-ai`, { stdio: 'ignore' });
|
|
6234
|
-
} catch { /* ignore */ }
|
|
6235
|
-
}
|
|
4268
|
+
console.error(`\n\nFailed to fetch instances: ${(e as Error).message}`);
|
|
4269
|
+
console.error('Check your token and try again.\n');
|
|
6236
4270
|
rl.close(); process.exit(1);
|
|
6237
4271
|
}
|
|
6238
4272
|
console.log(` found ${instances.length}\n`);
|
|
6239
4273
|
|
|
6240
4274
|
if (instances.length === 0) {
|
|
6241
|
-
|
|
6242
|
-
|
|
6243
|
-
|
|
6244
|
-
|
|
6245
|
-
|
|
6246
|
-
|
|
6247
|
-
|
|
6248
|
-
|
|
6249
|
-
|
|
6250
|
-
|
|
6251
|
-
|
|
6252
|
-
|
|
6253
|
-
|
|
6254
|
-
// Newly created — poll until running or give up after 30 s.
|
|
6255
|
-
const newId = autoBody.instance_id;
|
|
6256
|
-
console.log(` ✓ created (${newId.slice(0, 8)}…)\n`);
|
|
6257
|
-
process.stdout.write('⏳ Waiting for instance to start');
|
|
6258
|
-
for (let attempt = 0; attempt < 15; attempt++) {
|
|
6259
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
6260
|
-
process.stdout.write('.');
|
|
6261
|
-
try {
|
|
6262
|
-
const checkRes = await fetch(`${API_URL}/api/v1/instances/${newId}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
6263
|
-
if (checkRes.ok) {
|
|
6264
|
-
const inst = await checkRes.json() as { id: string; name: string; status: string; tier: string; region: string };
|
|
6265
|
-
if (inst.status === 'running') { instances = [inst]; break; }
|
|
6266
|
-
}
|
|
6267
|
-
} catch { /* keep polling */ }
|
|
6268
|
-
}
|
|
6269
|
-
console.log('');
|
|
4275
|
+
if (autoProvisioned) {
|
|
4276
|
+
// Poll up to 10× every 3s (30s total) waiting for the Brain to start
|
|
4277
|
+
process.stdout.write(' waiting for Brain to start');
|
|
4278
|
+
for (let attempt = 0; attempt < 10 && instances.length === 0; attempt++) {
|
|
4279
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
4280
|
+
process.stdout.write('.');
|
|
4281
|
+
try {
|
|
4282
|
+
const retry = await fetch(`${API_URL}/api/v1/instances`, { headers: { Authorization: `Bearer ${token}` } });
|
|
4283
|
+
if (retry.ok) {
|
|
4284
|
+
const b = await retry.json() as { data: typeof instances };
|
|
4285
|
+
instances = (b.data ?? []).filter(i => i.status === 'running');
|
|
4286
|
+
}
|
|
4287
|
+
} catch { /* ignore */ }
|
|
6270
4288
|
}
|
|
6271
|
-
|
|
6272
|
-
console.log(` failed: ${(e as Error).message}\n`);
|
|
4289
|
+
process.stdout.write(instances.length > 0 ? ' ready!\n' : '\n');
|
|
6273
4290
|
}
|
|
6274
|
-
|
|
6275
4291
|
if (instances.length === 0) {
|
|
6276
|
-
console.error('\
|
|
6277
|
-
try {
|
|
6278
|
-
const { execSync } = await import('node:child_process');
|
|
6279
|
-
const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
6280
|
-
execSync(`${openCmd} https://cachly.dev/instances`, { stdio: 'ignore' });
|
|
6281
|
-
} catch { /* ignore */ }
|
|
4292
|
+
console.error('\nBrain provisioning is taking longer than expected. Run setup again in a minute.\n');
|
|
6282
4293
|
rl.close(); process.exit(1);
|
|
6283
4294
|
}
|
|
6284
4295
|
}
|
|
@@ -6298,28 +4309,15 @@ if (process.argv[2] === 'setup') {
|
|
|
6298
4309
|
// ── Step 3: Detect editors ────────────────────────────────────────────────
|
|
6299
4310
|
const cwd = process.cwd();
|
|
6300
4311
|
const detected: string[] = [];
|
|
6301
|
-
const { homedir } = await import('node:os');
|
|
6302
|
-
const home = homedir();
|
|
6303
4312
|
// Claude Code: always include (CLAUDE.md is universal)
|
|
6304
4313
|
detected.push('claude');
|
|
6305
4314
|
if (existsSync(resolve(cwd, '.cursor'))) detected.push('cursor');
|
|
6306
4315
|
if (existsSync(resolve(cwd, '.windsurf'))) detected.push('windsurf');
|
|
6307
4316
|
if (existsSync(resolve(cwd, '.vscode'))) detected.push('copilot');
|
|
6308
4317
|
if (existsSync(resolve(cwd, '.continue'))) detected.push('continue');
|
|
6309
|
-
// Cline: VS Code extension — detect via globalStorage on the machine
|
|
6310
|
-
const clineGlobalDir = process.platform === 'darwin'
|
|
6311
|
-
? resolve(home, 'Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev')
|
|
6312
|
-
: resolve(home, '.vscode/extensions');
|
|
6313
|
-
if (existsSync(clineGlobalDir) && !detected.includes('copilot')) detected.push('cline');
|
|
6314
|
-
else if (existsSync(clineGlobalDir)) detected.push('cline'); // both copilot + cline share .vscode/mcp.json — fine
|
|
6315
|
-
// Zed editor — detect via app data dir
|
|
6316
|
-
const zedDir = process.platform === 'darwin'
|
|
6317
|
-
? resolve(home, 'Library/Application Support/Zed')
|
|
6318
|
-
: resolve(home, '.config/zed');
|
|
6319
|
-
if (existsSync(zedDir)) detected.push('zed');
|
|
6320
4318
|
|
|
6321
4319
|
const editorLabel = (e: string) =>
|
|
6322
|
-
({ claude: 'Claude Code', cursor: 'Cursor', windsurf: 'Windsurf', copilot: 'GitHub Copilot', continue: 'Continue.dev'
|
|
4320
|
+
({ claude: 'Claude Code', cursor: 'Cursor', windsurf: 'Windsurf', copilot: 'GitHub Copilot', continue: 'Continue.dev' })[e] ?? e;
|
|
6323
4321
|
|
|
6324
4322
|
console.log(`Step 3: Detected editors: ${detected.map(editorLabel).join(', ')}`);
|
|
6325
4323
|
const editorsRaw = await ask(` Configure for which? [all / ${detected.join('/')}] (Enter = all): `);
|
|
@@ -6328,21 +4326,7 @@ if (process.argv[2] === 'setup') {
|
|
|
6328
4326
|
: editorsRaw.split(/[,\s]+/).map(s => s.toLowerCase()).filter(s => EDITOR_FILES[s]);
|
|
6329
4327
|
console.log('');
|
|
6330
4328
|
|
|
6331
|
-
// ── Step 4:
|
|
6332
|
-
console.log('Step 4: Stay in the loop (optional)');
|
|
6333
|
-
const email = await ask(' Email for release notes & tips? [Enter to skip]: ');
|
|
6334
|
-
if (email && email.includes('@')) {
|
|
6335
|
-
try {
|
|
6336
|
-
await fetch(`${API_URL}/api/newsletter/subscribe`, {
|
|
6337
|
-
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
6338
|
-
body: JSON.stringify({ email: email.trim().toLowerCase(), source: 'mcp-setup' }),
|
|
6339
|
-
signal: AbortSignal.timeout(5000),
|
|
6340
|
-
});
|
|
6341
|
-
console.log('✅ Subscribed — you\'ll only hear from us when it matters.\n');
|
|
6342
|
-
} catch { console.log('⚠️ Could not subscribe (no internet?). Continuing...\n'); }
|
|
6343
|
-
} else { console.log(' Skipped.\n'); }
|
|
6344
|
-
|
|
6345
|
-
// ── Step 5: Write editor configs ──────────────────────────────────────────
|
|
4329
|
+
// ── Step 4: Write editor configs ──────────────────────────────────────────
|
|
6346
4330
|
for (const editor of editorsToSetup) {
|
|
6347
4331
|
const configFile = EDITOR_FILES[editor] ?? '.mcp.json';
|
|
6348
4332
|
const configPath = resolve(cwd, configFile);
|
|
@@ -6351,7 +4335,7 @@ if (process.argv[2] === 'setup') {
|
|
|
6351
4335
|
console.log(`✅ Written: ${configFile}`);
|
|
6352
4336
|
}
|
|
6353
4337
|
|
|
6354
|
-
// ── Step
|
|
4338
|
+
// ── Step 5: CLAUDE.md (always — idempotent) ───────────────────────────────
|
|
6355
4339
|
const mdResult = await writeClaudeMd(cwd, instance.id);
|
|
6356
4340
|
const mdLabel = mdResult === 'updated' ? '✅ Updated' : mdResult === 'appended' ? '✅ Appended to' : '✅ Written';
|
|
6357
4341
|
console.log(`${mdLabel}: CLAUDE.md`);
|
|
@@ -6359,172 +4343,11 @@ if (process.argv[2] === 'setup') {
|
|
|
6359
4343
|
rl.close();
|
|
6360
4344
|
console.log(`\n🧠 Done! Restart your editor — the \`cachly\` MCP tools will appear.`);
|
|
6361
4345
|
console.log(` Your AI now has persistent memory across every session.\n`);
|
|
6362
|
-
|
|
6363
|
-
// ── Step 7: Show first session_start preview (Aha moment) ────────────────
|
|
6364
|
-
// Fetch brain health from the API to show the user what their agent will see.
|
|
6365
|
-
process.stdout.write('\n⏳ Fetching your Brain health preview...');
|
|
6366
|
-
try {
|
|
6367
|
-
const brainRes = await fetch(`${API_URL}/api/v1/instances/${instance.id}/brain/stats`, {
|
|
6368
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
6369
|
-
signal: AbortSignal.timeout(6000),
|
|
6370
|
-
});
|
|
6371
|
-
if (brainRes.ok) {
|
|
6372
|
-
const brainData = await brainRes.json() as {
|
|
6373
|
-
lesson_count?: number; context_count?: number;
|
|
6374
|
-
open_failures?: number; quality_score?: number;
|
|
6375
|
-
};
|
|
6376
|
-
const lessons = brainData.lesson_count ?? 0;
|
|
6377
|
-
const contexts = brainData.context_count ?? 0;
|
|
6378
|
-
const score = brainData.quality_score ?? 0;
|
|
6379
|
-
const level = lessons === 0 ? 'Intern 🌱' :
|
|
6380
|
-
lessons < 10 ? 'Junior Dev 🔧' :
|
|
6381
|
-
lessons < 30 ? 'Mid Dev ⚡' :
|
|
6382
|
-
lessons < 60 ? 'Senior Dev 🧠' :
|
|
6383
|
-
lessons < 100 ? 'Staff Eng 🚀' : 'Principal Eng 🏆';
|
|
6384
|
-
console.log(' ✓\n');
|
|
6385
|
-
console.log('┌──────────────────────────────────────────────────────┐');
|
|
6386
|
-
console.log(`│ 🧠 Brain Health Report │`);
|
|
6387
|
-
console.log('├──────────────────────────────────────────────────────┤');
|
|
6388
|
-
console.log(`│ Lessons stored : ${String(lessons).padEnd(6)} │ Level: ${level.padEnd(20)}│`);
|
|
6389
|
-
console.log(`│ Context entries : ${String(contexts).padEnd(6)} │ Quality score: ${String(Math.round(score * 100)).padEnd(3)}% │`);
|
|
6390
|
-
if (lessons === 0) {
|
|
6391
|
-
console.log('├──────────────────────────────────────────────────────┤');
|
|
6392
|
-
console.log('│ Your Brain is empty and ready to learn. │');
|
|
6393
|
-
console.log('│ After your first coding session it will contain: │');
|
|
6394
|
-
console.log('│ • Lessons from every bug you fixed │');
|
|
6395
|
-
console.log('│ • Your project\'s indexed files │');
|
|
6396
|
-
console.log('│ • A session summary for next time │');
|
|
6397
|
-
}
|
|
6398
|
-
console.log('└──────────────────────────────────────────────────────┘');
|
|
6399
|
-
} else {
|
|
6400
|
-
console.log(' (skipped)');
|
|
6401
|
-
}
|
|
6402
|
-
} catch { console.log(' (skipped)'); }
|
|
6403
|
-
|
|
6404
|
-
console.log(`\n Your AI will call session_start automatically when it uses the first tool.`);
|
|
6405
|
-
console.log(` To see your Brain at any time: https://cachly.dev/instances/${instance.id}\n`);
|
|
6406
|
-
process.exit(0);
|
|
6407
|
-
}
|
|
6408
|
-
|
|
6409
|
-
// ── CLI: cachly index <dir> ───────────────────────────────────────────────────
|
|
6410
|
-
// Usage: npx @cachly-dev/mcp-server@latest index [./path/to/project]
|
|
6411
|
-
// Indexes the project directory into the Brain — perfect for CI/CD cron jobs.
|
|
6412
|
-
|
|
6413
|
-
if (process.argv[2] === 'index') {
|
|
6414
|
-
const { resolve } = await import('node:path');
|
|
6415
|
-
const argv = process.argv.slice(3);
|
|
6416
|
-
const flag = (name: string) => { const i = argv.indexOf(`--${name}`); return i !== -1 ? argv[i + 1] : undefined; };
|
|
6417
|
-
|
|
6418
|
-
const dir = resolve(flag('dir') ?? argv.find(a => !a.startsWith('--')) ?? '.');
|
|
6419
|
-
const instanceId = flag('instance-id') ?? process.env.CACHLY_BRAIN_INSTANCE_ID;
|
|
6420
|
-
const maxFiles = parseInt(flag('max-files') ?? '500', 10);
|
|
6421
|
-
const namespace = flag('namespace') ?? 'cachly:sem:code';
|
|
6422
|
-
|
|
6423
|
-
if (!instanceId || !JWT) {
|
|
6424
|
-
console.error('\n❌ CACHLY_BRAIN_INSTANCE_ID and CACHLY_JWT must be set\n');
|
|
6425
|
-
console.error(' export CACHLY_BRAIN_INSTANCE_ID=<uuid>');
|
|
6426
|
-
console.error(' export CACHLY_JWT=<cky_live_...>');
|
|
6427
|
-
console.error(' npx @cachly-dev/mcp-server@latest index ./my-project\n');
|
|
6428
|
-
process.exit(1);
|
|
6429
|
-
}
|
|
6430
|
-
|
|
6431
|
-
console.log(`\n📂 Indexing: ${dir}`);
|
|
6432
|
-
console.log(` Instance: ${instanceId.slice(0, 8)}… Max files: ${maxFiles}\n`);
|
|
6433
|
-
|
|
6434
|
-
try {
|
|
6435
|
-
const result = await handleTool('index_project', {
|
|
6436
|
-
instance_id: instanceId,
|
|
6437
|
-
dir,
|
|
6438
|
-
max_files: maxFiles,
|
|
6439
|
-
ttl: 86400 * 7,
|
|
6440
|
-
namespace,
|
|
6441
|
-
});
|
|
6442
|
-
console.log(result);
|
|
6443
|
-
console.log('\n✅ Indexing complete.\n');
|
|
6444
|
-
} catch (err) {
|
|
6445
|
-
console.error(`\n❌ Indexing failed: ${(err as Error).message}\n`);
|
|
6446
|
-
process.exit(1);
|
|
6447
|
-
}
|
|
6448
4346
|
process.exit(0);
|
|
6449
4347
|
}
|
|
6450
4348
|
|
|
6451
4349
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
6452
4350
|
|
|
6453
|
-
|
|
6454
|
-
|
|
6455
|
-
if (!JWT) {
|
|
6456
|
-
process.stderr.write(
|
|
6457
|
-
'\n' +
|
|
6458
|
-
'╔══════════════════════════════════════════════════════════════════╗\n' +
|
|
6459
|
-
'║ 🧠 cachly AI Brain — Setup required ║\n' +
|
|
6460
|
-
'╠══════════════════════════════════════════════════════════════════╣\n' +
|
|
6461
|
-
'║ ║\n' +
|
|
6462
|
-
'║ CACHLY_JWT is not set. Get your free credentials at: ║\n' +
|
|
6463
|
-
'║ ║\n' +
|
|
6464
|
-
'║ 👉 https://cachly.dev/setup-ai ║\n' +
|
|
6465
|
-
'║ ║\n' +
|
|
6466
|
-
'║ Then run the interactive setup wizard: ║\n' +
|
|
6467
|
-
'║ ║\n' +
|
|
6468
|
-
'║ npx @cachly-dev/mcp-server@latest setup ║\n' +
|
|
6469
|
-
'║ ║\n' +
|
|
6470
|
-
'║ Free tier — no credit card required. ║\n' +
|
|
6471
|
-
'╚══════════════════════════════════════════════════════════════════╝\n' +
|
|
6472
|
-
'\n',
|
|
6473
|
-
);
|
|
6474
|
-
}
|
|
6475
|
-
|
|
6476
|
-
// ── Update nudge (non-blocking, fire-and-forget) ─────────────────────────────
|
|
6477
|
-
// Check npm registry once per process start; if outdated, log to stderr so
|
|
6478
|
-
// the editor's MCP log shows an actionable one-liner. Skipped if opted out.
|
|
6479
|
-
if (!process.env.CACHLY_NO_UPDATE_CHECK) {
|
|
6480
|
-
(async () => {
|
|
6481
|
-
try {
|
|
6482
|
-
const res = await fetch(
|
|
6483
|
-
`https://registry.npmjs.org/@cachly-dev/mcp-server/latest`,
|
|
6484
|
-
{ signal: AbortSignal.timeout(4000) },
|
|
6485
|
-
);
|
|
6486
|
-
if (res.ok) {
|
|
6487
|
-
const data = await res.json() as { version: string };
|
|
6488
|
-
const latest = data?.version ?? '';
|
|
6489
|
-
if (latest && latest !== CURRENT_VERSION) {
|
|
6490
|
-
process.stderr.write(
|
|
6491
|
-
`\n⚡ cachly update available: ${CURRENT_VERSION} → ${latest}\n` +
|
|
6492
|
-
` Run: npx @cachly-dev/mcp-server@latest setup\n\n`,
|
|
6493
|
-
);
|
|
6494
|
-
}
|
|
6495
|
-
}
|
|
6496
|
-
} catch { /* ignore – network unavailable or timeout */ }
|
|
6497
|
-
})();
|
|
6498
|
-
}
|
|
6499
|
-
|
|
6500
|
-
const httpPort = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined;
|
|
6501
|
-
|
|
6502
|
-
if (httpPort) {
|
|
6503
|
-
// ── HTTP mode (Streamable HTTP transport) ───────────────────────────────
|
|
6504
|
-
// Used for Smithery URL deployment: PORT=3000 node dist/index.js
|
|
6505
|
-
const { createServer } = await import('node:http');
|
|
6506
|
-
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
6507
|
-
await server.connect(transport);
|
|
6508
|
-
const httpServer = createServer((req, res) => {
|
|
6509
|
-
const url = new URL(req.url ?? '/', `http://localhost:${httpPort}`);
|
|
6510
|
-
if (url.pathname === '/health') {
|
|
6511
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
6512
|
-
res.end(JSON.stringify({ status: 'ok', version: CURRENT_VERSION }));
|
|
6513
|
-
return;
|
|
6514
|
-
}
|
|
6515
|
-
if (url.pathname === '/mcp' || url.pathname === '/') {
|
|
6516
|
-
transport.handleRequest(req, res);
|
|
6517
|
-
return;
|
|
6518
|
-
}
|
|
6519
|
-
res.writeHead(404);
|
|
6520
|
-
res.end('not found');
|
|
6521
|
-
});
|
|
6522
|
-
httpServer.listen(httpPort, () => {
|
|
6523
|
-
process.stderr.write(`cachly-mcp HTTP server listening on :${httpPort}\n`);
|
|
6524
|
-
});
|
|
6525
|
-
} else {
|
|
6526
|
-
// ── stdio mode (default for local editor use) ───────────────────────────
|
|
6527
|
-
const transport = new StdioServerTransport();
|
|
6528
|
-
await server.connect(transport);
|
|
6529
|
-
}
|
|
4351
|
+
const transport = new StdioServerTransport();
|
|
4352
|
+
await server.connect(transport);
|
|
6530
4353
|
|