@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/dist/index.js 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,36 +33,104 @@ 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
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
39
40
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
40
41
  import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
41
42
  import { Redis } from 'ioredis';
42
- import OpenAI from 'openai';
43
43
  // ── Config ────────────────────────────────────────────────────────────────────
44
44
  const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
45
45
  const JWT = process.env.CACHLY_JWT ?? '';
46
- const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? 'text-embedding-3-small';
47
- /** Embedding provider: openai (default) | mistral | cohere | ollama | gemini */
48
- const EMBED_PROVIDER = (process.env.CACHLY_EMBED_PROVIDER ?? 'openai').toLowerCase();
46
+ const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
47
+ /**
48
+ * ┌──────────────────────────────────────────────────────────────────────┐
49
+ * │ EMBEDDING PROVIDER — pluggable, client-side first │
50
+ * │ │
51
+ * │ Auto-detects from env vars. To force a provider, set: │
52
+ * │ CACHLY_EMBED_PROVIDER=openai (+ OPENAI_API_KEY) │
53
+ * │ CACHLY_EMBED_PROVIDER=gemini (+ GEMINI_API_KEY) │
54
+ * │ CACHLY_EMBED_PROVIDER=mistral (+ MISTRAL_API_KEY) │
55
+ * │ CACHLY_EMBED_PROVIDER=cohere (+ COHERE_API_KEY) │
56
+ * │ CACHLY_EMBED_PROVIDER=ollama (+ OLLAMA_BASE_URL, local) │
57
+ * │ CACHLY_EMBED_PROVIDER=cachly (server-side fallback, no key) │
58
+ * │ │
59
+ * │ Priority: openai > gemini > mistral > cohere > ollama > cachly │
60
+ * │ Brain works WITHOUT embedding (keyword search + exact key lookup).│
61
+ * │ Embedding is an optional boost for semantic_search/index_project. │
62
+ * └──────────────────────────────────────────────────────────────────────┘
63
+ */
64
+ const EMBED_PROVIDER = (process.env.CACHLY_EMBED_PROVIDER ?? detectEmbedProvider()).toLowerCase();
65
+ /** Fast content hash for index invalidation (not cryptographic, just change detection) */
66
+ function simpleHash(text) {
67
+ return createHash('md5').update(text).digest('hex').slice(0, 12);
68
+ }
69
+ function detectEmbedProvider() {
70
+ // Client-side keys first (direct, fast, no server roundtrip)
71
+ if (process.env.OPENAI_API_KEY)
72
+ return 'openai';
73
+ if (process.env.GEMINI_API_KEY)
74
+ return 'gemini';
75
+ if (process.env.MISTRAL_API_KEY)
76
+ return 'mistral';
77
+ if (process.env.COHERE_API_KEY)
78
+ return 'cohere';
79
+ if (process.env.OLLAMA_BASE_URL)
80
+ return 'ollama';
81
+ // Server-side fallback — no API key needed, but adds latency
82
+ if (process.env.CACHLY_JWT)
83
+ return 'cachly';
84
+ return 'none'; // no provider → embedding disabled, brain still works via exact keys
85
+ }
49
86
  // ── Multi-provider embedding ─────────────────────────────────────────────────
50
87
  /**
51
88
  * Compute an embedding vector for `text` using the configured provider.
52
89
  *
53
- * Supported via CACHLY_EMBED_PROVIDER:
54
- * openai (default) – OPENAI_API_KEY · model: text-embedding-3-small
55
- * mistral – MISTRAL_API_KEY · model: mistral-embed
56
- * cohere – COHERE_API_KEY · model: embed-english-v3.0
57
- * ollama – OLLAMA_BASE_URL (default: http://localhost:11434) · model: nomic-embed-text
58
- * gemini – GEMINI_API_KEY · model: text-embedding-004
90
+ * Client-side (recommended — set one API key in your .env):
91
+ * openai – OPENAI_API_KEY · text-embedding-3-small
92
+ * gemini – GEMINI_API_KEY · text-embedding-004
93
+ * mistral – MISTRAL_API_KEY · mistral-embed
94
+ * cohere – COHERE_API_KEY · embed-english-v3.0
95
+ * ollama – OLLAMA_BASE_URL · nomic-embed-text (local, free)
96
+ *
97
+ * Server-side fallback (no key needed on client):
98
+ * cachly – POST /api/v1/embed (requires CACHLY_JWT)
59
99
  *
60
- * Note: Anthropic (Claude) does not provide embedding models.
61
- * Use Cohere or OpenAI instead.
100
+ * Note: Brain works fully WITHOUT embedding (keyword search + exact keys).
101
+ * Embedding is an OPTIONAL boost for semantic_search and index_project.
62
102
  */
