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