@cachly-dev/mcp-server 0.2.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { createHash } from 'node:crypto';
3
4
  import { readdir, readFile, stat } from 'node:fs/promises';
4
5
  import { join, relative, extname } from 'node:path';
5
6
  /**
@@ -24,7 +25,7 @@ import { join, relative, extname } from 'node:path';
24
25
  * • cache_keys – list keys matching a glob pattern
25
26
  * • cache_stats – memory, hit rate, ops/sec, keyspace info
26
27
  * • semantic_search – find semantically similar cached entries
27
- * (requires OPENAI_API_KEY + CACHLY_VECTOR_URL)
28
+ * (needs OPENAI_API_KEY or other embed provider in .env)
28
29
  *
29
30
  * ── Auth & Status ────────────────────────────────────────────────────────────
30
31
  * • get_api_status – check API health + JWT auth info (Keycloak)
@@ -32,8 +33,8 @@ import { join, relative, extname } from 'node:path';
32
33
  * Configuration (env vars):
33
34
  * CACHLY_API_URL – default https://api.cachly.dev
34
35
  * CACHLY_JWT – your JWT (Keycloak access token)
35
- * OPENAI_API_KEY – for semantic_search (optional)
36
- * CACHLY_EMBED_MODEL – embedding model (default: text-embedding-3-small)
36
+ * CACHLY_EMBED_PROVIDER – embedding backend: openai (default), gemini, mistral, cohere, ollama, cachly (server fallback)
37
+ * CACHLY_EMBED_MODEL – override embedding model (optional)
37
38
  */
38
39
 
39
40
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
@@ -45,33 +46,98 @@ import {
45
46
  McpError,
46
47
  } from '@modelcontextprotocol/sdk/types.js';
47
48
  import { Redis } from 'ioredis';
48
- import OpenAI from 'openai';
49
49
 
50
50
  // ── Config ────────────────────────────────────────────────────────────────────
51
51
 
52
52
  const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
53
53
  const JWT = process.env.CACHLY_JWT ?? '';
54
- const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? 'text-embedding-3-small';
55
- /** Embedding provider: openai (default) | mistral | cohere | ollama | gemini */
56
- const EMBED_PROVIDER = (process.env.CACHLY_EMBED_PROVIDER ?? 'openai').toLowerCase();
54
+ const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
55
+ /**
56
+ * ┌──────────────────────────────────────────────────────────────────────┐
57
+ * │ EMBEDDING PROVIDER — pluggable, client-side first │
58
+ * │ │
59
+ * │ Auto-detects from env vars. To force a provider, set: │
60
+ * │ CACHLY_EMBED_PROVIDER=openai (+ OPENAI_API_KEY) │
61
+ * │ CACHLY_EMBED_PROVIDER=gemini (+ GEMINI_API_KEY) │
62
+ * │ CACHLY_EMBED_PROVIDER=mistral (+ MISTRAL_API_KEY) │
63
+ * │ CACHLY_EMBED_PROVIDER=cohere (+ COHERE_API_KEY) │
64
+ * │ CACHLY_EMBED_PROVIDER=ollama (+ OLLAMA_BASE_URL, local) │
65
+ * │ CACHLY_EMBED_PROVIDER=cachly (server-side fallback, no key) │
66
+ * │ │
67
+ * │ Priority: openai > gemini > mistral > cohere > ollama > cachly │
68
+ * │ Brain works WITHOUT embedding (keyword search + exact key lookup).│
69
+ * │ Embedding is an optional boost for semantic_search/index_project. │
70
+ * └──────────────────────────────────────────────────────────────────────┘
71
+ */
72
+ const EMBED_PROVIDER = (process.env.CACHLY_EMBED_PROVIDER ?? detectEmbedProvider()).toLowerCase();
73
+
74
+ /** Fast content hash for index invalidation (not cryptographic, just change detection) */
75
+ function simpleHash(text: string): string {
76
+ return createHash('md5').update(text).digest('hex').slice(0, 12);
77
+ }
78
+
79
+ function detectEmbedProvider(): string {
80
+ // Client-side keys first (direct, fast, no server roundtrip)
81
+ if (process.env.OPENAI_API_KEY) return 'openai';
82
+ if (process.env.GEMINI_API_KEY) return 'gemini';
83
+ if (process.env.MISTRAL_API_KEY) return 'mistral';
84
+ if (process.env.COHERE_API_KEY) return 'cohere';
85
+ if (process.env.OLLAMA_BASE_URL) return 'ollama';
86
+ // Server-side fallback — no API key needed, but adds latency
87
+ if (process.env.CACHLY_JWT) return 'cachly';
88
+ return 'none'; // no provider → embedding disabled, brain still works via exact keys
89
+ }
57
90
 
58
91
  // ── Multi-provider embedding ─────────────────────────────────────────────────
59
92
 
60
93
  /**
61
94
  * Compute an embedding vector for `text` using the configured provider.
62
95
  *
63
- * Supported via CACHLY_EMBED_PROVIDER:
64
- * openai (default) – OPENAI_API_KEY · model: text-embedding-3-small
65
- * mistral – MISTRAL_API_KEY · model: mistral-embed
66
- * cohere – COHERE_API_KEY · model: embed-english-v3.0
67
- * ollama – OLLAMA_BASE_URL (default: http://localhost:11434) · model: nomic-embed-text
68
- * gemini – GEMINI_API_KEY · model: text-embedding-004
96
+ * Client-side (recommended — set one API key in your .env):
97
+ * openai – OPENAI_API_KEY · text-embedding-3-small
98
+ * gemini – GEMINI_API_KEY · text-embedding-004
99
+ * mistral – MISTRAL_API_KEY · mistral-embed
100
+ * cohere – COHERE_API_KEY · embed-english-v3.0
101
+ * ollama – OLLAMA_BASE_URL · nomic-embed-text (local, free)
102
+ *
103
+ * Server-side fallback (no key needed on client):
104
+ * cachly – POST /api/v1/embed (requires CACHLY_JWT)
69
105
  *
70
- * Note: Anthropic (Claude) does not provide embedding models.
71
- * Use Cohere or OpenAI instead.
106
+ * Note: Brain works fully WITHOUT embedding (keyword search + exact keys).
107
+ * Embedding is an OPTIONAL boost for semantic_search and index_project.
72
108
  */
