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