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