@cachly-dev/mcp-server 0.5.5 → 0.5.6

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
@@ -62,6 +62,39 @@ const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
62
62
  * └──────────────────────────────────────────────────────────────────────┘
63
63
  */
64
64
  const EMBED_PROVIDER = (process.env.CACHLY_EMBED_PROVIDER ?? detectEmbedProvider()).toLowerCase();
65
+ // ── Confidence Decay Config (all values configurable via env) ─────────────────
66
+ const CONFIDENCE_WARN_DAYS = Number(process.env.CACHLY_CONFIDENCE_WARN_DAYS ?? 5); // → 0.7
67
+ const CONFIDENCE_STALE_DAYS = Number(process.env.CACHLY_CONFIDENCE_STALE_DAYS ?? 10); // → 0.5
68
+ const CONFIDENCE_WARN_VALUE = 0.7;
69
+ const CONFIDENCE_STALE_VALUE = 0.5;
70
+ /** Calculate current confidence for a lesson based on how long since last verified. */
71
+ function calculateConfidence(lesson) {
72
+ const ref = lesson.verified_at ?? lesson.ts;
73
+ const ageMs = Date.now() - new Date(ref).getTime();
74
+ const ageDays = ageMs / 86400000;
75
+ if (ageDays >= CONFIDENCE_STALE_DAYS)
76
+ return CONFIDENCE_STALE_VALUE;
77
+ if (ageDays >= CONFIDENCE_WARN_DAYS)
78
+ return CONFIDENCE_WARN_VALUE;
79
+ // Linear interpolation between fresh (1.0) and warn threshold
80
+ return 1.0 - (ageDays / CONFIDENCE_WARN_DAYS) * (1.0 - CONFIDENCE_WARN_VALUE);
81
+ }
82
+ /** Render a confidence badge string. */
83
+ function confidenceBadge(confidence, ageDays) {
84
+ if (confidence >= 0.9)
85
+ return '✅';
86
+ if (confidence >= 0.7)
87
+ return `⚠️ (${Math.round(ageDays)}d old, confidence ${(confidence * 100).toFixed(0)}% — verify before applying)`;
88
+ return `🔴 STALE (${Math.round(ageDays)}d old, confidence ${(confidence * 100).toFixed(0)}% — likely outdated!)`;
89
+ }
90
+ /** Category-specific required fields for structured lessons. */
91
+ const STRUCTURED_TEMPLATES = {
92
+ 'deploy': { required: ['commands'], hint: 'deploy:* needs commands[]' },
93
+ 'bash': { required: ['commands'], hint: 'bash:* needs commands[]' },
94
+ 'infra': { required: ['commands'], hint: 'infra:* needs commands[] + verified on real system' },
95
+ 'pricing': { required: [], hint: 'pricing:* — add context with source (e.g. "Stripe Dashboard Apr 2026")' },
96
+ 'stripe': { required: [], hint: 'stripe:* — add context with Stripe API version' },
97
+ };
65
98
  /** Fast content hash for index invalidation (not cryptographic, just change detection) */