73
109
  async function computeEmbedding(text: string): Promise<number[]> {
74
110
  switch (EMBED_PROVIDER) {
111
+ case 'cachly': {
112
+ // Server-side embedding — the Cachly API computes the embedding
113
+ // using whatever provider is configured on the server. No client-side API key needed.
114
+ if (!JWT) throw new Error(
115
+ 'CACHLY_JWT not set.\n\n' +
116
+ 'The "cachly" provider uses server-side embeddings via the Cachly API.\n' +
117
+ 'Set CACHLY_JWT, or use another provider via CACHLY_EMBED_PROVIDER:\n' +
118
+ ' openai → OPENAI_API_KEY\n' +
119
+ ' gemini → GEMINI_API_KEY\n' +
120
+ ' ollama → OLLAMA_BASE_URL (local, no key needed)'
121
+ );
122
+ const url = `${API_URL}/api/v1/embed`;
123
+ const body: Record<string, string> = { text };
124
+ if (EMBED_MODEL) body.model = EMBED_MODEL;
125
+ const res = await fetch(url, {
126
+ method: 'POST',
127
+ headers: {
128
+ 'Content-Type': 'application/json',
129
+ 'Authorization': `Bearer ${JWT}`,
130
+ },
131
+ body: JSON.stringify(body),
132
+ });
133
+ if (!res.ok) {
134
+ const errBody = await res.text();
135
+ throw new Error(`Cachly embed API error ${res.status}: ${errBody}`);
136
+ }
137
+ const json = (await res.json()) as { embedding: number[]; dimensions: number };
138
+ return json.embedding;
139
+ }
140
+
75
141
  case 'mistral': {
76
142
  const key = process.env.MISTRAL_API_KEY;
77
143
  if (!key) throw new Error('MISTRAL_API_KEY not set');
@@ -130,20 +196,31 @@ async function computeEmbedding(text: string): Promise<number[]> {
130
196
  return json.embedding.values;
131
197
  }
132
198
 
133
- case 'openai':
134
- default: {
199
+ case 'openai': {
135
200
  const key = process.env.OPENAI_API_KEY;
136
201
  if (!key) throw new Error(
137
202
  'OPENAI_API_KEY not set.\n\n' +
138
- 'Other supported providers via CACHLY_EMBED_PROVIDER:\n' +
139
- ' mistral → MISTRAL_API_KEY\n' +
140
- ' cohere → COHERE_API_KEY\n' +
141
- ' ollama → OLLAMA_BASE_URL (local, no key needed)\n' +
142
- ' gemini → GEMINI_API_KEY'
203
+ 'Set OPENAI_API_KEY in your .env, or switch provider:\n' +
204
+ ' CACHLY_EMBED_PROVIDER=gemini (+ GEMINI_API_KEY)\n' +
205
+ ' CACHLY_EMBED_PROVIDER=ollama (local, free)\n' +
206
+ ' CACHLY_EMBED_PROVIDER=cachly (server-side, no key needed)'
207
+ );
208
+ const model = EMBED_MODEL || 'text-embedding-3-small';
209
+ const res = await fetch('https://api.openai.com/v1/embeddings', {
210
+ method: 'POST',
211
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
212
+ body: JSON.stringify({ model, input: text }),
213
+ });
214
+ if (!res.ok) throw new Error(`OpenAI embedding error: ${res.statusText}`);
215
+ const json = (await res.json()) as { data: { embedding: number[] }[] };
216
+ return json.data[0].embedding;
217
+ }
218
+
219
+ default: {
220
+ throw new Error(
221
+ `Unknown CACHLY_EMBED_PROVIDER="${EMBED_PROVIDER}".\n` +
222
+ 'Supported: cachly (default), openai, mistral, cohere, ollama, gemini'
143
223
  );
144
- const openai = new OpenAI({ apiKey: key });
145
- const embRes = await openai.embeddings.create({ model: EMBED_MODEL, input: text });
146
- return embRes.data[0].embedding;
147
224
  }
148
225
  }
149
226
  }
@@ -151,28 +228,445 @@ async function computeEmbedding(text: string): Promise<number[]> {
151
228
  /** Returns true if the configured embedding provider has its required key/URL set. */
152
229
  function hasEmbedProvider(): boolean {
153
230
  switch (EMBED_PROVIDER) {
231
+ case 'cachly': return !!JWT;
154
232
  case 'mistral': return !!process.env.MISTRAL_API_KEY;
155
233
  case 'cohere': return !!process.env.COHERE_API_KEY;
156
234
  case 'ollama': return true; // OLLAMA_BASE_URL is optional (defaults to localhost)
157
235
  case 'gemini': return !!process.env.GEMINI_API_KEY;
158
- case 'openai':
159
- default: return !!process.env.OPENAI_API_KEY;
236
+ case 'openai': return !!process.env.OPENAI_API_KEY;
237
+ case 'none': return false;
238
+ default: return false;
160
239
  }
161
240
  }
162
241
 
163
242
  /** Returns a human-readable description of the current embed provider/model for error messages. */
164
243
  function embedProviderHint(): string {
165
244
  const providerKeys: Record<string, string> = {
245
+ cachly: 'CACHLY_JWT (server-side, no extra key needed)',
166
246
  openai: 'OPENAI_API_KEY',
167
247
  mistral: 'MISTRAL_API_KEY',
168
248
  cohere: 'COHERE_API_KEY',
169
249
  ollama: 'OLLAMA_BASE_URL (optional, default: http://localhost:11434)',
170
250
  gemini: 'GEMINI_API_KEY',
171
251
  };
172
- const key = providerKeys[EMBED_PROVIDER] ?? 'OPENAI_API_KEY';
252
+ if (EMBED_PROVIDER === 'none') {
253
+ return 'No embedding provider configured. Set OPENAI_API_KEY, GEMINI_API_KEY, or another provider key in .env. Brain works without embedding.';
254
+ }
255
+ const key = providerKeys[EMBED_PROVIDER] ?? 'CACHLY_JWT';
173
256
  return `CACHLY_EMBED_PROVIDER=${EMBED_PROVIDER} → requires ${key}`;
174
257
  }
175
258
 
259
+ // ── Search Engine (BM25+ with enhancements, works without embedding) ──────────
260
+ //
261
+ // Features beyond standard BM25:
262
+ // • BM25+ (delta=1) — fixes BM25's "long document penalty" bug
263
+ // • Bigram proximity boost — adjacent query terms in doc score 2× more
264
+ // • Recency boost — newer entries rank higher (exponential decay, 7-day half-life)
265
+ // • Multi-query splitting — numbered lists, semicolons, conjunctions
266
+ // • Fuzzy matching with Levenshtein distance ≤ 2
267
+ // • Multilingual stopwords (EN, DE, FR, ES, IT, PT)
268
+ // • Pipeline Redis reads for performance
269
+ //
270
+
271
+ /**
272
+ * Stopwords — filtered out during tokenization.
273
+ * Covers: English, German, French, Spanish, Italian, Portuguese.
274
+ * Keeps the index small and scores meaningful.
275
+ */
276
+ const STOPWORDS = new Set([
277
+ // ── English ──
278
+ 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
279
+ 'have', 'has', 'had', 'having', 'do', 'does', 'did', 'doing',
280
+ 'will', 'would', 'could', 'should', 'may', 'might', 'shall', 'can',
281
+ 'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'as',
282
+ 'into', 'through', 'during', 'before', 'after', 'above', 'below',
283
+ 'between', 'about', 'against', 'among', 'around', 'without', 'within',
284
+ 'along', 'across', 'behind', 'beyond', 'upon', 'toward', 'towards',
285
+ 'and', 'but', 'or', 'not', 'no', 'nor', 'so', 'if', 'then', 'than',
286
+ 'too', 'very', 'quite', 'rather', 'just', 'also', 'only', 'even',
287
+ 'it', 'its', 'this', 'that', 'these', 'those', 'here', 'there',
288
+ 'my', 'your', 'his', 'her', 'our', 'their', 'mine', 'yours', 'ours',
289
+ 'what', 'which', 'who', 'whom', 'whose', 'how', 'when', 'where', 'why',
290
+ 'all', 'each', 'every', 'both', 'few', 'more', 'most', 'much', 'many',
291
+ 'other', 'some', 'such', 'own', 'same', 'any', 'either', 'neither',
292
+ 'been', 'being', 'because', 'until', 'while', 'once', 'again', 'further',
293
+ 'already', 'always', 'never', 'sometimes', 'often', 'still', 'yet',
294
+ // ── German ──
295
+ 'der', 'die', 'das', 'den', 'dem', 'des', 'ein', 'eine', 'einen',
296
+ 'einem', 'einer', 'eines', 'und', 'oder', 'aber', 'denn', 'weil',
297
+ 'ist', 'sind', 'war', 'waren', 'sein', 'wird', 'werden', 'wurde',
298
+ 'hat', 'haben', 'hatte', 'hatten', 'kann', 'können', 'konnte',
299
+ 'soll', 'sollen', 'sollte', 'muss', 'müssen', 'musste', 'darf',
300
+ 'mag', 'möchte', 'wollen', 'wollte', 'würde', 'könnte', 'sollte',
301
+ 'mit', 'für', 'auf', 'von', 'aus', 'bei', 'nach', 'über', 'unter',
302
+ 'vor', 'hinter', 'neben', 'zwischen', 'durch', 'gegen', 'ohne',
303
+ 'um', 'bis', 'seit', 'während', 'wegen', 'trotz', 'statt',
304
+ 'wie', 'was', 'wer', 'wen', 'wem', 'wessen', 'wo', 'wann', 'warum',
305
+ 'nicht', 'noch', 'auch', 'schon', 'nur', 'sehr', 'mehr', 'viel',
306
+ 'alle', 'jeder', 'jede', 'jedes', 'dieser', 'diese', 'dieses',
307
+ 'jener', 'jene', 'jenes', 'mein', 'dein', 'sein', 'ihr', 'unser',
308
+ 'euer', 'kein', 'keine', 'sich', 'mir', 'dir', 'ihm', 'uns', 'euch',
309
+ 'ich', 'du', 'er', 'sie', 'es', 'wir', 'ihr', 'man',
310
+ 'hier', 'dort', 'da', 'dann', 'also', 'doch', 'mal', 'eben', 'ganz',
311
+ // ── French ──
312
+ 'le', 'la', 'les', 'un', 'une', 'des', 'du', 'de', 'au', 'aux',
313
+ 'et', 'ou', 'mais', 'donc', 'car', 'ni', 'que', 'qui', 'quoi',
314
+ 'est', 'sont', 'était', 'ont', 'avoir', 'être', 'fait', 'faire',
315
+ 'pour', 'par', 'avec', 'dans', 'sur', 'sous', 'entre', 'vers',
316
+ 'chez', 'sans', 'avant', 'après', 'pendant', 'depuis', 'contre',
317
+ 'ce', 'cette', 'ces', 'mon', 'ton', 'son', 'notre', 'votre', 'leur',
318
+ 'je', 'tu', 'il', 'elle', 'nous', 'vous', 'ils', 'elles', 'on',
319
+ 'ne', 'pas', 'plus', 'très', 'bien', 'aussi', 'tout', 'tous', 'toute',
320
+ 'même', 'autre', 'quel', 'quelle', 'comment', 'quand', 'où', 'pourquoi',
321
+ // ── Spanish ──
322
+ 'el', 'la', 'los', 'las', 'un', 'una', 'unos', 'unas', 'del', 'al',
323
+ 'lo', 'que', 'en', 'es', 'por', 'con', 'para', 'como', 'pero', 'más',
324
+ 'fue', 'ser', 'hay', 'está', 'han', 'son', 'tiene', 'había', 'era',
325
+ 'su', 'sus', 'este', 'esta', 'estos', 'estas', 'ese', 'esa', 'esos',
326
+ 'mi', 'tu', 'yo', 'él', 'ella', 'nosotros', 'ellos', 'ellas', 'usted',
327
+ 'no', 'ya', 'sí', 'sin', 'sobre', 'entre', 'hasta', 'desde', 'donde',
328
+ 'muy', 'todo', 'toda', 'todos', 'cada', 'otro', 'otra', 'otros',
329
+ 'cuando', 'porque', 'aunque', 'también', 'solo', 'después', 'antes',
330
+ // ── Italian ──
331
+ 'il', 'lo', 'la', 'li', 'le', 'gli', 'uno', 'una', 'dei', 'del',
332
+ 'che', 'di', 'da', 'per', 'con', 'tra', 'fra', 'sul', 'nel', 'al',
333
+ 'è', 'sono', 'ha', 'hanno', 'era', 'essere', 'fare', 'fatto', 'stato',
334
+ 'suo', 'sua', 'suoi', 'questo', 'questa', 'questi', 'quello', 'quella',
335
+ 'io', 'tu', 'lui', 'lei', 'noi', 'voi', 'loro', 'ci', 'si',
336
+ 'non', 'più', 'molto', 'anche', 'solo', 'tutto', 'tutti', 'ogni',
337
+ 'come', 'dove', 'quando', 'perché', 'ancora', 'già', 'sempre', 'mai',
338
+ // ── Portuguese ──
339
+ 'um', 'uma', 'uns', 'umas', 'do', 'da', 'dos', 'das', 'no', 'na',
340
+ 'ao', 'aos', 'em', 'por', 'com', 'para', 'sem', 'sob', 'sobre',
341
+ 'que', 'se', 'mas', 'ou', 'como', 'mais', 'entre', 'até', 'desde',
342
+ 'é', 'são', 'foi', 'tem', 'ser', 'ter', 'estar', 'fazer', 'havia',
343
+ 'seu', 'sua', 'seus', 'suas', 'este', 'esta', 'esse', 'essa', 'aquele',
344
+ 'eu', 'tu', 'ele', 'ela', 'nós', 'eles', 'elas', 'você', 'vocês',
345
+ 'não', 'já', 'sim', 'bem', 'muito', 'também', 'ainda', 'sempre',
346
+ 'todo', 'toda', 'todos', 'cada', 'outro', 'outra', 'quando', 'porque',
347
+ ]);
348
+
349
+ /** Tokenize text into meaningful keywords. Handles unicode (accented chars). */
350
+ function tokenize(text: string): string[] {
351
+ return text
352
+ .toLowerCase()
353
+ .replace(/[^a-záàâãäåæçéèêëíìîïñóòôõöúùûüýÿßœ0-9:_\-./]/g, ' ')
354
+ .split(/\s+/)
355
+ .filter(w => w.length > 1 && !STOPWORDS.has(w));
356
+ }
357
+
358
+ /**
359
+ * Split a multi-topic query into sub-queries.
360
+ * Detects numbered lists, semicolons, "and also", line breaks, etc.
361
+ *
362
+ * Example: "deploy API fix routing and also check auth" →
363
+ * ["deploy API fix routing", "check auth"]
364
+ *
365
+ * Example: "1. deploy 2. fix routing 3. auth" →
366
+ * ["deploy", "fix routing", "auth"]
367
+ */
368
+ function splitMultiQuery(query: string): string[] {
369
+ // Numbered list: "1. foo 2. bar 3. baz"
370
+ const numberedParts = query.split(/\d+[.)]\s*/g).filter(s => s.trim().length > 2);
371
+ if (numberedParts.length >= 2) return numberedParts.map(s => s.trim());
372
+
373
+ // Semicolons or newlines
374
+ const semiParts = query.split(/[;\n]+/).filter(s => s.trim().length > 2);
375
+ if (semiParts.length >= 2) return semiParts.map(s => s.trim());
376
+
377
+ // Conjunctions (EN + DE + FR + ES)
378
+ const conjParts = query.split(
379
+ /\b(?:and also|also noch|außerdem|plus|additionally|furthermore|de plus|además|inoltre|além disso)\b/i
380
+ ).filter(s => s.trim().length > 2);
381
+ if (conjParts.length >= 2) return conjParts.map(s => s.trim());
382
+
383
+ // Comma-separated with 3+ parts (likely a list)
384
+ const commaParts = query.split(/,\s*/).filter(s => s.trim().length > 2);
385
+ if (commaParts.length >= 3) return commaParts.map(s => s.trim());
386
+
387
+ // Single query
388
+ return [query];
389
+ }
390
+
391
+ // ── BM25+ Scoring ─────────────────────────────────────────────────────────────
392
+ //
393
+ // BM25+ fixes the "long document under-scoring" bug in classic BM25.
394
+ // Paper: Lv & Zhai (2011) "Lower-Bounding Term Frequency Normalization"
395
+ //
396
+ // Improvements over standard BM25:
397
+ // 1. δ=1 additive term → guarantees TF>0 terms always contribute positively
398
+ // 2. Bigram proximity boost → adjacent query terms in doc get 2× weight
399
+ // 3. Recency boost → entries with timestamps get exp-decay bonus (7d half-life)
400
+ // 4. Levenshtein fuzzy match → typo-tolerant (distance ≤ 2)
401
+ //
402
+
403
+ /** BM25+ parameters */
404
+ const BM25_K1 = 1.2; // term frequency saturation
405
+ const BM25_B = 0.75; // length normalization
406
+ const BM25_DELTA = 1.0; // BM25+ lower-bound guarantee (0 = classic BM25)
407
+
408
+ /** Recency boost: half-life in days. Entry from 7 days ago gets 0.5× boost. */
409
+ const RECENCY_HALF_LIFE_DAYS = 7;
410
+
411
+ interface DocEntry {
412
+ key: string;
413
+ content: string;
414
+ tokens: string[];
415
+ tokenFreq: Map<string, number>; // term → count in this doc
416
+ bigrams: Set<string>; // "term1|term2" adjacency pairs
417
+ timestamp?: number; // epoch ms, extracted from content if present
418
+ }
419
+
420
+ interface KeywordMatch {
421
+ key: string;
422
+ content: string;
423
+ score: number;
424
+ matchedWords: string[];
425
+ subQuery?: string; // which sub-query matched (for multi-topic)
426
+ }
427
+
428
+ /**
429
+ * Levenshtein distance — edit distance between two strings.
430
+ * Used for typo-tolerant fuzzy matching (distance ≤ 2 = match).
431
+ * O(n*m) but strings are short (tokens), so this is fast.
432
+ */
433
+ function levenshtein(a: string, b: string): number {
434
+ if (a === b) return 0;
435
+ if (a.length === 0) return b.length;
436
+ if (b.length === 0) return a.length;
437
+ // Skip if length diff > 2 (can't be ≤ 2 edits)
438
+ if (Math.abs(a.length - b.length) > 2) return 3;
439
+
440
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
441
+ for (let i = 1; i <= a.length; i++) {
442
+ let corner = i - 1;
443
+ prev[0] = i;
444
+ for (let j = 1; j <= b.length; j++) {
445
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
446
+ const cur = Math.min(prev[j] + 1, prev[j - 1] + 1, corner + cost);
447
+ corner = prev[j];
448
+ prev[j] = cur;
449
+ }
450
+ }
451
+ return prev[b.length];
452
+ }
453
+
454
+ /** Extract a timestamp from JSON content (looks for "ts" or "created" fields). */
455
+ function extractTimestamp(content: string): number | undefined {
456
+ // Fast regex for ISO dates in JSON: "ts":"2026-04-17T..." or "created":"..."
457
+ const match = content.match(/"(?:ts|created|created_at|timestamp)"\s*:\s*"([^"]+)"/);
458
+ if (match) {
459
+ const ms = Date.parse(match[1]);
460
+ if (!isNaN(ms)) return ms;
461
+ }
462
+ return undefined;
463
+ }
464
+
465
+ /** Recency multiplier: 1.0 for now, 0.5 after half-life days, exponential decay. */
466
+ function recencyBoost(timestampMs: number | undefined): number {
467
+ if (!timestampMs) return 1.0; // no timestamp → neutral
468
+ const ageDays = (Date.now() - timestampMs) / (1000 * 60 * 60 * 24);
469
+ if (ageDays <= 0) return 1.5; // future/just-now → max boost
470
+ return Math.pow(0.5, ageDays / RECENCY_HALF_LIFE_DAYS) + 0.5; // range: [0.5, 1.5]
471
+ }
472
+
473
+ /**
474
+ * BM25+ with Bigram Proximity, Recency Boost, and Fuzzy Matching.
475
+ *
476
+ * Architecture:
477
+ * ┌─────────────────────────────────────────────────────────────────┐
478
+ * │ Query: "deploy API fix routing; check auth" │
479
+ * │ ↓ splitMultiQuery │
480
+ * │ Sub-queries: ["deploy API fix routing", "check auth"] │
481
+ * │ ↓ for each sub-query │
482
+ * │ Tokenize → BM25+ score per doc → Bigram boost → Fuzzy match │
483
+ * │ ↓ merge & deduplicate │
484
+ * │ Recency boost → Sort → Top-K │
485
+ * └─────────────────────────────────────────────────────────────────┘
486
+ */
487
+ async function keywordSearch(
488
+ redis: Redis,
489
+ patterns: string[],
490
+ query: string,
491
+ topK = 10,
492
+ ): Promise<KeywordMatch[]> {
493
+ // ── Step 1: Collect and tokenize all documents ──
494
+ const allKeys: string[] = [];
495
+ for (const pattern of patterns) {
496
+ const stream = redis.scanStream({ match: pattern, count: 200 });
497
+ await new Promise<void>((resolve, reject) => {
498
+ stream.on('data', (batch: string[]) => {
499
+ allKeys.push(...batch.filter((k: string) => !k.endsWith(':meta')));
500
+ });
501
+ stream.on('end', resolve);
502
+ stream.on('error', reject);
503
+ });
504
+ }
505
+
506
+ if (allKeys.length === 0) return [];
507
+
508
+ // Pipeline GET for speed
509
+ const pipeline = redis.pipeline();
510
+ for (const key of allKeys) pipeline.get(key);
511
+ const results = await pipeline.exec();
512
+
513
+ const docs: DocEntry[] = [];
514
+ let totalTokens = 0;
515
+
516
+ for (let i = 0; i < allKeys.length; i++) {
517
+ const content = results?.[i]?.[1] as string | null;
518
+ if (!content) continue;
519
+
520
+ const tokens = tokenize(`${allKeys[i]} ${content}`);
521
+ if (tokens.length === 0) continue;
522
+
523
+ // Term frequency map
524
+ const tokenFreq = new Map<string, number>();
525
+ for (const t of tokens) {
526
+ tokenFreq.set(t, (tokenFreq.get(t) ?? 0) + 1);
527
+ }
528
+
529
+ // Bigrams — adjacent token pairs for proximity detection
530
+ const bigrams = new Set<string>();
531
+ for (let j = 0; j < tokens.length - 1; j++) {
532
+ bigrams.add(`${tokens[j]}|${tokens[j + 1]}`);
533
+ }
534
+
535
+ const timestamp = extractTimestamp(content);
536
+ docs.push({ key: allKeys[i], content, tokens, tokenFreq, bigrams, timestamp });
537
+ totalTokens += tokens.length;
538
+ }
539
+
540
+ if (docs.length === 0) return [];
541
+ const avgDL = totalTokens / docs.length;
542
+
543
+ // ── Step 2: IDF (inverse document frequency) ──
544
+ const docFreq = new Map<string, number>();
545
+ for (const doc of docs) {
546
+ const seen = new Set<string>();
547
+ for (const t of doc.tokens) {
548
+ if (!seen.has(t)) {
549
+ docFreq.set(t, (docFreq.get(t) ?? 0) + 1);
550
+ seen.add(t);
551
+ }
552
+ }
553
+ }
554
+ const N = docs.length;
555
+
556
+ function idf(term: string): number {
557
+ const df = docFreq.get(term) ?? 0;
558
+ return Math.log((N - df + 0.5) / (df + 0.5) + 1);
559
+ }
560
+
561
+ /**
562
+ * BM25+ score for a term in a document.
563
+ * Classic BM25: TF_norm = tf*(k1+1) / (tf + k1*(1 - b + b*dl/avgdl))
564
+ * BM25+ adds: + δ (guarantees long docs with the term still score positively)
565
+ */
566
+ function bm25PlusTerm(term: string, doc: DocEntry): number {
567
+ const tf = doc.tokenFreq.get(term) ?? 0;
568
+ if (tf === 0) return 0;
569
+ const dl = doc.tokens.length;
570
+ const tfNorm = (tf * (BM25_K1 + 1)) / (tf + BM25_K1 * (1 - BM25_B + BM25_B * (dl / avgDL)));
571
+ return idf(term) * (tfNorm + BM25_DELTA);
572
+ }
573
+
574
+ /**
575
+ * Fuzzy match: tries exact → substring → Levenshtein ≤ 2.
576
+ * Returns [matchedTerm, weight] or null.
577
+ */
578
+ function fuzzyMatch(qt: string, docTermSet: Set<string>): [string, number] | null {
579
+ // Exact
580
+ if (docTermSet.has(qt)) return [qt, 1.0];
581
+ // Substring (partial)
582
+ for (const dt of docTermSet) {
583
+ if (dt.length > 3 && qt.length > 3 && (dt.includes(qt) || qt.includes(dt))) {
584
+ return [dt, 0.6];
585
+ }
586
+ }
587
+ // Levenshtein ≤ 2 (typo tolerance) — only for tokens ≥ 4 chars
588
+ if (qt.length >= 4) {
589
+ for (const dt of docTermSet) {
590
+ if (dt.length >= 4 && levenshtein(qt, dt) <= 2) {
591
+ return [dt, 0.4];
592
+ }
593
+ }
594
+ }
595
+ return null;
596
+ }
597
+
598
+ // ── Step 3: Score each sub-query independently ──
599
+ const subQueries = splitMultiQuery(query);
600
+ const allMatches = new Map<string, KeywordMatch>();
601
+
602
+ for (const sq of subQueries) {
603
+ const queryTokens = tokenize(sq);
604
+ if (queryTokens.length === 0) continue;
605
+
606
+ // Pre-compute query bigrams for proximity boost
607
+ const queryBigrams = new Set<string>();
608
+ for (let j = 0; j < queryTokens.length - 1; j++) {
609
+ queryBigrams.add(`${queryTokens[j]}|${queryTokens[j + 1]}`);
610
+ }
611
+
612
+ for (const doc of docs) {
613
+ let score = 0;
614
+ const matchedWords: string[] = [];
615
+ const docTermSet = new Set(doc.tokens);
616
+
617
+ for (const qt of queryTokens) {
618
+ // Try exact BM25+ first
619
+ const exactScore = bm25PlusTerm(qt, doc);
620
+ if (exactScore > 0) {
621
+ score += exactScore;
622
+ matchedWords.push(qt);
623
+ continue;
624
+ }
625
+ // Fuzzy match (substring or Levenshtein)
626
+ const fuzz = fuzzyMatch(qt, docTermSet);
627
+ if (fuzz) {
628
+ score += bm25PlusTerm(fuzz[0], doc) * fuzz[1];
629
+ matchedWords.push(`~${qt}`);
630
+ }
631
+ }
632
+
633
+ // Bigram proximity boost: +50% for each adjacent query term pair found in doc
634
+ if (queryBigrams.size > 0) {
635
+ let bigramHits = 0;
636
+ for (const bg of queryBigrams) {
637
+ if (doc.bigrams.has(bg)) bigramHits++;
638
+ }
639
+ if (bigramHits > 0) {
640
+ score *= 1 + 0.5 * (bigramHits / queryBigrams.size);
641
+ }
642
+ }
643
+
644
+ // Recency boost
645
+ score *= recencyBoost(doc.timestamp);
646
+
647
+ if (score > 0) {
648
+ const existing = allMatches.get(doc.key);
649
+ if (!existing || score > existing.score) {
650
+ allMatches.set(doc.key, {
651
+ key: doc.key,
652
+ content: doc.content,
653
+ score,
654
+ matchedWords: [...new Set(matchedWords)],
655
+ subQuery: subQueries.length > 1 ? sq : undefined,
656
+ });
657
+ }
658
+ }
659
+ }
660
+ }
661
+
662
+ // ── Step 4: Sort by score, return top-K ──
663
+ const sorted = [...allMatches.values()].sort((a, b) => b.score - a.score);
664
+ return sorted.slice(0, topK);
665
+ }
666
+
667
+ // ── Exported for testing ──────────────────────────────────────────────────────
668
+ export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS };
669
+
176
670
  // ── Types ─────────────────────────────────────────────────────────────────────
