@cachly-dev/mcp-server 0.9.0 → 0.9.2

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/dist/index.js CHANGED
@@ -47,7 +47,7 @@ import { Redis } from 'ioredis';
47
47
  const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
48
48
  const JWT = process.env.CACHLY_JWT ?? '';
49
49
  const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
50
- const CURRENT_VERSION = '0.8.3';
50
+ const CURRENT_VERSION = '0.9.2';
51
51
  /**
52
52
  * ┌──────────────────────────────────────────────────────────────────────┐
53
53
  * │ EMBEDDING PROVIDER — pluggable, client-side first │
@@ -2171,6 +2171,45 @@ async function apiFetch(path, options = {}) {
2171
2171
  return res.json();
2172
2172
  }
2173
2173
  import { detectNamespace } from './namespace.js';
2174
+ const STOPWORDS_CKG = new Set(['that', 'this', 'with', 'from', 'when', 'then', 'also', 'have', 'been', 'will', 'were', 'they', 'them', 'than', 'more', 'some', 'into', 'over', 'only', 'just', 'where', 'while', 'which', 'there', 'their', 'would', 'could', 'should', 'after', 'before', 'about']);
2175
+ function ckgSlug(text) {
2176
+ return text.toLowerCase().replace(/[^a-z0-9\-:]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 80);
2177
+ }
2178
+ /** Extract 1-3 significant keywords from free text for a problem concept */
2179
+ function extractProblemConcept(text) {
2180
+ const words = text.toLowerCase()
2181
+ .replace(/[^a-z0-9\s\-_]/g, ' ').split(/\s+/)
2182
+ .filter(w => w.length > 3 && !STOPWORDS_CKG.has(w))
2183
+ .slice(0, 5);
2184
+ if (words.length === 0)
2185
+ return null;
2186
+ return words.slice(0, 2).join('-');
2187
+ }
2188
+ async function ckgUpsertNode(redis, id, domain, type) {
2189
+ const key = `cachly:ckg:node:${id}`;
2190
+ const raw = await redis.get(key);
2191
+ const node = raw ? JSON.parse(raw) : { id, domain, type, count: 0, ts: new Date().toISOString() };
2192
+ node.count = (node.count || 0) + 1;
2193
+ node.ts = new Date().toISOString();
2194
+ await redis.set(key, JSON.stringify(node));
2195
+ }
2196
+ async function ckgUpdateEdge(redis, from, edgeType, to, success, partial = false) {
2197
+ const key = `cachly:ckg:edge:${from}:${edgeType}:${to}`;
2198
+ const raw = await redis.get(key);
2199
+ const edge = raw ? JSON.parse(raw) : { from, to, edgeType, successes: 0, trials: 0, confidence: 0, last_updated: '' };
2200
+ edge.trials = (edge.trials || 0) + 1;
2201
+ if (success)
2202
+ edge.successes = (edge.successes || 0) + 1;
2203
+ else if (partial)
2204
+ edge.successes = (edge.successes || 0) + 0.5;
2205
+ // Beta distribution smoothed confidence: (successes+1) / (trials+2)
2206
+ edge.confidence = (edge.successes + 1) / (edge.trials + 2);
2207
+ edge.last_updated = new Date().toISOString();
2208
+ await redis.set(key, JSON.stringify(edge));
2209
+ // Index: set of edge keys per source node (for fast traversal)
2210
+ await redis.sadd(`cachly:ckg:idx:from:${from}`, key);
2211
+ await redis.sadd(`cachly:ckg:idx:to:${to}`, key);
2212
+ }
2174
2213
  // ── Tools ─────────────────────────────────────────────────────────────────────
2175
2214
  const TOOLS = [
2176
2215
  // ── Instance Management ──────────────────────────────────────────────────
@@ -3465,58 +3504,50 @@ const TOOLS = [
3465
3504
  required: [],
3466
3505
  },
3467
3506
  },
3468
- // ── v0.9 — compact_recover ─────────────────────────────────────────────────
3507
+ // ── Layer 1: Causal Knowledge Graph ────────────────────────────────────────
3469
3508
  {
3470
- name: 'compact_recover',
3471
- description: 'FAST context recovery after Claude/Cursor/Copilot context window compaction. ' +
3472
- 'Call this as the FIRST tool call after any context limit hit or window restart — before reading any files. ' +
3473
- 'Returns: last checkpoint (task + files + next_step), session handoff if any, last 5 recalled lessons, open TODOs. ' +
3474
- 'Unlike session_start, this is instant — no full brain scan, no streak logic, just the critical recovery data. ' +
3475
- 'Typical use in CLAUDE.md: "If context is compacted, call compact_recover immediately."',
3509
+ name: 'brain_search',
3510
+ description: 'BM25+ full-text search over ALL brain data: lessons, context entries, session history, CKG nodes, roadmap items. ' +
3511
+ 'Unlike smart_recall (which focuses on lessons + context), brain_search casts a wider net. ' +
3512
+ 'Use when smart_recall returns nothing or when you want to find anything the brain knows about a topic.',
3476
3513
  inputSchema: {
3477
3514
  type: 'object',
3478
3515
  properties: {
3479
- instance_id: { type: 'string', description: 'UUID of the cache instance' },
3480
- focus: {
3481
- type: 'string',
3482
- description: 'What you are trying to recover context for — used to surface relevant lessons (optional)',
3483
- },
3516
+ instance_id: { type: 'string', description: 'Brain instance ID' },
3517
+ query: { type: 'string', description: 'What to search for' },
3518
+ limit: { type: 'number', description: 'Max results (default: 15)' },
3484
3519
  },
3485
- required: ['instance_id'],
3520
+ required: ['instance_id', 'query'],
3486
3521
  },
3487
3522
  },
