@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/src/index.ts
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,7 +34,6 @@ 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)
|
|
@@ -39,6 +41,7 @@ import { join, relative, extname } from 'node:path';
|
|
|
39
41
|
|
|
40
42
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
41
43
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
44
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
42
45
|
import {
|
|
43
46
|
CallToolRequestSchema,
|
|
44
47
|
ErrorCode,
|
|
@@ -50,9 +53,9 @@ import { Redis } from 'ioredis';
|
|
|
50
53
|
// ── Config ────────────────────────────────────────────────────────────────────
|
|
51
54
|
|
|
52
55
|
const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
|
|
53
|
-
const AUTH_URL = process.env.CACHLY_AUTH_URL ?? 'https://auth.cachly.dev';
|
|
54
56
|
const JWT = process.env.CACHLY_JWT ?? '';
|
|
55
57
|
const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
|
|
58
|
+
const CURRENT_VERSION = '0.5.26';
|
|
56
59
|
/**
|
|
57
60
|
* ┌──────────────────────────────────────────────────────────────────────┐
|
|
58
61
|
* │ EMBEDDING PROVIDER — pluggable, client-side first │
|
|
@@ -72,6 +75,44 @@ const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
|
|
|
72
75
|
*/
|
|
73
76
|
const EMBED_PROVIDER = (process.env.CACHLY_EMBED_PROVIDER ?? detectEmbedProvider()).toLowerCase();
|
|
74
77
|
|
|
78
|
+
// ── Confidence Decay Config (all values configurable via env) ─────────────────
|
|
79
|
+
const CONFIDENCE_WARN_DAYS = Number(process.env.CACHLY_CONFIDENCE_WARN_DAYS ?? 5); // → 0.7
|
|
80
|
+
const CONFIDENCE_STALE_DAYS = Number(process.env.CACHLY_CONFIDENCE_STALE_DAYS ?? 10); // → 0.5
|
|
81
|
+
const CONFIDENCE_WARN_VALUE = 0.7;
|
|
82
|
+
const CONFIDENCE_STALE_VALUE = 0.5;
|
|
83
|
+
|
|
84
|
+
/** Calculate current confidence for a lesson based on how long since last verified. */
|
|
85
|
+
function calculateConfidence(lesson: { verified_at?: string; ts: string; recall_count?: number }): number {
|
|
86
|
+
const ref = lesson.verified_at ?? lesson.ts;
|
|
87
|
+
const ageMs = Date.now() - new Date(ref).getTime();
|
|
88
|
+
const ageDays = ageMs / 86400000;
|
|
89
|
+
if (ageDays >= CONFIDENCE_STALE_DAYS) return CONFIDENCE_STALE_VALUE;
|
|
90
|
+
if (ageDays >= CONFIDENCE_WARN_DAYS) return CONFIDENCE_WARN_VALUE;
|
|
91
|
+
// Linear interpolation between fresh (1.0) and warn threshold
|
|
92
|
+
return 1.0 - (ageDays / CONFIDENCE_WARN_DAYS) * (1.0 - CONFIDENCE_WARN_VALUE);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Render a confidence badge string. */
|
|
96
|
+
function confidenceBadge(confidence: number, ageDays: number): string {
|
|
97
|
+
if (confidence >= 0.9) return '✅';
|
|
98
|
+
if (confidence >= 0.7) return `⚠️ (${Math.round(ageDays)}d old, confidence ${(confidence * 100).toFixed(0)}% — verify before applying)`;
|
|
99
|
+
return `🔴 STALE (${Math.round(ageDays)}d old, confidence ${(confidence * 100).toFixed(0)}% — likely outdated!)`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Category-specific required fields for structured lessons. */
|
|
103
|
+
const STRUCTURED_TEMPLATES: Record<string, { required: string[]; hint: string }> = {
|
|
104
|
+
'deploy': { required: ['commands'], hint: 'deploy:* needs commands[]' },
|
|
105
|
+
'bash': { required: ['commands'], hint: 'bash:* needs commands[]' },
|
|
106
|
+
'infra': { required: ['commands'], hint: 'infra:* needs commands[] + verified on real system' },
|
|
107
|
+
'pricing': { required: [], hint: 'pricing:* — add context with source (e.g. "Stripe Dashboard Apr 2026")' },
|
|
108
|
+
'stripe': { required: [], hint: 'stripe:* — add context with Stripe API version' },
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** Fast content hash for index invalidation (not cryptographic, just change detection) */
|
|
112
|
+
function simpleHash(text: string): string {
|
|
113
|
+
return createHash('md5').update(text).digest('hex').slice(0, 12);
|
|
114
|
+
}
|
|
115
|
+
|
|
75
116
|
function detectEmbedProvider(): string {
|
|
76
117
|
// Client-side keys first (direct, fast, no server roundtrip)
|
|
77
118
|
if (process.env.OPENAI_API_KEY) return 'openai';
|
|
@@ -260,13 +301,18 @@ function embedProviderHint(): string {
|
|
|
260
301
|
// • Recency boost — newer entries rank higher (exponential decay, 7-day half-life)
|
|
261
302
|
// • Multi-query splitting — numbered lists, semicolons, conjunctions
|
|
262
303
|
// • Fuzzy matching with Levenshtein distance ≤ 2
|
|
263
|
-
// • Multilingual stopwords (EN, DE, FR, ES, IT, PT)
|
|
304
|
+
// • Multilingual stopwords (EN, DE, FR, ES, IT, PT, ZH, JA, KO, AR, HE)
|
|
305
|
+
// • CJK character bigram extraction — handles Chinese, Japanese, Korean
|
|
306
|
+
// • RTL language support (Arabic, Hebrew) — word tokenization + light stemming
|
|
307
|
+
// • Romanization matching — katakana → romaji tokens for romaji queries
|
|
308
|
+
// • Cross-language retrieval — tech term synonyms EN↔JA↔ZH↔KO↔AR↔HE
|
|
264
309
|
// • Pipeline Redis reads for performance
|
|
265
310
|
//
|
|
266
311
|
|
|
267
312
|
/**
|
|
268
313
|
* Stopwords — filtered out during tokenization.
|
|
269
|
-
* Covers: English, German, French, Spanish, Italian, Portuguese
|
|
314
|
+
* Covers: English, German, French, Spanish, Italian, Portuguese,
|
|
315
|
+
* Chinese (Simplified + Traditional), Japanese, Korean.
|
|
270
316
|
* Keeps the index small and scores meaningful.
|
|
271
317
|
*/
|
|
272
318
|
const STOPWORDS = new Set([
|
|
@@ -340,15 +386,436 @@ const STOPWORDS = new Set([
|
|
|
340
386
|
'eu', 'tu', 'ele', 'ela', 'nós', 'eles', 'elas', 'você', 'vocês',
|
|
341
387
|
'não', 'já', 'sim', 'bem', 'muito', 'também', 'ainda', 'sempre',
|
|
342
388
|
'todo', 'toda', 'todos', 'cada', 'outro', 'outra', 'quando', 'porque',
|
|
389
|
+
// ── Chinese (Simplified + Traditional) — high-frequency function characters ──
|
|
390
|
+
'的', '了', '是', '在', '不', '和', '我', '他', '这', '中', '大', '为',
|
|
391
|
+
'上', '个', '国', '以', '要', '就', '出', '说', '们', '有', '来', '到',
|
|
392
|
+
'时', '地', '年', '得', '着', '那', '过', '后', '还', '与', '也', '可',
|
|
393
|
+
'于', '从', '但', '而', '被', '把', '让', '使', '对', '很', '都', '一',
|
|
394
|
+
'会', '没', '人', '它', '这个', '那个', '什么', '如果', '因为', '所以',
|
|
395
|
+
'已经', '可以', '这些', '那些', '我们', '他们', '她们', '它们',
|
|
396
|
+
// ── Japanese — common hiragana particles and auxiliary verbs ──
|
|
397
|
+
'の', 'は', 'が', 'を', 'に', 'で', 'と', 'も', 'や', 'か', 'な', 'ね',
|
|
398
|
+
'よ', 'わ', 'て', 'い', 'う', 'え', 'お', 'き', 'く', 'け', 'こ', 'さ',
|
|
399
|
+
'し', 'す', 'せ', 'そ', 'た', 'ち', 'つ', 'ぬ', 'ん', 'から', 'まで',
|
|
400
|
+
'より', 'へ', 'です', 'ます', 'ない', 'ある', 'いる', 'する', 'こと',
|
|
401
|
+
'もの', 'ので', 'では', 'には', 'との', 'への', 'から', 'まで',
|
|
402
|
+
// ── Korean — common particles and auxiliary forms ──
|
|
403
|
+
'이', '가', '은', '는', '을', '를', '의', '에', '와', '과', '도',
|
|
404
|
+
'로', '에서', '한', '하', '있', '없', '것', '수', '않', '들',
|
|
405
|
+
'이다', '하다', '이고', '이며', '라고', '에게', '에서', '으로',
|
|
406
|
+
// ── Arabic — high-frequency function words & particles ──
|
|
407
|
+
'في', 'من', 'إلى', 'على', 'عن', 'مع', 'هذا', 'هذه', 'ذلك', 'تلك',
|
|
408
|
+
'الذي', 'التي', 'الذين', 'اللواتي', 'هو', 'هي', 'هم', 'هن', 'نحن',
|
|
409
|
+
'أنت', 'أنتم', 'أنا', 'كان', 'كانت', 'كانوا', 'يكون', 'تكون',
|
|
410
|
+
'أن', 'إن', 'لأن', 'لكن', 'أو', 'بل', 'ثم', 'حتى', 'إذا', 'كي',
|
|
411
|
+
'قد', 'لقد', 'لم', 'لن', 'ما', 'لا', 'ليس', 'غير', 'بعض', 'كل',
|
|
412
|
+
'جميع', 'أي', 'كيف', 'متى', 'أين', 'لماذا', 'ماذا', 'هل', 'ال',
|
|
413
|
+
'و', 'ف', 'ب', 'ل', 'ك', 'يا', 'أم', 'إلا', 'عند', 'بين',
|
|
414
|
+
// ── Hebrew — common particles, pronouns, conjunctions ──
|
|
415
|
+
'של', 'את', 'אל', 'על', 'עם', 'לא', 'הוא', 'היא', 'הם', 'הן',
|
|
416
|
+
'אני', 'אתה', 'אנחנו', 'אתם', 'כי', 'אם', 'אבל', 'גם', 'כבר',
|
|
417
|
+
'רק', 'עוד', 'יש', 'אין', 'מה', 'זה', 'זאת', 'אלה', 'כל', 'כן',
|
|
418
|
+
'לו', 'לה', 'להם', 'בו', 'בה', 'בהם', 'שם', 'כך', 'כן', 'מי',
|
|
419
|
+
'אשר', 'אחרי', 'לפני', 'תחת', 'בין', 'מאז', 'עד', 'כמו', 'אז',
|
|
420
|
+
]);
|
|
421
|
+
|
|
422
|
+
// CJK Unicode ranges used for bigram extraction
|
|
423
|
+
const CJK_RE = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]/;
|
|
424
|
+
const SEGMENT_RE = /([\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]+|[^\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]+)/g;
|
|
425
|
+
|
|
426
|
+
// RTL Unicode ranges: Arabic (U+0600–U+06FF + extended), Hebrew (U+0590–U+05FF)
|
|
427
|
+
const RTL_RE = /[\u0590-\u05ff\u0600-\u06ff\u0750-\u077f\u08a0-\u08ff\ufb1d-\ufb4f\ufb50-\ufdff\ufe70-\ufeff]/;
|
|
428
|
+
|
|
429
|
+
// ── Katakana → Romaji conversion table (Hepburn system) ──────────────────────
|
|
430
|
+
// Digraphs must be listed before single chars so they match first.
|
|
431
|
+
const KATA_ROMAJI_TABLE: [string, string][] = [
|
|
432
|
+
// Special combinations for loanwords
|
|
433
|
+
['ファ', 'fa'], ['フィ', 'fi'], ['フェ', 'fe'], ['フォ', 'fo'],
|
|
434
|
+
['ティ', 'ti'], ['ディ', 'di'], ['トゥ', 'tu'], ['ドゥ', 'du'],
|
|
435
|
+
['ウィ', 'wi'], ['ウェ', 'we'], ['ウォ', 'wo'],
|
|
436
|
+
['チェ', 'che'], ['ジェ', 'je'], ['シェ', 'she'],
|
|
437
|
+
['イェ', 'ye'], ['ヴァ', 'va'], ['ヴィ', 'vi'], ['ヴェ', 've'], ['ヴォ', 'vo'],
|
|
438
|
+
// Digraphs (2-char → romaji)
|
|
439
|
+
['キャ', 'kya'], ['キュ', 'kyu'], ['キョ', 'kyo'],
|
|
440
|
+
['シャ', 'sha'], ['シュ', 'shu'], ['ショ', 'sho'],
|
|
441
|
+
['チャ', 'cha'], ['チュ', 'chu'], ['チョ', 'cho'],
|
|
442
|
+
['ニャ', 'nya'], ['ニュ', 'nyu'], ['ニョ', 'nyo'],
|
|
443
|
+
['ヒャ', 'hya'], ['ヒュ', 'hyu'], ['ヒョ', 'hyo'],
|
|
444
|
+
['ミャ', 'mya'], ['ミュ', 'myu'], ['ミョ', 'myo'],
|
|
445
|
+
['リャ', 'rya'], ['リュ', 'ryu'], ['リョ', 'ryo'],
|
|
446
|
+
['ギャ', 'gya'], ['ギュ', 'gyu'], ['ギョ', 'gyo'],
|
|
447
|
+
['ジャ', 'ja'], ['ジュ', 'ju'], ['ジョ', 'jo'],
|
|
448
|
+
['ビャ', 'bya'], ['ビュ', 'byu'], ['ビョ', 'byo'],
|
|
449
|
+
['ピャ', 'pya'], ['ピュ', 'pyu'], ['ピョ', 'pyo'],
|
|
450
|
+
// Single chars
|
|
451
|
+
['ア', 'a'], ['イ', 'i'], ['ウ', 'u'], ['エ', 'e'], ['オ', 'o'],
|
|
452
|
+
['カ', 'ka'], ['キ', 'ki'], ['ク', 'ku'], ['ケ', 'ke'], ['コ', 'ko'],
|
|
453
|
+
['サ', 'sa'], ['シ', 'shi'], ['ス', 'su'], ['セ', 'se'], ['ソ', 'so'],
|
|
454
|
+
['タ', 'ta'], ['チ', 'chi'], ['ツ', 'tsu'], ['テ', 'te'], ['ト', 'to'],
|
|
455
|
+
['ナ', 'na'], ['ニ', 'ni'], ['ヌ', 'nu'], ['ネ', 'ne'], ['ノ', 'no'],
|
|
456
|
+
['ハ', 'ha'], ['ヒ', 'hi'], ['フ', 'fu'], ['ヘ', 'he'], ['ホ', 'ho'],
|
|
457
|
+
['マ', 'ma'], ['ミ', 'mi'], ['ム', 'mu'], ['メ', 'me'], ['モ', 'mo'],
|
|
458
|
+
['ヤ', 'ya'], ['ユ', 'yu'], ['ヨ', 'yo'],
|
|
459
|
+
['ラ', 'ra'], ['リ', 'ri'], ['ル', 'ru'], ['レ', 're'], ['ロ', 'ro'],
|
|
460
|
+
['ワ', 'wa'], ['ヲ', 'wo'], ['ン', 'n'],
|
|
461
|
+
// Voiced
|
|
462
|
+
['ガ', 'ga'], ['ギ', 'gi'], ['グ', 'gu'], ['ゲ', 'ge'], ['ゴ', 'go'],
|
|
463
|
+
['ザ', 'za'], ['ジ', 'ji'], ['ズ', 'zu'], ['ゼ', 'ze'], ['ゾ', 'zo'],
|
|
464
|
+
['ダ', 'da'], ['ヂ', 'ji'], ['ヅ', 'zu'], ['デ', 'de'], ['ド', 'do'],
|
|
465
|
+
['バ', 'ba'], ['ビ', 'bi'], ['ブ', 'bu'], ['ベ', 'be'], ['ボ', 'bo'],
|
|
466
|
+
['パ', 'pa'], ['ピ', 'pi'], ['プ', 'pu'], ['ペ', 'pe'], ['ポ', 'po'],
|
|
467
|
+
['ヴ', 'v'],
|
|
468
|
+
// Long vowel / small chars
|
|
469
|
+
['ー', ''], ['ァ', 'a'], ['ィ', 'i'], ['ゥ', 'u'], ['ェ', 'e'], ['ォ', 'o'],
|
|
470
|
+
['ッ', ''], // handled separately (doubles next consonant)
|
|
471
|
+
];
|
|
472
|
+
|
|
473
|
+
const KATA_MAP = new Map<string, string>(KATA_ROMAJI_TABLE);
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Convert a katakana string to Hepburn romaji.
|
|
477
|
+
* Handles digraphs, geminate consonants (ッ), and long vowel marks (ー).
|
|
478
|
+
*/
|
|
479
|
+
function katakanaToRomaji(kata: string): string {
|
|
480
|
+
let result = '';
|
|
481
|
+
let i = 0;
|
|
482
|
+
while (i < kata.length) {
|
|
483
|
+
// Geminate consonant: ッ doubles the following consonant
|
|
484
|
+
if (kata[i] === 'ッ' && i + 1 < kata.length) {
|
|
485
|
+
const next = KATA_MAP.get(kata[i + 1]) ?? KATA_MAP.get(kata[i + 1] + kata[i + 2]) ?? '';
|
|
486
|
+
if (next.length > 0) result += next[0]; // double first consonant
|
|
487
|
+
i++;
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
// Try 2-char digraph first
|
|
491
|
+
if (i + 1 < kata.length) {
|
|
492
|
+
const two = kata[i] + kata[i + 1];
|
|
493
|
+
const r2 = KATA_MAP.get(two);
|
|
494
|
+
if (r2 !== undefined) { result += r2; i += 2; continue; }
|
|
495
|
+
}
|
|
496
|
+
// Single char
|
|
497
|
+
const r1 = KATA_MAP.get(kata[i]);
|
|
498
|
+
if (r1 !== undefined) result += r1;
|
|
499
|
+
i++;
|
|
500
|
+
}
|
|
501
|
+
return result;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Arabic light stemmer — strips definite article and common prefix particles.
|
|
506
|
+
* Handles: ال (al-), و (wa-), ب (bi-), ل (li-), ف (fa-), ك (ka-).
|
|
507
|
+
* Only strips when the result is still ≥ 3 chars to avoid over-stemming.
|
|
508
|
+
*/
|
|
509
|
+
function arabicLightStem(word: string): string {
|
|
510
|
+
if (word.startsWith('ال') && word.length > 4) return word.slice(2);
|
|
511
|
+
if (word.length > 3 && 'وبلفك'.includes(word[0]) && RTL_RE.test(word[1])) return word.slice(1);
|
|
512
|
+
return word;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ── Cross-lingual tech term dictionary ────────────────────────────────────────
|
|
516
|
+
// Maps tokens (English, Japanese katakana, Chinese, Korean, Arabic, Hebrew)
|
|
517
|
+
// to their equivalents in other languages. Used to expand tokens at search time
|
|
518
|
+
// so "deploy" finds documents containing "デプロイ" / "部署" / "배포", and vice versa.
|
|
519
|
+
//
|
|
520
|
+
// Keys must be lowercase. CJK values will be bigram-expanded at use time.
|
|
521
|
+
const CROSS_LINGUAL_MAP = new Map<string, string[]>([
|
|
522
|
+
// English → all
|
|
523
|
+
['deploy', ['デプロイ', '部署', '배포', 'deployment', 'deploying']],
|
|
524
|
+
['deployment', ['デプロイ', '部署', '배포', 'deploy']],
|
|
525
|
+
['container', ['コンテナ', '容器', '컨테이너', 'docker']],
|
|
526
|
+
['server', ['サーバー', 'サーバ', '服务器', '서버', 'سيرفر', 'שרת']],
|
|
527
|
+
['database', ['データベース', '数据库', '데이터베이스', 'db', 'قاعدة البيانات']],
|
|
528
|
+
['cache', ['キャッシュ', '缓存', '캐시', 'كاش', 'מטמון']],
|
|
529
|
+
['error', ['エラー', '错误', '오류', 'خطأ', 'שגיאה', 'exception', 'err']],
|
|
530
|
+
['bug', ['バグ', '缺陷', '버그', 'issue', 'defect', 'خلل']],
|
|
531
|
+
['fix', ['修正', '修复', '수정', 'bugfix', 'patch', 'hotfix', 'إصلاح', 'תיקון']],
|
|
532
|
+
['build', ['ビルド', '构建', '빌드', 'بناء', 'בנייה']],
|
|
533
|
+
['test', ['テスト', '测试', '테스트', 'اختبار', 'בדיקה']],
|
|
534
|
+
['auth', ['認証', '认证', '인증', 'authentication', 'login', 'oauth', 'مصادقة', 'אימות']],
|
|
535
|
+
['kubernetes', ['クベルネテス', 'k8s', 'kube']],
|
|
536
|
+
['network', ['ネットワーク', '网络', '네트워크', 'شبكة', 'רשת']],
|
|
537
|
+
['timeout', ['タイムアウト', '超时', '타임아웃', 'مهلة']],
|
|
538
|
+
['memory', ['メモリ', '内存', '메모리', 'ram', 'ذاكرة', 'זיכרון']],
|
|
539
|
+
['config', ['設定', '配置', '설정', 'configuration', 'settings', 'conf', 'إعدادات', 'הגדרות']],
|
|
540
|
+
['install', ['インストール', '安装', '설치', 'تثبيت', 'התקנה']],
|
|
541
|
+
['update', ['アップデート', '更新', '업데이트', 'upgrade', 'تحديث', 'עדכון']],
|
|
542
|
+
['log', ['ログ', '日志', '로그', 'logging', 'سجل', 'יומן']],
|
|
543
|
+
['port', ['ポート', '端口', '포트', 'منفذ', 'פורט']],
|
|
544
|
+
['file', ['ファイル', '文件', '파일', 'ملف', 'קובץ']],
|
|
545
|
+
['image', ['イメージ', '镜像', '이미지', 'صورة', 'תמונה']],
|
|
546
|
+
['volume', ['ボリューム', '卷', '볼륨']],
|
|
547
|
+
['cluster', ['クラスター', '集群', '클러스터']],
|
|
548
|
+
['node', ['ノード', '节点', '노드']],
|
|
549
|
+
['service', ['サービス', '服务', '서비스', 'svc', 'خدمة', 'שירות']],
|
|
550
|
+
['certificate', ['証明書', '证书', '인증서', 'cert', 'ssl', 'tls', 'شهادة', 'תעודה']],
|
|
551
|
+
['password', ['パスワード', '密码', '비밀번호', 'passwd', 'pwd', 'كلمة المرور', 'סיסמה']],
|
|
552
|
+
['token', ['トークン', '令牌', '토큰', 'jwt', 'secret', 'رمز', 'אסימון']],
|
|
553
|
+
['health', ['ヘルス', '健康', '헬스', 'healthcheck', 'probe', 'صحة']],
|
|
554
|
+
['migration', ['マイグレーション', '迁移', '마이그레이션', 'migrate', 'هجرة']],
|
|
555
|
+
['backup', ['バックアップ', '备份', '백업', 'نسخ احتياطي', 'גיבוי']],
|
|
556
|
+
['monitor', ['モニター', '监控', '모니터링', 'monitoring', 'metrics', 'alert', 'مراقبة', 'ניטור']],
|
|
557
|
+
['performance', ['パフォーマンス', '性能', '성능', 'latency', 'throughput']],
|
|
558
|
+
['connection', ['接続', '连接', '연결', 'conn', 'socket', 'اتصال', 'חיבור']],
|
|
559
|
+
['queue', ['キュー', '队列', '큐', 'طابور']],
|
|
560
|
+
['redis', ['レディス', '레디스']],
|
|
561
|
+
['nginx', ['エンジンエックス']],
|
|
562
|
+
['linux', ['リナックス', 'لينكس']],
|
|
563
|
+
['api', ['エーピーアイ', '接口', 'endpoint', 'واجهة برمجية', 'ממשק']],
|
|
564
|
+
['healthcheck', ['ヘルスチェック', 'health', 'probe', 'فحص الصحة']],
|
|
565
|
+
['loadbalancer',['ロードバランサー', '负载均衡', '로드밸런서', 'lb']],
|
|
566
|
+
['ssl', ['tls', 'https', 'certificate', 'cert']],
|
|
567
|
+
['tls', ['ssl', 'https', 'certificate', 'cert']],
|
|
568
|
+
['docker', ['container', 'コンテナ', '容器', '컨테이너', 'دوكر']],
|
|
569
|
+
['git', ['version control', 'repo', 'repository', 'commit', 'push', 'pull']],
|
|
570
|
+
['ci', ['pipeline', 'github actions', 'gitlab', 'jenkins', 'build']],
|
|
571
|
+
['cd', ['deploy', 'deployment', 'release']],
|
|
572
|
+
['debug', ['デバッグ', '调试', '디버그', 'debugging', 'breakpoint', 'تصحيح', 'איתור באגים']],
|
|
573
|
+
['crash', ['クラッシュ', '崩溃', '크래시', 'panic', 'segfault', 'انهيار']],
|
|
574
|
+
['restart', ['再起動', '重启', '재시작', 'reboot', 'إعادة تشغيل', 'הפעלה מחדש']],
|
|
575
|
+
['permission', ['権限', '权限', '권한', 'access', 'acl', 'chmod', 'صلاحية', 'הרשאה']],
|
|
576
|
+
// Japanese katakana → English
|
|
577
|
+
['デプロイ', ['deploy', 'deployment']],
|
|
578
|
+
['コンテナ', ['container', 'docker']],
|
|
579
|
+
['サーバー', ['server']],
|
|
580
|
+
['サーバ', ['server']],
|
|
581
|
+
['データベース',['database', 'db']],
|
|
582
|
+
['キャッシュ', ['cache']],
|
|
583
|
+
['エラー', ['error', 'err']],
|
|
584
|
+
['バグ', ['bug', 'issue']],
|
|
585
|
+
['ビルド', ['build']],
|
|
586
|
+
['テスト', ['test']],
|
|
587
|
+
['認証', ['auth', 'authentication']],
|
|
588
|
+
['設定', ['config', 'configuration']],
|
|
589
|
+
['インストール',['install']],
|
|
590
|
+
['アップデート',['update', 'upgrade']],
|
|
591
|
+
['ログ', ['log', 'logs']],
|
|
592
|
+
['ポート', ['port']],
|
|
593
|
+
['ファイル', ['file']],
|
|
594
|
+
['イメージ', ['image']],
|
|
595
|
+
['クラスター', ['cluster']],
|
|
596
|
+
['ノード', ['node']],
|
|
597
|
+
['サービス', ['service']],
|
|
598
|
+
['パスワード', ['password']],
|
|
599
|
+
['トークン', ['token']],
|
|
600
|
+
['バックアップ',['backup']],
|
|
601
|
+
['モニター', ['monitor', 'monitoring']],
|
|
602
|
+
['接続', ['connection']],
|
|
603
|
+
['キュー', ['queue']],
|
|
604
|
+
['ヘルスチェック',['healthcheck', 'health check', 'health']],
|
|
605
|
+
['ロードバランサー',['loadbalancer', 'load balancer']],
|
|
606
|
+
['デバッグ', ['debug']],
|
|
607
|
+
['クラッシュ', ['crash', 'panic']],
|
|
608
|
+
['再起動', ['restart', 'reboot']],
|
|
609
|
+
['権限', ['permission', 'access']],
|
|
610
|
+
// Chinese → English
|
|
611
|
+
['部署', ['deploy', 'deployment']],
|
|
612
|
+
['容器', ['container', 'docker']],
|
|
613
|
+
['服务器', ['server']],
|
|
614
|
+
['数据库', ['database']],
|
|
615
|
+
['缓存', ['cache']],
|
|
616
|
+
['错误', ['error']],
|
|
617
|
+
['修复', ['fix', 'bugfix']],
|
|
618
|
+
['构建', ['build']],
|
|
619
|
+
['测试', ['test']],
|
|
620
|
+
['认证', ['auth']],
|
|
621
|
+
['配置', ['config']],
|
|
622
|
+
['安装', ['install']],
|
|
623
|
+
['更新', ['update']],
|
|
624
|
+
['日志', ['log']],
|
|
625
|
+
['端口', ['port']],
|
|
626
|
+
['镜像', ['image']],
|
|
627
|
+
['集群', ['cluster']],
|
|
628
|
+
['节点', ['node']],
|
|
629
|
+
['服务', ['service']],
|
|
630
|
+
['密码', ['password']],
|
|
631
|
+
['令牌', ['token']],
|
|
632
|
+
['备份', ['backup']],
|
|
633
|
+
['监控', ['monitor']],
|
|
634
|
+
['连接', ['connection']],
|
|
635
|
+
['队列', ['queue']],
|
|
636
|
+
['性能', ['performance']],
|
|
637
|
+
['内存', ['memory']],
|
|
638
|
+
['调试', ['debug']],
|
|
639
|
+
['崩溃', ['crash']],
|
|
640
|
+
['重启', ['restart']],
|
|
641
|
+
['权限', ['permission']],
|
|
642
|
+
// Korean → English
|
|
643
|
+
['배포', ['deploy', 'deployment']],
|
|
644
|
+
['컨테이너', ['container']],
|
|
645
|
+
['서버', ['server']],
|
|
646
|
+
['데이터베이스',['database']],
|
|
647
|
+
['캐시', ['cache']],
|
|
648
|
+
['오류', ['error']],
|
|
649
|
+
['수정', ['fix']],
|
|
650
|
+
['빌드', ['build']],
|
|
651
|
+
['테스트', ['test']],
|
|
652
|
+
['인증', ['auth']],
|
|
653
|
+
['설정', ['config']],
|
|
654
|
+
['설치', ['install']],
|
|
655
|
+
['업데이트', ['update']],
|
|
656
|
+
['로그', ['log']],
|
|
657
|
+
['포트', ['port']],
|
|
658
|
+
['이미지', ['image']],
|
|
659
|
+
['클러스터', ['cluster']],
|
|
660
|
+
['노드', ['node']],
|
|
661
|
+
['서비스', ['service']],
|
|
662
|
+
['토큰', ['token']],
|
|
663
|
+
['백업', ['backup']],
|
|
664
|
+
['모니터링', ['monitor']],
|
|
665
|
+
['연결', ['connection']],
|
|
666
|
+
['큐', ['queue']],
|
|
667
|
+
['성능', ['performance']],
|
|
668
|
+
['메모리', ['memory']],
|
|
669
|
+
['디버그', ['debug']],
|
|
670
|
+
['크래시', ['crash']],
|
|
671
|
+
['재시작', ['restart']],
|
|
672
|
+
['권한', ['permission']],
|
|
673
|
+
// Arabic → English
|
|
674
|
+
['خطأ', ['error']],
|
|
675
|
+
['إصلاح', ['fix']],
|
|
676
|
+
['بناء', ['build']],
|
|
677
|
+
['اختبار', ['test']],
|
|
678
|
+
['مصادقة', ['auth']],
|
|
679
|
+
['إعدادات', ['config']],
|
|
680
|
+
['تثبيت', ['install']],
|
|
681
|
+
['تحديث', ['update']],
|
|
682
|
+
['سجل', ['log']],
|
|
683
|
+
['صورة', ['image']],
|
|
684
|
+
['خدمة', ['service']],
|
|
685
|
+
['نسخ احتياطي', ['backup']],
|
|
686
|
+
['مراقبة', ['monitor']],
|
|
687
|
+
['اتصال', ['connection']],
|
|
688
|
+
['ذاكرة', ['memory']],
|
|
689
|
+
['تصحيح', ['debug']],
|
|
690
|
+
['شبكة', ['network']],
|
|
691
|
+
// Hebrew → English
|
|
692
|
+
['שגיאה', ['error']],
|
|
693
|
+
['תיקון', ['fix']],
|
|
694
|
+
['בנייה', ['build']],
|
|
695
|
+
['בדיקה', ['test']],
|
|
696
|
+
['אימות', ['auth']],
|
|
697
|
+
['הגדרות', ['config']],
|
|
698
|
+
['התקנה', ['install']],
|
|
699
|
+
['עדכון', ['update']],
|
|
700
|
+
['יומן', ['log']],
|
|
701
|
+
['שרת', ['server']],
|
|
702
|
+
['שירות', ['service']],
|
|
703
|
+
['גיבוי', ['backup']],
|
|
704
|
+
['ניטור', ['monitor']],
|
|
705
|
+
['חיבור', ['connection']],
|
|
706
|
+
['זיכרון', ['memory']],
|
|
707
|
+
['רשת', ['network']],
|
|
708
|
+
['תמונה', ['image']],
|
|
709
|
+
['סיסמה', ['password']],
|
|
710
|
+
['הרשאה', ['permission']],
|
|
343
711
|
]);
|
|
344
712
|
|
|
345
|
-
/**
|
|
713
|
+
/**
|
|
714
|
+
* Expand a token to its cross-lingual synonyms.
|
|
715
|
+
* Returns synonyms to be added to the token stream (pre-tokenized).
|
|
716
|
+
* CJK synonyms are NOT pre-bigram'd here — caller handles that.
|
|
717
|
+
*/
|
|
718
|
+
function expandCrossLingual(token: string): string[] {
|
|
719
|
+
return CROSS_LINGUAL_MAP.get(token) ?? [];
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Tokenize text into meaningful keywords.
|
|
724
|
+
* Handles accented Latin, RTL (Arabic, Hebrew), and CJK (Chinese, Japanese, Korean).
|
|
725
|
+
*
|
|
726
|
+
* Features:
|
|
727
|
+
* • CJK → character bigrams
|
|
728
|
+
* • Katakana → additionally emits Hepburn romaji tokens (enables romaji queries)
|
|
729
|
+
* • Arabic/Hebrew → word tokenization with light Arabic stemming
|
|
730
|
+
* • All scripts → cross-lingual synonym expansion (EN↔JA↔ZH↔KO↔AR↔HE)
|
|
731
|
+
*/
|
|
346
732
|
function tokenize(text: string): string[] {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
733
|
+
const tokens: string[] = [];
|
|
734
|
+
const lower = text.toLowerCase();
|
|
735
|
+
|
|
736
|
+
for (const [seg] of lower.matchAll(SEGMENT_RE)) {
|
|
737
|
+
if (CJK_RE.test(seg)) {
|
|
738
|
+
// CJK segment — extract overlapping character bigrams
|
|
739
|
+
for (let i = 0; i < seg.length - 1; i++) {
|
|
740
|
+
const bigram = seg[i] + seg[i + 1];
|
|
741
|
+
if (!STOPWORDS.has(seg[i]) && !STOPWORDS.has(bigram)) {
|
|
742
|
+
tokens.push(bigram);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
// Trailing single character (for 1-char CJK terms)
|
|
746
|
+
if (seg.length >= 1) {
|
|
747
|
+
const last = seg[seg.length - 1];
|
|
748
|
+
if (!STOPWORDS.has(last)) tokens.push(last);
|
|
749
|
+
}
|
|
750
|
+
// Whole-segment cross-lingual lookup (for known tech terms like デプロイ, 部署, 배포)
|
|
751
|
+
// This ensures CJK full-words map to their English equivalents
|
|
752
|
+
const segSyns = expandCrossLingual(seg);
|
|
753
|
+
for (const syn of segSyns) {
|
|
754
|
+
if (!CJK_RE.test(syn)) {
|
|
755
|
+
syn.split(/\s+/).filter(w => w.length > 1 && !STOPWORDS.has(w)).forEach(w => tokens.push(w));
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
// Romanization: extract katakana-only portion and emit romaji tokens.
|
|
759
|
+
// This allows users to search "kontena" or "depuroi" to find katakana docs.
|
|
760
|
+
const kataOnly = seg.replace(/[^\u30a0-\u30ff]/g, '');
|
|
761
|
+
if (kataOnly.length >= 2) {
|
|
762
|
+
const romaji = katakanaToRomaji(kataOnly);
|
|
763
|
+
if (romaji.length >= 2 && romaji.length <= 40 && !STOPWORDS.has(romaji)) {
|
|
764
|
+
tokens.push(romaji);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
} else if (RTL_RE.test(seg)) {
|
|
768
|
+
// RTL segment (Arabic / Hebrew) — space-separated words with light stemming
|
|
769
|
+
const words = seg.trim().split(/\s+/);
|
|
770
|
+
for (const w of words) {
|
|
771
|
+
if (w.length <= 1 || STOPWORDS.has(w)) continue;
|
|
772
|
+
const stemmed = arabicLightStem(w);
|
|
773
|
+
if (stemmed.length > 1 && !STOPWORDS.has(stemmed)) tokens.push(stemmed);
|
|
774
|
+
}
|
|
775
|
+
} else {
|
|
776
|
+
// Latin / other — existing logic preserved exactly
|
|
777
|
+
const words = seg
|
|
778
|
+
.replace(/[^a-záàâãäåæçéèêëíìîïñóòôõöúùûüýÿßœ0-9:_\-./]/g, ' ')
|
|
779
|
+
.split(/\s+/)
|
|
780
|
+
.filter(w => w.length > 1 && !STOPWORDS.has(w));
|
|
781
|
+
tokens.push(...words);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// ── Cross-lingual expansion ──────────────────────────────────────────────
|
|
786
|
+
// For each token, look up synonyms in other languages and add them.
|
|
787
|
+
// CJK synonyms are bigram-expanded inline; Latin/RTL synonyms added as-is.
|
|
788
|
+
const expansions: string[] = [];
|
|
789
|
+
const seen = new Set(tokens);
|
|
790
|
+
for (const tok of tokens) {
|
|
791
|
+
const syns = expandCrossLingual(tok);
|
|
792
|
+
for (const syn of syns) {
|
|
793
|
+
if (seen.has(syn)) continue;
|
|
794
|
+
seen.add(syn);
|
|
795
|
+
if (CJK_RE.test(syn)) {
|
|
796
|
+
// CJK synonym → expand to character bigrams
|
|
797
|
+
for (let i = 0; i < syn.length - 1; i++) {
|
|
798
|
+
const bg = syn[i] + syn[i + 1];
|
|
799
|
+
if (!STOPWORDS.has(syn[i]) && !STOPWORDS.has(bg)) expansions.push(bg);
|
|
800
|
+
}
|
|
801
|
+
if (syn.length >= 1) {
|
|
802
|
+
const last = syn[syn.length - 1];
|
|
803
|
+
if (!STOPWORDS.has(last)) expansions.push(last);
|
|
804
|
+
}
|
|
805
|
+
} else if (RTL_RE.test(syn)) {
|
|
806
|
+
// RTL synonym
|
|
807
|
+
const stemmed = arabicLightStem(syn);
|
|
808
|
+
if (stemmed.length > 1 && !STOPWORDS.has(stemmed)) expansions.push(stemmed);
|
|
809
|
+
} else {
|
|
810
|
+
// Latin synonym — split in case of multi-word (e.g. "load balancer")
|
|
811
|
+
const parts = syn.split(/\s+/).filter(w => w.length > 1 && !STOPWORDS.has(w));
|
|
812
|
+
expansions.push(...parts);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
tokens.push(...expansions);
|
|
817
|
+
|
|
818
|
+
return tokens;
|
|
352
819
|
}
|
|
353
820
|
|
|
354
821
|
/**
|
|
@@ -661,7 +1128,8 @@ async function keywordSearch(
|
|
|
661
1128
|
}
|
|
662
1129
|
|
|
663
1130
|
// ── Exported for testing ──────────────────────────────────────────────────────
|
|
664
|
-
export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS
|
|
1131
|
+
export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS,
|
|
1132
|
+
katakanaToRomaji, arabicLightStem, expandCrossLingual, CROSS_LINGUAL_MAP };
|
|
665
1133
|
|
|
666
1134
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
667
1135
|
|
|
@@ -696,26 +1164,11 @@ interface SemanticSearchResponse {
|
|
|
696
1164
|
|
|
697
1165
|
// ── Connection pool ───────────────────────────────────────────────────────────
|
|
698
1166
|
|
|
699
|
-
/** Reuse Redis connections across tool calls (keyed by instance_id).
|
|
1167
|
+
/** Reuse Redis connections across tool calls (keyed by instance_id). */
|
|
700
1168
|
const pool = new Map<string, Redis>();
|
|
701
|
-
const POOL_MAX = 20;
|
|
702
1169
|
|
|
703
1170
|
async function getConnection(instance_id: string): Promise<Redis> {
|
|
704
|
-
if (pool.has(instance_id))
|
|
705
|
-
// Move to end (most-recently-used)
|
|
706
|
-
const conn = pool.get(instance_id)!;
|
|
707
|
-
pool.delete(instance_id);
|
|
708
|
-
pool.set(instance_id, conn);
|
|
709
|
-
return conn;
|
|
710
|
-
}
|
|
711
|
-
// Evict oldest entry when pool is full
|
|
712
|
-
if (pool.size >= POOL_MAX) {
|
|
713
|
-
const oldest = pool.keys().next().value;
|
|
714
|
-
if (oldest) {
|
|
715
|
-
pool.get(oldest)?.quit().catch(() => undefined);
|
|
716
|
-
pool.delete(oldest);
|
|
717
|
-
}
|
|
718
|
-
}
|
|
1171
|
+
if (pool.has(instance_id)) return pool.get(instance_id)!;
|
|
719
1172
|
|
|
720
1173
|
const inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
|
|
721
1174
|
if (inst.status !== 'running') {
|
|
@@ -785,34 +1238,13 @@ async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T>
|
|
|
785
1238
|
}
|
|
786
1239
|
const res = await fetch(`${API_URL}${path}`, {
|
|
787
1240
|
...options,
|
|
788
|
-
signal: AbortSignal.timeout(15000),
|
|
789
1241
|
headers: {
|
|
790
1242
|
'Content-Type': 'application/json',
|
|
791
1243
|
Authorization: `Bearer ${JWT}`,
|
|
792
1244
|
...options.headers,
|
|
793
1245
|
},
|
|
794
|
-
}).catch((err: Error) => {
|
|
795
|
-
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
|
|
796
|
-
throw new McpError(ErrorCode.InternalError,
|
|
797
|
-
`cachly API timed out after 15s — check your connection or try again.`);
|
|
798
|
-
}
|
|
799
|
-
throw new McpError(ErrorCode.InternalError, `cachly API unreachable: ${err.message}`);
|
|
800
1246
|
});
|
|
801
1247
|
if (!res.ok) {
|
|
802
|
-
if (res.status === 401 || res.status === 403) {
|
|
803
|
-
throw new McpError(
|
|
804
|
-
ErrorCode.InvalidRequest,
|
|
805
|
-
`cachly API: authentication failed (${res.status}) — your JWT may be expired or invalid.\n` +
|
|
806
|
-
`Get a new API token at https://cachly.dev/settings or run \`get_api_status\` to check token expiry.`
|
|
807
|
-
);
|
|
808
|
-
}
|
|
809
|
-
if (res.status === 503 || res.status === 502) {
|
|
810
|
-
throw new McpError(
|
|
811
|
-
ErrorCode.InternalError,
|
|
812
|
-
`cachly API: auth service unavailable (${res.status}) — the authentication service may be temporarily down.\n` +
|
|
813
|
-
`Please try again in a moment or contact support@cachly.dev`
|
|
814
|
-
);
|
|
815
|
-
}
|
|
816
1248
|
const body = await res.json().catch(() => ({ error: res.statusText }));
|
|
817
1249
|
throw new McpError(
|
|
818
1250
|
ErrorCode.InternalError,
|
|
@@ -839,8 +1271,8 @@ const TOOLS = [
|
|
|
839
1271
|
description:
|
|
840
1272
|
'Create a new managed Valkey/Redis cache instance on cachly.dev. ' +
|
|
841
1273
|
'Free tier provisions in ~30 seconds. Paid tiers return a Stripe checkout URL. ' +
|
|
842
|
-
'Available tiers: free (
|
|
843
|
-
'speed (
|
|
1274
|
+
'Available tiers: free (25 MB), dev (200 MB, €19/mo), pro (900 MB, €49/mo), ' +
|
|
1275
|
+
'speed (900 MB Dragonfly + Semantic Cache, €79/mo), business (7 GB, €199/mo).',
|
|
844
1276
|
inputSchema: {
|
|
845
1277
|
type: 'object',
|
|
846
1278
|
properties: {
|
|
@@ -1215,10 +1647,10 @@ const TOOLS = [
|
|
|
1215
1647
|
{
|
|
1216
1648
|
name: 'get_api_status',
|
|
1217
1649
|
description:
|
|
1218
|
-
'Check the cachly API health
|
|
1219
|
-
'Returns
|
|
1220
|
-
'
|
|
1221
|
-
'Use this to debug connection issues
|
|
1650
|
+
'Check the cachly API health and your authentication status. ' +
|
|
1651
|
+
'Returns whether the JWT is valid, your user ID (sub claim), ' +
|
|
1652
|
+
'token expiry, and the auth provider (keycloak). ' +
|
|
1653
|
+
'Use this to debug connection issues or verify your CACHLY_JWT is correct.',
|
|
1222
1654
|
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
1223
1655
|
},
|
|
1224
1656
|
// ── Thinking/Context Cache (for AI assistants) ────────────────────────────
|
|
@@ -1309,7 +1741,9 @@ const TOOLS = [
|
|
|
1309
1741
|
'Fields: topic (short slug like "deploy:web"), outcome ("success"|"failure"), ' +
|
|
1310
1742
|
'what_worked (what solved it), what_failed (what did NOT work), context (extra details). ' +
|
|
1311
1743
|
'Supports structured metadata: severity, file_paths (files involved), commands (working commands), tags. ' +
|
|
1312
|
-
'Deduplication: if a lesson for this topic already exists, it is updated
|
|
1744
|
+
'Deduplication: if a lesson for this topic already exists, it is updated with full audit trail. ' +
|
|
1745
|
+
'Contradiction detection: warns if new outcome conflicts with existing lesson outcome. ' +
|
|
1746
|
+
'Confidence: lesson starts at 1.0, decays after 5d (→0.7) and 10d (→0.5) without recall. ' +
|
|
1313
1747
|
'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"])',
|
|
1314
1748
|
inputSchema: {
|
|
1315
1749
|
type: 'object',
|
|
@@ -1340,6 +1774,15 @@ const TOOLS = [
|
|
|
1340
1774
|
items: { type: 'string' },
|
|
1341
1775
|
description: 'Topic tags for filtering (e.g. ["bash", "deploy", "env"])',
|
|
1342
1776
|
},
|
|
1777
|
+
depends_on: {
|
|
1778
|
+
type: 'array',
|
|
1779
|
+
items: { type: 'string' },
|
|
1780
|
+
description: 'Prerequisites this lesson depends on (e.g. ["node:>=20", "docker:running", "wireguard:active"]). When a dependency is marked stale, all dependent lessons get needs_review.',
|
|
1781
|
+
},
|
|
1782
|
+
author: {
|
|
1783
|
+
type: 'string',
|
|
1784
|
+
description: 'Name or handle of the person storing this lesson (e.g. "alice", "bob"). Used for Team Telepathy — teammates see each other\'s lessons in session_start.',
|
|
1785
|
+
},
|
|
1343
1786
|
},
|
|
1344
1787
|
required: ['instance_id', 'topic', 'outcome', 'what_worked'],
|
|
1345
1788
|
},
|
|
@@ -1349,7 +1792,9 @@ const TOOLS = [
|
|
|
1349
1792
|
description:
|
|
1350
1793
|
'Recall the best known solution for a topic from past lessons. ' +
|
|
1351
1794
|
'Call this BEFORE attempting any task that might have been done before. ' +
|
|
1352
|
-
'Returns the most recent successful lesson for the topic,
|
|
1795
|
+
'Returns the most recent successful lesson for the topic, with confidence indicator. ' +
|
|
1796
|
+
'⚠️ badge = lesson is >5d old (verify before applying). 🔴 = >10d old (likely stale!). ' +
|
|
1797
|
+
'Recalling a lesson resets its confidence clock to 1.0 (marks as recently verified). ' +
|
|
1353
1798
|
'Example: recall_best_solution(topic="deploy:web") → returns the working deploy command.',
|
|
1354
1799
|
inputSchema: {
|
|
1355
1800
|
type: 'object',
|
|
@@ -1360,35 +1805,6 @@ const TOOLS = [
|
|
|
1360
1805
|
required: ['instance_id', 'topic'],
|
|
1361
1806
|
},
|
|
1362
1807
|
},
|
|
1363
|
-
{
|
|
1364
|
-
name: 'list_lessons',
|
|
1365
|
-
description:
|
|
1366
|
-
'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. ' +
|
|
1367
|
-
'Returns topics sorted by most recently stored, with severity and recall count.',
|
|
1368
|
-
inputSchema: {
|
|
1369
|
-
type: 'object',
|
|
1370
|
-
properties: {
|
|
1371
|
-
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1372
|
-
filter: { type: 'string', description: 'Optional substring filter, e.g. "deploy" returns only deploy-related topics' },
|
|
1373
|
-
},
|
|
1374
|
-
required: ['instance_id'],
|
|
1375
|
-
},
|
|
1376
|
-
},
|
|
1377
|
-
{
|
|
1378
|
-
name: 'forget_lesson',
|
|
1379
|
-
description:
|
|
1380
|
-
'Delete a stored lesson from the Brain. Use this when a lesson is wrong, outdated, or was learned incorrectly. ' +
|
|
1381
|
-
'Removes both the best-solution entry and the attempt history for the topic. ' +
|
|
1382
|
-
'Example: forget_lesson(topic="deploy:web") → deletes the wrong deployment lesson so a correct one can be stored.',
|
|
1383
|
-
inputSchema: {
|
|
1384
|
-
type: 'object',
|
|
1385
|
-
properties: {
|
|
1386
|
-
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1387
|
-
topic: { type: 'string', description: 'Exact topic slug to delete, e.g. "deploy:web"' },
|
|
1388
|
-
},
|
|
1389
|
-
required: ['instance_id', 'topic'],
|
|
1390
|
-
},
|
|
1391
|
-
},
|
|
1392
1808
|
{
|
|
1393
1809
|
name: 'smart_recall',
|
|
1394
1810
|
description:
|
|
@@ -1411,7 +1827,8 @@ const TOOLS = [
|
|
|
1411
1827
|
description:
|
|
1412
1828
|
'Single-call session briefing. Call this at the START of every session INSTEAD of multiple separate smart_recall/recall_best_solution calls. ' +
|
|
1413
1829
|
'Returns: last session summary, recent lessons sorted by recency, relevant lessons for your focus area, ' +
|
|
1414
|
-
'open failures (topics with only failure outcomes),
|
|
1830
|
+
'open failures (topics with only failure outcomes), brain health stats, team telepathy (what teammates learned this week), ' +
|
|
1831
|
+
'predictive pre-warnings (if your focus area has known failure patterns), and memory crystals (compressed wisdom from old sessions). ' +
|
|
1415
1832
|
'Also saves a session start marker so session_end can compute duration.',
|
|
1416
1833
|
inputSchema: {
|
|
1417
1834
|
type: 'object',
|
|
@@ -1421,6 +1838,14 @@ const TOOLS = [
|
|
|
1421
1838
|
type: 'string',
|
|
1422
1839
|
description: 'Keywords for what you plan to work on today (e.g. "deploy infra api"). Used to surface relevant lessons at the top.',
|
|
1423
1840
|
},
|
|
1841
|
+
author: {
|
|
1842
|
+
type: 'string',
|
|
1843
|
+
description: 'Your name or handle (e.g. "alice"). Enables Team Telepathy — filters YOUR lessons vs TEAM lessons from past 7 days.',
|
|
1844
|
+
},
|
|
1845
|
+
provider: {
|
|
1846
|
+
type: 'string',
|
|
1847
|
+
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.',
|
|
1848
|
+
},
|
|
1424
1849
|
},
|
|
1425
1850
|
required: ['instance_id'],
|
|
1426
1851
|
},
|
|
@@ -1431,7 +1856,8 @@ const TOOLS = [
|
|
|
1431
1856
|
'Save a session summary when you finish working. ' +
|
|
1432
1857
|
'Records what was accomplished, files changed, and lesson count. ' +
|
|
1433
1858
|
'The next session_start will show this summary as "Last session". ' +
|
|
1434
|
-
'Call this when ending a work session, before going idle, or before summarizing.'
|
|
1859
|
+
'Call this when ending a work session, before going idle, or before summarizing. ' +
|
|
1860
|
+
'Ambient Learning: if workspace_path is provided, reads git log since session start and auto-learns from commits.',
|
|
1435
1861
|
inputSchema: {
|
|
1436
1862
|
type: 'object',
|
|
1437
1863
|
properties: {
|
|
@@ -1449,10 +1875,101 @@ const TOOLS = [
|
|
|
1449
1875
|
type: 'number',
|
|
1450
1876
|
description: 'Number of new lessons stored this session (optional)',
|
|
1451
1877
|
},
|
|
1878
|
+
workspace_path: {
|
|
1879
|
+
type: 'string',
|
|
1880
|
+
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.',
|
|
1881
|
+
},
|
|
1452
1882
|
},
|
|
1453
1883
|
required: ['instance_id', 'summary'],
|
|
1454
1884
|
},
|
|
1455
1885
|
},
|
|
1886
|
+
// ── Session Handoff — cross-window continuity ─────────────────────────────
|
|
1887
|
+
{
|
|
1888
|
+
name: 'session_handoff',
|
|
1889
|
+
description:
|
|
1890
|
+
'Save a detailed handoff for the NEXT chat window / session. ' +
|
|
1891
|
+
'Stores: current progress, TODO list (done + remaining), changed files with descriptions, ' +
|
|
1892
|
+
'instructions for the next assistant, and any incomplete work. ' +
|
|
1893
|
+
'The next session_start automatically includes this handoff so the new window knows EXACTLY what happened and what remains. ' +
|
|
1894
|
+
'Call this BEFORE closing a chat window, especially if work is incomplete. ' +
|
|
1895
|
+
'This prevents the "continue" problem where new windows lose context, skip tasks, or produce broken code.',
|
|
1896
|
+
inputSchema: {
|
|
1897
|
+
type: 'object',
|
|
1898
|
+
properties: {
|
|
1899
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1900
|
+
completed_tasks: {
|
|
1901
|
+
type: 'array',
|
|
1902
|
+
items: { type: 'string' },
|
|
1903
|
+
description: 'Tasks that were fully completed (e.g. "Implemented brainSearch() in JS SDK")',
|
|
1904
|
+
},
|
|
1905
|
+
remaining_tasks: {
|
|
1906
|
+
type: 'array',
|
|
1907
|
+
items: { type: 'string' },
|
|
1908
|
+
description: 'Tasks NOT yet done — the next window MUST pick these up',
|
|
1909
|
+
},
|
|
1910
|
+
files_changed: {
|
|
1911
|
+
type: 'array',
|
|
1912
|
+
items: {
|
|
1913
|
+
type: 'object',
|
|
1914
|
+
properties: {
|
|
1915
|
+
path: { type: 'string', description: 'File path relative to project root' },
|
|
1916
|
+
status: { type: 'string', enum: ['complete', 'partial', 'broken'], description: 'State of this file' },
|
|
1917
|
+
description: { type: 'string', description: 'What was changed and what still needs work' },
|
|
1918
|
+
},
|
|
1919
|
+
required: ['path', 'status'],
|
|
1920
|
+
},
|
|
1921
|
+
description: 'Changed files with their current state — marks partial/broken files so next window knows to fix them',
|
|
1922
|
+
},
|
|
1923
|
+
instructions: {
|
|
1924
|
+
type: 'string',
|
|
1925
|
+
description: 'Free-form instructions for the next assistant. Be specific: what to do next, what to avoid, what broke.',
|
|
1926
|
+
},
|
|
1927
|
+
context_summary: {
|
|
1928
|
+
type: 'string',
|
|
1929
|
+
description: 'Brief summary of what happened this session (architecture decisions, key findings, blockers)',
|
|
1930
|
+
},
|
|
1931
|
+
blocked_on: {
|
|
1932
|
+
type: 'string',
|
|
1933
|
+
description: 'If work is blocked, describe what is needed to unblock (e.g. "waiting for API deploy", "needs user input on design")',
|
|
1934
|
+
},
|
|
1935
|
+
},
|
|
1936
|
+
required: ['instance_id', 'completed_tasks', 'remaining_tasks'],
|
|
1937
|
+
},
|
|
1938
|
+
},
|
|
1939
|
+
// ── session_ping — lightweight in-session checkpoint ─────────────────────
|
|
1940
|
+
{
|
|
1941
|
+
name: 'session_ping',
|
|
1942
|
+
description:
|
|
1943
|
+
'Lightweight checkpoint — call this every ~5 tool calls or whenever you complete a significant step. ' +
|
|
1944
|
+
'Stores the current task + files touched so session_start on the NEXT provider can reconstruct what happened ' +
|
|
1945
|
+
'even if session_end was never called (e.g. Claude context limit hit, window crashed). ' +
|
|
1946
|
+
'This solves the provider-switching problem: Claude → Copilot → Cursor all see the same last checkpoint. ' +
|
|
1947
|
+
'Extremely fast — one Redis SET, no blocking operations.',
|
|
1948
|
+
inputSchema: {
|
|
1949
|
+
type: 'object',
|
|
1950
|
+
properties: {
|
|
1951
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1952
|
+
task: {
|
|
1953
|
+
type: 'string',
|
|
1954
|
+
description: 'What you are currently working on (e.g. "Implementing invite handler in handler/invite.go")',
|
|
1955
|
+
},
|
|
1956
|
+
files_touched: {
|
|
1957
|
+
type: 'array',
|
|
1958
|
+
items: { type: 'string' },
|
|
1959
|
+
description: 'Files modified so far this session',
|
|
1960
|
+
},
|
|
1961
|
+
next_step: {
|
|
1962
|
+
type: 'string',
|
|
1963
|
+
description: 'What the NEXT step is after this checkpoint (helps next provider resume immediately)',
|
|
1964
|
+
},
|
|
1965
|
+
provider: {
|
|
1966
|
+
type: 'string',
|
|
1967
|
+
description: 'Current AI provider (e.g. "claude-code", "copilot", "cursor", "windsurf")',
|
|
1968
|
+
},
|
|
1969
|
+
},
|
|
1970
|
+
required: ['instance_id', 'task'],
|
|
1971
|
+
},
|
|
1972
|
+
},
|
|
1456
1973
|
// ── AI Brain — Extended features ─────────────────────────────────────────
|
|
1457
1974
|
{
|
|
1458
1975
|
name: 'auto_learn_session',
|
|
@@ -1544,50 +2061,135 @@ const TOOLS = [
|
|
|
1544
2061
|
},
|
|
1545
2062
|
},
|
|
1546
2063
|
{
|
|
1547
|
-
name: '
|
|
2064
|
+
name: 'team_synthesize',
|
|
2065
|
+
description:
|
|
2066
|
+
'Team Brain Synthesis — merge multiple contributors\' lessons on the same topic into one canonical version. ' +
|
|
2067
|
+
'When 2+ developers store lessons for the same topic with different details, this proposes the best merged version. ' +
|
|
2068
|
+
'Shows: all contributions by author, what worked (consensus), what failed (union), canonical lesson to store. ' +
|
|
2069
|
+
'Use this when onboarding new team members or before documenting a process.',
|
|
2070
|
+
inputSchema: {
|
|
2071
|
+
type: 'object',
|
|
2072
|
+
properties: {
|
|
2073
|
+
instance_id: { type: 'string', description: 'UUID of the shared team brain instance' },
|
|
2074
|
+
topic: { type: 'string', description: 'Topic slug to synthesize (e.g. "deploy:api")' },
|
|
2075
|
+
},
|
|
2076
|
+
required: ['instance_id', 'topic'],
|
|
2077
|
+
},
|
|
2078
|
+
},
|
|
2079
|
+
{
|
|
2080
|
+
name: 'memory_crystalize',
|
|
1548
2081
|
description:
|
|
1549
|
-
'
|
|
1550
|
-
'
|
|
1551
|
-
'
|
|
2082
|
+
'Compress the last 30-50 sessions and auto-learned lessons into a dense Memory Crystal. ' +
|
|
2083
|
+
'A crystal is a compact, structured summary of everything the brain learned — grouped by category (deploy, fix, debug, …). ' +
|
|
2084
|
+
'Crystals survive session cleanup and appear in session_start once enough sessions have accumulated. ' +
|
|
2085
|
+
'Run this monthly or after a big milestone to preserve institutional knowledge. ' +
|
|
2086
|
+
'Returns a digest of what was crystallized.',
|
|
1552
2087
|
inputSchema: {
|
|
1553
2088
|
type: 'object',
|
|
1554
2089
|
properties: {
|
|
1555
2090
|
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
2091
|
+
label: {
|
|
2092
|
+
type: 'string',
|
|
2093
|
+
description: 'Optional label for this crystal (e.g. "Q1 2026", "v2 launch"). Auto-generated from date if omitted.',
|
|
2094
|
+
},
|
|
1556
2095
|
},
|
|
1557
2096
|
required: ['instance_id'],
|
|
1558
2097
|
},
|
|
1559
2098
|
},
|
|
2099
|
+
// ── Roadmap — Persistent project plan tracker ───────────────────────────
|
|
2100
|
+
{
|
|
2101
|
+
name: 'roadmap_add',
|
|
2102
|
+
description:
|
|
2103
|
+
'Add a new item to the persistent project roadmap stored in the Brain. ' +
|
|
2104
|
+
'Items survive across sessions and editors — the roadmap is always up to date. ' +
|
|
2105
|
+
'Use for features, bugs, refactors, or any planned work. ' +
|
|
2106
|
+
'Call roadmap_list to see all open items, roadmap_next to get the next actionable item.',
|
|
2107
|
+
inputSchema: {
|
|
2108
|
+
type: 'object',
|
|
2109
|
+
properties: {
|
|
2110
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
2111
|
+
title: { type: 'string', description: 'Short title of the task/feature (3–10 words)' },
|
|
2112
|
+
description: { type: 'string', description: 'What needs to be done, acceptance criteria, context' },
|
|
2113
|
+
priority: {
|
|
2114
|
+
type: 'string',
|
|
2115
|
+
enum: ['critical', 'high', 'medium', 'low'],
|
|
2116
|
+
description: 'Priority level (default: medium)',
|
|
2117
|
+
},
|
|
2118
|
+
tags: {
|
|
2119
|
+
type: 'array',
|
|
2120
|
+
items: { type: 'string' },
|
|
2121
|
+
description: 'Tags for filtering (e.g. ["api", "web", "sdk", "infra"])',
|
|
2122
|
+
},
|
|
2123
|
+
milestone: { type: 'string', description: 'Milestone/epic this belongs to (optional)' },
|
|
2124
|
+
},
|
|
2125
|
+
required: ['instance_id', 'title'],
|
|
2126
|
+
},
|
|
2127
|
+
},
|
|
2128
|
+
{
|
|
2129
|
+
name: 'roadmap_update',
|
|
2130
|
+
description:
|
|
2131
|
+
'Update the status, priority, or details of a roadmap item. ' +
|
|
2132
|
+
'Use to move items through the lifecycle: planned → in-progress → done (or blocked/cancelled). ' +
|
|
2133
|
+
'Also use to add notes/findings while working on an item.',
|
|
2134
|
+
inputSchema: {
|
|
2135
|
+
type: 'object',
|
|
2136
|
+
properties: {
|
|
2137
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
2138
|
+
id: { type: 'string', description: 'Item ID returned by roadmap_add or roadmap_list' },
|
|
2139
|
+
status: {
|
|
2140
|
+
type: 'string',
|
|
2141
|
+
enum: ['planned', 'in-progress', 'done', 'blocked', 'cancelled'],
|
|
2142
|
+
description: 'New status for the item',
|
|
2143
|
+
},
|
|
2144
|
+
priority: {
|
|
2145
|
+
type: 'string',
|
|
2146
|
+
enum: ['critical', 'high', 'medium', 'low'],
|
|
2147
|
+
description: 'Updated priority (optional)',
|
|
2148
|
+
},
|
|
2149
|
+
notes: { type: 'string', description: 'Progress notes, findings, or blockers (appended to existing notes)' },
|
|
2150
|
+
title: { type: 'string', description: 'Updated title (optional)' },
|
|
2151
|
+
description: { type: 'string', description: 'Updated description (optional)' },
|
|
2152
|
+
},
|
|
2153
|
+
required: ['instance_id', 'id'],
|
|
2154
|
+
},
|
|
2155
|
+
},
|
|
1560
2156
|
{
|
|
1561
|
-
name: '
|
|
2157
|
+
name: 'roadmap_list',
|
|
1562
2158
|
description:
|
|
1563
|
-
'
|
|
1564
|
-
'
|
|
1565
|
-
'
|
|
1566
|
-
'or importing into a new instance. ' +
|
|
1567
|
-
'Example: export_brain() → returns full markdown string you can save to a .md file.',
|
|
2159
|
+
'List all roadmap items, optionally filtered by status, priority, tag, or milestone. ' +
|
|
2160
|
+
'Returns items sorted by priority then creation date. ' +
|
|
2161
|
+
'Called automatically by session_start to show open work.',
|
|
1568
2162
|
inputSchema: {
|
|
1569
2163
|
type: 'object',
|
|
1570
2164
|
properties: {
|
|
1571
2165
|
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1572
|
-
|
|
1573
|
-
|
|
2166
|
+
status: {
|
|
2167
|
+
type: 'string',
|
|
2168
|
+
enum: ['planned', 'in-progress', 'done', 'blocked', 'cancelled', 'open'],
|
|
2169
|
+
description: 'Filter by status. Use \'open\' to see planned+in-progress+blocked (default: open)',
|
|
2170
|
+
},
|
|
2171
|
+
tag: { type: 'string', description: 'Filter by tag (optional)' },
|
|
2172
|
+
milestone: { type: 'string', description: 'Filter by milestone (optional)' },
|
|
2173
|
+
priority: {
|
|
2174
|
+
type: 'string',
|
|
2175
|
+
enum: ['critical', 'high', 'medium', 'low'],
|
|
2176
|
+
description: 'Filter by minimum priority (optional)',
|
|
2177
|
+
},
|
|
1574
2178
|
},
|
|
1575
2179
|
required: ['instance_id'],
|
|
1576
2180
|
},
|
|
1577
2181
|
},
|
|
1578
2182
|
{
|
|
1579
|
-
name: '
|
|
2183
|
+
name: 'roadmap_next',
|
|
1580
2184
|
description:
|
|
1581
|
-
'
|
|
1582
|
-
'
|
|
1583
|
-
'
|
|
1584
|
-
'No dashboard visit, no copy-paste of instance IDs required. ' +
|
|
1585
|
-
'Example: invite_link() → returns "npx @cachly-dev/mcp-server@latest join <token>"',
|
|
2185
|
+
'Get the single most important next actionable roadmap item. ' +
|
|
2186
|
+
'Returns the highest-priority in-progress item first, then planned items, sorted by priority. ' +
|
|
2187
|
+
'Call at session start to immediately know what to work on next.',
|
|
1586
2188
|
inputSchema: {
|
|
1587
2189
|
type: 'object',
|
|
1588
2190
|
properties: {
|
|
1589
|
-
instance_id: { type: 'string', description: 'UUID of the cache instance
|
|
1590
|
-
|
|
2191
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
2192
|
+
tag: { type: 'string', description: 'Filter by tag (optional)' },
|
|
1591
2193
|
},
|
|
1592
2194
|
required: ['instance_id'],
|
|
1593
2195
|
},
|
|
@@ -1596,12 +2198,16 @@ const TOOLS = [
|
|
|
1596
2198
|
name: 'brain_doctor',
|
|
1597
2199
|
description:
|
|
1598
2200
|
'Check the health of your AI Brain and get actionable recommendations. ' +
|
|
1599
|
-
'Reports: lesson count, context entries, last session age, open failures, quality score. ' +
|
|
2201
|
+
'Reports: lesson count, context entries, last session age, open failures, quality score, effective IQ boost, stale index. ' +
|
|
1600
2202
|
'Returns a prioritized list of issues with fix instructions.',
|
|
1601
2203
|
inputSchema: {
|
|
1602
2204
|
type: 'object',
|
|
1603
2205
|
properties: {
|
|
1604
2206
|
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
2207
|
+
workspace_path: {
|
|
2208
|
+
type: 'string',
|
|
2209
|
+
description: 'Absolute path to workspace root — enables package.json analysis for openclaw cross-promo (optional)',
|
|
2210
|
+
},
|
|
1605
2211
|
},
|
|
1606
2212
|
required: ['instance_id'],
|
|
1607
2213
|
},
|
|
@@ -1673,6 +2279,100 @@ const TOOLS = [
|
|
|
1673
2279
|
required: ['instance_id', 'framework'],
|
|
1674
2280
|
},
|
|
1675
2281
|
},
|
|
2282
|
+
// ── Brain Archaeology + Causal Chain ────────────────────────────────────
|
|
2283
|
+
{
|
|
2284
|
+
name: 'recall_at',
|
|
2285
|
+
description:
|
|
2286
|
+
'Brain Archaeology — see what a lesson looked like at a specific point in time. ' +
|
|
2287
|
+
'"What did we know about deployments 3 months ago?" ' +
|
|
2288
|
+
'Returns the history of a topic filtered to entries before the given date. ' +
|
|
2289
|
+
'Shows how the lesson evolved: failure → partial → success. ' +
|
|
2290
|
+
'Also useful to understand WHY old code decisions were made.',
|
|
2291
|
+
inputSchema: {
|
|
2292
|
+
type: 'object',
|
|
2293
|
+
properties: {
|
|
2294
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
2295
|
+
topic: { type: 'string', description: 'Topic slug to look up, e.g. "deploy:api"' },
|
|
2296
|
+
date: { type: 'string', description: 'ISO date string (e.g. "2026-01-15") — returns entries stored BEFORE this date' },
|
|
2297
|
+
},
|
|
2298
|
+
required: ['instance_id', 'topic', 'date'],
|
|
2299
|
+
},
|
|
2300
|
+
},
|
|
2301
|
+
{
|
|
2302
|
+
name: 'trace_dependency',
|
|
2303
|
+
description:
|
|
2304
|
+
'Causal Chain — find all lessons that depend on a given prerequisite. ' +
|
|
2305
|
+
'"What lessons are affected if node version changes?" ' +
|
|
2306
|
+
'When a dependency changes (new version, different provider, new OS), call this to see which lessons need review. ' +
|
|
2307
|
+
'Lessons store dependencies via the depends_on field in learn_from_attempts.',
|
|
2308
|
+
inputSchema: {
|
|
2309
|
+
type: 'object',
|
|
2310
|
+
properties: {
|
|
2311
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
2312
|
+
dependency: { type: 'string', description: 'Dependency to trace (e.g. "node:>=20", "docker:running", "wireguard:active")' },
|
|
2313
|
+
mark_review: { type: 'boolean', description: 'If true, marks all dependent lessons as needs_review (default: false)' },
|
|
2314
|
+
},
|
|
2315
|
+
required: ['instance_id', 'dependency'],
|
|
2316
|
+
},
|
|
2317
|
+
},
|
|
2318
|
+
// ── Team / Org Management ────────────────────────────────────────────────
|
|
2319
|
+
{
|
|
2320
|
+
name: 'list_orgs',
|
|
2321
|
+
description:
|
|
2322
|
+
'List your Cachly organizations (team/org plans). ' +
|
|
2323
|
+
'Returns each org with plan, seat count, and member info. ' +
|
|
2324
|
+
'Org plans (Team €99, Business €299, Enterprise custom) are billed separately from cache tiers.',
|
|
2325
|
+
inputSchema: {
|
|
2326
|
+
type: 'object',
|
|
2327
|
+
properties: {},
|
|
2328
|
+
required: [],
|
|
2329
|
+
},
|
|
2330
|
+
},
|
|
2331
|
+
{
|
|
2332
|
+
name: 'create_org',
|
|
2333
|
+
description:
|
|
2334
|
+
'Create a new Cachly organization for team collaboration. ' +
|
|
2335
|
+
'After creation, invite team members with invite_member and upgrade the plan via the billing portal. ' +
|
|
2336
|
+
'Org plans: Team (€99/mo, 10 seats), Business (€299/mo, 50 seats), Enterprise (custom).',
|
|
2337
|
+
inputSchema: {
|
|
2338
|
+
type: 'object',
|
|
2339
|
+
properties: {
|
|
2340
|
+
name: { type: 'string', description: 'Organization display name (e.g. "Acme Engineering")' },
|
|
2341
|
+
slug: { type: 'string', description: 'URL-safe slug (e.g. "acme-eng"). Auto-generated from name if omitted.' },
|
|
2342
|
+
},
|
|
2343
|
+
required: ['name'],
|
|
2344
|
+
},
|
|
2345
|
+
},
|
|
2346
|
+
{
|
|
2347
|
+
name: 'invite_member',
|
|
2348
|
+
description:
|
|
2349
|
+
'Invite a team member to a Cachly organization by email. ' +
|
|
2350
|
+
'They will receive an invite email and can join via the dashboard. ' +
|
|
2351
|
+
'Roles: owner (full access), admin (manage members + instances), member (read + cache ops).',
|
|
2352
|
+
inputSchema: {
|
|
2353
|
+
type: 'object',
|
|
2354
|
+
properties: {
|
|
2355
|
+
org_id: { type: 'string', description: 'UUID of the organization' },
|
|
2356
|
+
email: { type: 'string', description: 'Email address to invite' },
|
|
2357
|
+
role: { type: 'string', enum: ['admin', 'member'], description: 'Role for the invited member (default: member)' },
|
|
2358
|
+
},
|
|
2359
|
+
required: ['org_id', 'email'],
|
|
2360
|
+
},
|
|
2361
|
+
},
|
|
2362
|
+
{
|
|
2363
|
+
name: 'get_org_plan',
|
|
2364
|
+
description:
|
|
2365
|
+
'Get the current org plan, seat usage, and billing info for an organization. ' +
|
|
2366
|
+
'Shows: plan name, price, seats used/max, next billing date. ' +
|
|
2367
|
+
'To upgrade: use the billing portal URL returned by this tool.',
|
|
2368
|
+
inputSchema: {
|
|
2369
|
+
type: 'object',
|
|
2370
|
+
properties: {
|
|
2371
|
+
org_id: { type: 'string', description: 'UUID of the organization' },
|
|
2372
|
+
},
|
|
2373
|
+
required: ['org_id'],
|
|
2374
|
+
},
|
|
2375
|
+
},
|
|
1676
2376
|
// ── Legacy / Setup ────────────────────────────────────────────────────────
|
|
1677
2377
|
{
|
|
1678
2378
|
name: 'setup_ai_memory',
|
|
@@ -1770,10 +2470,50 @@ function buildConnectionString(inst: Instance): string {
|
|
|
1770
2470
|
return `${scheme}://${pw}${inst.host}:${inst.port}`;
|
|
1771
2471
|
}
|
|
1772
2472
|
|
|
2473
|
+
// Fires once per process when no JWT is set (anonymous, opt-out via CACHLY_NO_TELEMETRY=1)
|
|
2474
|
+
let _telemetryPingSent = false;
|
|
2475
|
+
async function sendAnonymousTelemetry(toolName: string): Promise<void> {
|
|
2476
|
+
if (_telemetryPingSent) return;
|
|
2477
|
+
if (process.env.CACHLY_NO_TELEMETRY === '1') return;
|
|
2478
|
+
_telemetryPingSent = true;
|
|
2479
|
+
// Detect editor from common env vars injected by IDE extensions
|
|
2480
|
+
const editor = process.env.CURSOR_TRACE_ID ? 'cursor'
|
|
2481
|
+
: process.env.WINDSURF_SESSION_ID ? 'windsurf'
|
|
2482
|
+
: process.env.GITHUB_COPILOT_WORKSPACE ? 'copilot'
|
|
2483
|
+
: process.env.CLAUDE_CODE_ENTRYPOINT ? 'claude'
|
|
2484
|
+
: 'unknown';
|
|
2485
|
+
try {
|
|
2486
|
+
await fetch(`${API_URL}/api/v1/telemetry/mcp`, {
|
|
2487
|
+
method: 'POST',
|
|
2488
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2489
|
+
body: JSON.stringify({ event: 'first_call_no_jwt', version: CURRENT_VERSION, editor, tool: toolName }),
|
|
2490
|
+
signal: AbortSignal.timeout(3000),
|
|
2491
|
+
});
|
|
2492
|
+
} catch { /* fire-and-forget, never block the user */ }
|
|
2493
|
+
}
|
|
2494
|
+
|
|
1773
2495
|
async function handleTool(name: string, args: Record<string, unknown>): Promise<string> {
|
|
1774
|
-
//
|
|
1775
|
-
if (!
|
|
1776
|
-
|
|
2496
|
+
// Guard: if no JWT, return actionable onboarding message instead of HTTP 401
|
|
2497
|
+
if (!JWT) {
|
|
2498
|
+
void sendAnonymousTelemetry(name);
|
|
2499
|
+
return [
|
|
2500
|
+
'🧠 **cachly AI Brain — Setup required**',
|
|
2501
|
+
'',
|
|
2502
|
+
'You have no API credentials configured. Get started in 30 seconds:',
|
|
2503
|
+
'',
|
|
2504
|
+
'**Step 1:** Register for free (no credit card):',
|
|
2505
|
+
' 👉 https://cachly.dev/setup-ai',
|
|
2506
|
+
'',
|
|
2507
|
+
'**Step 2:** Run the setup wizard in your terminal:',
|
|
2508
|
+
' ```',
|
|
2509
|
+
' npx @cachly-dev/mcp-server@latest setup',
|
|
2510
|
+
' ```',
|
|
2511
|
+
'',
|
|
2512
|
+
'The wizard auto-detects your editor (Claude Code, Cursor, Copilot, Windsurf)',
|
|
2513
|
+
'and writes the config file with your credentials.',
|
|
2514
|
+
'',
|
|
2515
|
+
'✨ Free tier includes: 1 Brain instance, persistent memory, semantic search.',
|
|
2516
|
+
].join('\n');
|
|
1777
2517
|
}
|
|
1778
2518
|
|
|
1779
2519
|
// Delegate v0.2 bulk/lock/stream tools first
|
|
@@ -1794,7 +2534,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
1794
2534
|
const { name: instName, tier } = args as { name: string; tier: string };
|
|
1795
2535
|
const res = await apiFetch<CreateResponse>('/api/v1/instances', {
|
|
1796
2536
|
method: 'POST',
|
|
1797
|
-
body: JSON.stringify({ name: instName, tier }),
|
|
2537
|
+
body: JSON.stringify({ name: instName, tier, created_via: 'api' }),
|
|
1798
2538
|
});
|
|
1799
2539
|
if (res.checkout_url) {
|
|
1800
2540
|
return [
|
|
@@ -1854,27 +2594,90 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
1854
2594
|
return `✅ Instance \`${instance_id}\` has been deleted and all data removed.`;
|
|
1855
2595
|
}
|
|
1856
2596
|
|
|
1857
|
-
// ──
|
|
1858
|
-
case '
|
|
1859
|
-
const
|
|
1860
|
-
const
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
// not JSON — return raw
|
|
1868
|
-
}
|
|
1869
|
-
return `Key \`${key}\`:\n\`\`\`\n${pretty}\n\`\`\``;
|
|
2597
|
+
// ── Org / Team management ────────────────────────────────────────────
|
|
2598
|
+
case 'list_orgs': {
|
|
2599
|
+
const res = await apiFetch<{ orgs: Array<{ id: string; name: string; slug: string; plan: string; max_members: number; member_count?: number }> }>('/api/v1/orgs');
|
|
2600
|
+
const orgs = res.orgs ?? [];
|
|
2601
|
+
if (orgs.length === 0) 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.`;
|
|
2602
|
+
return [
|
|
2603
|
+
`🏢 **Your organizations (${orgs.length})**\n`,
|
|
2604
|
+
...orgs.map(o => `• **${o.name}** (\`${o.slug}\`) — plan: ${o.plan} · seats: ${o.member_count ?? '?'}/${o.max_members}\n ID: \`${o.id}\``),
|
|
2605
|
+
`\n_Manage: \`get_org_plan\`, \`invite_member\`, dashboard → /team_`,
|
|
2606
|
+
].join('\n');
|
|
1870
2607
|
}
|
|
1871
2608
|
|
|
1872
|
-
case '
|
|
1873
|
-
const {
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
2609
|
+
case 'create_org': {
|
|
2610
|
+
const { name: orgName, slug } = args as { name: string; slug?: string };
|
|
2611
|
+
const body: Record<string, string> = { name: orgName };
|
|
2612
|
+
if (slug) body.slug = slug;
|
|
2613
|
+
const res = await apiFetch<{ id: string; name: string; slug: string; plan: string }>('/api/v1/orgs', {
|
|
2614
|
+
method: 'POST',
|
|
2615
|
+
body: JSON.stringify(body),
|
|
2616
|
+
});
|
|
2617
|
+
return [
|
|
2618
|
+
`✅ **Organization created:** ${res.name}`,
|
|
2619
|
+
` ID: \`${res.id}\` · Slug: \`${res.slug}\` · Plan: ${res.plan}`,
|
|
2620
|
+
``,
|
|
2621
|
+
`**Next steps:**`,
|
|
2622
|
+
`1. Invite team members: \`invite_member(org_id="${res.id}", email="dev@example.com")\``,
|
|
2623
|
+
`2. Upgrade plan: open billing portal via dashboard → /team`,
|
|
2624
|
+
` Team: €99/mo (10 seats) · Business: €299/mo (50 seats)`,
|
|
2625
|
+
].join('\n');
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
case 'invite_member': {
|
|
2629
|
+
const { org_id, email, role = 'member' } = args as { org_id: string; email: string; role?: string };
|
|
2630
|
+
await apiFetch(`/api/v1/orgs/${org_id}/members`, {
|
|
2631
|
+
method: 'POST',
|
|
2632
|
+
body: JSON.stringify({ email, role }),
|
|
2633
|
+
});
|
|
2634
|
+
return `✅ Invite sent to **${email}** as \`${role}\` in org \`${org_id}\`.\n\nThey will receive an email to join the organization.`;
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
case 'get_org_plan': {
|
|
2638
|
+
const { org_id } = args as { org_id: string };
|
|
2639
|
+
const org = await apiFetch<{
|
|
2640
|
+
id: string; name: string; plan: string; max_members: number;
|
|
2641
|
+
members: Array<{ role: string; invite_email: string; accepted_at?: string }>;
|
|
2642
|
+
stripe_customer_id?: string;
|
|
2643
|
+
}>(`/api/v1/orgs/${org_id}`);
|
|
2644
|
+
const accepted = (org.members ?? []).filter(m => m.accepted_at).length;
|
|
2645
|
+
const pending = (org.members ?? []).filter(m => !m.accepted_at).length;
|
|
2646
|
+
const planPrice: Record<string, string> = { free: '€0', team: '€99/mo', business: '€299/mo', enterprise: 'custom' };
|
|
2647
|
+
return [
|
|
2648
|
+
`🏢 **${org.name}** — Plan: **${org.plan}** (${planPrice[org.plan] ?? org.plan})`,
|
|
2649
|
+
` Seats: ${accepted} active + ${pending} pending / ${org.max_members} max`,
|
|
2650
|
+
``,
|
|
2651
|
+
`**Members:**`,
|
|
2652
|
+
...(org.members ?? []).map(m => ` • ${m.invite_email} (${m.role})${m.accepted_at ? '' : ' — pending'}`),
|
|
2653
|
+
``,
|
|
2654
|
+
org.stripe_customer_id
|
|
2655
|
+
? `💳 Billing: managed via Stripe. Upgrade/cancel: dashboard → /billing`
|
|
2656
|
+
: `💳 No payment method yet. Upgrade: dashboard → /billing → Team Plans`,
|
|
2657
|
+
].join('\n');
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
// ── Live cache operations ────────────────────────────────────────────
|
|
2661
|
+
case 'cache_get': {
|
|
2662
|
+
const { instance_id, key } = args as { instance_id: string; key: string };
|
|
2663
|
+
const redis = await getConnection(instance_id);
|
|
2664
|
+
const value = await redis.get(key);
|
|
2665
|
+
if (value === null) return `Key \`${key}\` → **not found** (null)`;
|
|
2666
|
+
let pretty = value;
|
|
2667
|
+
try {
|
|
2668
|
+
pretty = JSON.stringify(JSON.parse(value), null, 2);
|
|
2669
|
+
} catch {
|
|
2670
|
+
// not JSON — return raw
|
|
2671
|
+
}
|
|
2672
|
+
return `Key \`${key}\`:\n\`\`\`\n${pretty}\n\`\`\``;
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
case 'cache_set': {
|
|
2676
|
+
const { instance_id, key, value, ttl } = args as {
|
|
2677
|
+
instance_id: string;
|
|
2678
|
+
key: string;
|
|
2679
|
+
value: string;
|
|
2680
|
+
ttl?: number;
|
|
1878
2681
|
};
|
|
1879
2682
|
const redis = await getConnection(instance_id);
|
|
1880
2683
|
if (ttl && ttl > 0) {
|
|
@@ -2280,6 +3083,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
2280
3083
|
let skipped = 0;
|
|
2281
3084
|
let errors = 0;
|
|
2282
3085
|
let semanticIndexed = 0;
|
|
3086
|
+
let unchanged = 0;
|
|
2283
3087
|
const details: string[] = [];
|
|
2284
3088
|
const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
|
|
2285
3089
|
const redis = await getConnection(instance_id);
|
|
@@ -2287,15 +3091,31 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
2287
3091
|
for (const filePath of files) {
|
|
2288
3092
|
const relPath = relative(dir, filePath);
|
|
2289
3093
|
let content: string;
|
|
3094
|
+
let fileSize: number;
|
|
2290
3095
|
try {
|
|
2291
3096
|
const s = await stat(filePath);
|
|
2292
3097
|
if (s.size > 200_000) { skipped++; continue; } // skip files >200 KB
|
|
3098
|
+
fileSize = s.size;
|
|
2293
3099
|
content = await readFile(filePath, 'utf-8');
|
|
2294
3100
|
} catch {
|
|
2295
3101
|
errors++;
|
|
2296
3102
|
continue;
|
|
2297
3103
|
}
|
|
2298
3104
|
|
|
3105
|
+
// ── Smart invalidation: hash-based change detection ──
|
|
3106
|
+
// Compute a simple hash of file content to skip unchanged files
|
|
3107
|
+
const hashKey = `cachly:idx:hash:${relPath}`;
|
|
3108
|
+
const contentHash = `${fileSize}:${content.length}:${simpleHash(content)}`;
|
|
3109
|
+
const existingHash = await redis.get(hashKey);
|
|
3110
|
+
if (existingHash === contentHash) {
|
|
3111
|
+
// File unchanged — refresh TTL but skip re-indexing
|
|
3112
|
+
const idxKey = `cachly:idx:${relPath}`;
|
|
3113
|
+
if (ttl > 0) await redis.expire(idxKey, ttl);
|
|
3114
|
+
if (ttl > 0) await redis.expire(hashKey, ttl);
|
|
3115
|
+
unchanged++;
|
|
3116
|
+
continue;
|
|
3117
|
+
}
|
|
3118
|
+
|
|
2299
3119
|
const excerpt = content.slice(0, summary_chars).replace(/\s+/g, ' ').trim();
|
|
2300
3120
|
|
|
2301
3121
|
// ── Layer 1: Keyword index in Valkey (always works, no embedding needed) ──
|
|
@@ -2306,6 +3126,12 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
2306
3126
|
} else {
|
|
2307
3127
|
await redis.set(idxKey, idxValue);
|
|
2308
3128
|
}
|
|
3129
|
+
// Store content hash for smart invalidation on next run
|
|
3130
|
+
if (ttl > 0) {
|
|
3131
|
+
await redis.set(hashKey, contentHash, 'EX', ttl);
|
|
3132
|
+
} else {
|
|
3133
|
+
await redis.set(hashKey, contentHash);
|
|
3134
|
+
}
|
|
2309
3135
|
indexed++;
|
|
2310
3136
|
details.push(` ✅ ${relPath}`);
|
|
2311
3137
|
|
|
@@ -2334,7 +3160,8 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
2334
3160
|
`📂 **index_project Complete** — ${mode}`,
|
|
2335
3161
|
``,
|
|
2336
3162
|
` 📁 Dir: ${dir}`,
|
|
2337
|
-
` ✅ Indexed: **${indexed}** files (
|
|
3163
|
+
` ✅ Indexed: **${indexed}** files (new/changed)`,
|
|
3164
|
+
` ♻️ Unchanged: ${unchanged} files (hash match — skipped)`,
|
|
2338
3165
|
...(canEmbed ? [` 🎯 Semantic: **${semanticIndexed}** files (vector-searchable)`] : []),
|
|
2339
3166
|
` ⏭️ Skipped: ${skipped} (too large or filtered)`,
|
|
2340
3167
|
` ❌ Errors: ${errors}`,
|
|
@@ -2382,8 +3209,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
2382
3209
|
await redis.set(`${cacheKey}:meta`, meta);
|
|
2383
3210
|
}
|
|
2384
3211
|
|
|
2385
|
-
// Also index semantically for smart_recall (if vector available
|
|
2386
|
-
if (EMBED_PROVIDER !== 'none') {
|
|
3212
|
+
// Also index semantically for smart_recall (if vector available)
|
|
2387
3213
|
const inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
|
|
2388
3214
|
if (inst.vector_token) {
|
|
2389
3215
|
try {
|
|
@@ -2407,7 +3233,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
2407
3233
|
// Embedding optional — continue silently
|
|
2408
3234
|
}
|
|
2409
3235
|
}
|
|
2410
|
-
} // end EMBED_PROVIDER !== 'none'
|
|
2411
3236
|
|
|
2412
3237
|
return [
|
|
2413
3238
|
`🧠 **Context Saved**`,
|
|
@@ -2559,6 +3384,8 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
2559
3384
|
file_paths = [],
|
|
2560
3385
|
commands = [],
|
|
2561
3386
|
tags = [],
|
|
3387
|
+
depends_on = [],
|
|
3388
|
+
author = '',
|
|
2562
3389
|
} = args as {
|
|
2563
3390
|
instance_id: string;
|
|
2564
3391
|
topic: string;
|
|
@@ -2570,26 +3397,107 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
2570
3397
|
file_paths?: string[];
|
|
2571
3398
|
commands?: string[];
|
|
2572
3399
|
tags?: string[];
|
|
3400
|
+
depends_on?: string[];
|
|
3401
|
+
author?: string;
|
|
2573
3402
|
};
|
|
2574
3403
|
|
|
2575
3404
|
const redis = await getConnection(instance_id);
|
|
2576
3405
|
const ts = new Date().toISOString();
|
|
2577
3406
|
|
|
2578
|
-
// ──
|
|
3407
|
+
// ── Structured template hints ──────────────────────────────────────────
|
|
3408
|
+
const category = topic.split(':')[0];
|
|
3409
|
+
const template = STRUCTURED_TEMPLATES[category];
|
|
3410
|
+
const templateWarnings: string[] = [];
|
|
3411
|
+
if (template) {
|
|
3412
|
+
for (const req of template.required) {
|
|
3413
|
+
if (req === 'commands' && commands.length === 0) {
|
|
3414
|
+
templateWarnings.push(`📋 ${template.hint}`);
|
|
3415
|
+
}
|
|
3416
|
+
}
|
|
3417
|
+
}
|
|
3418
|
+
|
|
3419
|
+
// ── Deduplication + audit trail ────────────────────────────────────────
|
|
2579
3420
|
let isUpdate = false;
|
|
2580
3421
|
let recallCount = 0;
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
3422
|
+
let auditTrail: Array<{ ts: string; action: string; prev_outcome?: string }> = [];
|
|
3423
|
+
const existingRaw = await redis.get(`cachly:lesson:best:${topic}`);
|
|
3424
|
+
if (existingRaw) {
|
|
3425
|
+
try {
|
|
3426
|
+
const prev = JSON.parse(existingRaw) as {
|
|
3427
|
+
recall_count?: number;
|
|
3428
|
+
outcome?: string;
|
|
3429
|
+
audit_trail?: Array<{ ts: string; action: string; prev_outcome?: string }>;
|
|
3430
|
+
};
|
|
3431
|
+
recallCount = prev.recall_count ?? 0;
|
|
3432
|
+
auditTrail = prev.audit_trail ?? [];
|
|
3433
|
+
auditTrail.push({ ts, action: 'updated', prev_outcome: prev.outcome });
|
|
3434
|
+
if (auditTrail.length > 20) auditTrail = auditTrail.slice(-20);
|
|
3435
|
+
isUpdate = true;
|
|
3436
|
+
|
|
3437
|
+
// ── Contradiction detection ─────────────────────────────────────────
|
|
3438
|
+
const contradictionWarning: string[] = [];
|
|
3439
|
+
if (prev.outcome === 'success' && outcome === 'failure') {
|
|
3440
|
+
contradictionWarning.push(
|
|
3441
|
+
`⚠️ **Contradiction detected!** Existing lesson has outcome: \`success\`, but you're storing \`failure\`.`,
|
|
3442
|
+
`The existing "success" lesson will be preserved. Only the audit trail is updated.`,
|
|
3443
|
+
`If you meant to mark this as failed permanently, store a new lesson with a distinct topic slug.`,
|
|
3444
|
+
);
|
|
3445
|
+
} else if (prev.outcome === 'failure' && outcome === 'success') {
|
|
3446
|
+
contradictionWarning.push(
|
|
3447
|
+
`✅ **Conflict resolved!** Previous lesson was \`failure\` — now overwriting with \`success\`.`,
|
|
3448
|
+
);
|
|
3449
|
+
}
|
|
3450
|
+
if (contradictionWarning.length > 0) {
|
|
3451
|
+
// Store contradiction audit but don't block
|
|
3452
|
+
auditTrail[auditTrail.length - 1].action = 'contradiction-resolved';
|
|
3453
|
+
}
|
|
3454
|
+
} catch { /* ignore parse error */ }
|
|
3455
|
+
} else {
|
|
3456
|
+
auditTrail = [{ ts, action: 'created' }];
|
|
3457
|
+
}
|
|
3458
|
+
|
|
3459
|
+
// ── "I Was Wrong" Protocol — failure attribution ───────────────────────
|
|
3460
|
+
const iWasWrongWarning: string[] = [];
|
|
3461
|
+
if (outcome === 'failure') {
|
|
3462
|
+
// Search for related success lessons that might have prevented this failure
|
|
3463
|
+
const scanKeys: string[] = [];
|
|
3464
|
+
const scanStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 100 });
|
|
3465
|
+
await new Promise<void>((res, rej) => {
|
|
3466
|
+
scanStream.on('data', (b: string[]) => scanKeys.push(...b));
|
|
3467
|
+
scanStream.on('end', res);
|
|
3468
|
+
scanStream.on('error', rej);
|
|
3469
|
+
});
|
|
3470
|
+
const topicWords = topic.split(/[:\-_]/).filter(w => w.length > 2);
|
|
3471
|
+
for (const k of scanKeys.slice(0, 50)) {
|
|
3472
|
+
const raw = await redis.get(k);
|
|
3473
|
+
if (!raw) continue;
|
|
2584
3474
|
try {
|
|
2585
|
-
const
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
3475
|
+
const l = JSON.parse(raw) as { outcome?: string; topic?: string; severity?: string };
|
|
3476
|
+
if (l.outcome !== 'success') continue;
|
|
3477
|
+
const lWords = (l.topic ?? '').split(/[:\-_]/).filter(w => w.length > 2);
|
|
3478
|
+
const overlap = topicWords.filter(w => lWords.includes(w)).length;
|
|
3479
|
+
if (overlap >= 1 && l.topic !== topic) {
|
|
3480
|
+
iWasWrongWarning.push(
|
|
3481
|
+
`⚠️ **"I Was Wrong"**: lesson \`${l.topic}\` (success, ${l.severity ?? 'major'}) might have prevented this failure.`,
|
|
3482
|
+
` → Use \`recall_best_solution(topic="${l.topic}")\` before next attempt.`,
|
|
3483
|
+
` → To mark it critical: \`learn_from_attempts(topic="${l.topic}", ..., severity="critical")\``,
|
|
3484
|
+
);
|
|
3485
|
+
break; // only show the most relevant match
|
|
3486
|
+
}
|
|
3487
|
+
} catch { /* skip */ }
|
|
2589
3488
|
}
|
|
2590
3489
|
}
|
|
2591
3490
|
|
|
2592
|
-
|
|
3491
|
+
// ── Register dependency index for causal chain ─────────────────────────
|
|
3492
|
+
for (const dep of depends_on) {
|
|
3493
|
+
const depKey = `cachly:dep:${dep}`;
|
|
3494
|
+
const existing = await redis.get(depKey);
|
|
3495
|
+
const depTopics: string[] = existing ? JSON.parse(existing) : [];
|
|
3496
|
+
if (!depTopics.includes(topic)) depTopics.push(topic);
|
|
3497
|
+
await redis.set(depKey, JSON.stringify(depTopics));
|
|
3498
|
+
}
|
|
3499
|
+
|
|
3500
|
+
const lessonObj = {
|
|
2593
3501
|
topic,
|
|
2594
3502
|
outcome,
|
|
2595
3503
|
what_worked,
|
|
@@ -2599,20 +3507,36 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
2599
3507
|
file_paths,
|
|
2600
3508
|
commands,
|
|
2601
3509
|
tags,
|
|
3510
|
+
depends_on,
|
|
3511
|
+
...(author ? { author } : {}),
|
|
2602
3512
|
recall_count: recallCount,
|
|
2603
3513
|
ts,
|
|
2604
|
-
|
|
2605
|
-
|
|
3514
|
+
verified_at: outcome === 'success' || outcome === 'partial' ? ts : undefined,
|
|
3515
|
+
confidence: 1.0,
|
|
3516
|
+
audit_trail: auditTrail,
|
|
3517
|
+
version: 3,
|
|
3518
|
+
};
|
|
3519
|
+
const lesson = JSON.stringify(lessonObj);
|
|
2606
3520
|
|
|
2607
|
-
// Always append to the history list
|
|
3521
|
+
// Always append to the history list (audit log)
|
|
2608
3522
|
const listKey = `cachly:lessons:${topic}`;
|
|
2609
3523
|
await redis.rpush(listKey, lesson);
|
|
2610
3524
|
|
|
2611
|
-
// Update best key
|
|
3525
|
+
// Update best key for success/partial; for failure only update if no success exists
|
|
2612
3526
|
if (outcome === 'success' || outcome === 'partial') {
|
|
2613
3527
|
await redis.set(`cachly:lesson:best:${topic}`, lesson);
|
|
3528
|
+
} else if (!existingRaw) {
|
|
3529
|
+
await redis.set(`cachly:lesson:best:${topic}`, lesson);
|
|
2614
3530
|
}
|
|
2615
3531
|
|
|
3532
|
+
// Track in decision log for session replay
|
|
3533
|
+
try {
|
|
3534
|
+
const dlKey = 'cachly:session:decision-log';
|
|
3535
|
+
const dlEntry = JSON.stringify({ ts, topic, outcome, what_worked: what_worked.slice(0, 120) });
|
|
3536
|
+
await redis.rpush(dlKey, dlEntry);
|
|
3537
|
+
await redis.ltrim(dlKey, -50, -1);
|
|
3538
|
+
} catch { /* non-critical */ }
|
|
3539
|
+
|
|
2616
3540
|
const emoji = outcome === 'success' ? '✅' : outcome === 'partial' ? '⚠️' : '❌';
|
|
2617
3541
|
const sevEmoji = severity === 'critical' ? '🔴' : severity === 'major' ? '🟡' : '🟢';
|
|
2618
3542
|
const action = isUpdate ? 'updated' : 'stored';
|
|
@@ -2627,8 +3551,13 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
2627
3551
|
tags.length > 0 ? `**Tags:** ${tags.map(t => `#${t}`).join(' ')}` : '',
|
|
2628
3552
|
``,
|
|
2629
3553
|
isUpdate
|
|
2630
|
-
? `♻️ Updated
|
|
3554
|
+
? `♻️ Updated (recall count: ${recallCount} · audit entries: ${auditTrail.length})`
|
|
2631
3555
|
: `💡 Recall later with \`recall_best_solution(topic="${topic}")\``,
|
|
3556
|
+
depends_on.length > 0
|
|
3557
|
+
? `🔗 Depends on: ${depends_on.map(d => `\`${d}\``).join(', ')} → trace with \`trace_dependency\``
|
|
3558
|
+
: '',
|
|
3559
|
+
...templateWarnings,
|
|
3560
|
+
...iWasWrongWarning,
|
|
2632
3561
|
].filter(l => l !== '').join('\n');
|
|
2633
3562
|
}
|
|
2634
3563
|
|
|
@@ -2639,43 +3568,47 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
2639
3568
|
// Try exact best-solution key first
|
|
2640
3569
|
const best = await redis.get(`cachly:lesson:best:${topic}`);
|
|
2641
3570
|
if (best) {
|
|
2642
|
-
|
|
3571
|
+
const lesson = JSON.parse(best) as {
|
|
2643
3572
|
topic: string; outcome: string; what_worked: string; what_failed?: string;
|
|
2644
|
-
context?: string; ts: string;
|
|
2645
|
-
|
|
3573
|
+
context?: string; ts: string; verified_at?: string; severity?: string;
|
|
3574
|
+
file_paths?: string[]; commands?: string[]; tags?: string[];
|
|
3575
|
+
recall_count?: number; audit_trail?: unknown[];
|
|
2646
3576
|
};
|
|
2647
|
-
try { lesson = JSON.parse(best); } catch {
|
|
2648
|
-
return `⚠️ Lesson for \`${topic}\` could not be read (corrupted data). Use \`learn_from_attempts\` to overwrite it.`;
|
|
2649
|
-
}
|
|
2650
|
-
// Increment recall_count
|
|
2651
|
-
const updatedLesson = { ...lesson, recall_count: (lesson.recall_count ?? 0) + 1 };
|
|
2652
|
-
await redis.set(`cachly:lesson:best:${topic}`, JSON.stringify(updatedLesson));
|
|
2653
3577
|
|
|
2654
|
-
//
|
|
2655
|
-
const
|
|
2656
|
-
const
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2669
|
-
body: JSON.stringify({ event: isFirstRecall ? 'first_recall' : 'recall', api_key: JWT }),
|
|
2670
|
-
}).catch(() => { /* non-critical */ });
|
|
2671
|
-
}
|
|
3578
|
+
// ── Confidence decay check ───────────────────────────────────────────
|
|
3579
|
+
const confidence = calculateConfidence(lesson);
|
|
3580
|
+
const ref = lesson.verified_at ?? lesson.ts;
|
|
3581
|
+
const ageDays = (Date.now() - new Date(ref).getTime()) / 86400000;
|
|
3582
|
+
const badge = confidenceBadge(confidence, ageDays);
|
|
3583
|
+
|
|
3584
|
+
// Recall resets verified_at (confidence clock restart)
|
|
3585
|
+
const updatedLesson = {
|
|
3586
|
+
...lesson,
|
|
3587
|
+
recall_count: (lesson.recall_count ?? 0) + 1,
|
|
3588
|
+
verified_at: new Date().toISOString(),
|
|
3589
|
+
confidence: 1.0,
|
|
3590
|
+
};
|
|
3591
|
+
await redis.set(`cachly:lesson:best:${topic}`, JSON.stringify(updatedLesson));
|
|
2672
3592
|
|
|
2673
3593
|
const sevEmoji = lesson.severity === 'critical' ? '🔴' : lesson.severity === 'major' ? '🟡' : lesson.severity ? '🟢' : '';
|
|
2674
|
-
const
|
|
2675
|
-
|
|
2676
|
-
|
|
3594
|
+
const auditSummary = (lesson.audit_trail ?? []).length > 1
|
|
3595
|
+
? `_Audit: ${(lesson.audit_trail ?? []).length} changes · stored ${new Date(lesson.ts).toLocaleDateString('de-DE')}_`
|
|
3596
|
+
: '';
|
|
3597
|
+
|
|
3598
|
+
// "Remember when..." — emotional header for lessons > 60 days old
|
|
3599
|
+
const ageFromStoreDays = (Date.now() - new Date(lesson.ts).getTime()) / 86400000;
|
|
3600
|
+
const rememberWhen = ageFromStoreDays > 60
|
|
3601
|
+
? `💭 _Remember when you solved this ${Math.round(ageFromStoreDays / 30)} months ago? Still works._`
|
|
3602
|
+
: '';
|
|
3603
|
+
|
|
3604
|
+
// "Never Google This Again" — suggest pinning after 3rd recall
|
|
3605
|
+
const suggestPin = updatedLesson.recall_count === 3 && !(lesson as { pinned?: boolean }).pinned
|
|
3606
|
+
? `📌 **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.`
|
|
3607
|
+
: '';
|
|
3608
|
+
|
|
2677
3609
|
return [
|
|
2678
|
-
|
|
3610
|
+
rememberWhen,
|
|
3611
|
+
`${badge} **Best solution for \`${topic}\`** ${sevEmoji}${lesson.severity ? ` (${lesson.severity})` : ''} · recalled ${updatedLesson.recall_count}×`,
|
|
2679
3612
|
``,
|
|
2680
3613
|
`**What worked:** ${lesson.what_worked}`,
|
|
2681
3614
|
lesson.what_failed ? `**What failed (avoid this):** ${lesson.what_failed}` : '',
|
|
@@ -2683,8 +3616,8 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
2683
3616
|
(lesson.file_paths ?? []).length > 0 ? `**Files:** ${(lesson.file_paths ?? []).map((f: string) => `\`${f}\``).join(', ')}` : '',
|
|
2684
3617
|
(lesson.commands ?? []).length > 0 ? `**Commands:** ${(lesson.commands ?? []).map((c: string) => `\`${c}\``).join(', ')}` : '',
|
|
2685
3618
|
(lesson.tags ?? []).length > 0 ? `**Tags:** ${(lesson.tags ?? []).map((t: string) => `#${t}`).join(' ')}` : '',
|
|
2686
|
-
|
|
2687
|
-
|
|
3619
|
+
auditSummary,
|
|
3620
|
+
suggestPin,
|
|
2688
3621
|
].filter(l => l !== '').join('\n');
|
|
2689
3622
|
}
|
|
2690
3623
|
|
|
@@ -2703,7 +3636,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
2703
3636
|
const histKey = `cachly:lessons:${topic}`;
|
|
2704
3637
|
const all = await redis.lrange(histKey, -3, -1);
|
|
2705
3638
|
if (all.length > 0) {
|
|
2706
|
-
const parsed = all.
|
|
3639
|
+
const parsed = all.map(e => JSON.parse(e) as { outcome: string; what_worked: string; ts: string });
|
|
2707
3640
|
const lines = parsed.map(p => `- ${p.outcome === 'success' ? '✅' : '❌'} ${p.what_worked.slice(0, 120)} (${new Date(p.ts).toLocaleDateString('de-DE')})`);
|
|
2708
3641
|
return `⚠️ No successful solution for \`${topic}\` yet. Last attempts:\n\n${lines.join('\n')}`;
|
|
2709
3642
|
}
|
|
@@ -2715,70 +3648,12 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
2715
3648
|
for (const k of matching.slice(0, 5)) {
|
|
2716
3649
|
const raw = await redis.get(k);
|
|
2717
3650
|
if (!raw) continue;
|
|
2718
|
-
|
|
2719
|
-
try { lesson = JSON.parse(raw); } catch { continue; }
|
|
3651
|
+
const lesson = JSON.parse(raw) as { topic: string; what_worked: string; context?: string; ts: string };
|
|
2720
3652
|
results.push(`**\`${lesson.topic}\`** — ${lesson.what_worked.slice(0, 200)}`);
|
|
2721
3653
|
}
|
|
2722
3654
|
return `🔍 **Partial matches for \`${topic}\`:**\n\n${results.join('\n\n')}`;
|
|
2723
3655
|
}
|
|
2724
3656
|
|
|
2725
|
-
case 'list_lessons': {
|
|
2726
|
-
const { instance_id, filter = '' } = args as { instance_id: string; filter?: string };
|
|
2727
|
-
const redis = await getConnection(instance_id);
|
|
2728
|
-
const keys: string[] = [];
|
|
2729
|
-
const stream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
|
|
2730
|
-
await new Promise<void>((resolve, reject) => {
|
|
2731
|
-
stream.on('data', (batch: string[]) => keys.push(...batch));
|
|
2732
|
-
stream.on('end', resolve);
|
|
2733
|
-
stream.on('error', reject);
|
|
2734
|
-
});
|
|
2735
|
-
if (keys.length === 0) return `📭 No lessons stored yet. Use \`learn_from_attempts\` after solving any problem.`;
|
|
2736
|
-
|
|
2737
|
-
const values = await redis.mget(...keys);
|
|
2738
|
-
type LessonMeta = { topic: string; ts: string; severity?: string; recall_count?: number; outcome?: string };
|
|
2739
|
-
const lessons: LessonMeta[] = values.flatMap(raw => {
|
|
2740
|
-
if (!raw) return [];
|
|
2741
|
-
try { return [JSON.parse(raw) as LessonMeta]; } catch { return []; }
|
|
2742
|
-
});
|
|
2743
|
-
|
|
2744
|
-
const filtered = filter
|
|
2745
|
-
? lessons.filter(l => l.topic.toLowerCase().includes(filter.toLowerCase()))
|
|
2746
|
-
: lessons;
|
|
2747
|
-
|
|
2748
|
-
filtered.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
|
|
2749
|
-
|
|
2750
|
-
if (filtered.length === 0) return `📭 No lessons matching \`${filter}\`.`;
|
|
2751
|
-
|
|
2752
|
-
const sevEmoji = (s?: string) => s === 'critical' ? '🔴' : s === 'major' ? '🟡' : '🟢';
|
|
2753
|
-
const lines = [
|
|
2754
|
-
`🧠 **${filtered.length} lesson${filtered.length === 1 ? '' : 's'}${filter ? ` matching "${filter}"` : ''} in Brain:**`,
|
|
2755
|
-
'',
|
|
2756
|
-
...filtered.slice(0, 30).map(l => {
|
|
2757
|
-
const age = Math.round((Date.now() - new Date(l.ts).getTime()) / 86400000);
|
|
2758
|
-
const ageStr = age === 0 ? 'today' : age === 1 ? 'yesterday' : `${age}d ago`;
|
|
2759
|
-
const recalls = l.recall_count ? ` · recalled ${l.recall_count}×` : '';
|
|
2760
|
-
return `${sevEmoji(l.severity)} \`${l.topic}\` — ${ageStr}${recalls}`;
|
|
2761
|
-
}),
|
|
2762
|
-
filtered.length > 30 ? `\n_…and ${filtered.length - 30} more. Use filter to narrow down._` : '',
|
|
2763
|
-
].filter(l => l !== '');
|
|
2764
|
-
return lines.join('\n');
|
|
2765
|
-
}
|
|
2766
|
-
|
|
2767
|
-
case 'forget_lesson': {
|
|
2768
|
-
const { instance_id, topic } = args as { instance_id: string; topic: string };
|
|
2769
|
-
const redis = await getConnection(instance_id);
|
|
2770
|
-
const bestKey = `cachly:lesson:best:${topic}`;
|
|
2771
|
-
const histKey = `cachly:lessons:${topic}`;
|
|
2772
|
-
const [delBest, delHist] = await Promise.all([
|
|
2773
|
-
redis.del(bestKey),
|
|
2774
|
-
redis.del(histKey),
|
|
2775
|
-
]);
|
|
2776
|
-
if (delBest === 0 && delHist === 0) {
|
|
2777
|
-
return `📭 No lesson found for \`${topic}\` — nothing to delete.`;
|
|
2778
|
-
}
|
|
2779
|
-
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\`.`;
|
|
2780
|
-
}
|
|
2781
|
-
|
|
2782
3657
|
case 'smart_recall': {
|
|
2783
3658
|
const {
|
|
2784
3659
|
instance_id,
|
|
@@ -2893,32 +3768,18 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
2893
3768
|
|
|
2894
3769
|
// ── get_api_status ────────────────────────────────────────────────────────
|
|
2895
3770
|
case 'get_api_status': {
|
|
2896
|
-
// Check
|
|
2897
|
-
const [healthResult, authServiceResult] = await Promise.allSettled([
|
|
2898
|
-
fetch(`${API_URL}/health`, { signal: AbortSignal.timeout(5000) }),
|
|
2899
|
-
fetch(`${AUTH_URL}/.well-known/openid-configuration`, { signal: AbortSignal.timeout(5000) }),
|
|
2900
|
-
]);
|
|
2901
|
-
|
|
3771
|
+
// Check health
|
|
2902
3772
|
let healthStatus = 'unknown';
|
|
2903
|
-
|
|
2904
|
-
const healthRes =
|
|
3773
|
+
try {
|
|
3774
|
+
const healthRes = await fetch(`${API_URL}/health`);
|
|
2905
3775
|
if (healthRes.ok) {
|
|
2906
|
-
const body = await healthRes.json()
|
|
2907
|
-
healthStatus =
|
|
3776
|
+
const body = await healthRes.json() as { status?: string; db?: string };
|
|
3777
|
+
healthStatus = `${body.status ?? 'ok'} (db: ${body.db ?? '?'})`;
|
|
2908
3778
|
} else {
|
|
2909
|
-
healthStatus =
|
|
3779
|
+
healthStatus = `HTTP ${healthRes.status}`;
|
|
2910
3780
|
}
|
|
2911
|
-
}
|
|
2912
|
-
healthStatus =
|
|
2913
|
-
}
|
|
2914
|
-
|
|
2915
|
-
let authSvcStatus = 'unknown';
|
|
2916
|
-
if (authServiceResult.status === 'fulfilled') {
|
|
2917
|
-
authSvcStatus = authServiceResult.value.ok
|
|
2918
|
-
? `✅ reachable (${AUTH_URL})`
|
|
2919
|
-
: `❌ HTTP ${authServiceResult.value.status} — auth service may be down`;
|
|
2920
|
-
} else {
|
|
2921
|
-
authSvcStatus = `❌ unreachable: ${authServiceResult.reason?.message ?? 'timeout'} — auth service may be down`;
|
|
3781
|
+
} catch (e) {
|
|
3782
|
+
healthStatus = `unreachable: ${(e as Error).message}`;
|
|
2922
3783
|
}
|
|
2923
3784
|
|
|
2924
3785
|
// Check JWT / auth
|
|
@@ -2926,18 +3787,16 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
2926
3787
|
return [
|
|
2927
3788
|
`📡 **cachly API Status**`,
|
|
2928
3789
|
``,
|
|
2929
|
-
` 🌐 API:
|
|
2930
|
-
` 💓
|
|
2931
|
-
`
|
|
2932
|
-
` 🔑 JWT: ❌ CACHLY_JWT not set`,
|
|
3790
|
+
` 🌐 API: ${API_URL}`,
|
|
3791
|
+
` 💓 Health: ${healthStatus}`,
|
|
3792
|
+
` 🔑 Auth: ❌ CACHLY_JWT not set`,
|
|
2933
3793
|
``,
|
|
2934
|
-
`💡 Get your API token at https://cachly.dev/
|
|
3794
|
+
`💡 Get your API token at https://cachly.dev/instances → Settings → API Token`,
|
|
2935
3795
|
].join('\n');
|
|
2936
3796
|
}
|
|
2937
3797
|
|
|
2938
3798
|
// Decode JWT claims (inspection only, no verification)
|
|
2939
3799
|
let authInfo = '❌ invalid JWT format';
|
|
2940
|
-
let isExpired = false;
|
|
2941
3800
|
try {
|
|
2942
3801
|
const parts = JWT.split('.');
|
|
2943
3802
|
if (parts.length === 3) {
|
|
@@ -2948,44 +3807,33 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
2948
3807
|
const iss = payload.iss ?? '(unknown)';
|
|
2949
3808
|
const provider = iss.includes('keycloak') ? 'Keycloak' : 'OIDC';
|
|
2950
3809
|
const expTs = payload.exp ? new Date(payload.exp * 1000) : null;
|
|
2951
|
-
|
|
3810
|
+
const expired = expTs ? expTs < new Date() : false;
|
|
2952
3811
|
authInfo = [
|
|
2953
|
-
|
|
3812
|
+
`✅ JWT decoded`,
|
|
2954
3813
|
` Sub (user ID): ${sub}`,
|
|
2955
3814
|
` Provider: ${provider}`,
|
|
2956
3815
|
` Issuer: ${iss}`,
|
|
2957
|
-
` Expires: ${expTs ? expTs.toISOString() : 'never'} ${
|
|
3816
|
+
` Expires: ${expTs ? expTs.toISOString() : 'never'} ${expired ? '⚠️ EXPIRED – get a new token!' : '✅'}`,
|
|
2958
3817
|
].join('\n');
|
|
2959
3818
|
}
|
|
2960
3819
|
} catch {
|
|
2961
3820
|
authInfo = '❌ JWT decode failed – check CACHLY_JWT format';
|
|
2962
3821
|
}
|
|
2963
3822
|
|
|
2964
|
-
|
|
3823
|
+
return [
|
|
2965
3824
|
`📡 **cachly API Status**`,
|
|
2966
3825
|
``,
|
|
2967
|
-
` 🌐 API:
|
|
2968
|
-
` 💓
|
|
2969
|
-
` 🔐 Auth Service: ${authSvcStatus}`,
|
|
3826
|
+
` 🌐 API: ${API_URL}`,
|
|
3827
|
+
` 💓 Health: ${healthStatus}`,
|
|
2970
3828
|
``,
|
|
2971
|
-
`🔑 **Auth
|
|
3829
|
+
`🔑 **Auth:**`,
|
|
2972
3830
|
authInfo,
|
|
2973
|
-
];
|
|
2974
|
-
|
|
2975
|
-
if (isExpired) {
|
|
2976
|
-
lines.push(``, `⚠️ Your token is expired — get a new one at https://cachly.dev/settings`);
|
|
2977
|
-
}
|
|
2978
|
-
if (authSvcStatus.startsWith('❌')) {
|
|
2979
|
-
lines.push(``, `⚠️ Auth service appears down. Registration and login on cachly.dev may be unavailable.`);
|
|
2980
|
-
lines.push(` Contact support@cachly.dev if this persists.`);
|
|
2981
|
-
}
|
|
2982
|
-
|
|
2983
|
-
return lines.join('\n');
|
|
3831
|
+
].join('\n');
|
|
2984
3832
|
}
|
|
2985
3833
|
|
|
2986
3834
|
// ── session_start ─────────────────────────────────────────────────────────
|
|
2987
3835
|
case 'session_start': {
|
|
2988
|
-
const { instance_id, focus = '' } = args as { instance_id: string; focus?: string };
|
|
3836
|
+
const { instance_id, focus = '', author = '', provider = '' } = args as { instance_id: string; focus?: string; author?: string; provider?: string };
|
|
2989
3837
|
const redis = await getConnection(instance_id);
|
|
2990
3838
|
|
|
2991
3839
|
// 1. Scan all best-solution lessons
|
|
@@ -3000,16 +3848,14 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3000
3848
|
// 2. Fetch all lesson values for recency sorting + focus matching
|
|
3001
3849
|
type Lesson = {
|
|
3002
3850
|
topic: string; outcome: string; what_worked: string; what_failed?: string;
|
|
3003
|
-
ts: string;
|
|
3851
|
+
ts: string; verified_at?: string; severity?: string; recall_count?: number;
|
|
3852
|
+
tags?: string[]; confidence?: number; audit_trail?: unknown[];
|
|
3004
3853
|
};
|
|
3005
3854
|
const lessons: Lesson[] = [];
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
if (!raw) continue;
|
|
3011
|
-
try { lessons.push(JSON.parse(raw) as Lesson); } catch { /* skip corrupt */ }
|
|
3012
|
-
}
|
|
3855
|
+
for (const k of lessonKeys) {
|
|
3856
|
+
const raw = await redis.get(k);
|
|
3857
|
+
if (!raw) continue;
|
|
3858
|
+
try { lessons.push(JSON.parse(raw) as Lesson); } catch { /* skip corrupt */ }
|
|
3013
3859
|
}
|
|
3014
3860
|
lessons.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
|
|
3015
3861
|
|
|
@@ -3042,14 +3888,111 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3042
3888
|
)
|
|
3043
3889
|
: [];
|
|
3044
3890
|
|
|
3045
|
-
// 6.
|
|
3891
|
+
// 6. Streak tracking
|
|
3892
|
+
let streakDays = 0;
|
|
3893
|
+
let streakRecord = 0;
|
|
3894
|
+
let streakMessage = '';
|
|
3895
|
+
try {
|
|
3896
|
+
const streakRaw = await redis.get('cachly:streak:current');
|
|
3897
|
+
const streak = streakRaw ? JSON.parse(streakRaw) as { days: number; last_date: string; record: number } : null;
|
|
3898
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
3899
|
+
if (streak) {
|
|
3900
|
+
const lastDate = streak.last_date;
|
|
3901
|
+
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
|
3902
|
+
if (lastDate === today) {
|
|
3903
|
+
// Already counted today
|
|
3904
|
+
streakDays = streak.days;
|
|
3905
|
+
streakRecord = streak.record;
|
|
3906
|
+
} else if (lastDate === yesterday) {
|
|
3907
|
+
// Continuing streak
|
|
3908
|
+
streakDays = streak.days + 1;
|
|
3909
|
+
streakRecord = Math.max(streakDays, streak.record);
|
|
3910
|
+
await redis.set('cachly:streak:current', JSON.stringify({ days: streakDays, last_date: today, record: streakRecord }));
|
|
3911
|
+
} else {
|
|
3912
|
+
// Streak broken
|
|
3913
|
+
streakDays = 1;
|
|
3914
|
+
streakRecord = streak.record;
|
|
3915
|
+
await redis.set('cachly:streak:current', JSON.stringify({ days: 1, last_date: today, record: streakRecord }));
|
|
3916
|
+
}
|
|
3917
|
+
} else {
|
|
3918
|
+
// First session ever
|
|
3919
|
+
streakDays = 1;
|
|
3920
|
+
streakRecord = 1;
|
|
3921
|
+
await redis.set('cachly:streak:current', JSON.stringify({ days: 1, last_date: today, record: 1 }));
|
|
3922
|
+
}
|
|
3923
|
+
if (streakDays >= 7) streakMessage = `🔥 **${streakDays}-day streak!** ${streakDays === streakRecord ? ' New record!' : `Best: ${streakRecord}d`}`;
|
|
3924
|
+
else if (streakDays > 1) streakMessage = `🔥 ${streakDays}-day streak`;
|
|
3925
|
+
} catch { /* non-critical */ }
|
|
3926
|
+
|
|
3927
|
+
// 7. Save session start marker
|
|
3046
3928
|
await redis.set('cachly:session:current', JSON.stringify({
|
|
3047
3929
|
started: new Date().toISOString(),
|
|
3048
3930
|
focus,
|
|
3931
|
+
provider,
|
|
3049
3932
|
}), 'EX', 86400); // auto-expire after 24h if session_end never called
|
|
3050
3933
|
|
|
3051
3934
|
// ── Build briefing ──────────────────────────────────────────────────────
|
|
3052
|
-
const
|
|
3935
|
+
const providerLabel = provider ? ` · ${provider}` : '';
|
|
3936
|
+
const lines: string[] = [`🧠 **Session Briefing**${providerLabel}`, ''];
|
|
3937
|
+
if (streakMessage) lines.push(streakMessage, '');
|
|
3938
|
+
|
|
3939
|
+
// Handoff from previous window (if any)
|
|
3940
|
+
const handoffRaw = await redis.get('cachly:session:handoff');
|
|
3941
|
+
if (handoffRaw) {
|
|
3942
|
+
try {
|
|
3943
|
+
const handoff = JSON.parse(handoffRaw) as {
|
|
3944
|
+
ts: string; completed_tasks: string[]; remaining_tasks: string[];
|
|
3945
|
+
files_changed?: { path: string; status: string; description?: string }[];
|
|
3946
|
+
instructions?: string; context_summary?: string; blocked_on?: string;
|
|
3947
|
+
};
|
|
3948
|
+
const ago = Math.round((Date.now() - new Date(handoff.ts).getTime()) / 60000);
|
|
3949
|
+
const agoStr = ago < 60 ? `${ago}m ago` : ago < 1440 ? `${Math.round(ago / 60)}h ago` : `${Math.round(ago / 1440)}d ago`;
|
|
3950
|
+
|
|
3951
|
+
lines.push(`🤝 **Handoff from previous window** (${agoStr}):`);
|
|
3952
|
+
if (handoff.context_summary) lines.push(` ${handoff.context_summary}`);
|
|
3953
|
+
if (handoff.remaining_tasks.length > 0) {
|
|
3954
|
+
lines.push(` ⏳ **Remaining tasks:**`);
|
|
3955
|
+
for (const t of handoff.remaining_tasks) lines.push(` - ${t}`);
|
|
3956
|
+
}
|
|
3957
|
+
if (handoff.completed_tasks.length > 0) {
|
|
3958
|
+
lines.push(` ✅ **Already done:** ${handoff.completed_tasks.join(', ')}`);
|
|
3959
|
+
}
|
|
3960
|
+
const brokenFiles = (handoff.files_changed ?? []).filter(f => f.status === 'broken' || f.status === 'partial');
|
|
3961
|
+
if (brokenFiles.length > 0) {
|
|
3962
|
+
lines.push(` ⚠️ **Needs fix:** ${brokenFiles.map(f => `\`${f.path}\` (${f.status}${f.description ? ': ' + f.description : ''})`).join(', ')}`);
|
|
3963
|
+
}
|
|
3964
|
+
if (handoff.blocked_on) lines.push(` 🚫 **Blocked on:** ${handoff.blocked_on}`);
|
|
3965
|
+
if (handoff.instructions) lines.push(` 📝 **Instructions:** ${handoff.instructions}`);
|
|
3966
|
+
lines.push('');
|
|
3967
|
+
} catch { /* ignore corrupt handoff */ }
|
|
3968
|
+
}
|
|
3969
|
+
|
|
3970
|
+
// ── Last checkpoint (session_ping) — shown when no session_end found ────
|
|
3971
|
+
const checkpointRaw = await redis.get('cachly:session:checkpoint');
|
|
3972
|
+
if (checkpointRaw) {
|
|
3973
|
+
try {
|
|
3974
|
+
const cp = JSON.parse(checkpointRaw) as {
|
|
3975
|
+
ts: string; task: string; files_touched: string[]; next_step?: string; provider?: string;
|
|
3976
|
+
};
|
|
3977
|
+
// Only show checkpoint if it's more recent than last session_end
|
|
3978
|
+
const cpTime = new Date(cp.ts).getTime();
|
|
3979
|
+
const lastSessionTime = lastSession ? new Date(lastSession.ts).getTime() : 0;
|
|
3980
|
+
if (cpTime > lastSessionTime) {
|
|
3981
|
+
const ago = Math.round((Date.now() - cpTime) / 60000);
|
|
3982
|
+
const agoStr = ago < 60 ? `${ago}m ago` : ago < 1440 ? `${Math.round(ago / 60)}h ago` : `${Math.round(ago / 1440)}d ago`;
|
|
3983
|
+
const providerStr = cp.provider ? ` [${cp.provider}]` : '';
|
|
3984
|
+
lines.push(`📌 **Last checkpoint**${providerStr} (${agoStr}): ${cp.task}`);
|
|
3985
|
+
if (cp.files_touched.length > 0) {
|
|
3986
|
+
lines.push(` Files: ${cp.files_touched.slice(0, 5).map(f => `\`${f}\``).join(', ')}`);
|
|
3987
|
+
}
|
|
3988
|
+
if (cp.next_step) lines.push(` 📍 Next step was: ${cp.next_step}`);
|
|
3989
|
+
if (!lastSession || cpTime - lastSessionTime > 300_000) {
|
|
3990
|
+
lines.push(` ⚠️ No \`session_end\` found — reconstructed from last checkpoint`);
|
|
3991
|
+
}
|
|
3992
|
+
lines.push('');
|
|
3993
|
+
}
|
|
3994
|
+
} catch { /* ignore */ }
|
|
3995
|
+
}
|
|
3053
3996
|
|
|
3054
3997
|
// Last session
|
|
3055
3998
|
if (lastSession) {
|
|
@@ -3063,26 +4006,8 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3063
4006
|
lines.push('');
|
|
3064
4007
|
}
|
|
3065
4008
|
|
|
3066
|
-
// Brain health
|
|
3067
|
-
|
|
3068
|
-
lines.push(`🆕 **Brain is empty — first session!**`);
|
|
3069
|
-
lines.push(`After solving anything, call \`learn_from_attempts\` to store the lesson.`);
|
|
3070
|
-
lines.push(`After this session, call \`session_end\` to save a summary.`);
|
|
3071
|
-
lines.push('');
|
|
3072
|
-
} else {
|
|
3073
|
-
const totalRecalls = lessons.reduce((s, l) => s + (l.recall_count ?? 0), 0);
|
|
3074
|
-
const savedMin = totalRecalls * 15;
|
|
3075
|
-
const savedStr = savedMin >= 60
|
|
3076
|
-
? `~${(savedMin / 60).toFixed(1)}h saved`
|
|
3077
|
-
: savedMin > 0 ? `~${savedMin}min saved` : '';
|
|
3078
|
-
const statsLine = [
|
|
3079
|
-
`${lessons.length} lessons`,
|
|
3080
|
-
ctxCount > 0 ? `${ctxCount} context entries` : '',
|
|
3081
|
-
totalRecalls > 0 ? `${totalRecalls} recalls` : '',
|
|
3082
|
-
savedStr,
|
|
3083
|
-
].filter(Boolean).join(' · ');
|
|
3084
|
-
lines.push(`📊 **Brain:** ${statsLine}`, '');
|
|
3085
|
-
}
|
|
4009
|
+
// Brain health
|
|
4010
|
+
lines.push(`📊 **Brain:** ${lessons.length} lessons · ${ctxCount} context entries`, '');
|
|
3086
4011
|
|
|
3087
4012
|
// Focus-relevant lessons
|
|
3088
4013
|
if (focusLessons.length > 0) {
|
|
@@ -3119,6 +4044,136 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3119
4044
|
lines.push('');
|
|
3120
4045
|
}
|
|
3121
4046
|
|
|
4047
|
+
// ── Stale / low-confidence lessons (confidence decay) ─────────────────
|
|
4048
|
+
const staleSuccessLessons = lessons.filter(l => {
|
|
4049
|
+
if (l.outcome !== 'success' && l.outcome !== 'partial') return false;
|
|
4050
|
+
return calculateConfidence(l) < CONFIDENCE_WARN_VALUE;
|
|
4051
|
+
});
|
|
4052
|
+
if (staleSuccessLessons.length > 0) {
|
|
4053
|
+
lines.push(`🔴 **Stale lessons** (not recalled in >${CONFIDENCE_WARN_DAYS}d — verify before applying):`);
|
|
4054
|
+
for (const l of staleSuccessLessons.slice(0, 4)) {
|
|
4055
|
+
const conf = calculateConfidence(l);
|
|
4056
|
+
const ageDays = Math.round((Date.now() - new Date(l.verified_at ?? l.ts).getTime()) / 86400000);
|
|
4057
|
+
const flag = conf < CONFIDENCE_STALE_VALUE ? '🔴' : '⚠️';
|
|
4058
|
+
lines.push(` ${flag} \`${l.topic}\` — ${ageDays}d stale, ${(conf * 100).toFixed(0)}% confidence`);
|
|
4059
|
+
}
|
|
4060
|
+
lines.push(` _Run \`recall_best_solution\` on these to reset their confidence clock._`);
|
|
4061
|
+
lines.push('');
|
|
4062
|
+
}
|
|
4063
|
+
|
|
4064
|
+
// ── Session Replay: show last session's decision log ──────────────────
|
|
4065
|
+
const lastSessionAny = lastSession as unknown as { decision_log?: Array<{ topic: string; outcome: string; what_worked: string }> } | null;
|
|
4066
|
+
if (lastSessionAny?.decision_log?.length) {
|
|
4067
|
+
const dl = lastSessionAny.decision_log;
|
|
4068
|
+
const successes = dl.filter(d => d.outcome === 'success');
|
|
4069
|
+
const failures = dl.filter(d => d.outcome === 'failure');
|
|
4070
|
+
lines.push(`🎬 **Last session decisions** (${dl.length} lessons stored):`);
|
|
4071
|
+
if (successes.length > 0) lines.push(` ✅ Worked: ${successes.slice(0, 3).map(d => `\`${d.topic}\``).join(', ')}`);
|
|
4072
|
+
if (failures.length > 0) lines.push(` ❌ Failed: ${failures.slice(0, 3).map(d => `\`${d.topic}\``).join(', ')}`);
|
|
4073
|
+
lines.push('');
|
|
4074
|
+
}
|
|
4075
|
+
|
|
4076
|
+
// ── 🔮 Predictive Pre-Warning — intent-based danger detection ────────────
|
|
4077
|
+
// Fires BEFORE work starts. If focus area has known failure patterns → warn loudly.
|
|
4078
|
+
if (focusTerms.length > 0) {
|
|
4079
|
+
type LessonAny = typeof lessons[0] & { author?: string; tags?: string[] };
|
|
4080
|
+
const dangerLessons = (lessons as LessonAny[]).filter(l => {
|
|
4081
|
+
if (l.outcome === 'success') return false;
|
|
4082
|
+
const topicCategory = l.topic.split(':')[0];
|
|
4083
|
+
return focusTerms.some(term =>
|
|
4084
|
+
l.topic.toLowerCase().includes(term) ||
|
|
4085
|
+
topicCategory === term ||
|
|
4086
|
+
(l.tags ?? []).some((t: string) => t.toLowerCase() === term),
|
|
4087
|
+
);
|
|
4088
|
+
});
|
|
4089
|
+
if (dangerLessons.length >= 1) {
|
|
4090
|
+
// Insert warning block right after the title line (index 1 = blank line after title)
|
|
4091
|
+
const warning = [
|
|
4092
|
+
`🚨 **PRE-WARNING** — Read this BEFORE starting:`,
|
|
4093
|
+
` Known pitfalls for **"${focus}"** (${dangerLessons.length} past failure${dangerLessons.length > 1 ? 's' : ''}):`,
|
|
4094
|
+
...dangerLessons.slice(0, 3).map(l => ` ❌ \`${l.topic}\` — ${(l.what_failed ?? l.what_worked).slice(0, 80)}`),
|
|
4095
|
+
'',
|
|
4096
|
+
];
|
|
4097
|
+
lines.splice(2, 0, ...warning); // after '🧠 **Session Briefing**' + empty line
|
|
4098
|
+
}
|
|
4099
|
+
}
|
|
4100
|
+
|
|
4101
|
+
// ── 👥 Team Telepathy — what teammates learned this week ─────────────────
|
|
4102
|
+
if (author) {
|
|
4103
|
+
type LessonAny = typeof lessons[0] & { author?: string };
|
|
4104
|
+
const oneWeekAgo = Date.now() - 7 * 86_400_000;
|
|
4105
|
+
const teamLessons = (lessons as LessonAny[]).filter(l =>
|
|
4106
|
+
l.author && l.author !== author && new Date(l.ts).getTime() > oneWeekAgo,
|
|
4107
|
+
);
|
|
4108
|
+
if (teamLessons.length > 0) {
|
|
4109
|
+
// Group by author
|
|
4110
|
+
const byAuthor = new Map<string, LessonAny[]>();
|
|
4111
|
+
for (const l of teamLessons) {
|
|
4112
|
+
const a = l.author!;
|
|
4113
|
+
if (!byAuthor.has(a)) byAuthor.set(a, []);
|
|
4114
|
+
byAuthor.get(a)!.push(l);
|
|
4115
|
+
}
|
|
4116
|
+
lines.push(`👥 **Team this week** (${teamLessons.length} lesson${teamLessons.length > 1 ? 's' : ''} from teammates):`);
|
|
4117
|
+
for (const [teamAuthor, tls] of byAuthor) {
|
|
4118
|
+
lines.push(` 👤 **${teamAuthor}**:`);
|
|
4119
|
+
for (const l of tls.slice(0, 3)) {
|
|
4120
|
+
const emoji = l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
|
|
4121
|
+
lines.push(` ${emoji} \`${l.topic}\` — ${l.what_worked.slice(0, 80)}`);
|
|
4122
|
+
}
|
|
4123
|
+
if (tls.length > 3) lines.push(` … and ${tls.length - 3} more`);
|
|
4124
|
+
}
|
|
4125
|
+
lines.push('');
|
|
4126
|
+
}
|
|
4127
|
+
}
|
|
4128
|
+
|
|
4129
|
+
// ── 💎 Memory Crystal — compressed wisdom from old sessions ──────────────
|
|
4130
|
+
try {
|
|
4131
|
+
const crystalRaw = await redis.get('cachly:crystal:latest');
|
|
4132
|
+
if (crystalRaw) {
|
|
4133
|
+
const crystal = JSON.parse(crystalRaw) as {
|
|
4134
|
+
label: string; ts: string; session_count: number;
|
|
4135
|
+
top_patterns: Array<{ category: string; insight: string; count: number }>;
|
|
4136
|
+
};
|
|
4137
|
+
const crystalAge = Math.round((Date.now() - new Date(crystal.ts).getTime()) / 86_400_000);
|
|
4138
|
+
if (crystalAge <= 90) {
|
|
4139
|
+
lines.push(`💎 **Memory Crystal** (${crystal.label} · ${crystal.session_count} sessions compressed):`);
|
|
4140
|
+
for (const p of crystal.top_patterns.slice(0, 3)) {
|
|
4141
|
+
lines.push(` • **${p.category}** (${p.count}×): ${p.insight.slice(0, 90)}`);
|
|
4142
|
+
}
|
|
4143
|
+
lines.push('');
|
|
4144
|
+
}
|
|
4145
|
+
}
|
|
4146
|
+
} catch { /* non-critical */ }
|
|
4147
|
+
|
|
4148
|
+
// ── 🗺️ Roadmap — open items at session start ────────────────────────────
|
|
4149
|
+
try {
|
|
4150
|
+
const roadmapAll = await redis.hgetall(`cachly:roadmap:${instance_id}`);
|
|
4151
|
+
if (roadmapAll && Object.keys(roadmapAll).length > 0) {
|
|
4152
|
+
const PRIORITY_ORDER: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
4153
|
+
const PRIORITY_ICON: Record<string, string> = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
|
|
4154
|
+
const openStatuses = new Set(['planned', 'in-progress', 'blocked']);
|
|
4155
|
+
const allItems = Object.values(roadmapAll).map(v => JSON.parse(v as string) as Record<string, unknown>);
|
|
4156
|
+
const openItems = allItems
|
|
4157
|
+
.filter(i => openStatuses.has(i.status as string))
|
|
4158
|
+
.sort((a, b) => {
|
|
4159
|
+
if (a.status === 'in-progress' && b.status !== 'in-progress') return -1;
|
|
4160
|
+
if (b.status === 'in-progress' && a.status !== 'in-progress') return 1;
|
|
4161
|
+
return (PRIORITY_ORDER[a.priority as string] ?? 99) - (PRIORITY_ORDER[b.priority as string] ?? 99);
|
|
4162
|
+
});
|
|
4163
|
+
const doneCount = allItems.filter(i => i.status === 'done').length;
|
|
4164
|
+
if (openItems.length > 0) {
|
|
4165
|
+
lines.push(`🗺️ **Roadmap** (${openItems.length} open · ${doneCount} done):`);
|
|
4166
|
+
for (const it of openItems.slice(0, 5)) {
|
|
4167
|
+
const statusIcon = it.status === 'in-progress' ? '⚡' : it.status === 'blocked' ? '🚫' : '📋';
|
|
4168
|
+
lines.push(` ${statusIcon} ${PRIORITY_ICON[it.priority as string] ?? '⚪'} \`${it.id}\` **${it.title}**`);
|
|
4169
|
+
}
|
|
4170
|
+
if (openItems.length > 5) lines.push(` … and ${openItems.length - 5} more`);
|
|
4171
|
+
lines.push(` _Use \`roadmap_next\` for the top priority item · \`roadmap_list\` for full view_`);
|
|
4172
|
+
lines.push('');
|
|
4173
|
+
}
|
|
4174
|
+
}
|
|
4175
|
+
} catch { /* non-critical */ }
|
|
4176
|
+
|
|
3122
4177
|
return lines.join('\n');
|
|
3123
4178
|
}
|
|
3124
4179
|
|
|
@@ -3129,11 +4184,13 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3129
4184
|
summary,
|
|
3130
4185
|
files_changed = [],
|
|
3131
4186
|
lessons_learned,
|
|
4187
|
+
workspace_path = '',
|
|
3132
4188
|
} = args as {
|
|
3133
4189
|
instance_id: string;
|
|
3134
4190
|
summary: string;
|
|
3135
4191
|
files_changed?: string[];
|
|
3136
4192
|
lessons_learned?: number;
|
|
4193
|
+
workspace_path?: string;
|
|
3137
4194
|
};
|
|
3138
4195
|
|
|
3139
4196
|
const redis = await getConnection(instance_id);
|
|
@@ -3149,12 +4206,21 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3149
4206
|
} catch { /* ignore */ }
|
|
3150
4207
|
}
|
|
3151
4208
|
|
|
4209
|
+
// ── Session Replay: capture decision log ─────────────────────────────
|
|
4210
|
+
let decisionLog: Array<{ ts: string; topic: string; outcome: string; what_worked: string }> = [];
|
|
4211
|
+
try {
|
|
4212
|
+
const dlEntries = await redis.lrange('cachly:session:decision-log', 0, -1);
|
|
4213
|
+
decisionLog = dlEntries.map(e => JSON.parse(e) as { ts: string; topic: string; outcome: string; what_worked: string });
|
|
4214
|
+
await redis.del('cachly:session:decision-log');
|
|
4215
|
+
} catch { /* non-critical */ }
|
|
4216
|
+
|
|
3152
4217
|
const sessionRecord = {
|
|
3153
4218
|
ts: now.toISOString(),
|
|
3154
4219
|
summary,
|
|
3155
4220
|
files_changed,
|
|
3156
4221
|
...(lessons_learned !== undefined ? { lessons_learned } : {}),
|
|
3157
4222
|
...(durationMin !== undefined ? { duration_min: durationMin } : {}),
|
|
4223
|
+
...(decisionLog.length > 0 ? { decision_log: decisionLog } : {}),
|
|
3158
4224
|
};
|
|
3159
4225
|
|
|
3160
4226
|
// Save as "last session"
|
|
@@ -3168,6 +4234,127 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3168
4234
|
// Clean up current session marker
|
|
3169
4235
|
await redis.del('cachly:session:current');
|
|
3170
4236
|
|
|
4237
|
+
// ── AUTO-LEARN from session summary (no manual call needed) ─────────────
|
|
4238
|
+
// Parse the summary for actionable lessons and store them automatically.
|
|
4239
|
+
const autoLearned: string[] = [];
|
|
4240
|
+
try {
|
|
4241
|
+
// Extract key sentences from the summary that contain action verbs
|
|
4242
|
+
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;
|
|
4243
|
+
const sentences = summary
|
|
4244
|
+
.split(/[.!\n]+/)
|
|
4245
|
+
.map(s => s.trim())
|
|
4246
|
+
.filter(s => s.length > 20 && actionVerbs.test(s));
|
|
4247
|
+
|
|
4248
|
+
for (const sentence of sentences.slice(0, 6)) {
|
|
4249
|
+
// Build a topic slug from the first meaningful words
|
|
4250
|
+
const words = sentence.toLowerCase()
|
|
4251
|
+
.replace(/[^a-z0-9\s]/g, ' ')
|
|
4252
|
+
.split(/\s+/)
|
|
4253
|
+
.filter(w => w.length > 3 && !['that', 'this', 'with', 'from', 'have', 'been', 'were', 'they', 'then', 'when', 'also', 'into', 'will', 'would', 'could', 'should'].includes(w));
|
|
4254
|
+
const slug = words.slice(0, 4).join('-');
|
|
4255
|
+
if (!slug) continue;
|
|
4256
|
+
const topic = `auto:${slug}`;
|
|
4257
|
+
const key = `cachly:lesson:best:${topic}`;
|
|
4258
|
+
|
|
4259
|
+
// Don't overwrite existing successful lessons
|
|
4260
|
+
const existing = await redis.get(key);
|
|
4261
|
+
if (existing) {
|
|
4262
|
+
try {
|
|
4263
|
+
const ex = JSON.parse(existing) as { outcome: string };
|
|
4264
|
+
if (ex.outcome === 'success') continue;
|
|
4265
|
+
} catch { /* ignore */ }
|
|
4266
|
+
}
|
|
4267
|
+
|
|
4268
|
+
const lesson = {
|
|
4269
|
+
topic,
|
|
4270
|
+
outcome: 'success',
|
|
4271
|
+
what_worked: sentence,
|
|
4272
|
+
context: `Auto-learned from session summary. Full summary: ${summary.slice(0, 300)}`,
|
|
4273
|
+
severity: 'minor',
|
|
4274
|
+
ts: now.toISOString(),
|
|
4275
|
+
recall_count: 0,
|
|
4276
|
+
auto_learned: true,
|
|
4277
|
+
session_ts: now.toISOString(),
|
|
4278
|
+
version: 2,
|
|
4279
|
+
};
|
|
4280
|
+
await redis.set(key, JSON.stringify(lesson));
|
|
4281
|
+
// 90-day TTL for auto-learned lessons
|
|
4282
|
+
await redis.expire(key, 90 * 86400);
|
|
4283
|
+
autoLearned.push(topic);
|
|
4284
|
+
}
|
|
4285
|
+
|
|
4286
|
+
// Also store a lesson per changed file area if files were changed
|
|
4287
|
+
if (files_changed.length > 0) {
|
|
4288
|
+
const areas = [...new Set(files_changed.map(f => f.split('/').slice(0, 2).join('/')))].slice(0, 3);
|
|
4289
|
+
for (const area of areas) {
|
|
4290
|
+
const slug = area.replace(/[^a-z0-9]/gi, '-').toLowerCase().replace(/-+/g, '-').slice(0, 30);
|
|
4291
|
+
const topic = `auto:changed:${slug}`;
|
|
4292
|
+
const key = `cachly:lesson:best:${topic}`;
|
|
4293
|
+
const lesson = {
|
|
4294
|
+
topic,
|
|
4295
|
+
outcome: 'success',
|
|
4296
|
+
what_worked: `Files changed in ${area}: ${files_changed.filter(f => f.startsWith(area.split('/')[0])).slice(0, 5).join(', ')}`,
|
|
4297
|
+
context: summary.slice(0, 200),
|
|
4298
|
+
severity: 'minor',
|
|
4299
|
+
ts: now.toISOString(),
|
|
4300
|
+
recall_count: 0,
|
|
4301
|
+
auto_learned: true,
|
|
4302
|
+
version: 2,
|
|
4303
|
+
};
|
|
4304
|
+
await redis.set(key, JSON.stringify(lesson));
|
|
4305
|
+
await redis.expire(key, 90 * 86400);
|
|
4306
|
+
autoLearned.push(topic);
|
|
4307
|
+
}
|
|
4308
|
+
}
|
|
4309
|
+
} catch { /* auto-learn errors must never break session_end */ }
|
|
4310
|
+
|
|
4311
|
+
// ── 🌿 Ambient Git Learning ────────────────────────────────────────────────
|
|
4312
|
+
// Read git commits since session start → auto-learn each meaningful commit.
|
|
4313
|
+
const ambientLearned: string[] = [];
|
|
4314
|
+
if (workspace_path) {
|
|
4315
|
+
try {
|
|
4316
|
+
// Get the session start time (stored by session_start)
|
|
4317
|
+
const sessionStartTs = currentRaw
|
|
4318
|
+
? (() => { try { return (JSON.parse(currentRaw) as { started?: string }).started ?? ''; } catch { return ''; } })()
|
|
4319
|
+
: '';
|
|
4320
|
+
const sinceArg = sessionStartTs ? `--since="${sessionStartTs}"` : '--since="1 hour ago"';
|
|
4321
|
+
const gitOut = execSync(
|
|
4322
|
+
`git -C "${workspace_path}" log ${sinceArg} --oneline --format="%H|||%s|||%ai"`,
|
|
4323
|
+
{ timeout: 5000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
4324
|
+
).trim();
|
|
4325
|
+
if (gitOut) {
|
|
4326
|
+
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;
|
|
4327
|
+
for (const line of gitOut.split('\n').slice(0, 10)) {
|
|
4328
|
+
const [hash, msg, dateStr] = line.split('|||');
|
|
4329
|
+
if (!msg || !commitActionRe.test(msg)) continue;
|
|
4330
|
+
const slug = msg
|
|
4331
|
+
.toLowerCase().replace(/^(fix|feat|chore|docs|test|ci|perf|refactor|build|revert)[:(\s]/i, '')
|
|
4332
|
+
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
|
|
4333
|
+
if (!slug) continue;
|
|
4334
|
+
const topic = `git:${slug}`;
|
|
4335
|
+
const key = `cachly:lesson:best:${topic}`;
|
|
4336
|
+
const existing = await redis.get(key);
|
|
4337
|
+
if (existing) continue; // don't overwrite existing
|
|
4338
|
+
const commitLesson = {
|
|
4339
|
+
topic,
|
|
4340
|
+
outcome: 'success' as const,
|
|
4341
|
+
what_worked: msg.slice(0, 200),
|
|
4342
|
+
context: `Auto-learned from git commit ${(hash ?? '').slice(0, 7)} at ${dateStr ?? ''} in ${workspace_path}`,
|
|
4343
|
+
severity: 'minor' as const,
|
|
4344
|
+
ts: now.toISOString(),
|
|
4345
|
+
recall_count: 0,
|
|
4346
|
+
auto_learned: true,
|
|
4347
|
+
source: 'ambient-git',
|
|
4348
|
+
version: 3,
|
|
4349
|
+
};
|
|
4350
|
+
await redis.set(key, JSON.stringify(commitLesson));
|
|
4351
|
+
await redis.expire(key, 60 * 86400); // 60 day TTL for git lessons
|
|
4352
|
+
ambientLearned.push(topic);
|
|
4353
|
+
}
|
|
4354
|
+
}
|
|
4355
|
+
} catch { /* git not available or not a repo — silent skip */ }
|
|
4356
|
+
}
|
|
4357
|
+
|
|
3171
4358
|
const durationStr = durationMin !== undefined ? ` · ${durationMin} min` : '';
|
|
3172
4359
|
return [
|
|
3173
4360
|
`✅ **Session saved**${durationStr}`,
|
|
@@ -3175,11 +4362,115 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3175
4362
|
`📋 **Summary:** ${summary}`,
|
|
3176
4363
|
files_changed.length > 0 ? `📁 **Files changed:** ${files_changed.map(f => `\`${f}\``).join(', ')}` : '',
|
|
3177
4364
|
lessons_learned !== undefined ? `🧠 **Lessons stored:** ${lessons_learned}` : '',
|
|
4365
|
+
autoLearned.length > 0 ? `🤖 **Auto-learned:** ${autoLearned.length} lessons extracted from summary (${autoLearned.slice(0, 3).map(t => `\`${t}\``).join(', ')}${autoLearned.length > 3 ? '…' : ''})` : '',
|
|
4366
|
+
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 ? '…' : ''})` : '',
|
|
3178
4367
|
``,
|
|
3179
4368
|
`💡 Next session: \`session_start(focus="...")\` to see this summary.`,
|
|
3180
4369
|
].filter(l => l !== '').join('\n');
|
|
3181
4370
|
}
|
|
3182
4371
|
|
|
4372
|
+
// ── session_ping — lightweight checkpoint ─────────────────────────────────
|
|
4373
|
+
case 'session_ping': {
|
|
4374
|
+
const {
|
|
4375
|
+
instance_id,
|
|
4376
|
+
task,
|
|
4377
|
+
files_touched = [],
|
|
4378
|
+
next_step = '',
|
|
4379
|
+
provider = '',
|
|
4380
|
+
} = args as {
|
|
4381
|
+
instance_id: string;
|
|
4382
|
+
task: string;
|
|
4383
|
+
files_touched?: string[];
|
|
4384
|
+
next_step?: string;
|
|
4385
|
+
provider?: string;
|
|
4386
|
+
};
|
|
4387
|
+
|
|
4388
|
+
const redis = await getConnection(instance_id);
|
|
4389
|
+
const checkpoint = {
|
|
4390
|
+
ts: new Date().toISOString(),
|
|
4391
|
+
task,
|
|
4392
|
+
files_touched,
|
|
4393
|
+
next_step,
|
|
4394
|
+
provider,
|
|
4395
|
+
};
|
|
4396
|
+
|
|
4397
|
+
// Store as the latest checkpoint — session_start reads this when no session_end found
|
|
4398
|
+
await redis.set('cachly:session:checkpoint', JSON.stringify(checkpoint), 'EX', 86400 * 3); // 3-day TTL
|
|
4399
|
+
|
|
4400
|
+
// Also keep a short rolling log (last 20 checkpoints for history)
|
|
4401
|
+
await redis.lpush('cachly:session:checkpoint:log', JSON.stringify(checkpoint));
|
|
4402
|
+
await redis.ltrim('cachly:session:checkpoint:log', 0, 19);
|
|
4403
|
+
|
|
4404
|
+
const providerStr = provider ? ` [${provider}]` : '';
|
|
4405
|
+
const filesStr = files_touched.length > 0 ? ` · ${files_touched.length} file${files_touched.length > 1 ? 's' : ''} touched` : '';
|
|
4406
|
+
const nextStr = next_step ? `\n📍 **Next step:** ${next_step}` : '';
|
|
4407
|
+
|
|
4408
|
+
return [
|
|
4409
|
+
`📌 **Checkpoint saved**${providerStr} — ${new Date().toLocaleTimeString()}`,
|
|
4410
|
+
`🔨 **Working on:** ${task}${filesStr}`,
|
|
4411
|
+
nextStr,
|
|
4412
|
+
``,
|
|
4413
|
+
`💡 If you switch providers, \`session_start\` will show this checkpoint automatically.`,
|
|
4414
|
+
].filter(l => l !== '').join('\n');
|
|
4415
|
+
}
|
|
4416
|
+
|
|
4417
|
+
// ── session_handoff — cross-window continuity ─────────────────────────────
|
|
4418
|
+
case 'session_handoff': {
|
|
4419
|
+
const {
|
|
4420
|
+
instance_id,
|
|
4421
|
+
completed_tasks = [],
|
|
4422
|
+
remaining_tasks = [],
|
|
4423
|
+
files_changed = [],
|
|
4424
|
+
instructions = '',
|
|
4425
|
+
context_summary = '',
|
|
4426
|
+
blocked_on = '',
|
|
4427
|
+
} = args as {
|
|
4428
|
+
instance_id: string;
|
|
4429
|
+
completed_tasks: string[];
|
|
4430
|
+
remaining_tasks: string[];
|
|
4431
|
+
files_changed?: { path: string; status: string; description?: string }[];
|
|
4432
|
+
instructions?: string;
|
|
4433
|
+
context_summary?: string;
|
|
4434
|
+
blocked_on?: string;
|
|
4435
|
+
};
|
|
4436
|
+
|
|
4437
|
+
const redis = await getConnection(instance_id);
|
|
4438
|
+
const now = new Date();
|
|
4439
|
+
|
|
4440
|
+
const handoff = {
|
|
4441
|
+
ts: now.toISOString(),
|
|
4442
|
+
completed_tasks,
|
|
4443
|
+
remaining_tasks,
|
|
4444
|
+
files_changed,
|
|
4445
|
+
instructions,
|
|
4446
|
+
context_summary,
|
|
4447
|
+
blocked_on,
|
|
4448
|
+
};
|
|
4449
|
+
|
|
4450
|
+
// Store handoff — never expires until next handoff overwrites it
|
|
4451
|
+
await redis.set('cachly:session:handoff', JSON.stringify(handoff));
|
|
4452
|
+
|
|
4453
|
+
// Also append to history
|
|
4454
|
+
await redis.lpush('cachly:session:handoff:history', JSON.stringify(handoff));
|
|
4455
|
+
await redis.ltrim('cachly:session:handoff:history', 0, 19);
|
|
4456
|
+
|
|
4457
|
+
const totalTasks = completed_tasks.length + remaining_tasks.length;
|
|
4458
|
+
const pct = totalTasks > 0 ? Math.round((completed_tasks.length / totalTasks) * 100) : 0;
|
|
4459
|
+
const brokenFiles = files_changed.filter(f => f.status === 'broken' || f.status === 'partial');
|
|
4460
|
+
|
|
4461
|
+
return [
|
|
4462
|
+
`🤝 **Handoff saved** — ${completed_tasks.length}/${totalTasks} tasks done (${pct}%)`,
|
|
4463
|
+
``,
|
|
4464
|
+
completed_tasks.length > 0 ? `✅ **Completed:**\n${completed_tasks.map(t => ` - ${t}`).join('\n')}` : '',
|
|
4465
|
+
remaining_tasks.length > 0 ? `\n⏳ **Remaining for next window:**\n${remaining_tasks.map(t => ` - ${t}`).join('\n')}` : '',
|
|
4466
|
+
brokenFiles.length > 0 ? `\n⚠️ **Needs attention:** ${brokenFiles.map(f => `\`${f.path}\` (${f.status})`).join(', ')}` : '',
|
|
4467
|
+
blocked_on ? `\n🚫 **Blocked on:** ${blocked_on}` : '',
|
|
4468
|
+
instructions ? `\n📝 **Instructions:** ${instructions}` : '',
|
|
4469
|
+
``,
|
|
4470
|
+
`💡 The next \`session_start\` will include this handoff automatically.`,
|
|
4471
|
+
].filter(l => l !== '').join('\n');
|
|
4472
|
+
}
|
|
4473
|
+
|
|
3183
4474
|
// ── auto_learn_session ────────────────────────────────────────────────────
|
|
3184
4475
|
case 'auto_learn_session': {
|
|
3185
4476
|
const { instance_id, observations } = args as {
|
|
@@ -3205,8 +4496,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3205
4496
|
// Only overwrite if this is a success and existing is failure, or topic is new
|
|
3206
4497
|
const existing = await redis.get(key);
|
|
3207
4498
|
if (existing) {
|
|
3208
|
-
|
|
3209
|
-
try { existingLesson = JSON.parse(existing); } catch { existingLesson = { outcome: 'failure' }; }
|
|
4499
|
+
const existingLesson = JSON.parse(existing) as { outcome: string };
|
|
3210
4500
|
if (existingLesson.outcome === 'success' && obs.outcome !== 'success') {
|
|
3211
4501
|
skipped.push(topic);
|
|
3212
4502
|
continue;
|
|
@@ -3271,23 +4561,20 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3271
4561
|
|
|
3272
4562
|
type Lesson = { topic: string; what_worked: string; outcome: string; file_paths?: string[] };
|
|
3273
4563
|
const relevant: string[] = [];
|
|
3274
|
-
|
|
3275
|
-
const
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
const
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
const emoji = lesson.outcome === 'success' ? '✅' : '⚠️';
|
|
3289
|
-
relevant.push(` ${emoji} \`${lesson.topic}\` — ${lesson.what_worked.slice(0, 80)}`);
|
|
3290
|
-
}
|
|
4564
|
+
for (const k of lessonKeys) {
|
|
4565
|
+
const raw = await redis.get(k);
|
|
4566
|
+
if (!raw) continue;
|
|
4567
|
+
const lesson = JSON.parse(raw) as Lesson;
|
|
4568
|
+
// Match by file_paths stored in lesson OR by topic keywords matching file name
|
|
4569
|
+
const topicWords = lesson.topic.toLowerCase().split(/[:\-_]/);
|
|
4570
|
+
const fileMatches = changed_files.some(f => {
|
|
4571
|
+
const fname = f.split('/').pop()?.replace(/\.[^.]+$/, '').toLowerCase() ?? '';
|
|
4572
|
+
return topicWords.some(w => w.length > 3 && fname.includes(w))
|
|
4573
|
+
|| (lesson.file_paths ?? []).some(lf => f.includes(lf) || lf.includes(f));
|
|
4574
|
+
});
|
|
4575
|
+
if (fileMatches) {
|
|
4576
|
+
const emoji = lesson.outcome === 'success' ? '✅' : '⚠️';
|
|
4577
|
+
relevant.push(` ${emoji} \`${lesson.topic}\` — ${lesson.what_worked.slice(0, 80)}`);
|
|
3291
4578
|
}
|
|
3292
4579
|
}
|
|
3293
4580
|
|
|
@@ -3328,7 +4615,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3328
4615
|
file_paths: file_paths ?? [],
|
|
3329
4616
|
commands: commands ?? [],
|
|
3330
4617
|
tags: [...(tags ?? []), 'team'],
|
|
3331
|
-
|
|
4618
|
+
timestamp: new Date().toISOString(),
|
|
3332
4619
|
recall_count: 0,
|
|
3333
4620
|
version: 2,
|
|
3334
4621
|
};
|
|
@@ -3367,13 +4654,11 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3367
4654
|
author?: string; tags?: string[];
|
|
3368
4655
|
};
|
|
3369
4656
|
let lessons: TeamLesson[] = [];
|
|
3370
|
-
|
|
3371
|
-
const
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
}
|
|
3376
|
-
}
|
|
4657
|
+
for (const k of lessonKeys) {
|
|
4658
|
+
const raw = await redis.get(k);
|
|
4659
|
+
if (!raw) continue;
|
|
4660
|
+
try { lessons.push(JSON.parse(raw) as TeamLesson); } catch { /* skip */ }
|
|
4661
|
+
}
|
|
3377
4662
|
|
|
3378
4663
|
// Filter
|
|
3379
4664
|
if (topic) {
|
|
@@ -3413,306 +4698,153 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3413
4698
|
return lines.join('\n');
|
|
3414
4699
|
}
|
|
3415
4700
|
|
|
3416
|
-
// ──
|
|
3417
|
-
case '
|
|
3418
|
-
const { instance_id } = args as { instance_id: string };
|
|
4701
|
+
// ── team_synthesize — Team Brain Synthesis ────────────────────────────────
|
|
4702
|
+
case 'team_synthesize': {
|
|
4703
|
+
const { instance_id, topic } = args as { instance_id: string; topic: string };
|
|
3419
4704
|
const redis = await getConnection(instance_id);
|
|
3420
4705
|
|
|
3421
|
-
//
|
|
3422
|
-
const
|
|
3423
|
-
const
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
lStream.on('end', resolve);
|
|
3427
|
-
lStream.on('error', reject);
|
|
3428
|
-
});
|
|
3429
|
-
|
|
3430
|
-
type Lesson = { topic: string; outcome: string; recall_count?: number; ts: string; severity?: string; what_worked: string; author?: string };
|
|
3431
|
-
const lessons: Lesson[] = [];
|
|
3432
|
-
if (lessonKeys.length > 0) {
|
|
3433
|
-
const vals = await redis.mget(...lessonKeys);
|
|
3434
|
-
for (const raw of vals) {
|
|
3435
|
-
if (!raw) continue;
|
|
3436
|
-
try { lessons.push(JSON.parse(raw) as Lesson); } catch { /* skip */ }
|
|
3437
|
-
}
|
|
4706
|
+
// Load history list for this topic (all authors' contributions)
|
|
4707
|
+
const listKey = `cachly:lessons:${topic}`;
|
|
4708
|
+
const all = await redis.lrange(listKey, 0, -1);
|
|
4709
|
+
if (all.length < 2) {
|
|
4710
|
+
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}", ...)\`.`;
|
|
3438
4711
|
}
|
|
3439
4712
|
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
const ctxStream = redis.scanStream({ match: 'cachly:ctx:*', count: 200 });
|
|
3443
|
-
await new Promise<void>((resolve, reject) => {
|
|
3444
|
-
ctxStream.on('data', (batch: string[]) => { ctxCount += batch.filter((k: string) => !k.endsWith(':meta')).length; });
|
|
3445
|
-
ctxStream.on('end', resolve);
|
|
3446
|
-
ctxStream.on('error', reject);
|
|
3447
|
-
});
|
|
4713
|
+
type Entry = { outcome: string; what_worked: string; what_failed?: string; author?: string; ts: string; severity?: string };
|
|
4714
|
+
const entries: Entry[] = all.map(r => { try { return JSON.parse(r) as Entry; } catch { return null; } }).filter((e): e is Entry => e !== null);
|
|
3448
4715
|
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
const totalRecalls = lessons.reduce((s, l) => s + (l.recall_count ?? 0), 0);
|
|
3454
|
-
const savedMin = totalRecalls * 15;
|
|
3455
|
-
const savedHours = (savedMin / 60).toFixed(1);
|
|
3456
|
-
const successCount = lessons.filter(l => l.outcome === 'success').length;
|
|
3457
|
-
const failureCount = lessons.filter(l => l.outcome === 'failure' || l.outcome === 'partial').length;
|
|
3458
|
-
const criticalCount = lessons.filter(l => l.severity === 'critical').length;
|
|
3459
|
-
const majorCount = lessons.filter(l => l.severity === 'major').length;
|
|
3460
|
-
const authors = [...new Set(lessons.map(l => l.author).filter(Boolean))];
|
|
3461
|
-
const topByRecall = [...lessons].sort((a, b) => (b.recall_count ?? 0) - (a.recall_count ?? 0)).slice(0, 5);
|
|
3462
|
-
const newestLesson = [...lessons].sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime())[0];
|
|
3463
|
-
|
|
3464
|
-
// Age of last session
|
|
3465
|
-
let lastSessionStr = 'none yet';
|
|
3466
|
-
if (lastSession) {
|
|
3467
|
-
const ageMin = Math.round((Date.now() - new Date(lastSession.ts).getTime()) / 60000);
|
|
3468
|
-
lastSessionStr = ageMin < 60 ? `${ageMin}m ago`
|
|
3469
|
-
: ageMin < 1440 ? `${Math.round(ageMin / 60)}h ago`
|
|
3470
|
-
: `${Math.round(ageMin / 1440)}d ago`;
|
|
3471
|
-
}
|
|
4716
|
+
// Group by outcome
|
|
4717
|
+
const successes = entries.filter(e => e.outcome === 'success');
|
|
4718
|
+
const failures = entries.filter(e => e.outcome === 'failure');
|
|
4719
|
+
const partials = entries.filter(e => e.outcome === 'partial');
|
|
3472
4720
|
|
|
3473
|
-
const
|
|
3474
|
-
const
|
|
3475
|
-
const costSaved = (tokensSaved * 0.000003).toFixed(2);
|
|
4721
|
+
const authors = [...new Set(entries.map(e => e.author).filter(Boolean))];
|
|
4722
|
+
const hasMultiAuthor = authors.length > 1;
|
|
3476
4723
|
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
`║ 🔄 Total recalls: ${String(totalRecalls).padEnd(18)}║`,
|
|
3483
|
-
`║ 🕐 Time saved: ~${savedHours}h${' '.repeat(Math.max(0, 16 - savedHours.length))}║`,
|
|
3484
|
-
`║ 💎 Tokens saved: ${tokenStr.padEnd(18)}║`,
|
|
3485
|
-
`║ 💵 Cost saved: ~$${costSaved.padEnd(17)}║`,
|
|
3486
|
-
`║ 📁 Context keys: ${String(ctxCount).padEnd(18)}║`,
|
|
3487
|
-
`╚══════════════════════════════════════╝`,
|
|
3488
|
-
``,
|
|
3489
|
-
`**Lessons breakdown:**`,
|
|
3490
|
-
` ✅ Success: ${successCount} ❌ Failures/partial: ${failureCount}`,
|
|
3491
|
-
` 🔴 Critical: ${criticalCount} 🟡 Major: ${majorCount}`,
|
|
3492
|
-
``,
|
|
3493
|
-
`**Last session:** ${lastSessionStr}${lastSession?.summary ? ` — ${lastSession.summary.slice(0, 80)}` : ''}`,
|
|
3494
|
-
`**Newest lesson:** ${newestLesson ? `\`${newestLesson.topic}\`` : 'none'}`,
|
|
3495
|
-
``,
|
|
3496
|
-
];
|
|
4724
|
+
// Build canonical merged version
|
|
4725
|
+
// what_worked: pick the most recent success, or longest for most detail
|
|
4726
|
+
const bestSuccess = successes.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime())[0];
|
|
4727
|
+
const whatWorkedCandidates = successes.map(e => e.what_worked).filter(w => w && w.length > 10);
|
|
4728
|
+
const canonicalWorked = whatWorkedCandidates.sort((a, b) => b.length - a.length)[0] ?? bestSuccess?.what_worked ?? '';
|
|
3497
4729
|
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
lines.push(` ${emoji} \`${l.topic}\` — recalled ${l.recall_count ?? 0}× · ${l.what_worked.slice(0, 60)}`);
|
|
3503
|
-
}
|
|
3504
|
-
lines.push('');
|
|
3505
|
-
}
|
|
3506
|
-
|
|
3507
|
-
if (authors.length > 0) {
|
|
3508
|
-
lines.push(`**Team contributors (${authors.length}):** ${(authors as string[]).join(', ')}`);
|
|
3509
|
-
lines.push('');
|
|
3510
|
-
}
|
|
4730
|
+
// what_failed: union of all unique failure reasons
|
|
4731
|
+
const allFailed = [...new Set(
|
|
4732
|
+
[...failures, ...partials].map(e => e.what_failed).filter((w): w is string => !!w && w.length > 5)
|
|
4733
|
+
)];
|
|
3511
4734
|
|
|
3512
|
-
|
|
3513
|
-
|
|
4735
|
+
const severities = entries.map(e => e.severity).filter(Boolean);
|
|
4736
|
+
const canonicalSeverity = severities.includes('critical') ? 'critical' : severities.includes('major') ? 'major' : 'minor';
|
|
3514
4737
|
|
|
3515
|
-
|
|
4738
|
+
const lines = [
|
|
4739
|
+
`🧬 **Team Brain Synthesis: \`${topic}\`**`,
|
|
4740
|
+
`_${entries.length} entries from ${authors.length} author${authors.length === 1 ? '' : 's'}${hasMultiAuthor ? ` (${authors.join(', ')})` : ''} · ${successes.length} success · ${failures.length} failure · ${partials.length} partial_`,
|
|
4741
|
+
'',
|
|
4742
|
+
`**Canonical "what worked":**`,
|
|
4743
|
+
`> ${canonicalWorked}`,
|
|
4744
|
+
'',
|
|
4745
|
+
allFailed.length > 0 ? `**Avoid (combined failures):**` : '',
|
|
4746
|
+
...allFailed.map(f => `> ❌ ${f}`),
|
|
4747
|
+
allFailed.length > 0 ? '' : '',
|
|
4748
|
+
`**Suggested canonical lesson:**`,
|
|
4749
|
+
'```',
|
|
4750
|
+
`learn_from_attempts(`,
|
|
4751
|
+
` topic = "${topic}",`,
|
|
4752
|
+
` outcome = "success",`,
|
|
4753
|
+
` what_worked = "${canonicalWorked.replace(/"/g, "'")}",`,
|
|
4754
|
+
allFailed.length > 0 ? ` what_failed = "${allFailed[0].replace(/"/g, "'")}",` : '',
|
|
4755
|
+
` severity = "${canonicalSeverity}",`,
|
|
4756
|
+
`)`,
|
|
4757
|
+
'```',
|
|
4758
|
+
'',
|
|
4759
|
+
hasMultiAuthor
|
|
4760
|
+
? `💡 _${authors.length} team members contributed to this synthesis. Store the canonical version to replace individual entries._`
|
|
4761
|
+
: `💡 _Single author — more value when multiple team members contribute to the same topic._`,
|
|
4762
|
+
].filter(l => l !== undefined).join('\n');
|
|
4763
|
+
return lines;
|
|
3516
4764
|
}
|
|
3517
4765
|
|
|
3518
|
-
// ──
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
include_context?: boolean;
|
|
3523
|
-
max_lessons?: number;
|
|
3524
|
-
};
|
|
4766
|
+
// ── brain_doctor ──────────────────────────────────────────────────────────
|
|
4767
|
+
// ── memory_crystalize ─────────────────────────────────────────────────────
|
|
4768
|
+
case 'memory_crystalize': {
|
|
4769
|
+
const { instance_id, label: crystalLabel = '' } = args as { instance_id: string; label?: string };
|
|
3525
4770
|
const redis = await getConnection(instance_id);
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
const
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
4771
|
+
const now = new Date();
|
|
4772
|
+
const week = `${now.getFullYear()}-W${String(Math.ceil((now.getDate() - now.getDay() + 10) / 7)).padStart(2, '0')}`;
|
|
4773
|
+
const effectiveLabel = crystalLabel || `${now.toISOString().slice(0, 7)} Crystal`;
|
|
4774
|
+
|
|
4775
|
+
// Read session history
|
|
4776
|
+
const sessionHistory = await redis.lrange('cachly:session:history', 0, 49);
|
|
4777
|
+
|
|
4778
|
+
// Read all auto-learned lessons
|
|
4779
|
+
const allLessonKeys: string[] = [];
|
|
4780
|
+
const ls = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
|
|
4781
|
+
await new Promise<void>((res, rej) => {
|
|
4782
|
+
ls.on('data', (b: string[]) => allLessonKeys.push(...b));
|
|
4783
|
+
ls.on('end', res);
|
|
4784
|
+
ls.on('error', rej);
|
|
3534
4785
|
});
|
|
3535
4786
|
|
|
3536
|
-
type
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
if (lessonKeys.length > 0) {
|
|
3543
|
-
const vals = await redis.mget(...lessonKeys);
|
|
3544
|
-
for (const raw of vals) {
|
|
3545
|
-
if (!raw) continue;
|
|
3546
|
-
try { lessons.push(JSON.parse(raw) as FullLesson); } catch { /* skip */ }
|
|
3547
|
-
}
|
|
3548
|
-
}
|
|
3549
|
-
lessons.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
|
|
3550
|
-
if (max_lessons && max_lessons > 0) lessons = lessons.slice(0, max_lessons);
|
|
3551
|
-
|
|
3552
|
-
// Last session + crystal
|
|
3553
|
-
const lastSessionRaw = await redis.get('cachly:session:last');
|
|
3554
|
-
let lastSession: { ts: string; summary: string } | null = null;
|
|
3555
|
-
if (lastSessionRaw) { try { lastSession = JSON.parse(lastSessionRaw); } catch { /* ignore */ } }
|
|
3556
|
-
|
|
3557
|
-
const crystalRaw = await redis.get('cachly:crystal');
|
|
3558
|
-
let crystal: { summary: string; created_at: string } | null = null;
|
|
3559
|
-
if (crystalRaw) { try { crystal = JSON.parse(crystalRaw); } catch { /* ignore */ } }
|
|
3560
|
-
|
|
3561
|
-
// Context entries
|
|
3562
|
-
type CtxEntry = { key: string; value: string };
|
|
3563
|
-
const ctxEntries: CtxEntry[] = [];
|
|
3564
|
-
if (include_context) {
|
|
3565
|
-
const ctxKeys: string[] = [];
|
|
3566
|
-
const ctxStream = redis.scanStream({ match: 'cachly:ctx:*', count: 200 });
|
|
3567
|
-
await new Promise<void>((resolve, reject) => {
|
|
3568
|
-
ctxStream.on('data', (batch: string[]) => {
|
|
3569
|
-
ctxKeys.push(...batch.filter((k: string) => !k.endsWith(':meta')));
|
|
3570
|
-
});
|
|
3571
|
-
ctxStream.on('end', resolve);
|
|
3572
|
-
ctxStream.on('error', reject);
|
|
3573
|
-
});
|
|
3574
|
-
if (ctxKeys.length > 0) {
|
|
3575
|
-
const ctxVals = await redis.mget(...ctxKeys);
|
|
3576
|
-
for (let i = 0; i < ctxKeys.length; i++) {
|
|
3577
|
-
const val = ctxVals[i];
|
|
3578
|
-
if (val) ctxEntries.push({ key: ctxKeys[i].replace('cachly:ctx:', ''), value: val });
|
|
3579
|
-
}
|
|
3580
|
-
}
|
|
4787
|
+
type RawLesson = { topic: string; outcome: string; what_worked: string; severity?: string; ts: string; auto_learned?: boolean };
|
|
4788
|
+
const allLessons: RawLesson[] = [];
|
|
4789
|
+
for (const k of allLessonKeys) {
|
|
4790
|
+
const raw = await redis.get(k);
|
|
4791
|
+
if (!raw) continue;
|
|
4792
|
+
try { allLessons.push(JSON.parse(raw) as RawLesson); } catch { /* skip */ }
|
|
3581
4793
|
}
|
|
3582
4794
|
|
|
3583
|
-
|
|
3584
|
-
const
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
``,
|
|
3590
|
-
`> Exported: ${exportedAt} `,
|
|
3591
|
-
`> Instance: \`${instance_id}\` `,
|
|
3592
|
-
`> Lessons: ${lessons.length} · Recalls: ${totalRecalls} · Time saved: ~${savedHours}h`,
|
|
3593
|
-
``,
|
|
3594
|
-
];
|
|
3595
|
-
|
|
3596
|
-
if (crystal) {
|
|
3597
|
-
md.push(`## 💎 Memory Crystal`, ``, `> ${crystal.summary}`, ``, `_Generated: ${crystal.created_at}_`, ``);
|
|
4795
|
+
// Group lessons by top-level category
|
|
4796
|
+
const categoryMap = new Map<string, RawLesson[]>();
|
|
4797
|
+
for (const l of allLessons) {
|
|
4798
|
+
const cat = l.topic.split(':')[0] || 'misc';
|
|
4799
|
+
if (!categoryMap.has(cat)) categoryMap.set(cat, []);
|
|
4800
|
+
categoryMap.get(cat)!.push(l);
|
|
3598
4801
|
}
|
|
3599
4802
|
|
|
3600
|
-
|
|
3601
|
-
|
|
4803
|
+
// Build top patterns (most frequent categories with a representative insight)
|
|
4804
|
+
const topPatterns: Array<{ category: string; insight: string; count: number }> = [];
|
|
4805
|
+
for (const [cat, catLessons] of [...categoryMap.entries()].sort((a, b) => b[1].length - a[1].length).slice(0, 8)) {
|
|
4806
|
+
const successLessons = catLessons.filter(l => l.outcome === 'success');
|
|
4807
|
+
const best = successLessons[0] ?? catLessons[0];
|
|
4808
|
+
if (!best) continue;
|
|
4809
|
+
topPatterns.push({
|
|
4810
|
+
category: cat,
|
|
4811
|
+
insight: best.what_worked.slice(0, 120),
|
|
4812
|
+
count: catLessons.length,
|
|
4813
|
+
});
|
|
3602
4814
|
}
|
|
3603
4815
|
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
``,
|
|
3613
|
-
`**Outcome:** ${l.outcome} `,
|
|
3614
|
-
`**Recalls:** ${l.recall_count ?? 0}× `,
|
|
3615
|
-
`**Stored:** ${l.ts.slice(0, 10)}${l.author ? ` \n**Author:** ${l.author}` : ''}`,
|
|
3616
|
-
``,
|
|
3617
|
-
`**What worked:** `,
|
|
3618
|
-
l.what_worked,
|
|
3619
|
-
];
|
|
3620
|
-
if (l.what_failed) { lines.push(``, `**What failed:** `, l.what_failed); }
|
|
3621
|
-
if ((l.commands ?? []).length > 0) {
|
|
3622
|
-
lines.push(``, `**Commands:**`);
|
|
3623
|
-
for (const cmd of l.commands!) lines.push(`\`\`\`\n${cmd}\n\`\`\``);
|
|
3624
|
-
}
|
|
3625
|
-
if ((l.file_paths ?? []).length > 0) {
|
|
3626
|
-
lines.push(``, `**Files:** ${l.file_paths!.map(f => `\`${f}\``).join(', ')}`);
|
|
3627
|
-
}
|
|
3628
|
-
if ((l.tags ?? []).length > 0) {
|
|
3629
|
-
lines.push(``, `**Tags:** ${l.tags!.map(t => `\`${t}\``).join(', ')}`);
|
|
3630
|
-
}
|
|
3631
|
-
lines.push(``);
|
|
3632
|
-
return lines.join('\n');
|
|
4816
|
+
const crystal = {
|
|
4817
|
+
label: effectiveLabel,
|
|
4818
|
+
ts: now.toISOString(),
|
|
4819
|
+
session_count: sessionHistory.length,
|
|
4820
|
+
lesson_count: allLessons.length,
|
|
4821
|
+
top_patterns: topPatterns,
|
|
4822
|
+
categories: [...categoryMap.keys()],
|
|
4823
|
+
created_from: `${sessionHistory.length} sessions, ${allLessons.length} lessons`,
|
|
3633
4824
|
};
|
|
3634
4825
|
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
}
|
|
3639
|
-
|
|
3640
|
-
if (failureLessons.length > 0) {
|
|
3641
|
-
md.push(`## ⚠️ Open / Partial Issues (${failureLessons.length})`, ``);
|
|
3642
|
-
for (const l of failureLessons) md.push(formatLesson(l));
|
|
3643
|
-
}
|
|
3644
|
-
|
|
3645
|
-
if (ctxEntries.length > 0) {
|
|
3646
|
-
md.push(`## 📁 Context Entries (${ctxEntries.length})`, ``);
|
|
3647
|
-
for (const e of ctxEntries) {
|
|
3648
|
-
md.push(`### \`${e.key}\``, ``, e.value.slice(0, 500) + (e.value.length > 500 ? '\n\n_[truncated]_' : ''), ``);
|
|
3649
|
-
}
|
|
3650
|
-
}
|
|
3651
|
-
|
|
3652
|
-
md.push(
|
|
3653
|
-
`---`,
|
|
3654
|
-
``,
|
|
3655
|
-
`_Exported from [cachly AI Brain](https://cachly.dev) · Re-import with \`npx @cachly-dev/mcp-server@latest setup\`_`,
|
|
3656
|
-
);
|
|
3657
|
-
|
|
3658
|
-
return md.join('\n');
|
|
3659
|
-
}
|
|
3660
|
-
|
|
3661
|
-
// ── invite_link ───────────────────────────────────────────────────────────
|
|
3662
|
-
case 'invite_link': {
|
|
3663
|
-
const { instance_id, label = '' } = args as { instance_id: string; label?: string };
|
|
3664
|
-
|
|
3665
|
-
// Generate a share token via API
|
|
3666
|
-
let shareToken = '';
|
|
3667
|
-
let inviteUrl = '';
|
|
3668
|
-
try {
|
|
3669
|
-
const res = await apiFetch<{ token?: string; url?: string }>(
|
|
3670
|
-
`/api/v1/instances/${instance_id}/invite`,
|
|
3671
|
-
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label }) }
|
|
3672
|
-
);
|
|
3673
|
-
shareToken = res.token ?? '';
|
|
3674
|
-
inviteUrl = res.url ?? '';
|
|
3675
|
-
} catch {
|
|
3676
|
-
// API doesn't support invite yet — generate a local share snippet instead
|
|
3677
|
-
}
|
|
3678
|
-
|
|
3679
|
-
// Build the join command
|
|
3680
|
-
const joinCmd = shareToken
|
|
3681
|
-
? `npx @cachly-dev/mcp-server@latest join ${shareToken}`
|
|
3682
|
-
: `npx @cachly-dev/mcp-server@latest setup`;
|
|
4826
|
+
const crystalJson = JSON.stringify(crystal);
|
|
4827
|
+
await redis.set('cachly:crystal:latest', crystalJson);
|
|
4828
|
+
await redis.expire('cachly:crystal:latest', 90 * 86400);
|
|
4829
|
+
await redis.set(`cachly:crystal:${week}`, crystalJson);
|
|
4830
|
+
await redis.expire(`cachly:crystal:${week}`, 365 * 86400);
|
|
3683
4831
|
|
|
3684
4832
|
const lines = [
|
|
3685
|
-
|
|
4833
|
+
`💎 **Memory Crystal created: ${effectiveLabel}**`,
|
|
3686
4834
|
``,
|
|
3687
|
-
|
|
4835
|
+
`📊 Compressed: **${sessionHistory.length} sessions** + **${allLessons.length} lessons** → ${topPatterns.length} top patterns`,
|
|
3688
4836
|
``,
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
`\`\`\``,
|
|
3692
|
-
``,
|
|
3693
|
-
`**What happens when they run it:**`,
|
|
3694
|
-
` 1. Browser opens → they sign in (free, 30 seconds)`,
|
|
3695
|
-
` 2. Their Brain is auto-connected to this shared instance`,
|
|
3696
|
-
` 3. All ${label ? `"${label}" ` : ''}lessons and context are instantly available`,
|
|
3697
|
-
` 4. Editor configs written automatically — no copy-paste`,
|
|
4837
|
+
`**Top patterns by category:**`,
|
|
4838
|
+
...topPatterns.slice(0, 6).map(p => ` • **${p.category}** (${p.count}×): ${p.insight.slice(0, 90)}`),
|
|
3698
4839
|
``,
|
|
4840
|
+
`💡 This crystal will appear in every future \`session_start\` briefing.`,
|
|
4841
|
+
`💡 Re-run \`memory_crystalize\` monthly to keep it fresh.`,
|
|
3699
4842
|
];
|
|
3700
|
-
|
|
3701
|
-
if (inviteUrl) {
|
|
3702
|
-
lines.push(`**Or share the link directly:** ${inviteUrl}`, ``);
|
|
3703
|
-
}
|
|
3704
|
-
|
|
3705
|
-
lines.push(
|
|
3706
|
-
`💡 Teammates can also run \`session_start\` immediately to see all your lessons.`,
|
|
3707
|
-
` Use \`team_learn\` with an \`author\` param so everyone knows who learned what.`,
|
|
3708
|
-
);
|
|
3709
|
-
|
|
3710
4843
|
return lines.join('\n');
|
|
3711
4844
|
}
|
|
3712
4845
|
|
|
3713
|
-
// ── brain_doctor ──────────────────────────────────────────────────────────
|
|
3714
4846
|
case 'brain_doctor': {
|
|
3715
|
-
const { instance_id } = args as { instance_id: string };
|
|
4847
|
+
const { instance_id, workspace_path: drWorkspacePath = '' } = args as { instance_id: string; workspace_path?: string };
|
|
3716
4848
|
const redis = await getConnection(instance_id);
|
|
3717
4849
|
const issues: string[] = [];
|
|
3718
4850
|
const checks: string[] = [];
|
|
@@ -3738,12 +4870,15 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3738
4870
|
});
|
|
3739
4871
|
|
|
3740
4872
|
// Load lessons for analysis
|
|
3741
|
-
type
|
|
3742
|
-
|
|
4873
|
+
type DrLesson = {
|
|
4874
|
+
topic: string; outcome: string; recall_count?: number; ts: string;
|
|
4875
|
+
verified_at?: string; severity?: string; audit_trail?: unknown[];
|
|
4876
|
+
};
|
|
4877
|
+
const lessons: DrLesson[] = [];
|
|
3743
4878
|
for (const k of lessonKeys) {
|
|
3744
4879
|
const raw = await redis.get(k);
|
|
3745
4880
|
if (!raw) continue;
|
|
3746
|
-
try { lessons.push(JSON.parse(raw) as
|
|
4881
|
+
try { lessons.push(JSON.parse(raw) as DrLesson); } catch { /* skip */ }
|
|
3747
4882
|
}
|
|
3748
4883
|
|
|
3749
4884
|
// Last session
|
|
@@ -3759,6 +4894,17 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3759
4894
|
const unusedLessons = lessons.filter(l => (l.recall_count ?? 0) === 0);
|
|
3760
4895
|
// Critical lessons
|
|
3761
4896
|
const criticalLessons = lessons.filter(l => l.severity === 'critical');
|
|
4897
|
+
// Confidence decay analysis
|
|
4898
|
+
const staleLessons = lessons.filter(l => l.outcome === 'success' && calculateConfidence(l) < CONFIDENCE_STALE_VALUE);
|
|
4899
|
+
const warnLessons = lessons.filter(l => l.outcome === 'success' && calculateConfidence(l) >= CONFIDENCE_STALE_VALUE && calculateConfidence(l) < CONFIDENCE_WARN_VALUE);
|
|
4900
|
+
const withAudit = lessons.filter(l => (l.audit_trail ?? []).length > 1);
|
|
4901
|
+
// Team lessons
|
|
4902
|
+
type DrLessonWithAuthor = DrLesson & { author?: string };
|
|
4903
|
+
const teamLessons = (lessons as DrLessonWithAuthor[]).filter(l => l.author);
|
|
4904
|
+
const uniqueAuthors = new Set((lessons as DrLessonWithAuthor[]).map(l => l.author).filter(Boolean));
|
|
4905
|
+
// Effective IQ boost: total recalls / lessons (how much the brain actually helped)
|
|
4906
|
+
const totalRecalls = lessons.reduce((sum, l) => sum + (l.recall_count ?? 0), 0);
|
|
4907
|
+
const iqBoostPct = lessons.length > 0 ? Math.min(100, Math.round((totalRecalls / lessons.length) * 10)) : 0;
|
|
3762
4908
|
|
|
3763
4909
|
// Quality score (0-100)
|
|
3764
4910
|
let score = 50;
|
|
@@ -3770,12 +4916,67 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3770
4916
|
if (openFailures.length === 0) score += 5;
|
|
3771
4917
|
const unusedRatio = lessons.length > 0 ? unusedLessons.length / lessons.length : 0;
|
|
3772
4918
|
if (unusedRatio < 0.5) score += 10;
|
|
4919
|
+
if (staleLessons.length === 0) score += 5;
|
|
4920
|
+
if (uniqueAuthors.size >= 2) score += 5; // team collaboration bonus
|
|
3773
4921
|
|
|
3774
4922
|
const scoreEmoji = score >= 80 ? '🟢' : score >= 50 ? '🟡' : '🔴';
|
|
4923
|
+
const iqEmoji = iqBoostPct >= 50 ? '🚀' : iqBoostPct >= 20 ? '📈' : '💤';
|
|
3775
4924
|
|
|
3776
4925
|
checks.push(`${scoreEmoji} **Brain Quality Score: ${score}/100**`);
|
|
3777
|
-
checks.push(
|
|
4926
|
+
checks.push(`${iqEmoji} **Effective IQ Boost: ${iqBoostPct}%** (${totalRecalls} recalls across ${lessons.length} lessons)`);
|
|
4927
|
+
checks.push(`📚 **Lessons:** ${lessonKeys.length} (${criticalLessons.length} critical · ${withAudit.length} with audit trail · ${teamLessons.length} from team)`);
|
|
3778
4928
|
checks.push(`💾 **Context entries:** ${ctxCount}`);
|
|
4929
|
+
checks.push(`🎯 **Confidence:** ${lessons.length - staleLessons.length - warnLessons.length} fresh · ${warnLessons.length} warn · ${staleLessons.length} stale`);
|
|
4930
|
+
checks.push(`⏱️ **Decay config:** warn after ${CONFIDENCE_WARN_DAYS}d · stale after ${CONFIDENCE_STALE_DAYS}d`);
|
|
4931
|
+
if (uniqueAuthors.size >= 2) {
|
|
4932
|
+
checks.push(`👥 **Team:** ${uniqueAuthors.size} contributors (${[...uniqueAuthors].join(', ')})`);
|
|
4933
|
+
}
|
|
4934
|
+
|
|
4935
|
+
// Stale index detection
|
|
4936
|
+
try {
|
|
4937
|
+
const lastIndexRaw = await redis.get('cachly:index:last_run');
|
|
4938
|
+
if (lastIndexRaw) {
|
|
4939
|
+
const lastIndexAge = Math.round((Date.now() - new Date(lastIndexRaw).getTime()) / 86_400_000);
|
|
4940
|
+
if (lastIndexAge > 7) {
|
|
4941
|
+
issues.push(`🔄 Index is ${lastIndexAge}d stale — run \`index_project\` to re-sync semantic search`);
|
|
4942
|
+
} else {
|
|
4943
|
+
checks.push(`🗂️ **Semantic index:** ${lastIndexAge}d old (fresh)`);
|
|
4944
|
+
}
|
|
4945
|
+
} else {
|
|
4946
|
+
issues.push(`💡 No semantic index — run \`index_project(dir="<your-src>")\` to enable semantic search`);
|
|
4947
|
+
}
|
|
4948
|
+
} catch { /* non-critical */ }
|
|
4949
|
+
|
|
4950
|
+
// Memory crystal status
|
|
4951
|
+
try {
|
|
4952
|
+
const crystalRaw = await redis.get('cachly:crystal:latest');
|
|
4953
|
+
if (crystalRaw) {
|
|
4954
|
+
const crystal = JSON.parse(crystalRaw) as { ts: string; label: string };
|
|
4955
|
+
const crystalAge = Math.round((Date.now() - new Date(crystal.ts).getTime()) / 86_400_000);
|
|
4956
|
+
checks.push(`💎 **Memory Crystal:** ${crystal.label} (${crystalAge}d ago)`);
|
|
4957
|
+
if (crystalAge > 30) issues.push(`💡 Memory Crystal is ${crystalAge}d old — re-run \`memory_crystalize\` to compress new sessions`);
|
|
4958
|
+
} else if (lessonKeys.length >= 10) {
|
|
4959
|
+
issues.push(`💡 ${lessonKeys.length} lessons but no Memory Crystal — run \`memory_crystalize\` to compress wisdom`);
|
|
4960
|
+
}
|
|
4961
|
+
} catch { /* non-critical */ }
|
|
4962
|
+
|
|
4963
|
+
// openclaw cross-promo (check package.json in workspace)
|
|
4964
|
+
if (drWorkspacePath) {
|
|
4965
|
+
try {
|
|
4966
|
+
const pkgPath = drWorkspacePath.replace(/\/$/, '') + '/package.json';
|
|
4967
|
+
const pkgRaw = readFileSync(pkgPath, 'utf-8');
|
|
4968
|
+
const pkg = JSON.parse(pkgRaw) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
|
|
4969
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
4970
|
+
const hasLLMDep = ['openai', '@anthropic-ai/sdk', '@google/generative-ai', 'mistralai', 'cohere-ai'].some(d => d in allDeps);
|
|
4971
|
+
const hasOpenclaw = '@cachly-dev/openclaw' in allDeps;
|
|
4972
|
+
if (hasLLMDep && !hasOpenclaw) {
|
|
4973
|
+
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\``);
|
|
4974
|
+
issues.push(` → \`npm install @cachly-dev/openclaw\` cuts LLM costs 60–90% with 3 lines of code`);
|
|
4975
|
+
} else if (hasOpenclaw) {
|
|
4976
|
+
checks.push(`✅ **@cachly-dev/openclaw installed** (LLM cost caching active)`);
|
|
4977
|
+
}
|
|
4978
|
+
} catch { /* no package.json or unreadable */ }
|
|
4979
|
+
}
|
|
3779
4980
|
|
|
3780
4981
|
if (lastSession) {
|
|
3781
4982
|
const ageMin = Math.round((Date.now() - new Date(lastSession.ts).getTime()) / 60000);
|
|
@@ -3791,6 +4992,10 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3791
4992
|
issues.push(`💡 Only ${lessonKeys.length} lessons — add more after each problem solved`);
|
|
3792
4993
|
}
|
|
3793
4994
|
|
|
4995
|
+
if (iqBoostPct === 0 && lessons.length >= 5) {
|
|
4996
|
+
issues.push(`💤 **IQ Boost is 0%** — lessons exist but are never recalled. Use \`recall_best_solution\` BEFORE tasks.`);
|
|
4997
|
+
}
|
|
4998
|
+
|
|
3794
4999
|
if (ctxCount === 0) {
|
|
3795
5000
|
issues.push('💡 No context — use `remember_context` to cache architecture docs, ADRs, etc.');
|
|
3796
5001
|
}
|
|
@@ -3799,6 +5004,15 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3799
5004
|
issues.push(`⚠️ ${openFailures.length} unresolved failure${openFailures.length > 1 ? 's' : ''}: ${openFailures.slice(0, 3).map(l => `\`${l.topic}\``).join(', ')}`);
|
|
3800
5005
|
}
|
|
3801
5006
|
|
|
5007
|
+
if (staleLessons.length > 0) {
|
|
5008
|
+
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(', ')}`);
|
|
5009
|
+
issues.push(` → Re-verify with \`recall_best_solution\` to reset confidence clock`);
|
|
5010
|
+
}
|
|
5011
|
+
|
|
5012
|
+
if (warnLessons.length > 0) {
|
|
5013
|
+
issues.push(`⚠️ ${warnLessons.length} lesson${warnLessons.length > 1 ? 's' : ''} aging (>${CONFIDENCE_WARN_DAYS}d): ${warnLessons.slice(0, 3).map(l => `\`${l.topic}\``).join(', ')}`);
|
|
5014
|
+
}
|
|
5015
|
+
|
|
3802
5016
|
if (unusedRatio > 0.7 && lessons.length > 5) {
|
|
3803
5017
|
issues.push(`💡 ${unusedLessons.length} lessons never recalled — verify topics match your workflow`);
|
|
3804
5018
|
}
|
|
@@ -3814,6 +5028,77 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3814
5028
|
return lines.join('\n');
|
|
3815
5029
|
}
|
|
3816
5030
|
|
|
5031
|
+
// ── recall_at — Brain Archaeology ────────────────────────────────────────
|
|
5032
|
+
case 'recall_at': {
|
|
5033
|
+
const { instance_id, topic, date } = args as { instance_id: string; topic: string; date: string };
|
|
5034
|
+
const redis = await getConnection(instance_id);
|
|
5035
|
+
const cutoff = new Date(date).getTime();
|
|
5036
|
+
if (isNaN(cutoff)) return `❌ Invalid date "${date}". Use ISO format: "2026-01-15"`;
|
|
5037
|
+
|
|
5038
|
+
const listKey = `cachly:lessons:${topic}`;
|
|
5039
|
+
const all = await redis.lrange(listKey, 0, -1);
|
|
5040
|
+
if (all.length === 0) return `📭 No history found for \`${topic}\`. Lessons are stored via \`learn_from_attempts\`.`;
|
|
5041
|
+
|
|
5042
|
+
const before = all
|
|
5043
|
+
.map(raw => { try { return JSON.parse(raw) as { ts: string; outcome?: string; what_worked?: string; what_failed?: string }; } catch { return null; } })
|
|
5044
|
+
.filter((l): l is NonNullable<typeof l> => l !== null && new Date(l.ts).getTime() <= cutoff)
|
|
5045
|
+
.sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
|
|
5046
|
+
|
|
5047
|
+
if (before.length === 0) return `📭 No entries for \`${topic}\` found before **${date}**. Earliest entry: ${new Date(JSON.parse(all[0]).ts).toLocaleDateString('de-DE')}.`;
|
|
5048
|
+
|
|
5049
|
+
const lines = [
|
|
5050
|
+
`🏺 **Brain Archaeology: \`${topic}\` before ${date}**`,
|
|
5051
|
+
`_${before.length} of ${all.length} total entries shown_`,
|
|
5052
|
+
'',
|
|
5053
|
+
];
|
|
5054
|
+
for (const l of before.slice(-10)) {
|
|
5055
|
+
const emoji = l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
|
|
5056
|
+
const d = new Date(l.ts).toLocaleDateString('de-DE');
|
|
5057
|
+
lines.push(`**${d}** ${emoji} ${l.outcome}`);
|
|
5058
|
+
if (l.what_worked) lines.push(` → ${l.what_worked.slice(0, 100)}`);
|
|
5059
|
+
lines.push('');
|
|
5060
|
+
}
|
|
5061
|
+
lines.push(`_Full evolution: ${all.map(r => { try { const l = JSON.parse(r) as { outcome?: string }; return l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌'; } catch { return '?'; } }).join(' → ')}_`);
|
|
5062
|
+
return lines.join('\n');
|
|
5063
|
+
}
|
|
5064
|
+
|
|
5065
|
+
// ── trace_dependency — Causal Chain ──────────────────────────────────────
|
|
5066
|
+
case 'trace_dependency': {
|
|
5067
|
+
const { instance_id, dependency, mark_review = false } = args as { instance_id: string; dependency: string; mark_review?: boolean };
|
|
5068
|
+
const redis = await getConnection(instance_id);
|
|
5069
|
+
|
|
5070
|
+
const depKey = `cachly:dep:${dependency}`;
|
|
5071
|
+
const raw = await redis.get(depKey);
|
|
5072
|
+
if (!raw) return `📭 No lessons found that depend on \`${dependency}\`.\n\nAdd dependencies via: \`learn_from_attempts(..., depends_on=["${dependency}"])\``;
|
|
5073
|
+
|
|
5074
|
+
const topics: string[] = JSON.parse(raw);
|
|
5075
|
+
const lines = [
|
|
5076
|
+
`🔗 **Causal Chain: \`${dependency}\`** — ${topics.length} dependent lesson${topics.length === 1 ? '' : 's'}`,
|
|
5077
|
+
'',
|
|
5078
|
+
];
|
|
5079
|
+
|
|
5080
|
+
for (const t of topics) {
|
|
5081
|
+
const lessonRaw = await redis.get(`cachly:lesson:best:${t}`);
|
|
5082
|
+
if (!lessonRaw) { lines.push(` • \`${t}\` _(lesson deleted)_`); continue; }
|
|
5083
|
+
const lesson = JSON.parse(lessonRaw) as { outcome?: string; severity?: string; needs_review?: boolean };
|
|
5084
|
+
const emoji = lesson.outcome === 'success' ? '✅' : lesson.outcome === 'partial' ? '⚠️' : '❌';
|
|
5085
|
+
const reviewBadge = lesson.needs_review ? ' 🔍 **needs_review**' : '';
|
|
5086
|
+
lines.push(` ${emoji} \`${t}\` (${lesson.severity ?? 'major'})${reviewBadge}`);
|
|
5087
|
+
|
|
5088
|
+
if (mark_review) {
|
|
5089
|
+
const updated = { ...lesson, needs_review: true };
|
|
5090
|
+
await redis.set(`cachly:lesson:best:${t}`, JSON.stringify(updated));
|
|
5091
|
+
}
|
|
5092
|
+
}
|
|
5093
|
+
|
|
5094
|
+
if (mark_review) {
|
|
5095
|
+
lines.push('', `🔍 All ${topics.length} lessons marked as **needs_review** — verify they still work with the changed dependency.`);
|
|
5096
|
+
} else {
|
|
5097
|
+
lines.push('', `_Run with \`mark_review: true\` to flag all dependent lessons for re-verification._`);
|
|
5098
|
+
}
|
|
5099
|
+
return lines.join('\n');
|
|
5100
|
+
}
|
|
5101
|
+
|
|
3817
5102
|
// ── global_learn ──────────────────────────────────────────────────────────
|
|
3818
5103
|
case 'global_learn': {
|
|
3819
5104
|
const { instance_id, topic, lesson, severity = 'minor', tags = [] } = args as {
|
|
@@ -4095,65 +5380,148 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
4095
5380
|
// Generate copilot-instructions.md content
|
|
4096
5381
|
const tier = inst.tier.toUpperCase();
|
|
4097
5382
|
const smartRecallNote = hasVector
|
|
4098
|
-
? '
|
|
4099
|
-
: '
|
|
5383
|
+
? '- `smart_recall("natural language query")` — semantic search (finds by meaning)'
|
|
5384
|
+
: '- `recall_context("arch:*")` — exact/glob key lookup (upgrade to Speed/Business for smart_recall)';
|
|
4100
5385
|
const layerNote = hasVector
|
|
4101
|
-
? `Layer 3 (Semantic): smart_recall uses pgvector HNSW on your ${tier} instance
|
|
4102
|
-
: `Layer 3 (Autopilot):
|
|
5386
|
+
? `Layer 3 (Semantic): smart_recall uses pgvector HNSW on your ${tier} instance`
|
|
5387
|
+
: `Layer 3 (Autopilot): this file — upgrade to Speed/Business tier to unlock smart_recall`;
|
|
5388
|
+
|
|
5389
|
+
const copilotInstructions = `# cachly AI Brain — ${project_description}
|
|
5390
|
+
|
|
5391
|
+
> 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.
|
|
4103
5392
|
|
|
4104
|
-
|
|
5393
|
+
---
|
|
4105
5394
|
|
|
4106
|
-
##
|
|
5395
|
+
## 🔴 MANDATORY RULES — Always enforced, no exceptions
|
|
4107
5396
|
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
- **Layer 3 — Autopilot (this file):** instructs the AI to run memory tools automatically
|
|
5397
|
+
### Rule 1 — Session Start (FIRST thing in every window)
|
|
5398
|
+
Call \`session_start\` **before reading any source files or making any changes**:
|
|
4111
5399
|
|
|
4112
|
-
|
|
5400
|
+
\`\`\`
|
|
5401
|
+
session_start(
|
|
5402
|
+
instance_id = "${instance_id}",
|
|
5403
|
+
focus = "<what you plan to work on today>",
|
|
5404
|
+
)
|
|
5405
|
+
\`\`\`
|
|
4113
5406
|
|
|
4114
|
-
|
|
4115
|
-
- \`recall_best_solution("deploy:web")\` → returns working deploy command
|
|
4116
|
-
- \`recall_best_solution("debug:redis-tls")\` → returns root cause + fix
|
|
4117
|
-
- If a solution exists, use it — do NOT re-discover from scratch
|
|
5407
|
+
Returns: last session summary, handoff from previous window, recent lessons, open failures, brain health.
|
|
4118
5408
|
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
5409
|
+
**If \`session_start\` is unavailable**, fall back:
|
|
5410
|
+
1. \`smart_recall("recent lessons <topic>")\`
|
|
5411
|
+
2. \`recall_best_solution("<topic>")\`
|
|
4122
5412
|
|
|
4123
|
-
|
|
4124
|
-
|
|
5413
|
+
### Rule 2 — Learn After Every Fix/Deploy/Discovery (AUTOMATIC — no prompting needed)
|
|
5414
|
+
After **every** bug fix, deployment, failed attempt, or discovery — call \`learn_from_attempts\` immediately:
|
|
4125
5415
|
|
|
4126
|
-
|
|
5416
|
+
\`\`\`
|
|
5417
|
+
learn_from_attempts(
|
|
5418
|
+
instance_id = "${instance_id}",
|
|
5419
|
+
topic = "category:keyword", # deploy:web | fix:auth | debug:db | infra:docker
|
|
5420
|
+
outcome = "success" | "failure" | "partial",
|
|
5421
|
+
what_worked = "exact approach or command that worked",
|
|
5422
|
+
what_failed = "what did NOT work (prevents future wasted time)",
|
|
5423
|
+
severity = "critical" | "major" | "minor",
|
|
5424
|
+
commands = ["exact command that worked"],
|
|
5425
|
+
file_paths = ["path/to/changed/file"],
|
|
5426
|
+
)
|
|
5427
|
+
\`\`\`
|
|
5428
|
+
|
|
5429
|
+
**These events MUST automatically trigger \`learn_from_attempts\`:**
|
|
5430
|
+
- ✅ Successfully deployed → \`topic="deploy:<component>", outcome="success"\`
|
|
5431
|
+
- ✅ Fixed a bug → \`topic="fix:<description>", outcome="success"\`
|
|
5432
|
+
- ✅ Discovered root cause → \`topic="debug:<area>", outcome="partial"\`
|
|
5433
|
+
- ✅ Tried something that failed → \`topic="<area>", outcome="failure", what_failed="..."\`
|
|
5434
|
+
- ✅ Found a config quirk → \`topic="infra:<topic>", outcome="success"\`
|
|
5435
|
+
- ✅ Ran a working command → store it in \`commands=[...]\`
|
|
5436
|
+
|
|
5437
|
+
### Rule 3 — Session Handoff (when closing window or task list > 3 items)
|
|
5438
|
+
\`\`\`
|
|
5439
|
+
session_handoff(
|
|
5440
|
+
instance_id = "${instance_id}",
|
|
5441
|
+
completed_tasks = ["what was done"],
|
|
5442
|
+
remaining_tasks = ["what is still to do"],
|
|
5443
|
+
files_changed = [{"path": "src/...", "status": "modified", "description": "what changed"}],
|
|
5444
|
+
instructions = "IMPORTANT: what the next window MUST know to not break things",
|
|
5445
|
+
context_summary = "current state of the system",
|
|
5446
|
+
)
|
|
5447
|
+
\`\`\`
|
|
5448
|
+
|
|
5449
|
+
The next \`session_start\` automatically includes this handoff. The new window sees exactly what's done, what remains, and what NOT to break.
|
|
5450
|
+
|
|
5451
|
+
### Rule 4 — Session End (LAST thing before closing)
|
|
5452
|
+
\`\`\`
|
|
5453
|
+
session_end(
|
|
5454
|
+
instance_id = "${instance_id}",
|
|
5455
|
+
summary = "What was accomplished (2-3 sentences with specifics)",
|
|
5456
|
+
files_changed = ["list", "of", "changed", "files"],
|
|
5457
|
+
lessons_learned = <number of learn_from_attempts calls made>,
|
|
5458
|
+
)
|
|
5459
|
+
\`\`\`
|
|
4127
5460
|
|
|
4128
|
-
|
|
5461
|
+
> \`session_end\` **automatically extracts and stores lessons** from the summary text — no manual calls needed.
|
|
4129
5462
|
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
5463
|
+
### Rule 5 — Code Index Invalidation (AUTOMATIC)
|
|
5464
|
+
- \`index_project\` tracks MD5 hashes per file — **skips unchanged files automatically**
|
|
5465
|
+
- After code changes: next \`index_project\` run re-indexes only modified files
|
|
5466
|
+
- After delete/rename: run \`index_project\` again to sync
|
|
5467
|
+
- No manual invalidation needed
|
|
4134
5468
|
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
5469
|
+
---
|
|
5470
|
+
|
|
5471
|
+
## Task-type trigger table
|
|
5472
|
+
|
|
5473
|
+
| You're about to... | Call BEFORE | Call AFTER |
|
|
5474
|
+
|---|---|---|
|
|
5475
|
+
| Deploy anything | \`recall_best_solution("deploy:<component>")\` | \`learn_from_attempts(topic="deploy:...")\` |
|
|
5476
|
+
| Fix a bug | \`recall_best_solution("fix:<area>")\` | \`learn_from_attempts(topic="fix:...")\` |
|
|
5477
|
+
| Add a feature | \`session_start(focus="feat:<area>")\` | \`learn_from_attempts(topic="feat:...")\` |
|
|
5478
|
+
| Infra/server work | \`recall_best_solution("infra:<topic>")\` | \`learn_from_attempts(topic="infra:...")\` |
|
|
5479
|
+
| Debug an issue | \`smart_recall("<error message or symptom>")\` | \`learn_from_attempts(topic="debug:...")\` |
|
|
5480
|
+
|
|
5481
|
+
---
|
|
5482
|
+
|
|
5483
|
+
## Available Brain Tools
|
|
5484
|
+
|
|
5485
|
+
| Tool | When to use |
|
|
5486
|
+
|------|-------------|
|
|
5487
|
+
| \`session_start\` | **FIRST** — mandatory at start of every session |
|
|
5488
|
+
| \`session_end\` | **LAST** — mandatory at end, auto-learns from summary |
|
|
5489
|
+
| \`session_handoff\` | When closing window with remaining tasks |
|
|
5490
|
+
| \`learn_from_attempts\` | **AUTOMATIC** after every fix/deploy/discovery |
|
|
5491
|
+
| \`recall_best_solution\` | Before any non-trivial task |
|
|
5492
|
+
| \`remember_context\` | After analyzing code — save findings for future sessions |
|
|
5493
|
+
${smartRecallNote ? `| \`smart_recall\` | Search brain by meaning/keywords |\n` : ''}\
|
|
5494
|
+
| \`recall_context\` | Get exact key (supports glob: \`arch:*\`, \`file:*\`) |
|
|
5495
|
+
| \`brain_search\` | BM25+ full-text search over all brain data |
|
|
5496
|
+
| \`auto_learn_session\` | Batch-learn from a list of observations (optional) |
|
|
5497
|
+
| \`index_project\` | Index source files (smart hash, skips unchanged files) |
|
|
5498
|
+
| \`list_remembered\` | See what's cached in the brain |
|
|
5499
|
+
| \`forget_context\` | Remove stale context |
|
|
5500
|
+
|
|
5501
|
+
---
|
|
4138
5502
|
|
|
4139
5503
|
## Instance Details
|
|
4140
5504
|
|
|
4141
|
-
- Instance ID
|
|
4142
|
-
- Instance name
|
|
4143
|
-
- Tier
|
|
4144
|
-
-
|
|
4145
|
-
- Embedding provider
|
|
5505
|
+
- **Instance ID:** \`${instance_id}\`
|
|
5506
|
+
- **Instance name:** ${inst.name}
|
|
5507
|
+
- **Tier:** ${tier}
|
|
5508
|
+
- **${layerNote}**
|
|
5509
|
+
- **Embedding provider:** ${providerArg}
|
|
4146
5510
|
|
|
4147
|
-
|
|
5511
|
+
---
|
|
5512
|
+
|
|
5513
|
+
## How the 3-layer system works
|
|
4148
5514
|
|
|
4149
5515
|
\`\`\`
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
remember_context("key", "content") # save analysis
|
|
5516
|
+
Layer 1 — Storage: Your cachly Valkey instance (${inst.name}) — persists forever
|
|
5517
|
+
Layer 2 — Tools: learn_from_attempts · recall_best_solution · brain_search · session_start/end
|
|
5518
|
+
Layer 3 — Autopilot: This file — AI reads it and runs tools automatically every session
|
|
4154
5519
|
\`\`\`
|
|
5520
|
+
|
|
5521
|
+
Result: Your AI **never solves the same problem twice** and always picks up exactly where it left off. 🚀
|
|
4155
5522
|
`;
|
|
4156
5523
|
|
|
5524
|
+
|
|
4157
5525
|
const lines: string[] = [
|
|
4158
5526
|
`🧠 **cachly AI Memory Setup Complete**`,
|
|
4159
5527
|
``,
|
|
@@ -4177,8 +5545,8 @@ remember_context("key", "content") # save analysis
|
|
|
4177
5545
|
`**Step 3 — copilot-instructions.md (Layer 3 Autopilot):**`,
|
|
4178
5546
|
``,
|
|
4179
5547
|
...(project_dir
|
|
4180
|
-
? [
|
|
4181
|
-
: [`Copy this to \`.github/copilot-instructions.md\` in your project:`]),
|
|
5548
|
+
? [`🔍 Detecting IDEs in \`${project_dir}\`…`]
|
|
5549
|
+
: [`Copy this to \`.github/copilot-instructions.md\` (Copilot), \`CLAUDE.md\` (Claude Code), or \`.cursor/rules\` (Cursor) in your project:`]),
|
|
4182
5550
|
``,
|
|
4183
5551
|
`\`\`\`markdown`,
|
|
4184
5552
|
copilotInstructions,
|
|
@@ -4193,16 +5561,104 @@ remember_context("key", "content") # save analysis
|
|
|
4193
5561
|
`Result: Your AI never solves the same problem twice. 🚀`,
|
|
4194
5562
|
];
|
|
4195
5563
|
|
|
4196
|
-
//
|
|
5564
|
+
// ── IDE auto-detection + file writing ────────────────────────────────
|
|
4197
5565
|
if (project_dir) {
|
|
4198
|
-
const { mkdir, writeFile } = await import('node:fs/promises');
|
|
4199
|
-
const
|
|
4200
|
-
|
|
4201
|
-
const
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
5566
|
+
const { mkdir, writeFile, access } = await import('node:fs/promises');
|
|
5567
|
+
const { constants } = await import('node:fs');
|
|
5568
|
+
|
|
5569
|
+
const exists = async (p: string) => access(p, constants.F_OK).then(() => true).catch(() => false);
|
|
5570
|
+
|
|
5571
|
+
// Detect which IDEs are present based on marker files/dirs
|
|
5572
|
+
interface IdeTarget { ide: string; path: string; content: string }
|
|
5573
|
+
const targets: IdeTarget[] = [];
|
|
5574
|
+
let stopHookWritten = false;
|
|
5575
|
+
|
|
5576
|
+
// GitHub Copilot — always write (universal fallback)
|
|
5577
|
+
targets.push({
|
|
5578
|
+
ide: 'GitHub Copilot',
|
|
5579
|
+
path: join(project_dir, '.github', 'copilot-instructions.md'),
|
|
5580
|
+
content: copilotInstructions,
|
|
5581
|
+
});
|
|
5582
|
+
|
|
5583
|
+
// Claude Code — CLAUDE.md in project root
|
|
5584
|
+
if (await exists(join(project_dir, 'CLAUDE.md')) || await exists(join(project_dir, '.claude'))) {
|
|
5585
|
+
targets.push({
|
|
5586
|
+
ide: 'Claude Code',
|
|
5587
|
+
path: join(project_dir, 'CLAUDE.md'),
|
|
5588
|
+
content: copilotInstructions,
|
|
5589
|
+
});
|
|
5590
|
+
|
|
5591
|
+
// Claude Code Stop-Hook — auto-saves checkpoint when Claude stops responding
|
|
5592
|
+
const claudeDir = join(project_dir, '.claude');
|
|
5593
|
+
await mkdir(claudeDir, { recursive: true });
|
|
5594
|
+
const stopHook = {
|
|
5595
|
+
hooks: {
|
|
5596
|
+
Stop: [
|
|
5597
|
+
{
|
|
5598
|
+
matcher: '',
|
|
5599
|
+
hooks: [
|
|
5600
|
+
{
|
|
5601
|
+
type: 'command',
|
|
5602
|
+
command: `npx --yes @cachly-dev/mcp-server@latest checkpoint --instance-id ${instance_id}`,
|
|
5603
|
+
},
|
|
5604
|
+
],
|
|
5605
|
+
},
|
|
5606
|
+
],
|
|
5607
|
+
},
|
|
5608
|
+
};
|
|
5609
|
+
const settingsPath = join(claudeDir, 'settings.json');
|
|
5610
|
+
let existingSettings: Record<string, unknown> = {};
|
|
5611
|
+
try {
|
|
5612
|
+
const { readFile: rf } = await import('node:fs/promises');
|
|
5613
|
+
existingSettings = JSON.parse(await rf(settingsPath, 'utf-8'));
|
|
5614
|
+
} catch { /* new file */ }
|
|
5615
|
+
const merged = { ...existingSettings, hooks: (stopHook as Record<string, unknown>).hooks };
|
|
5616
|
+
await writeFile(settingsPath, JSON.stringify(merged, null, 2), 'utf-8');
|
|
5617
|
+
stopHookWritten = true;
|
|
5618
|
+
}
|
|
5619
|
+
|
|
5620
|
+
// Cursor — .cursor/rules (new format) or .cursorrules (legacy)
|
|
5621
|
+
if (
|
|
5622
|
+
await exists(join(project_dir, '.cursor')) ||
|
|
5623
|
+
await exists(join(project_dir, '.cursorrules'))
|
|
5624
|
+
) {
|
|
5625
|
+
const cursorDir = join(project_dir, '.cursor');
|
|
5626
|
+
await mkdir(cursorDir, { recursive: true });
|
|
5627
|
+
targets.push({
|
|
5628
|
+
ide: 'Cursor',
|
|
5629
|
+
path: join(cursorDir, 'rules'),
|
|
5630
|
+
content: copilotInstructions,
|
|
5631
|
+
});
|
|
5632
|
+
}
|
|
5633
|
+
|
|
5634
|
+
// Windsurf — .windsurfrules
|
|
5635
|
+
if (
|
|
5636
|
+
await exists(join(project_dir, '.windsurfrules')) ||
|
|
5637
|
+
await exists(join(project_dir, '.windsurf'))
|
|
5638
|
+
) {
|
|
5639
|
+
targets.push({
|
|
5640
|
+
ide: 'Windsurf',
|
|
5641
|
+
path: join(project_dir, '.windsurfrules'),
|
|
5642
|
+
content: copilotInstructions,
|
|
5643
|
+
});
|
|
5644
|
+
}
|
|
5645
|
+
|
|
5646
|
+
// VS Code (Copilot) — already covered by .github/copilot-instructions.md above
|
|
5647
|
+
// Continue.dev — .continue/config.json is JSON, not markdown — skip, copilot-instructions handles it
|
|
5648
|
+
|
|
5649
|
+
const written: string[] = [];
|
|
5650
|
+
for (const target of targets) {
|
|
5651
|
+
const dir = target.path.substring(0, target.path.lastIndexOf('/'));
|
|
5652
|
+
await mkdir(dir, { recursive: true });
|
|
5653
|
+
await writeFile(target.path, target.content, 'utf-8');
|
|
5654
|
+
written.push(`✅ [${target.ide}] → \`${target.path.replace(project_dir, '.')}\``);
|
|
5655
|
+
}
|
|
5656
|
+
|
|
5657
|
+
if (stopHookWritten) {
|
|
5658
|
+
written.push(`✅ [Claude Code Stop-Hook] → \`.claude/settings.json\` (auto-checkpoint on stop)`);
|
|
5659
|
+
}
|
|
5660
|
+
|
|
5661
|
+
lines.push(...written);
|
|
4206
5662
|
}
|
|
4207
5663
|
|
|
4208
5664
|
return lines.join('\n');
|
|
@@ -4330,6 +5786,178 @@ async function handleBulkLockStream(name: string, args: Record<string, unknown>)
|
|
|
4330
5786
|
);
|
|
4331
5787
|
}
|
|
4332
5788
|
|
|
5789
|
+
// ── Roadmap ──────────────────────────────────────────────────────────────
|
|
5790
|
+
|
|
5791
|
+
case 'roadmap_add': {
|
|
5792
|
+
const {
|
|
5793
|
+
instance_id: rid,
|
|
5794
|
+
title,
|
|
5795
|
+
description: desc = '',
|
|
5796
|
+
priority = 'medium',
|
|
5797
|
+
tags: rtags = [],
|
|
5798
|
+
milestone = '',
|
|
5799
|
+
} = args as {
|
|
5800
|
+
instance_id: string; title: string; description?: string;
|
|
5801
|
+
priority?: string; tags?: string[]; milestone?: string;
|
|
5802
|
+
};
|
|
5803
|
+
const redis = await getConnection(rid);
|
|
5804
|
+
const id = `rm_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
5805
|
+
const item = {
|
|
5806
|
+
id, title, description: desc, priority, tags: rtags, milestone,
|
|
5807
|
+
status: 'planned',
|
|
5808
|
+
created: new Date().toISOString(),
|
|
5809
|
+
updated: new Date().toISOString(),
|
|
5810
|
+
notes: '',
|
|
5811
|
+
};
|
|
5812
|
+
await redis.hset(`cachly:roadmap:${rid}`, id, JSON.stringify(item));
|
|
5813
|
+
const PRIORITY_ICON: Record<string, string> = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
|
|
5814
|
+
return [
|
|
5815
|
+
`📋 **Roadmap item added**`,
|
|
5816
|
+
``,
|
|
5817
|
+
` ID: \`${id}\``,
|
|
5818
|
+
` Title: ${title}`,
|
|
5819
|
+
` Priority: ${PRIORITY_ICON[priority] ?? '⚪'} ${priority}`,
|
|
5820
|
+
` Status: planned`,
|
|
5821
|
+
milestone ? ` Milestone: ${milestone}` : '',
|
|
5822
|
+
rtags.length ? ` Tags: ${rtags.join(', ')}` : '',
|
|
5823
|
+
``,
|
|
5824
|
+
`💡 Use \`roadmap_update(id: "${id}", status: "in-progress")\` when you start working on it.`,
|
|
5825
|
+
].filter(Boolean).join('\n');
|
|
5826
|
+
}
|
|
5827
|
+
|
|
5828
|
+
case 'roadmap_update': {
|
|
5829
|
+
const {
|
|
5830
|
+
instance_id: rid,
|
|
5831
|
+
id: itemId,
|
|
5832
|
+
status: newStatus,
|
|
5833
|
+
priority: newPriority,
|
|
5834
|
+
notes: newNotes,
|
|
5835
|
+
title: newTitle,
|
|
5836
|
+
description: newDesc,
|
|
5837
|
+
} = args as {
|
|
5838
|
+
instance_id: string; id: string; status?: string; priority?: string;
|
|
5839
|
+
notes?: string; title?: string; description?: string;
|
|
5840
|
+
};
|
|
5841
|
+
const redis = await getConnection(rid);
|
|
5842
|
+
const raw = await redis.hget(`cachly:roadmap:${rid}`, itemId);
|
|
5843
|
+
if (!raw) return `⚠️ **roadmap_update** – Item \`${itemId}\` not found. Use \`roadmap_list\` to see all items.`;
|
|
5844
|
+
const item = JSON.parse(raw) as Record<string, unknown>;
|
|
5845
|
+
const oldStatus = item.status as string;
|
|
5846
|
+
if (newStatus) item.status = newStatus;
|
|
5847
|
+
if (newPriority) item.priority = newPriority;
|
|
5848
|
+
if (newTitle) item.title = newTitle;
|
|
5849
|
+
if (newDesc) item.description = newDesc;
|
|
5850
|
+
if (newNotes) item.notes = item.notes ? `${item.notes}\n[${new Date().toISOString().slice(0, 10)}] ${newNotes}` : `[${new Date().toISOString().slice(0, 10)}] ${newNotes}`;
|
|
5851
|
+
item.updated = new Date().toISOString();
|
|
5852
|
+
await redis.hset(`cachly:roadmap:${rid}`, itemId, JSON.stringify(item));
|
|
5853
|
+
const statusEmoji: Record<string, string> = { planned: '📋', 'in-progress': '⚡', done: '✅', blocked: '🚫', cancelled: '🗑️' };
|
|
5854
|
+
return [
|
|
5855
|
+
`${statusEmoji[newStatus ?? oldStatus] ?? '📋'} **Roadmap updated** \`${itemId}\``,
|
|
5856
|
+
``,
|
|
5857
|
+
` Title: ${item.title}`,
|
|
5858
|
+
oldStatus !== newStatus ? ` Status: ${oldStatus} → ${newStatus}` : ` Status: ${item.status}`,
|
|
5859
|
+
newNotes ? ` Notes: ${newNotes}` : '',
|
|
5860
|
+
].filter(Boolean).join('\n');
|
|
5861
|
+
}
|
|
5862
|
+
|
|
5863
|
+
case 'roadmap_list': {
|
|
5864
|
+
const {
|
|
5865
|
+
instance_id: rid,
|
|
5866
|
+
status: filterStatus = 'open',
|
|
5867
|
+
tag: filterTag,
|
|
5868
|
+
milestone: filterMilestone,
|
|
5869
|
+
priority: filterPriority,
|
|
5870
|
+
} = args as {
|
|
5871
|
+
instance_id: string; status?: string; tag?: string;
|
|
5872
|
+
milestone?: string; priority?: string;
|
|
5873
|
+
};
|
|
5874
|
+
const redis = await getConnection(rid);
|
|
5875
|
+
const all = await redis.hgetall(`cachly:roadmap:${rid}`);
|
|
5876
|
+
if (!all || Object.keys(all).length === 0) {
|
|
5877
|
+
return '📋 **Roadmap is empty.**\n\nUse `roadmap_add` to create your first item.';
|
|
5878
|
+
}
|
|
5879
|
+
const PRIORITY_ORDER: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
5880
|
+
const PRIORITY_ICON: Record<string, string> = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
|
|
5881
|
+
const STATUS_ICON: Record<string, string> = { planned: '📋', 'in-progress': '⚡', done: '✅', blocked: '🚫', cancelled: '🗑️' };
|
|
5882
|
+
const openStatuses = new Set(['planned', 'in-progress', 'blocked']);
|
|
5883
|
+
let items = Object.values(all).map(v => JSON.parse(v as string) as Record<string, string | string[]>);
|
|
5884
|
+
// Filter
|
|
5885
|
+
if (filterStatus === 'open') items = items.filter(i => openStatuses.has(i.status as string));
|
|
5886
|
+
else if (filterStatus) items = items.filter(i => i.status === filterStatus);
|
|
5887
|
+
if (filterTag) items = items.filter(i => (i.tags as string[]).includes(filterTag));
|
|
5888
|
+
if (filterMilestone) items = items.filter(i => i.milestone === filterMilestone);
|
|
5889
|
+
if (filterPriority) items = items.filter(i => (PRIORITY_ORDER[i.priority as string] ?? 99) <= (PRIORITY_ORDER[filterPriority] ?? 99));
|
|
5890
|
+
// Sort: priority asc, then created asc
|
|
5891
|
+
items.sort((a, b) => {
|
|
5892
|
+
const pa = PRIORITY_ORDER[a.priority as string] ?? 99;
|
|
5893
|
+
const pb = PRIORITY_ORDER[b.priority as string] ?? 99;
|
|
5894
|
+
return pa !== pb ? pa - pb : (a.created as string).localeCompare(b.created as string);
|
|
5895
|
+
});
|
|
5896
|
+
if (items.length === 0) return `📋 **No roadmap items** match the current filter (status: ${filterStatus}).`;
|
|
5897
|
+
const grouped: Record<string, typeof items> = {};
|
|
5898
|
+
for (const it of items) {
|
|
5899
|
+
const st = it.status as string;
|
|
5900
|
+
if (!grouped[st]) grouped[st] = [];
|
|
5901
|
+
grouped[st].push(it);
|
|
5902
|
+
}
|
|
5903
|
+
const lines: string[] = [`📋 **Roadmap** (${items.length} item${items.length !== 1 ? 's' : ''})`, ''];
|
|
5904
|
+
for (const [st, grp] of Object.entries(grouped)) {
|
|
5905
|
+
lines.push(`**${STATUS_ICON[st] ?? '•'} ${st.toUpperCase()}** (${grp.length})`);
|
|
5906
|
+
for (const it of grp) {
|
|
5907
|
+
const tags = (it.tags as string[]).length ? ` [${(it.tags as string[]).join(', ')}]` : '';
|
|
5908
|
+
const milestone = it.milestone ? ` · ${it.milestone}` : '';
|
|
5909
|
+
lines.push(` ${PRIORITY_ICON[it.priority as string] ?? '⚪'} \`${it.id}\` **${it.title}**${tags}${milestone}`);
|
|
5910
|
+
if (it.description) lines.push(` ${(it.description as string).slice(0, 120)}`);
|
|
5911
|
+
if (it.notes) lines.push(` 📝 ${(it.notes as string).split('\n').pop()?.slice(0, 100)}`);
|
|
5912
|
+
}
|
|
5913
|
+
lines.push('');
|
|
5914
|
+
}
|
|
5915
|
+
lines.push(`💡 \`roadmap_update(id, status: "in-progress")\` to start · \`roadmap_next\` for top priority item`);
|
|
5916
|
+
return lines.join('\n');
|
|
5917
|
+
}
|
|
5918
|
+
|
|
5919
|
+
case 'roadmap_next': {
|
|
5920
|
+
const { instance_id: rid, tag: filterTag } = args as { instance_id: string; tag?: string };
|
|
5921
|
+
const redis = await getConnection(rid);
|
|
5922
|
+
const all = await redis.hgetall(`cachly:roadmap:${rid}`);
|
|
5923
|
+
if (!all || Object.keys(all).length === 0) {
|
|
5924
|
+
return '📋 **Roadmap is empty.** Use `roadmap_add` to plan your first task.';
|
|
5925
|
+
}
|
|
5926
|
+
const PRIORITY_ORDER: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
5927
|
+
const PRIORITY_ICON: Record<string, string> = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
|
|
5928
|
+
let items = Object.values(all)
|
|
5929
|
+
.map(v => JSON.parse(v as string) as Record<string, unknown>)
|
|
5930
|
+
.filter(i => i.status === 'in-progress' || i.status === 'planned')
|
|
5931
|
+
.filter(i => !filterTag || (i.tags as string[]).includes(filterTag));
|
|
5932
|
+
if (items.length === 0) return '🎉 **No open roadmap items!** All tasks are done (or use `roadmap_list` to check).';
|
|
5933
|
+
// in-progress first, then by priority
|
|
5934
|
+
items.sort((a, b) => {
|
|
5935
|
+
if (a.status === 'in-progress' && b.status !== 'in-progress') return -1;
|
|
5936
|
+
if (b.status === 'in-progress' && a.status !== 'in-progress') return 1;
|
|
5937
|
+
return (PRIORITY_ORDER[a.priority as string] ?? 99) - (PRIORITY_ORDER[b.priority as string] ?? 99);
|
|
5938
|
+
});
|
|
5939
|
+
const next = items[0];
|
|
5940
|
+
const remaining = items.length - 1;
|
|
5941
|
+
const tags = (next.tags as string[]).length ? `\nTags: ${(next.tags as string[]).join(', ')}` : '';
|
|
5942
|
+
const milestone = next.milestone ? `\nMilestone: ${next.milestone}` : '';
|
|
5943
|
+
const notes = next.notes ? `\nNotes: ${(next.notes as string).split('\n').pop()?.slice(0, 120)}` : '';
|
|
5944
|
+
return [
|
|
5945
|
+
`${next.status === 'in-progress' ? '⚡' : '📋'} **Next up: ${next.title}**`,
|
|
5946
|
+
``,
|
|
5947
|
+
`ID: \`${next.id}\``,
|
|
5948
|
+
`Priority: ${PRIORITY_ICON[next.priority as string] ?? '⚪'} ${next.priority}`,
|
|
5949
|
+
`Status: ${next.status}`,
|
|
5950
|
+
next.description ? `\nWhat to do:\n${next.description}` : '',
|
|
5951
|
+
tags, milestone, notes,
|
|
5952
|
+
``,
|
|
5953
|
+
remaining > 0 ? `(+${remaining} more open item${remaining !== 1 ? 's' : ''} in roadmap)` : '(last open item)',
|
|
5954
|
+
``,
|
|
5955
|
+
next.status === 'planned'
|
|
5956
|
+
? `💡 Start with: \`roadmap_update(id: "${next.id}", status: "in-progress")\``
|
|
5957
|
+
: `💡 Finish with: \`roadmap_update(id: "${next.id}", status: "done", notes: "...")\``,
|
|
5958
|
+
].filter(s => s !== undefined).join('\n');
|
|
5959
|
+
}
|
|
5960
|
+
|
|
4333
5961
|
default:
|
|
4334
5962
|
return null;
|
|
4335
5963
|
}
|
|
@@ -4338,14 +5966,81 @@ async function handleBulkLockStream(name: string, args: Record<string, unknown>)
|
|
|
4338
5966
|
// ── Server setup ──────────────────────────────────────────────────────────────
|
|
4339
5967
|
|
|
4340
5968
|
const server = new Server(
|
|
4341
|
-
{ name: 'cachly-mcp', version:
|
|
5969
|
+
{ name: 'cachly-mcp', version: CURRENT_VERSION },
|
|
4342
5970
|
{ capabilities: { tools: {} } }
|
|
4343
5971
|
);
|
|
4344
5972
|
|
|
4345
5973
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
4346
5974
|
|
|
5975
|
+
// ── Auto-session management ───────────────────────────────────────────────────
|
|
5976
|
+
// Transparently starts a Brain session on the first tool call of a process
|
|
5977
|
+
// and saves a session_end summary on SIGTERM/exit so users never have to call
|
|
5978
|
+
// session_start/session_end manually.
|
|
5979
|
+
|
|
5980
|
+
let _autoSessionInstanceId: string | null = null;
|
|
5981
|
+
let _autoSessionStarted = false;
|
|
5982
|
+
let _autoSessionToolCount = 0;
|
|
5983
|
+
|
|
5984
|
+
async function autoStartSession(instanceId: string): Promise<void> {
|
|
5985
|
+
if (_autoSessionStarted) return;
|
|
5986
|
+
_autoSessionStarted = true;
|
|
5987
|
+
_autoSessionInstanceId = instanceId;
|
|
5988
|
+
try {
|
|
5989
|
+
await handleTool('session_start', { instance_id: instanceId, focus: 'auto (MCP session)' });
|
|
5990
|
+
} catch { /* non-fatal — session tracking is a best-effort feature */ }
|
|
5991
|
+
|
|
5992
|
+
// Auto-index the project if it hasn't been indexed in the last 24h.
|
|
5993
|
+
// This is the main lever for growing token savings: more indexed code →
|
|
5994
|
+
// more semantic cache hits → fewer LLM calls for repeated questions.
|
|
5995
|
+
if (process.env.CACHLY_AUTO_INDEX !== 'false') {
|
|
5996
|
+
try {
|
|
5997
|
+
const redis = await getConnection(instanceId);
|
|
5998
|
+
const lastIndexed = await redis.get(`cachly:index:last_indexed:${instanceId}`);
|
|
5999
|
+
const staleMs = 24 * 60 * 60 * 1000;
|
|
6000
|
+
const isStale = !lastIndexed || (Date.now() - parseInt(lastIndexed, 10)) > staleMs;
|
|
6001
|
+
if (isStale) {
|
|
6002
|
+
// Mark as indexing now to prevent concurrent re-runs.
|
|
6003
|
+
await redis.set(`cachly:index:last_indexed:${instanceId}`, String(Date.now()), 'EX', 90000);
|
|
6004
|
+
// Run in background — don't block the first tool call.
|
|
6005
|
+
handleTool('index_project', {
|
|
6006
|
+
instance_id: instanceId,
|
|
6007
|
+
dir: process.cwd(),
|
|
6008
|
+
max_files: 150,
|
|
6009
|
+
ttl: 86400 * 7, // cache for 7 days
|
|
6010
|
+
namespace: 'cachly:sem:code',
|
|
6011
|
+
}).catch(() => undefined);
|
|
6012
|
+
}
|
|
6013
|
+
} catch { /* never block the session on indexing errors */ }
|
|
6014
|
+
}
|
|
6015
|
+
}
|
|
6016
|
+
|
|
6017
|
+
async function autoEndSession(): Promise<void> {
|
|
6018
|
+
if (!_autoSessionStarted || !_autoSessionInstanceId) return;
|
|
6019
|
+
_autoSessionStarted = false;
|
|
6020
|
+
try {
|
|
6021
|
+
await handleTool('session_end', {
|
|
6022
|
+
instance_id: _autoSessionInstanceId,
|
|
6023
|
+
summary: `Auto-ended after ${_autoSessionToolCount} tool call(s). Session was started automatically.`,
|
|
6024
|
+
files_changed: [],
|
|
6025
|
+
lessons_learned: 0,
|
|
6026
|
+
});
|
|
6027
|
+
} catch { /* non-fatal */ }
|
|
6028
|
+
}
|
|
6029
|
+
|
|
4347
6030
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
4348
6031
|
const { name, arguments: args } = request.params;
|
|
6032
|
+
|
|
6033
|
+
// Auto-start brain session on first tool call that has an instance_id.
|
|
6034
|
+
// Skip session management tools to avoid recursion.
|
|
6035
|
+
const sessionTools = new Set(['session_start', 'session_end', 'auto_learn_session']);
|
|
6036
|
+
if (!sessionTools.has(name) && !_autoSessionStarted) {
|
|
6037
|
+
const instanceId = (args as Record<string, unknown>)?.instance_id as string | undefined;
|
|
6038
|
+
if (instanceId) {
|
|
6039
|
+
await autoStartSession(instanceId).catch(() => undefined);
|
|
6040
|
+
}
|
|
6041
|
+
}
|
|
6042
|
+
if (!sessionTools.has(name)) _autoSessionToolCount++;
|
|
6043
|
+
|
|
4349
6044
|
try {
|
|
4350
6045
|
const text = await handleTool(name, (args ?? {}) as Record<string, unknown>);
|
|
4351
6046
|
return { content: [{ type: 'text', text }] };
|
|
@@ -4355,8 +6050,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4355
6050
|
}
|
|
4356
6051
|
});
|
|
4357
6052
|
|
|
4358
|
-
// Graceful shutdown – close all Redis connections
|
|
6053
|
+
// Graceful shutdown – close all Redis connections + auto-end brain session
|
|
4359
6054
|
process.on('SIGTERM', async () => {
|
|
6055
|
+
await autoEndSession().catch(() => undefined);
|
|
6056
|
+
for (const [, client] of pool) await client.quit().catch(() => undefined);
|
|
6057
|
+
process.exit(0);
|
|
6058
|
+
});
|
|
6059
|
+
|
|
6060
|
+
process.on('SIGINT', async () => {
|
|
6061
|
+
await autoEndSession().catch(() => undefined);
|
|
4360
6062
|
for (const [, client] of pool) await client.quit().catch(() => undefined);
|
|
4361
6063
|
process.exit(0);
|
|
4362
6064
|
});
|
|
@@ -4377,6 +6079,8 @@ const EDITOR_FILES: Record<string, string> = {
|
|
|
4377
6079
|
windsurf: '.windsurf/mcp.json',
|
|
4378
6080
|
copilot: '.vscode/mcp.json',
|
|
4379
6081
|
continue: '.continue/config.json',
|
|
6082
|
+
cline: '.vscode/mcp.json', // Cline respects VS Code project MCP config
|
|
6083
|
+
zed: '.zed/settings.json', // Zed project-level context_servers
|
|
4380
6084
|
};
|
|
4381
6085
|
|
|
4382
6086
|
const CLAUDE_MD_MARKER_START = '<!-- cachly-brain-start -->';
|
|
@@ -4393,6 +6097,20 @@ function buildMcpConfig(apiKey: string, instanceId: string, editor: string): str
|
|
|
4393
6097
|
},
|
|
4394
6098
|
}, null, 2);
|
|
4395
6099
|
}
|
|
6100
|
+
if (editor === 'zed') {
|
|
6101
|
+
return JSON.stringify({
|
|
6102
|
+
context_servers: {
|
|
6103
|
+
cachly: {
|
|
6104
|
+
command: {
|
|
6105
|
+
path: 'npx',
|
|
6106
|
+
args: ['-y', '@cachly-dev/mcp-server@latest'],
|
|
6107
|
+
env: { CACHLY_API_URL: 'https://api.cachly.dev', CACHLY_JWT: apiKey, CACHLY_BRAIN_INSTANCE_ID: instanceId },
|
|
6108
|
+
},
|
|
6109
|
+
settings: {},
|
|
6110
|
+
},
|
|
6111
|
+
},
|
|
6112
|
+
}, null, 2);
|
|
6113
|
+
}
|
|
4396
6114
|
return JSON.stringify({
|
|
4397
6115
|
mcpServers: {
|
|
4398
6116
|
cachly: {
|
|
@@ -4406,48 +6124,54 @@ function buildMcpConfig(apiKey: string, instanceId: string, editor: string): str
|
|
|
4406
6124
|
|
|
4407
6125
|
function buildClaudeMdBlock(instanceId: string): string {
|
|
4408
6126
|
return `${CLAUDE_MD_MARKER_START}
|
|
4409
|
-
## Cachly AI Brain
|
|
6127
|
+
## Cachly AI Brain
|
|
4410
6128
|
|
|
4411
|
-
|
|
6129
|
+
The Cachly MCP server is available as tool \`cachly\`.
|
|
6130
|
+
Your instance ID: \`${instanceId}\`
|
|
4412
6131
|
|
|
4413
|
-
>
|
|
4414
|
-
>
|
|
4415
|
-
> instance_id is pre-filled below. Do NOT ask the user for it.
|
|
6132
|
+
> **Sessions are fully automatic** (MCP ≥ 0.5.3).
|
|
6133
|
+
> 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.
|
|
4416
6134
|
|
|
4417
|
-
|
|
4418
|
-
\`\`\`
|
|
4419
|
-
session_start(instance_id="${instanceId}", focus="<what you're working on today>")
|
|
4420
|
-
\`\`\`
|
|
4421
|
-
This loads your previous context, recent lessons, and open tasks automatically.
|
|
6135
|
+
---
|
|
4422
6136
|
|
|
4423
|
-
###
|
|
6137
|
+
### Before editing any file — WIP-registry (prevents half-finished code across sessions):
|
|
4424
6138
|
\`\`\`
|
|
4425
|
-
|
|
6139
|
+
remember_context(
|
|
6140
|
+
instance_id = "${instanceId}",
|
|
6141
|
+
key = "wip-registry",
|
|
6142
|
+
content = "EDITING: <file:line> — goal: <what you are implementing>",
|
|
6143
|
+
category = "bug",
|
|
6144
|
+
ttl = 86400,
|
|
6145
|
+
)
|
|
4426
6146
|
\`\`\`
|
|
6147
|
+
After the edit is complete, update \`content\` to \`"DONE: <file> — <what was completed>"\`.
|
|
4427
6148
|
|
|
4428
|
-
### After fixing
|
|
6149
|
+
### After fixing any bug or solving a tricky problem:
|
|
4429
6150
|
\`\`\`
|
|
4430
6151
|
learn_from_attempts(
|
|
4431
|
-
instance_id="${instanceId}",
|
|
4432
|
-
topic="category:keyword",
|
|
4433
|
-
outcome="success",
|
|
4434
|
-
what_worked=
|
|
4435
|
-
what_failed=
|
|
4436
|
-
|
|
4437
|
-
file_paths=["
|
|
4438
|
-
|
|
6152
|
+
instance_id = "${instanceId}",
|
|
6153
|
+
topic = "category:keyword",
|
|
6154
|
+
outcome = "success",
|
|
6155
|
+
what_worked = "...",
|
|
6156
|
+
what_failed = "...",
|
|
6157
|
+
severity = "critical" | "major" | "minor",
|
|
6158
|
+
file_paths = ["path/to/file"],
|
|
6159
|
+
commands = ["the command that worked"],
|
|
6160
|
+
tags = ["tag1"],
|
|
4439
6161
|
)
|
|
4440
6162
|
\`\`\`
|
|
4441
6163
|
|
|
4442
|
-
### Before starting any
|
|
6164
|
+
### Before starting any task — recall relevant lessons first:
|
|
4443
6165
|
\`\`\`
|
|
4444
|
-
|
|
6166
|
+
smart_recall(
|
|
6167
|
+
instance_id = "${instanceId}",
|
|
6168
|
+
query = "<describe what you are about to do>",
|
|
6169
|
+
)
|
|
4445
6170
|
\`\`\`
|
|
4446
6171
|
|
|
4447
|
-
###
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
\`\`\`
|
|
6172
|
+
### Half-finished code rule:
|
|
6173
|
+
Never commit code that does not compile. Run \`tsc --noEmit\` / \`go build ./...\` before every commit.
|
|
6174
|
+
If a session ends mid-task, save the WIP-registry entry so the next session picks up exactly where you left off.
|
|
4451
6175
|
${CLAUDE_MD_MARKER_END}`;
|
|
4452
6176
|
}
|
|
4453
6177
|
|
|
@@ -4538,8 +6262,9 @@ if (process.argv[2] === 'setup') {
|
|
|
4538
6262
|
const AUTH_BASE = 'https://auth.cachly.dev/realms/cachly/protocol/openid-connect';
|
|
4539
6263
|
const CLIENT_ID = 'cachly-cli';
|
|
4540
6264
|
|
|
4541
|
-
console.log('Step 1: Sign in to cachly (free
|
|
6265
|
+
console.log('Step 1: Sign in to cachly (free, no credit card)\n');
|
|
4542
6266
|
|
|
6267
|
+
// Start device flow
|
|
4543
6268
|
let deviceCode = '', userCode = '', verifyUri = '', pollInterval = 5000;
|
|
4544
6269
|
try {
|
|
4545
6270
|
const deviceRes = await fetch(`${AUTH_BASE}/auth/device`, {
|
|
@@ -4547,92 +6272,91 @@ if (process.argv[2] === 'setup') {
|
|
|
4547
6272
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
4548
6273
|
body: `client_id=${CLIENT_ID}&scope=openid`,
|
|
4549
6274
|
});
|
|
4550
|
-
if (!deviceRes.ok) throw new Error(`HTTP ${deviceRes.status}`);
|
|
6275
|
+
if (!deviceRes.ok) throw new Error(`Device flow error: HTTP ${deviceRes.status}`);
|
|
4551
6276
|
const data = await deviceRes.json() as {
|
|
4552
6277
|
device_code: string; user_code: string;
|
|
4553
6278
|
verification_uri_complete: string; interval: number;
|
|
4554
6279
|
};
|
|
4555
|
-
deviceCode
|
|
4556
|
-
userCode
|
|
4557
|
-
verifyUri
|
|
4558
|
-
pollInterval
|
|
6280
|
+
deviceCode = data.device_code;
|
|
6281
|
+
userCode = data.user_code;
|
|
6282
|
+
verifyUri = data.verification_uri_complete;
|
|
6283
|
+
pollInterval = (data.interval ?? 5) * 1000;
|
|
4559
6284
|
} catch (e) {
|
|
4560
|
-
console.error(`\
|
|
4561
|
-
console.error('
|
|
6285
|
+
console.error(`\nFailed to start device flow: ${(e as Error).message}`);
|
|
6286
|
+
console.error('Falling back: sign in at https://cachly.dev/setup-ai and paste your API token.\n');
|
|
4562
6287
|
token = await ask(' Paste API token (cky_live_...): ');
|
|
4563
|
-
if (!token) { console.error('\nToken required. Aborting.\n'); rl.close(); process.exit(1); }
|
|
4564
|
-
|
|
6288
|
+
if (!token) { console.error('\nToken is required. Aborting.\n'); rl.close(); process.exit(1); }
|
|
6289
|
+
console.log('');
|
|
6290
|
+
deviceCode = ''; // mark as fallback so we skip polling
|
|
4565
6291
|
}
|
|
4566
6292
|
|
|
4567
|
-
if (deviceCode) {
|
|
4568
|
-
|
|
4569
|
-
console.log(`
|
|
6293
|
+
if (deviceCode!) {
|
|
6294
|
+
// Open browser
|
|
6295
|
+
console.log(` Code: \x1b[1;33m${userCode!}\x1b[0m`);
|
|
6296
|
+
console.log(` URL: ${verifyUri!}\n`);
|
|
4570
6297
|
try {
|
|
4571
6298
|
const { execSync } = await import('node:child_process');
|
|
4572
6299
|
const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
4573
|
-
execSync(`${openCmd} "${verifyUri}"`, { stdio: 'ignore' });
|
|
6300
|
+
execSync(`${openCmd} "${verifyUri!}"`, { stdio: 'ignore' });
|
|
4574
6301
|
console.log(' ✓ Browser opened — confirm the code above to continue...\n');
|
|
4575
6302
|
} catch {
|
|
4576
|
-
console.log(' 👉 Open the URL above in your browser and
|
|
6303
|
+
console.log(' 👉 Open the URL above in your browser and enter the code.\n');
|
|
4577
6304
|
}
|
|
4578
6305
|
|
|
6306
|
+
// Poll for token
|
|
4579
6307
|
process.stdout.write(' Waiting for authorization');
|
|
4580
|
-
const deadline = Date.now() + 10 * 60 * 1000;
|
|
6308
|
+
const deadline = Date.now() + 10 * 60 * 1000; // 10 min
|
|
4581
6309
|
while (Date.now() < deadline) {
|
|
4582
|
-
await new Promise(r => setTimeout(r, pollInterval));
|
|
6310
|
+
await new Promise(r => setTimeout(r, pollInterval!));
|
|
4583
6311
|
process.stdout.write('.');
|
|
4584
6312
|
try {
|
|
4585
6313
|
const tokenRes = await fetch(`${AUTH_BASE}/token`, {
|
|
4586
6314
|
method: 'POST',
|
|
4587
6315
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
4588
|
-
body: `client_id=${CLIENT_ID}&grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=${deviceCode}`,
|
|
6316
|
+
body: `client_id=${CLIENT_ID}&grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=${deviceCode!}`,
|
|
4589
6317
|
});
|
|
4590
|
-
const
|
|
4591
|
-
if (
|
|
4592
|
-
token =
|
|
6318
|
+
const tokenData = await tokenRes.json() as { access_token?: string; error?: string };
|
|
6319
|
+
if (tokenData.access_token) {
|
|
6320
|
+
token = tokenData.access_token;
|
|
4593
6321
|
console.log(' \x1b[32m✓ Authorized!\x1b[0m\n');
|
|
4594
6322
|
break;
|
|
4595
6323
|
}
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
6324
|
+
// authorization_pending = keep polling; slow_down = increase interval
|
|
6325
|
+
if (tokenData.error === 'slow_down') pollInterval = Math.min(pollInterval! + 2000, 15000);
|
|
6326
|
+
else if (tokenData.error && tokenData.error !== 'authorization_pending') {
|
|
6327
|
+
console.error(`\nAuth error: ${tokenData.error}. Aborting.\n`);
|
|
4599
6328
|
rl.close(); process.exit(1);
|
|
4600
6329
|
}
|
|
4601
6330
|
} catch { /* network hiccup — keep polling */ }
|
|
4602
6331
|
}
|
|
4603
|
-
if (!token) { console.error('\nTimed out. Aborting.\n'); rl.close(); process.exit(1); }
|
|
6332
|
+
if (!token) { console.error('\nTimed out waiting for authorization. Aborting.\n'); rl.close(); process.exit(1); }
|
|
6333
|
+
console.log('');
|
|
4604
6334
|
}
|
|
6335
|
+
}
|
|
4605
6336
|
|
|
4606
|
-
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
|
|
4612
|
-
|
|
4613
|
-
|
|
4614
|
-
}
|
|
4615
|
-
|
|
4616
|
-
|
|
4617
|
-
|
|
4618
|
-
|
|
4619
|
-
|
|
4620
|
-
|
|
4621
|
-
|
|
6337
|
+
// ── Step 1b: Exchange Keycloak JWT → long-lived cky_live_ API key ──────────
|
|
6338
|
+
// Only do this when the token looks like a Keycloak JWT (starts with "eyJ"),
|
|
6339
|
+
// not when the user already pasted a cky_live_ key directly.
|
|
6340
|
+
if (token.startsWith('eyJ')) {
|
|
6341
|
+
process.stdout.write('⏳ Generating your API key...');
|
|
6342
|
+
try {
|
|
6343
|
+
const keyRes = await fetch(`${API_URL}/api/v1/api-keys`, {
|
|
6344
|
+
method: 'POST',
|
|
6345
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
6346
|
+
body: JSON.stringify({ name: 'cachly-mcp-setup', scope: 'read_write' }),
|
|
6347
|
+
});
|
|
6348
|
+
if (!keyRes.ok) throw new Error(`HTTP ${keyRes.status}`);
|
|
6349
|
+
const keyBody = await keyRes.json() as { key: string };
|
|
6350
|
+
if (!keyBody.key) throw new Error('no key in response');
|
|
6351
|
+
token = keyBody.key; // swap JWT → cky_live_...
|
|
6352
|
+
console.log(' ✓\n');
|
|
6353
|
+
} catch (e) {
|
|
6354
|
+
console.log(' (skipped)\n');
|
|
6355
|
+
// Non-fatal: fall back to using the Keycloak JWT directly.
|
|
6356
|
+
// It will expire but setup still works for now.
|
|
4622
6357
|
}
|
|
4623
6358
|
}
|
|
4624
6359
|
|
|
4625
|
-
// ── Step 1b: Auto-provision a Brain instance if none exist ─────────────────
|
|
4626
|
-
// POST /api/v1/instances/auto is idempotent — returns existing or creates free Brain
|
|
4627
|
-
let autoProvisioned = false;
|
|
4628
|
-
try {
|
|
4629
|
-
const autoRes = await fetch(`${API_URL}/api/v1/instances/auto`, {
|
|
4630
|
-
method: 'POST',
|
|
4631
|
-
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
4632
|
-
});
|
|
4633
|
-
if (autoRes.ok) autoProvisioned = true;
|
|
4634
|
-
} catch { /* non-fatal */ }
|
|
4635
|
-
|
|
4636
6360
|
// ── Step 2: Fetch & pick instance ─────────────────────────────────────────
|
|
4637
6361
|
process.stdout.write('⏳ Fetching your instances...');
|
|
4638
6362
|
let instances: Array<{ id: string; name: string; status: string; tier: string; region: string }> = [];
|
|
@@ -4644,31 +6368,62 @@ if (process.argv[2] === 'setup') {
|
|
|
4644
6368
|
const body = await res.json() as { data: typeof instances };
|
|
4645
6369
|
instances = (body.data ?? []).filter(i => i.status === 'running');
|
|
4646
6370
|
} catch (e) {
|
|
4647
|
-
|
|
4648
|
-
console.error(
|
|
6371
|
+
const msg = (e as Error).message;
|
|
6372
|
+
console.error(`\n\nFailed to fetch instances: ${msg}`);
|
|
6373
|
+
if (msg.includes('401')) {
|
|
6374
|
+
console.error('Token rejected. Get a valid token at https://cachly.dev/setup-ai\n');
|
|
6375
|
+
try {
|
|
6376
|
+
const { execSync } = await import('node:child_process');
|
|
6377
|
+
const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
6378
|
+
execSync(`${openCmd} https://cachly.dev/setup-ai`, { stdio: 'ignore' });
|
|
6379
|
+
} catch { /* ignore */ }
|
|
6380
|
+
}
|
|
4649
6381
|
rl.close(); process.exit(1);
|
|
4650
6382
|
}
|
|
4651
6383
|
console.log(` found ${instances.length}\n`);
|
|
4652
6384
|
|
|
4653
6385
|
if (instances.length === 0) {
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
|
|
4659
|
-
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
6386
|
+
// Auto-provision a free Brain instance so users don't have to visit the website.
|
|
6387
|
+
process.stdout.write('⏳ Creating your free Brain instance...');
|
|
6388
|
+
try {
|
|
6389
|
+
const autoRes = await fetch(`${API_URL}/api/v1/instances/auto`, {
|
|
6390
|
+
method: 'POST',
|
|
6391
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
6392
|
+
});
|
|
6393
|
+
if (!autoRes.ok) throw new Error(`HTTP ${autoRes.status}`);
|
|
6394
|
+
const autoBody = await autoRes.json() as { instance?: { id: string; name: string; status: string; tier: string; region: string }; instance_id?: string; status?: string; created?: boolean };
|
|
6395
|
+
if (autoBody.instance) {
|
|
6396
|
+
// Returned an existing instance.
|
|
6397
|
+
instances = [autoBody.instance];
|
|
6398
|
+
} else if (autoBody.instance_id) {
|
|
6399
|
+
// Newly created — poll until running or give up after 30 s.
|
|
6400
|
+
const newId = autoBody.instance_id;
|
|
6401
|
+
console.log(` ✓ created (${newId.slice(0, 8)}…)\n`);
|
|
6402
|
+
process.stdout.write('⏳ Waiting for instance to start');
|
|
6403
|
+
for (let attempt = 0; attempt < 15; attempt++) {
|
|
6404
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
6405
|
+
process.stdout.write('.');
|
|
6406
|
+
try {
|
|
6407
|
+
const checkRes = await fetch(`${API_URL}/api/v1/instances/${newId}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
6408
|
+
if (checkRes.ok) {
|
|
6409
|
+
const inst = await checkRes.json() as { id: string; name: string; status: string; tier: string; region: string };
|
|
6410
|
+
if (inst.status === 'running') { instances = [inst]; break; }
|
|
6411
|
+
}
|
|
6412
|
+
} catch { /* keep polling */ }
|
|
6413
|
+
}
|
|
6414
|
+
console.log('');
|
|
4667
6415
|
}
|
|
4668
|
-
|
|
6416
|
+
} catch (e) {
|
|
6417
|
+
console.log(` failed: ${(e as Error).message}\n`);
|
|
4669
6418
|
}
|
|
6419
|
+
|
|
4670
6420
|
if (instances.length === 0) {
|
|
4671
|
-
console.error('\
|
|
6421
|
+
console.error('\nCould not create an instance automatically. Opening https://cachly.dev/instances …\n');
|
|
6422
|
+
try {
|
|
6423
|
+
const { execSync } = await import('node:child_process');
|
|
6424
|
+
const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
6425
|
+
execSync(`${openCmd} https://cachly.dev/instances`, { stdio: 'ignore' });
|
|
6426
|
+
} catch { /* ignore */ }
|
|
4672
6427
|
rl.close(); process.exit(1);
|
|
4673
6428
|
}
|
|
4674
6429
|
}
|
|
@@ -4688,15 +6443,28 @@ if (process.argv[2] === 'setup') {
|
|
|
4688
6443
|
// ── Step 3: Detect editors ────────────────────────────────────────────────
|
|
4689
6444
|
const cwd = process.cwd();
|
|
4690
6445
|
const detected: string[] = [];
|
|
6446
|
+
const { homedir } = await import('node:os');
|
|
6447
|
+
const home = homedir();
|
|
4691
6448
|
// Claude Code: always include (CLAUDE.md is universal)
|
|
4692
6449
|
detected.push('claude');
|
|
4693
6450
|
if (existsSync(resolve(cwd, '.cursor'))) detected.push('cursor');
|
|
4694
6451
|
if (existsSync(resolve(cwd, '.windsurf'))) detected.push('windsurf');
|
|
4695
6452
|
if (existsSync(resolve(cwd, '.vscode'))) detected.push('copilot');
|
|
4696
6453
|
if (existsSync(resolve(cwd, '.continue'))) detected.push('continue');
|
|
6454
|
+
// Cline: VS Code extension — detect via globalStorage on the machine
|
|
6455
|
+
const clineGlobalDir = process.platform === 'darwin'
|
|
6456
|
+
? resolve(home, 'Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev')
|
|
6457
|
+
: resolve(home, '.vscode/extensions');
|
|
6458
|
+
if (existsSync(clineGlobalDir) && !detected.includes('copilot')) detected.push('cline');
|
|
6459
|
+
else if (existsSync(clineGlobalDir)) detected.push('cline'); // both copilot + cline share .vscode/mcp.json — fine
|
|
6460
|
+
// Zed editor — detect via app data dir
|
|
6461
|
+
const zedDir = process.platform === 'darwin'
|
|
6462
|
+
? resolve(home, 'Library/Application Support/Zed')
|
|
6463
|
+
: resolve(home, '.config/zed');
|
|
6464
|
+
if (existsSync(zedDir)) detected.push('zed');
|
|
4697
6465
|
|
|
4698
6466
|
const editorLabel = (e: string) =>
|
|
4699
|
-
({ claude: 'Claude Code', cursor: 'Cursor', windsurf: 'Windsurf', copilot: 'GitHub Copilot', continue: 'Continue.dev' })[e] ?? e;
|
|
6467
|
+
({ claude: 'Claude Code', cursor: 'Cursor', windsurf: 'Windsurf', copilot: 'GitHub Copilot', continue: 'Continue.dev', cline: 'Cline (VSCode)', zed: 'Zed' })[e] ?? e;
|
|
4700
6468
|
|
|
4701
6469
|
console.log(`Step 3: Detected editors: ${detected.map(editorLabel).join(', ')}`);
|
|
4702
6470
|
const editorsRaw = await ask(` Configure for which? [all / ${detected.join('/')}] (Enter = all): `);
|
|
@@ -4705,7 +6473,21 @@ if (process.argv[2] === 'setup') {
|
|
|
4705
6473
|
: editorsRaw.split(/[,\s]+/).map(s => s.toLowerCase()).filter(s => EDITOR_FILES[s]);
|
|
4706
6474
|
console.log('');
|
|
4707
6475
|
|
|
4708
|
-
// ── Step 4:
|
|
6476
|
+
// ── Step 4: Email opt-in ──────────────────────────────────────────────────
|
|
6477
|
+
console.log('Step 4: Stay in the loop (optional)');
|
|
6478
|
+
const email = await ask(' Email for release notes & tips? [Enter to skip]: ');
|
|
6479
|
+
if (email && email.includes('@')) {
|
|
6480
|
+
try {
|
|
6481
|
+
await fetch(`${API_URL}/api/newsletter/subscribe`, {
|
|
6482
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
6483
|
+
body: JSON.stringify({ email: email.trim().toLowerCase(), source: 'mcp-setup' }),
|
|
6484
|
+
signal: AbortSignal.timeout(5000),
|
|
6485
|
+
});
|
|
6486
|
+
console.log('✅ Subscribed — you\'ll only hear from us when it matters.\n');
|
|
6487
|
+
} catch { console.log('⚠️ Could not subscribe (no internet?). Continuing...\n'); }
|
|
6488
|
+
} else { console.log(' Skipped.\n'); }
|
|
6489
|
+
|
|
6490
|
+
// ── Step 5: Write editor configs ──────────────────────────────────────────
|
|
4709
6491
|
for (const editor of editorsToSetup) {
|
|
4710
6492
|
const configFile = EDITOR_FILES[editor] ?? '.mcp.json';
|
|
4711
6493
|
const configPath = resolve(cwd, configFile);
|
|
@@ -4714,7 +6496,7 @@ if (process.argv[2] === 'setup') {
|
|
|
4714
6496
|
console.log(`✅ Written: ${configFile}`);
|
|
4715
6497
|
}
|
|
4716
6498
|
|
|
4717
|
-
// ── Step
|
|
6499
|
+
// ── Step 6: CLAUDE.md (always — idempotent) ───────────────────────────────
|
|
4718
6500
|
const mdResult = await writeClaudeMd(cwd, instance.id);
|
|
4719
6501
|
const mdLabel = mdResult === 'updated' ? '✅ Updated' : mdResult === 'appended' ? '✅ Appended to' : '✅ Written';
|
|
4720
6502
|
console.log(`${mdLabel}: CLAUDE.md`);
|
|
@@ -4722,11 +6504,172 @@ if (process.argv[2] === 'setup') {
|
|
|
4722
6504
|
rl.close();
|
|
4723
6505
|
console.log(`\n🧠 Done! Restart your editor — the \`cachly\` MCP tools will appear.`);
|
|
4724
6506
|
console.log(` Your AI now has persistent memory across every session.\n`);
|
|
6507
|
+
|
|
6508
|
+
// ── Step 7: Show first session_start preview (Aha moment) ────────────────
|
|
6509
|
+
// Fetch brain health from the API to show the user what their agent will see.
|
|
6510
|
+
process.stdout.write('\n⏳ Fetching your Brain health preview...');
|
|
6511
|
+
try {
|
|
6512
|
+
const brainRes = await fetch(`${API_URL}/api/v1/instances/${instance.id}/brain/stats`, {
|
|
6513
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
6514
|
+
signal: AbortSignal.timeout(6000),
|
|
6515
|
+
});
|
|
6516
|
+
if (brainRes.ok) {
|
|
6517
|
+
const brainData = await brainRes.json() as {
|
|
6518
|
+
lesson_count?: number; context_count?: number;
|
|
6519
|
+
open_failures?: number; quality_score?: number;
|
|
6520
|
+
};
|
|
6521
|
+
const lessons = brainData.lesson_count ?? 0;
|
|
6522
|
+
const contexts = brainData.context_count ?? 0;
|
|
6523
|
+
const score = brainData.quality_score ?? 0;
|
|
6524
|
+
const level = lessons === 0 ? 'Intern 🌱' :
|
|
6525
|
+
lessons < 10 ? 'Junior Dev 🔧' :
|
|
6526
|
+
lessons < 30 ? 'Mid Dev ⚡' :
|
|
6527
|
+
lessons < 60 ? 'Senior Dev 🧠' :
|
|
6528
|
+
lessons < 100 ? 'Staff Eng 🚀' : 'Principal Eng 🏆';
|
|
6529
|
+
console.log(' ✓\n');
|
|
6530
|
+
console.log('┌──────────────────────────────────────────────────────┐');
|
|
6531
|
+
console.log(`│ 🧠 Brain Health Report │`);
|
|
6532
|
+
console.log('├──────────────────────────────────────────────────────┤');
|
|
6533
|
+
console.log(`│ Lessons stored : ${String(lessons).padEnd(6)} │ Level: ${level.padEnd(20)}│`);
|
|
6534
|
+
console.log(`│ Context entries : ${String(contexts).padEnd(6)} │ Quality score: ${String(Math.round(score * 100)).padEnd(3)}% │`);
|
|
6535
|
+
if (lessons === 0) {
|
|
6536
|
+
console.log('├──────────────────────────────────────────────────────┤');
|
|
6537
|
+
console.log('│ Your Brain is empty and ready to learn. │');
|
|
6538
|
+
console.log('│ After your first coding session it will contain: │');
|
|
6539
|
+
console.log('│ • Lessons from every bug you fixed │');
|
|
6540
|
+
console.log('│ • Your project\'s indexed files │');
|
|
6541
|
+
console.log('│ • A session summary for next time │');
|
|
6542
|
+
}
|
|
6543
|
+
console.log('└──────────────────────────────────────────────────────┘');
|
|
6544
|
+
} else {
|
|
6545
|
+
console.log(' (skipped)');
|
|
6546
|
+
}
|
|
6547
|
+
} catch { console.log(' (skipped)'); }
|
|
6548
|
+
|
|
6549
|
+
console.log(`\n Your AI will call session_start automatically when it uses the first tool.`);
|
|
6550
|
+
console.log(` To see your Brain at any time: https://cachly.dev/instances/${instance.id}\n`);
|
|
6551
|
+
process.exit(0);
|
|
6552
|
+
}
|
|
6553
|
+
|
|
6554
|
+
// ── CLI: cachly index <dir> ───────────────────────────────────────────────────
|
|
6555
|
+
// Usage: npx @cachly-dev/mcp-server@latest index [./path/to/project]
|
|
6556
|
+
// Indexes the project directory into the Brain — perfect for CI/CD cron jobs.
|
|
6557
|
+
|
|
6558
|
+
if (process.argv[2] === 'index') {
|
|
6559
|
+
const { resolve } = await import('node:path');
|
|
6560
|
+
const argv = process.argv.slice(3);
|
|
6561
|
+
const flag = (name: string) => { const i = argv.indexOf(`--${name}`); return i !== -1 ? argv[i + 1] : undefined; };
|
|
6562
|
+
|
|
6563
|
+
const dir = resolve(flag('dir') ?? argv.find(a => !a.startsWith('--')) ?? '.');
|
|
6564
|
+
const instanceId = flag('instance-id') ?? process.env.CACHLY_BRAIN_INSTANCE_ID;
|
|
6565
|
+
const maxFiles = parseInt(flag('max-files') ?? '500', 10);
|
|
6566
|
+
const namespace = flag('namespace') ?? 'cachly:sem:code';
|
|
6567
|
+
|
|
6568
|
+
if (!instanceId || !JWT) {
|
|
6569
|
+
console.error('\n❌ CACHLY_BRAIN_INSTANCE_ID and CACHLY_JWT must be set\n');
|
|
6570
|
+
console.error(' export CACHLY_BRAIN_INSTANCE_ID=<uuid>');
|
|
6571
|
+
console.error(' export CACHLY_JWT=<cky_live_...>');
|
|
6572
|
+
console.error(' npx @cachly-dev/mcp-server@latest index ./my-project\n');
|
|
6573
|
+
process.exit(1);
|
|
6574
|
+
}
|
|
6575
|
+
|
|
6576
|
+
console.log(`\n📂 Indexing: ${dir}`);
|
|
6577
|
+
console.log(` Instance: ${instanceId.slice(0, 8)}… Max files: ${maxFiles}\n`);
|
|
6578
|
+
|
|
6579
|
+
try {
|
|
6580
|
+
const result = await handleTool('index_project', {
|
|
6581
|
+
instance_id: instanceId,
|
|
6582
|
+
dir,
|
|
6583
|
+
max_files: maxFiles,
|
|
6584
|
+
ttl: 86400 * 7,
|
|
6585
|
+
namespace,
|
|
6586
|
+
});
|
|
6587
|
+
console.log(result);
|
|
6588
|
+
console.log('\n✅ Indexing complete.\n');
|
|
6589
|
+
} catch (err) {
|
|
6590
|
+
console.error(`\n❌ Indexing failed: ${(err as Error).message}\n`);
|
|
6591
|
+
process.exit(1);
|
|
6592
|
+
}
|
|
4725
6593
|
process.exit(0);
|
|
4726
6594
|
}
|
|
4727
6595
|
|
|
4728
6596
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
4729
6597
|
|
|
4730
|
-
|
|
4731
|
-
|
|
6598
|
+
// Warn on stderr when credentials are missing so the user sees a clear
|
|
6599
|
+
// actionable message in their editor's MCP log instead of silent failures.
|
|
6600
|
+
if (!JWT) {
|
|
6601
|
+
process.stderr.write(
|
|
6602
|
+
'\n' +
|
|
6603
|
+
'╔══════════════════════════════════════════════════════════════════╗\n' +
|
|
6604
|
+
'║ 🧠 cachly AI Brain — Setup required ║\n' +
|
|
6605
|
+
'╠══════════════════════════════════════════════════════════════════╣\n' +
|
|
6606
|
+
'║ ║\n' +
|
|
6607
|
+
'║ CACHLY_JWT is not set. Get your free credentials at: ║\n' +
|
|
6608
|
+
'║ ║\n' +
|
|
6609
|
+
'║ 👉 https://cachly.dev/setup-ai ║\n' +
|
|
6610
|
+
'║ ║\n' +
|
|
6611
|
+
'║ Then run the interactive setup wizard: ║\n' +
|
|
6612
|
+
'║ ║\n' +
|
|
6613
|
+
'║ npx @cachly-dev/mcp-server@latest setup ║\n' +
|
|
6614
|
+
'║ ║\n' +
|
|
6615
|
+
'║ Free tier — no credit card required. ║\n' +
|
|
6616
|
+
'╚══════════════════════════════════════════════════════════════════╝\n' +
|
|
6617
|
+
'\n',
|
|
6618
|
+
);
|
|
6619
|
+
}
|
|
6620
|
+
|
|
6621
|
+
// ── Update nudge (non-blocking, fire-and-forget) ─────────────────────────────
|
|
6622
|
+
// Check npm registry once per process start; if outdated, log to stderr so
|
|
6623
|
+
// the editor's MCP log shows an actionable one-liner. Skipped if opted out.
|
|
6624
|
+
if (!process.env.CACHLY_NO_UPDATE_CHECK) {
|
|
6625
|
+
(async () => {
|
|
6626
|
+
try {
|
|
6627
|
+
const res = await fetch(
|
|
6628
|
+
`https://registry.npmjs.org/@cachly-dev/mcp-server/latest`,
|
|
6629
|
+
{ signal: AbortSignal.timeout(4000) },
|
|
6630
|
+
);
|
|
6631
|
+
if (res.ok) {
|
|
6632
|
+
const data = await res.json() as { version: string };
|
|
6633
|
+
const latest = data?.version ?? '';
|
|
6634
|
+
if (latest && latest !== CURRENT_VERSION) {
|
|
6635
|
+
process.stderr.write(
|
|
6636
|
+
`\n⚡ cachly update available: ${CURRENT_VERSION} → ${latest}\n` +
|
|
6637
|
+
` Run: npx @cachly-dev/mcp-server@latest setup\n\n`,
|
|
6638
|
+
);
|
|
6639
|
+
}
|
|
6640
|
+
}
|
|
6641
|
+
} catch { /* ignore – network unavailable or timeout */ }
|
|
6642
|
+
})();
|
|
6643
|
+
}
|
|
6644
|
+
|
|
6645
|
+
const httpPort = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined;
|
|
6646
|
+
|
|
6647
|
+
if (httpPort) {
|
|
6648
|
+
// ── HTTP mode (Streamable HTTP transport) ───────────────────────────────
|
|
6649
|
+
// Used for Smithery URL deployment: PORT=3000 node dist/index.js
|
|
6650
|
+
const { createServer } = await import('node:http');
|
|
6651
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
6652
|
+
await server.connect(transport);
|
|
6653
|
+
const httpServer = createServer((req, res) => {
|
|
6654
|
+
const url = new URL(req.url ?? '/', `http://localhost:${httpPort}`);
|
|
6655
|
+
if (url.pathname === '/health') {
|
|
6656
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
6657
|
+
res.end(JSON.stringify({ status: 'ok', version: CURRENT_VERSION }));
|
|
6658
|
+
return;
|
|
6659
|
+
}
|
|
6660
|
+
if (url.pathname === '/mcp' || url.pathname === '/') {
|
|
6661
|
+
transport.handleRequest(req, res);
|
|
6662
|
+
return;
|
|
6663
|
+
}
|
|
6664
|
+
res.writeHead(404);
|
|
6665
|
+
res.end('not found');
|
|
6666
|
+
});
|
|
6667
|
+
httpServer.listen(httpPort, () => {
|
|
6668
|
+
process.stderr.write(`cachly-mcp HTTP server listening on :${httpPort}\n`);
|
|
6669
|
+
});
|
|
6670
|
+
} else {
|
|
6671
|
+
// ── stdio mode (default for local editor use) ───────────────────────────
|
|
6672
|
+
const transport = new StdioServerTransport();
|
|
6673
|
+
await server.connect(transport);
|
|
6674
|
+
}
|
|
4732
6675
|
|