66
99
  function simpleHash(text) {
67
100
  return createHash('md5').update(text).digest('hex').slice(0, 12);
@@ -1142,7 +1175,9 @@ const TOOLS = [
1142
1175
  'Fields: topic (short slug like "deploy:web"), outcome ("success"|"failure"), ' +
1143
1176
  'what_worked (what solved it), what_failed (what did NOT work), context (extra details). ' +
1144
1177
  'Supports structured metadata: severity, file_paths (files involved), commands (working commands), tags. ' +
1145
- 'Deduplication: if a lesson for this topic already exists, it is updated instead of duplicated. ' +
1178
+ 'Deduplication: if a lesson for this topic already exists, it is updated with full audit trail. ' +
1179
+ 'Contradiction detection: warns if new outcome conflicts with existing lesson outcome. ' +
1180
+ 'Confidence: lesson starts at 1.0, decays after 5d (→0.7) and 10d (→0.5) without recall. ' +
1146
1181
  'Example: learn_from_attempts(topic="deploy:api", outcome="success", what_worked="nohup docker compose up -d --build", what_failed="docker compose up hangs on SSH timeout", severity="critical", commands=["nohup docker compose up -d --build"])',
1147
1182
  inputSchema: {
1148
1183
  type: 'object',
@@ -1181,7 +1216,9 @@ const TOOLS = [
1181
1216
  name: 'recall_best_solution',
1182
1217
  description: 'Recall the best known solution for a topic from past lessons. ' +
1183
1218
  'Call this BEFORE attempting any task that might have been done before. ' +
1184
- 'Returns the most recent successful lesson for the topic, or a summary of attempts. ' +
1219
+ 'Returns the most recent successful lesson for the topic, with confidence indicator. ' +
1220
+ '⚠️ badge = lesson is >5d old (verify before applying). 🔴 = >10d old (likely stale!). ' +
1221
+ 'Recalling a lesson resets its confidence clock to 1.0 (marks as recently verified). ' +
1185
1222
  'Example: recall_best_solution(topic="deploy:web") → returns the working deploy command.',
1186
1223
  inputSchema: {
1187
1224
  type: 'object',
@@ -2219,21 +2256,50 @@ async function handleTool(name, args) {
2219
2256
  const { instance_id, topic, outcome, what_worked, what_failed = '', context: ctx = '', severity = 'major', file_paths = [], commands = [], tags = [], } = args;
2220
2257
  const redis = await getConnection(instance_id);
2221
2258
  const ts = new Date().toISOString();
2222
- // ── Deduplication: check for existing lesson with same topic ─────────────
2259
+ // ── Structured template hints ──────────────────────────────────────────
2260
+ const category = topic.split(':')[0];
2261
+ const template = STRUCTURED_TEMPLATES[category];
2262
+ const templateWarnings = [];
2263
+ if (template) {
2264
+ for (const req of template.required) {
2265
+ if (req === 'commands' && commands.length === 0) {
2266
+ templateWarnings.push(`📋 ${template.hint}`);
2267
+ }
2268
+ }
2269
+ }
2270
+ // ── Deduplication + audit trail ────────────────────────────────────────
2223
2271
  let isUpdate = false;
2224
2272
  let recallCount = 0;
2225
- if (outcome === 'success') {
2226
- const existing = await redis.get(`cachly:lesson:best:${topic}`);
2227
- if (existing) {
2228
- try {
2229
- const prev = JSON.parse(existing);
2230
- recallCount = (prev.recall_count ?? 0); // preserve existing recall count
2231
- isUpdate = true;
2273
+ let auditTrail = [];
2274
+ const existingRaw = await redis.get(`cachly:lesson:best:${topic}`);
2275
+ if (existingRaw) {
2276
+ try {
2277
+ const prev = JSON.parse(existingRaw);
2278
+ recallCount = prev.recall_count ?? 0;
2279
+ auditTrail = prev.audit_trail ?? [];
2280
+ auditTrail.push({ ts, action: 'updated', prev_outcome: prev.outcome });
2281
+ if (auditTrail.length > 20)
2282
+ auditTrail = auditTrail.slice(-20);
2283
+ isUpdate = true;
2284
+ // ── Contradiction detection ─────────────────────────────────────────
2285
+ const contradictionWarning = [];
2286
+ if (prev.outcome === 'success' && outcome === 'failure') {
2287
+ contradictionWarning.push(`⚠️ **Contradiction detected!** Existing lesson has outcome: \`success\`, but you're storing \`failure\`.`, `The existing "success" lesson will be preserved. Only the audit trail is updated.`, `If you meant to mark this as failed permanently, store a new lesson with a distinct topic slug.`);
2288
+ }
2289
+ else if (prev.outcome === 'failure' && outcome === 'success') {
2290
+ contradictionWarning.push(`✅ **Conflict resolved!** Previous lesson was \`failure\` — now overwriting with \`success\`.`);
2291
+ }
2292
+ if (contradictionWarning.length > 0) {
2293
+ // Store contradiction audit but don't block
2294
+ auditTrail[auditTrail.length - 1].action = 'contradiction-resolved';
2232
2295
  }
2233
- catch { /* ignore parse error */ }
2234
2296
  }
2297
+ catch { /* ignore parse error */ }
2298
+ }
2299
+ else {
2300
+ auditTrail = [{ ts, action: 'created' }];
2235
2301
  }
2236
- const lesson = JSON.stringify({
2302
+ const lessonObj = {
2237
2303
  topic,
2238
2304
  outcome,
2239
2305
  what_worked,
@@ -2245,15 +2311,30 @@ async function handleTool(name, args) {
2245
2311
  tags,
2246
2312
  recall_count: recallCount,
2247
2313
  ts,
2248
- version: 2,
2249
- });
2250
- // Always append to the history list
2314
+ verified_at: outcome === 'success' || outcome === 'partial' ? ts : undefined,
2315
+ confidence: 1.0,
2316
+ audit_trail: auditTrail,
2317
+ version: 3,
2318
+ };
2319
+ const lesson = JSON.stringify(lessonObj);
2320
+ // Always append to the history list (audit log)
2251
2321
  const listKey = `cachly:lessons:${topic}`;
2252
2322
  await redis.rpush(listKey, lesson);
2253
- // Update best key only for success/partial (not for failures)
2323
+ // Update best key for success/partial; for failure only update if no success exists
2254
2324
  if (outcome === 'success' || outcome === 'partial') {
2255
2325
  await redis.set(`cachly:lesson:best:${topic}`, lesson);
2256
2326
  }
2327
+ else if (!existingRaw) {
2328
+ await redis.set(`cachly:lesson:best:${topic}`, lesson);
2329
+ }
2330
+ // Track in decision log for session replay
2331
+ try {
2332
+ const dlKey = 'cachly:session:decision-log';
2333
+ const dlEntry = JSON.stringify({ ts, topic, outcome, what_worked: what_worked.slice(0, 120) });
2334
+ await redis.rpush(dlKey, dlEntry);
2335
+ await redis.ltrim(dlKey, -50, -1);
2336
+ }
2337
+ catch { /* non-critical */ }
2257
2338
  const emoji = outcome === 'success' ? '✅' : outcome === 'partial' ? '⚠️' : '❌';
2258
2339
  const sevEmoji = severity === 'critical' ? '🔴' : severity === 'major' ? '🟡' : '🟢';
2259
2340
  const action = isUpdate ? 'updated' : 'stored';
@@ -2268,8 +2349,9 @@ async function handleTool(name, args) {
2268
2349
  tags.length > 0 ? `**Tags:** ${tags.map(t => `#${t}`).join(' ')}` : '',
2269
2350
  ``,
2270
2351
  isUpdate
2271
- ? `♻️ Updated existing lesson (preserved recall count: ${recallCount})`
2352
+ ? `♻️ Updated (recall count: ${recallCount} · audit entries: ${auditTrail.length})`
2272
2353
  : `💡 Recall later with \`recall_best_solution(topic="${topic}")\``,
2354
+ ...templateWarnings,
2273
2355
  ].filter(l => l !== '').join('\n');
2274
2356
  }
2275
2357
  case 'recall_best_solution': {
@@ -2279,12 +2361,25 @@ async function handleTool(name, args) {
2279
2361
  const best = await redis.get(`cachly:lesson:best:${topic}`);
2280
2362
  if (best) {
2281
2363
  const lesson = JSON.parse(best);
2282
- // Increment recall_count
2283
- const updatedLesson = { ...lesson, recall_count: (lesson.recall_count ?? 0) + 1 };
2364
+ // ── Confidence decay check ───────────────────────────────────────────
2365
+ const confidence = calculateConfidence(lesson);
2366
+ const ref = lesson.verified_at ?? lesson.ts;
2367
+ const ageDays = (Date.now() - new Date(ref).getTime()) / 86400000;
2368
+ const badge = confidenceBadge(confidence, ageDays);
2369
+ // Recall resets verified_at (confidence clock restart)
2370
+ const updatedLesson = {
2371
+ ...lesson,
2372
+ recall_count: (lesson.recall_count ?? 0) + 1,
2373
+ verified_at: new Date().toISOString(),
2374
+ confidence: 1.0,
2375
+ };
2284
2376
  await redis.set(`cachly:lesson:best:${topic}`, JSON.stringify(updatedLesson));
2285
2377
  const sevEmoji = lesson.severity === 'critical' ? '🔴' : lesson.severity === 'major' ? '🟡' : lesson.severity ? '🟢' : '';
2378
+ const auditSummary = (lesson.audit_trail ?? []).length > 1
2379
+ ? `\n_Audit: ${(lesson.audit_trail ?? []).length} changes · ${new Date(lesson.ts).toLocaleDateString('de-DE')}_`
2380
+ : '';
2286
2381
  return [
2287
- `✅ **Best solution for \`${topic}\`** ${sevEmoji}${lesson.severity ? ` (${lesson.severity})` : ''} · recalled ${updatedLesson.recall_count}× · ${new Date(lesson.ts).toLocaleDateString('de-DE')}`,
2382
+ `${badge} **Best solution for \`${topic}\`** ${sevEmoji}${lesson.severity ? ` (${lesson.severity})` : ''} · recalled ${updatedLesson.recall_count}×`,
2288
2383
  ``,
2289
2384
  `**What worked:** ${lesson.what_worked}`,
2290
2385
  lesson.what_failed ? `**What failed (avoid this):** ${lesson.what_failed}` : '',
@@ -2292,6 +2387,7 @@ async function handleTool(name, args) {
2292
2387
  (lesson.file_paths ?? []).length > 0 ? `**Files:** ${(lesson.file_paths ?? []).map((f) => `\`${f}\``).join(', ')}` : '',
2293
2388
  (lesson.commands ?? []).length > 0 ? `**Commands:** ${(lesson.commands ?? []).map((c) => `\`${c}\``).join(', ')}` : '',
2294
2389
  (lesson.tags ?? []).length > 0 ? `**Tags:** ${(lesson.tags ?? []).map((t) => `#${t}`).join(' ')}` : '',
2390
+ auditSummary,
2295
2391
  ].filter(l => l !== '').join('\n');
2296
2392
  }
2297
2393
  // Partial match: scan all lesson keys for topic substring
@@ -2611,6 +2707,36 @@ async function handleTool(name, args) {
2611
2707
  }
2612
2708
  lines.push('');
2613
2709
  }
2710
+ // ── Stale / low-confidence lessons (confidence decay) ─────────────────
2711
+ const staleSuccessLessons = lessons.filter(l => {
2712
+ if (l.outcome !== 'success' && l.outcome !== 'partial')
2713
+ return false;
2714
+ return calculateConfidence(l) < CONFIDENCE_WARN_VALUE;
2715
+ });
2716
+ if (staleSuccessLessons.length > 0) {
2717
+ lines.push(`🔴 **Stale lessons** (not recalled in >${CONFIDENCE_WARN_DAYS}d — verify before applying):`);
2718
+ for (const l of staleSuccessLessons.slice(0, 4)) {
2719
+ const conf = calculateConfidence(l);
2720
+ const ageDays = Math.round((Date.now() - new Date(l.verified_at ?? l.ts).getTime()) / 86400000);
2721
+ const flag = conf < CONFIDENCE_STALE_VALUE ? '🔴' : '⚠️';
2722
+ lines.push(` ${flag} \`${l.topic}\` — ${ageDays}d stale, ${(conf * 100).toFixed(0)}% confidence`);
2723
+ }
2724
+ lines.push(` _Run \`recall_best_solution\` on these to reset their confidence clock._`);
2725
+ lines.push('');
2726
+ }
2727
+ // ── Session Replay: show last session's decision log ──────────────────
2728
+ const lastSessionAny = lastSession;
2729
+ if (lastSessionAny?.decision_log?.length) {
2730
+ const dl = lastSessionAny.decision_log;
2731
+ const successes = dl.filter(d => d.outcome === 'success');
2732
+ const failures = dl.filter(d => d.outcome === 'failure');
2733
+ lines.push(`🎬 **Last session decisions** (${dl.length} lessons stored):`);
2734
+ if (successes.length > 0)
2735
+ lines.push(` ✅ Worked: ${successes.slice(0, 3).map(d => `\`${d.topic}\``).join(', ')}`);
2736
+ if (failures.length > 0)
2737
+ lines.push(` ❌ Failed: ${failures.slice(0, 3).map(d => `\`${d.topic}\``).join(', ')}`);
2738
+ lines.push('');
2739
+ }
2614
2740
  return lines.join('\n');
2615
2741
  }
2616
2742
  // ── session_end ───────────────────────────────────────────────────────────
@@ -2628,12 +2754,21 @@ async function handleTool(name, args) {
2628
2754
  }
2629
2755
  catch { /* ignore */ }
2630
2756
  }
2757
+ // ── Session Replay: capture decision log ─────────────────────────────
2758
+ let decisionLog = [];
2759
+ try {
2760
+ const dlEntries = await redis.lrange('cachly:session:decision-log', 0, -1);
2761
+ decisionLog = dlEntries.map(e => JSON.parse(e));
2762
+ await redis.del('cachly:session:decision-log');
2763
+ }
2764
+ catch { /* non-critical */ }
2631
2765
  const sessionRecord = {
2632
2766
  ts: now.toISOString(),
2633
2767
  summary,
2634
2768
  files_changed,
2635
2769
  ...(lessons_learned !== undefined ? { lessons_learned } : {}),
2636
2770
  ...(durationMin !== undefined ? { duration_min: durationMin } : {}),
2771
+ ...(decisionLog.length > 0 ? { decision_log: decisionLog } : {}),
2637
2772
  };
2638
2773
  // Save as "last session"
2639
2774
  await redis.set('cachly:session:last', JSON.stringify(sessionRecord));
@@ -3000,6 +3135,10 @@ async function handleTool(name, args) {
3000
3135
  const unusedLessons = lessons.filter(l => (l.recall_count ?? 0) === 0);
3001
3136
  // Critical lessons
3002
3137
  const criticalLessons = lessons.filter(l => l.severity === 'critical');
3138
+ // Confidence decay analysis
3139
+ const staleLessons = lessons.filter(l => l.outcome === 'success' && calculateConfidence(l) < CONFIDENCE_STALE_VALUE);
3140
+ const warnLessons = lessons.filter(l => l.outcome === 'success' && calculateConfidence(l) >= CONFIDENCE_STALE_VALUE && calculateConfidence(l) < CONFIDENCE_WARN_VALUE);
3141
+ const withAudit = lessons.filter(l => (l.audit_trail ?? []).length > 1);
3003
3142
  // Quality score (0-100)
3004
3143
  let score = 50;
3005
3144
  if (lessonKeys.length >= 5)
@@ -3017,10 +3156,14 @@ async function handleTool(name, args) {
3017
3156
  const unusedRatio = lessons.length > 0 ? unusedLessons.length / lessons.length : 0;
3018
3157
  if (unusedRatio < 0.5)
3019
3158
  score += 10;
3159
+ if (staleLessons.length === 0)
3160
+ score += 5;
3020
3161
  const scoreEmoji = score >= 80 ? '🟢' : score >= 50 ? '🟡' : '🔴';
3021
3162
  checks.push(`${scoreEmoji} **Brain Quality Score: ${score}/100**`);
3022
- checks.push(`📚 **Lessons:** ${lessonKeys.length} (${criticalLessons.length} critical)`);
3163
+ checks.push(`📚 **Lessons:** ${lessonKeys.length} (${criticalLessons.length} critical · ${withAudit.length} with audit trail)`);
3023
3164
  checks.push(`💾 **Context entries:** ${ctxCount}`);
3165
+ checks.push(`🎯 **Confidence:** ${lessons.length - staleLessons.length - warnLessons.length} fresh · ${warnLessons.length} warn · ${staleLessons.length} stale`);
3166
+ checks.push(`⏱️ **Decay config:** warn after ${CONFIDENCE_WARN_DAYS}d · stale after ${CONFIDENCE_STALE_DAYS}d`);
3024
3167
  if (lastSession) {
3025
3168
  const ageMin = Math.round((Date.now() - new Date(lastSession.ts).getTime()) / 60000);
3026
3169
  const ageStr = ageMin < 60 ? `${ageMin}m` : ageMin < 1440 ? `${Math.round(ageMin / 60)}h` : `${Math.round(ageMin / 1440)}d`;
@@ -3041,6 +3184,13 @@ async function handleTool(name, args) {
3041
3184
  if (openFailures.length > 0) {
3042
3185
  issues.push(`⚠️ ${openFailures.length} unresolved failure${openFailures.length > 1 ? 's' : ''}: ${openFailures.slice(0, 3).map(l => `\`${l.topic}\``).join(', ')}`);
3043
3186
  }
3187
+ if (staleLessons.length > 0) {
3188
+ issues.push(`🔴 ${staleLessons.length} STALE lesson${staleLessons.length > 1 ? 's' : ''} (>${CONFIDENCE_STALE_DAYS}d, confidence <${CONFIDENCE_STALE_VALUE * 100}%): ${staleLessons.slice(0, 3).map(l => `\`${l.topic}\``).join(', ')}`);
3189
+ issues.push(` → Re-verify with \`recall_best_solution\` to reset confidence clock`);
3190
+ }
3191
+ if (warnLessons.length > 0) {
3192
+ issues.push(`⚠️ ${warnLessons.length} lesson${warnLessons.length > 1 ? 's' : ''} aging (>${CONFIDENCE_WARN_DAYS}d): ${warnLessons.slice(0, 3).map(l => `\`${l.topic}\``).join(', ')}`);
3193
+ }
3044
3194
  if (unusedRatio > 0.7 && lessons.length > 5) {
3045
3195
  issues.push(`💡 ${unusedLessons.length} lessons never recalled — verify topics match your workflow`);
3046
3196
  }