@cachly-dev/mcp-server 0.9.17 → 0.9.18

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.
@@ -0,0 +1,1322 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { calculateConfidence, confidenceBadge, STRUCTURED_TEMPLATES, CONFIDENCE_WARN_VALUE, CONFIDENCE_STALE_VALUE, CONFIDENCE_WARN_DAYS } from '../confidence.js';
3
+ import { ckgSlug, extractProblemConcept, ckgUpsertNode, ckgUpdateEdge } from '../ckg.js';
4
+ import { keywordSearch, tokenize, splitMultiQuery, levenshtein, indexVocab as _indexVocab } from '../search.js';
5
+ import { computeEmbedding, hasEmbedProvider } from '../embeddings.js';
6
+ export const BRAIN_TOOL_NAMES = new Set([
7
+ 'learn_from_attempts', 'recall_best_solution', 'smart_recall',
8
+ 'session_start', 'session_end', 'session_ping', 'session_handoff', 'auto_learn_session',
9
+ ]);
10
+ export async function handleBrainTool(name, args, getConnection, apiFetch) {
11
+ switch (name) {
12
+ case 'learn_from_attempts': {
13
+ const { instance_id, topic, outcome, what_worked, what_failed = '', context: ctx = '', severity = 'major', file_paths = [], commands = [], tags = [], depends_on = [], author = '', } = args;
14
+ const redis = await getConnection(instance_id);
15
+ const ts = new Date().toISOString();
16
+ // ── Structured template hints ──────────────────────────────────────────
17
+ const category = topic.split(':')[0];
18
+ const template = STRUCTURED_TEMPLATES[category];
19
+ const templateWarnings = [];
20
+ if (template) {
21
+ for (const req of template.required) {
22
+ if (req === 'commands' && commands.length === 0) {
23
+ templateWarnings.push(`📋 ${template.hint}`);
24
+ }
25
+ }
26
+ }
27
+ // ── Deduplication + audit trail ────────────────────────────────────────
28
+ let isUpdate = false;
29
+ let recallCount = 0;
30
+ let auditTrail = [];
31
+ const existingRaw = await redis.get(`cachly:lesson:best:${topic}`);
32
+ if (existingRaw) {
33
+ try {
34
+ const prev = JSON.parse(existingRaw);
35
+ recallCount = prev.recall_count ?? 0;
36
+ auditTrail = prev.audit_trail ?? [];
37
+ auditTrail.push({ ts, action: 'updated', prev_outcome: prev.outcome });
38
+ if (auditTrail.length > 20)
39
+ auditTrail = auditTrail.slice(-20);
40
+ isUpdate = true;
41
+ // ── Contradiction detection ─────────────────────────────────────────
42
+ const contradictionWarning = [];
43
+ if (prev.outcome === 'success' && outcome === 'failure') {
44
+ 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.`);
45
+ }
46
+ else if (prev.outcome === 'failure' && outcome === 'success') {
47
+ contradictionWarning.push(`✅ **Conflict resolved!** Previous lesson was \`failure\` — now overwriting with \`success\`.`);
48
+ }
49
+ if (contradictionWarning.length > 0) {
50
+ // Store contradiction audit but don't block
51
+ auditTrail[auditTrail.length - 1].action = 'contradiction-resolved';
52
+ // Layer 3: Write CKG contradicts edge for MADC to process
53
+ try {
54
+ const cId = ckgSlug(topic);
55
+ const resId = ckgSlug(`resolution:${topic}`);
56
+ await ckgUpdateEdge(redis, cId, 'contradicts', resId, false);
57
+ }
58
+ catch { /* non-critical */ }
59
+ contradictionWarning.push(`🗳️ Run \`madc_deliberate(topic="${topic}")\` to resolve via expert agent voting.`);
60
+ }
61
+ }
62
+ catch { /* ignore parse error */ }
63
+ }
64
+ else {
65
+ auditTrail = [{ ts, action: 'created' }];
66
+ }
67
+ // ── "I Was Wrong" Protocol — failure attribution ───────────────────────
68
+ const iWasWrongWarning = [];
69
+ if (outcome === 'failure') {
70
+ // Search for related success lessons that might have prevented this failure
71
+ const scanKeys = [];
72
+ const scanStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 100 });
73
+ await new Promise((res, rej) => {
74
+ scanStream.on('data', (b) => scanKeys.push(...b));
75
+ scanStream.on('end', res);
76
+ scanStream.on('error', rej);
77
+ });
78
+ const topicWords = topic.split(/[:\-_]/).filter(w => w.length > 2);
79
+ for (const k of scanKeys.slice(0, 50)) {
80
+ const raw = await redis.get(k);
81
+ if (!raw)
82
+ continue;
83
+ try {
84
+ const l = JSON.parse(raw);
85
+ if (l.outcome !== 'success')
86
+ continue;
87
+ const lWords = (l.topic ?? '').split(/[:\-_]/).filter(w => w.length > 2);
88
+ const overlap = topicWords.filter(w => lWords.includes(w)).length;
89
+ if (overlap >= 1 && l.topic !== topic) {
90
+ iWasWrongWarning.push(`⚠️ **"I Was Wrong"**: lesson \`${l.topic}\` (success, ${l.severity ?? 'major'}) might have prevented this failure.`, ` → Use \`recall_best_solution(topic="${l.topic}")\` before next attempt.`, ` → To mark it critical: \`learn_from_attempts(topic="${l.topic}", ..., severity="critical")\``);
91
+ break; // only show the most relevant match
92
+ }
93
+ }
94
+ catch { /* skip */ }
95
+ }
96
+ }
97
+ // ── Register dependency index for causal chain ─────────────────────────
98
+ for (const dep of depends_on) {
99
+ const depKey = `cachly:dep:${dep}`;
100
+ const existing = await redis.get(depKey);
101
+ const depTopics = existing ? JSON.parse(existing) : [];
102
+ if (!depTopics.includes(topic))
103
+ depTopics.push(topic);
104
+ await redis.set(depKey, JSON.stringify(depTopics));
105
+ }
106
+ const lessonObj = {
107
+ topic,
108
+ outcome,
109
+ what_worked,
110
+ what_failed,
111
+ context: ctx,
112
+ severity,
113
+ file_paths,
114
+ commands,
115
+ tags,
116
+ depends_on,
117
+ ...(author ? { author } : {}),
118
+ recall_count: recallCount,
119
+ ts,
120
+ verified_at: outcome === 'success' || outcome === 'partial' ? ts : undefined,
121
+ confidence: 1.0,
122
+ audit_trail: auditTrail,
123
+ version: 3,
124
+ };
125
+ const lesson = JSON.stringify(lessonObj);
126
+ // Always append to the history list (audit log)
127
+ const listKey = `cachly:lessons:${topic}`;
128
+ await redis.rpush(listKey, lesson);
129
+ // Update best key for success/partial; for failure only update if no success exists
130
+ if (outcome === 'success' || outcome === 'partial') {
131
+ await redis.set(`cachly:lesson:best:${topic}`, lesson);
132
+ }
133
+ else if (!existingRaw) {
134
+ await redis.set(`cachly:lesson:best:${topic}`, lesson);
135
+ }
136
+ // Track in decision log for session replay
137
+ try {
138
+ const dlKey = 'cachly:session:decision-log';
139
+ const dlEntry = JSON.stringify({ ts, topic, outcome, what_worked: what_worked.slice(0, 120) });
140
+ await redis.rpush(dlKey, dlEntry);
141
+ await redis.ltrim(dlKey, -50, -1);
142
+ }
143
+ catch { /* non-critical */ }
144
+ // ── Layer 1+2: CKG update (Causal Knowledge Graph + Belief Update Engine) ──
145
+ // BUE: Bayesian confidence, contradiction detection, second-degree propagation, decay
146
+ let beliefConflict = null;
147
+ try {
148
+ const conceptId = ckgSlug(topic);
149
+ const domain = topic.split(':')[0] ?? 'unknown';
150
+ const conceptType = domain; // fix, debug, deploy, infra, api, etc.
151
+ // Upsert concept node
152
+ await ckgUpsertNode(redis, conceptId, domain, conceptType);
153
+ // Tag co-occurrence edges
154
+ for (const tag of tags) {
155
+ const tagId = ckgSlug(`tag:${tag}`);
156
+ await ckgUpsertNode(redis, tagId, 'tag', 'tag');
157
+ await ckgUpdateEdge(redis, conceptId, 'co-occurs', tagId, outcome === 'success', outcome === 'partial');
158
+ }
159
+ // depends_on → requires edges (structural, always confidence 1.0 direction)
160
+ for (const dep of depends_on) {
161
+ const depId = ckgSlug(dep);
162
+ await ckgUpdateEdge(redis, conceptId, 'requires', depId, true);
163
+ }
164
+ // fixes edge: if category=fix and outcome=success, link to problem concept
165
+ if ((domain === 'fix' || domain === 'debug') && (outcome === 'success' || outcome === 'partial')) {
166
+ const problemText = what_failed || ctx || '';
167
+ const problemConcept = problemText ? extractProblemConcept(problemText) : null;
168
+ if (problemConcept) {
169
+ const problemId = ckgSlug(`problem:${problemConcept}`);
170
+ await ckgUpsertNode(redis, problemId, 'problem', 'problem');
171
+ await ckgUpdateEdge(redis, conceptId, 'fixes', problemId, outcome === 'success', outcome === 'partial');
172
+ }
173
+ }
174
+ // causes edge: if outcome=failure, link topic concept to the problem context
175
+ if (outcome === 'failure' && (what_failed || what_worked)) {
176
+ const causeText = what_failed || what_worked;
177
+ const causeConcept = extractProblemConcept(causeText);
178
+ if (causeConcept) {
179
+ const causeId = ckgSlug(`cause:${causeConcept}`);
180
+ await ckgUpsertNode(redis, causeId, 'cause', 'cause');
181
+ await ckgUpdateEdge(redis, conceptId, 'causes', causeId, false);
182
+ }
183
+ }
184
+ // ── BUE: Contradiction detection ──────────────────────────────────────
185
+ // If this topic previously had a confirmed 'fixes' edge (confidence > 0.7)
186
+ // and now outcome=failure → flag belief_conflict
187
+ if (outcome === 'failure') {
188
+ const existingEdgeKeys = await redis.smembers(`cachly:ckg:idx:from:${conceptId}`);
189
+ for (const ek of existingEdgeKeys) {
190
+ const er = await redis.get(ek);
191
+ if (!er)
192
+ continue;
193
+ const existEdge = JSON.parse(er);
194
+ if (existEdge.edgeType === 'fixes' && existEdge.confidence > 0.7 && existEdge.trials >= 3) {
195
+ 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.`;
196
+ // Store conflict marker
197
+ const conflictKey = `cachly:ckg:conflict:${conceptId}`;
198
+ 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);
199
+ // Auto-trigger MADC deliberation in background — never blocks learn_from_attempts
200
+ // Note: fire-and-forget; madc_deliberate is handled elsewhere in the switch
201
+ apiFetch(`/api/v1/instances/${instance_id}`).catch(() => undefined); // keep apiFetch warm
202
+ }
203
+ }
204
+ }
205
+ // ── BUE: Second-degree propagation ────────────────────────────────────
206
+ // When a 'fixes' edge gets stronger, boost co-occurring second-degree edges slightly
207
+ if (outcome === 'success' && (domain === 'fix' || domain === 'debug')) {
208
+ const fromEdgeKeys = await redis.smembers(`cachly:ckg:idx:from:${conceptId}`);
209
+ for (const ek of fromEdgeKeys.slice(0, 10)) {
210
+ const er = await redis.get(ek);
211
+ if (!er)
212
+ continue;
213
+ const e2 = JSON.parse(er);
214
+ if (e2.edgeType !== 'fixes')
215
+ continue;
216
+ // Boost second-degree: edges from e2.to get a small fractional success
217
+ const secondKeys = await redis.smembers(`cachly:ckg:idx:from:${e2.to}`);
218
+ for (const sk of secondKeys.slice(0, 5)) {
219
+ const sr = await redis.get(sk);
220
+ if (!sr)
221
+ continue;
222
+ const se = JSON.parse(sr);
223
+ if (se.edgeType !== 'co-occurs')
224
+ continue;
225
+ // Add 0.1 fractional success (second-degree signal)
226
+ se.successes = (se.successes || 0) + 0.1;
227
+ se.trials = (se.trials || 0) + 0.1;
228
+ se.confidence = (se.successes + 1) / (se.trials + 2);
229
+ se.last_updated = new Date().toISOString();
230
+ await redis.set(sk, JSON.stringify(se));
231
+ }
232
+ }
233
+ }
234
+ // ── BUE: Stale edge decay ─────────────────────────────────────────────
235
+ // Edges older than 90 days with < 3 trials decay by 10% confidence.
236
+ // Only run probabilistically (1% of calls) to avoid per-call overhead.
237
+ if (Math.random() < 0.01) {
238
+ const decayCutoff = Date.now() - 90 * 24 * 60 * 60 * 1000;
239
+ const fromEdgeKeys = await redis.smembers(`cachly:ckg:idx:from:${conceptId}`);
240
+ for (const ek of fromEdgeKeys) {
241
+ const er = await redis.get(ek);
242
+ if (!er)
243
+ continue;
244
+ const de = JSON.parse(er);
245
+ if (de.trials < 3 && de.last_updated && new Date(de.last_updated).getTime() < decayCutoff) {
246
+ de.confidence = de.confidence * 0.9;
247
+ de.last_updated = new Date().toISOString();
248
+ await redis.set(ek, JSON.stringify(de));
249
+ }
250
+ }
251
+ }
252
+ }
253
+ catch { /* CKG updates are non-critical */ }
254
+ const emoji = outcome === 'success' ? '✅' : outcome === 'partial' ? '⚠️' : '❌';
255
+ const sevEmoji = severity === 'critical' ? '🔴' : severity === 'major' ? '🟡' : '🟢';
256
+ const action = isUpdate ? 'updated' : 'stored';
257
+ return [
258
+ `${emoji} **Lesson ${action}:** \`${topic}\` (${outcome}) ${sevEmoji} ${severity}`,
259
+ beliefConflict ?? '',
260
+ ``,
261
+ `**What worked:** ${what_worked}`,
262
+ what_failed ? `**What failed:** ${what_failed}` : '',
263
+ ctx ? `**Context:** ${ctx}` : '',
264
+ file_paths.length > 0 ? `**Files:** ${file_paths.map(f => `\`${f}\``).join(', ')}` : '',
265
+ commands.length > 0 ? `**Commands:** ${commands.map(c => `\`${c}\``).join(', ')}` : '',
266
+ tags.length > 0 ? `**Tags:** ${tags.map(t => `#${t}`).join(' ')}` : '',
267
+ ``,
268
+ isUpdate
269
+ ? `♻️ Updated (recall count: ${recallCount} · audit entries: ${auditTrail.length})`
270
+ : `💡 Recall later with \`recall_best_solution(topic="${topic}")\``,
271
+ depends_on.length > 0
272
+ ? `🔗 Depends on: ${depends_on.map(d => `\`${d}\``).join(', ')} → trace with \`trace_dependency\``
273
+ : '',
274
+ ...templateWarnings,
275
+ ...iWasWrongWarning,
276
+ ].filter(l => l !== '').join('\n');
277
+ }
278
+ case 'recall_best_solution': {
279
+ const { instance_id, topic } = args;
280
+ const redis = await getConnection(instance_id);
281
+ // Try exact best-solution key first
282
+ const best = await redis.get(`cachly:lesson:best:${topic}`);
283
+ if (best) {
284
+ const lesson = JSON.parse(best);
285
+ // ── Confidence decay check ───────────────────────────────────────────
286
+ const confidence = calculateConfidence(lesson);
287
+ const ref = lesson.verified_at ?? lesson.ts;
288
+ const ageDays = (Date.now() - new Date(ref).getTime()) / 86400000;
289
+ const badge = confidenceBadge(confidence, ageDays);
290
+ // Recall resets verified_at (confidence clock restart)
291
+ const updatedLesson = {
292
+ ...lesson,
293
+ recall_count: (lesson.recall_count ?? 0) + 1,
294
+ verified_at: new Date().toISOString(),
295
+ confidence: 1.0,
296
+ };
297
+ await redis.set(`cachly:lesson:best:${topic}`, JSON.stringify(updatedLesson));
298
+ const sevEmoji = lesson.severity === 'critical' ? '🔴' : lesson.severity === 'major' ? '🟡' : lesson.severity ? '🟢' : '';
299
+ const auditSummary = (lesson.audit_trail ?? []).length > 1
300
+ ? `_Audit: ${(lesson.audit_trail ?? []).length} changes · stored ${new Date(lesson.ts).toLocaleDateString('de-DE')}_`
301
+ : '';
302
+ // "Remember when..." — emotional header for lessons > 60 days old
303
+ const ageFromStoreDays = (Date.now() - new Date(lesson.ts).getTime()) / 86400000;
304
+ const rememberWhen = ageFromStoreDays > 60
305
+ ? `💭 _Remember when you solved this ${Math.round(ageFromStoreDays / 30)} months ago? Still works._`
306
+ : '';
307
+ // "Never Google This Again" — suggest pinning after 3rd recall
308
+ const suggestPin = updatedLesson.recall_count === 3 && !lesson.pinned
309
+ ? `📌 **You've looked this up 3 times.** Consider pinning it for instant access: add \`pinned: true\` via \`learn_from_attempts\` to always surface it first.`
310
+ : '';
311
+ return [
312
+ rememberWhen,
313
+ `${badge} **Best solution for \`${topic}\`** ${sevEmoji}${lesson.severity ? ` (${lesson.severity})` : ''} · recalled ${updatedLesson.recall_count}×`,
314
+ ``,
315
+ `**What worked:** ${lesson.what_worked}`,
316
+ lesson.what_failed ? `**What failed (avoid this):** ${lesson.what_failed}` : '',
317
+ lesson.context ? `**Context:** ${lesson.context}` : '',
318
+ (lesson.file_paths ?? []).length > 0 ? `**Files:** ${(lesson.file_paths ?? []).map((f) => `\`${f}\``).join(', ')}` : '',
319
+ (lesson.commands ?? []).length > 0 ? `**Commands:** ${(lesson.commands ?? []).map((c) => `\`${c}\``).join(', ')}` : '',
320
+ (lesson.tags ?? []).length > 0 ? `**Tags:** ${(lesson.tags ?? []).map((t) => `#${t}`).join(' ')}` : '',
321
+ auditSummary,
322
+ suggestPin,
323
+ ].filter(l => l !== '').join('\n');
324
+ }
325
+ // Partial match: scan all lesson keys for topic substring
326
+ const allKeys = [];
327
+ const scanStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 100 });
328
+ await new Promise((resolve, reject) => {
329
+ scanStream.on('data', (batch) => allKeys.push(...batch));
330
+ scanStream.on('end', resolve);
331
+ scanStream.on('error', reject);
332
+ });
333
+ const matching = allKeys.filter(k => k.toLowerCase().includes(topic.toLowerCase()));
334
+ if (matching.length === 0) {
335
+ // Check attempt history as fallback
336
+ const histKey = `cachly:lessons:${topic}`;
337
+ const all = await redis.lrange(histKey, -3, -1);
338
+ if (all.length > 0) {
339
+ const parsed = all.map(e => JSON.parse(e));
340
+ const lines = parsed.map(p => `- ${p.outcome === 'success' ? '✅' : '❌'} ${p.what_worked.slice(0, 120)} (${new Date(p.ts).toLocaleDateString('de-DE')})`);
341
+ return `⚠️ No successful solution for \`${topic}\` yet. Last attempts:\n\n${lines.join('\n')}`;
342
+ }
343
+ return `📭 No lessons found for \`${topic}\`. Use \`learn_from_attempts\` after solving it.`;
344
+ }
345
+ // Return all partial matches
346
+ const results = [];
347
+ for (const k of matching.slice(0, 5)) {
348
+ const raw = await redis.get(k);
349
+ if (!raw)
350
+ continue;
351
+ const lesson = JSON.parse(raw);
352
+ results.push(`**\`${lesson.topic}\`** — ${lesson.what_worked.slice(0, 200)}`);
353
+ }
354
+ return `🔍 **Partial matches for \`${topic}\`:**\n\n${results.join('\n\n')}`;
355
+ }
356
+ case 'smart_recall': {
357
+ const { instance_id, query, threshold = 0.78, } = args;
358
+ const redis = await getConnection(instance_id);
359
+ // ── Layer 1: Keyword search across ALL brain data (always works, no embedding) ──
360
+ const kwMatches = await keywordSearch(redis, ['cachly:ctx:*', 'cachly:lesson:best:*', 'cachly:idx:*'], query, 10);
361
+ const lines = [`🧠 **Smart Recall** for: _"${query}"_\n`];
362
+ // Show sub-query info if multi-topic was detected
363
+ const subQueries = splitMultiQuery(query);
364
+ if (subQueries.length > 1) {
365
+ lines.push(`_Detected ${subQueries.length} sub-topics:_ ${subQueries.map((s, i) => `${i + 1}. "${s}"`).join(', ')}\n`);
366
+ }
367
+ if (kwMatches.length > 0) {
368
+ lines.push(`### 🔍 BM25 Matches (${kwMatches.length})\n`);
369
+ // Group by sub-query if multi-topic
370
+ if (subQueries.length > 1) {
371
+ const grouped = new Map();
372
+ for (const m of kwMatches.slice(0, 12)) {
373
+ const sq = m.subQuery ?? query;
374
+ if (!grouped.has(sq))
375
+ grouped.set(sq, []);
376
+ grouped.get(sq).push(m);
377
+ }
378
+ for (const [sq, matches] of grouped) {
379
+ lines.push(`**Topic: "${sq}"** (${matches.length} results)\n`);
380
+ for (const m of matches.slice(0, 4)) {
381
+ const label = m.key
382
+ .replace('cachly:ctx:', '📝 ')
383
+ .replace('cachly:lesson:best:', '💡 ')
384
+ .replace('cachly:idx:', '📂 ');
385
+ const preview = m.content.slice(0, 300).replace(/\n/g, ' ');
386
+ lines.push(` **${label}** _(BM25: ${m.score.toFixed(2)}, matched: ${m.matchedWords.join(', ')})_`);
387
+ lines.push(` > ${preview}${m.content.length > 300 ? '…' : ''}\n`);
388
+ }
389
+ }
390
+ // Summary: which sub-queries had matches
391
+ const matched = [...grouped.keys()];
392
+ const unmatched = subQueries.filter(sq => !matched.includes(sq));
393
+ if (unmatched.length > 0) {
394
+ lines.push(`\n⚠️ **No results for:** ${unmatched.map(s => `"${s}"`).join(', ')}`);
395
+ }
396
+ }
397
+ else {
398
+ for (const m of kwMatches.slice(0, 8)) {
399
+ const label = m.key
400
+ .replace('cachly:ctx:', '📝 ')
401
+ .replace('cachly:lesson:best:', '💡 ')
402
+ .replace('cachly:idx:', '📂 ');
403
+ const preview = m.content.slice(0, 400).replace(/\n/g, ' ');
404
+ lines.push(`**${label}** _(BM25: ${m.score.toFixed(2)}, matched: ${m.matchedWords.join(', ')})_`);
405
+ lines.push(`> ${preview}${m.content.length > 400 ? '…' : ''}\n`);
406
+ }
407
+ }
408
+ }
409
+ // ── Layer 2: Semantic search (optional, only if embedding provider + vector_token available) ──
410
+ const inst = await apiFetch(`/api/v1/instances/${instance_id}`);
411
+ if (inst.vector_token && hasEmbedProvider()) {
412
+ try {
413
+ const embedding = await computeEmbedding(query);
414
+ const vectorUrl = `https://api.cachly.dev/v1/sem/${inst.vector_token}`;
415
+ const searchRes = await fetch(`${vectorUrl}/search`, {
416
+ method: 'POST',
417
+ headers: { 'Content-Type': 'application/json' },
418
+ body: JSON.stringify({ embedding, namespace: 'cachly:ctx', threshold, top_k: 5 }),
419
+ });
420
+ if (searchRes.ok) {
421
+ const results = (await searchRes.json());
422
+ const semHits = results.filter(r => r.found && r.id);
423
+ if (semHits.length > 0) {
424
+ lines.push(`\n### 🎯 Semantic Matches (${semHits.length})\n`);
425
+ for (const hit of semHits) {
426
+ const parts = hit.id.replace('ctx:', '').split(':');
427
+ const category = parts[0];
428
+ const key = parts.slice(1).join(':');
429
+ const content = await redis.get(`cachly:ctx:${category}:${key}`);
430
+ lines.push(`**${key}** _(${((hit.similarity ?? 0) * 100).toFixed(0)}% similar)_`, `> ${content?.slice(0, 300) ?? '(evicted)'}${(content?.length ?? 0) > 300 ? '…' : ''}\n`);
431
+ }
432
+ }
433
+ }
434
+ }
435
+ catch {
436
+ // Semantic search failed silently — keyword results are enough
437
+ }
438
+ }
439
+ if (kwMatches.length === 0) {
440
+ lines.push(`⚠️ No matches found for: "${query}"`);
441
+ // Did-You-Mean: find nearest token in index vocab
442
+ const queryTokens = tokenize(query);
443
+ const suggestions = [];
444
+ if (_indexVocab.size > 0 && queryTokens.length > 0) {
445
+ for (const qt of queryTokens.slice(0, 3)) {
446
+ if (qt.length < 4)
447
+ continue;
448
+ let bestDist = 3;
449
+ let bestTok = '';
450
+ for (const v of _indexVocab) {
451
+ if (v.length < 3 || Math.abs(v.length - qt.length) > 4)
452
+ continue;
453
+ const d = levenshtein(qt, v);
454
+ if (d > 0 && d < bestDist) {
455
+ bestDist = d;
456
+ bestTok = v;
457
+ }
458
+ }
459
+ if (bestTok)
460
+ suggestions.push(`"${bestTok}" (instead of "${qt}")`);
461
+ }
462
+ }
463
+ if (suggestions.length > 0) {
464
+ lines.push(`💡 **Did you mean:** ${suggestions.join(', ')}?`);
465
+ }
466
+ else {
467
+ lines.push(`\n💡 Tips:`);
468
+ lines.push(` • Try different keywords`);
469
+ lines.push(` • Use \`list_remembered\` to see available context`);
470
+ lines.push(` • Use \`recall_best_solution("topic")\` for exact topic lookup`);
471
+ }
472
+ }
473
+ return lines.join('\n');
474
+ }
475
+ // ── get_api_status ────────────────────────────────────────────────────────
476
+ case 'session_start': {
477
+ const { instance_id, focus = '', author = '', provider = '', workspace_path = '' } = args;
478
+ const redis = await getConnection(instance_id);
479
+ // 1. Scan all best-solution lessons
480
+ const lessonKeys = [];
481
+ const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
482
+ await new Promise((resolve, reject) => {
483
+ lStream.on('data', (batch) => lessonKeys.push(...batch));
484
+ lStream.on('end', resolve);
485
+ lStream.on('error', reject);
486
+ });
487
+ const lessons = [];
488
+ for (const k of lessonKeys) {
489
+ const raw = await redis.get(k);
490
+ if (!raw)
491
+ continue;
492
+ try {
493
+ lessons.push(JSON.parse(raw));
494
+ }
495
+ catch { /* skip corrupt */ }
496
+ }
497
+ lessons.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
498
+ // 3. Count context entries (filter :meta keys)
499
+ let ctxCount = 0;
500
+ const ctxStream = redis.scanStream({ match: 'cachly:ctx:*', count: 200 });
501
+ await new Promise((resolve, reject) => {
502
+ ctxStream.on('data', (batch) => {
503
+ ctxCount += batch.filter((k) => !k.endsWith(':meta')).length;
504
+ });
505
+ ctxStream.on('end', resolve);
506
+ ctxStream.on('error', reject);
507
+ });
508
+ // 4. Last session
509
+ const lastSessionRaw = await redis.get('cachly:session:last');
510
+ let lastSession = null;
511
+ if (lastSessionRaw) {
512
+ try {
513
+ lastSession = JSON.parse(lastSessionRaw);
514
+ }
515
+ catch { /* ignore */ }
516
+ }
517
+ // 5. Focus filtering
518
+ const focusTerms = focus.toLowerCase().split(/\s+/).filter(Boolean);
519
+ const focusLessons = focusTerms.length > 0
520
+ ? lessons.filter(l => focusTerms.some(term => l.topic.toLowerCase().includes(term) ||
521
+ (l.tags ?? []).some((t) => t.toLowerCase().includes(term))))
522
+ : [];
523
+ // 6. Streak tracking
524
+ let streakDays = 0;
525
+ let streakRecord = 0;
526
+ let streakMessage = '';
527
+ try {
528
+ const streakRaw = await redis.get('cachly:streak:current');
529
+ const streak = streakRaw ? JSON.parse(streakRaw) : null;
530
+ const today = new Date().toISOString().slice(0, 10);
531
+ if (streak) {
532
+ const lastDate = streak.last_date;
533
+ const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
534
+ if (lastDate === today) {
535
+ // Already counted today
536
+ streakDays = streak.days;
537
+ streakRecord = streak.record;
538
+ }
539
+ else if (lastDate === yesterday) {
540
+ // Continuing streak
541
+ streakDays = streak.days + 1;
542
+ streakRecord = Math.max(streakDays, streak.record);
543
+ await redis.set('cachly:streak:current', JSON.stringify({ days: streakDays, last_date: today, record: streakRecord }));
544
+ }
545
+ else {
546
+ // Streak broken
547
+ streakDays = 1;
548
+ streakRecord = streak.record;
549
+ await redis.set('cachly:streak:current', JSON.stringify({ days: 1, last_date: today, record: streakRecord }));
550
+ }
551
+ }
552
+ else {
553
+ // First session ever
554
+ streakDays = 1;
555
+ streakRecord = 1;
556
+ await redis.set('cachly:streak:current', JSON.stringify({ days: 1, last_date: today, record: 1 }));
557
+ }
558
+ if (streakDays >= 7)
559
+ streakMessage = `🔥 **${streakDays}-day streak!** ${streakDays === streakRecord ? ' New record!' : `Best: ${streakRecord}d`}`;
560
+ else if (streakDays > 1)
561
+ streakMessage = `🔥 ${streakDays}-day streak`;
562
+ }
563
+ catch { /* non-critical */ }
564
+ // 7. Save session start marker
565
+ await redis.set('cachly:session:current', JSON.stringify({
566
+ started: new Date().toISOString(),
567
+ focus,
568
+ provider,
569
+ }), 'EX', 86400); // auto-expire after 24h if session_end never called
570
+ // ── Build briefing ──────────────────────────────────────────────────────
571
+ const providerLabel = provider ? ` · ${provider}` : '';
572
+ const lines = [`🧠 **Session Briefing**${providerLabel}`, ''];
573
+ if (streakMessage)
574
+ lines.push(streakMessage, '');
575
+ // Handoff from previous window (if any)
576
+ const handoffRaw = await redis.get('cachly:session:handoff');
577
+ if (handoffRaw) {
578
+ try {
579
+ const handoff = JSON.parse(handoffRaw);
580
+ const ago = Math.round((Date.now() - new Date(handoff.ts).getTime()) / 60000);
581
+ const agoStr = ago < 60 ? `${ago}m ago` : ago < 1440 ? `${Math.round(ago / 60)}h ago` : `${Math.round(ago / 1440)}d ago`;
582
+ lines.push(`🤝 **Handoff from previous window** (${agoStr}):`);
583
+ if (handoff.context_summary)
584
+ lines.push(` ${handoff.context_summary}`);
585
+ if (handoff.remaining_tasks.length > 0) {
586
+ lines.push(` ⏳ **Remaining tasks:**`);
587
+ for (const t of handoff.remaining_tasks)
588
+ lines.push(` - ${t}`);
589
+ }
590
+ if (handoff.completed_tasks.length > 0) {
591
+ lines.push(` ✅ **Already done:** ${handoff.completed_tasks.join(', ')}`);
592
+ }
593
+ const brokenFiles = (handoff.files_changed ?? []).filter(f => f.status === 'broken' || f.status === 'partial');
594
+ if (brokenFiles.length > 0) {
595
+ lines.push(` ⚠️ **Needs fix:** ${brokenFiles.map(f => `\`${f.path}\` (${f.status}${f.description ? ': ' + f.description : ''})`).join(', ')}`);
596
+ }
597
+ if (handoff.blocked_on)
598
+ lines.push(` 🚫 **Blocked on:** ${handoff.blocked_on}`);
599
+ if (handoff.instructions)
600
+ lines.push(` 📝 **Instructions:** ${handoff.instructions}`);
601
+ lines.push('');
602
+ }
603
+ catch { /* ignore corrupt handoff */ }
604
+ }
605
+ // ── Last checkpoint (session_ping) — shown when no session_end found ────
606
+ const checkpointRaw = await redis.get('cachly:session:checkpoint');
607
+ if (checkpointRaw) {
608
+ try {
609
+ const cp = JSON.parse(checkpointRaw);
610
+ // Only show checkpoint if it's more recent than last session_end
611
+ const cpTime = new Date(cp.ts).getTime();
612
+ const lastSessionTime = lastSession ? new Date(lastSession.ts).getTime() : 0;
613
+ if (cpTime > lastSessionTime) {
614
+ const ago = Math.round((Date.now() - cpTime) / 60000);
615
+ const agoStr = ago < 60 ? `${ago}m ago` : ago < 1440 ? `${Math.round(ago / 60)}h ago` : `${Math.round(ago / 1440)}d ago`;
616
+ const providerStr = cp.provider ? ` [${cp.provider}]` : '';
617
+ lines.push(`📌 **Last checkpoint**${providerStr} (${agoStr}): ${cp.task}`);
618
+ if (cp.files_touched.length > 0) {
619
+ lines.push(` Files: ${cp.files_touched.slice(0, 5).map(f => `\`${f}\``).join(', ')}`);
620
+ }
621
+ if (cp.next_step)
622
+ lines.push(` 📍 Next step was: ${cp.next_step}`);
623
+ if (!lastSession || cpTime - lastSessionTime > 300_000) {
624
+ lines.push(` ⚠️ No \`session_end\` found — reconstructed from last checkpoint`);
625
+ }
626
+ lines.push('');
627
+ }
628
+ }
629
+ catch { /* ignore */ }
630
+ }
631
+ // ── Git reconstruction — when no session_end + workspace_path given ─────
632
+ if (workspace_path && !lastSession) {
633
+ try {
634
+ const { execSync } = await import('node:child_process');
635
+ const gitLog = execSync(`git -C "${workspace_path}" log --oneline --format="%h %s" -15 2>/dev/null`, { encoding: 'utf-8', timeout: 5000 }).trim();
636
+ const gitDiff = execSync(`git -C "${workspace_path}" diff --stat HEAD~3 2>/dev/null || git -C "${workspace_path}" diff --stat 2>/dev/null`, { encoding: 'utf-8', timeout: 5000 }).trim();
637
+ if (gitLog) {
638
+ lines.push(`🔍 **Git reconstruction** (no session_end found — reconstructed from git):`);
639
+ for (const l of gitLog.split('\n').slice(0, 8))
640
+ lines.push(` ${l}`);
641
+ if (gitDiff) {
642
+ const diffLines = gitDiff.split('\n').filter(l => l.includes('|') || l.includes('changed'));
643
+ if (diffLines.length > 0) {
644
+ lines.push(` **Recent changes:**`);
645
+ for (const dl of diffLines.slice(0, 5))
646
+ lines.push(` ${dl.trim()}`);
647
+ }
648
+ }
649
+ lines.push('');
650
+ }
651
+ }
652
+ catch { /* git not available or no repo — silent */ }
653
+ }
654
+ // Last session
655
+ if (lastSession) {
656
+ const ago = Math.round((Date.now() - new Date(lastSession.ts).getTime()) / 60000);
657
+ const agoStr = ago < 60 ? `${ago}m ago` : ago < 1440 ? `${Math.round(ago / 60)}h ago` : `${Math.round(ago / 1440)}d ago`;
658
+ lines.push(`📅 **Last session** (${agoStr}): ${lastSession.summary}`);
659
+ if (lastSession.duration_min)
660
+ lines.push(` Duration: ${lastSession.duration_min} min`);
661
+ if ((lastSession.files_changed ?? []).length > 0) {
662
+ lines.push(` Files: ${(lastSession.files_changed ?? []).slice(0, 5).map((f) => `\`${f}\``).join(', ')}`);
663
+ }
664
+ lines.push('');
665
+ }
666
+ // Brain health
667
+ lines.push(`📊 **Brain:** ${lessons.length} lessons · ${ctxCount} context entries`, '');
668
+ // ── Layer 7: MCM Domain Coverage Map ────────────────────────────────────
669
+ if (lessons.length >= 3) {
670
+ const domainMap = new Map();
671
+ for (const l of lessons) {
672
+ const dom = l.topic.split(':')[0] ?? 'other';
673
+ if (!domainMap.has(dom))
674
+ domainMap.set(dom, { total: 0, success: 0, critical: 0 });
675
+ const d = domainMap.get(dom);
676
+ d.total++;
677
+ if (l.outcome === 'success')
678
+ d.success++;
679
+ if (l.severity === 'critical')
680
+ d.critical++;
681
+ }
682
+ const sorted = [...domainMap.entries()].sort((a, b) => b[1].total - a[1].total).slice(0, 6);
683
+ const hasContestedDomains = sorted.some(([, d]) => d.success < d.total * 0.4 && d.total >= 2);
684
+ if (sorted.length > 0) {
685
+ lines.push(`🗺️ **Knowledge Coverage:**`);
686
+ for (const [dom, d] of sorted) {
687
+ const pct = Math.round((d.success / d.total) * 100);
688
+ const filled = Math.round(pct / 10);
689
+ const bar = '█'.repeat(filled) + '░'.repeat(10 - filled);
690
+ const flag = d.critical > 0 ? ' 🔴' : pct < 40 && d.total >= 2 ? ' ⚠️' : '';
691
+ lines.push(` ${bar} ${dom.padEnd(18)} ${String(pct).padStart(3)}% (${d.success}/${d.total} confirmed)${flag}`);
692
+ }
693
+ if (hasContestedDomains) {
694
+ lines.push(` ⚠️ _Some domains have contested beliefs — use \`ckg_inspect\` to review_`);
695
+ }
696
+ lines.push('');
697
+ }
698
+ }
699
+ // ── Layer 7 MCM: Active belief conflicts ─────────────────────────────────
700
+ try {
701
+ const conflictKeys = [];
702
+ const cfStream = redis.scanStream({ match: 'cachly:ckg:conflict:*', count: 50 });
703
+ await new Promise((res, rej) => { cfStream.on('data', (b) => conflictKeys.push(...b)); cfStream.on('end', res); cfStream.on('error', rej); });
704
+ if (conflictKeys.length > 0) {
705
+ lines.push(`⚡ **Active belief conflicts (${conflictKeys.length}):**`);
706
+ for (const ck of conflictKeys.slice(0, 3)) {
707
+ const cr = await redis.get(ck);
708
+ if (!cr)
709
+ continue;
710
+ const cf = JSON.parse(cr);
711
+ 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)}")\``);
712
+ }
713
+ lines.push('');
714
+ }
715
+ }
716
+ catch { /* non-critical */ }
717
+ // ── Layer 7 MCM: Blind Spot Detection ────────────────────────────────────
718
+ // If the focus mentions a domain that has no CKG node → surface blind spot
719
+ if (focus && focus.length > 3) {
720
+ try {
721
+ const focusTokens = focus.toLowerCase().replace(/[^a-z0-9\s:_-]/g, ' ').split(/\s+/).filter(t => t.length > 3);
722
+ const blindSpots = [];
723
+ for (const token of focusTokens.slice(0, 6)) {
724
+ const nodeExists = await redis.exists(`cachly:ckg:node:${token}`);
725
+ if (!nodeExists) {
726
+ // Check if any node starts with this token (prefix match)
727
+ const prefixKeys = [];
728
+ const psStream = redis.scanStream({ match: `cachly:ckg:node:${token}*`, count: 10 });
729
+ await new Promise((res, rej) => { psStream.on('data', (b) => prefixKeys.push(...b)); psStream.on('end', res); psStream.on('error', rej); });
730
+ if (prefixKeys.length === 0)
731
+ blindSpots.push(token);
732
+ }
733
+ }
734
+ if (blindSpots.length > 0) {
735
+ lines.push(`🔭 **Blind spots detected for this focus:**`);
736
+ for (const bs of blindSpots.slice(0, 3)) {
737
+ lines.push(` ⬜ \`${bs}\` — no CKG knowledge. Suggestions:`);
738
+ lines.push(` • \`brain_from_git(instance_id="...", concept="${bs}")\` — bootstrap from commits`);
739
+ lines.push(` • \`fedbrain_search(query="${bs}")\` — search global commons`);
740
+ }
741
+ lines.push('');
742
+ }
743
+ }
744
+ catch { /* non-critical */ }
745
+ }
746
+ if (focusLessons.length > 0) {
747
+ lines.push(`🎯 **Relevant for "${focus}":**`);
748
+ for (const l of focusLessons.slice(0, 4)) {
749
+ const emoji = l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
750
+ const sev = l.severity === 'critical' ? '🔴' : l.severity === 'major' ? '🟡' : '';
751
+ lines.push(` ${emoji}${sev} \`${l.topic}\` — ${l.what_worked.slice(0, 100)}`);
752
+ }
753
+ lines.push('');
754
+ }
755
+ // Recent lessons
756
+ if (lessons.length > 0) {
757
+ lines.push(`🕐 **Recent lessons:**`);
758
+ const toShow = focusLessons.length > 0 ? lessons.filter(l => !focusLessons.includes(l)).slice(0, 4) : lessons.slice(0, 5);
759
+ for (const l of toShow) {
760
+ const emoji = l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
761
+ const sev = l.severity === 'critical' ? '🔴' : l.severity === 'major' ? '🟡' : '';
762
+ lines.push(` ${emoji}${sev} \`${l.topic}\` — ${l.what_worked.slice(0, 90)}`);
763
+ }
764
+ lines.push('');
765
+ }
766
+ else {
767
+ lines.push('📭 No lessons yet. Use `learn_from_attempts` after solving tasks.', '');
768
+ }
769
+ // Open failures (lessons whose best-key has outcome != success)
770
+ const openFailures = lessons.filter(l => l.outcome === 'failure' || l.outcome === 'partial');
771
+ if (openFailures.length > 0) {
772
+ lines.push(`⚠️ **Unresolved** (${openFailures.length} topic${openFailures.length > 1 ? 's' : ''} with no success yet):`);
773
+ for (const l of openFailures.slice(0, 3)) {
774
+ lines.push(` ❌ \`${l.topic}\` — ${(l.what_failed ?? l.what_worked).slice(0, 80)}`);
775
+ }
776
+ lines.push('');
777
+ }
778
+ // ── Stale / low-confidence lessons (confidence decay) ─────────────────
779
+ const staleSuccessLessons = lessons.filter(l => {
780
+ if (l.outcome !== 'success' && l.outcome !== 'partial')
781
+ return false;
782
+ return calculateConfidence(l) < CONFIDENCE_WARN_VALUE;
783
+ });
784
+ if (staleSuccessLessons.length > 0) {
785
+ lines.push(`🔴 **Stale lessons** (not recalled in >${CONFIDENCE_WARN_DAYS}d — verify before applying):`);
786
+ for (const l of staleSuccessLessons.slice(0, 4)) {
787
+ const conf = calculateConfidence(l);
788
+ const ageDays = Math.round((Date.now() - new Date(l.verified_at ?? l.ts).getTime()) / 86400000);
789
+ const flag = conf < CONFIDENCE_STALE_VALUE ? '🔴' : '⚠️';
790
+ lines.push(` ${flag} \`${l.topic}\` — ${ageDays}d stale, ${(conf * 100).toFixed(0)}% confidence`);
791
+ }
792
+ lines.push(` _Run \`recall_best_solution\` on these to reset their confidence clock._`);
793
+ lines.push('');
794
+ }
795
+ // ── Session Replay: show last session's decision log ──────────────────
796
+ const lastSessionAny = lastSession;
797
+ if (lastSessionAny?.decision_log?.length) {
798
+ const dl = lastSessionAny.decision_log;
799
+ const successes = dl.filter(d => d.outcome === 'success');
800
+ const failures = dl.filter(d => d.outcome === 'failure');
801
+ lines.push(`🎬 **Last session decisions** (${dl.length} lessons stored):`);
802
+ if (successes.length > 0)
803
+ lines.push(` ✅ Worked: ${successes.slice(0, 3).map(d => `\`${d.topic}\``).join(', ')}`);
804
+ if (failures.length > 0)
805
+ lines.push(` ❌ Failed: ${failures.slice(0, 3).map(d => `\`${d.topic}\``).join(', ')}`);
806
+ lines.push('');
807
+ }
808
+ // ── 🔮 Predictive Pre-Warning — intent-based danger detection ────────────
809
+ // Fires BEFORE work starts. If focus area has known failure patterns → warn loudly.
810
+ if (focusTerms.length > 0) {
811
+ const dangerLessons = lessons.filter(l => {
812
+ if (l.outcome === 'success')
813
+ return false;
814
+ const topicCategory = l.topic.split(':')[0];
815
+ return focusTerms.some(term => l.topic.toLowerCase().includes(term) ||
816
+ topicCategory === term ||
817
+ (l.tags ?? []).some((t) => t.toLowerCase() === term));
818
+ });
819
+ if (dangerLessons.length >= 1) {
820
+ // Insert warning block right after the title line (index 1 = blank line after title)
821
+ const warning = [
822
+ `🚨 **PRE-WARNING** — Read this BEFORE starting:`,
823
+ ` Known pitfalls for **"${focus}"** (${dangerLessons.length} past failure${dangerLessons.length > 1 ? 's' : ''}):`,
824
+ ...dangerLessons.slice(0, 3).map(l => ` ❌ \`${l.topic}\` — ${(l.what_failed ?? l.what_worked).slice(0, 80)}`),
825
+ '',
826
+ ];
827
+ lines.splice(2, 0, ...warning); // after '🧠 **Session Briefing**' + empty line
828
+ }
829
+ }
830
+ // ── 👥 Team Telepathy — what teammates learned this week ─────────────────
831
+ if (author) {
832
+ const oneWeekAgo = Date.now() - 7 * 86_400_000;
833
+ const teamLessons = lessons.filter(l => l.author && l.author !== author && new Date(l.ts).getTime() > oneWeekAgo);
834
+ if (teamLessons.length > 0) {
835
+ // Group by author
836
+ const byAuthor = new Map();
837
+ for (const l of teamLessons) {
838
+ const a = l.author;
839
+ if (!byAuthor.has(a))
840
+ byAuthor.set(a, []);
841
+ byAuthor.get(a).push(l);
842
+ }
843
+ lines.push(`👥 **Team this week** (${teamLessons.length} lesson${teamLessons.length > 1 ? 's' : ''} from teammates):`);
844
+ for (const [teamAuthor, tls] of byAuthor) {
845
+ lines.push(` 👤 **${teamAuthor}**:`);
846
+ for (const l of tls.slice(0, 3)) {
847
+ const emoji = l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
848
+ lines.push(` ${emoji} \`${l.topic}\` — ${l.what_worked.slice(0, 80)}`);
849
+ }
850
+ if (tls.length > 3)
851
+ lines.push(` … and ${tls.length - 3} more`);
852
+ }
853
+ lines.push('');
854
+ }
855
+ }
856
+ // ── 💎 Memory Crystal — compressed wisdom from old sessions ──────────────
857
+ try {
858
+ const crystalRaw = await redis.get('cachly:crystal:latest');
859
+ if (crystalRaw) {
860
+ const crystal = JSON.parse(crystalRaw);
861
+ const crystalAge = Math.round((Date.now() - new Date(crystal.ts).getTime()) / 86_400_000);
862
+ if (crystalAge <= 90) {
863
+ lines.push(`💎 **Memory Crystal** (${crystal.label} · ${crystal.session_count} sessions compressed):`);
864
+ for (const p of crystal.top_patterns.slice(0, 3)) {
865
+ lines.push(` • **${p.category}** (${p.count}×): ${p.insight.slice(0, 90)}`);
866
+ }
867
+ lines.push('');
868
+ }
869
+ }
870
+ }
871
+ catch { /* non-critical */ }
872
+ // ── 🗺️ Roadmap — open items at session start ────────────────────────────
873
+ try {
874
+ const roadmapAll = await redis.hgetall(`cachly:roadmap:${instance_id}`);
875
+ if (roadmapAll && Object.keys(roadmapAll).length > 0) {
876
+ const PRIORITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
877
+ const PRIORITY_ICON = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
878
+ const openStatuses = new Set(['planned', 'in-progress', 'blocked']);
879
+ const allItems = Object.values(roadmapAll).map(v => JSON.parse(v));
880
+ const openItems = allItems
881
+ .filter(i => openStatuses.has(i.status))
882
+ .sort((a, b) => {
883
+ if (a.status === 'in-progress' && b.status !== 'in-progress')
884
+ return -1;
885
+ if (b.status === 'in-progress' && a.status !== 'in-progress')
886
+ return 1;
887
+ return (PRIORITY_ORDER[a.priority] ?? 99) - (PRIORITY_ORDER[b.priority] ?? 99);
888
+ });
889
+ const doneCount = allItems.filter(i => i.status === 'done').length;
890
+ if (openItems.length > 0) {
891
+ lines.push(`🗺️ **Roadmap** (${openItems.length} open · ${doneCount} done):`);
892
+ for (const it of openItems.slice(0, 5)) {
893
+ const statusIcon = it.status === 'in-progress' ? '⚡' : it.status === 'blocked' ? '🚫' : '📋';
894
+ lines.push(` ${statusIcon} ${PRIORITY_ICON[it.priority] ?? '⚪'} \`${it.id}\` **${it.title}**`);
895
+ }
896
+ if (openItems.length > 5)
897
+ lines.push(` … and ${openItems.length - 5} more`);
898
+ lines.push(` _Use \`roadmap_next\` for the top priority item · \`roadmap_list\` for full view_`);
899
+ lines.push('');
900
+ }
901
+ }
902
+ }
903
+ catch { /* non-critical */ }
904
+ // ── 🔮 PPE: Predictive Pre-fetch — CKG-powered risk detection ────────────
905
+ // Layer 4: Before starting any work, scan the CKG for nodes matching focus
906
+ // tokens and traverse causal edges to surface predicted failure points inline.
907
+ // This is the PPE "pre-fetch" that was previously only available via brain_predict.
908
+ if (focus && focus.length > 3) {
909
+ try {
910
+ const ppeFocusTokens = focus.toLowerCase().replace(/[^a-z0-9\s\-_:]/g, ' ').split(/\s+/).filter(t => t.length > 2).slice(0, 5);
911
+ const ppePredictions = [];
912
+ for (const token of ppeFocusTokens) {
913
+ const nodeKeys = [];
914
+ const nStream = redis.scanStream({ match: `cachly:ckg:node:*${token}*`, count: 20 });
915
+ await new Promise((res, rej) => { nStream.on('data', (b) => nodeKeys.push(...b)); nStream.on('end', res); nStream.on('error', rej); });
916
+ for (const nk of nodeKeys.slice(0, 3)) {
917
+ const nodeRaw = await redis.get(nk);
918
+ if (!nodeRaw)
919
+ continue;
920
+ const node = JSON.parse(nodeRaw);
921
+ const edgeKeys = await redis.smembers(`cachly:ckg:idx:from:${node.id}`);
922
+ for (const ek of edgeKeys.slice(0, 15)) {
923
+ const edgeRaw = await redis.get(ek);
924
+ if (!edgeRaw)
925
+ continue;
926
+ const edge = JSON.parse(edgeRaw);
927
+ if (edge.edgeType !== 'causes' && edge.edgeType !== 'co-occurs' && edge.edgeType !== 'fixes')
928
+ continue;
929
+ if (edge.confidence < 0.35)
930
+ continue;
931
+ // For "fixes" edges, the lesson is on the "from" node (fix for "to")
932
+ // For "causes" edges, target is the failure mode
933
+ const lessonKey = edge.edgeType === 'fixes' ? `cachly:lesson:best:${edge.from}` : `cachly:lesson:best:${edge.to}`;
934
+ const lessonRaw = await redis.get(lessonKey);
935
+ const lesson = lessonRaw ? JSON.parse(lessonRaw) : undefined;
936
+ ppePredictions.push({ concept: node.id, edgeType: edge.edgeType, target: edge.to, confidence: edge.confidence, lesson });
937
+ }
938
+ }
939
+ }
940
+ const ppeSeen = new Set();
941
+ const ppeUniq = ppePredictions
942
+ .filter(p => { const k = `${p.concept}:${p.target}`; if (ppeSeen.has(k))
943
+ return false; ppeSeen.add(k); return true; })
944
+ .sort((a, b) => b.confidence - a.confidence)
945
+ .slice(0, 4);
946
+ if (ppeUniq.length > 0) {
947
+ lines.push(`🔮 **Predicted risks for "${focus}"** (PPE pre-fetch):`);
948
+ for (const p of ppeUniq) {
949
+ const confPct = Math.round(p.confidence * 100);
950
+ const icon = p.edgeType === 'causes' ? '⚡' : p.edgeType === 'fixes' ? '🔧' : '🔄';
951
+ lines.push(` ${icon} **${confPct}%** \`${p.concept}\` ${p.edgeType} \`${p.target}\``);
952
+ if (p.lesson?.what_worked)
953
+ lines.push(` ✅ ${p.lesson.what_worked.slice(0, 110)}`);
954
+ }
955
+ lines.push(` _Full analysis: \`brain_predict(context="${focus}")\`_`);
956
+ lines.push('');
957
+ }
958
+ }
959
+ catch { /* non-critical — never block session start */ }
960
+ }
961
+ // ── 📐 MCM Confidence Calibration — 30-day accuracy pass ─────────────────
962
+ // Layer 7: Periodically check if high-confidence lessons are actually reliable.
963
+ // If last calibration was >30 days ago (or never), run a quick pass.
964
+ // Measures: of recalled lessons with confidence > 0.85, what % had outcome=success?
965
+ try {
966
+ const calRaw = await redis.get('cachly:mcm:calibration:last');
967
+ const lastCalMs = calRaw ? JSON.parse(calRaw).ts : 0;
968
+ const daysSinceCal = (Date.now() - lastCalMs) / 86_400_000;
969
+ if (daysSinceCal >= 30 && lessons.length >= 5) {
970
+ // Quick calibration pass on recalled lessons (recall_count > 0, outcome=success)
971
+ const recalledSuccess = lessons.filter(l => l.outcome === 'success' && (l.recall_count ?? 0) > 0);
972
+ const recalledAll = lessons.filter(l => (l.recall_count ?? 0) > 0);
973
+ if (recalledAll.length >= 3) {
974
+ const precision = recalledAll.length > 0 ? recalledSuccess.length / recalledAll.length : 1;
975
+ const lowPrecisionDomains = [];
976
+ // Per-domain breakdown
977
+ const domCalMap = new Map();
978
+ for (const l of recalledAll) {
979
+ const dom = l.topic.split(':')[0] ?? 'other';
980
+ if (!domCalMap.has(dom))
981
+ domCalMap.set(dom, { success: 0, total: 0 });
982
+ const d = domCalMap.get(dom);
983
+ d.total++;
984
+ if (l.outcome === 'success')
985
+ d.success++;
986
+ }
987
+ for (const [dom, d] of domCalMap) {
988
+ if (d.total >= 2 && d.success / d.total < 0.6)
989
+ lowPrecisionDomains.push(dom);
990
+ }
991
+ // Save calibration result
992
+ await redis.set('cachly:mcm:calibration:last', JSON.stringify({
993
+ ts: Date.now(),
994
+ precision: precision,
995
+ recalled: recalledAll.length,
996
+ low_precision_domains: lowPrecisionDomains,
997
+ }));
998
+ if (lowPrecisionDomains.length > 0 || precision < 0.7) {
999
+ lines.push(`📐 **MCM Calibration** (30-day pass — ${recalledAll.length} recalled lessons):`);
1000
+ lines.push(` Overall precision: **${(precision * 100).toFixed(0)}%** recalled lessons actually worked`);
1001
+ if (lowPrecisionDomains.length > 0) {
1002
+ lines.push(` ⚠️ Low-precision domains: ${lowPrecisionDomains.map(d => `\`${d}\``).join(', ')} — consider revisiting these lessons`);
1003
+ }
1004
+ lines.push(` 💡 Use \`learn_from_attempts\` to update stale lessons and improve accuracy.`);
1005
+ lines.push('');
1006
+ }
1007
+ }
1008
+ // Even if no issues, stamp the date so we don't re-check for 30d
1009
+ await redis.set('cachly:mcm:calibration:last', JSON.stringify({ ts: Date.now(), precision: 1, recalled: 0 }), 'EX', 35 * 86400);
1010
+ }
1011
+ }
1012
+ catch { /* non-critical */ }
1013
+ // ── 🌍 Knowledge Commons — community stats banner ───────────────────────
1014
+ // Fetch syndication stats and show a 1-liner: total lessons + confirms + weekly growth.
1015
+ // Non-fatal: never blocks session start if the API call fails.
1016
+ try {
1017
+ const commonsStats = await apiFetch('/api/v1/syndication/stats');
1018
+ if (commonsStats.total_lessons > 0) {
1019
+ lines.push(`🌍 **Commons:** ${commonsStats.total_lessons.toLocaleString()} lessons · ` +
1020
+ `${commonsStats.total_confirms.toLocaleString()} confirms · ` +
1021
+ `+${commonsStats.added_last_7_days} this week`);
1022
+ lines.push('');
1023
+ }
1024
+ }
1025
+ catch { /* non-critical — never block session start */ }
1026
+ return lines.join('\n');
1027
+ }
1028
+ // ── session_end ───────────────────────────────────────────────────────────
1029
+ case 'session_end': {
1030
+ const { instance_id, summary, files_changed = [], lessons_learned, workspace_path = '', } = args;
1031
+ const redis = await getConnection(instance_id);
1032
+ const now = new Date();
1033
+ // Calculate duration from session_start marker
1034
+ let durationMin;
1035
+ const currentRaw = await redis.get('cachly:session:current');
1036
+ if (currentRaw) {
1037
+ try {
1038
+ const current = JSON.parse(currentRaw);
1039
+ durationMin = Math.round((now.getTime() - new Date(current.started).getTime()) / 60000);
1040
+ }
1041
+ catch { /* ignore */ }
1042
+ }
1043
+ // ── Session Replay: capture decision log ─────────────────────────────
1044
+ let decisionLog = [];
1045
+ try {
1046
+ const dlEntries = await redis.lrange('cachly:session:decision-log', 0, -1);
1047
+ decisionLog = dlEntries.map(e => JSON.parse(e));
1048
+ await redis.del('cachly:session:decision-log');
1049
+ }
1050
+ catch { /* non-critical */ }
1051
+ const sessionRecord = {
1052
+ ts: now.toISOString(),
1053
+ summary,
1054
+ files_changed,
1055
+ ...(lessons_learned !== undefined ? { lessons_learned } : {}),
1056
+ ...(durationMin !== undefined ? { duration_min: durationMin } : {}),
1057
+ ...(decisionLog.length > 0 ? { decision_log: decisionLog } : {}),
1058
+ };
1059
+ // Save as "last session"
1060
+ await redis.set('cachly:session:last', JSON.stringify(sessionRecord));
1061
+ // Append to history list (keep last 50 sessions, TTL 90 days)
1062
+ await redis.lpush('cachly:session:history', JSON.stringify(sessionRecord));
1063
+ await redis.ltrim('cachly:session:history', 0, 49);
1064
+ await redis.expire('cachly:session:history', 90 * 86400);
1065
+ // Clean up current session marker
1066
+ await redis.del('cachly:session:current');
1067
+ // ── AUTO-LEARN from session summary (no manual call needed) ─────────────
1068
+ // Parse the summary for actionable lessons and store them automatically.
1069
+ const autoLearned = [];
1070
+ try {
1071
+ // Extract key sentences from the summary that contain action verbs
1072
+ const actionVerbs = /\b(fixed|deployed|added|removed|refactored|migrated|updated|resolved|implemented|improved|optimized|configured|created|deleted|disabled|enabled|discovered|found|learned|debugged|patched|upgraded|installed|tested|built|rewrote|moved|renamed|split|merged|extracted)\b/i;
1073
+ const sentences = summary
1074
+ .split(/[.!\n]+/)
1075
+ .map(s => s.trim())
1076
+ .filter(s => s.length > 20 && actionVerbs.test(s));
1077
+ for (const sentence of sentences.slice(0, 6)) {
1078
+ // Build a topic slug from the first meaningful words
1079
+ const words = sentence.toLowerCase()
1080
+ .replace(/[^a-z0-9\s]/g, ' ')
1081
+ .split(/\s+/)
1082
+ .filter(w => w.length > 3 && !['that', 'this', 'with', 'from', 'have', 'been', 'were', 'they', 'then', 'when', 'also', 'into', 'will', 'would', 'could', 'should'].includes(w));
1083
+ const slug = words.slice(0, 4).join('-');
1084
+ if (!slug)
1085
+ continue;
1086
+ const topic = `auto:${slug}`;
1087
+ const key = `cachly:lesson:best:${topic}`;
1088
+ // Don't overwrite existing successful lessons
1089
+ const existing = await redis.get(key);
1090
+ if (existing) {
1091
+ try {
1092
+ const ex = JSON.parse(existing);
1093
+ if (ex.outcome === 'success')
1094
+ continue;
1095
+ }
1096
+ catch { /* ignore */ }
1097
+ }
1098
+ const lesson = {
1099
+ topic,
1100
+ outcome: 'success',
1101
+ what_worked: sentence,
1102
+ context: `Auto-learned from session summary. Full summary: ${summary.slice(0, 300)}`,
1103
+ severity: 'minor',
1104
+ ts: now.toISOString(),
1105
+ recall_count: 0,
1106
+ auto_learned: true,
1107
+ session_ts: now.toISOString(),
1108
+ version: 2,
1109
+ };
1110
+ await redis.set(key, JSON.stringify(lesson));
1111
+ // 90-day TTL for auto-learned lessons
1112
+ await redis.expire(key, 90 * 86400);
1113
+ autoLearned.push(topic);
1114
+ }
1115
+ // Also store a lesson per changed file area if files were changed
1116
+ if (files_changed.length > 0) {
1117
+ const areas = [...new Set(files_changed.map(f => f.split('/').slice(0, 2).join('/')))].slice(0, 3);
1118
+ for (const area of areas) {
1119
+ const slug = area.replace(/[^a-z0-9]/gi, '-').toLowerCase().replace(/-+/g, '-').slice(0, 30);
1120
+ const topic = `auto:changed:${slug}`;
1121
+ const key = `cachly:lesson:best:${topic}`;
1122
+ const lesson = {
1123
+ topic,
1124
+ outcome: 'success',
1125
+ what_worked: `Files changed in ${area}: ${files_changed.filter(f => f.startsWith(area.split('/')[0])).slice(0, 5).join(', ')}`,
1126
+ context: summary.slice(0, 200),
1127
+ severity: 'minor',
1128
+ ts: now.toISOString(),
1129
+ recall_count: 0,
1130
+ auto_learned: true,
1131
+ version: 2,
1132
+ };
1133
+ await redis.set(key, JSON.stringify(lesson));
1134
+ await redis.expire(key, 90 * 86400);
1135
+ autoLearned.push(topic);
1136
+ }
1137
+ }
1138
+ }
1139
+ catch { /* auto-learn errors must never break session_end */ }
1140
+ // ── 🌿 Ambient Git Learning ────────────────────────────────────────────────
1141
+ // Read git commits since session start → auto-learn each meaningful commit.
1142
+ const ambientLearned = [];
1143
+ if (workspace_path) {
1144
+ try {
1145
+ // Get the session start time (stored by session_start)
1146
+ const sessionStartTs = currentRaw
1147
+ ? (() => { try {
1148
+ return JSON.parse(currentRaw).started ?? '';
1149
+ }
1150
+ catch {
1151
+ return '';
1152
+ } })()
1153
+ : '';
1154
+ const sinceArg = sessionStartTs ? `--since="${sessionStartTs}"` : '--since="1 hour ago"';
1155
+ const gitOut = execSync(`git -C "${workspace_path}" log ${sinceArg} --oneline --format="%H|||%s|||%ai"`, { timeout: 5000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
1156
+ if (gitOut) {
1157
+ const commitActionRe = /\b(fix|add|remove|refactor|migrate|update|resolve|implement|improve|optimize|configure|create|delete|disable|enable|debug|patch|upgrade|build|rewrite|deploy|feat|chore|docs|test|perf|ci)\b/i;
1158
+ for (const line of gitOut.split('\n').slice(0, 10)) {
1159
+ const [hash, msg, dateStr] = line.split('|||');
1160
+ if (!msg || !commitActionRe.test(msg))
1161
+ continue;
1162
+ const slug = msg
1163
+ .toLowerCase().replace(/^(fix|feat|chore|docs|test|ci|perf|refactor|build|revert)[:(\s]/i, '')
1164
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
1165
+ if (!slug)
1166
+ continue;
1167
+ const topic = `git:${slug}`;
1168
+ const key = `cachly:lesson:best:${topic}`;
1169
+ const existing = await redis.get(key);
1170
+ if (existing)
1171
+ continue; // don't overwrite existing
1172
+ const commitLesson = {
1173
+ topic,
1174
+ outcome: 'success',
1175
+ what_worked: msg.slice(0, 200),
1176
+ context: `Auto-learned from git commit ${(hash ?? '').slice(0, 7)} at ${dateStr ?? ''} in ${workspace_path}`,
1177
+ severity: 'minor',
1178
+ ts: now.toISOString(),
1179
+ recall_count: 0,
1180
+ auto_learned: true,
1181
+ source: 'ambient-git',
1182
+ version: 3,
1183
+ };
1184
+ await redis.set(key, JSON.stringify(commitLesson));
1185
+ await redis.expire(key, 60 * 86400); // 60 day TTL for git lessons
1186
+ ambientLearned.push(topic);
1187
+ }
1188
+ }
1189
+ }
1190
+ catch { /* git not available or not a repo — silent skip */ }
1191
+ }
1192
+ const durationStr = durationMin !== undefined ? ` · ${durationMin} min` : '';
1193
+ return [
1194
+ `✅ **Session saved**${durationStr}`,
1195
+ ``,
1196
+ `📋 **Summary:** ${summary}`,
1197
+ files_changed.length > 0 ? `📁 **Files changed:** ${files_changed.map(f => `\`${f}\``).join(', ')}` : '',
1198
+ lessons_learned !== undefined ? `🧠 **Lessons stored:** ${lessons_learned}` : '',
1199
+ autoLearned.length > 0 ? `🤖 **Auto-learned:** ${autoLearned.length} lessons extracted from summary (${autoLearned.slice(0, 3).map(t => `\`${t}\``).join(', ')}${autoLearned.length > 3 ? '…' : ''})` : '',
1200
+ ambientLearned.length > 0 ? `🌿 **Ambient git learning:** ${ambientLearned.length} commit${ambientLearned.length > 1 ? 's' : ''} auto-learned (${ambientLearned.slice(0, 3).map(t => `\`${t}\``).join(', ')}${ambientLearned.length > 3 ? '…' : ''})` : '',
1201
+ ``,
1202
+ `💡 Next session: \`session_start(focus="...")\` to see this summary.`,
1203
+ ].filter(l => l !== '').join('\n');
1204
+ }
1205
+ // ── session_ping — lightweight checkpoint ─────────────────────────────────
1206
+ case 'session_ping': {
1207
+ const { instance_id, task, files_touched = [], next_step = '', provider = '', } = args;
1208
+ const redis = await getConnection(instance_id);
1209
+ const checkpoint = {
1210
+ ts: new Date().toISOString(),
1211
+ task,
1212
+ files_touched,
1213
+ next_step,
1214
+ provider,
1215
+ };
1216
+ // Store as the latest checkpoint — session_start reads this when no session_end found
1217
+ await redis.set('cachly:session:checkpoint', JSON.stringify(checkpoint), 'EX', 86400 * 3); // 3-day TTL
1218
+ // Also keep a short rolling log (last 20 checkpoints for history)
1219
+ await redis.lpush('cachly:session:checkpoint:log', JSON.stringify(checkpoint));
1220
+ await redis.ltrim('cachly:session:checkpoint:log', 0, 19);
1221
+ const providerStr = provider ? ` [${provider}]` : '';
1222
+ const filesStr = files_touched.length > 0 ? ` · ${files_touched.length} file${files_touched.length > 1 ? 's' : ''} touched` : '';
1223
+ const nextStr = next_step ? `\n📍 **Next step:** ${next_step}` : '';
1224
+ return [
1225
+ `📌 **Checkpoint saved**${providerStr} — ${new Date().toLocaleTimeString()}`,
1226
+ `🔨 **Working on:** ${task}${filesStr}`,
1227
+ nextStr,
1228
+ ``,
1229
+ `💡 If you switch providers, \`session_start\` will show this checkpoint automatically.`,
1230
+ ].filter(l => l !== '').join('\n');
1231
+ }
1232
+ // ── session_handoff — cross-window continuity ─────────────────────────────
1233
+ case 'session_handoff': {
1234
+ const { instance_id, completed_tasks = [], remaining_tasks = [], files_changed = [], instructions = '', context_summary = '', blocked_on = '', } = args;
1235
+ const redis = await getConnection(instance_id);
1236
+ const now = new Date();
1237
+ const handoff = {
1238
+ ts: now.toISOString(),
1239
+ completed_tasks,
1240
+ remaining_tasks,
1241
+ files_changed,
1242
+ instructions,
1243
+ context_summary,
1244
+ blocked_on,
1245
+ };
1246
+ // Store handoff — never expires until next handoff overwrites it
1247
+ await redis.set('cachly:session:handoff', JSON.stringify(handoff));
1248
+ // Also append to history
1249
+ await redis.lpush('cachly:session:handoff:history', JSON.stringify(handoff));
1250
+ await redis.ltrim('cachly:session:handoff:history', 0, 19);
1251
+ const totalTasks = completed_tasks.length + remaining_tasks.length;
1252
+ const pct = totalTasks > 0 ? Math.round((completed_tasks.length / totalTasks) * 100) : 0;
1253
+ const brokenFiles = files_changed.filter(f => f.status === 'broken' || f.status === 'partial');
1254
+ return [
1255
+ `🤝 **Handoff saved** — ${completed_tasks.length}/${totalTasks} tasks done (${pct}%)`,
1256
+ ``,
1257
+ completed_tasks.length > 0 ? `✅ **Completed:**\n${completed_tasks.map(t => ` - ${t}`).join('\n')}` : '',
1258
+ remaining_tasks.length > 0 ? `\n⏳ **Remaining for next window:**\n${remaining_tasks.map(t => ` - ${t}`).join('\n')}` : '',
1259
+ brokenFiles.length > 0 ? `\n⚠️ **Needs attention:** ${brokenFiles.map(f => `\`${f.path}\` (${f.status})`).join(', ')}` : '',
1260
+ blocked_on ? `\n🚫 **Blocked on:** ${blocked_on}` : '',
1261
+ instructions ? `\n📝 **Instructions:** ${instructions}` : '',
1262
+ ``,
1263
+ `💡 The next \`session_start\` will include this handoff automatically.`,
1264
+ ].filter(l => l !== '').join('\n');
1265
+ }
1266
+ // ── auto_learn_session ────────────────────────────────────────────────────
1267
+ case 'auto_learn_session': {
1268
+ const { instance_id, observations } = args;
1269
+ const redis = await getConnection(instance_id);
1270
+ const stored = [];
1271
+ const skipped = [];
1272
+ for (const obs of observations) {
1273
+ // Auto-generate topic from action if not provided
1274
+ const rawTopic = obs.topic ?? obs.action
1275
+ .toLowerCase()
1276
+ .replace(/[^a-z0-9:\-_\s]/g, '')
1277
+ .trim()
1278
+ .split(/\s+/)
1279
+ .slice(0, 4)
1280
+ .join('-');
1281
+ const topic = rawTopic.includes(':') ? rawTopic : `auto:${rawTopic}`;
1282
+ const key = `cachly:lesson:best:${topic}`;
1283
+ // Only overwrite if this is a success and existing is failure, or topic is new
1284
+ const existing = await redis.get(key);
1285
+ if (existing) {
1286
+ const existingLesson = JSON.parse(existing);
1287
+ if (existingLesson.outcome === 'success' && obs.outcome !== 'success') {
1288
+ skipped.push(topic);
1289
+ continue;
1290
+ }
1291
+ }
1292
+ const lesson = {
1293
+ topic,
1294
+ outcome: obs.outcome,
1295
+ what_worked: obs.outcome === 'success' ? obs.action : (obs.details ?? obs.action),
1296
+ what_failed: obs.outcome === 'failure' ? obs.action : undefined,
1297
+ context: obs.details,
1298
+ severity: obs.severity ?? 'minor',
1299
+ ts: new Date().toISOString(),
1300
+ recall_count: 0,
1301
+ auto_learned: true,
1302
+ version: 2,
1303
+ };
1304
+ await redis.set(key, JSON.stringify(lesson));
1305
+ stored.push(`${obs.outcome === 'success' ? '✅' : obs.outcome === 'partial' ? '⚠️' : '❌'} \`${topic}\``);
1306
+ }
1307
+ const lines = [
1308
+ `🤖 **Auto-learn complete**: ${stored.length} stored, ${skipped.length} skipped`,
1309
+ '',
1310
+ ];
1311
+ if (stored.length > 0)
1312
+ lines.push('**Stored:**', ...stored.map(s => ' ' + s), '');
1313
+ if (skipped.length > 0)
1314
+ lines.push(`**Skipped** (better lesson already exists): ${skipped.map(t => `\`${t}\``).join(', ')}`);
1315
+ return lines.join('\n');
1316
+ }
1317
+ // ── sync_file_changes ─────────────────────────────────────────────────────
1318
+ default:
1319
+ return null;
1320
+ }
1321
+ }
1322
+ //# sourceMappingURL=brain.js.map