@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 +171 -21
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +193 -28
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
|
|
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,
|
|
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
|
-
// ──
|
|
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
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
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
|
|
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
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
2283
|
-
const
|
|
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
|
-
|
|
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
|
}
|