177
671
 
178
672
  interface Instance {
@@ -898,6 +1392,59 @@ const TOOLS = [
898
1392
  required: ['instance_id', 'summary'],
899
1393
  },
900
1394
  },
1395
+ // ── Session Handoff — cross-window continuity ─────────────────────────────
1396
+ {
1397
+ name: 'session_handoff',
1398
+ description:
1399
+ 'Save a detailed handoff for the NEXT chat window / session. ' +
1400
+ 'Stores: current progress, TODO list (done + remaining), changed files with descriptions, ' +
1401
+ 'instructions for the next assistant, and any incomplete work. ' +
1402
+ 'The next session_start automatically includes this handoff so the new window knows EXACTLY what happened and what remains. ' +
1403
+ 'Call this BEFORE closing a chat window, especially if work is incomplete. ' +
1404
+ 'This prevents the "continue" problem where new windows lose context, skip tasks, or produce broken code.',
1405
+ inputSchema: {
1406
+ type: 'object',
1407
+ properties: {
1408
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
1409
+ completed_tasks: {
1410
+ type: 'array',
1411
+ items: { type: 'string' },
1412
+ description: 'Tasks that were fully completed (e.g. "Implemented brainSearch() in JS SDK")',
1413
+ },
1414
+ remaining_tasks: {
1415
+ type: 'array',
1416
+ items: { type: 'string' },
1417
+ description: 'Tasks NOT yet done — the next window MUST pick these up',
1418
+ },
1419
+ files_changed: {
1420
+ type: 'array',
1421
+ items: {
1422
+ type: 'object',
1423
+ properties: {
1424
+ path: { type: 'string', description: 'File path relative to project root' },
1425
+ status: { type: 'string', enum: ['complete', 'partial', 'broken'], description: 'State of this file' },
1426
+ description: { type: 'string', description: 'What was changed and what still needs work' },
1427
+ },
1428
+ required: ['path', 'status'],
1429
+ },
1430
+ description: 'Changed files with their current state — marks partial/broken files so next window knows to fix them',
1431
+ },
1432
+ instructions: {
1433
+ type: 'string',
1434
+ description: 'Free-form instructions for the next assistant. Be specific: what to do next, what to avoid, what broke.',
1435
+ },
1436
+ context_summary: {
1437
+ type: 'string',
1438
+ description: 'Brief summary of what happened this session (architecture decisions, key findings, blockers)',
1439
+ },
1440
+ blocked_on: {
1441
+ type: 'string',
1442
+ description: 'If work is blocked, describe what is needed to unblock (e.g. "waiting for API deploy", "needs user input on design")',
1443
+ },
1444
+ },
1445
+ required: ['instance_id', 'completed_tasks', 'remaining_tasks'],
1446
+ },
1447
+ },
901
1448
  // ── AI Brain — Extended features ─────────────────────────────────────────