63
103
  async function computeEmbedding(text) {
64
104
  switch (EMBED_PROVIDER) {
105
+ case 'cachly': {
106
+ // Server-side embedding — the Cachly API computes the embedding
107
+ // using whatever provider is configured on the server. No client-side API key needed.
108
+ if (!JWT)
109
+ throw new Error('CACHLY_JWT not set.\n\n' +
110
+ 'The "cachly" provider uses server-side embeddings via the Cachly API.\n' +
111
+ 'Set CACHLY_JWT, or use another provider via CACHLY_EMBED_PROVIDER:\n' +
112
+ ' openai → OPENAI_API_KEY\n' +
113
+ ' gemini → GEMINI_API_KEY\n' +
114
+ ' ollama → OLLAMA_BASE_URL (local, no key needed)');
115
+ const url = `${API_URL}/api/v1/embed`;
116
+ const body = { text };
117
+ if (EMBED_MODEL)
118
+ body.model = EMBED_MODEL;
119
+ const res = await fetch(url, {
120
+ method: 'POST',
121
+ headers: {
122
+ 'Content-Type': 'application/json',
123
+ 'Authorization': `Bearer ${JWT}`,
124
+ },
125
+ body: JSON.stringify(body),
126
+ });
127
+ if (!res.ok) {
128
+ const errBody = await res.text();
129
+ throw new Error(`Cachly embed API error ${res.status}: ${errBody}`);
130
+ }
131
+ const json = (await res.json());
132
+ return json.embedding;
133
+ }
65
134
  case 'mistral': {
66
135
  const key = process.env.MISTRAL_API_KEY;
67
136
  if (!key)
@@ -120,45 +189,428 @@ async function computeEmbedding(text) {
120
189
  const json = (await res.json());
121
190
  return json.embedding.values;
122
191
  }
123
- case 'openai':
124
- default: {
192
+ case 'openai': {
125
193
  const key = process.env.OPENAI_API_KEY;
126
194
  if (!key)
127
195
  throw new Error('OPENAI_API_KEY not set.\n\n' +
128
- 'Other supported providers via CACHLY_EMBED_PROVIDER:\n' +
129
- ' mistral → MISTRAL_API_KEY\n' +
130
- ' cohere → COHERE_API_KEY\n' +
131
- ' ollama → OLLAMA_BASE_URL (local, no key needed)\n' +
132
- ' gemini → GEMINI_API_KEY');
133
- const openai = new OpenAI({ apiKey: key });
134
- const embRes = await openai.embeddings.create({ model: EMBED_MODEL, input: text });
135
- return embRes.data[0].embedding;
196
+ 'Set OPENAI_API_KEY in your .env, or switch provider:\n' +
197
+ ' CACHLY_EMBED_PROVIDER=gemini (+ GEMINI_API_KEY)\n' +
198
+ ' CACHLY_EMBED_PROVIDER=ollama (local, free)\n' +
199
+ ' CACHLY_EMBED_PROVIDER=cachly (server-side, no key needed)');
200
+ const model = EMBED_MODEL || 'text-embedding-3-small';
201
+ const res = await fetch('https://api.openai.com/v1/embeddings', {
202
+ method: 'POST',
203
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
204
+ body: JSON.stringify({ model, input: text }),
205
+ });
206
+ if (!res.ok)
207
+ throw new Error(`OpenAI embedding error: ${res.statusText}`);
208
+ const json = (await res.json());
209
+ return json.data[0].embedding;
210
+ }
211
+ default: {
212
+ throw new Error(`Unknown CACHLY_EMBED_PROVIDER="${EMBED_PROVIDER}".\n` +
213
+ 'Supported: cachly (default), openai, mistral, cohere, ollama, gemini');
136
214
  }
137
215
  }
138
216
  }
139
217
  /** Returns true if the configured embedding provider has its required key/URL set. */
140
218
  function hasEmbedProvider() {
141
219
  switch (EMBED_PROVIDER) {
220
+ case 'cachly': return !!JWT;
142
221
  case 'mistral': return !!process.env.MISTRAL_API_KEY;
143
222
  case 'cohere': return !!process.env.COHERE_API_KEY;
144
223
  case 'ollama': return true; // OLLAMA_BASE_URL is optional (defaults to localhost)
145
224
  case 'gemini': return !!process.env.GEMINI_API_KEY;
146
- case 'openai':
147
- default: return !!process.env.OPENAI_API_KEY;
225
+ case 'openai': return !!process.env.OPENAI_API_KEY;
226
+ case 'none': return false;
227
+ default: return false;
148
228
  }
149
229
  }
150
230
  /** Returns a human-readable description of the current embed provider/model for error messages. */
151
231
  function embedProviderHint() {
152
232
  const providerKeys = {
233
+ cachly: 'CACHLY_JWT (server-side, no extra key needed)',
153
234
  openai: 'OPENAI_API_KEY',
154
235
  mistral: 'MISTRAL_API_KEY',
155
236
  cohere: 'COHERE_API_KEY',
156
237
  ollama: 'OLLAMA_BASE_URL (optional, default: http://localhost:11434)',
157
238
  gemini: 'GEMINI_API_KEY',
158
239
  };
159
- const key = providerKeys[EMBED_PROVIDER] ?? 'OPENAI_API_KEY';
240
+ if (EMBED_PROVIDER === 'none') {
241
+ return 'No embedding provider configured. Set OPENAI_API_KEY, GEMINI_API_KEY, or another provider key in .env. Brain works without embedding.';
242
+ }
243
+ const key = providerKeys[EMBED_PROVIDER] ?? 'CACHLY_JWT';
160
244
  return `CACHLY_EMBED_PROVIDER=${EMBED_PROVIDER} → requires ${key}`;
161
245
  }
246
+ // ── Search Engine (BM25+ with enhancements, works without embedding) ──────────
247
+ //
248
+ // Features beyond standard BM25:
249
+ // • BM25+ (delta=1) — fixes BM25's "long document penalty" bug
250
+ // • Bigram proximity boost — adjacent query terms in doc score 2× more
251
+ // • Recency boost — newer entries rank higher (exponential decay, 7-day half-life)
252
+ // • Multi-query splitting — numbered lists, semicolons, conjunctions
253
+ // • Fuzzy matching with Levenshtein distance ≤ 2
254
+ // • Multilingual stopwords (EN, DE, FR, ES, IT, PT)
255
+ // • Pipeline Redis reads for performance
256
+ //
257
+ /**
258
+ * Stopwords — filtered out during tokenization.
259
+ * Covers: English, German, French, Spanish, Italian, Portuguese.
260
+ * Keeps the index small and scores meaningful.
261
+ */
262
+ const STOPWORDS = new Set([
263
+ // ── English ──
264
+ 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
265
+ 'have', 'has', 'had', 'having', 'do', 'does', 'did', 'doing',
266
+ 'will', 'would', 'could', 'should', 'may', 'might', 'shall', 'can',
267
+ 'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'as',
268
+ 'into', 'through', 'during', 'before', 'after', 'above', 'below',
269
+ 'between', 'about', 'against', 'among', 'around', 'without', 'within',
270
+ 'along', 'across', 'behind', 'beyond', 'upon', 'toward', 'towards',
271
+ 'and', 'but', 'or', 'not', 'no', 'nor', 'so', 'if', 'then', 'than',
272
+ 'too', 'very', 'quite', 'rather', 'just', 'also', 'only', 'even',
273
+ 'it', 'its', 'this', 'that', 'these', 'those', 'here', 'there',
274
+ 'my', 'your', 'his', 'her', 'our', 'their', 'mine', 'yours', 'ours',
275
+ 'what', 'which', 'who', 'whom', 'whose', 'how', 'when', 'where', 'why',
276
+ 'all', 'each', 'every', 'both', 'few', 'more', 'most', 'much', 'many',
277
+ 'other', 'some', 'such', 'own', 'same', 'any', 'either', 'neither',
278
+ 'been', 'being', 'because', 'until', 'while', 'once', 'again', 'further',
279
+ 'already', 'always', 'never', 'sometimes', 'often', 'still', 'yet',
280
+ // ── German ──
281
+ 'der', 'die', 'das', 'den', 'dem', 'des', 'ein', 'eine', 'einen',
282
+ 'einem', 'einer', 'eines', 'und', 'oder', 'aber', 'denn', 'weil',
283
+ 'ist', 'sind', 'war', 'waren', 'sein', 'wird', 'werden', 'wurde',
284
+ 'hat', 'haben', 'hatte', 'hatten', 'kann', 'können', 'konnte',
285
+ 'soll', 'sollen', 'sollte', 'muss', 'müssen', 'musste', 'darf',
286
+ 'mag', 'möchte', 'wollen', 'wollte', 'würde', 'könnte', 'sollte',
287
+ 'mit', 'für', 'auf', 'von', 'aus', 'bei', 'nach', 'über', 'unter',
288
+ 'vor', 'hinter', 'neben', 'zwischen', 'durch', 'gegen', 'ohne',
289
+ 'um', 'bis', 'seit', 'während', 'wegen', 'trotz', 'statt',
290
+ 'wie', 'was', 'wer', 'wen', 'wem', 'wessen', 'wo', 'wann', 'warum',
291
+ 'nicht', 'noch', 'auch', 'schon', 'nur', 'sehr', 'mehr', 'viel',
292
+ 'alle', 'jeder', 'jede', 'jedes', 'dieser', 'diese', 'dieses',
293
+ 'jener', 'jene', 'jenes', 'mein', 'dein', 'sein', 'ihr', 'unser',
294
+ 'euer', 'kein', 'keine', 'sich', 'mir', 'dir', 'ihm', 'uns', 'euch',
295
+ 'ich', 'du', 'er', 'sie', 'es', 'wir', 'ihr', 'man',
296
+ 'hier', 'dort', 'da', 'dann', 'also', 'doch', 'mal', 'eben', 'ganz',
297
+ // ── French ──
298
+ 'le', 'la', 'les', 'un', 'une', 'des', 'du', 'de', 'au', 'aux',
299
+ 'et', 'ou', 'mais', 'donc', 'car', 'ni', 'que', 'qui', 'quoi',
300
+ 'est', 'sont', 'était', 'ont', 'avoir', 'être', 'fait', 'faire',
301
+ 'pour', 'par', 'avec', 'dans', 'sur', 'sous', 'entre', 'vers',
302
+ 'chez', 'sans', 'avant', 'après', 'pendant', 'depuis', 'contre',
303
+ 'ce', 'cette', 'ces', 'mon', 'ton', 'son', 'notre', 'votre', 'leur',
304
+ 'je', 'tu', 'il', 'elle', 'nous', 'vous', 'ils', 'elles', 'on',
305
+ 'ne', 'pas', 'plus', 'très', 'bien', 'aussi', 'tout', 'tous', 'toute',
306
+ 'même', 'autre', 'quel', 'quelle', 'comment', 'quand', 'où', 'pourquoi',
307
+ // ── Spanish ──
308
+ 'el', 'la', 'los', 'las', 'un', 'una', 'unos', 'unas', 'del', 'al',
309
+ 'lo', 'que', 'en', 'es', 'por', 'con', 'para', 'como', 'pero', 'más',
310
+ 'fue', 'ser', 'hay', 'está', 'han', 'son', 'tiene', 'había', 'era',
311
+ 'su', 'sus', 'este', 'esta', 'estos', 'estas', 'ese', 'esa', 'esos',
312
+ 'mi', 'tu', 'yo', 'él', 'ella', 'nosotros', 'ellos', 'ellas', 'usted',
313
+ 'no', 'ya', 'sí', 'sin', 'sobre', 'entre', 'hasta', 'desde', 'donde',
314
+ 'muy', 'todo', 'toda', 'todos', 'cada', 'otro', 'otra', 'otros',
315
+ 'cuando', 'porque', 'aunque', 'también', 'solo', 'después', 'antes',
316
+ // ── Italian ──
317
+ 'il', 'lo', 'la', 'li', 'le', 'gli', 'uno', 'una', 'dei', 'del',
318
+ 'che', 'di', 'da', 'per', 'con', 'tra', 'fra', 'sul', 'nel', 'al',
319
+ 'è', 'sono', 'ha', 'hanno', 'era', 'essere', 'fare', 'fatto', 'stato',
320
+ 'suo', 'sua', 'suoi', 'questo', 'questa', 'questi', 'quello', 'quella',
321
+ 'io', 'tu', 'lui', 'lei', 'noi', 'voi', 'loro', 'ci', 'si',
322
+ 'non', 'più', 'molto', 'anche', 'solo', 'tutto', 'tutti', 'ogni',
323
+ 'come', 'dove', 'quando', 'perché', 'ancora', 'già', 'sempre', 'mai',
324
+ // ── Portuguese ──
325
+ 'um', 'uma', 'uns', 'umas', 'do', 'da', 'dos', 'das', 'no', 'na',
326
+ 'ao', 'aos', 'em', 'por', 'com', 'para', 'sem', 'sob', 'sobre',
327
+ 'que', 'se', 'mas', 'ou', 'como', 'mais', 'entre', 'até', 'desde',
328
+ 'é', 'são', 'foi', 'tem', 'ser', 'ter', 'estar', 'fazer', 'havia',
329
+ 'seu', 'sua', 'seus', 'suas', 'este', 'esta', 'esse', 'essa', 'aquele',
330
+ 'eu', 'tu', 'ele', 'ela', 'nós', 'eles', 'elas', 'você', 'vocês',
331
+ 'não', 'já', 'sim', 'bem', 'muito', 'também', 'ainda', 'sempre',
332
+ 'todo', 'toda', 'todos', 'cada', 'outro', 'outra', 'quando', 'porque',
333
+ ]);
334
+ /** Tokenize text into meaningful keywords. Handles unicode (accented chars). */
335
+ function tokenize(text) {
336
+ return text
337
+ .toLowerCase()
338
+ .replace(/[^a-záàâãäåæçéèêëíìîïñóòôõöúùûüýÿßœ0-9:_\-./]/g, ' ')
339
+ .split(/\s+/)
340
+ .filter(w => w.length > 1 && !STOPWORDS.has(w));
341
+ }
342
+ /**
343
+ * Split a multi-topic query into sub-queries.
344
+ * Detects numbered lists, semicolons, "and also", line breaks, etc.
345
+ *
346
+ * Example: "deploy API fix routing and also check auth" →
347
+ * ["deploy API fix routing", "check auth"]
348
+ *
349
+ * Example: "1. deploy 2. fix routing 3. auth" →
350
+ * ["deploy", "fix routing", "auth"]
351
+ */
352
+ function splitMultiQuery(query) {
353
+ // Numbered list: "1. foo 2. bar 3. baz"
354
+ const numberedParts = query.split(/\d+[.)]\s*/g).filter(s => s.trim().length > 2);
355
+ if (numberedParts.length >= 2)
356
+ return numberedParts.map(s => s.trim());
357
+ // Semicolons or newlines
358
+ const semiParts = query.split(/[;\n]+/).filter(s => s.trim().length > 2);
359
+ if (semiParts.length >= 2)
360
+ return semiParts.map(s => s.trim());
361
+ // Conjunctions (EN + DE + FR + ES)
362
+ const conjParts = query.split(/\b(?:and also|also noch|außerdem|plus|additionally|furthermore|de plus|además|inoltre|além disso)\b/i).filter(s => s.trim().length > 2);
363
+ if (conjParts.length >= 2)
364
+ return conjParts.map(s => s.trim());
365
+ // Comma-separated with 3+ parts (likely a list)
366
+ const commaParts = query.split(/,\s*/).filter(s => s.trim().length > 2);
367
+ if (commaParts.length >= 3)
368
+ return commaParts.map(s => s.trim());
369
+ // Single query
370
+ return [query];
371
+ }
372
+ // ── BM25+ Scoring ─────────────────────────────────────────────────────────────
373
+ //
374
+ // BM25+ fixes the "long document under-scoring" bug in classic BM25.
375
+ // Paper: Lv & Zhai (2011) "Lower-Bounding Term Frequency Normalization"
376
+ //
377
+ // Improvements over standard BM25:
378
+ // 1. δ=1 additive term → guarantees TF>0 terms always contribute positively
379
+ // 2. Bigram proximity boost → adjacent query terms in doc get 2× weight
380
+ // 3. Recency boost → entries with timestamps get exp-decay bonus (7d half-life)
381
+ // 4. Levenshtein fuzzy match → typo-tolerant (distance ≤ 2)
382
+ //
383
+ /** BM25+ parameters */
384
+ const BM25_K1 = 1.2; // term frequency saturation
385
+ const BM25_B = 0.75; // length normalization
386
+ const BM25_DELTA = 1.0; // BM25+ lower-bound guarantee (0 = classic BM25)
387
+ /** Recency boost: half-life in days. Entry from 7 days ago gets 0.5× boost. */
388
+ const RECENCY_HALF_LIFE_DAYS = 7;
389
+ /**
390
+ * Levenshtein distance — edit distance between two strings.
391
+ * Used for typo-tolerant fuzzy matching (distance ≤ 2 = match).
392
+ * O(n*m) but strings are short (tokens), so this is fast.
393
+ */
394
+ function levenshtein(a, b) {
395
+ if (a === b)
396
+ return 0;
397
+ if (a.length === 0)
398
+ return b.length;
399
+ if (b.length === 0)
400
+ return a.length;
401
+ // Skip if length diff > 2 (can't be ≤ 2 edits)
402
+ if (Math.abs(a.length - b.length) > 2)
403
+ return 3;
404
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
405
+ for (let i = 1; i <= a.length; i++) {
406
+ let corner = i - 1;
407
+ prev[0] = i;
408
+ for (let j = 1; j <= b.length; j++) {
409
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
410
+ const cur = Math.min(prev[j] + 1, prev[j - 1] + 1, corner + cost);
411
+ corner = prev[j];
412
+ prev[j] = cur;
413
+ }
414
+ }
415
+ return prev[b.length];
416
+ }
417
+ /** Extract a timestamp from JSON content (looks for "ts" or "created" fields). */
418
+ function extractTimestamp(content) {
419
+ // Fast regex for ISO dates in JSON: "ts":"2026-04-17T..." or "created":"..."
420
+ const match = content.match(/"(?:ts|created|created_at|timestamp)"\s*:\s*"([^"]+)"/);
421
+ if (match) {
422
+ const ms = Date.parse(match[1]);
423
+ if (!isNaN(ms))
424
+ return ms;
425
+ }
426
+ return undefined;
427
+ }
428
+ /** Recency multiplier: 1.0 for now, 0.5 after half-life days, exponential decay. */
429
+ function recencyBoost(timestampMs) {
430
+ if (!timestampMs)
431
+ return 1.0; // no timestamp → neutral
432
+ const ageDays = (Date.now() - timestampMs) / (1000 * 60 * 60 * 24);
433
+ if (ageDays <= 0)
434
+ return 1.5; // future/just-now → max boost
435
+ return Math.pow(0.5, ageDays / RECENCY_HALF_LIFE_DAYS) + 0.5; // range: [0.5, 1.5]
436
+ }
437
+ /**
438
+ * BM25+ with Bigram Proximity, Recency Boost, and Fuzzy Matching.
439
+ *
440
+ * Architecture:
441
+ * ┌─────────────────────────────────────────────────────────────────┐
442
+ * │ Query: "deploy API fix routing; check auth" │
443
+ * │ ↓ splitMultiQuery │
444
+ * │ Sub-queries: ["deploy API fix routing", "check auth"] │
445
+ * │ ↓ for each sub-query │
446
+ * │ Tokenize → BM25+ score per doc → Bigram boost → Fuzzy match │
447
+ * │ ↓ merge & deduplicate │
448
+ * │ Recency boost → Sort → Top-K │
449
+ * └─────────────────────────────────────────────────────────────────┘
450
+ */
451
+ async function keywordSearch(redis, patterns, query, topK = 10) {
452
+ // ── Step 1: Collect and tokenize all documents ──
453
+ const allKeys = [];
454
+ for (const pattern of patterns) {
455
+ const stream = redis.scanStream({ match: pattern, count: 200 });
456
+ await new Promise((resolve, reject) => {
457
+ stream.on('data', (batch) => {
458
+ allKeys.push(...batch.filter((k) => !k.endsWith(':meta')));
459
+ });
460
+ stream.on('end', resolve);
461
+ stream.on('error', reject);
462
+ });
463
+ }
464
+ if (allKeys.length === 0)
465
+ return [];
466
+ // Pipeline GET for speed
467
+ const pipeline = redis.pipeline();
468
+ for (const key of allKeys)
469
+ pipeline.get(key);
470
+ const results = await pipeline.exec();
471
+ const docs = [];
472
+ let totalTokens = 0;
473
+ for (let i = 0; i < allKeys.length; i++) {
474
+ const content = results?.[i]?.[1];
475
+ if (!content)
476
+ continue;
477
+ const tokens = tokenize(`${allKeys[i]} ${content}`);
478
+ if (tokens.length === 0)
479
+ continue;
480
+ // Term frequency map
481
+ const tokenFreq = new Map();
482
+ for (const t of tokens) {
483
+ tokenFreq.set(t, (tokenFreq.get(t) ?? 0) + 1);
484
+ }
485
+ // Bigrams — adjacent token pairs for proximity detection
486
+ const bigrams = new Set();
487
+ for (let j = 0; j < tokens.length - 1; j++) {
488
+ bigrams.add(`${tokens[j]}|${tokens[j + 1]}`);
489
+ }
490
+ const timestamp = extractTimestamp(content);
491
+ docs.push({ key: allKeys[i], content, tokens, tokenFreq, bigrams, timestamp });
492
+ totalTokens += tokens.length;
493
+ }
494
+ if (docs.length === 0)
495
+ return [];
496
+ const avgDL = totalTokens / docs.length;
497
+ // ── Step 2: IDF (inverse document frequency) ──
498
+ const docFreq = new Map();
499
+ for (const doc of docs) {
500
+ const seen = new Set();
501
+ for (const t of doc.tokens) {
502
+ if (!seen.has(t)) {
503
+ docFreq.set(t, (docFreq.get(t) ?? 0) + 1);
504
+ seen.add(t);
505
+ }
506
+ }
507
+ }
508
+ const N = docs.length;
509
+ function idf(term) {
510
+ const df = docFreq.get(term) ?? 0;
511
+ return Math.log((N - df + 0.5) / (df + 0.5) + 1);
512
+ }
513
+ /**
514
+ * BM25+ score for a term in a document.
515
+ * Classic BM25: TF_norm = tf*(k1+1) / (tf + k1*(1 - b + b*dl/avgdl))
516
+ * BM25+ adds: + δ (guarantees long docs with the term still score positively)
517
+ */
518
+ function bm25PlusTerm(term, doc) {
519
+ const tf = doc.tokenFreq.get(term) ?? 0;
520
+ if (tf === 0)
521
+ return 0;
522
+ const dl = doc.tokens.length;
523
+ const tfNorm = (tf * (BM25_K1 + 1)) / (tf + BM25_K1 * (1 - BM25_B + BM25_B * (dl / avgDL)));
524
+ return idf(term) * (tfNorm + BM25_DELTA);
525
+ }
526
+ /**
527
+ * Fuzzy match: tries exact → substring → Levenshtein ≤ 2.
528
+ * Returns [matchedTerm, weight] or null.
529
+ */
530
+ function fuzzyMatch(qt, docTermSet) {
531
+ // Exact
532
+ if (docTermSet.has(qt))
533
+ return [qt, 1.0];
534
+ // Substring (partial)
535
+ for (const dt of docTermSet) {
536
+ if (dt.length > 3 && qt.length > 3 && (dt.includes(qt) || qt.includes(dt))) {
537
+ return [dt, 0.6];
538
+ }
539
+ }
540
+ // Levenshtein ≤ 2 (typo tolerance) — only for tokens ≥ 4 chars
541
+ if (qt.length >= 4) {
542
+ for (const dt of docTermSet) {
543
+ if (dt.length >= 4 && levenshtein(qt, dt) <= 2) {
544
+ return [dt, 0.4];
545
+ }
546
+ }
547
+ }
548
+ return null;
549
+ }
550
+ // ── Step 3: Score each sub-query independently ──
551
+ const subQueries = splitMultiQuery(query);
552
+ const allMatches = new Map();
553
+ for (const sq of subQueries) {
554
+ const queryTokens = tokenize(sq);
555
+ if (queryTokens.length === 0)
556
+ continue;
557
+ // Pre-compute query bigrams for proximity boost
558
+ const queryBigrams = new Set();
559
+ for (let j = 0; j < queryTokens.length - 1; j++) {
560
+ queryBigrams.add(`${queryTokens[j]}|${queryTokens[j + 1]}`);
561
+ }
562
+ for (const doc of docs) {
563
+ let score = 0;
564
+ const matchedWords = [];
565
+ const docTermSet = new Set(doc.tokens);
566
+ for (const qt of queryTokens) {
567
+ // Try exact BM25+ first
568
+ const exactScore = bm25PlusTerm(qt, doc);
569
+ if (exactScore > 0) {
570
+ score += exactScore;
571
+ matchedWords.push(qt);
572
+ continue;
573
+ }
574
+ // Fuzzy match (substring or Levenshtein)
575
+ const fuzz = fuzzyMatch(qt, docTermSet);
576
+ if (fuzz) {
577
+ score += bm25PlusTerm(fuzz[0], doc) * fuzz[1];
578
+ matchedWords.push(`~${qt}`);
579
+ }
580
+ }
581
+ // Bigram proximity boost: +50% for each adjacent query term pair found in doc
582
+ if (queryBigrams.size > 0) {
583
+ let bigramHits = 0;
584
+ for (const bg of queryBigrams) {
585
+ if (doc.bigrams.has(bg))
586
+ bigramHits++;
587
+ }
588
+ if (bigramHits > 0) {
589
+ score *= 1 + 0.5 * (bigramHits / queryBigrams.size);
590
+ }
591
+ }
592
+ // Recency boost
593
+ score *= recencyBoost(doc.timestamp);
594
+ if (score > 0) {
595
+ const existing = allMatches.get(doc.key);
596
+ if (!existing || score > existing.score) {
597
+ allMatches.set(doc.key, {
598
+ key: doc.key,
599
+ content: doc.content,
600
+ score,
601
+ matchedWords: [...new Set(matchedWords)],
602
+ subQuery: subQueries.length > 1 ? sq : undefined,
603
+ });
604
+ }
605
+ }
606
+ }
607
+ }
608
+ // ── Step 4: Sort by score, return top-K ──
609
+ const sorted = [...allMatches.values()].sort((a, b) => b.score - a.score);
610
+ return sorted.slice(0, topK);
611
+ }
612
+ // ── Exported for testing ──────────────────────────────────────────────────────
613
+ export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS };
162
614
  // ── Connection pool ───────────────────────────────────────────────────────────
