@cachly-dev/mcp-server 0.9.6 → 0.9.8

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/glama.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "$schema": "https://glama.ai/mcp/schemas/server.json",
3
+ "maintainers": [
4
+ "HeinrichNebula"
5
+ ]
6
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@cachly-dev/mcp-server",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
4
4
  "mcpName": "io.github.cachly-dev/cachly-mcp",
5
- "description": "Persistent AI memory for Claude Code, Cursor, Copilot & Windsurf — 63 MCP tools. Causal Knowledge Graph, predictive failure detection, continuous learning from git commits + CI, federated knowledge sharing. Your AI learns from every bug fix and never re-researches the same problem.",
5
+ "description": "Your AI forgets everything between sessions. cachly fixes that permanently. A causal brain that learns WHY things break — built from every bug fix, git commit, and CI run, without any explicit calls. Arrives pre-briefed each session. Predicts failures before they happen. Never re-researches the same problem. 63 MCP tools · Claude Code · Cursor · GitHub Copilot · Windsurf · Free tier · EU servers.",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "cachly-mcp": "dist/index.js"
package/server.json CHANGED
@@ -1,18 +1,18 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.cachly-dev/cachly-mcp",
4
- "description": "Persistent AI memory for Claude Code, Cursor, Copilot & Windsurf. Your AI learns from every bug fix and never re-researches the same problem. 63 MCP tools — Causal Knowledge Graph, predictive failure detection, continuous learning from git commits, federated knowledge sharing.",
4
+ "description": "Your AI forgets everything between sessions. cachly fixes that permanently. A causal brain that learns WHY things break — built from every bug fix, git commit, and CI run, without any explicit calls. Arrives pre-briefed each session. Predicts failures before they happen. Never re-researches the same problem. 67 MCP tools · Free tier · EU servers.",
5
5
  "repository": {
6
6
  "url": "https://github.com/cachly-dev/cachly-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.9.6",
9
+ "version": "0.9.7",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "name": "@cachly-dev/mcp-server",
14
14
  "identifier": "@cachly-dev/mcp-server",
15
- "version": "0.9.6",
15
+ "version": "0.9.7",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
package/src/index.ts CHANGED
@@ -55,7 +55,7 @@ import { Redis } from 'ioredis';
55
55
  const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
56
56
  let JWT = process.env.CACHLY_JWT ?? '';
57
57
  const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
58
- const CURRENT_VERSION = '0.9.6';
58
+ const CURRENT_VERSION = '0.9.8';
59
59
 
60
60
  // ── Default Instance Resolution (for Smithery & single-credential setups) ────
61
61
  // When CACHLY_BRAIN_INSTANCE_ID is set, tools can omit the instance_id parameter.
@@ -1960,18 +1960,20 @@ async function keywordSearch(
1960
1960
  query: string,
1961
1961
  topK = 10,
1962
1962
  ): Promise<KeywordMatch[]> {
1963
- // ── Step 1: Collect and tokenize all documents ──
1964
- const allKeys: string[] = [];
1965
- for (const pattern of patterns) {
1966
- const stream = redis.scanStream({ match: pattern, count: 200 });
1967
- await new Promise<void>((resolve, reject) => {
1963
+ // ── Step 1: Collect all matching keys — patterns scanned in parallel ──
1964
+ const keyBatches = await Promise.all(patterns.map(pattern => {
1965
+ const keys: string[] = [];
1966
+ const stream = redis.scanStream({ match: pattern, count: 500 });
1967
+ return new Promise<string[]>((resolve, reject) => {
1968
1968
  stream.on('data', (batch: string[]) => {
1969
- allKeys.push(...batch.filter((k: string) => !k.endsWith(':meta')));
1969
+ keys.push(...batch.filter((k: string) => !k.endsWith(':meta')));
1970
1970
  });
1971
- stream.on('end', resolve);
1971
+ stream.on('end', () => resolve(keys));
1972
1972
  stream.on('error', reject);
1973
1973
  });
1974
- }
1974
+ }));
1975
+ // Deduplicate across patterns (a key may match multiple globs)
1976
+ const allKeys = [...new Set(keyBatches.flat())];
1975
1977
 
1976
1978
  if (allKeys.length === 0) return [];
1977
1979
 
@@ -2285,15 +2287,37 @@ async function getConnection(instance_id: string): Promise<Redis> {
2285
2287
 
2286
2288
  // ── API helper ────────────────────────────────────────────────────────────────
2287
2289
 
2290
+ function jwtExpiryMs(token: string): number | null {
2291
+ try {
2292
+ const parts = token.split('.');
2293
+ if (parts.length !== 3) return null;
2294
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')) as { exp?: number };
2295
+ return payload.exp ? payload.exp * 1000 : null;
2296
+ } catch { return null; }
2297
+ }
2298
+
2288
2299
  async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
2289
2300
  if (!JWT) {
2290
2301
  throw new McpError(
2291
2302
  ErrorCode.InvalidRequest,
2292
- 'CACHLY_JWT env var not set. Get your API token from https://cachly.dev/settings'
2303
+ 'CACHLY_JWT env var not set.\n\nGet your token at https://cachly.dev/setup-ai and add it to your MCP config:\n CACHLY_JWT=<your-token>'
2293
2304
  );
2294
2305
  }
2306
+
2307
+ // Detect token expiry before making the network call — gives a clearer error
2308
+ const expMs = jwtExpiryMs(JWT);
2309
+ if (expMs !== null && expMs < Date.now()) {
2310
+ const expiredAt = new Date(expMs).toISOString();
2311
+ throw new McpError(
2312
+ ErrorCode.InvalidRequest,
2313
+ `CACHLY_JWT expired at ${expiredAt}.\n\nGet a fresh token at https://cachly.dev/setup-ai and update CACHLY_JWT in your MCP config.`
2314
+ );
2315
+ }
2316
+
2317
+ const timeoutMs = Number(process.env.CACHLY_API_TIMEOUT_MS ?? 15_000);
2295
2318
  const res = await fetch(`${API_URL}${path}`, {
2296
2319
  ...options,
2320
+ signal: AbortSignal.timeout(timeoutMs),
2297
2321
  headers: {
2298
2322
  'Content-Type': 'application/json',
2299
2323
  Authorization: `Bearer ${JWT}`,
@@ -2302,9 +2326,22 @@ async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T>
2302
2326
  });
2303
2327
  if (!res.ok) {
2304
2328
  const body = await res.json().catch(() => ({ error: res.statusText }));
2329
+ const detail = (body as { error?: string }).error ?? res.statusText;
2330
+ if (res.status === 401) {
2331
+ throw new McpError(
2332
+ ErrorCode.InvalidRequest,
2333
+ `Authentication failed (401): ${detail}\n\nYour CACHLY_JWT may be expired or invalid. Get a fresh token at https://cachly.dev/setup-ai`
2334
+ );
2335
+ }
2336
+ if (res.status === 403) {
2337
+ throw new McpError(
2338
+ ErrorCode.InvalidRequest,
2339
+ `Access denied (403): ${detail}\n\nCheck that your CACHLY_JWT belongs to an account with access to this resource.`
2340
+ );
2341
+ }
2305
2342
  throw new McpError(
2306
2343
  ErrorCode.InternalError,
2307
- `cachly API error ${res.status}: ${(body as { error?: string }).error ?? res.statusText}`
2344
+ `cachly API error ${res.status}: ${detail}`
2308
2345
  );
2309
2346
  }
2310
2347
  if (res.status === 204) return undefined as T;
@@ -3919,6 +3956,72 @@ const TOOLS = [
3919
3956
  required: ['instance_id'],
3920
3957
  },
3921
3958
  },
3959
+ {
3960
+ name: 'crystal_view',
3961
+ description:
3962
+ 'Inspect the current Memory Crystal — the compressed wisdom distilled from all past sessions. ' +
3963
+ 'Shows top patterns per category, lesson count, and when the crystal was last refreshed. ' +
3964
+ 'Call after session_start when you want to quickly see accumulated wisdom across all past work.',
3965
+ inputSchema: {
3966
+ type: 'object',
3967
+ properties: {
3968
+ instance_id: { type: 'string', description: 'Brain instance ID' },
3969
+ show_raw: { type: 'boolean', description: 'Include raw JSON crystal data (default: false)' },
3970
+ },
3971
+ required: ['instance_id'],
3972
+ },
3973
+ },
3974
+ {
3975
+ name: 'compact_recover',
3976
+ description:
3977
+ 'Call FIRST after any context limit hit / compaction. Reconstructs full context from Memory Crystal + ' +
3978
+ 'recent sessions + WIP registry + open failures. Returns a condensed briefing so the new context ' +
3979
+ 'window starts exactly where the previous one left off — no lost progress.',
3980
+ inputSchema: {
3981
+ type: 'object',
3982
+ properties: {
3983
+ instance_id: { type: 'string', description: 'Brain instance ID' },
3984
+ focus: { type: 'string', description: 'What you were working on (helps filter relevant context)' },
3985
+ },
3986
+ required: ['instance_id'],
3987
+ },
3988
+ },
3989
+ {
3990
+ name: 'brain_from_git',
3991
+ description:
3992
+ 'Bootstrap brain lessons from git history. Parses commit messages and infers fix/feature/refactor ' +
3993
+ 'lessons automatically. Great for onboarding an existing codebase — run once and the brain instantly ' +
3994
+ 'knows your team\'s accumulated patterns. Supports limit and branch options.',
3995
+ inputSchema: {
3996
+ type: 'object',
3997
+ properties: {
3998
+ instance_id: { type: 'string', description: 'Brain instance ID' },
3999
+ repo_path: { type: 'string', description: 'Path to git repository (default: current directory)' },
4000
+ limit: { type: 'number', description: 'Max commits to process (default: 100, max: 500)' },
4001
+ branch: { type: 'string', description: 'Git branch to parse (default: current branch / HEAD)' },
4002
+ since: { type: 'string', description: 'Only commits after this date, e.g. "2024-01-01" (optional)' },
4003
+ },
4004
+ required: ['instance_id'],
4005
+ },
4006
+ },
4007
+ {
4008
+ name: 'brain_predict_failures',
4009
+ description:
4010
+ 'Pre-deploy failure prediction with probability percentages. Given a change context (e.g. ' +
4011
+ '"upgrading Keycloak 21→24" or "deploying Redis 7 to prod"), returns the top likely failure modes ' +
4012
+ 'ranked by probability, with pre-loaded fixes. Uses CKG causal edges + lesson history. ' +
4013
+ 'Call before any significant deploy, migration, or infrastructure change.',
4014
+ inputSchema: {
4015
+ type: 'object',
4016
+ properties: {
4017
+ instance_id: { type: 'string', description: 'Brain instance ID' },
4018
+ context: { type: 'string', description: 'What you are about to do, e.g. "upgrading Keycloak 21 to 24"' },
4019
+ top_k: { type: 'number', description: 'Number of failure predictions to return (default: 5)' },
4020
+ format: { type: 'string', enum: ['brief', 'detailed'], description: 'Output format (default: detailed)' },
4021
+ },
4022
+ required: ['instance_id', 'context'],
4023
+ },
4024
+ },
3922
4025
  ] as const;
3923
4026
 
3924
4027
  // ── Handlers ──────────────────────────────────────────────────────────────────
@@ -9176,6 +9279,404 @@ smart_recall(
9176
9279
  return lines.join('\n');
9177
9280
  }
9178
9281
 
9282
+ // ── crystal_view ──────────────────────────────────────────────────────────
9283
+ case 'crystal_view': {
9284
+ const { instance_id, show_raw = false } = args as { instance_id: string; show_raw?: boolean };
9285
+ const redis = await getConnection(instance_id);
9286
+
9287
+ const raw = await redis.get('cachly:crystal:latest');
9288
+ if (!raw) {
9289
+ return [
9290
+ `💎 **Memory Crystal: not yet created**`, '',
9291
+ `No crystal found. Create one with \`memory_crystalize()\` to compress your accumulated wisdom.`,
9292
+ '', `💡 Tip: run \`memory_crystalize\` monthly for best results.`,
9293
+ ].join('\n');
9294
+ }
9295
+
9296
+ type Crystal = { label: string; ts: string; session_count: number; lesson_count: number; top_patterns: Array<{ category: string; insight: string; count: number }>; categories: string[]; created_from: string };
9297
+ const crystal: Crystal = JSON.parse(raw);
9298
+ const age = Math.floor((Date.now() - new Date(crystal.ts).getTime()) / 86400000);
9299
+ const freshEmoji = age <= 7 ? '🟢' : age <= 30 ? '🟡' : '🔴';
9300
+
9301
+ const lines = [
9302
+ `💎 **Memory Crystal: ${crystal.label}**`, '',
9303
+ `📅 Created: ${new Date(crystal.ts).toLocaleDateString('de-DE')} (${age}d ago ${freshEmoji})`,
9304
+ `📊 Compressed from: ${crystal.created_from}`,
9305
+ `🗂️ Categories: ${crystal.categories.slice(0, 10).map(c => `\`${c}\``).join(', ')}${crystal.categories.length > 10 ? ` +${crystal.categories.length - 10} more` : ''}`,
9306
+ '',
9307
+ `**🔑 Top patterns (${crystal.top_patterns.length}):**`,
9308
+ ];
9309
+ for (const p of crystal.top_patterns) {
9310
+ lines.push(` • **${p.category}** (${p.count}×): ${p.insight.slice(0, 110)}`);
9311
+ }
9312
+ if (age > 30) {
9313
+ lines.push('', `⚠️ Crystal is ${age}d old — run \`memory_crystalize()\` to refresh it.`);
9314
+ }
9315
+ if (show_raw) {
9316
+ lines.push('', '```json', JSON.stringify(crystal, null, 2), '```');
9317
+ }
9318
+ lines.push('', `💡 Refresh: \`memory_crystalize()\` | Recover: \`compact_recover(instance_id="...")\``);
9319
+ return lines.join('\n');
9320
+ }
9321
+
9322
+ // ── compact_recover ───────────────────────────────────────────────────────
9323
+ case 'compact_recover': {
9324
+ const { instance_id, focus = '' } = args as { instance_id: string; focus?: string };
9325
+ const redis = await getConnection(instance_id);
9326
+
9327
+ const lines = [`🔁 **Compact Recovery Briefing**\n`];
9328
+ lines.push(`> *Call this first after any context limit hit. Reconstructs where you left off.*\n`);
9329
+
9330
+ // 1. Memory Crystal
9331
+ const crystalRaw = await redis.get('cachly:crystal:latest');
9332
+ if (crystalRaw) {
9333
+ type Crystal = { label: string; ts: string; session_count: number; lesson_count: number; top_patterns: Array<{ category: string; insight: string; count: number }> };
9334
+ const crystal: Crystal = JSON.parse(crystalRaw);
9335
+ lines.push(`### 💎 Memory Crystal: ${crystal.label}`);
9336
+ lines.push(`Compressed from ${crystal.session_count} sessions, ${crystal.lesson_count} lessons.`);
9337
+ const topN = focus
9338
+ ? crystal.top_patterns.filter(p => p.category.toLowerCase().includes(focus.toLowerCase()) || p.insight.toLowerCase().includes(focus.toLowerCase())).slice(0, 4)
9339
+ : crystal.top_patterns.slice(0, 4);
9340
+ for (const p of topN) lines.push(` • **${p.category}**: ${p.insight.slice(0, 100)}`);
9341
+ lines.push('');
9342
+ }
9343
+
9344
+ // 2. Last session summary
9345
+ const lastSession = await redis.get('cachly:session:last');
9346
+ if (lastSession) {
9347
+ type Session = { summary?: string; ts?: string; focus?: string };
9348
+ const sess: Session = JSON.parse(lastSession);
9349
+ lines.push(`### 🕐 Last Session`);
9350
+ if (sess.focus) lines.push(`Focus: _${sess.focus}_`);
9351
+ if (sess.summary) lines.push(`Summary: ${sess.summary.slice(0, 300)}`);
9352
+ lines.push('');
9353
+ }
9354
+
9355
+ // 3. Session handoff
9356
+ const handoff = await redis.get('cachly:session:handoff');
9357
+ if (handoff) {
9358
+ type Handoff = { remaining_tasks?: string[]; instructions?: string; context_summary?: string; blocked_on?: string };
9359
+ const h: Handoff = JSON.parse(handoff);
9360
+ lines.push(`### 📋 Handoff (from last window)`);
9361
+ if (h.context_summary) lines.push(`Context: ${h.context_summary.slice(0, 200)}`);
9362
+ if (h.remaining_tasks?.length) {
9363
+ lines.push(`Remaining tasks:`);
9364
+ for (const t of h.remaining_tasks.slice(0, 5)) lines.push(` • ${t}`);
9365
+ }
9366
+ if (h.instructions) lines.push(`⚠️ Instructions: ${h.instructions.slice(0, 200)}`);
9367
+ if (h.blocked_on) lines.push(`🚧 Blocked on: ${h.blocked_on}`);
9368
+ lines.push('');
9369
+ }
9370
+
9371
+ // 4. WIP registry
9372
+ const wipRaw = await redis.get('cachly:ctx:wip-registry');
9373
+ if (wipRaw) {
9374
+ type Ctx = { content?: string };
9375
+ const wip: Ctx = JSON.parse(wipRaw);
9376
+ if (wip.content) {
9377
+ lines.push(`### 🔧 WIP Registry`);
9378
+ lines.push(wip.content.slice(0, 400));
9379
+ lines.push('');
9380
+ }
9381
+ }
9382
+
9383
+ // 5. Open failures (roadmap with status=blocked/in_progress)
9384
+ const roadmapKeys: string[] = [];
9385
+ const rStream = redis.scanStream({ match: 'cachly:roadmap:*', count: 100 });
9386
+ await new Promise<void>((res, rej) => { rStream.on('data', (b: string[]) => roadmapKeys.push(...b)); rStream.on('end', res); rStream.on('error', rej); });
9387
+ const openItems: Array<{ title: string; status: string; priority?: string }> = [];
9388
+ for (const k of roadmapKeys.slice(0, 30)) {
9389
+ const r = await redis.get(k);
9390
+ if (!r) continue;
9391
+ try {
9392
+ const item = JSON.parse(r) as { title?: string; status?: string; priority?: string };
9393
+ if (item.status === 'in_progress' || item.status === 'blocked') openItems.push({ title: item.title ?? k, status: item.status, priority: item.priority });
9394
+ } catch { /* skip */ }
9395
+ }
9396
+ if (openItems.length > 0) {
9397
+ lines.push(`### 🚧 Open Items`);
9398
+ for (const i of openItems.slice(0, 5)) lines.push(` • [${i.status}] ${i.title}`);
9399
+ lines.push('');
9400
+ }
9401
+
9402
+ // 6. Focus-relevant lessons
9403
+ if (focus) {
9404
+ const lessonKeys: string[] = [];
9405
+ const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
9406
+ await new Promise<void>((res, rej) => { lStream.on('data', (b: string[]) => lessonKeys.push(...b)); lStream.on('end', res); lStream.on('error', rej); });
9407
+ const relevant: Array<{ topic: string; what_worked: string }> = [];
9408
+ for (const k of lessonKeys) {
9409
+ const r = await redis.get(k);
9410
+ if (!r) continue;
9411
+ try {
9412
+ const l = JSON.parse(r) as { topic: string; what_worked?: string };
9413
+ if (l.topic.toLowerCase().includes(focus.toLowerCase()) && l.what_worked) {
9414
+ relevant.push({ topic: l.topic, what_worked: l.what_worked });
9415
+ }
9416
+ } catch { /* skip */ }
9417
+ }
9418
+ if (relevant.length > 0) {
9419
+ lines.push(`### 💡 Relevant Lessons for "${focus}"`);
9420
+ for (const l of relevant.slice(0, 4)) lines.push(` • **${l.topic}**: ${l.what_worked.slice(0, 100)}`);
9421
+ lines.push('');
9422
+ }
9423
+ }
9424
+
9425
+ if (lines.length <= 3) {
9426
+ lines.push(`_No brain data found. Start accumulating knowledge with \`learn_from_attempts\` and \`session_start\`._`);
9427
+ }
9428
+ lines.push(`---`, `🧠 Brain is ready. Continue your work — full context restored.`);
9429
+ return lines.join('\n');
9430
+ }
9431
+
9432
+ // ── brain_from_git ────────────────────────────────────────────────────────
9433
+ case 'brain_from_git': {
9434
+ const { instance_id, repo_path = '.', limit = 100, branch = 'HEAD', since = '' } = args as {
9435
+ instance_id: string; repo_path?: string; limit?: number; branch?: string; since?: string;
9436
+ };
9437
+ const redis = await getConnection(instance_id);
9438
+ const { execSync } = await import('node:child_process');
9439
+ const { resolve } = await import('node:path');
9440
+
9441
+ const repoDir = resolve(repo_path);
9442
+ const maxCommits = Math.min(Number(limit) || 100, 500);
9443
+
9444
+ // Verify it's a git repo
9445
+ try {
9446
+ execSync('git rev-parse --git-dir', { cwd: repoDir, stdio: 'pipe' });
9447
+ } catch {
9448
+ return `❌ Not a git repository: \`${repoDir}\`. Pass \`repo_path\` pointing to a git checkout.`;
9449
+ }
9450
+
9451
+ // Build git log command
9452
+ const sinceFlag = since ? `--since="${since}"` : '';
9453
+ const logCmd = `git log ${branch} ${sinceFlag} --pretty=format:"%H|||%s|||%ad|||%an" --date=short --no-merges -n ${maxCommits}`;
9454
+
9455
+ let logOutput = '';
9456
+ try {
9457
+ logOutput = execSync(logCmd, { cwd: repoDir, encoding: 'utf-8', stdio: 'pipe' });
9458
+ } catch (e) {
9459
+ return `❌ git log failed: ${(e as Error).message}. Check \`repo_path\` and \`branch\`.`;
9460
+ }
9461
+
9462
+ const commits = logOutput.trim().split('\n').filter(Boolean).map(line => {
9463
+ const [sha, subject, date, author] = line.split('|||');
9464
+ return { sha: (sha ?? '').trim(), subject: (subject ?? '').trim(), date: (date ?? '').trim(), author: (author ?? '').trim() };
9465
+ });
9466
+
9467
+ if (commits.length === 0) {
9468
+ return `⚠️ No commits found in \`${repoDir}\` on branch \`${branch}\`${since ? ` since ${since}` : ''}.`;
9469
+ }
9470
+
9471
+ // Pattern classifiers
9472
+ const classifyCommit = (subject: string): { category: string; outcome: 'success' | 'failure' | 'partial'; severity: 'critical' | 'major' | 'minor' } => {
9473
+ const s = subject.toLowerCase();
9474
+ if (/\b(fix|fixed|fixes|bug|hotfix|patch|revert|resolve|closes? #\d+)\b/.test(s)) {
9475
+ const sev: 'critical' | 'major' | 'minor' = /\b(critical|crash|security|auth|data loss|outage|prod|production)\b/.test(s) ? 'critical' : /\b(major|breaking|regression|hotfix)\b/.test(s) ? 'major' : 'minor';
9476
+ return { category: 'fix', outcome: 'success', severity: sev };
9477
+ }
9478
+ if (/\b(feat|feature|add|added|implement|new|introduce)\b/.test(s)) return { category: 'feat', outcome: 'success', severity: 'minor' };
9479
+ if (/\b(refactor|clean|cleanup|improve|simplify|extract|rename)\b/.test(s)) return { category: 'refactor', outcome: 'success', severity: 'minor' };
9480
+ if (/\b(perf|optimize|speed|cache|latency|memory|performance)\b/.test(s)) return { category: 'perf', outcome: 'success', severity: 'major' };
9481
+ if (/\b(security|cve|auth|csrf|xss|sql|injection|sanitize|escape|encrypt)\b/.test(s)) return { category: 'security', outcome: 'success', severity: 'critical' };
9482
+ if (/\b(deploy|ci|cd|build|docker|k8s|helm|infra|devops)\b/.test(s)) return { category: 'deploy', outcome: 'success', severity: 'major' };
9483
+ if (/\b(test|spec|coverage|assert|mock|unit|integration)\b/.test(s)) return { category: 'test', outcome: 'success', severity: 'minor' };
9484
+ return { category: 'chore', outcome: 'success', severity: 'minor' };
9485
+ };
9486
+
9487
+ // Extract domain keywords from commit subject
9488
+ const extractDomain = (subject: string): string => {
9489
+ const s = subject.toLowerCase();
9490
+ const tokens = s.replace(/[^a-z0-9\s\-_]/g, ' ').split(/\s+/).filter(t => t.length > 3 && !['that', 'this', 'with', 'from', 'when', 'into', 'also', 'some', 'were'].includes(t));
9491
+ return tokens.slice(0, 3).join('-') || 'general';
9492
+ };
9493
+
9494
+ const ts = new Date().toISOString();
9495
+ let ingested = 0;
9496
+ let skipped = 0;
9497
+ const categoryCount = new Map<string, number>();
9498
+
9499
+ for (const commit of commits) {
9500
+ if (!commit.subject) { skipped++; continue; }
9501
+ const { category, outcome, severity } = classifyCommit(commit.subject);
9502
+ const domain = extractDomain(commit.subject);
9503
+ const topic = `${category}:${domain}`;
9504
+ categoryCount.set(category, (categoryCount.get(category) ?? 0) + 1);
9505
+
9506
+ const lessonObj = {
9507
+ topic, outcome, severity,
9508
+ what_worked: commit.subject.slice(0, 200),
9509
+ what_failed: '',
9510
+ context: `git:${commit.sha.slice(0, 8)} by ${commit.author} on ${commit.date}`,
9511
+ file_paths: [], commands: [`git show ${commit.sha.slice(0, 8)}`],
9512
+ tags: ['brain_from_git', category, 'git-history'],
9513
+ depends_on: [], recall_count: 0, ts, verified_at: ts,
9514
+ confidence: 0.55, // lower confidence for auto-inferred lessons
9515
+ audit_trail: [{ ts, action: 'brain_from_git', sha: commit.sha.slice(0, 8) }],
9516
+ version: 3,
9517
+ };
9518
+
9519
+ // Only store if no existing lesson for this topic (avoid overwriting higher-confidence lessons)
9520
+ const existing = await redis.get(`cachly:lesson:best:${topic}`);
9521
+ if (!existing) {
9522
+ await redis.set(`cachly:lesson:best:${topic}`, JSON.stringify(lessonObj));
9523
+ await redis.rpush(`cachly:lessons:${topic}`, JSON.stringify(lessonObj));
9524
+ }
9525
+ await redis.rpush('cachly:lessons:brain_from_git:all', JSON.stringify({ topic, sha: commit.sha.slice(0, 8), subject: commit.subject.slice(0, 60) }));
9526
+ await redis.ltrim('cachly:lessons:brain_from_git:all', -500, -1);
9527
+
9528
+ // Update CKG
9529
+ const conceptId = ckgSlug(topic);
9530
+ await ckgUpsertNode(redis, conceptId, category, 'git-derived');
9531
+ ingested++;
9532
+ }
9533
+
9534
+ const categoryBreakdown = [...categoryCount.entries()].sort((a, b) => b[1] - a[1]).map(([k, v]) => ` • **${k}** (${v})`).join('\n');
9535
+
9536
+ return [
9537
+ `🔁 **brain_from_git: ${repoDir}**`, '',
9538
+ `📂 Branch: \`${branch}\` | Processed: **${commits.length}** commits | Ingested: **${ingested}** lessons | Skipped: ${skipped}`,
9539
+ ``,
9540
+ `**Breakdown by category:**`,
9541
+ categoryBreakdown,
9542
+ ``,
9543
+ `💡 New lessons are stored with confidence 0.55 (auto-inferred).`,
9544
+ `💡 As you confirm them via \`learn_from_attempts\`, confidence rises automatically.`,
9545
+ `🔍 Explore: \`brain_search(query="fix")\` | \`ckg_inspect(concept="deploy")\``,
9546
+ ].join('\n');
9547
+ }
9548
+
9549
+ // ── brain_predict_failures ─────────────────────────────────────────────────
9550
+ case 'brain_predict_failures': {
9551
+ const { instance_id, context: ctx, top_k = 5, format = 'detailed' } = args as {
9552
+ instance_id: string; context: string; top_k?: number; format?: 'brief' | 'detailed';
9553
+ };
9554
+ const redis = await getConnection(instance_id);
9555
+
9556
+ const ctxTokens = ctx.toLowerCase().replace(/[^a-z0-9\s\-_:.]/g, ' ').split(/\s+/).filter(t => t.length > 2);
9557
+
9558
+ type FailurePred = { concept: string; failure: string; probability: number; fix?: string; topic?: string; source: 'ckg' | 'lesson' };
9559
+ const failures: FailurePred[] = [];
9560
+
9561
+ // Step 1: CKG — find 'causes' and 'degrades_under' edges from context tokens
9562
+ for (const token of ctxTokens.slice(0, 8)) {
9563
+ const nodeKeys: string[] = [];
9564
+ const nStream = redis.scanStream({ match: `cachly:ckg:node:*${token}*`, count: 50 });
9565
+ await new Promise<void>((res, rej) => { nStream.on('data', (b: string[]) => nodeKeys.push(...b)); nStream.on('end', res); nStream.on('error', rej); });
9566
+
9567
+ for (const nk of nodeKeys.slice(0, 5)) {
9568
+ const nodeRaw = await redis.get(nk);
9569
+ if (!nodeRaw) continue;
9570
+ const node: CKGNode = JSON.parse(nodeRaw);
9571
+ const edgeKeys = await redis.smembers(`cachly:ckg:idx:from:${node.id}`);
9572
+ for (const ek of edgeKeys.slice(0, 20)) {
9573
+ const edgeRaw = await redis.get(ek);
9574
+ if (!edgeRaw) continue;
9575
+ const edge: CKGEdge = JSON.parse(edgeRaw);
9576
+ if (edge.edgeType !== 'causes' && edge.edgeType !== 'degrades_under') continue;
9577
+
9578
+ // Look up fix for this failure from CKG 'fixes' edges
9579
+ const fixEdgeKeys = await redis.smembers(`cachly:ckg:idx:from:${edge.to}`);
9580
+ let fix: string | undefined;
9581
+ for (const fek of fixEdgeKeys.slice(0, 10)) {
9582
+ const feRaw = await redis.get(fek);
9583
+ if (!feRaw) continue;
9584
+ const fe: CKGEdge = JSON.parse(feRaw);
9585
+ if (fe.edgeType === 'fixes') {
9586
+ const lessonRaw = await redis.get(`cachly:lesson:best:${fe.from}`);
9587
+ if (lessonRaw) {
9588
+ const lesson = JSON.parse(lessonRaw) as { what_worked?: string };
9589
+ fix = lesson.what_worked?.slice(0, 120);
9590
+ break;
9591
+ }
9592
+ }
9593
+ }
9594
+
9595
+ failures.push({
9596
+ concept: node.id,
9597
+ failure: edge.to.replace(/-/g, ' '),
9598
+ probability: edge.confidence,
9599
+ fix,
9600
+ source: 'ckg',
9601
+ });
9602
+ }
9603
+ }
9604
+ }
9605
+
9606
+ // Step 2: Lesson history — find failure-outcome lessons matching context
9607
+ const lessonKeys: string[] = [];
9608
+ const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 300 });
9609
+ await new Promise<void>((res, rej) => { lStream.on('data', (b: string[]) => lessonKeys.push(...b)); lStream.on('end', res); lStream.on('error', rej); });
9610
+
9611
+ for (const k of lessonKeys.slice(0, 100)) {
9612
+ const r = await redis.get(k);
9613
+ if (!r) continue;
9614
+ try {
9615
+ const l = JSON.parse(r) as { topic: string; outcome: string; what_failed?: string; what_worked?: string; confidence?: number; severity?: string };
9616
+ if (l.outcome !== 'failure' && l.outcome !== 'partial') continue;
9617
+ if (!l.what_failed) continue;
9618
+ const haystack = `${l.topic} ${l.what_failed}`.toLowerCase();
9619
+ const matchScore = ctxTokens.filter(t => haystack.includes(t)).length / Math.max(1, ctxTokens.length);
9620
+ if (matchScore < 0.15) continue;
9621
+ const sevBoost = l.severity === 'critical' ? 0.15 : l.severity === 'major' ? 0.05 : 0;
9622
+ failures.push({
9623
+ concept: l.topic,
9624
+ failure: l.what_failed.slice(0, 80),
9625
+ probability: Math.min(0.97, (l.confidence ?? 0.5) * matchScore * 1.5 + sevBoost),
9626
+ fix: l.what_worked?.slice(0, 120),
9627
+ topic: l.topic,
9628
+ source: 'lesson',
9629
+ });
9630
+ } catch { /* skip */ }
9631
+ }
9632
+
9633
+ if (failures.length === 0) {
9634
+ return [
9635
+ `🔮 **Failure Prediction: "${ctx}"**`, '',
9636
+ `No known failure patterns found for this context.`,
9637
+ `💡 The brain learns from every \`learn_from_attempts(outcome="failure")\` call.`,
9638
+ `🔍 Try: \`brain_predict(context="${ctx}")\` for broader predictions.`,
9639
+ ].join('\n');
9640
+ }
9641
+
9642
+ // Deduplicate and rank by probability
9643
+ const seen = new Set<string>();
9644
+ const ranked = failures.filter(f => {
9645
+ const k = f.failure.slice(0, 40);
9646
+ if (seen.has(k)) return false;
9647
+ seen.add(k);
9648
+ return true;
9649
+ }).sort((a, b) => b.probability - a.probability).slice(0, Number(top_k));
9650
+
9651
+ const lines = [`🔮 **Failure Prediction for: "${ctx}"**\n`];
9652
+ lines.push(`> Pre-deploy failure analysis based on ${failures.length} patterns. Ranked by probability.\n`);
9653
+
9654
+ for (let i = 0; i < ranked.length; i++) {
9655
+ const f = ranked[i];
9656
+ const pct = Math.round(f.probability * 100);
9657
+ const bar = '█'.repeat(Math.round(pct / 10)) + '░'.repeat(10 - Math.round(pct / 10));
9658
+ const icon = pct >= 70 ? '🔴' : pct >= 40 ? '🟡' : '🟢';
9659
+ lines.push(`${icon} **${i + 1}. ${f.failure}**`);
9660
+ lines.push(` Probability: ${bar} **${pct}%** (${f.source === 'ckg' ? 'CKG causal edge' : 'lesson history'})`);
9661
+ if (format === 'detailed' && f.fix) {
9662
+ lines.push(` ✅ Pre-loaded fix: _${f.fix}_`);
9663
+ }
9664
+ if (format === 'detailed' && f.topic) {
9665
+ lines.push(` 📚 Lesson: \`${f.topic}\``);
9666
+ }
9667
+ lines.push('');
9668
+ }
9669
+
9670
+ const highRisk = ranked.filter(f => f.probability >= 0.6);
9671
+ if (highRisk.length > 0) {
9672
+ lines.push(`⚠️ **${highRisk.length} high-risk failure${highRisk.length > 1 ? 's' : ''} detected** (≥60% probability). Review fixes before proceeding.`);
9673
+ } else {
9674
+ lines.push(`✅ No high-risk failures detected. Proceed with caution and monitor closely.`);
9675
+ }
9676
+ lines.push('', `💡 After deploy: \`learn_from_attempts(topic="deploy:...", outcome="success|failure")\` to improve future predictions.`);
9677
+ return lines.join('\n');
9678
+ }
9679
+
9179
9680
  default:
