@cachly-dev/mcp-server 0.1.0 → 0.5.1

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)
69
102
  *
70
- * Note: Anthropic (Claude) does not provide embedding models.
71
- * Use Cohere or OpenAI instead.
103
+ * Server-side fallback (no key needed on client):
104
+ * cachly – POST /api/v1/embed (requires CACHLY_JWT)
105
+ *
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 {
@@ -3228,95 +4056,243 @@ process.on('unhandledRejection', (reason) => {
3228
4056
  process.stderr.write(`[cachly-mcp] unhandledRejection: ${msg}\n`);
3229
4057
  });
3230
4058
 
4059
+ // ── CLI helpers ───────────────────────────────────────────────────────────────
4060
+
4061
+ const EDITOR_FILES: Record<string, string> = {
4062
+ claude: '.mcp.json',
4063
+ cursor: '.cursor/mcp.json',
4064
+ windsurf: '.windsurf/mcp.json',
4065
+ copilot: '.vscode/mcp.json',
4066
+ continue: '.continue/config.json',
4067
+ };
4068
+
4069
+ const CLAUDE_MD_MARKER_START = '<!-- cachly-brain-start -->';
4070
+ const CLAUDE_MD_MARKER_END = '<!-- cachly-brain-end -->';
4071
+
4072
+ function buildMcpConfig(apiKey: string, instanceId: string, editor: string): string {
4073
+ if (editor === 'continue') {
4074
+ return JSON.stringify({
4075
+ experimental: {
4076
+ modelContextProtocolServers: [{
4077
+ transport: { type: 'stdio', command: 'npx', args: ['-y', '@cachly-dev/mcp-server@latest'] },
4078
+ env: { CACHLY_API_URL: 'https://api.cachly.dev', CACHLY_JWT: apiKey, CACHLY_BRAIN_INSTANCE_ID: instanceId },
4079
+ }],
4080
+ },
4081
+ }, null, 2);
4082
+ }
4083
+ return JSON.stringify({
4084
+ mcpServers: {
4085
+ cachly: {
4086
+ command: 'npx',
4087
+ args: ['-y', '@cachly-dev/mcp-server@latest'],
4088
+ env: { CACHLY_API_URL: 'https://api.cachly.dev', CACHLY_JWT: apiKey, CACHLY_BRAIN_INSTANCE_ID: instanceId },
4089
+ },
4090
+ },
4091
+ }, null, 2);
4092
+ }
4093
+
4094
+ function buildClaudeMdBlock(instanceId: string): string {
4095
+ return `${CLAUDE_MD_MARKER_START}
4096
+ ## Cachly AI Brain
4097
+
4098
+ The Cachly MCP server is available as tool \`cachly\`.
4099
+
4100
+ ### At the START of every session (one call):
4101
+ \`\`\`
4102
+ session_start(
4103
+ instance_id = "${instanceId}",
4104
+ focus = "<what you plan to work on today>",
4105
+ )
4106
+ \`\`\`
4107
+
4108
+ ### At the END of every session:
4109
+ \`\`\`
4110
+ session_end(
4111
+ instance_id = "${instanceId}",
4112
+ summary = "What was accomplished (2-3 sentences)",
4113
+ files_changed = ["src/..."],
4114
+ lessons_learned = 1,
4115
+ )
4116
+ \`\`\`
4117
+
4118
+ ### After fixing any bug or learning something new:
4119
+ \`\`\`
4120
+ learn_from_attempts(
4121
+ instance_id = "${instanceId}",
4122
+ topic = "category:keyword",
4123
+ outcome = "success",
4124
+ what_worked = "...",
4125
+ what_failed = "...",
4126
+ severity = "critical" | "major" | "minor",
4127
+ file_paths = ["path/to/file"],
4128
+ commands = ["the command that worked"],
4129
+ tags = ["tag1", "tag2"],
4130
+ )
4131
+ \`\`\`
4132
+ ${CLAUDE_MD_MARKER_END}`;
4133
+ }
4134
+
4135
+ async function writeClaudeMd(projectDir: string, instanceId: string): Promise<'written' | 'updated' | 'appended'> {
4136
+ const { writeFile, appendFile, readFile } = await import('node:fs/promises');
4137
+ const { existsSync } = await import('node:fs');
4138
+ const { resolve } = await import('node:path');
4139
+
4140
+ const claudeMdPath = resolve(projectDir, 'CLAUDE.md');
4141
+ const block = '\n' + buildClaudeMdBlock(instanceId) + '\n';
4142
+
4143
+ if (existsSync(claudeMdPath)) {
4144
+ const existing = await readFile(claudeMdPath, 'utf-8');
4145
+ if (existing.includes(CLAUDE_MD_MARKER_START)) {
4146
+ // Idempotent update: replace existing block (new instance-id, refreshed content)
4147
+ const updated = existing.replace(
4148
+ new RegExp(`${CLAUDE_MD_MARKER_START}[\\s\\S]*?${CLAUDE_MD_MARKER_END}`),
4149
+ buildClaudeMdBlock(instanceId)
4150
+ );
4151
+ await writeFile(claudeMdPath, updated, 'utf-8');
4152
+ return 'updated';
4153
+ }
4154
+ await appendFile(claudeMdPath, block, 'utf-8');
4155
+ return 'appended';
4156
+ }
4157
+ await writeFile(claudeMdPath, block.trimStart(), 'utf-8');
4158
+ return 'written';
4159
+ }
4160
+
3231
4161
  // ── CLI: cachly init ──────────────────────────────────────────────────────────
3232
4162
  // Usage: npx @cachly-dev/mcp-server init --instance-id <id> --api-key <key> [--editor claude|cursor|windsurf|copilot|continue] [--project-dir /path]
3233
4163
 
3234
4164
  if (process.argv[2] === 'init') {
3235
4165
  const { writeFile, mkdir } = await import('node:fs/promises');
3236
- const { existsSync } = await import('node:fs');
3237
4166
  const { resolve, dirname } = await import('node:path');
3238
4167
 
3239
- // Parse CLI flags
3240
4168
  const argv = process.argv.slice(3);
3241
- const flag = (name: string) => {
3242
- const i = argv.indexOf(`--${name}`);
3243
- return i !== -1 ? argv[i + 1] : undefined;
3244
- };
4169
+ const flag = (name: string) => { const i = argv.indexOf(`--${name}`); return i !== -1 ? argv[i + 1] : undefined; };
3245
4170
 
3246
4171
  const instanceId = flag('instance-id') ?? process.env.CACHLY_BRAIN_INSTANCE_ID;
3247
- const apiKey = flag('api-key') ?? process.env.CACHLY_JWT;
3248
- const editor = (flag('editor') ?? 'claude').toLowerCase();
4172
+ const apiKey = flag('api-key') ?? process.env.CACHLY_JWT;
4173
+ const editor = (flag('editor') ?? 'claude').toLowerCase();
3249
4174
  const projectDir = resolve(flag('project-dir') ?? '.');
3250
4175
 
3251
4176
  if (!instanceId || !apiKey) {
3252
4177
  console.error('\nUsage: npx @cachly-dev/mcp-server@latest init --instance-id <uuid> --api-key <cky_live_...> [--editor claude|cursor|windsurf|copilot|continue] [--project-dir /path]\n');
3253
- console.error('Get your instance ID and API key from: https://cachly.dev/instances\n');
4178
+ console.error('Or run interactively (no flags needed): npx @cachly-dev/mcp-server@latest setup\n');
4179
+ console.error('Get your credentials from: https://cachly.dev/setup-ai\n');
3254
4180
  process.exit(1);
3255
4181
  }
3256
4182
 
3257
- const editorFiles: Record<string, string> = {
3258
- claude: '.mcp.json',
3259
- cursor: '.cursor/mcp.json',
3260
- windsurf: '.windsurf/mcp.json',
3261
- copilot: '.vscode/mcp.json',
3262
- continue: '.continue/config.json',
3263
- };
3264
- const configFile = editorFiles[editor] ?? '.mcp.json';
4183
+ const configFile = EDITOR_FILES[editor] ?? '.mcp.json';
3265
4184
  const configPath = resolve(projectDir, configFile);
4185
+ await mkdir(dirname(configPath), { recursive: true });
4186
+ await writeFile(configPath, buildMcpConfig(apiKey, instanceId, editor), 'utf-8');
4187
+ console.log(`\n✅ Written: ${configFile}`);
3266
4188
 
3267
- // Build config content
3268
- let configContent: string;
3269
- if (editor === 'continue') {
3270
- configContent = JSON.stringify({
3271
- experimental: {
3272
- modelContextProtocolServers: [{
3273
- transport: { type: 'stdio', command: 'npx', args: ['-y', '@cachly-dev/mcp-server@latest'] },
3274
- env: {
3275
- CACHLY_API_URL: 'https://api.cachly.dev',
3276
- CACHLY_JWT: apiKey,
3277
- CACHLY_BRAIN_INSTANCE_ID: instanceId,
3278
- },
3279
- }],
3280
- },
3281
- }, null, 2);
4189
+ // Always write CLAUDE.md (idempotent — safe to run multiple times)
4190
+ const result = await writeClaudeMd(projectDir, instanceId);
4191
+ const action = result === 'updated' ? '✅ Updated' : result === 'appended' ? '✅ Appended to' : '✅ Written';
4192
+ console.log(`${action}: CLAUDE.md`);
4193
+
4194
+ console.log(`\n🧠 Cachly AI Brain configured for ${editor === 'claude' ? 'Claude Code' : editor}!`);
4195
+ console.log(` Restart your editor — the \`cachly\` MCP tools will appear.\n`);
4196
+ process.exit(0);
4197
+ }
4198
+
4199
+ // ── CLI: cachly setup (interactive — no flags required) ───────────────────────
4200
+ // Usage: npx @cachly-dev/mcp-server setup
4201
+
4202
+ if (process.argv[2] === 'setup') {
4203
+ const { writeFile, mkdir } = await import('node:fs/promises');
4204
+ const { existsSync } = await import('node:fs');
4205
+ const { resolve, dirname } = await import('node:path');
4206
+ const { createInterface } = await import('node:readline');
4207
+
4208
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
4209
+ const ask = (q: string): Promise<string> => new Promise(res => rl.question(q, ans => res(ans.trim())));
4210
+
4211
+ console.log('\n🧠 cachly AI Brain — Interactive Setup');
4212
+ console.log('────────────────────────────────────────\n');
4213
+
4214
+ // ── Step 1: API token ──────────────────────────────────────────────────────
4215
+ let token = process.env.CACHLY_JWT ?? '';
4216
+ if (token) {
4217
+ console.log('✓ Using token from CACHLY_JWT env var\n');
3282
4218
  } else {
3283
- configContent = JSON.stringify({
3284
- mcpServers: {
3285
- cachly: {
3286
- command: 'npx',
3287
- args: ['-y', '@cachly-dev/mcp-server@latest'],
3288
- env: {
3289
- CACHLY_API_URL: 'https://api.cachly.dev',
3290
- CACHLY_JWT: apiKey,
3291
- CACHLY_BRAIN_INSTANCE_ID: instanceId,
3292
- },
3293
- },
3294
- },
3295
- }, null, 2);
4219
+ console.log('Step 1: Get your API token at https://cachly.dev/setup-ai\n');
4220
+ token = await ask(' Paste API token (cky_live_...): ');
4221
+ if (!token) { console.error('\nToken is required. Aborting.\n'); rl.close(); process.exit(1); }
4222
+ console.log('');
3296
4223
  }
3297
4224
 
3298
- // Create parent dirs if needed, then write config
3299
- await mkdir(dirname(configPath), { recursive: true });
3300
- await writeFile(configPath, configContent, 'utf-8');
3301
- console.log(`\n✅ Written: ${configPath}`);
3302
-
3303
- // For Claude Code, also offer to append CLAUDE.md snippet
3304
- if (editor === 'claude') {
3305
- const claudeMdSnippet = `\n## Cachly AI Brain\n\nThe Cachly MCP server is available as tool \`cachly\`.\n\n### At the START of every session (one call):\n\`\`\`\nsession_start(\n instance_id = "${instanceId}",\n focus = "<what you plan to work on today>",\n)\n\`\`\`\n\n### At the END of every session:\n\`\`\`\nsession_end(\n instance_id = "${instanceId}",\n summary = "What was accomplished",\n files_changed = ["src/..."],\n lessons_learned = 1,\n)\n\`\`\`\n\n### After fixing any bug or learning something:\n\`\`\`\nlearn_from_attempts(\n instance_id = "${instanceId}",\n topic = "category:keyword",\n outcome = "success",\n what_worked = "...",\n what_failed = "...",\n severity = "critical" | "major" | "minor",\n file_paths = ["path/to/file"],\n commands = ["the command that worked"],\n tags = ["tag1", "tag2"],\n)\n\`\`\`\n`;
3306
-
3307
- const claudeMdPath = resolve(projectDir, 'CLAUDE.md');
3308
- const { appendFile } = await import('node:fs/promises');
3309
- if (existsSync(claudeMdPath)) {
3310
- await appendFile(claudeMdPath, claudeMdSnippet, 'utf-8');
3311
- console.log(`✅ Appended: ${claudeMdPath}`);
3312
- } else {
3313
- await writeFile(claudeMdPath, claudeMdSnippet.trimStart(), 'utf-8');
3314
- console.log(`✅ Written: ${claudeMdPath}`);
3315
- }
4225
+ // ── Step 2: Fetch & pick instance ─────────────────────────────────────────
4226
+ process.stdout.write('⏳ Fetching your instances...');
4227
+ let instances: Array<{ id: string; name: string; status: string; tier: string; region: string }> = [];
4228
+ try {
4229
+ const res = await fetch(`${API_URL}/api/v1/instances`, {
4230
+ headers: { Authorization: `Bearer ${token}` },
4231
+ });
4232
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
4233
+ const body = await res.json() as { data: typeof instances };
4234
+ instances = (body.data ?? []).filter(i => i.status === 'running');
4235
+ } catch (e) {
4236
+ console.error(`\n\nFailed to fetch instances: ${(e as Error).message}`);
4237
+ console.error('Check your token and try again.\n');
4238
+ rl.close(); process.exit(1);
3316
4239
  }
4240
+ console.log(` found ${instances.length}\n`);
3317
4241
 
3318
- console.log(`\n🧠 Cachly AI Brain configured for ${editor === 'claude' ? 'Claude Code' : editor}!`);
3319
- console.log(` Restart your editor — the \`cachly\` MCP tools will appear.\n`);
4242
+ if (instances.length === 0) {
4243
+ console.error('No running instances found. Create one at https://cachly.dev/instances\n');
4244
+ rl.close(); process.exit(1);
4245
+ }
4246
+
4247
+ let instance = instances[0];
4248
+ if (instances.length > 1) {
4249
+ console.log('Step 2: Pick your Brain instance:\n');
4250
+ instances.forEach((inst, idx) =>
4251
+ console.log(` ${idx + 1}. ${inst.name.padEnd(24)} ${inst.tier.padEnd(10)} ${inst.region}`)
4252
+ );
4253
+ const raw = await ask(`\n Pick [1-${instances.length}] (Enter = 1): `);
4254
+ const idx = parseInt(raw || '1', 10) - 1;
4255
+ instance = instances[Math.max(0, Math.min(instances.length - 1, idx))];
4256
+ }
4257
+ console.log(`✓ Instance: ${instance.name} (${instance.id.slice(0, 8)}…)\n`);
4258
+
4259
+ // ── Step 3: Detect editors ────────────────────────────────────────────────
4260
+ const cwd = process.cwd();
4261
+ const detected: string[] = [];
4262
+ // Claude Code: always include (CLAUDE.md is universal)
4263
+ detected.push('claude');
4264
+ if (existsSync(resolve(cwd, '.cursor'))) detected.push('cursor');
4265
+ if (existsSync(resolve(cwd, '.windsurf'))) detected.push('windsurf');
4266
+ if (existsSync(resolve(cwd, '.vscode'))) detected.push('copilot');
4267
+ if (existsSync(resolve(cwd, '.continue'))) detected.push('continue');
4268
+
4269
+ const editorLabel = (e: string) =>
4270
+ ({ claude: 'Claude Code', cursor: 'Cursor', windsurf: 'Windsurf', copilot: 'GitHub Copilot', continue: 'Continue.dev' })[e] ?? e;
4271
+
4272
+ console.log(`Step 3: Detected editors: ${detected.map(editorLabel).join(', ')}`);
4273
+ const editorsRaw = await ask(` Configure for which? [all / ${detected.join('/')}] (Enter = all): `);
4274
+ const editorsToSetup = !editorsRaw || editorsRaw.toLowerCase() === 'all'
4275
+ ? detected
4276
+ : editorsRaw.split(/[,\s]+/).map(s => s.toLowerCase()).filter(s => EDITOR_FILES[s]);
4277
+ console.log('');
4278
+
4279
+ // ── Step 4: Write editor configs ──────────────────────────────────────────
4280
+ for (const editor of editorsToSetup) {
4281
+ const configFile = EDITOR_FILES[editor] ?? '.mcp.json';
4282
+ const configPath = resolve(cwd, configFile);
4283
+ await mkdir(dirname(configPath), { recursive: true });
4284
+ await writeFile(configPath, buildMcpConfig(token, instance.id, editor), 'utf-8');
4285
+ console.log(`✅ Written: ${configFile}`);
4286
+ }
4287
+
4288
+ // ── Step 5: CLAUDE.md (always — idempotent) ───────────────────────────────
4289
+ const mdResult = await writeClaudeMd(cwd, instance.id);
4290
+ const mdLabel = mdResult === 'updated' ? '✅ Updated' : mdResult === 'appended' ? '✅ Appended to' : '✅ Written';
4291
+ console.log(`${mdLabel}: CLAUDE.md`);
4292
+
4293
+ rl.close();
4294
+ console.log(`\n🧠 Done! Restart your editor — the \`cachly\` MCP tools will appear.`);
4295
+ console.log(` Your AI now has persistent memory across every session.\n`);
3320
4296
  process.exit(0);
3321
4297
  }
3322
4298