@cachly-dev/mcp-server 0.2.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -18
- package/dist/index.d.ts +30 -1
- package/dist/index.js +991 -150
- package/dist/index.js.map +1 -1
- package/dist/search.test.d.ts +1 -0
- package/dist/search.test.js +238 -0
- package/dist/search.test.js.map +1 -0
- package/package.json +2 -3
- package/src/index.ts +1075 -164
- package/src/search.test.ts +282 -0
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
|
-
* (
|
|
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
|
-
*
|
|
36
|
-
* CACHLY_EMBED_MODEL – embedding model (
|
|
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 ?? '
|
|
47
|
-
/**
|
|
48
|
-
|
|
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
|
-
*
|
|
54
|
-
* openai
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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)
|
|
59
96
|
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
97
|
+
* Server-side fallback (no key needed on client):
|
|
98
|
+
* cachly – POST /api/v1/embed (requires CACHLY_JWT)
|
|
99
|
+
*
|
|
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
|
-
'
|
|
129
|
-
'
|
|
130
|
-
'
|
|
131
|
-
'
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
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
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
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:
|
|
1479
|
-
` ✅ Indexed:
|
|
1480
|
-
`
|
|
1481
|
-
`
|
|
1482
|
-
`
|
|
1483
|
-
`
|
|
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.
|
|
1487
|
-
`
|
|
1488
|
-
`
|
|
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 (
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
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
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
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
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
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
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
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;
|
|
@@ -2532,62 +3291,144 @@ async function handleTool(name, args) {
|
|
|
2532
3291
|
// Generate copilot-instructions.md content
|
|
2533
3292
|
const tier = inst.tier.toUpperCase();
|
|
2534
3293
|
const smartRecallNote = hasVector
|
|
2535
|
-
? '
|
|
2536
|
-
: '
|
|
3294
|
+
? '- `smart_recall("natural language query")` — semantic search (finds by meaning)'
|
|
3295
|
+
: '- `recall_context("arch:*")` — exact/glob key lookup (upgrade to Speed/Business for smart_recall)';
|
|
2537
3296
|
const layerNote = hasVector
|
|
2538
|
-
? `Layer 3 (Semantic): smart_recall uses pgvector HNSW on your ${tier} instance
|
|
2539
|
-
: `Layer 3 (Autopilot):
|
|
2540
|
-
const copilotInstructions = `# cachly AI
|
|
3297
|
+
? `Layer 3 (Semantic): smart_recall uses pgvector HNSW on your ${tier} instance`
|
|
3298
|
+
: `Layer 3 (Autopilot): this file — upgrade to Speed/Business tier to unlock smart_recall`;
|
|
3299
|
+
const copilotInstructions = `# cachly AI Brain — ${project_description}
|
|
3300
|
+
|
|
3301
|
+
> AI memory system powered by [cachly.dev](https://cachly.dev). Works with GitHub Copilot, Claude Code, Cursor, Windsurf, Continue.dev, and any MCP-compatible editor.
|
|
3302
|
+
|
|
3303
|
+
---
|
|
2541
3304
|
|
|
2542
|
-
##
|
|
3305
|
+
## 🔴 MANDATORY RULES — Always enforced, no exceptions
|
|
2543
3306
|
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
- **Layer 3 — Autopilot (this file):** instructs the AI to run memory tools automatically
|
|
3307
|
+
### Rule 1 — Session Start (FIRST thing in every window)
|
|
3308
|
+
Call \`session_start\` **before reading any source files or making any changes**:
|
|
2547
3309
|
|
|
2548
|
-
|
|
3310
|
+
\`\`\`
|
|
3311
|
+
session_start(
|
|
3312
|
+
instance_id = "${instance_id}",
|
|
3313
|
+
focus = "<what you plan to work on today>",
|
|
3314
|
+
)
|
|
3315
|
+
\`\`\`
|
|
2549
3316
|
|
|
2550
|
-
|
|
2551
|
-
- \`recall_best_solution("deploy:web")\` → returns working deploy command
|
|
2552
|
-
- \`recall_best_solution("debug:redis-tls")\` → returns root cause + fix
|
|
2553
|
-
- If a solution exists, use it — do NOT re-discover from scratch
|
|
3317
|
+
Returns: last session summary, handoff from previous window, recent lessons, open failures, brain health.
|
|
2554
3318
|
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
3319
|
+
**If \`session_start\` is unavailable**, fall back:
|
|
3320
|
+
1. \`smart_recall("recent lessons <topic>")\`
|
|
3321
|
+
2. \`recall_best_solution("<topic>")\`
|
|
2558
3322
|
|
|
2559
|
-
|
|
2560
|
-
|
|
3323
|
+
### Rule 2 — Learn After Every Fix/Deploy/Discovery (AUTOMATIC — no prompting needed)
|
|
3324
|
+
After **every** bug fix, deployment, failed attempt, or discovery — call \`learn_from_attempts\` immediately:
|
|
2561
3325
|
|
|
2562
|
-
|
|
3326
|
+
\`\`\`
|
|
3327
|
+
learn_from_attempts(
|
|
3328
|
+
instance_id = "${instance_id}",
|
|
3329
|
+
topic = "category:keyword", # deploy:web | fix:auth | debug:db | infra:docker
|
|
3330
|
+
outcome = "success" | "failure" | "partial",
|
|
3331
|
+
what_worked = "exact approach or command that worked",
|
|
3332
|
+
what_failed = "what did NOT work (prevents future wasted time)",
|
|
3333
|
+
severity = "critical" | "major" | "minor",
|
|
3334
|
+
commands = ["exact command that worked"],
|
|
3335
|
+
file_paths = ["path/to/changed/file"],
|
|
3336
|
+
)
|
|
3337
|
+
\`\`\`
|
|
2563
3338
|
|
|
2564
|
-
|
|
3339
|
+
**These events MUST automatically trigger \`learn_from_attempts\`:**
|
|
3340
|
+
- ✅ Successfully deployed → \`topic="deploy:<component>", outcome="success"\`
|
|
3341
|
+
- ✅ Fixed a bug → \`topic="fix:<description>", outcome="success"\`
|
|
3342
|
+
- ✅ Discovered root cause → \`topic="debug:<area>", outcome="partial"\`
|
|
3343
|
+
- ✅ Tried something that failed → \`topic="<area>", outcome="failure", what_failed="..."\`
|
|
3344
|
+
- ✅ Found a config quirk → \`topic="infra:<topic>", outcome="success"\`
|
|
3345
|
+
- ✅ Ran a working command → store it in \`commands=[...]\`
|
|
2565
3346
|
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
3347
|
+
### Rule 3 — Session Handoff (when closing window or task list > 3 items)
|
|
3348
|
+
\`\`\`
|
|
3349
|
+
session_handoff(
|
|
3350
|
+
instance_id = "${instance_id}",
|
|
3351
|
+
completed_tasks = ["what was done"],
|
|
3352
|
+
remaining_tasks = ["what is still to do"],
|
|
3353
|
+
files_changed = [{"path": "src/...", "status": "modified", "description": "what changed"}],
|
|
3354
|
+
instructions = "IMPORTANT: what the next window MUST know to not break things",
|
|
3355
|
+
context_summary = "current state of the system",
|
|
3356
|
+
)
|
|
3357
|
+
\`\`\`
|
|
2570
3358
|
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
3359
|
+
The next \`session_start\` automatically includes this handoff. The new window sees exactly what's done, what remains, and what NOT to break.
|
|
3360
|
+
|
|
3361
|
+
### Rule 4 — Session End (LAST thing before closing)
|
|
3362
|
+
\`\`\`
|
|
3363
|
+
session_end(
|
|
3364
|
+
instance_id = "${instance_id}",
|
|
3365
|
+
summary = "What was accomplished (2-3 sentences with specifics)",
|
|
3366
|
+
files_changed = ["list", "of", "changed", "files"],
|
|
3367
|
+
lessons_learned = <number of learn_from_attempts calls made>,
|
|
3368
|
+
)
|
|
3369
|
+
\`\`\`
|
|
3370
|
+
|
|
3371
|
+
> \`session_end\` **automatically extracts and stores lessons** from the summary text — no manual calls needed.
|
|
3372
|
+
|
|
3373
|
+
### Rule 5 — Code Index Invalidation (AUTOMATIC)
|
|
3374
|
+
- \`index_project\` tracks MD5 hashes per file — **skips unchanged files automatically**
|
|
3375
|
+
- After code changes: next \`index_project\` run re-indexes only modified files
|
|
3376
|
+
- After delete/rename: run \`index_project\` again to sync
|
|
3377
|
+
- No manual invalidation needed
|
|
3378
|
+
|
|
3379
|
+
---
|
|
3380
|
+
|
|
3381
|
+
## Task-type trigger table
|
|
3382
|
+
|
|
3383
|
+
| You're about to... | Call BEFORE | Call AFTER |
|
|
3384
|
+
|---|---|---|
|
|
3385
|
+
| Deploy anything | \`recall_best_solution("deploy:<component>")\` | \`learn_from_attempts(topic="deploy:...")\` |
|
|
3386
|
+
| Fix a bug | \`recall_best_solution("fix:<area>")\` | \`learn_from_attempts(topic="fix:...")\` |
|
|
3387
|
+
| Add a feature | \`session_start(focus="feat:<area>")\` | \`learn_from_attempts(topic="feat:...")\` |
|
|
3388
|
+
| Infra/server work | \`recall_best_solution("infra:<topic>")\` | \`learn_from_attempts(topic="infra:...")\` |
|
|
3389
|
+
| Debug an issue | \`smart_recall("<error message or symptom>")\` | \`learn_from_attempts(topic="debug:...")\` |
|
|
3390
|
+
|
|
3391
|
+
---
|
|
3392
|
+
|
|
3393
|
+
## Available Brain Tools
|
|
3394
|
+
|
|
3395
|
+
| Tool | When to use |
|
|
3396
|
+
|------|-------------|
|
|
3397
|
+
| \`session_start\` | **FIRST** — mandatory at start of every session |
|
|
3398
|
+
| \`session_end\` | **LAST** — mandatory at end, auto-learns from summary |
|
|
3399
|
+
| \`session_handoff\` | When closing window with remaining tasks |
|
|
3400
|
+
| \`learn_from_attempts\` | **AUTOMATIC** after every fix/deploy/discovery |
|
|
3401
|
+
| \`recall_best_solution\` | Before any non-trivial task |
|
|
3402
|
+
| \`remember_context\` | After analyzing code — save findings for future sessions |
|
|
3403
|
+
${smartRecallNote ? `| \`smart_recall\` | Search brain by meaning/keywords |\n` : ''}\
|
|
3404
|
+
| \`recall_context\` | Get exact key (supports glob: \`arch:*\`, \`file:*\`) |
|
|
3405
|
+
| \`brain_search\` | BM25+ full-text search over all brain data |
|
|
3406
|
+
| \`auto_learn_session\` | Batch-learn from a list of observations (optional) |
|
|
3407
|
+
| \`index_project\` | Index source files (smart hash, skips unchanged files) |
|
|
3408
|
+
| \`list_remembered\` | See what's cached in the brain |
|
|
3409
|
+
| \`forget_context\` | Remove stale context |
|
|
3410
|
+
|
|
3411
|
+
---
|
|
2574
3412
|
|
|
2575
3413
|
## Instance Details
|
|
2576
3414
|
|
|
2577
|
-
- Instance ID
|
|
2578
|
-
- Instance name
|
|
2579
|
-
- Tier
|
|
2580
|
-
-
|
|
2581
|
-
- Embedding provider
|
|
3415
|
+
- **Instance ID:** \`${instance_id}\`
|
|
3416
|
+
- **Instance name:** ${inst.name}
|
|
3417
|
+
- **Tier:** ${tier}
|
|
3418
|
+
- **${layerNote}**
|
|
3419
|
+
- **Embedding provider:** ${providerArg}
|
|
3420
|
+
|
|
3421
|
+
---
|
|
2582
3422
|
|
|
2583
|
-
##
|
|
3423
|
+
## How the 3-layer system works
|
|
2584
3424
|
|
|
2585
3425
|
\`\`\`
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
remember_context("key", "content") # save analysis
|
|
3426
|
+
Layer 1 — Storage: Your cachly Valkey instance (${inst.name}) — persists forever
|
|
3427
|
+
Layer 2 — Tools: learn_from_attempts · recall_best_solution · brain_search · session_start/end
|
|
3428
|
+
Layer 3 — Autopilot: This file — AI reads it and runs tools automatically every session
|
|
2590
3429
|
\`\`\`
|
|
3430
|
+
|
|
3431
|
+
Result: Your AI **never solves the same problem twice** and always picks up exactly where it left off. 🚀
|
|
2591
3432
|
`;
|
|
2592
3433
|
const lines = [
|
|
2593
3434
|
`🧠 **cachly AI Memory Setup Complete**`,
|