@cachly-dev/mcp-server 0.10.114 → 0.10.116

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/README.md CHANGED
@@ -22,11 +22,16 @@
22
22
  <img src="https://img.shields.io/badge/GDPR-EU%20servers-green" alt="GDPR: EU servers" />
23
23
  </a>
24
24
  &nbsp;
25
- <img src="https://img.shields.io/badge/136%20MCP%20tools-violet" alt="136 MCP tools" />
25
+ <img src="https://img.shields.io/badge/140%20MCP%20tools-violet" alt="140 MCP tools" />
26
26
  &nbsp;
27
27
  <img src="https://img.shields.io/badge/License-Apache--2.0-yellow" alt="License: Apache-2.0" />
28
28
  </p>
29
29
 
30
+ <p align="center">
31
+ <strong><a href="https://cachly.dev/sign-up">⚡ Get your free Brain → cachly.dev</a></strong><br/>
32
+ <sub>Free forever · no credit card · 1-command setup · German servers · GDPR</sub>
33
+ </p>
34
+
30
35
  ---
31
36
 
32
37
  ## The story you already live every day
@@ -286,7 +291,7 @@ MCP tool — bulk-ingests past outcomes the same way `brain_from_git` ingests co
286
291
 
287
292
  ---
288
293
 
289
- ## MCP Tools (136 total)
294
+ ## MCP Tools (140 total)
290
295
 
291
296
  ### 🧠 Session & Memory (most used)
292
297
 