9180
9681
  return null;
9181
9682
  }
@@ -9833,6 +10334,23 @@ if (!JWT) {
9833
10334
  '╚══════════════════════════════════════════════════════════════════╝\n' +
9834
10335
  '\n',
9835
10336
  );
10337
+ } else {
10338
+ // Warn if the JWT is already expired or expiring within the hour.
10339
+ const expMs = jwtExpiryMs(JWT);
10340
+ if (expMs !== null) {
10341
+ const minsLeft = Math.floor((expMs - Date.now()) / 60_000);
10342
+ if (minsLeft <= 0) {
10343
+ process.stderr.write(
10344
+ `\n⚠️ cachly: CACHLY_JWT expired ${Math.abs(minsLeft)} minute(s) ago.\n` +
10345
+ ` All tool calls will fail. Get a fresh token at https://cachly.dev/setup-ai\n\n`,
10346
+ );
10347
+ } else if (minsLeft < 60) {
10348
+ process.stderr.write(
10349
+ `\n⚠️ cachly: CACHLY_JWT expires in ${minsLeft} minute(s).\n` +
10350
+ ` Refresh it soon at https://cachly.dev/setup-ai to avoid interruptions.\n\n`,
10351
+ );
10352
+ }
10353
+ }
9836
10354
  }
9837
10355
 
9838
10356
  // ── Update nudge (non-blocking, fire-and-forget) ─────────────────────────────