163
615
  /** Reuse Redis connections across tool calls (keyed by instance_id). */
164
616
  const pool = new Map();
@@ -801,6 +1253,58 @@ const TOOLS = [
801
1253
  required: ['instance_id', 'summary'],
802
1254
  },
803
1255
  },
1256
+ // ── Session Handoff — cross-window continuity ─────────────────────────────
1257
+ {
1258
+ name: 'session_handoff',
1259
+ description: 'Save a detailed handoff for the NEXT chat window / session. ' +
1260
+ 'Stores: current progress, TODO list (done + remaining), changed files with descriptions, ' +
1261
+ 'instructions for the next assistant, and any incomplete work. ' +
1262
+ 'The next session_start automatically includes this handoff so the new window knows EXACTLY what happened and what remains. ' +
1263
+ 'Call this BEFORE closing a chat window, especially if work is incomplete. ' +
1264
+ 'This prevents the "continue" problem where new windows lose context, skip tasks, or produce broken code.',
1265
+ inputSchema: {
1266
+ type: 'object',
1267
+ properties: {
1268
+ instance_id: { type: 'string', description: 'UUID of the cache instance' },
1269
+ completed_tasks: {
1270
+ type: 'array',
1271
+ items: { type: 'string' },
1272
+ description: 'Tasks that were fully completed (e.g. "Implemented brainSearch() in JS SDK")',
1273
+ },
1274
+ remaining_tasks: {
1275
+ type: 'array',
1276
+ items: { type: 'string' },
1277
+ description: 'Tasks NOT yet done — the next window MUST pick these up',
1278
+ },
1279
+ files_changed: {
1280
+ type: 'array',
1281
+ items: {
1282
+ type: 'object',
1283
+ properties: {
1284
+ path: { type: 'string', description: 'File path relative to project root' },
1285
+ status: { type: 'string', enum: ['complete', 'partial', 'broken'], description: 'State of this file' },
1286
+ description: { type: 'string', description: 'What was changed and what still needs work' },
1287
+ },
1288
+ required: ['path', 'status'],
1289
+ },
1290
+ description: 'Changed files with their current state — marks partial/broken files so next window knows to fix them',
1291
+ },
1292
+ instructions: {
1293
+ type: 'string',
1294
+ description: 'Free-form instructions for the next assistant. Be specific: what to do next, what to avoid, what broke.',
1295
+ },
1296
+ context_summary: {
1297
+ type: 'string',
1298
+ description: 'Brief summary of what happened this session (architecture decisions, key findings, blockers)',
1299
+ },
1300
+ blocked_on: {
1301
+ type: 'string',
1302
+ description: 'If work is blocked, describe what is needed to unblock (e.g. "waiting for API deploy", "needs user input on design")',
1303
+ },
1304
+ },
1305
+ required: ['instance_id', 'completed_tasks', 'remaining_tasks'],
1306
+ },
1307
+ },
804
1308
  // ── AI Brain — Extended features ─────────────────────────────────────────