902
1449
  {
903
1450
  name: 'auto_learn_session',
@@ -947,6 +1494,29 @@ const TOOLS = [
947
1494
  required: ['instance_id', 'changed_files'],
948
1495
  },
949
1496
  },
1497
+ {
1498
+ name: 'team_learn',
1499
+ description:
1500
+ 'Store a lesson in a shared team brain so all team members benefit. ' +
1501
+ 'Like learn_from_attempts, but REQUIRES an author name for attribution. ' +
1502
+ 'Shows up in team_recall with "by <author>" so the team knows who learned it.',
1503
+ inputSchema: {
1504
+ type: 'object',
1505
+ properties: {
1506
+ instance_id: { type: 'string', description: 'UUID of the shared team brain instance' },
1507
+ author: { type: 'string', description: 'Your name or handle (required for team attribution)' },
1508
+ topic: { type: 'string', description: 'Topic in category:keyword format (e.g. "deploy:api")' },
1509
+ outcome: { type: 'string', enum: ['success', 'failure', 'partial'], description: 'What happened' },
1510
+ what_worked: { type: 'string', description: 'What worked (the solution)' },
1511
+ what_failed: { type: 'string', description: 'What did NOT work (avoid this)' },
1512
+ severity: { type: 'string', enum: ['critical', 'major', 'minor'], description: 'Impact level' },
1513
+ file_paths: { type: 'array', items: { type: 'string' }, description: 'Relevant file paths' },
1514
+ commands: { type: 'array', items: { type: 'string' }, description: 'Commands that worked' },
1515
+ tags: { type: 'array', items: { type: 'string' }, description: 'Tags for categorization' },
1516
+ },
1517
+ required: ['instance_id', 'author', 'topic', 'outcome', 'what_worked'],
1518
+ },
1519
+ },
950
1520
  {
951
1521
  name: 'team_recall',
952
1522
  description:
@@ -1642,76 +2212,100 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
1642
2212
  const vectorUrl =
1643
2213
  process.env.CACHLY_VECTOR_URL ??
1644
2214
  (inst.vector_token ? `https://api.cachly.dev/v1/sem/${inst.vector_token}` : null);
1645
-
1646
- if (!vectorUrl) {
1647
- return (
1648
- `❌ index_project requires the Speed or Business tier (pgvector).\n\n` +
1649
- `Your instance "${inst.name}" (${inst.tier.toUpperCase()}) does not have a vector_token.\n` +
1650
- `Upgrade at https://cachly.dev/instances/${instance_id}`
1651
- );
1652
- }
2215
+ const canEmbed = vectorUrl && hasEmbedProvider();
1653
2216
 
1654
2217
  let indexed = 0;
1655
2218
  let skipped = 0;
1656
2219
  let errors = 0;
2220
+ let semanticIndexed = 0;
2221
+ let unchanged = 0;
1657
2222
  const details: string[] = [];
1658
2223
  const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
2224
+ const redis = await getConnection(instance_id);
1659
2225
 
1660
2226
  for (const filePath of files) {
1661
2227
  const relPath = relative(dir, filePath);
1662
2228
  let content: string;
2229
+ let fileSize: number;
1663
2230
  try {
1664
2231
  const s = await stat(filePath);
1665
2232
  if (s.size > 200_000) { skipped++; continue; } // skip files >200 KB
2233
+ fileSize = s.size;
1666
2234
  content = await readFile(filePath, 'utf-8');
1667
2235
  } catch {
1668
2236
  errors++;
1669
2237
  continue;
1670
2238
  }
1671
2239
 
1672
- // Prompt = relative path + first N chars of content (for semantic matching)
1673
- const excerpt = content.slice(0, summary_chars).replace(/\s+/g, ' ').trim();
1674
- const prompt = `File: ${relPath}\n${excerpt}`;
1675
- // Value = relative path so the AI knows which file to read_file next
1676
-
1677
- try {
1678
- const embedding = await computeEmbedding(prompt);
1679
- const id = randomUUID();
2240
+ // ── Smart invalidation: hash-based change detection ──
2241
+ // Compute a simple hash of file content to skip unchanged files
2242
+ const hashKey = `cachly:idx:hash:${relPath}`;
2243
+ const contentHash = `${fileSize}:${content.length}:${simpleHash(content)}`;
2244
+ const existingHash = await redis.get(hashKey);
2245
+ if (existingHash === contentHash) {
2246
+ // File unchanged — refresh TTL but skip re-indexing
2247
+ const idxKey = `cachly:idx:${relPath}`;
2248
+ if (ttl > 0) await redis.expire(idxKey, ttl);
2249
+ if (ttl > 0) await redis.expire(hashKey, ttl);
2250
+ unchanged++;
2251
+ continue;
2252
+ }
1680
2253
 
1681
- // Write value to vector index
1682
- await fetch(`${vectorUrl}/entries`, {
1683
- method: 'POST',
1684
- headers: { 'Content-Type': 'application/json' },
1685
- body: JSON.stringify({ id, prompt, namespace: nsArg, embedding, expires_at: expiresAt }),
1686
- });
2254
+ const excerpt = content.slice(0, summary_chars).replace(/\s+/g, ' ').trim();
1687
2255
 
1688
- // Write value to Valkey for retrieval on semantic hit
1689
- const redis = await getConnection(instance_id);
1690
- await redis.set(`${nsArg}:val:${id}`, relPath, 'EX', ttl);
2256
+ // ── Layer 1: Keyword index in Valkey (always works, no embedding needed) ──
2257
+ const idxKey = `cachly:idx:${relPath}`;
2258
+ const idxValue = `File: ${relPath}\n${excerpt}`;
2259
+ if (ttl > 0) {
2260
+ await redis.set(idxKey, idxValue, 'EX', ttl);
2261
+ } else {
2262
+ await redis.set(idxKey, idxValue);
2263
+ }
2264
+ // Store content hash for smart invalidation on next run
2265
+ if (ttl > 0) {
2266
+ await redis.set(hashKey, contentHash, 'EX', ttl);
2267
+ } else {
2268
+ await redis.set(hashKey, contentHash);
2269
+ }
2270
+ indexed++;
2271
+ details.push(` ✅ ${relPath}`);
1691
2272
 
1692
- indexed++;
1693
- details.push(` ✅ ${relPath}`);
1694
- } catch {
1695
- errors++;
1696
- details.push(` ❌ ${relPath} (embedding failed)`);
2273
+ // ── Layer 2: Semantic vector index (optional, only if embedding available) ──
2274
+ if (canEmbed) {
2275
+ try {
2276
+ const prompt = `File: ${relPath}\n${excerpt}`;
2277
+ const embedding = await computeEmbedding(prompt);
2278
+ const id = randomUUID();
2279
+ await fetch(`${vectorUrl}/entries`, {
2280
+ method: 'POST',
2281
+ headers: { 'Content-Type': 'application/json' },
2282
+ body: JSON.stringify({ id, prompt, namespace: nsArg, embedding, expires_at: expiresAt }),
2283
+ });
2284
+ await redis.set(`${nsArg}:val:${id}`, relPath, 'EX', ttl);
2285
+ semanticIndexed++;
2286
+ } catch {
2287
+ // Semantic indexing failed — keyword index is enough
2288
+ }
1697
2289
  }
1698
2290
  }
1699
2291
 
2292
+ const mode = canEmbed ? '🔍 Keyword + 🎯 Semantic' : '🔍 Keyword only (no embedding provider)';
2293
+
1700
2294
  return [
1701
- `📂 **index_project Complete**`,
2295
+ `📂 **index_project Complete** — ${mode}`,
1702
2296
  ``,
1703
- ` 📁 Dir: ${dir}`,
1704
- ` ✅ Indexed: **${indexed}** files`,
1705
- ` ⏭️ Skipped: ${skipped} (too large or filtered)`,
1706
- ` ❌ Errors: ${errors}`,
1707
- ` ⏱️ TTL: ${ttl}s (${Math.round(ttl / 3600)}h)`,
1708
- ` 🏷️ Namespace: \`${nsArg}\``,
2297
+ ` 📁 Dir: ${dir}`,
2298
+ ` ✅ Indexed: **${indexed}** files (new/changed)`,
2299
+ ` ♻️ Unchanged: ${unchanged} files (hash match — skipped)`,
2300
+ ...(canEmbed ? [` 🎯 Semantic: **${semanticIndexed}** files (vector-searchable)`] : []),
2301
+ ` ⏭️ Skipped: ${skipped} (too large or filtered)`,
2302
+ ` ❌ Errors: ${errors}`,
2303
+ ` ⏱️ TTL: ${ttl}s (${Math.round(ttl / 3600)}h)`,
1709
2304
  ``,
1710
2305
  `💡 **Next steps:**`,
1711
- ` 1. Add to .github/copilot-instructions.md:`,
1712
- ` "Before reading source files, call semantic_search with the topic."`,
1713
- ` 2. Re-run index_project after major refactors (or set up a cron TTL).`,
1714
- ` 3. Use semantic_search("how does X work?") to find relevant files.`,
2306
+ ` 1. Use \`smart_recall("how does auth work")\` to find relevant files.`,
2307
+ ` 2. Re-run index_project after major refactors.`,
2308
+ ...(canEmbed ? [] : [` 3. Set OPENAI_API_KEY (or similar) in .env to also enable semantic search.`]),
1715
2309
  ``,
1716
2310
  ...(details.length <= 20 ? details : [...details.slice(0, 20), ` … and ${details.length - 20} more`]),
1717
2311
  ].join('\n');
@@ -2067,72 +2661,106 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2067
2661
  threshold = 0.78,
2068
2662
  } = args as { instance_id: string; query: string; threshold?: number };
2069
2663
 
2070
- const inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
2071
- if (!inst.vector_token) {
2072
- // Fallback to listing and keyword matching
2073
- const redis = await getConnection(instance_id);
2074
- const keys: string[] = [];
2075
- const stream = redis.scanStream({ match: 'cachly:ctx:*', count: 100 });
2076
- await new Promise<void>((resolve, reject) => {
2077
- stream.on('data', (batch: string[]) => {
2078
- keys.push(...batch.filter((k: string) => !k.endsWith(':meta')));
2079
- });
2080
- stream.on('end', resolve);
2081
- stream.on('error', reject);
2082
- });
2664
+ const redis = await getConnection(instance_id);
2083
2665
 
2084
- // Simple keyword matching
2085
- const queryWords = query.toLowerCase().split(/\s+/);
2086
- const matches: string[] = [];
2087
- for (const k of keys) {
2088
- const keyLower = k.toLowerCase();
2089
- if (queryWords.some(w => keyLower.includes(w))) {
2090
- const content = await redis.get(k);
2091
- matches.push(`**${k.replace('cachly:ctx:', '')}**:\n${content?.slice(0, 300)}…\n`);
2092
- }
2093
- }
2666
+ // ── Layer 1: Keyword search across ALL brain data (always works, no embedding) ──
2667
+ const kwMatches = await keywordSearch(
2668
+ redis,
2669
+ ['cachly:ctx:*', 'cachly:lesson:best:*', 'cachly:idx:*'],
2670
+ query,
2671
+ 10,
2672
+ );
2673
+
2674
+ const lines: string[] = [`🧠 **Smart Recall** for: _"${query}"_\n`];
2094
2675
 
2095
- if (matches.length === 0) return `⚠️ No matching context found for: "${query}"\n\nTip: Upgrade to Speed tier for semantic search.`;
2096
- return `🔍 **Keyword-matched context:**\n\n${matches.join('\n')}`;
2676
+ // Show sub-query info if multi-topic was detected
2677
+ const subQueries = splitMultiQuery(query);
2678
+ if (subQueries.length > 1) {
2679
+ lines.push(`_Detected ${subQueries.length} sub-topics:_ ${subQueries.map((s, i) => `${i + 1}. "${s}"`).join(', ')}\n`);
2097
2680
  }
2098
2681
 
2099
- // Semantic search
2100
- const embedding = await computeEmbedding(query);
2101
- const vectorUrl = `https://api.cachly.dev/v1/sem/${inst.vector_token}`;
2102
- const searchRes = await fetch(`${vectorUrl}/search`, {
2103
- method: 'POST',
2104
- headers: { 'Content-Type': 'application/json' },
2105
- body: JSON.stringify({ embedding, namespace: 'cachly:ctx', threshold, top_k: 5 }),
2106
- });
2682
+ if (kwMatches.length > 0) {
2683
+ lines.push(`### 🔍 BM25 Matches (${kwMatches.length})\n`);
2107
2684
 
2108
- if (!searchRes.ok) {
2109
- return `❌ Semantic search failed: ${searchRes.statusText}`;
2685
+ // Group by sub-query if multi-topic
2686
+ if (subQueries.length > 1) {
2687
+ const grouped = new Map<string, KeywordMatch[]>();
2688
+ for (const m of kwMatches.slice(0, 12)) {
2689
+ const sq = m.subQuery ?? query;
2690
+ if (!grouped.has(sq)) grouped.set(sq, []);
2691
+ grouped.get(sq)!.push(m);
2692
+ }
2693
+ for (const [sq, matches] of grouped) {
2694
+ lines.push(`**Topic: "${sq}"** (${matches.length} results)\n`);
2695
+ for (const m of matches.slice(0, 4)) {
2696
+ const label = m.key
2697
+ .replace('cachly:ctx:', '📝 ')
2698
+ .replace('cachly:lesson:best:', '💡 ')
2699
+ .replace('cachly:idx:', '📂 ');
2700
+ const preview = m.content.slice(0, 300).replace(/\n/g, ' ');
2701
+ lines.push(` **${label}** _(BM25: ${m.score.toFixed(2)}, matched: ${m.matchedWords.join(', ')})_`);
2702
+ lines.push(` > ${preview}${m.content.length > 300 ? '…' : ''}\n`);
2703
+ }
2704
+ }
2705
+ // Summary: which sub-queries had matches
2706
+ const matched = [...grouped.keys()];
2707
+ const unmatched = subQueries.filter(sq => !matched.includes(sq));
2708
+ if (unmatched.length > 0) {
2709
+ lines.push(`\n⚠️ **No results for:** ${unmatched.map(s => `"${s}"`).join(', ')}`);
2710
+ }
2711
+ } else {
2712
+ for (const m of kwMatches.slice(0, 8)) {
2713
+ const label = m.key
2714
+ .replace('cachly:ctx:', '📝 ')
2715
+ .replace('cachly:lesson:best:', '💡 ')
2716
+ .replace('cachly:idx:', '📂 ');
2717
+ const preview = m.content.slice(0, 400).replace(/\n/g, ' ');
2718
+ lines.push(`**${label}** _(BM25: ${m.score.toFixed(2)}, matched: ${m.matchedWords.join(', ')})_`);
2719
+ lines.push(`> ${preview}${m.content.length > 400 ? '…' : ''}\n`);
2720
+ }
2721
+ }
2110
2722
  }
2111
2723
 
2112
- const results = (await searchRes.json()) as SemanticSearchResponse[];
2113
- if (!results.length || !results[0].found) {
2114
- return `⚠️ No semantically similar context found for: "${query}"\n\nTry lowering the threshold (current: ${threshold}) or use \`list_remembered\` to see available context.`;
2115
- }
2724
+ // ── Layer 2: Semantic search (optional, only if embedding provider + vector_token available) ──
2725
+ const inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
2726
+ if (inst.vector_token && hasEmbedProvider()) {
2727
+ try {
2728
+ const embedding = await computeEmbedding(query);
2729
+ const vectorUrl = `https://api.cachly.dev/v1/sem/${inst.vector_token}`;
2730
+ const searchRes = await fetch(`${vectorUrl}/search`, {
2731
+ method: 'POST',
2732
+ headers: { 'Content-Type': 'application/json' },
2733
+ body: JSON.stringify({ embedding, namespace: 'cachly:ctx', threshold, top_k: 5 }),
2734
+ });
2116
2735
 
2117
- const redis = await getConnection(instance_id);
2118
- const lines: string[] = [`🧠 **Smart Recall Results** for: _"${query}"_\n`];
2736
+ if (searchRes.ok) {
2737
+ const results = (await searchRes.json()) as SemanticSearchResponse[];
2738
+ const semHits = results.filter(r => r.found && r.id);
2739
+ if (semHits.length > 0) {
2740
+ lines.push(`\n### 🎯 Semantic Matches (${semHits.length})\n`);
2741
+ for (const hit of semHits) {
2742
+ const parts = hit.id!.replace('ctx:', '').split(':');
2743
+ const category = parts[0];
2744
+ const key = parts.slice(1).join(':');
2745
+ const content = await redis.get(`cachly:ctx:${category}:${key}`);
2746
+ lines.push(
2747
+ `**${key}** _(${((hit.similarity ?? 0) * 100).toFixed(0)}% similar)_`,
2748
+ `> ${content?.slice(0, 300) ?? '(evicted)'}${(content?.length ?? 0) > 300 ? '…' : ''}\n`,
2749
+ );
2750
+ }
2751
+ }
2752
+ }
2753
+ } catch {
2754
+ // Semantic search failed silently — keyword results are enough
2755
+ }
2756
+ }
2119
2757
 
2120
- for (const hit of results) {
2121
- if (!hit.found || !hit.id) continue;
2122
- // id format: ctx:category:key
2123
- const parts = hit.id.replace('ctx:', '').split(':');
2124
- const category = parts[0];
2125
- const key = parts.slice(1).join(':');
2126
- const content = await redis.get(`cachly:ctx:${category}:${key}`);
2127
- lines.push(
2128
- `### ${key} (${((hit.similarity ?? 0) * 100).toFixed(0)}% match)`,
2129
- `_Category: ${category}_`,
2130
- `\`\`\``,
2131
- content?.slice(0, 500) ?? '(content evicted)',
2132
- `${(content?.length ?? 0) > 500 ? '…' : ''}`,
2133
- `\`\`\``,
2134
- ``
2135
- );
2758
+ if (kwMatches.length === 0) {
2759
+ lines.push(`⚠️ No matches found for: "${query}"`);
2760
+ lines.push(`\n💡 Tips:`);
2761
+ lines.push(` • Try different keywords`);
2762
+ lines.push(` • Use \`list_remembered\` to see available context`);
2763
+ lines.push(` • Use \`recall_best_solution("topic")\` for exact topic lookup`);
2136
2764
  }
2137
2765
 
2138
2766
  return lines.join('\n');
@@ -2268,6 +2896,37 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2268
2896
  // ── Build briefing ──────────────────────────────────────────────────────
2269
2897
  const lines: string[] = ['🧠 **Session Briefing**', ''];
2270
2898
 
2899
+ // Handoff from previous window (if any)
2900
+ const handoffRaw = await redis.get('cachly:session:handoff');
2901
+ if (handoffRaw) {
2902
+ try {
2903
+ const handoff = JSON.parse(handoffRaw) as {
2904
+ ts: string; completed_tasks: string[]; remaining_tasks: string[];
2905
+ files_changed?: { path: string; status: string; description?: string }[];
2906
+ instructions?: string; context_summary?: string; blocked_on?: string;
2907
+ };
2908
+ const ago = Math.round((Date.now() - new Date(handoff.ts).getTime()) / 60000);
2909
+ const agoStr = ago < 60 ? `${ago}m ago` : ago < 1440 ? `${Math.round(ago / 60)}h ago` : `${Math.round(ago / 1440)}d ago`;
2910
+
2911
+ lines.push(`🤝 **Handoff from previous window** (${agoStr}):`);
2912
+ if (handoff.context_summary) lines.push(` ${handoff.context_summary}`);
2913
+ if (handoff.remaining_tasks.length > 0) {
2914
+ lines.push(` ⏳ **Remaining tasks:**`);
2915
+ for (const t of handoff.remaining_tasks) lines.push(` - ${t}`);
2916
+ }
2917
+ if (handoff.completed_tasks.length > 0) {
2918
+ lines.push(` ✅ **Already done:** ${handoff.completed_tasks.join(', ')}`);
2919
+ }
2920
+ const brokenFiles = (handoff.files_changed ?? []).filter(f => f.status === 'broken' || f.status === 'partial');
2921
+ if (brokenFiles.length > 0) {
2922
+ lines.push(` ⚠️ **Needs fix:** ${brokenFiles.map(f => `\`${f.path}\` (${f.status}${f.description ? ': ' + f.description : ''})`).join(', ')}`);
2923
+ }
2924
+ if (handoff.blocked_on) lines.push(` 🚫 **Blocked on:** ${handoff.blocked_on}`);
2925
+ if (handoff.instructions) lines.push(` 📝 **Instructions:** ${handoff.instructions}`);
2926
+ lines.push('');
2927
+ } catch { /* ignore corrupt handoff */ }
2928
+ }
2929
+
2271
2930
  // Last session
2272
2931
  if (lastSession) {
2273
2932
  const ago = Math.round((Date.now() - new Date(lastSession.ts).getTime()) / 60000);
@@ -2367,6 +3026,80 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2367
3026
  // Clean up current session marker
2368
3027
  await redis.del('cachly:session:current');
2369
3028
 
3029
+ // ── AUTO-LEARN from session summary (no manual call needed) ─────────────
3030
+ // Parse the summary for actionable lessons and store them automatically.
3031
+ const autoLearned: string[] = [];
3032
+ try {
3033
+ // Extract key sentences from the summary that contain action verbs
3034
+ 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;
3035
+ const sentences = summary
3036
+ .split(/[.!\n]+/)
3037
+ .map(s => s.trim())
3038
+ .filter(s => s.length > 20 && actionVerbs.test(s));
3039
+
3040
+ for (const sentence of sentences.slice(0, 6)) {
3041
+ // Build a topic slug from the first meaningful words
3042
+ const words = sentence.toLowerCase()
3043
+ .replace(/[^a-z0-9\s]/g, ' ')
3044
+ .split(/\s+/)
3045
+ .filter(w => w.length > 3 && !['that', 'this', 'with', 'from', 'have', 'been', 'were', 'they', 'then', 'when', 'also', 'into', 'will', 'would', 'could', 'should'].includes(w));
3046
+ const slug = words.slice(0, 4).join('-');
3047
+ if (!slug) continue;
3048
+ const topic = `auto:${slug}`;
3049
+ const key = `cachly:lesson:best:${topic}`;
3050
+
3051
+ // Don't overwrite existing successful lessons
3052
+ const existing = await redis.get(key);
3053
+ if (existing) {
3054
+ try {
3055
+ const ex = JSON.parse(existing) as { outcome: string };
3056
+ if (ex.outcome === 'success') continue;
3057
+ } catch { /* ignore */ }
3058
+ }
3059
+
3060
+ const lesson = {
3061
+ topic,
3062
+ outcome: 'success',
3063
+ what_worked: sentence,
3064
+ context: `Auto-learned from session summary. Full summary: ${summary.slice(0, 300)}`,
3065
+ severity: 'minor',
3066
+ ts: now.toISOString(),
3067
+ recall_count: 0,
3068
+ auto_learned: true,
3069
+ session_ts: now.toISOString(),
3070
+ version: 2,
3071
+ };
3072
+ await redis.set(key, JSON.stringify(lesson));
3073
+ // 90-day TTL for auto-learned lessons
3074
+ await redis.expire(key, 90 * 86400);
3075
+ autoLearned.push(topic);
3076
+ }
3077
+
3078
+ // Also store a lesson per changed file area if files were changed
3079
+ if (files_changed.length > 0) {
3080
+ const areas = [...new Set(files_changed.map(f => f.split('/').slice(0, 2).join('/')))].slice(0, 3);
3081
+ for (const area of areas) {
3082
+ const slug = area.replace(/[^a-z0-9]/gi, '-').toLowerCase().replace(/-+/g, '-').slice(0, 30);
3083
+ const topic = `auto:changed:${slug}`;
3084
+ const key = `cachly:lesson:best:${topic}`;
3085
+ const lesson = {
3086
+ topic,
3087
+ outcome: 'success',
3088
+ what_worked: `Files changed in ${area}: ${files_changed.filter(f => f.startsWith(area.split('/')[0])).slice(0, 5).join(', ')}`,
3089
+ context: summary.slice(0, 200),
3090
+ severity: 'minor',
3091
+ ts: now.toISOString(),
3092
+ recall_count: 0,
3093
+ auto_learned: true,
3094
+ version: 2,
3095
+ };
3096
+ await redis.set(key, JSON.stringify(lesson));
3097
+ await redis.expire(key, 90 * 86400);
3098
+ autoLearned.push(topic);
3099
+ }
3100
+ }
3101
+ } catch { /* auto-learn errors must never break session_end */ }
3102
+
2370
3103
  const durationStr = durationMin !== undefined ? ` · ${durationMin} min` : '';
2371
3104
  return [
2372
3105
  `✅ **Session saved**${durationStr}`,
@@ -2374,11 +3107,69 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2374
3107
  `📋 **Summary:** ${summary}`,
2375
3108
  files_changed.length > 0 ? `📁 **Files changed:** ${files_changed.map(f => `\`${f}\``).join(', ')}` : '',
2376
3109
  lessons_learned !== undefined ? `🧠 **Lessons stored:** ${lessons_learned}` : '',
3110
+ autoLearned.length > 0 ? `🤖 **Auto-learned:** ${autoLearned.length} lessons extracted from summary (${autoLearned.slice(0, 3).map(t => `\`${t}\``).join(', ')}${autoLearned.length > 3 ? '…' : ''})` : '',
2377
3111
  ``,
2378
3112
  `💡 Next session: \`session_start(focus="...")\` to see this summary.`,
2379
3113
  ].filter(l => l !== '').join('\n');
2380
3114
  }
2381
3115
 
3116
+ // ── session_handoff — cross-window continuity ─────────────────────────────
3117
+ case 'session_handoff': {
3118
+ const {
3119
+ instance_id,
3120
+ completed_tasks = [],
3121
+ remaining_tasks = [],
3122
+ files_changed = [],
3123
+ instructions = '',
3124
+ context_summary = '',
3125
+ blocked_on = '',
3126
+ } = args as {
3127
+ instance_id: string;
3128
+ completed_tasks: string[];
3129
+ remaining_tasks: string[];
3130
+ files_changed?: { path: string; status: string; description?: string }[];
3131
+ instructions?: string;
3132
+ context_summary?: string;
3133
+ blocked_on?: string;
3134
+ };
3135
+
3136
+ const redis = await getConnection(instance_id);
3137
+ const now = new Date();
3138
+
3139
+ const handoff = {
3140
+ ts: now.toISOString(),
3141
+ completed_tasks,
3142
+ remaining_tasks,
3143
+ files_changed,
3144
+ instructions,
3145
+ context_summary,
3146
+ blocked_on,
3147
+ };
3148
+
3149
+ // Store handoff — never expires until next handoff overwrites it
3150
+ await redis.set('cachly:session:handoff', JSON.stringify(handoff));
3151
+
3152
+ // Also append to history
3153
+ await redis.lpush('cachly:session:handoff:history', JSON.stringify(handoff));
3154
+ await redis.ltrim('cachly:session:handoff:history', 0, 19);
3155
+
3156
+ const totalTasks = completed_tasks.length + remaining_tasks.length;
3157
+ const pct = totalTasks > 0 ? Math.round((completed_tasks.length / totalTasks) * 100) : 0;
3158
+ const brokenFiles = files_changed.filter(f => f.status === 'broken' || f.status === 'partial');
3159
+
3160
+ return [
3161
+ `🤝 **Handoff saved** — ${completed_tasks.length}/${totalTasks} tasks done (${pct}%)`,
3162
+ ``,
3163
+ completed_tasks.length > 0 ? `✅ **Completed:**\n${completed_tasks.map(t => ` - ${t}`).join('\n')}` : '',
3164
+ remaining_tasks.length > 0 ? `\n⏳ **Remaining for next window:**\n${remaining_tasks.map(t => ` - ${t}`).join('\n')}` : '',
3165
+ brokenFiles.length > 0 ? `\n⚠️ **Needs attention:** ${brokenFiles.map(f => `\`${f.path}\` (${f.status})`).join(', ')}` : '',
3166
+ blocked_on ? `\n🚫 **Blocked on:** ${blocked_on}` : '',
3167
+ instructions ? `\n📝 **Instructions:** ${instructions}` : '',
3168
+ ``,
3169
+ `💡 The next \`session_start\` will include this handoff automatically.`,
3170
+ ].filter(l => l !== '').join('\n');
3171
+ }
3172
+
2382
3173
  // ── auto_learn_session ────────────────────────────────────────────────────
2383
3174
  case 'auto_learn_session': {
2384
3175
  const { instance_id, observations } = args as {
@@ -2501,6 +3292,43 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2501
3292
  return lines.filter(Boolean).join('\n');
2502
3293
  }
2503
3294
 
3295
+ // ── team_learn ────────────────────────────────────────────────────────────
3296
+ case 'team_learn': {
3297
+ const { instance_id, author, topic, outcome, what_worked, what_failed, severity, file_paths, commands, tags } = args as {
3298
+ instance_id: string; author: string; topic: string; outcome: string;
3299
+ what_worked: string; what_failed?: string; severity?: string;
3300
+ file_paths?: string[]; commands?: string[]; tags?: string[];
3301
+ };
3302
+ if (!author || !topic || !outcome || !what_worked) {
3303
+ return '❌ Required: author, topic, outcome, what_worked';
3304
+ }
3305
+ const iid = instance_id;
3306
+ if (!iid) return '❌ instance_id required';
3307
+
3308
+ // Store with author attribution via the same learn_from_attempts Redis structure
3309
+ const lesson = {
3310
+ topic, outcome, what_worked,
3311
+ what_failed: what_failed ?? '',
3312
+ severity: severity ?? 'minor',
3313
+ author,
3314
+ file_paths: file_paths ?? [],
3315
+ commands: commands ?? [],
3316
+ tags: [...(tags ?? []), 'team'],
3317
+ timestamp: new Date().toISOString(),
3318
+ recall_count: 0,
3319
+ version: 2,
3320
+ };
3321
+
3322
+ const redis = await getConnection(iid);
3323
+ const key = `cachly:lessons:${topic}`;
3324
+ await redis.rpush(key, JSON.stringify(lesson));
3325
+ if (outcome === 'success') {
3326
+ await redis.set(`cachly:lesson:best:${topic}`, JSON.stringify(lesson));
3327
+ }
3328
+
3329
+ return `✅ Team lesson stored by **${author}**: \`${topic}\` (${outcome})\n💡 ${what_worked.slice(0, 120)}`;
3330
+ }
3331
+
2504
3332
  // ── team_recall ───────────────────────────────────────────────────────────
2505
3333
  case 'team_recall': {
2506
3334
  const { instance_id, topic, author, limit = 10 } = args as {
@@ -2954,65 +3782,148 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
2954
3782
  // Generate copilot-instructions.md content
2955
3783
  const tier = inst.tier.toUpperCase();
2956
3784
  const smartRecallNote = hasVector
2957
- ? '3. Call `smart_recall` with keywords for semantic context search'
2958
- : '3. Call `recall_context` with relevant key patterns like "arch:*" or "file:*"';
3785
+ ? '- `smart_recall("natural language query")` — semantic search (finds by meaning)'
3786
+ : '- `recall_context("arch:*")` — exact/glob key lookup (upgrade to Speed/Business for smart_recall)';
2959
3787
  const layerNote = hasVector
2960
- ? `Layer 3 (Semantic): smart_recall uses pgvector HNSW on your ${tier} instance for meaning-based search`
2961
- : `Layer 3 (Autopilot): This file — upgrade to Speed/Business tier to unlock smart_recall`;
3788
+ ? `Layer 3 (Semantic): smart_recall uses pgvector HNSW on your ${tier} instance`
3789
+ : `Layer 3 (Autopilot): this file — upgrade to Speed/Business tier to unlock smart_recall`;
2962
3790
 
2963
- const copilotInstructions = `# cachly AI Memory — ${project_description}
3791
+ const copilotInstructions = `# cachly AI Brain — ${project_description}
2964
3792
 
2965
- ## 3-Layer AI Memory System
3793
+ > AI memory system powered by [cachly.dev](https://cachly.dev). Works with GitHub Copilot, Claude Code, Cursor, Windsurf, Continue.dev, and any MCP-compatible editor.
2966
3794
 
2967
- - **Layer 1 — Storage:** cachly instance \`${inst.name}\` (${tier}) — persistent Valkey brain, survives restarts
2968
- - **Layer 2 — Tools:** learn_from_attempts · recall_best_solution · smart_recall — the memory API
2969
- - **Layer 3 — Autopilot (this file):** instructs the AI to run memory tools automatically
3795
+ ---
2970
3796
 
2971
- ## BEFORE every task — check memory first
3797
+ ## 🔴 MANDATORY RULES — Always enforced, no exceptions
2972
3798
 
2973
- 1. Call \`recall_best_solution\` with the task topic to get known solutions
2974
- - \`recall_best_solution("deploy:web")\` → returns working deploy command
2975
- - \`recall_best_solution("debug:redis-tls")\` → returns root cause + fix
2976
- - If a solution exists, use it — do NOT re-discover from scratch
3799
+ ### Rule 1 — Session Start (FIRST thing in every window)
3800
+ Call \`session_start\` **before reading any source files or making any changes**:
2977
3801
 
2978
- 2. Call \`recall_context\` with the area you're working in
2979
- - \`recall_context("arch:*")\` → cached architecture decisions
2980
- - \`recall_context("file:src/auth*")\` → cached file summaries
3802
+ \`\`\`
3803
+ session_start(
3804
+ instance_id = "${instance_id}",
3805
+ focus = "<what you plan to work on today>",
3806
+ )
3807
+ \`\`\`
2981
3808
 
2982
- ${smartRecallNote}
2983
- - \`smart_recall("how does authentication work")\` → returns cached auth notes
3809
+ Returns: last session summary, handoff from previous window, recent lessons, open failures, brain health.
2984
3810
 
2985
- 4. If nothing is found, proceed normally — then save (see below)
3811
+ **If \`session_start\` is unavailable**, fall back:
3812
+ 1. \`smart_recall("recent lessons <topic>")\`
3813
+ 2. \`recall_best_solution("<topic>")\`
2986
3814
 
2987
- ## AFTER every non-trivial task — save to memory
3815
+ ### Rule 2 — Learn After Every Fix/Deploy/Discovery (AUTOMATIC — no prompting needed)
3816
+ After **every** bug fix, deployment, failed attempt, or discovery — call \`learn_from_attempts\` immediately:
2988
3817
 
2989
- 1. Call \`learn_from_attempts\` for deploy/debug/fix tasks:
2990
- - Always set outcome = "success" | "failure" | "partial"
2991
- - Always include what_failed — it prevents future wasted time
2992
- - Example: \`learn_from_attempts(topic="deploy:api", outcome="success", what_worked="nohup docker compose up -d --build", what_failed="direct docker compose hangs on SSH timeout")\`
3818
+ \`\`\`
3819
+ learn_from_attempts(
3820
+ instance_id = "${instance_id}",
3821
+ topic = "category:keyword", # deploy:web | fix:auth | debug:db | infra:docker
3822
+ outcome = "success" | "failure" | "partial",
3823
+ what_worked = "exact approach or command that worked",
3824
+ what_failed = "what did NOT work (prevents future wasted time)",
3825
+ severity = "critical" | "major" | "minor",
3826
+ commands = ["exact command that worked"],
3827
+ file_paths = ["path/to/changed/file"],
3828
+ )
3829
+ \`\`\`
2993
3830
 
2994
- 2. Call \`remember_context\` for code/architecture findings:
2995
- - \`remember_context("arch:auth", "Uses Keycloak with JWT...", category="architecture")\`
2996
- - \`remember_context("file:src/index.ts", "Entry point, registers routes...", category="file_summary")\`
3831
+ **These events MUST automatically trigger \`learn_from_attempts\`:**
3832
+ - ✅ Successfully deployed → \`topic="deploy:<component>", outcome="success"\`
3833
+ - ✅ Fixed a bug → \`topic="fix:<description>", outcome="success"\`
3834
+ - ✅ Discovered root cause → \`topic="debug:<area>", outcome="partial"\`
3835
+ - ✅ Tried something that failed → \`topic="<area>", outcome="failure", what_failed="..."\`
3836
+ - ✅ Found a config quirk → \`topic="infra:<topic>", outcome="success"\`
3837
+ - ✅ Ran a working command → store it in \`commands=[...]\`
3838
+
3839
+ ### Rule 3 — Session Handoff (when closing window or task list > 3 items)
3840
+ \`\`\`
3841
+ session_handoff(
3842
+ instance_id = "${instance_id}",
3843
+ completed_tasks = ["what was done"],
3844
+ remaining_tasks = ["what is still to do"],
3845
+ files_changed = [{"path": "src/...", "status": "modified", "description": "what changed"}],
3846
+ instructions = "IMPORTANT: what the next window MUST know to not break things",
3847
+ context_summary = "current state of the system",
3848
+ )
3849
+ \`\`\`
3850
+
3851
+ The next \`session_start\` automatically includes this handoff. The new window sees exactly what's done, what remains, and what NOT to break.
3852
+
3853
+ ### Rule 4 — Session End (LAST thing before closing)
3854
+ \`\`\`
3855
+ session_end(
3856
+ instance_id = "${instance_id}",
3857
+ summary = "What was accomplished (2-3 sentences with specifics)",
3858
+ files_changed = ["list", "of", "changed", "files"],
3859
+ lessons_learned = <number of learn_from_attempts calls made>,
3860
+ )
3861
+ \`\`\`
3862
+
3863
+ > \`session_end\` **automatically extracts and stores lessons** from the summary text — no manual calls needed.
3864
+
3865
+ ### Rule 5 — Code Index Invalidation (AUTOMATIC)
3866
+ - \`index_project\` tracks MD5 hashes per file — **skips unchanged files automatically**
3867
+ - After code changes: next \`index_project\` run re-indexes only modified files
3868
+ - After delete/rename: run \`index_project\` again to sync
3869
+ - No manual invalidation needed
3870
+
3871
+ ---
3872
+
3873
+ ## Task-type trigger table
3874
+
3875
+ | You're about to... | Call BEFORE | Call AFTER |
3876
+ |---|---|---|
3877
+ | Deploy anything | \`recall_best_solution("deploy:<component>")\` | \`learn_from_attempts(topic="deploy:...")\` |
3878
+ | Fix a bug | \`recall_best_solution("fix:<area>")\` | \`learn_from_attempts(topic="fix:...")\` |
3879
+ | Add a feature | \`session_start(focus="feat:<area>")\` | \`learn_from_attempts(topic="feat:...")\` |
3880
+ | Infra/server work | \`recall_best_solution("infra:<topic>")\` | \`learn_from_attempts(topic="infra:...")\` |
3881
+ | Debug an issue | \`smart_recall("<error message or symptom>")\` | \`learn_from_attempts(topic="debug:...")\` |
3882
+
3883
+ ---
3884
+
3885
+ ## Available Brain Tools
3886
+
3887
+ | Tool | When to use |
3888
+ |------|-------------|
3889
+ | \`session_start\` | **FIRST** — mandatory at start of every session |
3890
+ | \`session_end\` | **LAST** — mandatory at end, auto-learns from summary |
3891
+ | \`session_handoff\` | When closing window with remaining tasks |
3892
+ | \`learn_from_attempts\` | **AUTOMATIC** after every fix/deploy/discovery |
3893
+ | \`recall_best_solution\` | Before any non-trivial task |
3894
+ | \`remember_context\` | After analyzing code — save findings for future sessions |
3895
+ ${smartRecallNote ? `| \`smart_recall\` | Search brain by meaning/keywords |\n` : ''}\
3896
+ | \`recall_context\` | Get exact key (supports glob: \`arch:*\`, \`file:*\`) |
3897
+ | \`brain_search\` | BM25+ full-text search over all brain data |
3898
+ | \`auto_learn_session\` | Batch-learn from a list of observations (optional) |
3899
+ | \`index_project\` | Index source files (smart hash, skips unchanged files) |
3900
+ | \`list_remembered\` | See what's cached in the brain |
3901
+ | \`forget_context\` | Remove stale context |
3902
+
3903
+ ---
2997
3904
 
2998
3905
  ## Instance Details
2999
3906
 
3000
- - Instance ID: \`${instance_id}\`
3001
- - Instance name: ${inst.name}
3002
- - Tier: ${tier}
3003
- - ${layerNote}
3004
- - Embedding provider: ${providerArg}
3907
+ - **Instance ID:** \`${instance_id}\`
3908
+ - **Instance name:** ${inst.name}
3909
+ - **Tier:** ${tier}
3910
+ - **${layerNote}**
3911
+ - **Embedding provider:** ${providerArg}
3912
+
3913
+ ---
3005
3914
 
3006
- ## Quick reference
3915
+ ## How the 3-layer system works
3007
3916
 
3008
3917
  \`\`\`
3009
- recall_best_solution("topic") # before task
3010
- smart_recall("natural language") # find by meaning
3011
- learn_from_attempts(...) # after task
3012
- remember_context("key", "content") # save analysis
3918
+ Layer 1 — Storage: Your cachly Valkey instance (${inst.name}) — persists forever
3919
+ Layer 2 — Tools: learn_from_attempts · recall_best_solution · brain_search · session_start/end
3920
+ Layer 3 — Autopilot: This file — AI reads it and runs tools automatically every session
3013
3921
  \`\`\`
3922
+
3923
+ Result: Your AI **never solves the same problem twice** and always picks up exactly where it left off. 🚀
3014
3924
  `;
3015
3925
 
3926
+
3016
3927
  const lines: string[] = [
3017
3928
  `🧠 **cachly AI Memory Setup Complete**`,
3018
3929
  ``,