3488
- // ── v0.9 — brain_from_git ─────────────────────────────────────────────────
3489
3523
  {
3490
- name: 'brain_from_git',
3491
- description: 'Bootstrap the AI Brain from git history — reads commit messages and automatically stores lessons. ' +
3492
- 'Scans the last N days of commits, extracts actionable knowledge (fixes, reverts, deploys, migrations), ' +
3493
- 'and bulk-loads them into the Brain as lessons. ' +
3494
- 'Run once to pre-load months of team knowledge before the first session_start. ' +
3495
- 'Skips commits already in the Brain (safe to re-run). ' +
3496
- 'Example: brain_from_git(instance_id="...", workspace_path="/home/you/project", days=180) ' +
3497
- '→ "📊 Scanned 342 commits · ✅ 28 lessons stored · ❌ 9 failure lessons · 0 duplicates skipped"',
3524
+ name: 'ckg_inspect',
3525
+ description: 'Inspect the Causal Knowledge Graph (CKG) for a concept. Shows all typed edges (fixes, requires, co-occurs, causes) ' +
3526
+ 'with Bayesian confidence scores. Use to understand what the brain knows about a topic and which fixes have the ' +
3527
+ 'highest confidence. Also shows related concepts via graph traversal.',
3498
3528
  inputSchema: {
3499
3529
  type: 'object',
3500
3530
  properties: {
3501
- instance_id: { type: 'string', description: 'UUID of the cache instance' },
3502
- workspace_path: {
3503
- type: 'string',
3504
- description: 'Absolute path to the git repository root (e.g. "/home/you/myproject")',
3505
- },
3506
- days: {
3507
- type: 'number',
3508
- description: 'How many days of history to scan (default: 180, max: 365)',
3509
- },
3510
- max_commits: {
3511
- type: 'number',
3512
- description: 'Maximum number of commits to process (default: 200, max: 500)',
3513
- },
3514
- author: {
3515
- type: 'string',
3516
- description: 'Filter to commits by this git author email or name (optional — omit to scan all authors)',
3517
- },
3531
+ instance_id: { type: 'string', description: 'Brain instance ID' },
3532
+ concept: { type: 'string', description: 'Concept to inspect, e.g. "fix:clickhouse-ipv6" or "docker"' },
3533
+ max_hops: { type: 'number', description: 'Traversal depth (default: 2)' },
3534
+ },
3535
+ required: ['instance_id', 'concept'],
3536
+ },
3537
+ },
3538
+ {
3539
+ name: 'brain_predict',
3540
+ description: 'Predictive Pre-fetch Engine (PPE): given your current context (what you\'re working on), traverses the CKG ' +
3541
+ 'to predict likely failures and pre-load relevant fixes. Returns top predicted pitfalls + highest-confidence fixes. ' +
3542
+ 'Call at session_start when working on a specific feature or debugging area.',
3543
+ inputSchema: {
3544
+ type: 'object',
3545
+ properties: {
3546
+ instance_id: { type: 'string', description: 'Brain instance ID' },
3547
+ context: { type: 'string', description: 'What you\'re working on, e.g. "upgrading Keycloak from 21 to 24"' },
3548
+ top_k: { type: 'number', description: 'Max predictions to return (default: 5)' },
3518
3549
  },
3519
- required: ['instance_id', 'workspace_path'],
3550
+ required: ['instance_id', 'context'],
3520
3551
  },
3521
3552
  },
3522
3553
  ];
@@ -4427,6 +4458,46 @@ async function handleTool(name, args) {
4427
4458
  await redis.ltrim(dlKey, -50, -1);
4428
4459
  }
4429
4460
  catch { /* non-critical */ }
4461
+ // ── Layer 1+2: CKG update (Causal Knowledge Graph + Belief Update Engine) ──
4462
+ try {
4463
+ const conceptId = ckgSlug(topic);
4464
+ const domain = topic.split(':')[0] ?? 'unknown';
4465
+ const conceptType = domain; // fix, debug, deploy, infra, api, etc.
4466
+ // Upsert concept node
4467
+ await ckgUpsertNode(redis, conceptId, domain, conceptType);
4468
+ // Tag co-occurrence edges
4469
+ for (const tag of tags) {
4470
+ const tagId = ckgSlug(`tag:${tag}`);
4471
+ await ckgUpsertNode(redis, tagId, 'tag', 'tag');
4472
+ await ckgUpdateEdge(redis, conceptId, 'co-occurs', tagId, outcome === 'success', outcome === 'partial');
4473
+ }
4474
+ // depends_on → requires edges (structural, always confidence 1.0 direction)
4475
+ for (const dep of depends_on) {
4476
+ const depId = ckgSlug(dep);
4477
+ await ckgUpdateEdge(redis, conceptId, 'requires', depId, true);
4478
+ }
4479
+ // fixes edge: if category=fix and outcome=success, link to problem concept
4480
+ if ((domain === 'fix' || domain === 'debug') && (outcome === 'success' || outcome === 'partial')) {
4481
+ const problemText = what_failed || ctx || '';
4482
+ const problemConcept = problemText ? extractProblemConcept(problemText) : null;
4483
+ if (problemConcept) {
4484
+ const problemId = ckgSlug(`problem:${problemConcept}`);
4485
+ await ckgUpsertNode(redis, problemId, 'problem', 'problem');
4486
+ await ckgUpdateEdge(redis, conceptId, 'fixes', problemId, outcome === 'success', outcome === 'partial');
4487
+ }
4488
+ }
4489
+ // causes edge: if outcome=failure, link topic concept to the problem context
4490
+ if (outcome === 'failure' && (what_failed || what_worked)) {
4491
+ const causeText = what_failed || what_worked;
4492
+ const causeConcept = extractProblemConcept(causeText);
4493
+ if (causeConcept) {
4494
+ const causeId = ckgSlug(`cause:${causeConcept}`);
4495
+ await ckgUpsertNode(redis, causeId, 'cause', 'cause');
4496
+ await ckgUpdateEdge(redis, conceptId, 'causes', causeId, false);
4497
+ }
4498
+ }
4499
+ }
4500
+ catch { /* CKG updates are non-critical */ }
4430
4501
  const emoji = outcome === 'success' ? '✅' : outcome === 'partial' ? '⚠️' : '❌';