@@ -7,8 +7,15 @@ import { keywordSearch, tokenize, splitMultiQuery, levenshtein, indexVocab as _i
7
7
  import { rerankByQuality } from '../rerank.js';
8
8
  import { computeEmbedding, hasEmbedProvider } from '../embeddings.js';
9
9
  // ── Changelog (shown once per version in session_start) ──────────────────────
10
- const MCP_VERSION = '0.10.114';
10
+ const MCP_VERSION = '0.10.115';
11
11
  const WHATS_NEW = {
12
+ '0.10.115': [
13
+ `🔧 **\`brain_get_pref\` + \`brain_set_pref\` — persistent Brain preferences**`,
14
+ ` 📖 \`brain_get_pref\` reads back any stored preference (omit key to list all).`,
15
+ ` ✏️ \`brain_set_pref\` saves a preference to Redis — survives restarts, scoped per Brain instance.`,
16
+ ` 💡 Known key: \`auto_changelog\` — set to "false" to silence the new-lessons digest at session_start.`,
17
+ ` 📊 140 MCP tools`,
18
+ ],
12
19
  '0.10.114': [
13
20
  `📋 **\`brain_changelog\` — weekly knowledge digest in one call**`,
14
21
  ` 📅 Generates a grouped Markdown changelog of lessons learned in the last N days.`,
@@ -356,6 +363,7 @@ export const BRAIN_TOOL_NAMES = new Set([
356
363
  'brain_who_knows', 'brain_file_map', 'team_expertise_map',
357
364
  'skill_gaps', 'brain_coverage', 'brain_metrics', 'brain_service_map',
358
365
  'brain_collab_pairs', 'brain_portability', 'brain_changelog',
366
+ 'brain_set_pref', 'brain_get_pref',
359
367
  ]);
360
368
  // ── Free-tier Teaser-Gate ──────────────────────────────────────────────────
361
369
  // When a Free-tier Brain crosses its recall limit, smart_recall still delivers
@@ -364,7 +372,6 @@ export const BRAIN_TOOL_NAMES = new Set([
364
372
  // DEPTH gate (hide the tail), never an access wall: the #1 result is always
365
373
  // returned. Usage/limit come from the same authoritative numbers as the Brain
366
374
  // Health bar (GET /instances/:id/memory), cached briefly to avoid per-call latency.
367
- const FREE_TIER_VISIBLE_RECALLS = 3;
368
375
  const UPGRADE_URL = 'https://cachly.dev/billing';
369
376
  const _recallGateCache = new Map();
370
377
  async function getRecallGate(instanceId, apiFetch) {
@@ -375,7 +382,12 @@ async function getRecallGate(instanceId, apiFetch) {
375
382
  const mem = await apiFetch(`/api/v1/instances/${instanceId}/memory`);
376
383
  const limit = mem?.recall_limit ?? -1;
377
384
  const used = mem?.total_recall_count ?? 0;
378
- const gate = { reached: limit > 0 && used >= limit, limit, used };
385
+ const gate = {
386
+ reached: limit > 0 && used >= limit,
387
+ limit,
388
+ used,
389
+ goodwillMessage: mem?.goodwill_message,
390
+ };
379
391
  _recallGateCache.set(instanceId, { gate, expiresAt: Date.now() + 60_000 });
380
392
  return gate;
381
393
  }
@@ -1089,10 +1101,18 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
1089
1101
  .sort((a, b) => b.hybridScore - a.hybridScore);
1090
1102
  // ── Teaser-Gate: free tier over its recall limit hides the long tail ──────
1091
1103
  const recallGate = await getRecallGate(instance_id, apiFetch);
1092
- const gateActive = recallGate.reached && hybridResults.length > FREE_TIER_VISIBLE_RECALLS;
1093
- const visibleCount = gateActive ? FREE_TIER_VISIBLE_RECALLS : 8;
1104
+ // Hard gate: once a free user is over their (monthly, goodwill-adjusted)
1105
+ // recall limit, the long tail is fully withheld until they upgrade or the
1106
+ // month resets. We still show the single highest-value proven lesson below
1107
+ // as a teaser so the tool isn't dead and the value is obvious.
1108
+ const gateActive = recallGate.reached;
1109
+ const visibleCount = gateActive ? 0 : 8;
1094
1110
  // ── Build output ──────────────────────────────────────────────────────────
1095
1111
  const lines = [`🧠 **Smart Recall** for: _"${query}"_\n`];
1112
+ // Warm goodwill note — shown the month a free user is gifted +250 recalls.
1113
+ if (recallGate.goodwillMessage) {
1114
+ lines.push(`${recallGate.goodwillMessage}\n`);
1115
+ }
1096
1116
  // "Brain saved you here" banner — surfaces value inline for proven recalled lessons
1097
1117
  const SORDER = { critical: 0, major: 1, minor: 2 };
1098
1118
  const topLesson = savedHere.sort((a, b) => (SORDER[a.severity] ?? 1) - (SORDER[b.severity] ?? 1))[0];
@@ -1140,7 +1160,7 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
1140
1160
  // Group by sub-query for multi-topic queries
1141
1161
  if (subQueries.length > 1) {
1142
1162
  const grouped = new Map();
1143
- for (const r of hybridResults.slice(0, gateActive ? FREE_TIER_VISIBLE_RECALLS : 12)) {
1163
+ for (const r of hybridResults.slice(0, gateActive ? 0 : 12)) {
1144
1164
  const sq = r.subQuery ?? query;
1145
1165
  if (!grouped.has(sq))
1146
1166
  grouped.set(sq, []);
@@ -1190,12 +1210,12 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
1190
1210
  lines.push(`> ${preview}${r.content.length > 400 ? '…' : ''}\n`);
1191
1211
  }
1192
1212
  }
1193
- // ── Teaser-Gate footer: sell the withheld tail at the moment of need ──
1213
+ // ── Hard gate footer: over the recall limit, the tail is withheld ──────
1194
1214
  if (gateActive) {
1195
1215
  const withheld = Math.max(0, hybridResults.length - visibleCount);
1196
- if (withheld > 0) {
1197
- lines.push(`🔒 **${withheld} more relevant lesson${withheld !== 1 ? 's' : ''} available** — you've reached your Free recall limit (${recallGate.used}/${recallGate.limit}). Unlock your full Brain → ${UPGRADE_URL}\n`);
1198
- }
1216
+ lines.push(`🔒 **Free recall limit reached** (${recallGate.used}/${recallGate.limit} this month). ` +
1217
+ `${withheld} relevant lesson${withheld !== 1 ? 's' : ''} withheld. ` +
1218
+ `Your limit resets next month, or unlock your full Brain now → ${UPGRADE_URL}\n`);
1199
1219
  }
1200
1220
  }
1201
1221
  else {
@@ -2012,6 +2032,49 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
2012
2032
  reasons.push('stale lessons');
2013
2033
  lines.push(`🩺 _Run \`brain_doctor\` to fix: ${reasons.join(', ')}._`);
2014
2034
  }
2035
+ // ── Auto-Changelog — new lessons since last session ──────────────────────
2036
+ // Appended at the end so it never competes with the core briefing.
2037
+ // Skipped on: first session, no new lessons, or when disabled via prefs.
2038
+ try {
2039
+ const autoChangelogPref = await redis.get(`cachly:prefs:auto_changelog:${instance_id}`).catch(() => null);
2040
+ const autoChangelogEnabled = autoChangelogPref !== 'false';
2041
+ if (autoChangelogEnabled && lastSession?.ts && !isFirstSession) {
2042
+ const lastTs = new Date(lastSession.ts).getTime();
2043
+ if (!Number.isNaN(lastTs)) {
2044
+ const newLessons = lessons.filter(l => {
2045
+ const t = new Date(l.ts).getTime();
2046
+ return !Number.isNaN(t) && t > lastTs;
2047
+ });
2048
+ if (newLessons.length > 0) {
2049
+ const ago = Math.round((Date.now() - lastTs) / 60000);
2050
+ const agoStr = ago < 60 ? `${ago}m` : ago < 1440 ? `${Math.round(ago / 60)}h` : `${Math.round(ago / 1440)}d`;
2051
+ lines.push('');
2052
+ lines.push(`📋 **${newLessons.length} new lesson${newLessons.length !== 1 ? 's' : ''} since your last session** (${agoStr} ago):`);
2053
+ // Group by category
2054
+ const catMap = new Map();
2055
+ for (const l of newLessons.slice(0, 20)) {
2056
+ const cat = l.topic.includes(':') ? l.topic.split(':')[0] : 'general';
2057
+ const arr = catMap.get(cat) ?? [];
2058
+ arr.push(l);
2059
+ catMap.set(cat, arr);
2060
+ }
2061
+ for (const [cat, catLessons] of catMap) {
2062
+ lines.push(` **${cat}**`);
2063
+ for (const l of catLessons) {
2064
+ const icon = l.outcome === 'failure' ? '❌' : l.severity === 'critical' ? '🚨' : l.severity === 'major' ? '⚠️' : '✅';
2065
+ const body = (l.outcome === 'failure' ? l.what_failed : l.what_worked) ?? '';
2066
+ lines.push(` ${icon} \`${l.topic}\`${body ? ` — ${body.slice(0, 80)}` : ''}`);
2067
+ }
2068
+ }
2069
+ if (newLessons.length > 20) {
2070
+ lines.push(` … and ${newLessons.length - 20} more. Call \`brain_changelog\` for the full list.`);
2071
+ }
2072
+ lines.push(` _Disable auto-changelog: \`brain_set_pref(instance_id="${instance_id}", key="auto_changelog", value="false")\`_`);
2073
+ }
2074
+ }
2075
+ }
2076
+ }
2077
+ catch { /* changelog must never block session_start */ }
2015
2078
  return lines.join('\n');
2016
2079
  }
2017
2080
  // ── session_start_summary ─────────────────────────────────────────────────
@@ -3279,7 +3342,6 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
3279
3342
  const { instance_id, days = 7, max_lessons = 30, include_failures = true, } = args;
3280
3343
  const redis = await getConnection(instance_id);
3281
3344
  const cutoff = Date.now() - Number(days) * 86_400_000;
3282
- // Scan all lessons and collect recent ones.
3283
3345
  const keys = await scanKeys(redis, 'cachly:lesson:best:*', { max: 4000, timeoutMs: 4000 });
3284
3346
  const recent = [];
3285
3347
  for (const k of keys) {
@@ -3295,7 +3357,6 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
3295
3357
  const topic = k.replace('cachly:lesson:best:', '');
3296
3358
  recent.push({ ...lesson, topic });
3297
3359
  }
3298
- // Sort by timestamp desc, cap at max_lessons.
3299
3360
  recent.sort((a, b) => {
3300
3361
  const ta = a.ts ? new Date(a.ts).getTime() : 0;
3301
3362
  const tb = b.ts ? new Date(b.ts).getTime() : 0;
@@ -3309,7 +3370,6 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
3309
3370
  `_No new lessons learned in this window. Your Brain already knows everything it knows — add new lessons with \`learn_from_attempts\`._`,
3310
3371
  ].join('\n');
3311
3372
  }
3312
- // Group by category (topic prefix before first ':').
3313
3373
  const groups = new Map();
3314
3374
  for (const l of lessons) {
3315
3375
  const cat = l.topic.includes(':') ? l.topic.split(':')[0] : 'general';
@@ -3348,6 +3408,37 @@ export async function handleBrainTool(name, args, getConnection, apiFetch) {
3348
3408
  lines.push('---', `_Generated from Brain \`${instance_id}\` · Share this in your standup or Slack_`);
3349
3409
  return lines.join('\n');
3350
3410
  }
3411
+ // ── brain_set_pref ────────────────────────────────────────────────────────
3412
+ case 'brain_set_pref': {
3413
+ const { instance_id, key, value, } = args;
3414
+ if (!key || !key.trim())
3415
+ throw new Error('key is required');
3416
+ const redis = await getConnection(instance_id);
3417
+ const redisKey = `cachly:prefs:${key.trim()}:${instance_id}`;
3418
+ await redis.set(redisKey, String(value));
3419
+ return `✅ Preference \`${key}\` set to \`${value}\` for this Brain instance.`;
3420
+ }
3421
+ // ── brain_get_pref ────────────────────────────────────────────────────────
3422
+ case 'brain_get_pref': {
3423
+ const { instance_id, key } = args;
3424
+ const redis = await getConnection(instance_id);
3425
+ if (key && key.trim()) {
3426
+ const val = await redis.get(`cachly:prefs:${key.trim()}:${instance_id}`).catch(() => null);
3427
+ return val === null
3428
+ ? `ℹ️ Preference \`${key}\` is not set (using default).`
3429
+ : `🔧 Preference \`${key}\` = \`${val}\``;
3430
+ }
3431
+ const prefKeys = await scanKeys(redis, `cachly:prefs:*:${instance_id}`, { max: 200, timeoutMs: 2000 });
3432
+ if (prefKeys.length === 0)
3433
+ return 'ℹ️ No preferences set for this Brain instance.';
3434
+ const vals = await redis.mget(...prefKeys);
3435
+ const lines = ['🔧 **Brain preferences:**', ''];
3436
+ for (let i = 0; i < prefKeys.length; i++) {
3437
+ const k = prefKeys[i].replace('cachly:prefs:', '').replace(`:${instance_id}`, '');
3438
+ lines.push(`- \`${k}\` = \`${vals[i] ?? '(null)'}\``);
3439
+ }
3440
+ return lines.join('\n');
3441
+ }
3351
3442
  // ── sync_file_changes ─────────────────────────────────────────────────────
3352
3443
  default:
3353
3444
  return null;