@cachly-dev/mcp-server 0.9.11 → 0.9.13

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
@@ -2434,7 +2434,11 @@ const TOOLS = [
2434
2434
  {
2435
2435
  name: 'cache_get',
2436
2436
  description: 'Get a value from a running cache instance by key. ' +
2437
- 'Returns the value (string or JSON) or null if the key does not exist.',
2437
+ 'Returns the stored value (string or deserialized JSON object) or null if the key does not exist or has expired. ' +
2438
+ 'Read-only — no side effects. ' +
2439
+ 'Use cache_mget when you need multiple keys in one round-trip. ' +
2440
+ 'Use cache_exists to check existence without retrieving the value. ' +
2441
+ 'Use semantic_search when you need fuzzy/vector search across stored values.',
2438
2442
  inputSchema: {
2439
2443
  type: 'object',
2440
2444
  properties: {
@@ -2503,12 +2507,15 @@ const TOOLS = [
2503
2507
  },
2504
2508
  {
2505
2509
  name: 'cache_ttl',
2506
- description: 'Get the remaining time-to-live (TTL) of a key in seconds. Returns -1 if no TTL, -2 if the key does not exist.',
2510
+ description: 'Get the remaining time-to-live (TTL) of a key in seconds. ' +
2511
+ 'Returns -1 if the key exists but has no expiry, -2 if the key does not exist. ' +
2512
+ 'Read-only — no side effects. ' +
2513
+ 'Use cache_set with a ttl parameter to set or update the expiry.',
2507
2514
  inputSchema: {
2508
2515
  type: 'object',
2509
2516
  properties: {
2510
- instance_id: { type: 'string' },
2511
- key: { type: 'string' },
2517
+ instance_id: { type: 'string', description: 'UUID of the cache instance (from list_instances)' },
2518
+ key: { type: 'string', description: 'Cache key to inspect' },
2512
2519
  },
2513
2520
  required: ['instance_id', 'key'],
2514
2521
  },
@@ -2688,7 +2695,11 @@ const TOOLS = [
2688
2695
  name: 'cache_mset',
2689
2696
  description: 'Set multiple key-value pairs in a single pipeline round-trip. ' +
2690
2697
  'Supports per-key TTL – unlike native MSET. ' +
2691
- 'Uses one TCP round-trip for N keys via Redis pipeline.',
2698
+ 'Uses one TCP round-trip for N keys via Redis pipeline. ' +
2699
+ 'Each item overwrites any existing value for that key. ' +
2700
+ 'On partial failure the successfully pipelined keys are committed; a per-key error list is returned for any that failed. ' +
2701
+ 'Returns a summary: { set: N, errors: [...] }. ' +
2702
+ 'Use cache_set for a single key; use cache_stream_set for large streaming payloads.',
2692
2703
  inputSchema: {
2693
2704
  type: 'object',
2694
2705
  properties: {
@@ -2771,8 +2782,11 @@ const TOOLS = [
2771
2782
  'Perfect for caching: codebase overviews, file summaries, project structure, ' +
2772
2783
  'frequently-accessed data, or "thinking" results like dependency analysis. ' +
2773
2784
  'The AI assistant can use this to avoid re-reading the entire codebase every time. ' +
2785
+ 'Overwrites any existing value stored under the same key. ' +
2786
+ 'Returns { key, stored_at, ttl } confirming the saved context. ' +
2774
2787
  'Example: remember_context("project overview", "This is a Next.js app with...") ' +
2775
- 'then later: recall_context("project overview")',
2788
+ 'then later: recall_context("project overview"). ' +
2789
+ 'Use recall_context to retrieve; use list_remembered to see all stored keys.',
2776
2790
  inputSchema: {
2777
2791
  type: 'object',
2778
2792
  properties: {
@@ -3347,7 +3361,11 @@ const TOOLS = [
3347
3361
  name: 'publish_lesson',
3348
3362
  description: 'Publish a lesson to the Cachly Public Brain (anonymized community knowledge base). ' +
3349
3363
  'Published lessons can be imported by other developers via import_public_brain. ' +
3350
- 'PII is stripped automatically. Visible under the framework/category tag.',
3364
+ 'PII is stripped automatically. Visible under the framework/category tag. ' +
3365
+ 'Returns { lesson_id, topic, framework, published_at } confirming the publish. ' +
3366
+ 'Irreversible — once published to the public brain, lessons cannot be deleted via the MCP interface. ' +
3367
+ 'Use learn_from_attempts or global_learn for private lessons; ' +
3368
+ 'use syndicate for anonymized global sharing without framework tagging.',
3351
3369
  inputSchema: {
3352
3370
  type: 'object',
3353
3371
  properties: {
@@ -3644,7 +3662,10 @@ const TOOLS = [
3644
3662
  'This is how individual knowledge becomes collective intelligence. ' +
3645
3663
  'Call this AFTER every learn_from_attempts that is worth sharing universally ' +
3646
3664
  '(critical bugs, deployment gotchas, architecture discoveries). ' +
3647
- 'Use scope="org" to keep the lesson private to your organisation.',
3665
+ 'If a lesson with the same topic already exists in the commons, it is updated in place (idempotent). ' +
3666
+ 'Returns { key, confirm_count, scope } confirming the stored lesson. ' +
3667
+ 'Use scope="org" to keep the lesson private to your organisation. ' +
3668
+ 'Do NOT use for secrets or PII — content is stored in a shared knowledge base.',
3648
3669
  inputSchema: {
3649
3670
  type: 'object',
3650
3671
  properties: {
@@ -4904,6 +4925,8 @@ async function handleTool(name, args) {
4904
4925
  }
4905
4926
  catch { /* non-critical */ }
4906
4927
  // ── Layer 1+2: CKG update (Causal Knowledge Graph + Belief Update Engine) ──
4928
+ // BUE: Bayesian confidence, contradiction detection, second-degree propagation, decay
4929
+ let beliefConflict = null;
4907
4930
  try {
4908
4931
  const conceptId = ckgSlug(topic);
4909
4932
  const domain = topic.split(':')[0] ?? 'unknown';
@@ -4941,6 +4964,71 @@ async function handleTool(name, args) {
4941
4964
  await ckgUpdateEdge(redis, conceptId, 'causes', causeId, false);
4942
4965
  }
4943
4966
  }
4967
+ // ── BUE: Contradiction detection ──────────────────────────────────────
4968
+ // If this topic previously had a confirmed 'fixes' edge (confidence > 0.7)
4969
+ // and now outcome=failure → flag belief_conflict
4970
+ if (outcome === 'failure') {
4971
+ const existingEdgeKeys = await redis.smembers(`cachly:ckg:idx:from:${conceptId}`);
4972
+ for (const ek of existingEdgeKeys) {
4973
+ const er = await redis.get(ek);
4974
+ if (!er)
4975
+ continue;
4976
+ const existEdge = JSON.parse(er);
4977
+ if (existEdge.edgeType === 'fixes' && existEdge.confidence > 0.7 && existEdge.trials >= 3) {
4978
+ beliefConflict = `⚠️ **belief_conflict** on \`${topic}\`: previously confirmed fix (confidence ${existEdge.confidence.toFixed(2)}, n=${existEdge.trials}) now reports failure. Both beliefs retained as \`contested\`. Use \`ckg_inspect(concept="${conceptId}")\` to review.`;
4979
+ // Store conflict marker
4980
+ const conflictKey = `cachly:ckg:conflict:${conceptId}`;
4981
+ await redis.set(conflictKey, JSON.stringify({ topic, detected_at: new Date().toISOString(), fix_confidence: existEdge.confidence, fix_trials: existEdge.trials, failure_outcome: outcome }), 'EX', 60 * 60 * 24 * 90);
4982
+ }
4983
+ }
4984
+ }
4985
+ // ── BUE: Second-degree propagation ────────────────────────────────────
4986
+ // When a 'fixes' edge gets stronger, boost co-occurring second-degree edges slightly
4987
+ if (outcome === 'success' && (domain === 'fix' || domain === 'debug')) {
4988
+ const fromEdgeKeys = await redis.smembers(`cachly:ckg:idx:from:${conceptId}`);
4989
+ for (const ek of fromEdgeKeys.slice(0, 10)) {
4990
+ const er = await redis.get(ek);
4991
+ if (!er)
4992
+ continue;
4993
+ const e2 = JSON.parse(er);
4994
+ if (e2.edgeType !== 'fixes')
4995
+ continue;
4996
+ // Boost second-degree: edges from e2.to get a small fractional success
4997
+ const secondKeys = await redis.smembers(`cachly:ckg:idx:from:${e2.to}`);
4998
+ for (const sk of secondKeys.slice(0, 5)) {
4999
+ const sr = await redis.get(sk);
5000
+ if (!sr)
5001
+ continue;
5002
+ const se = JSON.parse(sr);
5003
+ if (se.edgeType !== 'co-occurs')
5004
+ continue;
5005
+ // Add 0.1 fractional success (second-degree signal)
5006
+ se.successes = (se.successes || 0) + 0.1;
5007
+ se.trials = (se.trials || 0) + 0.1;
5008
+ se.confidence = (se.successes + 1) / (se.trials + 2);
5009
+ se.last_updated = new Date().toISOString();
5010
+ await redis.set(sk, JSON.stringify(se));
5011
+ }
5012
+ }
5013
+ }
5014
+ // ── BUE: Stale edge decay ─────────────────────────────────────────────
5015
+ // Edges older than 90 days with < 3 trials decay by 10% confidence.
5016
+ // Only run probabilistically (1% of calls) to avoid per-call overhead.
5017
+ if (Math.random() < 0.01) {
5018
+ const decayCutoff = Date.now() - 90 * 24 * 60 * 60 * 1000;
5019
+ const fromEdgeKeys = await redis.smembers(`cachly:ckg:idx:from:${conceptId}`);
5020
+ for (const ek of fromEdgeKeys) {
5021
+ const er = await redis.get(ek);
5022
+ if (!er)
5023
+ continue;
5024
+ const de = JSON.parse(er);
5025
+ if (de.trials < 3 && de.last_updated && new Date(de.last_updated).getTime() < decayCutoff) {
5026
+ de.confidence = de.confidence * 0.9;
5027
+ de.last_updated = new Date().toISOString();
5028
+ await redis.set(ek, JSON.stringify(de));
5029
+ }
5030
+ }
5031
+ }
4944
5032
  }
4945
5033
  catch { /* CKG updates are non-critical */ }
4946
5034
  const emoji = outcome === 'success' ? '✅' : outcome === 'partial' ? '⚠️' : '❌';
@@ -4948,6 +5036,7 @@ async function handleTool(name, args) {
4948
5036
  const action = isUpdate ? 'updated' : 'stored';
4949
5037
  return [
4950
5038
  `${emoji} **Lesson ${action}:** \`${topic}\` (${outcome}) ${sevEmoji} ${severity}`,
5039
+ beliefConflict ?? '',
4951
5040
  ``,
4952
5041
  `**What worked:** ${what_worked}`,
4953
5042
  what_failed ? `**What failed:** ${what_failed}` : '',
@@ -5449,6 +5538,24 @@ async function handleTool(name, args) {
5449
5538
  lines.push('');
5450
5539
  }
5451
5540
  }
5541
+ // ── Layer 7 MCM: Active belief conflicts ─────────────────────────────────
5542
+ try {
5543
+ const conflictKeys = [];
5544
+ const cfStream = redis.scanStream({ match: 'cachly:ckg:conflict:*', count: 50 });
5545
+ await new Promise((res, rej) => { cfStream.on('data', (b) => conflictKeys.push(...b)); cfStream.on('end', res); cfStream.on('error', rej); });
5546
+ if (conflictKeys.length > 0) {
5547
+ lines.push(`⚡ **Active belief conflicts (${conflictKeys.length}):**`);
5548
+ for (const ck of conflictKeys.slice(0, 3)) {
5549
+ const cr = await redis.get(ck);
5550
+ if (!cr)
5551
+ continue;
5552
+ const cf = JSON.parse(cr);
5553
+ lines.push(` ⚠️ \`${cf.topic}\` — previously confirmed fix (${(cf.fix_confidence * 100).toFixed(0)}%, n=${cf.fix_trials}) now contradicted. Use \`ckg_inspect(concept="${ckgSlug(cf.topic)}")\``);
5554
+ }
5555
+ lines.push('');
5556
+ }
5557
+ }
5558
+ catch { /* non-critical */ }
5452
5559
  if (focusLessons.length > 0) {
5453
5560
  lines.push(`🎯 **Relevant for "${focus}":**`);
5454
5561
  for (const l of focusLessons.slice(0, 4)) {
@@ -8737,107 +8844,114 @@ smart_recall(
8737
8844
  const { resolve } = await import('node:path');
8738
8845
  const repoDir = resolve(repo_path);
8739
8846
  const maxCommits = Math.min(Number(limit) || 100, 500);
8740
- // Verify it's a git repo
8741
- try {
8742
- execSync('git rev-parse --git-dir', { cwd: repoDir, stdio: 'pipe' });
8743
- }
8744
- catch {
8745
- return `❌ Not a git repository: \`${repoDir}\`. Pass \`repo_path\` pointing to a git checkout.`;
8746
- }
8747
- // Build git log command
8748
- const sinceFlag = since ? `--since="${since}"` : '';
8749
- const logCmd = `git log ${branch} ${sinceFlag} --pretty=format:"%H|||%s|||%ad|||%an" --date=short --no-merges -n ${maxCommits}`;
8750
- let logOutput = '';
8847
+ // Semaphore: max 10 concurrent git subprocesses per MCP process
8848
+ await _gitSemAcquire();
8751
8849
  try {
8752
- logOutput = execSync(logCmd, { cwd: repoDir, encoding: 'utf-8', stdio: 'pipe' });
8753
- }
8754
- catch (e) {
8755
- return `❌ git log failed: ${e.message}. Check \`repo_path\` and \`branch\`.`;
8756
- }
8757
- const commits = logOutput.trim().split('\n').filter(Boolean).map(line => {
8758
- const [sha, subject, date, author] = line.split('|||');
8759
- return { sha: (sha ?? '').trim(), subject: (subject ?? '').trim(), date: (date ?? '').trim(), author: (author ?? '').trim() };
8760
- });
8761
- if (commits.length === 0) {
8762
- return `⚠️ No commits found in \`${repoDir}\` on branch \`${branch}\`${since ? ` since ${since}` : ''}.`;
8763
- }
8764
- // Pattern classifiers
8765
- const classifyCommit = (subject) => {
8766
- const s = subject.toLowerCase();
8767
- if (/\b(fix|fixed|fixes|bug|hotfix|patch|revert|resolve|closes? #\d+)\b/.test(s)) {
8768
- const sev = /\b(critical|crash|security|auth|data loss|outage|prod|production)\b/.test(s) ? 'critical' : /\b(major|breaking|regression|hotfix)\b/.test(s) ? 'major' : 'minor';
8769
- return { category: 'fix', outcome: 'success', severity: sev };
8770
- }
8771
- if (/\b(feat|feature|add|added|implement|new|introduce)\b/.test(s))
8772
- return { category: 'feat', outcome: 'success', severity: 'minor' };
8773
- if (/\b(refactor|clean|cleanup|improve|simplify|extract|rename)\b/.test(s))
8774
- return { category: 'refactor', outcome: 'success', severity: 'minor' };
8775
- if (/\b(perf|optimize|speed|cache|latency|memory|performance)\b/.test(s))
8776
- return { category: 'perf', outcome: 'success', severity: 'major' };
8777
- if (/\b(security|cve|auth|csrf|xss|sql|injection|sanitize|escape|encrypt)\b/.test(s))
8778
- return { category: 'security', outcome: 'success', severity: 'critical' };
8779
- if (/\b(deploy|ci|cd|build|docker|k8s|helm|infra|devops)\b/.test(s))
8780
- return { category: 'deploy', outcome: 'success', severity: 'major' };
8781
- if (/\b(test|spec|coverage|assert|mock|unit|integration)\b/.test(s))
8782
- return { category: 'test', outcome: 'success', severity: 'minor' };
8783
- return { category: 'chore', outcome: 'success', severity: 'minor' };
8784
- };
8785
- // Extract domain keywords from commit subject
8786
- const extractDomain = (subject) => {
8787
- const s = subject.toLowerCase();
8788
- 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));
8789
- return tokens.slice(0, 3).join('-') || 'general';
8790
- };
8791
- const ts = new Date().toISOString();
8792
- let ingested = 0;
8793
- let skipped = 0;
8794
- const categoryCount = new Map();
8795
- for (const commit of commits) {
8796
- if (!commit.subject) {
8797
- skipped++;
8798
- continue;
8850
+ // Verify it's a git repo
8851
+ try {
8852
+ execSync('git rev-parse --git-dir', { cwd: repoDir, stdio: 'pipe' });
8799
8853
  }
8800
- const { category, outcome, severity } = classifyCommit(commit.subject);
8801
- const domain = extractDomain(commit.subject);
8802
- const topic = `${category}:${domain}`;
8803
- categoryCount.set(category, (categoryCount.get(category) ?? 0) + 1);
8804
- const lessonObj = {
8805
- topic, outcome, severity,
8806
- what_worked: commit.subject.slice(0, 200),
8807
- what_failed: '',
8808
- context: `git:${commit.sha.slice(0, 8)} by ${commit.author} on ${commit.date}`,
8809
- file_paths: [], commands: [`git show ${commit.sha.slice(0, 8)}`],
8810
- tags: ['brain_from_git', category, 'git-history'],
8811
- depends_on: [], recall_count: 0, ts, verified_at: ts,
8812
- confidence: 0.55, // lower confidence for auto-inferred lessons
8813
- audit_trail: [{ ts, action: 'brain_from_git', sha: commit.sha.slice(0, 8) }],
8814
- version: 3,
8815
- };
8816
- // Only store if no existing lesson for this topic (avoid overwriting higher-confidence lessons)
8817
- const existing = await redis.get(`cachly:lesson:best:${topic}`);
8818
- if (!existing) {
8819
- await redis.set(`cachly:lesson:best:${topic}`, JSON.stringify(lessonObj));
8820
- await redis.rpush(`cachly:lessons:${topic}`, JSON.stringify(lessonObj));
8854
+ catch {
8855
+ return `❌ Not a git repository: \`${repoDir}\`. Pass \`repo_path\` pointing to a git checkout.`;
8821
8856
  }
8822
- await redis.rpush('cachly:lessons:brain_from_git:all', JSON.stringify({ topic, sha: commit.sha.slice(0, 8), subject: commit.subject.slice(0, 60) }));
8823
- await redis.ltrim('cachly:lessons:brain_from_git:all', -500, -1);
8824
- // Update CKG
8825
- const conceptId = ckgSlug(topic);
8826
- await ckgUpsertNode(redis, conceptId, category, 'git-derived');
8827
- ingested++;
8857
+ // Build git log command
8858
+ const sinceFlag = since ? `--since="${since}"` : '';
8859
+ const logCmd = `git log ${branch} ${sinceFlag} --pretty=format:"%H|||%s|||%ad|||%an" --date=short --no-merges -n ${maxCommits}`;
8860
+ let logOutput = '';
8861
+ try {
8862
+ logOutput = execSync(logCmd, { cwd: repoDir, encoding: 'utf-8', stdio: 'pipe' });
8863
+ }
8864
+ catch (e) {
8865
+ return `❌ git log failed: ${e.message}. Check \`repo_path\` and \`branch\`.`;
8866
+ }
8867
+ const commits = logOutput.trim().split('\n').filter(Boolean).map(line => {
8868
+ const [sha, subject, date, author] = line.split('|||');
8869
+ return { sha: (sha ?? '').trim(), subject: (subject ?? '').trim(), date: (date ?? '').trim(), author: (author ?? '').trim() };
8870
+ });
8871
+ if (commits.length === 0) {
8872
+ return `⚠️ No commits found in \`${repoDir}\` on branch \`${branch}\`${since ? ` since ${since}` : ''}.`;
8873
+ }
8874
+ // Pattern classifiers
8875
+ const classifyCommit = (subject) => {
8876
+ const s = subject.toLowerCase();
8877
+ if (/\b(fix|fixed|fixes|bug|hotfix|patch|revert|resolve|closes? #\d+)\b/.test(s)) {
8878
+ const sev = /\b(critical|crash|security|auth|data loss|outage|prod|production)\b/.test(s) ? 'critical' : /\b(major|breaking|regression|hotfix)\b/.test(s) ? 'major' : 'minor';
8879
+ return { category: 'fix', outcome: 'success', severity: sev };
8880
+ }
8881
+ if (/\b(feat|feature|add|added|implement|new|introduce)\b/.test(s))
8882
+ return { category: 'feat', outcome: 'success', severity: 'minor' };
8883
+ if (/\b(refactor|clean|cleanup|improve|simplify|extract|rename)\b/.test(s))
8884
+ return { category: 'refactor', outcome: 'success', severity: 'minor' };
8885
+ if (/\b(perf|optimize|speed|cache|latency|memory|performance)\b/.test(s))
8886
+ return { category: 'perf', outcome: 'success', severity: 'major' };
8887
+ if (/\b(security|cve|auth|csrf|xss|sql|injection|sanitize|escape|encrypt)\b/.test(s))
8888
+ return { category: 'security', outcome: 'success', severity: 'critical' };
8889
+ if (/\b(deploy|ci|cd|build|docker|k8s|helm|infra|devops)\b/.test(s))
8890
+ return { category: 'deploy', outcome: 'success', severity: 'major' };
8891
+ if (/\b(test|spec|coverage|assert|mock|unit|integration)\b/.test(s))
8892
+ return { category: 'test', outcome: 'success', severity: 'minor' };
8893
+ return { category: 'chore', outcome: 'success', severity: 'minor' };
8894
+ };
8895
+ // Extract domain keywords from commit subject
8896
+ const extractDomain = (subject) => {
8897
+ const s = subject.toLowerCase();
8898
+ 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));
8899
+ return tokens.slice(0, 3).join('-') || 'general';
8900
+ };
8901
+ const ts = new Date().toISOString();
8902
+ let ingested = 0;
8903
+ let skipped = 0;
8904
+ const categoryCount = new Map();
8905
+ for (const commit of commits) {
8906
+ if (!commit.subject) {
8907
+ skipped++;
8908
+ continue;
8909
+ }
8910
+ const { category, outcome, severity } = classifyCommit(commit.subject);
8911
+ const domain = extractDomain(commit.subject);
8912
+ const topic = `${category}:${domain}`;
8913
+ categoryCount.set(category, (categoryCount.get(category) ?? 0) + 1);
8914
+ const lessonObj = {
8915
+ topic, outcome, severity,
8916
+ what_worked: commit.subject.slice(0, 200),
8917
+ what_failed: '',
8918
+ context: `git:${commit.sha.slice(0, 8)} by ${commit.author} on ${commit.date}`,
8919
+ file_paths: [], commands: [`git show ${commit.sha.slice(0, 8)}`],
8920
+ tags: ['brain_from_git', category, 'git-history'],
8921
+ depends_on: [], recall_count: 0, ts, verified_at: ts,
8922
+ confidence: 0.55, // lower confidence for auto-inferred lessons
8923
+ audit_trail: [{ ts, action: 'brain_from_git', sha: commit.sha.slice(0, 8) }],
8924
+ version: 3,
8925
+ };
8926
+ // Only store if no existing lesson for this topic (avoid overwriting higher-confidence lessons)
8927
+ const existing = await redis.get(`cachly:lesson:best:${topic}`);
8928
+ if (!existing) {
8929
+ await redis.set(`cachly:lesson:best:${topic}`, JSON.stringify(lessonObj));
8930
+ await redis.rpush(`cachly:lessons:${topic}`, JSON.stringify(lessonObj));
8931
+ }
8932
+ await redis.rpush('cachly:lessons:brain_from_git:all', JSON.stringify({ topic, sha: commit.sha.slice(0, 8), subject: commit.subject.slice(0, 60) }));
8933
+ await redis.ltrim('cachly:lessons:brain_from_git:all', -500, -1);
8934
+ // Update CKG
8935
+ const conceptId = ckgSlug(topic);
8936
+ await ckgUpsertNode(redis, conceptId, category, 'git-derived');
8937
+ ingested++;
8938
+ }
8939
+ const categoryBreakdown = [...categoryCount.entries()].sort((a, b) => b[1] - a[1]).map(([k, v]) => ` • **${k}** (${v})`).join('\n');
8940
+ return [
8941
+ `🔁 **brain_from_git: ${repoDir}**`, '',
8942
+ `📂 Branch: \`${branch}\` | Processed: **${commits.length}** commits | Ingested: **${ingested}** lessons | Skipped: ${skipped}`,
8943
+ ``,
8944
+ `**Breakdown by category:**`,
8945
+ categoryBreakdown,
8946
+ ``,
8947
+ `💡 New lessons are stored with confidence 0.55 (auto-inferred).`,
8948
+ `💡 As you confirm them via \`learn_from_attempts\`, confidence rises automatically.`,
8949
+ `🔍 Explore: \`brain_search(query="fix")\` | \`ckg_inspect(concept="deploy")\``,
8950
+ ].join('\n');
8951
+ }
8952
+ finally {
8953
+ _gitSemRelease();
8828
8954
  }
8829
- const categoryBreakdown = [...categoryCount.entries()].sort((a, b) => b[1] - a[1]).map(([k, v]) => ` • **${k}** (${v})`).join('\n');
8830
- return [
8831
- `🔁 **brain_from_git: ${repoDir}**`, '',
8832
- `📂 Branch: \`${branch}\` | Processed: **${commits.length}** commits | Ingested: **${ingested}** lessons | Skipped: ${skipped}`,
8833
- ``,
8834
- `**Breakdown by category:**`,
8835
- categoryBreakdown,
8836
- ``,
8837
- `💡 New lessons are stored with confidence 0.55 (auto-inferred).`,
8838
- `💡 As you confirm them via \`learn_from_attempts\`, confidence rises automatically.`,
8839
- `🔍 Explore: \`brain_search(query="fix")\` | \`ckg_inspect(concept="deploy")\``,
8840
- ].join('\n');
8841
8955
  }
8842
8956
  // ── brain_predict_failures ─────────────────────────────────────────────────
8843
8957
  case 'brain_predict_failures': {
@@ -8978,6 +9092,28 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
8978
9092
  let _autoSessionInstanceId = null;
8979
9093
  let _autoSessionStarted = false;
8980
9094
  let _autoSessionToolCount = 0;
9095
+ // ── brain_from_git concurrency limiter ────────────────────────────────────────
9096
+ // Spawning many `git log` subprocesses in parallel can fork-bomb the host.
9097
+ // This simple semaphore caps concurrent brain_from_git calls to 10 per process.
9098
+ let _gitSemCount = 0;
9099
+ const _GIT_SEM_MAX = 10;
9100
+ const _gitSemQueue = [];
9101
+ function _gitSemAcquire() {
9102
+ if (_gitSemCount < _GIT_SEM_MAX) {
9103
+ _gitSemCount++;
9104
+ return Promise.resolve();
9105
+ }
9106
+ return new Promise(resolve => _gitSemQueue.push(resolve));
9107
+ }
9108
+ function _gitSemRelease() {
9109
+ const next = _gitSemQueue.shift();
9110
+ if (next) {
9111
+ next();
9112
+ }
9113
+ else {
9114
+ _gitSemCount--;
9115
+ }
9116
+ }
8981
9117
  async function autoStartSession(instanceId) {
8982
9118
  if (_autoSessionStarted)
8983
9119
  return;
@@ -9214,6 +9350,47 @@ if (process.argv[2] === 'init') {
9214
9350
  const result = await writeClaudeMd(projectDir, instanceId);
9215
9351
  const action = result === 'updated' ? '✅ Updated' : result === 'appended' ? '✅ Appended to' : '✅ Written';
9216
9352
  console.log(`${action}: CLAUDE.md`);
9353
+ // ── CLS Phase 4: Auto-install git post-commit hook ─────────────────────────
9354
+ try {
9355
+ const { existsSync } = await import('node:fs');
9356
+ const gitHookDir = resolve(projectDir, '.git', 'hooks');
9357
+ const hookPath = resolve(gitHookDir, 'post-commit');
9358
+ if (existsSync(resolve(projectDir, '.git'))) {
9359
+ await mkdir(gitHookDir, { recursive: true });
9360
+ const hookScript = [
9361
+ `#!/bin/sh`,
9362
+ `# cachly CLS — Continuous Learning Stream (installed by cachly init)`,
9363
+ `# Runs silently on every commit to keep your brain up to date.`,
9364
+ `CACHLY_INSTANCE="${instanceId}"`,
9365
+ `SHA=$(git rev-parse HEAD 2>/dev/null || echo "")`,
9366
+ `MSG=$(git log -1 --pretty=%B 2>/dev/null | head -1 | tr '"' "'" | cut -c1-200)`,
9367
+ `FILES=$(git diff-tree --no-commit-id -r --name-only HEAD 2>/dev/null | tr '\\n' ',' | sed 's/,$//')`,
9368
+ `node -e "try{require('child_process').execSync('npx @cachly-dev/mcp-server@latest cls-ingest \\''+ JSON.stringify({instance_id:'$CACHLY_INSTANCE',source:'git_commit',payload:{message:'$MSG',sha:'$SHA',files:'$FILES'.split(',').filter(Boolean)}})+'\\'' ,{stdio:'ignore',timeout:5000})}catch(e){}" 2>/dev/null &`,
9369
+ `exit 0`,
9370
+ ].join('\n');
9371
+ let existing = '';
9372
+ try {
9373
+ const { readFile } = await import('node:fs/promises');
9374
+ existing = await readFile(hookPath, 'utf-8');
9375
+ }
9376
+ catch { /* no existing hook */ }
9377
+ if (existing && !existing.includes('cachly CLS')) {
9378
+ // Append to existing hook
9379
+ await writeFile(hookPath, existing.trimEnd() + '\n\n' + hookScript + '\n', 'utf-8');
9380
+ console.log(`✅ Appended: .git/hooks/post-commit (CLS hook)`);
9381
+ }
9382
+ else if (!existing) {
9383
+ await writeFile(hookPath, hookScript + '\n', 'utf-8');
9384
+ const { chmod } = await import('node:fs/promises');
9385
+ await chmod(hookPath, 0o755);
9386
+ console.log(`✅ Written: .git/hooks/post-commit (CLS hook)`);
9387
+ }
9388
+ else {
9389
+ console.log(`✓ CLS hook already present in .git/hooks/post-commit`);
9390
+ }
9391
+ }
9392
+ }
9393
+ catch { /* non-critical — git hook is a best-effort feature */ }
9217
9394
  console.log(`\n🧠 Cachly AI Brain configured for ${editor === 'claude' ? 'Claude Code' : editor}!`);
9218
9395
  console.log(` Restart your editor — the \`cachly\` MCP tools will appear.\n`);
9219
9396
  process.exit(0);
@@ -9541,6 +9718,43 @@ if (process.argv[2] === 'setup') {
9541
9718
  catch { /* fire and forget */ }
9542
9719
  }
9543
9720
  }
9721
+ // ── CLS Phase 4: Auto-install git post-commit hook in setup flow ─────────
9722
+ try {
9723
+ const { existsSync } = await import('node:fs');
9724
+ const { writeFile: wf2, mkdir: mk2, chmod: ch2, readFile: rf2 } = await import('node:fs/promises');
9725
+ const { resolve: res2 } = await import('node:path');
9726
+ const setupProjectDir = process.cwd();
9727
+ const gitHookDir2 = res2(setupProjectDir, '.git', 'hooks');
9728
+ const hookPath2 = res2(gitHookDir2, 'post-commit');
9729
+ if (existsSync(res2(setupProjectDir, '.git'))) {
9730
+ await mk2(gitHookDir2, { recursive: true });
9731
+ const hs2 = [
9732
+ `#!/bin/sh`,
9733
+ `# cachly CLS — Continuous Learning Stream (installed by cachly setup)`,
9734
+ `CACHLY_INSTANCE="${instance.id}"`,
9735
+ `SHA=$(git rev-parse HEAD 2>/dev/null || echo "")`,
9736
+ `MSG=$(git log -1 --pretty=%B 2>/dev/null | head -1 | tr '"' "'" | cut -c1-200)`,
9737
+ `FILES=$(git diff-tree --no-commit-id -r --name-only HEAD 2>/dev/null | tr '\\n' ',' | sed 's/,$//')`,
9738
+ `node -e "try{require('child_process').execSync('npx @cachly-dev/mcp-server@latest cls-ingest \\''+ JSON.stringify({instance_id:'$CACHLY_INSTANCE',source:'git_commit',payload:{message:'$MSG',sha:'$SHA',files:'$FILES'.split(',').filter(Boolean)}})+'\\'' ,{stdio:'ignore',timeout:5000})}catch(e){}" 2>/dev/null &`,
9739
+ `exit 0`,
9740
+ ].join('\n');
9741
+ let ex2 = '';
9742
+ try {
9743
+ ex2 = await rf2(hookPath2, 'utf-8');
9744
+ }
9745
+ catch { /* no existing */ }
9746
+ if (!ex2) {
9747
+ await wf2(hookPath2, hs2 + '\n', 'utf-8');
9748
+ await ch2(hookPath2, 0o755);
9749
+ console.log(`\n✅ CLS git hook installed — your brain will learn from every commit.`);
9750
+ }
9751
+ else if (!ex2.includes('cachly CLS')) {
9752
+ await wf2(hookPath2, ex2.trimEnd() + '\n\n' + hs2 + '\n', 'utf-8');
9753
+ console.log(`\n✅ CLS git hook appended to existing post-commit.`);
9754
+ }
9755
+ }
9756
+ }
9757
+ catch { /* non-critical */ }
9544
9758
  rl.close();
9545
9759
  process.exit(0);
9546
9760
  }