805
1309
  {
806
1310
  name: 'auto_learn_session',
@@ -848,6 +1352,28 @@ const TOOLS = [
848
1352
  required: ['instance_id', 'changed_files'],
849
1353
  },
850
1354
  },
1355
+ {
1356
+ name: 'team_learn',
1357
+ description: 'Store a lesson in a shared team brain so all team members benefit. ' +
1358
+ 'Like learn_from_attempts, but REQUIRES an author name for attribution. ' +
1359
+ 'Shows up in team_recall with "by <author>" so the team knows who learned it.',
1360
+ inputSchema: {
1361
+ type: 'object',
1362
+ properties: {
1363
+ instance_id: { type: 'string', description: 'UUID of the shared team brain instance' },
1364
+ author: { type: 'string', description: 'Your name or handle (required for team attribution)' },
1365
+ topic: { type: 'string', description: 'Topic in category:keyword format (e.g. "deploy:api")' },
1366
+ outcome: { type: 'string', enum: ['success', 'failure', 'partial'], description: 'What happened' },
1367
+ what_worked: { type: 'string', description: 'What worked (the solution)' },
1368
+ what_failed: { type: 'string', description: 'What did NOT work (avoid this)' },
1369
+ severity: { type: 'string', enum: ['critical', 'major', 'minor'], description: 'Impact level' },
1370
+ file_paths: { type: 'array', items: { type: 'string' }, description: 'Relevant file paths' },
1371
+ commands: { type: 'array', items: { type: 'string' }, description: 'Commands that worked' },
1372
+ tags: { type: 'array', items: { type: 'string' }, description: 'Tags for categorization' },
1373
+ },
1374
+ required: ['instance_id', 'author', 'topic', 'outcome', 'what_worked'],
1375
+ },
1376
+ },
851
1377
  {
852
1378
  name: 'team_recall',
853
1379
  description: 'Recall lessons from a shared team brain, showing who learned what. ' +
@@ -1423,70 +1949,101 @@ async function handleTool(name, args) {
1423
1949
  const inst = await apiFetch(`/api/v1/instances/${instance_id}`);
1424
1950
  const vectorUrl = process.env.CACHLY_VECTOR_URL ??
1425
1951
  (inst.vector_token ? `https://api.cachly.dev/v1/sem/${inst.vector_token}` : null);
1426
- if (!vectorUrl) {
1427
- return (`❌ index_project requires the Speed or Business tier (pgvector).\n\n` +
1428
- `Your instance "${inst.name}" (${inst.tier.toUpperCase()}) does not have a vector_token.\n` +
1429
- `Upgrade at https://cachly.dev/instances/${instance_id}`);
1430
- }
1952
+ const canEmbed = vectorUrl && hasEmbedProvider();
1431
1953
  let indexed = 0;
1432
1954
  let skipped = 0;
1433
1955
  let errors = 0;
1956
+ let semanticIndexed = 0;
1957
+ let unchanged = 0;
1434
1958
  const details = [];
1435
1959
  const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
1960
+ const redis = await getConnection(instance_id);
1436
1961
  for (const filePath of files) {
1437
1962
  const relPath = relative(dir, filePath);
1438
1963
  let content;
1964
+ let fileSize;
1439
1965
  try {
1440
1966
  const s = await stat(filePath);
1441
1967
  if (s.size > 200_000) {
1442
1968
  skipped++;
1443
1969
  continue;
1444
1970
  } // skip files >200 KB
1971
+ fileSize = s.size;
1445
1972
  content = await readFile(filePath, 'utf-8');
1446
1973
  }
1447
1974
  catch {
1448
1975
  errors++;
1449
1976
  continue;
1450
1977
  }
1451
- // Prompt = relative path + first N chars of content (for semantic matching)
1978
+ // ── Smart invalidation: hash-based change detection ──
1979
+ // Compute a simple hash of file content to skip unchanged files
1980
+ const hashKey = `cachly:idx:hash:${relPath}`;
1981
+ const contentHash = `${fileSize}:${content.length}:${simpleHash(content)}`;
1982
+ const existingHash = await redis.get(hashKey);
1983
+ if (existingHash === contentHash) {
1984
+ // File unchanged — refresh TTL but skip re-indexing
1985
+ const idxKey = `cachly:idx:${relPath}`;
1986
+ if (ttl > 0)
1987
+ await redis.expire(idxKey, ttl);
1988
+ if (ttl > 0)
1989
+ await redis.expire(hashKey, ttl);
1990
+ unchanged++;
1991
+ continue;
1992
+ }
1452
1993
  const excerpt = content.slice(0, summary_chars).replace(/\s+/g, ' ').trim();
1453
- const prompt = `File: ${relPath}\n${excerpt}`;
1454
- // Value = relative path so the AI knows which file to read_file next
1455
- try {
1456
- const embedding = await computeEmbedding(prompt);
1457
- const id = randomUUID();
1458
- // Write value to vector index
1459
- await fetch(`${vectorUrl}/entries`, {
1460
- method: 'POST',
1461
- headers: { 'Content-Type': 'application/json' },
1462
- body: JSON.stringify({ id, prompt, namespace: nsArg, embedding, expires_at: expiresAt }),
1463
- });
1464
- // Write value to Valkey for retrieval on semantic hit
1465
- const redis = await getConnection(instance_id);
1466
- await redis.set(`${nsArg}:val:${id}`, relPath, 'EX', ttl);
1467
- indexed++;
1468
- details.push(` ✅ ${relPath}`);
1994
+ // ── Layer 1: Keyword index in Valkey (always works, no embedding needed) ──
1995
+ const idxKey = `cachly:idx:${relPath}`;
1996
+ const idxValue = `File: ${relPath}\n${excerpt}`;
1997
+ if (ttl > 0) {
1998
+ await redis.set(idxKey, idxValue, 'EX', ttl);
1469
1999
  }
1470
- catch {
1471
- errors++;
1472
- details.push(` ❌ ${relPath} (embedding failed)`);
2000
+ else {
2001
+ await redis.set(idxKey, idxValue);
2002
+ }
2003
+ // Store content hash for smart invalidation on next run
2004
+ if (ttl > 0) {
2005
+ await redis.set(hashKey, contentHash, 'EX', ttl);
2006
+ }
2007
+ else {
2008
+ await redis.set(hashKey, contentHash);
2009
+ }
2010
+ indexed++;
2011
+ details.push(` ✅ ${relPath}`);
2012
+ // ── Layer 2: Semantic vector index (optional, only if embedding available) ──
2013
+ if (canEmbed) {
2014
+ try {
2015
+ const prompt = `File: ${relPath}\n${excerpt}`;
2016
+ const embedding = await computeEmbedding(prompt);
2017
+ const id = randomUUID();
2018
+ await fetch(`${vectorUrl}/entries`, {
2019
+ method: 'POST',
2020
+ headers: { 'Content-Type': 'application/json' },
2021
+ body: JSON.stringify({ id, prompt, namespace: nsArg, embedding, expires_at: expiresAt }),
2022
+ });
2023
+ await redis.set(`${nsArg}:val:${id}`, relPath, 'EX', ttl);
2024
+ semanticIndexed++;
2025
+ }
2026
+ catch {
2027
+ // Semantic indexing failed — keyword index is enough
2028
+ }
1473
2029
  }
1474
2030
  }
2031
+ const mode = canEmbed ? '🔍 Keyword + 🎯 Semantic' : '🔍 Keyword only (no embedding provider)';
1475
2032
  return [
1476
- `📂 **index_project Complete**`,
2033
+ `📂 **index_project Complete** — ${mode}`,
1477
2034
  ``,
1478
- ` 📁 Dir: ${dir}`,
1479
- ` ✅ Indexed: **${indexed}** files`,
1480
- ` ⏭️ Skipped: ${skipped} (too large or filtered)`,
1481
- ` ❌ Errors: ${errors}`,
1482
- ` ⏱️ TTL: ${ttl}s (${Math.round(ttl / 3600)}h)`,
1483
- ` 🏷️ Namespace: \`${nsArg}\``,
2035
+ ` 📁 Dir: ${dir}`,
2036
+ ` ✅ Indexed: **${indexed}** files (new/changed)`,
2037
+ ` ♻️ Unchanged: ${unchanged} files (hash match — skipped)`,
2038
+ ...(canEmbed ? [` 🎯 Semantic: **${semanticIndexed}** files (vector-searchable)`] : []),
2039
+ ` ⏭️ Skipped: ${skipped} (too large or filtered)`,
2040
+ ` ❌ Errors: ${errors}`,
2041
+ ` ⏱️ TTL: ${ttl}s (${Math.round(ttl / 3600)}h)`,
1484
2042
  ``,
1485
2043
  `💡 **Next steps:**`,
1486
- ` 1. Add to .github/copilot-instructions.md:`,
1487
- ` "Before reading source files, call semantic_search with the topic."`,
1488
- ` 2. Re-run index_project after major refactors (or set up a cron TTL).`,
1489
- ` 3. Use semantic_search("how does X work?") to find relevant files.`,
2044
+ ` 1. Use \`smart_recall("how does auth work")\` to find relevant files.`,
2045
+ ` 2. Re-run index_project after major refactors.`,
2046
+ ...(canEmbed ? [] : [` 3. Set OPENAI_API_KEY (or similar) in .env to also enable semantic search.`]),
1490
2047
  ``,
1491
2048
  ...(details.length <= 20 ? details : [...details.slice(0, 20), ` … and ${details.length - 20} more`]),
1492
2049
  ].join('\n');
@@ -1770,59 +2327,93 @@ async function handleTool(name, args) {
1770
2327
  }
1771
2328
  case 'smart_recall': {
1772
2329
  const { instance_id, query, threshold = 0.78, } = args;
2330
+ const redis = await getConnection(instance_id);
2331
+ // ── Layer 1: Keyword search across ALL brain data (always works, no embedding) ──
2332
+ const kwMatches = await keywordSearch(redis, ['cachly:ctx:*', 'cachly:lesson:best:*', 'cachly:idx:*'], query, 10);
2333
+ const lines = [`🧠 **Smart Recall** for: _"${query}"_\n`];
2334
+ // Show sub-query info if multi-topic was detected
2335
+ const subQueries = splitMultiQuery(query);
2336
+ if (subQueries.length > 1) {
2337
+ lines.push(`_Detected ${subQueries.length} sub-topics:_ ${subQueries.map((s, i) => `${i + 1}. "${s}"`).join(', ')}\n`);
2338
+ }
2339
+ if (kwMatches.length > 0) {
2340
+ lines.push(`### 🔍 BM25 Matches (${kwMatches.length})\n`);
2341
+ // Group by sub-query if multi-topic
2342
+ if (subQueries.length > 1) {
2343
+ const grouped = new Map();
2344
+ for (const m of kwMatches.slice(0, 12)) {
2345
+ const sq = m.subQuery ?? query;
2346
+ if (!grouped.has(sq))
2347
+ grouped.set(sq, []);
2348
+ grouped.get(sq).push(m);
2349
+ }
2350
+ for (const [sq, matches] of grouped) {
2351
+ lines.push(`**Topic: "${sq}"** (${matches.length} results)\n`);
2352
+ for (const m of matches.slice(0, 4)) {
2353
+ const label = m.key
2354
+ .replace('cachly:ctx:', '📝 ')
2355
+ .replace('cachly:lesson:best:', '💡 ')
2356
+ .replace('cachly:idx:', '📂 ');
2357
+ const preview = m.content.slice(0, 300).replace(/\n/g, ' ');
2358
+ lines.push(` **${label}** _(BM25: ${m.score.toFixed(2)}, matched: ${m.matchedWords.join(', ')})_`);
2359
+ lines.push(` > ${preview}${m.content.length > 300 ? '…' : ''}\n`);
2360
+ }
2361
+ }
2362
+ // Summary: which sub-queries had matches
2363
+ const matched = [...grouped.keys()];
2364
+ const unmatched = subQueries.filter(sq => !matched.includes(sq));
2365
+ if (unmatched.length > 0) {
2366
+ lines.push(`\n⚠️ **No results for:** ${unmatched.map(s => `"${s}"`).join(', ')}`);
2367
+ }
2368
+ }
2369
+ else {
2370
+ for (const m of kwMatches.slice(0, 8)) {
2371
+ const label = m.key
2372
+ .replace('cachly:ctx:', '📝 ')
2373
+ .replace('cachly:lesson:best:', '💡 ')
2374
+ .replace('cachly:idx:', '📂 ');
2375
+ const preview = m.content.slice(0, 400).replace(/\n/g, ' ');
2376
+ lines.push(`**${label}** _(BM25: ${m.score.toFixed(2)}, matched: ${m.matchedWords.join(', ')})_`);
2377
+ lines.push(`> ${preview}${m.content.length > 400 ? '…' : ''}\n`);
2378
+ }
2379
+ }
2380
+ }
2381
+ // ── Layer 2: Semantic search (optional, only if embedding provider + vector_token available) ──
1773
2382
  const inst = await apiFetch(`/api/v1/instances/${instance_id}`);
1774
- if (!inst.vector_token) {
1775
- // Fallback to listing and keyword matching
1776
- const redis = await getConnection(instance_id);
1777
- const keys = [];
1778
- const stream = redis.scanStream({ match: 'cachly:ctx:*', count: 100 });
1779
- await new Promise((resolve, reject) => {
1780
- stream.on('data', (batch) => {
1781
- keys.push(...batch.filter((k) => !k.endsWith(':meta')));
2383
+ if (inst.vector_token && hasEmbedProvider()) {
2384
+ try {
2385
+ const embedding = await computeEmbedding(query);
2386
+ const vectorUrl = `https://api.cachly.dev/v1/sem/${inst.vector_token}`;
2387
+ const searchRes = await fetch(`${vectorUrl}/search`, {
2388
+ method: 'POST',
2389
+ headers: { 'Content-Type': 'application/json' },
2390
+ body: JSON.stringify({ embedding, namespace: 'cachly:ctx', threshold, top_k: 5 }),
1782
2391
  });
1783
- stream.on('end', resolve);
1784
- stream.on('error', reject);
1785
- });
1786
- // Simple keyword matching
1787
- const queryWords = query.toLowerCase().split(/\s+/);
1788
- const matches = [];
1789
- for (const k of keys) {
1790
- const keyLower = k.toLowerCase();
1791
- if (queryWords.some(w => keyLower.includes(w))) {
1792
- const content = await redis.get(k);
1793
- matches.push(`**${k.replace('cachly:ctx:', '')}**:\n${content?.slice(0, 300)}…\n`);
2392
+ if (searchRes.ok) {
2393
+ const results = (await searchRes.json());
2394
+ const semHits = results.filter(r => r.found && r.id);
2395
+ if (semHits.length > 0) {
2396
+ lines.push(`\n### 🎯 Semantic Matches (${semHits.length})\n`);
2397
+ for (const hit of semHits) {
2398
+ const parts = hit.id.replace('ctx:', '').split(':');
2399
+ const category = parts[0];
2400
+ const key = parts.slice(1).join(':');
2401
+ const content = await redis.get(`cachly:ctx:${category}:${key}`);
2402
+ lines.push(`**${key}** _(${((hit.similarity ?? 0) * 100).toFixed(0)}% similar)_`, `> ${content?.slice(0, 300) ?? '(evicted)'}${(content?.length ?? 0) > 300 ? '…' : ''}\n`);
2403
+ }
2404
+ }
1794
2405
  }
1795
2406
  }
1796
- if (matches.length === 0)
1797
- return `⚠️ No matching context found for: "${query}"\n\nTip: Upgrade to Speed tier for semantic search.`;
1798
- return `🔍 **Keyword-matched context:**\n\n${matches.join('\n')}`;
1799
- }
1800
- // Semantic search
1801
- const embedding = await computeEmbedding(query);
1802
- const vectorUrl = `https://api.cachly.dev/v1/sem/${inst.vector_token}`;
1803
- const searchRes = await fetch(`${vectorUrl}/search`, {
1804
- method: 'POST',
1805
- headers: { 'Content-Type': 'application/json' },
1806
- body: JSON.stringify({ embedding, namespace: 'cachly:ctx', threshold, top_k: 5 }),
1807
- });
1808
- if (!searchRes.ok) {
1809
- return `❌ Semantic search failed: ${searchRes.statusText}`;
1810
- }
1811
- const results = (await searchRes.json());
1812
- if (!results.length || !results[0].found) {
1813
- return `⚠️ No semantically similar context found for: "${query}"\n\nTry lowering the threshold (current: ${threshold}) or use \`list_remembered\` to see available context.`;
2407
+ catch {
2408
+ // Semantic search failed silently — keyword results are enough
2409
+ }
1814
2410
  }
1815
- const redis = await getConnection(instance_id);
1816
- const lines = [`🧠 **Smart Recall Results** for: _"${query}"_\n`];
1817
- for (const hit of results) {
1818
- if (!hit.found || !hit.id)
1819
- continue;
1820
- // id format: ctx:category:key
1821
- const parts = hit.id.replace('ctx:', '').split(':');
1822
- const category = parts[0];
1823
- const key = parts.slice(1).join(':');
1824
- const content = await redis.get(`cachly:ctx:${category}:${key}`);
1825
- lines.push(`### ${key} (${((hit.similarity ?? 0) * 100).toFixed(0)}% match)`, `_Category: ${category}_`, `\`\`\``, content?.slice(0, 500) ?? '(content evicted)', `${(content?.length ?? 0) > 500 ? '…' : ''}`, `\`\`\``, ``);
2411
+ if (kwMatches.length === 0) {
2412
+ lines.push(`⚠️ No matches found for: "${query}"`);
2413
+ lines.push(`\n💡 Tips:`);
2414
+ lines.push(` • Try different keywords`);
2415
+ lines.push(` • Use \`list_remembered\` to see available context`);
2416
+ lines.push(` • Use \`recall_best_solution("topic")\` for exact topic lookup`);
1826
2417
  }
1827
2418
  return lines.join('\n');
1828
2419
  }
@@ -1943,6 +2534,36 @@ async function handleTool(name, args) {
1943
2534
  }), 'EX', 86400); // auto-expire after 24h if session_end never called
1944
2535
  // ── Build briefing ──────────────────────────────────────────────────────
1945
2536
  const lines = ['🧠 **Session Briefing**', ''];
2537
+ // Handoff from previous window (if any)
2538
+ const handoffRaw = await redis.get('cachly:session:handoff');
2539
+ if (handoffRaw) {
2540
+ try {
2541
+ const handoff = JSON.parse(handoffRaw);
2542
+ const ago = Math.round((Date.now() - new Date(handoff.ts).getTime()) / 60000);
2543
+ const agoStr = ago < 60 ? `${ago}m ago` : ago < 1440 ? `${Math.round(ago / 60)}h ago` : `${Math.round(ago / 1440)}d ago`;
2544
+ lines.push(`🤝 **Handoff from previous window** (${agoStr}):`);
2545
+ if (handoff.context_summary)
2546
+ lines.push(` ${handoff.context_summary}`);
2547
+ if (handoff.remaining_tasks.length > 0) {
2548
+ lines.push(` ⏳ **Remaining tasks:**`);
2549
+ for (const t of handoff.remaining_tasks)
2550
+ lines.push(` - ${t}`);
2551
+ }
2552
+ if (handoff.completed_tasks.length > 0) {
2553
+ lines.push(` ✅ **Already done:** ${handoff.completed_tasks.join(', ')}`);
2554
+ }
2555
+ const brokenFiles = (handoff.files_changed ?? []).filter(f => f.status === 'broken' || f.status === 'partial');
2556
+ if (brokenFiles.length > 0) {
2557
+ lines.push(` ⚠️ **Needs fix:** ${brokenFiles.map(f => `\`${f.path}\` (${f.status}${f.description ? ': ' + f.description : ''})`).join(', ')}`);
2558
+ }
2559
+ if (handoff.blocked_on)
2560
+ lines.push(` 🚫 **Blocked on:** ${handoff.blocked_on}`);
2561
+ if (handoff.instructions)
2562
+ lines.push(` 📝 **Instructions:** ${handoff.instructions}`);
2563
+ lines.push('');
2564
+ }
2565
+ catch { /* ignore corrupt handoff */ }
2566
+ }
1946
2567
  // Last session
1947
2568
  if (lastSession) {
1948
2569
  const ago = Math.round((Date.now() - new Date(lastSession.ts).getTime()) / 60000);
@@ -2022,6 +2643,79 @@ async function handleTool(name, args) {
2022
2643
  await redis.expire('cachly:session:history', 90 * 86400);
2023
2644
  // Clean up current session marker
2024
2645
  await redis.del('cachly:session:current');
2646
+ // ── AUTO-LEARN from session summary (no manual call needed) ─────────────
2647
+ // Parse the summary for actionable lessons and store them automatically.
2648
+ const autoLearned = [];
2649
+ try {
2650
+ // Extract key sentences from the summary that contain action verbs
2651
+ 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;
2652
+ const sentences = summary
2653
+ .split(/[.!\n]+/)
2654
+ .map(s => s.trim())
2655
+ .filter(s => s.length > 20 && actionVerbs.test(s));
2656
+ for (const sentence of sentences.slice(0, 6)) {
2657
+ // Build a topic slug from the first meaningful words
2658
+ const words = sentence.toLowerCase()
2659
+ .replace(/[^a-z0-9\s]/g, ' ')
2660
+ .split(/\s+/)
2661
+ .filter(w => w.length > 3 && !['that', 'this', 'with', 'from', 'have', 'been', 'were', 'they', 'then', 'when', 'also', 'into', 'will', 'would', 'could', 'should'].includes(w));
2662
+ const slug = words.slice(0, 4).join('-');
2663
+ if (!slug)
2664
+ continue;
2665
+ const topic = `auto:${slug}`;
2666
+ const key = `cachly:lesson:best:${topic}`;
2667
+ // Don't overwrite existing successful lessons
2668
+ const existing = await redis.get(key);
2669
+ if (existing) {
2670
+ try {
2671
+ const ex = JSON.parse(existing);
2672
+ if (ex.outcome === 'success')
2673
+ continue;
2674
+ }
2675
+ catch { /* ignore */ }
2676
+ }
2677
+ const lesson = {
2678
+ topic,
2679
+ outcome: 'success',
2680
+ what_worked: sentence,
2681
+ context: `Auto-learned from session summary. Full summary: ${summary.slice(0, 300)}`,
2682
+ severity: 'minor',
2683
+ ts: now.toISOString(),
2684
+ recall_count: 0,
2685
+ auto_learned: true,
2686
+ session_ts: now.toISOString(),
2687
+ version: 2,
2688
+ };
2689
+ await redis.set(key, JSON.stringify(lesson));
2690
+ // 90-day TTL for auto-learned lessons
2691
+ await redis.expire(key, 90 * 86400);
2692
+ autoLearned.push(topic);
2693
+ }
2694
+ // Also store a lesson per changed file area if files were changed
2695
+ if (files_changed.length > 0) {
2696
+ const areas = [...new Set(files_changed.map(f => f.split('/').slice(0, 2).join('/')))].slice(0, 3);
2697
+ for (const area of areas) {
2698
+ const slug = area.replace(/[^a-z0-9]/gi, '-').toLowerCase().replace(/-+/g, '-').slice(0, 30);
2699
+ const topic = `auto:changed:${slug}`;
2700
+ const key = `cachly:lesson:best:${topic}`;
2701
+ const lesson = {
2702
+ topic,
2703
+ outcome: 'success',
2704
+ what_worked: `Files changed in ${area}: ${files_changed.filter(f => f.startsWith(area.split('/')[0])).slice(0, 5).join(', ')}`,
2705
+ context: summary.slice(0, 200),
2706
+ severity: 'minor',
2707
+ ts: now.toISOString(),
2708
+ recall_count: 0,
2709
+ auto_learned: true,
2710
+ version: 2,
2711
+ };
2712
+ await redis.set(key, JSON.stringify(lesson));
2713
+ await redis.expire(key, 90 * 86400);
2714
+ autoLearned.push(topic);
2715
+ }
2716
+ }
2717
+ }
2718
+ catch { /* auto-learn errors must never break session_end */ }
2025
2719
  const durationStr = durationMin !== undefined ? ` · ${durationMin} min` : '';
2026
2720
  return [
2027
2721
  `✅ **Session saved**${durationStr}`,
@@ -2029,10 +2723,45 @@ async function handleTool(name, args) {
2029
2723
  `📋 **Summary:** ${summary}`,
2030
2724
  files_changed.length > 0 ? `📁 **Files changed:** ${files_changed.map(f => `\`${f}\``).join(', ')}` : '',
2031
2725
  lessons_learned !== undefined ? `🧠 **Lessons stored:** ${lessons_learned}` : '',
2726
+ autoLearned.length > 0 ? `🤖 **Auto-learned:** ${autoLearned.length} lessons extracted from summary (${autoLearned.slice(0, 3).map(t => `\`${t}\``).join(', ')}${autoLearned.length > 3 ? '…' : ''})` : '',
2032
2727
  ``,
2033
2728
  `💡 Next session: \`session_start(focus="...")\` to see this summary.`,
2034
2729
  ].filter(l => l !== '').join('\n');
2035
2730
  }
2731
+ // ── session_handoff — cross-window continuity ─────────────────────────────
2732
+ case 'session_handoff': {
2733
+ const { instance_id, completed_tasks = [], remaining_tasks = [], files_changed = [], instructions = '', context_summary = '', blocked_on = '', } = args;
2734
+ const redis = await getConnection(instance_id);
2735
+ const now = new Date();
2736
+ const handoff = {
2737
+ ts: now.toISOString(),
2738
+ completed_tasks,
2739
+ remaining_tasks,
2740
+ files_changed,
2741
+ instructions,
2742
+ context_summary,
2743
+ blocked_on,
2744
+ };
2745
+ // Store handoff — never expires until next handoff overwrites it
2746
+ await redis.set('cachly:session:handoff', JSON.stringify(handoff));
2747
+ // Also append to history
2748
+ await redis.lpush('cachly:session:handoff:history', JSON.stringify(handoff));
2749
+ await redis.ltrim('cachly:session:handoff:history', 0, 19);
2750
+ const totalTasks = completed_tasks.length + remaining_tasks.length;
2751
+ const pct = totalTasks > 0 ? Math.round((completed_tasks.length / totalTasks) * 100) : 0;
2752
+ const brokenFiles = files_changed.filter(f => f.status === 'broken' || f.status === 'partial');
2753
+ return [
2754
+ `🤝 **Handoff saved** — ${completed_tasks.length}/${totalTasks} tasks done (${pct}%)`,
2755
+ ``,
2756
+ completed_tasks.length > 0 ? `✅ **Completed:**\n${completed_tasks.map(t => ` - ${t}`).join('\n')}` : '',
2757
+ remaining_tasks.length > 0 ? `\n⏳ **Remaining for next window:**\n${remaining_tasks.map(t => ` - ${t}`).join('\n')}` : '',
2758
+ brokenFiles.length > 0 ? `\n⚠️ **Needs attention:** ${brokenFiles.map(f => `\`${f.path}\` (${f.status})`).join(', ')}` : '',
2759
+ blocked_on ? `\n🚫 **Blocked on:** ${blocked_on}` : '',
2760
+ instructions ? `\n📝 **Instructions:** ${instructions}` : '',
2761
+ ``,
2762
+ `💡 The next \`session_start\` will include this handoff automatically.`,
2763
+ ].filter(l => l !== '').join('\n');
2764
+ }
2036
2765
  // ── auto_learn_session ────────────────────────────────────────────────────
2037
2766
  case 'auto_learn_session': {
2038
2767
  const { instance_id, observations } = args;
@@ -2139,6 +2868,36 @@ async function handleTool(name, args) {
2139
2868
  }
2140
2869
  return lines.filter(Boolean).join('\n');
2141
2870
  }
2871
+ // ── team_learn ────────────────────────────────────────────────────────────
2872
+ case 'team_learn': {
2873
+ const { instance_id, author, topic, outcome, what_worked, what_failed, severity, file_paths, commands, tags } = args;
2874
+ if (!author || !topic || !outcome || !what_worked) {
2875
+ return '❌ Required: author, topic, outcome, what_worked';
2876
+ }
2877
+ const iid = instance_id;
2878
+ if (!iid)
2879
+ return '❌ instance_id required';
2880
+ // Store with author attribution via the same learn_from_attempts Redis structure
2881
+ const lesson = {
2882
+ topic, outcome, what_worked,
2883
+ what_failed: what_failed ?? '',
2884
+ severity: severity ?? 'minor',
2885
+ author,
2886
+ file_paths: file_paths ?? [],
2887
+ commands: commands ?? [],
2888
+ tags: [...(tags ?? []), 'team'],
2889
+ timestamp: new Date().toISOString(),
2890
+ recall_count: 0,
2891
+ version: 2,
2892
+ };
2893
+ const redis = await getConnection(iid);
2894
+ const key = `cachly:lessons:${topic}`;
2895
+ await redis.rpush(key, JSON.stringify(lesson));
2896
+ if (outcome === 'success') {
2897
+ await redis.set(`cachly:lesson:best:${topic}`, JSON.stringify(lesson));
2898
+ }
2899
+ return `✅ Team lesson stored by **${author}**: \`${topic}\` (${outcome})\n💡 ${what_worked.slice(0, 120)}`;
2900
+ }
2142
2901
  // ── team_recall ───────────────────────────────────────────────────────────
2143
2902
  case 'team_recall': {
2144
2903
  const { instance_id, topic, author, limit = 10 } = args;
@@ -2790,87 +3549,221 @@ process.on('unhandledRejection', (reason) => {
2790
3549
  // Write to stderr only (stdout is the JSON-RPC channel)
2791
3550
  process.stderr.write(`[cachly-mcp] unhandledRejection: ${msg}\n`);
2792
3551
  });
3552
+ // ── CLI helpers ───────────────────────────────────────────────────────────────
3553
+ const EDITOR_FILES = {
3554
+ claude: '.mcp.json',
3555
+ cursor: '.cursor/mcp.json',
3556
+ windsurf: '.windsurf/mcp.json',
3557
+ copilot: '.vscode/mcp.json',
3558
+ continue: '.continue/config.json',
3559
+ };
3560
+ const CLAUDE_MD_MARKER_START = '<!-- cachly-brain-start -->';
3561
+ const CLAUDE_MD_MARKER_END = '<!-- cachly-brain-end -->';
3562
+ function buildMcpConfig(apiKey, instanceId, editor) {
3563
+ if (editor === 'continue') {
3564
+ return JSON.stringify({
3565
+ experimental: {
3566
+ modelContextProtocolServers: [{
3567
+ transport: { type: 'stdio', command: 'npx', args: ['-y', '@cachly-dev/mcp-server@latest'] },
3568
+ env: { CACHLY_API_URL: 'https://api.cachly.dev', CACHLY_JWT: apiKey, CACHLY_BRAIN_INSTANCE_ID: instanceId },
3569
+ }],
3570
+ },
3571
+ }, null, 2);
3572
+ }
3573
+ return JSON.stringify({
3574
+ mcpServers: {
3575
+ cachly: {
3576
+ command: 'npx',
3577
+ args: ['-y', '@cachly-dev/mcp-server@latest'],
3578
+ env: { CACHLY_API_URL: 'https://api.cachly.dev', CACHLY_JWT: apiKey, CACHLY_BRAIN_INSTANCE_ID: instanceId },
3579
+ },
3580
+ },
3581
+ }, null, 2);
3582
+ }
3583
+ function buildClaudeMdBlock(instanceId) {
3584
+ return `${CLAUDE_MD_MARKER_START}
3585
+ ## Cachly AI Brain
3586
+
3587
+ The Cachly MCP server is available as tool \`cachly\`.
3588
+
3589
+ ### At the START of every session (one call):
3590
+ \`\`\`
3591
+ session_start(
3592
+ instance_id = "${instanceId}",
3593
+ focus = "<what you plan to work on today>",
3594
+ )
3595
+ \`\`\`
3596
+
3597
+ ### At the END of every session:
3598
+ \`\`\`
3599
+ session_end(
3600
+ instance_id = "${instanceId}",
3601
+ summary = "What was accomplished (2-3 sentences)",
3602
+ files_changed = ["src/..."],
3603
+ lessons_learned = 1,
3604
+ )
3605
+ \`\`\`
3606
+
3607
+ ### After fixing any bug or learning something new:
3608
+ \`\`\`
3609
+ learn_from_attempts(
3610
+ instance_id = "${instanceId}",
3611
+ topic = "category:keyword",
3612
+ outcome = "success",
3613
+ what_worked = "...",
3614
+ what_failed = "...",
3615
+ severity = "critical" | "major" | "minor",
3616
+ file_paths = ["path/to/file"],
3617
+ commands = ["the command that worked"],
3618
+ tags = ["tag1", "tag2"],
3619
+ )
3620
+ \`\`\`
3621
+ ${CLAUDE_MD_MARKER_END}`;
3622
+ }
3623
+ async function writeClaudeMd(projectDir, instanceId) {
3624
+ const { writeFile, appendFile, readFile } = await import('node:fs/promises');
3625
+ const { existsSync } = await import('node:fs');
3626
+ const { resolve } = await import('node:path');
3627
+ const claudeMdPath = resolve(projectDir, 'CLAUDE.md');
3628
+ const block = '\n' + buildClaudeMdBlock(instanceId) + '\n';
3629
+ if (existsSync(claudeMdPath)) {
3630
+ const existing = await readFile(claudeMdPath, 'utf-8');
3631
+ if (existing.includes(CLAUDE_MD_MARKER_START)) {
3632
+ // Idempotent update: replace existing block (new instance-id, refreshed content)
3633
+ const updated = existing.replace(new RegExp(`${CLAUDE_MD_MARKER_START}[\\s\\S]*?${CLAUDE_MD_MARKER_END}`), buildClaudeMdBlock(instanceId));
3634
+ await writeFile(claudeMdPath, updated, 'utf-8');
3635
+ return 'updated';
3636
+ }
3637
+ await appendFile(claudeMdPath, block, 'utf-8');
3638
+ return 'appended';
3639
+ }
3640
+ await writeFile(claudeMdPath, block.trimStart(), 'utf-8');
3641
+ return 'written';
3642
+ }
2793
3643
  // ── CLI: cachly init ──────────────────────────────────────────────────────────
2794
3644
  // Usage: npx @cachly-dev/mcp-server init --instance-id <id> --api-key <key> [--editor claude|cursor|windsurf|copilot|continue] [--project-dir /path]
2795
3645
  if (process.argv[2] === 'init') {
2796
3646
  const { writeFile, mkdir } = await import('node:fs/promises');
2797
- const { existsSync } = await import('node:fs');
2798
3647
  const { resolve, dirname } = await import('node:path');
2799
- // Parse CLI flags
2800
3648
  const argv = process.argv.slice(3);
2801
- const flag = (name) => {
2802
- const i = argv.indexOf(`--${name}`);
2803
- return i !== -1 ? argv[i + 1] : undefined;
2804
- };
3649
+ const flag = (name) => { const i = argv.indexOf(`--${name}`); return i !== -1 ? argv[i + 1] : undefined; };
2805
3650
  const instanceId = flag('instance-id') ?? process.env.CACHLY_BRAIN_INSTANCE_ID;
2806
3651
  const apiKey = flag('api-key') ?? process.env.CACHLY_JWT;
2807
3652
  const editor = (flag('editor') ?? 'claude').toLowerCase();
2808
3653
  const projectDir = resolve(flag('project-dir') ?? '.');
2809
3654
  if (!instanceId || !apiKey) {
2810
3655
  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');
2811
- console.error('Get your instance ID and API key from: https://cachly.dev/instances\n');
3656
+ console.error('Or run interactively (no flags needed): npx @cachly-dev/mcp-server@latest setup\n');
3657
+ console.error('Get your credentials from: https://cachly.dev/setup-ai\n');
2812
3658
  process.exit(1);
2813
3659
  }
2814
- const editorFiles = {
2815
- claude: '.mcp.json',
2816
- cursor: '.cursor/mcp.json',
2817
- windsurf: '.windsurf/mcp.json',
2818
- copilot: '.vscode/mcp.json',
2819
- continue: '.continue/config.json',
2820
- };
2821
- const configFile = editorFiles[editor] ?? '.mcp.json';
3660
+ const configFile = EDITOR_FILES[editor] ?? '.mcp.json';
2822
3661
  const configPath = resolve(projectDir, configFile);
2823
- // Build config content
2824
- let configContent;
2825
- if (editor === 'continue') {
2826
- configContent = JSON.stringify({
2827
- experimental: {
2828
- modelContextProtocolServers: [{
2829
- transport: { type: 'stdio', command: 'npx', args: ['-y', '@cachly-dev/mcp-server@latest'] },
2830
- env: {
2831
- CACHLY_API_URL: 'https://api.cachly.dev',
2832
- CACHLY_JWT: apiKey,
2833
- CACHLY_BRAIN_INSTANCE_ID: instanceId,
2834
- },
2835
- }],
2836
- },
2837
- }, null, 2);
3662
+ await mkdir(dirname(configPath), { recursive: true });
3663
+ await writeFile(configPath, buildMcpConfig(apiKey, instanceId, editor), 'utf-8');
3664
+ console.log(`\n✅ Written: ${configFile}`);
3665
+ // Always write CLAUDE.md (idempotent — safe to run multiple times)
3666
+ const result = await writeClaudeMd(projectDir, instanceId);
3667
+ const action = result === 'updated' ? '✅ Updated' : result === 'appended' ? '✅ Appended to' : '✅ Written';
3668
+ console.log(`${action}: CLAUDE.md`);
3669
+ console.log(`\n🧠 Cachly AI Brain configured for ${editor === 'claude' ? 'Claude Code' : editor}!`);
3670
+ console.log(` Restart your editor — the \`cachly\` MCP tools will appear.\n`);
3671
+ process.exit(0);
3672
+ }
3673
+ // ── CLI: cachly setup (interactive — no flags required) ───────────────────────
3674
+ // Usage: npx @cachly-dev/mcp-server setup
3675
+ if (process.argv[2] === 'setup') {
3676
+ const { writeFile, mkdir } = await import('node:fs/promises');
3677
+ const { existsSync } = await import('node:fs');
3678
+ const { resolve, dirname } = await import('node:path');
3679
+ const { createInterface } = await import('node:readline');
3680
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
3681
+ const ask = (q) => new Promise(res => rl.question(q, ans => res(ans.trim())));
3682
+ console.log('\n🧠 cachly AI Brain — Interactive Setup');
3683
+ console.log('────────────────────────────────────────\n');
3684
+ // ── Step 1: API token ──────────────────────────────────────────────────────
3685
+ let token = process.env.CACHLY_JWT ?? '';
3686
+ if (token) {
3687
+ console.log('✓ Using token from CACHLY_JWT env var\n');
2838
3688
  }
2839
3689
  else {
2840
- configContent = JSON.stringify({
2841
- mcpServers: {
2842
- cachly: {
2843
- command: 'npx',
2844
- args: ['-y', '@cachly-dev/mcp-server@latest'],
2845
- env: {
2846
- CACHLY_API_URL: 'https://api.cachly.dev',
2847
- CACHLY_JWT: apiKey,
2848
- CACHLY_BRAIN_INSTANCE_ID: instanceId,
2849
- },
2850
- },
2851
- },
2852
- }, null, 2);
2853
- }
2854
- // Create parent dirs if needed, then write config
2855
- await mkdir(dirname(configPath), { recursive: true });
2856
- await writeFile(configPath, configContent, 'utf-8');
2857
- console.log(`\n✅ Written: ${configPath}`);
2858
- // For Claude Code, also offer to append CLAUDE.md snippet
2859
- if (editor === 'claude') {
2860
- 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`;
2861
- const claudeMdPath = resolve(projectDir, 'CLAUDE.md');
2862
- const { appendFile } = await import('node:fs/promises');
2863
- if (existsSync(claudeMdPath)) {
2864
- await appendFile(claudeMdPath, claudeMdSnippet, 'utf-8');
2865
- console.log(`✅ Appended: ${claudeMdPath}`);
2866
- }
2867
- else {
2868
- await writeFile(claudeMdPath, claudeMdSnippet.trimStart(), 'utf-8');
2869
- console.log(`✅ Written: ${claudeMdPath}`);
3690
+ console.log('Step 1: Get your API token at https://cachly.dev/setup-ai\n');
3691
+ token = await ask(' Paste API token (cky_live_...): ');
3692
+ if (!token) {
3693
+ console.error('\nToken is required. Aborting.\n');
3694
+ rl.close();
3695
+ process.exit(1);
2870
3696
  }
3697
+ console.log('');
2871
3698
  }
2872
- console.log(`\n🧠 Cachly AI Brain configured for ${editor === 'claude' ? 'Claude Code' : editor}!`);
2873
- console.log(` Restart your editor — the \`cachly\` MCP tools will appear.\n`);
3699
+ // ── Step 2: Fetch & pick instance ─────────────────────────────────────────
3700
+ process.stdout.write('⏳ Fetching your instances...');
3701
+ let instances = [];
3702
+ try {
3703
+ const res = await fetch(`${API_URL}/api/v1/instances`, {
3704
+ headers: { Authorization: `Bearer ${token}` },
3705
+ });
3706
+ if (!res.ok)
3707
+ throw new Error(`HTTP ${res.status}`);
3708
+ const body = await res.json();
3709
+ instances = (body.data ?? []).filter(i => i.status === 'running');
3710
+ }
3711
+ catch (e) {
3712
+ console.error(`\n\nFailed to fetch instances: ${e.message}`);
3713
+ console.error('Check your token and try again.\n');
3714
+ rl.close();
3715
+ process.exit(1);
3716
+ }
3717
+ console.log(` found ${instances.length}\n`);
3718
+ if (instances.length === 0) {
3719
+ console.error('No running instances found. Create one at https://cachly.dev/instances\n');
3720
+ rl.close();
3721
+ process.exit(1);
3722
+ }
3723
+ let instance = instances[0];
3724
+ if (instances.length > 1) {
3725
+ console.log('Step 2: Pick your Brain instance:\n');
3726
+ instances.forEach((inst, idx) => console.log(` ${idx + 1}. ${inst.name.padEnd(24)} ${inst.tier.padEnd(10)} ${inst.region}`));
3727
+ const raw = await ask(`\n Pick [1-${instances.length}] (Enter = 1): `);
3728
+ const idx = parseInt(raw || '1', 10) - 1;
3729
+ instance = instances[Math.max(0, Math.min(instances.length - 1, idx))];
3730
+ }
3731
+ console.log(`✓ Instance: ${instance.name} (${instance.id.slice(0, 8)}…)\n`);
3732
+ // ── Step 3: Detect editors ────────────────────────────────────────────────
3733
+ const cwd = process.cwd();
3734
+ const detected = [];
3735
+ // Claude Code: always include (CLAUDE.md is universal)
3736
+ detected.push('claude');
3737
+ if (existsSync(resolve(cwd, '.cursor')))
3738
+ detected.push('cursor');
3739
+ if (existsSync(resolve(cwd, '.windsurf')))
3740
+ detected.push('windsurf');
3741
+ if (existsSync(resolve(cwd, '.vscode')))
3742
+ detected.push('copilot');
3743
+ if (existsSync(resolve(cwd, '.continue')))
3744
+ detected.push('continue');
3745
+ const editorLabel = (e) => ({ claude: 'Claude Code', cursor: 'Cursor', windsurf: 'Windsurf', copilot: 'GitHub Copilot', continue: 'Continue.dev' })[e] ?? e;
3746
+ console.log(`Step 3: Detected editors: ${detected.map(editorLabel).join(', ')}`);
3747
+ const editorsRaw = await ask(` Configure for which? [all / ${detected.join('/')}] (Enter = all): `);
3748
+ const editorsToSetup = !editorsRaw || editorsRaw.toLowerCase() === 'all'
3749
+ ? detected
3750
+ : editorsRaw.split(/[,\s]+/).map(s => s.toLowerCase()).filter(s => EDITOR_FILES[s]);
3751
+ console.log('');
3752
+ // ── Step 4: Write editor configs ──────────────────────────────────────────
3753
+ for (const editor of editorsToSetup) {
3754
+ const configFile = EDITOR_FILES[editor] ?? '.mcp.json';
3755
+ const configPath = resolve(cwd, configFile);
3756
+ await mkdir(dirname(configPath), { recursive: true });
3757
+ await writeFile(configPath, buildMcpConfig(token, instance.id, editor), 'utf-8');
3758
+ console.log(`✅ Written: ${configFile}`);
3759
+ }
3760
+ // ── Step 5: CLAUDE.md (always — idempotent) ───────────────────────────────
3761
+ const mdResult = await writeClaudeMd(cwd, instance.id);
3762
+ const mdLabel = mdResult === 'updated' ? '✅ Updated' : mdResult === 'appended' ? '✅ Appended to' : '✅ Written';
3763
+ console.log(`${mdLabel}: CLAUDE.md`);
3764
+ rl.close();
3765
+ console.log(`\n🧠 Done! Restart your editor — the \`cachly\` MCP tools will appear.`);
3766
+ console.log(` Your AI now has persistent memory across every session.\n`);
2874
3767
  process.exit(0);
2875
3768
  }
2876
3769
  // ── Start ─────────────────────────────────────────────────────────────────────