4431
4502
  const sevEmoji = severity === 'critical' ? '🔴' : severity === 'major' ? '🟡' : '🟢';
4432
4503
  const action = isUpdate ? 'updated' : 'stored';
@@ -4902,7 +4973,37 @@ async function handleTool(name, args) {
4902
4973
  }
4903
4974
  // Brain health
4904
4975
  lines.push(`📊 **Brain:** ${lessons.length} lessons · ${ctxCount} context entries`, '');
4905
- // Focus-relevant lessons
4976
+ // ── Layer 7: MCM Domain Coverage Map ────────────────────────────────────
4977
+ if (lessons.length >= 3) {
4978
+ const domainMap = new Map();
4979
+ for (const l of lessons) {
4980
+ const dom = l.topic.split(':')[0] ?? 'other';
4981
+ if (!domainMap.has(dom))
4982
+ domainMap.set(dom, { total: 0, success: 0, critical: 0 });
4983
+ const d = domainMap.get(dom);
4984
+ d.total++;
4985
+ if (l.outcome === 'success')
4986
+ d.success++;
4987
+ if (l.severity === 'critical')
4988
+ d.critical++;
4989
+ }
4990
+ const sorted = [...domainMap.entries()].sort((a, b) => b[1].total - a[1].total).slice(0, 6);
4991
+ const hasContestedDomains = sorted.some(([, d]) => d.success < d.total * 0.4 && d.total >= 2);
4992
+ if (sorted.length > 0) {
4993
+ lines.push(`🗺️ **Knowledge Coverage:**`);
4994
+ for (const [dom, d] of sorted) {
4995
+ const pct = Math.round((d.success / d.total) * 100);
4996
+ const filled = Math.round(pct / 10);
4997
+ const bar = '█'.repeat(filled) + '░'.repeat(10 - filled);
4998
+ const flag = d.critical > 0 ? ' 🔴' : pct < 40 && d.total >= 2 ? ' ⚠️' : '';
4999
+ lines.push(` ${bar} ${dom.padEnd(18)} ${String(pct).padStart(3)}% (${d.success}/${d.total} confirmed)${flag}`);
5000
+ }
5001
+ if (hasContestedDomains) {
5002
+ lines.push(` ⚠️ _Some domains have contested beliefs — use \`ckg_inspect\` to review_`);
5003
+ }
5004
+ lines.push('');
5005
+ }
5006
+ }
4906
5007
  if (focusLessons.length > 0) {
4907
5008
  lines.push(`🎯 **Relevant for "${focus}":**`);
4908
5009
  for (const l of focusLessons.slice(0, 4)) {
@@ -6866,7 +6967,59 @@ async function handleBulkLockStream(name, args) {
6866
6967
  .replace(/[^a-z0-9\s\-_:]/g, ' ')
6867
6968
  .split(/\s+/)
6868
6969
  .filter(t => t.length > 2);
6869
- // Scan all lessons + history entries
6970
+ const SEV_ICON = { critical: '🔴', major: '🟠', minor: '🟡' };
6971
+ const ckgResults = [];
6972
+ try {
6973
+ for (const token of tokens.slice(0, 4)) {
6974
+ // Search for CKG nodes matching this token
6975
+ const fromKeys = await redis.smembers(`cachly:ckg:idx:from:${ckgSlug(token)}`);
6976
+ const toKeys = await redis.smembers(`cachly:ckg:idx:to:${ckgSlug(token)}`);
6977
+ // Also try pattern: scan nodes containing the token
6978
+ const nodeKeys = [];
6979
+ const nStream = redis.scanStream({ match: `cachly:ckg:node:*${token}*`, count: 50 });
6980
+ await new Promise((res, rej) => { nStream.on('data', (b) => nodeKeys.push(...b)); nStream.on('end', res); nStream.on('error', rej); });
6981
+ for (const nodeKey of nodeKeys.slice(0, 10)) {
6982
+ const nodeRaw = await redis.get(nodeKey);
6983
+ if (!nodeRaw)
6984
+ continue;
6985
+ const node = JSON.parse(nodeRaw);
6986
+ // Get edges from this node
6987
+ const edgeKeys = await redis.smembers(`cachly:ckg:idx:from:${node.id}`);
6988
+ for (const ek of edgeKeys.slice(0, 20)) {
6989
+ const edgeRaw = await redis.get(ek);
6990
+ if (!edgeRaw)
6991
+ continue;
6992
+ const edge = JSON.parse(edgeRaw);
6993
+ if (edge.edgeType !== 'fixes' && edge.edgeType !== 'requires')
6994
+ continue;
6995
+ // Find lesson for this concept
6996
+ const lessonRaw = await redis.get(`cachly:lesson:best:${edge.from.replace(/-/g, ':').replace(/^fix:/, 'fix:')}`);
6997
+ const lesson = lessonRaw ? JSON.parse(lessonRaw) : undefined;
6998
+ ckgResults.push({ conceptId: node.id, edge, lesson });
6999
+ }
7000
+ }
7001
+ for (const ek of [...fromKeys, ...toKeys].slice(0, 20)) {
7002
+ const edgeRaw = await redis.get(ek);
7003
+ if (!edgeRaw)
7004
+ continue;
7005
+ const edge = JSON.parse(edgeRaw);
7006
+ const lessonRaw = await redis.get(`cachly:lesson:best:${edge.from}`);
7007
+ const lesson = lessonRaw ? JSON.parse(lessonRaw) : undefined;
7008
+ ckgResults.push({ conceptId: edge.from, edge, lesson });
7009
+ }
7010
+ }
7011
+ }
7012
+ catch { /* CKG traversal non-critical */ }
7013
+ // Deduplicate CKG results and sort by confidence
7014
+ const ckgSeen = new Set();
7015
+ const ckgDeduped = ckgResults.filter(r => {
7016
+ const key = `${r.edge.from}:${r.edge.edgeType}:${r.edge.to}`;
7017
+ if (ckgSeen.has(key))
7018
+ return false;
7019
+ ckgSeen.add(key);
7020
+ return true;
7021
+ }).sort((a, b) => b.edge.confidence - a.edge.confidence).slice(0, max_depth);
7022
+ // ── Layer 2 (fallback): text similarity over all lessons ─────────────────
6870
7023
  let cursor = 0;
6871
7024
  const lessonKeys = [];
6872
7025
  do {
@@ -6882,7 +7035,6 @@ async function handleBulkLockStream(name, args) {
6882
7035
  continue;
6883
7036
  try {
6884
7037
  const l = JSON.parse(raw);
6885
- // Skip if filter tags given and lesson has none of them
6886
7038
  if (filterTags.length > 0 && !(l.tags ?? []).some((t) => filterTags.includes(t)))
6887
7039
  continue;
6888
7040
  const haystack = [l.topic, l.what_failed ?? '', l.what_worked ?? '', l.context ?? '']
@@ -6895,7 +7047,7 @@ async function handleBulkLockStream(name, args) {
6895
7047
  }
6896
7048
  scored.sort((a, b) => b.score - a.score);
6897
7049
  const chain = scored.slice(0, max_depth);
6898
- if (chain.length === 0) {
7050
+ if (chain.length === 0 && ckgDeduped.length === 0) {
6899
7051
  return [
6900
7052
  `🔍 **Causal Trace: "${problem}"**`,
6901
7053
  ``,
@@ -6913,50 +7065,64 @@ async function handleBulkLockStream(name, args) {
6913
7065
  `\`\`\``,
6914
7066
  ].join('\n');
6915
7067
  }
6916
- // Build causal chain narrative: failures first → then successes
6917
- const failures = chain.filter(c => c.lesson.outcome !== 'success');
6918
- const solutions = chain.filter(c => c.lesson.outcome === 'success');
6919
- const SEV_ICON = { critical: '🔴', major: '🟠', minor: '🟡' };
6920
7068
  const lines = [
6921
7069
  `🔍 **Causal Trace: "${problem}"**`,
6922
7070
  ``,
6923
- `Found **${chain.length}** related lessons. Reconstructed causal chain:`,
6924
- ``,
6925
7071
  ];
6926
- if (failures.length > 0) {
6927
- lines.push(`**Root causes & failure chain:**`);
6928
- failures.forEach((c, i) => {
6929
- const l = c.lesson;
6930
- const sev = SEV_ICON[l.severity ?? 'minor'] ?? '🟡';
6931
- lines.push(`${i === 0 ? ' Root:' : ' → :'} ${sev} \`${l.topic}\``);
6932
- if (l.what_failed)
6933
- lines.push(` ↳ ${l.what_failed.slice(0, 120)}`);
6934
- });
6935
- lines.push('');
6936
- }
6937
- if (solutions.length > 0) {
6938
- lines.push(`**Solutions that worked before:**`);
6939
- solutions.forEach((c, i) => {
6940
- const l = c.lesson;
6941
- const date = new Date(l.ts).toLocaleDateString('de-DE');
6942
- lines.push(` ${i + 1}. ✅ \`${l.topic}\` — ${date} · recalled ${l.recall_count ?? 0}× `);
6943
- if (l.what_worked)
6944
- lines.push(` ${l.what_worked.slice(0, 200)}`);
6945
- });
6946
- lines.push('');
7072
+ // Show CKG graph results first if available
7073
+ if (ckgDeduped.length > 0) {
7074
+ lines.push(`### 🕸️ CKG Graph (confidence-ranked)`);
7075
+ for (const r of ckgDeduped) {
7076
+ const confPct = Math.round(r.edge.confidence * 100);
7077
+ const confBar = '▓'.repeat(Math.round(confPct / 10)) + '░'.repeat(10 - Math.round(confPct / 10));
7078
+ lines.push(` ${r.edge.from} **→[${r.edge.edgeType}]→** ${r.edge.to}`);
7079
+ lines.push(` ${confBar} ${confPct}% confidence (${r.edge.successes}/${r.edge.trials} confirmed)`);
7080
+ if (r.lesson?.what_worked)
7081
+ lines.push(` ✅ Fix: ${r.lesson.what_worked.slice(0, 150)}`);
7082
+ lines.push('');
7083
+ }
6947
7084
  }
6948
- const topSolution = solutions[0]?.lesson;
6949
- if (topSolution?.what_worked) {
6950
- lines.push(`**⚡ Most likely fix:**`);
6951
- lines.push(`\`\`\``);
6952
- lines.push(topSolution.what_worked.slice(0, 500));
6953
- lines.push(`\`\`\``);
6954
- lines.push('');
7085
+ // Build text-based causal chain narrative
7086
+ if (chain.length > 0) {
7087
+ lines.push(ckgDeduped.length > 0 ? `### 📚 Text Search (${chain.length} related lessons)` : `Found **${chain.length}** related lessons. Reconstructed causal chain:`, '');
7088
+ const failures = chain.filter(c => c.lesson.outcome !== 'success');
7089
+ const solutions = chain.filter(c => c.lesson.outcome === 'success');
7090
+ if (failures.length > 0) {
7091
+ lines.push(`**Root causes & failure chain:**`);
7092
+ failures.forEach((c, i) => {
7093
+ const l = c.lesson;
7094
+ const sev = SEV_ICON[l.severity ?? 'minor'] ?? '🟡';
7095
+ lines.push(`${i === 0 ? ' Root:' : ' → :'} ${sev} \`${l.topic}\``);
7096
+ if (l.what_failed)
7097
+ lines.push(` ↳ ${l.what_failed.slice(0, 120)}`);
7098
+ });
7099
+ lines.push('');
7100
+ }
7101
+ if (solutions.length > 0) {
7102
+ lines.push(`**Solutions that worked before:**`);
7103
+ solutions.forEach((c, i) => {
7104
+ const l = c.lesson;
7105
+ const date = new Date(l.ts).toLocaleDateString('de-DE');
7106
+ lines.push(` ${i + 1}. ✅ \`${l.topic}\` — ${date} · recalled ${l.recall_count ?? 0}×`);
7107
+ if (l.what_worked)
7108
+ lines.push(` ${l.what_worked.slice(0, 200)}`);
7109
+ });
7110
+ lines.push('');
7111
+ }
7112
+ const topSolution = solutions[0]?.lesson;
7113
+ if (topSolution?.what_worked) {
7114
+ lines.push(`**⚡ Most likely fix:**`);
7115
+ lines.push(`\`\`\``);
7116
+ lines.push(topSolution.what_worked.slice(0, 500));
7117
+ lines.push(`\`\`\``);
7118
+ lines.push('');
7119
+ }
6955
7120
  }
6956
7121
  lines.push(`💡 After applying: \`learn_from_attempts(topic="fix:${tokens[0] ?? 'issue'}", outcome="success", ...)\``);
7122
+ if (ckgDeduped.length > 0)
7123
+ lines.push(`🕸️ Explore graph: \`ckg_inspect(concept="${tokens[0] ?? 'fix'}")\``);
6957
7124
  return lines.join('\n');
6958
7125
  }
6959
- // ── v0.6 Cognitive Cache: knowledge_decay ────────────────────────────────
6960
7126
  case 'knowledge_decay': {
6961
7127
  const { instance_id, min_age_days = 0, show_top = 20 } = args;
6962
7128
  const redis = await getConnection(instance_id);
@@ -7268,172 +7434,198 @@ smart_recall(
7268
7434
  lines.push(`---`, `**Confirm** (if this helped you): \`syndicate(topic="${res.results[0]?.topic ?? '...'}", what_worked="...")\` → auto-deduped, trust +1`, `**All trending:** \`syndicate_trending(limit=50)\` | **Search:** \`syndicate_search(q="...")\``);
7269
7435
  return lines.filter(l => l !== '').join('\n');
7270
7436
  }
7271
- // ── v0.9 compact_recover — fast context recovery ──────────────────────────
7272
- case 'compact_recover': {
7273
- const { instance_id, focus = '' } = args;
7437
+ // ── Layer 1: brain_search ─────────────────────────────────────────────────
7438
+ case 'brain_search': {
7439
+ const { instance_id, query, limit = 15 } = args;
7274
7440
  const redis = await getConnection(instance_id);
7275
- const lines = ['⚡ **Context Recovered**', ''];
7276
- // 1. Latest checkpoint
7277
- const checkpointRaw = await redis.get('cachly:session:checkpoint');
7278
- if (checkpointRaw) {
7279
- try {
7280
- const cp = JSON.parse(checkpointRaw);
7281
- const ago = Math.round((Date.now() - new Date(cp.ts).getTime()) / 60000);
7282
- const agoStr = ago < 60 ? `${ago}m ago` : `${Math.round(ago / 60)}h ago`;
7283
- lines.push(`📌 **Last checkpoint** (${agoStr}): ${cp.task}`);
7284
- if (cp.files_touched && cp.files_touched.length > 0) {
7285
- lines.push(` Files: ${cp.files_touched.slice(0, 5).join(', ')}${cp.files_touched.length > 5 ? '…' : ''}`);
7441
+ // BM25+ over ALL brain key namespaces
7442
+ const allMatches = await keywordSearch(redis, [
7443
+ 'cachly:lesson:best:*',
7444
+ 'cachly:ctx:*',
7445
+ 'cachly:idx:*',
7446
+ 'cachly:session:last',
7447
+ 'cachly:session:handoff',
7448
+ 'cachly:roadmap:*',
7449
+ 'cachly:ckg:node:*',
7450
+ ], query, limit);
7451
+ if (allMatches.length === 0) {
7452
+ return [`🔎 **Brain Search: "${query}"**`, '', `No results found across all brain data.`, '', `💡 Try \`smart_recall\` or check \`list_remembered\`.`].join('\n');
7453
+ }
7454
+ const lines = [`🔎 **Brain Search: "${query}"** — ${allMatches.length} result${allMatches.length !== 1 ? 's' : ''} across all brain data\n`];
7455
+ for (const m of allMatches.slice(0, limit)) {
7456
+ const ns = m.key.startsWith('cachly:lesson:') ? '💡 lesson'
7457
+ : m.key.startsWith('cachly:ctx:') ? '📝 context'
7458
+ : m.key.startsWith('cachly:idx:') ? '📂 index'
7459
+ : m.key.startsWith('cachly:session:') ? '🕐 session'
7460
+ : m.key.startsWith('cachly:roadmap:') ? '🗺️ roadmap'
7461
+ : m.key.startsWith('cachly:ckg:node:') ? '🕸️ ckg-node'
7462
+ : '🗄️ data';
7463
+ const preview = m.content.slice(0, 280).replace(/\n/g, ' ');
7464
+ lines.push(`**${ns}** \`${m.key.split(':').slice(2).join(':')}\` _(BM25: ${m.score.toFixed(2)})_`);
7465
+ lines.push(`> ${preview}${m.content.length > 280 ? '…' : ''}\n`);
7466
+ }
7467
+ return lines.join('\n');
7468
+ }
7469
+ // ── Layer 1: ckg_inspect ─────────────────────────────────────────────────
7470
+ case 'ckg_inspect': {
7471
+ const { instance_id, concept, max_hops = 2 } = args;
7472
+ const redis = await getConnection(instance_id);
7473
+ const conceptId = ckgSlug(concept);
7474
+ const visited = new Set();
7475
+ const allEdges = [];
7476
+ // BFS traversal of CKG
7477
+ const queue = [{ id: conceptId, hop: 0 }];
7478
+ while (queue.length > 0) {
7479
+ const { id, hop } = queue.shift();
7480
+ if (visited.has(id) || hop > max_hops)
7481
+ continue;
7482
+ visited.add(id);
7483
+ const fromKeys = await redis.smembers(`cachly:ckg:idx:from:${id}`);
7484
+ const toKeys = await redis.smembers(`cachly:ckg:idx:to:${id}`);
7485
+ for (const ek of [...fromKeys, ...toKeys].slice(0, 50)) {
7486
+ const raw = await redis.get(ek);
7487
+ if (!raw)
7488
+ continue;
7489
+ const edge = JSON.parse(raw);
7490
+ allEdges.push(edge);
7491
+ if (hop < max_hops) {
7492
+ if (!visited.has(edge.from))
7493
+ queue.push({ id: edge.from, hop: hop + 1 });
7494
+ if (!visited.has(edge.to))
7495
+ queue.push({ id: edge.to, hop: hop + 1 });
7286
7496
  }
7287
- if (cp.next_step)
7288
- lines.push(`📍 **Resume with:** ${cp.next_step}`);
7289
- lines.push('');
7290
7497
  }
7291
- catch { /* ignore corrupt */ }
7292
7498
  }
7293
- // 2. Handoff (if any, from a different window)
7294
- const handoffRaw = await redis.get('cachly:session:handoff');
7295
- if (handoffRaw) {
7296
- try {
7297
- const hf = JSON.parse(handoffRaw);
7298
- const remaining = hf.remaining_tasks ?? [];
7299
- if (remaining.length > 0) {
7300
- lines.push(`⏳ **Remaining tasks:**`);
7301
- remaining.slice(0, 5).forEach(t => lines.push(` - ${t}`));
7302
- if (remaining.length > 5)
7303
- lines.push(` …and ${remaining.length - 5} more`);
7304
- lines.push('');
7305
- }
7306
- if (hf.blocked_on) {
7307
- lines.push(`🚫 **Blocked on:** ${hf.blocked_on}`, '');
7499
+ if (allEdges.length === 0) {
7500
+ // Try fuzzy: scan for nodes matching the concept as substring
7501
+ const nodeKeys = [];
7502
+ const nStream = redis.scanStream({ match: `cachly:ckg:node:*${conceptId}*`, count: 100 });
7503
+ await new Promise((res, rej) => { nStream.on('data', (b) => nodeKeys.push(...b)); nStream.on('end', res); nStream.on('error', rej); });
7504
+ if (nodeKeys.length === 0) {
7505
+ return [`🕸️ **CKG Inspect: "${concept}"**`, '', `No CKG nodes found. The graph builds automatically as you call \`learn_from_attempts\`.`, '', `💡 Once you have lessons stored, CKG edges will appear here.`].join('\n');
7506
+ }
7507
+ const nodeList = nodeKeys.slice(0, 10).map(k => ` • \`${k.replace('cachly:ckg:node:', '')}\``).join('\n');
7508
+ return [`🕸️ **CKG Inspect: "${concept}"**`, '', `No edges found for \`${conceptId}\`, but found similar nodes:`, nodeList, '', `Try: \`ckg_inspect(concept="<exact-node-id>")\``].join('\n');
7509
+ }
7510
+ // Sort by confidence desc, deduplicate
7511
+ const edgeSeen = new Set();
7512
+ const unique = allEdges.filter(e => {
7513
+ const k = `${e.from}:${e.edgeType}:${e.to}`;
7514
+ if (edgeSeen.has(k))
7515
+ return false;
7516
+ edgeSeen.add(k);
7517
+ return true;
7518
+ }).sort((a, b) => b.confidence - a.confidence);
7519
+ const EDGE_ICON = { fixes: '🔧', requires: '🔗', 'co-occurs': '🔄', causes: '⚡', contradicts: '⚠️', degrades_under: '📉' };
7520
+ const lines = [`🕸️ **CKG Inspect: "${concept}"** (${unique.length} edge${unique.length !== 1 ? 's' : ''}, ${visited.size} node${visited.size !== 1 ? 's' : ''} traversed)\n`];
7521
+ // Group by edge type
7522
+ const byType = new Map();
7523
+ for (const e of unique) {
7524
+ if (!byType.has(e.edgeType))
7525
+ byType.set(e.edgeType, []);
7526
+ byType.get(e.edgeType).push(e);
7527
+ }
7528
+ for (const [eType, edges] of byType) {
7529
+ const icon = EDGE_ICON[eType] ?? '→';
7530
+ lines.push(`**${icon} ${eType}** (${edges.length})`);
7531
+ for (const e of edges.slice(0, 8)) {
7532
+ const confPct = Math.round(e.confidence * 100);
7533
+ const bar = '▓'.repeat(Math.round(confPct / 10)) + '░'.repeat(10 - Math.round(confPct / 10));
7534
+ lines.push(` \`${e.from}\` → \`${e.to}\` ${bar} ${confPct}% (${e.successes.toFixed(1)}/${e.trials} trials)`);
7535
+ }
7536
+ lines.push('');
7537
+ }
7538
+ lines.push(`💡 Expand: \`ckg_inspect(concept="${concept}", max_hops=3)\` | Predict: \`brain_predict(context="${concept}")\``);
7539
+ return lines.join('\n');
7540
+ }
7541
+ // ── Layer 4: brain_predict (PPE) ─────────────────────────────────────────
7542
+ case 'brain_predict': {
7543
+ const { instance_id, context: ctx, top_k = 5 } = args;
7544
+ const redis = await getConnection(instance_id);
7545
+ const ctxTokens = ctx.toLowerCase().replace(/[^a-z0-9\s\-_:]/g, ' ').split(/\s+/).filter(t => t.length > 2);
7546
+ const predictions = [];
7547
+ for (const token of ctxTokens.slice(0, 6)) {
7548
+ const nodeKeys = [];
7549
+ const nStream = redis.scanStream({ match: `cachly:ckg:node:*${token}*`, count: 50 });
7550
+ await new Promise((res, rej) => { nStream.on('data', (b) => nodeKeys.push(...b)); nStream.on('end', res); nStream.on('error', rej); });
7551
+ for (const nk of nodeKeys.slice(0, 5)) {
7552
+ const nodeRaw = await redis.get(nk);
7553
+ if (!nodeRaw)
7554
+ continue;
7555
+ const node = JSON.parse(nodeRaw);
7556
+ const edgeKeys = await redis.smembers(`cachly:ckg:idx:from:${node.id}`);
7557
+ for (const ek of edgeKeys.slice(0, 20)) {
7558
+ const edgeRaw = await redis.get(ek);
7559
+ if (!edgeRaw)
7560
+ continue;
7561
+ const edge = JSON.parse(edgeRaw);
7562
+ // Only interested in fixes and co-occurs for prediction
7563
+ if (edge.edgeType !== 'fixes' && edge.edgeType !== 'co-occurs' && edge.edgeType !== 'causes')
7564
+ continue;
7565
+ const lessonRaw = await redis.get(`cachly:lesson:best:${edge.from}`);
7566
+ const lesson = lessonRaw ? JSON.parse(lessonRaw) : undefined;
7567
+ predictions.push({ concept: node.id, edgeType: edge.edgeType, target: edge.to, confidence: edge.confidence, lesson });
7308
7568
  }
7309
7569
  }
7310
- catch { /* ignore */ }
7311
7570
  }
7312
- // 3. Top recently recalled lessons (BM25 if focus, else recency)
7571
+ // Step 2: Text-based fallback — scan lessons for matching topics
7572
+ const textPredictions = [];
7313
7573
  const lessonKeys = [];
7314
7574
  const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
7315
- await new Promise((resolve, reject) => {
7316
- lStream.on('data', (batch) => lessonKeys.push(...batch));
7317
- lStream.on('end', resolve);
7318
- lStream.on('error', reject);
7319
- });
7320
- const allLessons = [];
7575
+ await new Promise((res, rej) => { lStream.on('data', (b) => lessonKeys.push(...b)); lStream.on('end', res); lStream.on('error', rej); });
7321
7576
  for (const k of lessonKeys) {
7322
7577
  const raw = await redis.get(k);
7323
7578
  if (!raw)
7324
7579
  continue;
7325
7580
  try {
7326
- allLessons.push(JSON.parse(raw));
7581
+ const l = JSON.parse(raw);
7582
+ const haystack = [l.topic, l.what_failed ?? '', l.what_worked ?? ''].join(' ').toLowerCase();
7583
+ const score = ctxTokens.reduce((s, t) => s + (haystack.includes(t) ? 1 : 0), 0);
7584
+ if (score >= 1 && l.outcome !== 'failure') {
7585
+ const conf = calculateConfidence(l);
7586
+ textPredictions.push({ ...l, confidence: conf });
7587
+ }
7327
7588
  }
7328
7589
  catch { /* skip */ }
7329
7590
  }
7330
- const focusTerms = focus.toLowerCase().split(/\s+/).filter(Boolean);
7331
- let topLessons = focusTerms.length > 0
7332
- ? allLessons.filter(l => focusTerms.some(t => l.topic.toLowerCase().includes(t) || (l.tags ?? []).some((tag) => tag.toLowerCase().includes(t)))).slice(0, 5)
7333
- : allLessons.sort((a, b) => (b.recall_count ?? 0) - (a.recall_count ?? 0)).slice(0, 5);
7334
- if (topLessons.length > 0) {
7335
- lines.push(`🧠 **Top lessons${focus ? ` for "${focus}"` : ''}:**`);
7336
- for (const l of topLessons) {
7337
- const icon = l.outcome === 'success' ? '✅' : l.outcome === 'failure' ? '❌' : '⚠️';
7338
- lines.push(` ${icon} \`${l.topic}\` — ${l.what_worked.slice(0, 120)}${l.what_worked.length > 120 ? '…' : ''}`);
7591
+ textPredictions.sort((a, b) => b.confidence - a.confidence);
7592
+ if (predictions.length === 0 && textPredictions.length === 0) {
7593
+ return [
7594
+ `🔮 **Brain Predict: "${ctx}"**`,
7595
+ ``,
7596
+ `No predictions yet — the brain hasn't seen this domain.`,
7597
+ ``,
7598
+ `💡 As you solve problems in this area and call \`learn_from_attempts\`, the CKG builds up and predictions become available.`,
7599
+ ].join('\n');
7600
+ }
7601
+ const lines = [`🔮 **Brain Predict: "${ctx}"**\n`];
7602
+ // CKG-based predictions
7603
+ if (predictions.length > 0) {
7604
+ const pSeen = new Set();
7605
+ const pUniq = predictions.filter(p => { const k = `${p.concept}:${p.edgeType}:${p.target}`; if (pSeen.has(k))
7606
+ return false; pSeen.add(k); return true; })
7607
+ .sort((a, b) => b.confidence - a.confidence).slice(0, top_k);
7608
+ lines.push(`### 🕸️ CKG Predictions (based on ${pUniq.length} known edges)`);
7609
+ for (const p of pUniq) {
7610
+ const confPct = Math.round(p.confidence * 100);
7611
+ const icon = p.edgeType === 'fixes' ? '🔧' : p.edgeType === 'co-occurs' ? '🔄' : '⚡';
7612
+ lines.push(`${icon} **${confPct}%** \`${p.concept}\` _${p.edgeType}_ \`${p.target}\``);
7613
+ if (p.lesson?.what_worked)
7614
+ lines.push(` ✅ ${p.lesson.what_worked.slice(0, 120)}`);
7339
7615
  }
7340
7616
  lines.push('');
7341
7617
  }
7342
- lines.push(`💡 Call \`session_start\` for the full briefing, or resume work directly.`);
7343
- return lines.filter(l => l !== '').join('\n');
7344
- }
7345
- // ── v0.9 brain_from_git — bulk-load git history as lessons ───────────────
7346
- case 'brain_from_git': {
7347
- const { instance_id, workspace_path, days = 180, max_commits = 200, author = '', } = args;
7348
- const redis = await getConnection(instance_id);
7349
- const { execSync } = await import('node:child_process');
7350
- const clampedDays = Math.min(Math.max(1, days), 365);
7351
- const clampedMax = Math.min(Math.max(1, max_commits), 500);
7352
- const authorArg = author ? `--author="${author}"` : '';
7353
- let gitRaw = '';
7354
- try {
7355
- gitRaw = execSync(`git -C "${workspace_path}" log --since="${clampedDays} days ago" ${authorArg} --oneline --format="%H|||%s|||%ai|||%ae" -n ${clampedMax}`, { timeout: 15000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
7356
- }
7357
- catch {
7358
- return `❌ **brain_from_git failed**: Could not read git log from \`${workspace_path}\`.\nMake sure the path exists and is a git repository.`;
7359
- }
7360
- if (!gitRaw) {
7361
- return `📭 **No commits found** in the last ${clampedDays} days${author ? ` by "${author}"` : ''}.\nTry increasing \`days\` or removing the \`author\` filter.`;
7362
- }
7363
- const commitLines = gitRaw.split('\n');
7364
- const now = new Date();
7365
- const actionRe = /\b(fix|add|remove|refactor|migrate|update|resolve|implement|improve|optimize|configure|create|delete|disable|enable|debug|patch|upgrade|rewrite|deploy|feat|revert|break|merge|bump)\b/i;
7366
- const failRe = /\b(revert|broken|broke|breaking|rollback|undo|hotfix|crash|oops|wrong|mistake|bad)\b/i;
7367
- let stored = 0;
7368
- let skipped = 0;
7369
- let failures = 0;
7370
- const topLessons = [];
7371
- for (const line of commitLines) {
7372
- const parts = line.split('|||');
7373
- if (parts.length < 2)
7374
- continue;
7375
- const [hash, msg, dateStr] = parts;
7376
- if (!msg || !actionRe.test(msg))
7377
- continue;
7378
- const isFail = failRe.test(msg);
7379
- const cleanMsg = msg
7380
- .replace(/^(fix|feat|chore|docs|test|ci|perf|refactor|build|revert|hotfix|bump)[:(\s!]+/i, '')
7381
- .trim();
7382
- if (!cleanMsg || cleanMsg.length < 5)
7383
- continue;
7384
- const slug = cleanMsg
7385
- .toLowerCase()
7386
- .replace(/[^a-z0-9]+/g, '-')
7387
- .replace(/^-+|-+$/g, '')
7388
- .slice(0, 40);
7389
- if (!slug)
7390
- continue;
7391
- const topic = `git:${slug}`;
7392
- const key = `cachly:lesson:best:${topic}`;
7393
- const existing = await redis.get(key);
7394
- if (existing) {
7395
- skipped++;
7396
- continue;
7618
+ // Text-based lesson predictions
7619
+ if (textPredictions.length > 0) {
7620
+ lines.push(`### 📚 Relevant Lessons (${Math.min(textPredictions.length, top_k)} pre-loaded)`);
7621
+ for (const l of textPredictions.slice(0, top_k)) {
7622
+ const confPct = Math.round(l.confidence * 100);
7623
+ lines.push(` ✅ **${confPct}%** \`${l.topic}\` — ${(l.what_worked ?? '').slice(0, 120)}`);
7397
7624
  }
7398
- const lesson = {
7399
- topic,
7400
- outcome: isFail ? 'failure' : 'success',
7401
- what_worked: isFail ? '' : cleanMsg.slice(0, 300),
7402
- what_failed: isFail ? cleanMsg.slice(0, 300) : undefined,
7403
- context: `Auto-learned from git commit ${(hash ?? '').slice(0, 7)}${dateStr ? ` at ${dateStr}` : ''} in ${workspace_path}${author ? ` (author: ${author})` : ''}`,
7404
- severity: 'minor',
7405
- ts: dateStr ? new Date(dateStr).toISOString() : now.toISOString(),
7406
- recall_count: 0,
7407
- auto_learned: true,
7408
- source: 'brain_from_git',
7409
- version: 3,
7410
- };
7411
- await redis.set(key, JSON.stringify(lesson));
7412
- await redis.expire(key, 90 * 86400); // 90-day TTL for git-sourced lessons
7413
- stored++;
7414
- if (isFail)
7415
- failures++;
7416
- if (topLessons.length < 5)
7417
- topLessons.push(` ${isFail ? '❌' : '✅'} \`${topic}\` — ${cleanMsg.slice(0, 80)}`);
7418
- }
7419
- const lines = [
7420
- `🧠 **Brain bootstrapped from git history**`,
7421
- ``,
7422
- `📊 **Scanned:** ${commitLines.filter(l => l.trim()).length} commits over last ${clampedDays} days`,
7423
- `✅ **Success lessons stored:** ${stored - failures}`,
7424
- `❌ **Failure/revert lessons:** ${failures}`,
7425
- skipped > 0 ? `⏭️ **Already in brain (skipped):** ${skipped}` : '',
7426
- ``,
7427
- ];
7428
- if (topLessons.length > 0) {
7429
- lines.push(`**Top lessons extracted:**`);
7430
- lines.push(...topLessons);
7431
- if (stored > 5)
7432
- lines.push(` …and ${stored - 5} more`);
7433
7625
  lines.push('');
7434
7626
  }
7435
- lines.push(`💡 **Next step:** Call \`session_start(focus="...")\` to see these lessons in your briefing.`, `🔄 **Re-run anytime** — duplicates are safely skipped.`);
7436
- return lines.filter(l => l !== '').join('\n');
7627
+ lines.push(`💡 Outcome confirmed? \`learn_from_attempts(topic="fix:...", outcome="success", ...)\` → improves future predictions`);
7628
+ return lines.join('\n');
7437
7629
  }
7438
7630
  default:
7439
7631
  return null;