@cachly-dev/mcp-server 0.9.18 → 0.9.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/handlers/advanced.d.ts +6 -0
- package/dist/handlers/advanced.js +586 -0
- package/dist/handlers/advanced.js.map +1 -0
- package/dist/handlers/cache.d.ts +6 -0
- package/dist/handlers/cache.js +520 -0
- package/dist/handlers/cache.js.map +1 -0
- package/dist/handlers/context.d.ts +6 -0
- package/dist/handlers/context.js +177 -0
- package/dist/handlers/context.js.map +1 -0
- package/dist/handlers/fedbrain.d.ts +6 -0
- package/dist/handlers/fedbrain.js +1078 -0
- package/dist/handlers/fedbrain.js.map +1 -0
- package/dist/handlers/instances.d.ts +9 -0
- package/dist/handlers/instances.js +153 -0
- package/dist/handlers/instances.js.map +1 -0
- package/dist/handlers/roadmap.d.ts +6 -0
- package/dist/handlers/roadmap.js +165 -0
- package/dist/handlers/roadmap.js.map +1 -0
- package/dist/handlers/syndicate.d.ts +6 -0
- package/dist/handlers/syndicate.js +339 -0
- package/dist/handlers/syndicate.js.map +1 -0
- package/dist/handlers/team.d.ts +6 -0
- package/dist/handlers/team.js +1048 -0
- package/dist/handlers/team.js.map +1 -0
- package/dist/index.js +38 -4018
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/handlers/advanced.ts +637 -0
- package/src/handlers/cache.ts +634 -0
- package/src/handlers/context.ts +218 -0
- package/src/handlers/fedbrain.ts +1143 -0
- package/src/handlers/instances.ts +180 -0
- package/src/handlers/roadmap.ts +192 -0
- package/src/handlers/syndicate.ts +451 -0
- package/src/handlers/team.ts +1163 -0
- package/src/index.ts +42 -4460
- package/.github/workflows/publish.yml +0 -42
- package/LICENSE +0 -202
- package/glama.json +0 -6
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { readdir, stat, readFile } from 'node:fs/promises';
|
|
5
|
+
import { join, relative, extname } from 'node:path';
|
|
6
|
+
import type { Redis } from 'ioredis';
|
|
7
|
+
import type { Instance } from './brain.js';
|
|
8
|
+
import { computeEmbedding, hasEmbedProvider, embedProviderHint, EMBED_PROVIDER } from '../embeddings.js';
|
|
9
|
+
import { detectNamespace } from '../namespace.js';
|
|
10
|
+
import { simpleHash } from '../confidence.js';
|
|
11
|
+
|
|
12
|
+
interface SemanticSearchResponse {
|
|
13
|
+
found: boolean; id?: string; similarity?: number; prompt?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type GetConnection = (instanceId: string) => Promise<Redis>;
|
|
17
|
+
type ApiFetch = <T>(path: string, options?: RequestInit) => Promise<T>;
|
|
18
|
+
|
|
19
|
+
export const CACHE_TOOL_NAMES = new Set([
|
|
20
|
+
'cache_get', 'cache_set', 'cache_delete', 'cache_exists', 'cache_ttl', 'cache_keys',
|
|
21
|
+
'cache_stats', 'semantic_search', 'detect_namespace', 'cache_warmup', 'index_project',
|
|
22
|
+
'cache_mset', 'cache_mget', 'cache_lock_acquire', 'cache_lock_release',
|
|
23
|
+
'cache_stream_set', 'cache_stream_get',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
export async function handleCacheTool(
|
|
27
|
+
name: string,
|
|
28
|
+
args: Record<string, unknown>,
|
|
29
|
+
getConnection: GetConnection,
|
|
30
|
+
apiFetch: ApiFetch,
|
|
31
|
+
): Promise<string | null> {
|
|
32
|
+
switch (name) {
|
|
33
|
+
case 'cache_get': {
|
|
34
|
+
const { instance_id, key } = args as { instance_id: string; key: string };
|
|
35
|
+
const redis = await getConnection(instance_id);
|
|
36
|
+
const value = await redis.get(key);
|
|
37
|
+
if (value === null) return `Key \`${key}\` → **not found** (null)`;
|
|
38
|
+
let pretty = value;
|
|
39
|
+
try {
|
|
40
|
+
pretty = JSON.stringify(JSON.parse(value), null, 2);
|
|
41
|
+
} catch {
|
|
42
|
+
// not JSON — return raw
|
|
43
|
+
}
|
|
44
|
+
return `Key \`${key}\`:\n\`\`\`\n${pretty}\n\`\`\``;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
case 'cache_set': {
|
|
48
|
+
const { instance_id, key, value, ttl } = args as {
|
|
49
|
+
instance_id: string;
|
|
50
|
+
key: string;
|
|
51
|
+
value: string;
|
|
52
|
+
ttl?: number;
|
|
53
|
+
};
|
|
54
|
+
const redis = await getConnection(instance_id);
|
|
55
|
+
if (ttl && ttl > 0) {
|
|
56
|
+
await redis.set(key, value, 'EX', ttl);
|
|
57
|
+
return `✅ Set \`${key}\` (TTL: ${ttl}s)`;
|
|
58
|
+
}
|
|
59
|
+
await redis.set(key, value);
|
|
60
|
+
return `✅ Set \`${key}\` (no expiry)`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
case 'cache_delete': {
|
|
64
|
+
const { instance_id, keys } = args as { instance_id: string; keys: string[] };
|
|
65
|
+
const redis = await getConnection(instance_id);
|
|
66
|
+
const deleted = await redis.del(...keys);
|
|
67
|
+
return `✅ Deleted **${deleted}** of ${keys.length} key(s): ${keys.map((k) => `\`${k}\``).join(', ')}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
case 'cache_exists': {
|
|
71
|
+
const { instance_id, keys } = args as { instance_id: string; keys: string[] };
|
|
72
|
+
const redis = await getConnection(instance_id);
|
|
73
|
+
const count = await redis.exists(...keys);
|
|
74
|
+
return `**${count}** of ${keys.length} key(s) exist in cache.`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
case 'cache_ttl': {
|
|
78
|
+
const { instance_id, key } = args as { instance_id: string; key: string };
|
|
79
|
+
const redis = await getConnection(instance_id);
|
|
80
|
+
const ttl = await redis.ttl(key);
|
|
81
|
+
if (ttl === -2) return `Key \`${key}\` → **does not exist**`;
|
|
82
|
+
if (ttl === -1) return `Key \`${key}\` → **no expiry** (persists forever)`;
|
|
83
|
+
const mins = Math.floor(ttl / 60);
|
|
84
|
+
const secs = ttl % 60;
|
|
85
|
+
return `Key \`${key}\` → TTL: **${ttl}s** (${mins}m ${secs}s remaining)`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
case 'cache_keys': {
|
|
89
|
+
const { instance_id, pattern = '*', count = 50 } = args as {
|
|
90
|
+
instance_id: string;
|
|
91
|
+
pattern?: string;
|
|
92
|
+
count?: number;
|
|
93
|
+
};
|
|
94
|
+
const limit = Math.min(count, 500);
|
|
95
|
+
const redis = await getConnection(instance_id);
|
|
96
|
+
const keys: string[] = [];
|
|
97
|
+
const stream = redis.scanStream({ match: pattern, count: 100 });
|
|
98
|
+
await new Promise<void>((resolve, reject) => {
|
|
99
|
+
stream.on('data', (batch: string[]) => {
|
|
100
|
+
keys.push(...batch);
|
|
101
|
+
if (keys.length >= limit) {
|
|
102
|
+
stream.destroy();
|
|
103
|
+
resolve();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
stream.on('end', resolve);
|
|
107
|
+
stream.on('error', reject);
|
|
108
|
+
});
|
|
109
|
+
const result = keys.slice(0, limit);
|
|
110
|
+
if (result.length === 0) return `No keys found matching pattern \`${pattern}\`.`;
|
|
111
|
+
return [
|
|
112
|
+
`Found **${result.length}** key(s) matching \`${pattern}\`:`,
|
|
113
|
+
...result.map((k) => ` • \`${k}\``),
|
|
114
|
+
result.length === limit ? `\n_(showing first ${limit} — narrow pattern to see more)_` : '',
|
|
115
|
+
]
|
|
116
|
+
.filter(Boolean)
|
|
117
|
+
.join('\n');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
case 'cache_stats': {
|
|
121
|
+
const { instance_id } = args as { instance_id: string };
|
|
122
|
+
const redis = await getConnection(instance_id);
|
|
123
|
+
|
|
124
|
+
const [infoAll, infoStats, infoKeyspace] = await Promise.all([
|
|
125
|
+
redis.info('memory'),
|
|
126
|
+
redis.info('stats'),
|
|
127
|
+
redis.info('keyspace'),
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
const parse = (section: string, field: string): string =>
|
|
131
|
+
section.match(new RegExp(`${field}:([^\r\n]+)`))?.[1]?.trim() ?? 'n/a';
|
|
132
|
+
|
|
133
|
+
const usedMem = parse(infoAll, 'used_memory_human');
|
|
134
|
+
const peakMem = parse(infoAll, 'used_memory_peak_human');
|
|
135
|
+
const hits = parse(infoStats, 'keyspace_hits');
|
|
136
|
+
const misses = parse(infoStats, 'keyspace_misses');
|
|
137
|
+
const opsPerSec = parse(infoStats, 'instantaneous_ops_per_sec');
|
|
138
|
+
const connectedClients = (await redis.info('clients')).match(/connected_clients:(\d+)/)?.[1] ?? 'n/a';
|
|
139
|
+
|
|
140
|
+
const hitsN = parseInt(hits) || 0;
|
|
141
|
+
const missesN = parseInt(misses) || 0;
|
|
142
|
+
const total = hitsN + missesN;
|
|
143
|
+
const hitRate = total > 0 ? ((hitsN / total) * 100).toFixed(1) : 'n/a';
|
|
144
|
+
|
|
145
|
+
const keyspaceLines = infoKeyspace
|
|
146
|
+
.split('\n')
|
|
147
|
+
.filter((l: string) => l.startsWith('db'))
|
|
148
|
+
.map((l: string) => ` ${l.trim()}`);
|
|
149
|
+
|
|
150
|
+
return [
|
|
151
|
+
`📊 **Cache Stats for instance \`${instance_id}\`:**`,
|
|
152
|
+
``,
|
|
153
|
+
` 💾 Memory used: ${usedMem} (peak: ${peakMem})`,
|
|
154
|
+
` ⚡ Ops/sec: ${opsPerSec}`,
|
|
155
|
+
` 🎯 Hit rate: ${hitRate}% (${hits} hits / ${misses} misses)`,
|
|
156
|
+
` 🔗 Clients: ${connectedClients}`,
|
|
157
|
+
``,
|
|
158
|
+
keyspaceLines.length > 0
|
|
159
|
+
? ` 🗂️ Keyspace:\n${keyspaceLines.join('\n')}`
|
|
160
|
+
: ` 🗂️ Keyspace: (empty)`,
|
|
161
|
+
].join('\n');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
case 'semantic_search': {
|
|
165
|
+
const {
|
|
166
|
+
instance_id,
|
|
167
|
+
query,
|
|
168
|
+
threshold = 0.82,
|
|
169
|
+
namespace: nsArg = 'cachly:sem',
|
|
170
|
+
top_k = 5,
|
|
171
|
+
use_hybrid = false,
|
|
172
|
+
auto_namespace = false,
|
|
173
|
+
} = args as {
|
|
174
|
+
instance_id: string;
|
|
175
|
+
query: string;
|
|
176
|
+
threshold?: number;
|
|
177
|
+
namespace?: string;
|
|
178
|
+
top_k?: number;
|
|
179
|
+
use_hybrid?: boolean;
|
|
180
|
+
auto_namespace?: boolean;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// resolve namespace from query text when requested
|
|
184
|
+
const namespace = auto_namespace ? detectNamespace(query) : nsArg;
|
|
185
|
+
|
|
186
|
+
if (!hasEmbedProvider()) {
|
|
187
|
+
return (
|
|
188
|
+
`❌ semantic_search requires an embedding provider.\n\n` +
|
|
189
|
+
`Current: ${embedProviderHint()}\n\n` +
|
|
190
|
+
`Set one of these in your MCP env config:\n` +
|
|
191
|
+
` OPENAI_API_KEY (provider: openai – default)\n` +
|
|
192
|
+
` MISTRAL_API_KEY (provider: mistral)\n` +
|
|
193
|
+
` COHERE_API_KEY (provider: cohere)\n` +
|
|
194
|
+
` GEMINI_API_KEY (provider: gemini)\n` +
|
|
195
|
+
` OLLAMA_BASE_URL (provider: ollama – local, no key needed)\n` +
|
|
196
|
+
`Also set: CACHLY_EMBED_PROVIDER=<provider>`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
|
|
201
|
+
if (!inst.vector_token) {
|
|
202
|
+
return (
|
|
203
|
+
`❌ Semantic search is only available on Speed and Business tiers.\n\n` +
|
|
204
|
+
`Your instance "${inst.name}" is on the **${inst.tier.toUpperCase()}** tier.\n` +
|
|
205
|
+
`Upgrade at https://cachly.dev/instances/${instance_id}`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Compute embedding via configured provider
|
|
210
|
+
const embedding = await computeEmbedding(query);
|
|
211
|
+
|
|
212
|
+
// Query cachly vector API
|
|
213
|
+
const vectorUrl = process.env.CACHLY_VECTOR_URL ?? `https://api.cachly.dev/v1/sem/${inst.vector_token}`;
|
|
214
|
+
const searchPayload: Record<string, unknown> = { embedding, namespace, threshold, top_k };
|
|
215
|
+
// hybrid BM25+Vector RRF: include query text when requested.
|
|
216
|
+
if (use_hybrid) {
|
|
217
|
+
searchPayload['hybrid'] = true;
|
|
218
|
+
searchPayload['prompt'] = query;
|
|
219
|
+
}
|
|
220
|
+
const searchRes = await fetch(`${vectorUrl}/search`, {
|
|
221
|
+
method: 'POST',
|
|
222
|
+
headers: { 'Content-Type': 'application/json' },
|
|
223
|
+
body: JSON.stringify(searchPayload),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
if (!searchRes.ok) {
|
|
227
|
+
throw new McpError(ErrorCode.InternalError, `Vector search failed: ${searchRes.statusText}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const results = (await searchRes.json()) as SemanticSearchResponse[];
|
|
231
|
+
|
|
232
|
+
if (!results.length || (results.length === 1 && !results[0].found)) {
|
|
233
|
+
return (
|
|
234
|
+
`🔍 No semantically similar entries found for:\n _"${query}"_\n\n` +
|
|
235
|
+
`Try lowering the threshold (current: ${threshold}) or using different keywords.`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const redis = await getConnection(instance_id);
|
|
240
|
+
const lines: string[] = [
|
|
241
|
+
`🔍 **Semantic search results** for: _"${query}"_`,
|
|
242
|
+
` Threshold: ${threshold} · Namespace: \`${namespace}\``,
|
|
243
|
+
``,
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
for (const hit of results) {
|
|
247
|
+
if (!hit.found || !hit.id) continue;
|
|
248
|
+
const value = await redis.get(`${namespace}:val:${hit.id}`);
|
|
249
|
+
lines.push(
|
|
250
|
+
`**Match** (similarity: ${((hit.similarity ?? 0) * 100).toFixed(1)}%)`,
|
|
251
|
+
` Prompt: _"${hit.prompt ?? '(unknown)'}"_`,
|
|
252
|
+
value ? ` Value: \`${value.slice(0, 200)}${value.length > 200 ? '…' : ''}\`` : ` Value: _(evicted from cache)_`,
|
|
253
|
+
``
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return lines.join('\n');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── Namespace Auto-Detection ──────────────────────────────────────────
|
|
261
|
+
case 'detect_namespace': {
|
|
262
|
+
const { prompt } = args as { prompt: string };
|
|
263
|
+
const ns = detectNamespace(prompt);
|
|
264
|
+
const typeLabel = ns.split(':').pop()!;
|
|
265
|
+
const descriptions: Record<string, string> = {
|
|
266
|
+
code: '💻 Code — contains programming constructs or syntax',
|
|
267
|
+
translation: '🌐 Translation — asks to translate between languages',
|
|
268
|
+
summary: '📝 Summary — requests a summary or key points (TL;DR)',
|
|
269
|
+
qa: '❓ Q&A — a direct question or query',
|
|
270
|
+
creative: '🎨 Creative — general, creative, or conversational prompt',
|
|
271
|
+
};
|
|
272
|
+
return [
|
|
273
|
+
`**Detected namespace:** \`${ns}\``,
|
|
274
|
+
`**Type:** ${descriptions[typeLabel] ?? typeLabel}`,
|
|
275
|
+
``,
|
|
276
|
+
`_Prompt: "${prompt.slice(0, 120)}${prompt.length > 120 ? '…' : ''}"_`,
|
|
277
|
+
``,
|
|
278
|
+
`💡 Use this namespace in \`semantic_search\` or \`cache_warmup\` for better hit rates.`,
|
|
279
|
+
` Set \`auto_namespace: true\` to apply this detection automatically.`,
|
|
280
|
+
].join('\n');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── Cache Warmup ───────────────────────────────────────────────────────
|
|
284
|
+
case 'cache_warmup': {
|
|
285
|
+
const {
|
|
286
|
+
instance_id,
|
|
287
|
+
entries: rawEntries,
|
|
288
|
+
namespace: nsArg = 'cachly:sem',
|
|
289
|
+
ttl,
|
|
290
|
+
auto_namespace = false,
|
|
291
|
+
} = args as {
|
|
292
|
+
instance_id: string;
|
|
293
|
+
entries: Array<{ prompt: string; value: string; namespace?: string }>;
|
|
294
|
+
namespace?: string;
|
|
295
|
+
ttl?: number;
|
|
296
|
+
auto_namespace?: boolean;
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
if (!hasEmbedProvider()) {
|
|
300
|
+
return (
|
|
301
|
+
`❌ cache_warmup requires an embedding provider.\n\n` +
|
|
302
|
+
`Current: ${embedProviderHint()}\n\n` +
|
|
303
|
+
`Supported: openai (default) · mistral · cohere · ollama (local) · gemini\n` +
|
|
304
|
+
`Set CACHLY_EMBED_PROVIDER and the matching API key env var.`
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
|
|
309
|
+
const vectorUrl =
|
|
310
|
+
process.env.CACHLY_VECTOR_URL ??
|
|
311
|
+
(inst.vector_token ? `https://api.cachly.dev/v1/sem/${inst.vector_token}` : null);
|
|
312
|
+
|
|
313
|
+
const redis = await getConnection(instance_id);
|
|
314
|
+
|
|
315
|
+
let warmed = 0;
|
|
316
|
+
let skipped = 0;
|
|
317
|
+
const details: string[] = [];
|
|
318
|
+
|
|
319
|
+
for (const entry of rawEntries) {
|
|
320
|
+
// resolve namespace per entry
|
|
321
|
+
const ns = entry.namespace ?? (auto_namespace ? detectNamespace(entry.prompt) : nsArg);
|
|
322
|
+
|
|
323
|
+
// Compute embedding for this prompt
|
|
324
|
+
const embedding = await computeEmbedding(entry.prompt);
|
|
325
|
+
|
|
326
|
+
// Check if a very-similar entry already exists (threshold 0.98 → skip to avoid duplicates)
|
|
327
|
+
let alreadyCached = false;
|
|
328
|
+
if (vectorUrl) {
|
|
329
|
+
const checkRes = await fetch(`${vectorUrl}/search`, {
|
|
330
|
+
method: 'POST',
|
|
331
|
+
headers: { 'Content-Type': 'application/json' },
|
|
332
|
+
body: JSON.stringify({ embedding, namespace: ns, threshold: 0.98 }),
|
|
333
|
+
}).catch(() => null);
|
|
334
|
+
if (checkRes?.ok) {
|
|
335
|
+
const results = (await checkRes.json()) as SemanticSearchResponse[];
|
|
336
|
+
alreadyCached = results[0]?.found ?? false;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (alreadyCached) {
|
|
341
|
+
skipped++;
|
|
342
|
+
details.push(` ⏭️ _"${entry.prompt.slice(0, 60)}${entry.prompt.length > 60 ? '…' : ''}"_ → already cached`);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Write value to Valkey
|
|
347
|
+
const id = randomUUID();
|
|
348
|
+
const vk = `${ns}:val:${id}`;
|
|
349
|
+
if (ttl && ttl > 0) {
|
|
350
|
+
await redis.set(vk, entry.value, 'EX', ttl);
|
|
351
|
+
} else {
|
|
352
|
+
await redis.set(vk, entry.value);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (vectorUrl) {
|
|
356
|
+
// pgvector path – index embedding in HNSW
|
|
357
|
+
const body: Record<string, unknown> = { id, prompt: entry.prompt, namespace: ns, embedding };
|
|
358
|
+
if (ttl && ttl > 0) {
|
|
359
|
+
body['expires_at'] = new Date(Date.now() + ttl * 1000).toISOString();
|
|
360
|
+
}
|
|
361
|
+
await fetch(`${vectorUrl}/entries`, {
|
|
362
|
+
method: 'POST',
|
|
363
|
+
headers: { 'Content-Type': 'application/json' },
|
|
364
|
+
body: JSON.stringify(body),
|
|
365
|
+
}).catch(() => undefined);
|
|
366
|
+
} else {
|
|
367
|
+
// Legacy SCAN path – write emb key to Valkey
|
|
368
|
+
const embKey = `${ns}:emb:${id}`;
|
|
369
|
+
const embPayload = JSON.stringify({ embedding, prompt: entry.prompt });
|
|
370
|
+
if (ttl && ttl > 0) {
|
|
371
|
+
await redis.set(embKey, embPayload, 'EX', ttl);
|
|
372
|
+
} else {
|
|
373
|
+
await redis.set(embKey, embPayload);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
warmed++;
|
|
378
|
+
details.push(` ✅ _"${entry.prompt.slice(0, 60)}${entry.prompt.length > 60 ? '…' : ''}"_ → \`${ns}\``);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return [
|
|
382
|
+
`🔥 **Cache Warmup Complete**`,
|
|
383
|
+
``,
|
|
384
|
+
` ✅ Warmed: **${warmed}** new entries`,
|
|
385
|
+
` ⏭️ Skipped: **${skipped}** (already cached at ≥ 0.98 similarity)`,
|
|
386
|
+
` 📦 Total: ${rawEntries.length}`,
|
|
387
|
+
auto_namespace
|
|
388
|
+
? ` 🏷️ Namespacing: auto-detected per prompt`
|
|
389
|
+
: ` 🏷️ Namespace: \`${nsArg}\``,
|
|
390
|
+
vectorUrl
|
|
391
|
+
? ` 🔍 Mode: pgvector HNSW (Speed/Business tier)`
|
|
392
|
+
: ` 🔍 Mode: Valkey SCAN (upgrade to Speed tier for scalable search)`,
|
|
393
|
+
``,
|
|
394
|
+
...details,
|
|
395
|
+
].join('\n');
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ── index_project – Codebase Indexing ─────────────────────────────────────
|
|
399
|
+
case 'index_project': {
|
|
400
|
+
const {
|
|
401
|
+
instance_id,
|
|
402
|
+
dir,
|
|
403
|
+
extensions: extArg,
|
|
404
|
+
max_files = 100,
|
|
405
|
+
ttl = 86400,
|
|
406
|
+
summary_chars = 1200,
|
|
407
|
+
namespace: nsArg = 'cachly:sem:code',
|
|
408
|
+
} = args as {
|
|
409
|
+
instance_id: string;
|
|
410
|
+
dir: string;
|
|
411
|
+
extensions?: string[];
|
|
412
|
+
max_files?: number;
|
|
413
|
+
ttl?: number;
|
|
414
|
+
summary_chars?: number;
|
|
415
|
+
namespace?: string;
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const ALLOWED_EXT = new Set(
|
|
419
|
+
(extArg ?? ['ts', 'js', 'tsx', 'jsx', 'go', 'py', 'java', 'rs', 'md', 'kt', 'swift']).map(
|
|
420
|
+
(e) => (e.startsWith('.') ? e : `.${e}`),
|
|
421
|
+
),
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
// Recursively collect files up to max_files limit
|
|
425
|
+
const files: string[] = [];
|
|
426
|
+
async function walk(d: string): Promise<void> {
|
|
427
|
+
if (files.length >= max_files) return;
|
|
428
|
+
const entries = await readdir(d, { withFileTypes: true }).catch(() => null);
|
|
429
|
+
if (!entries) return;
|
|
430
|
+
for (const entry of entries) {
|
|
431
|
+
if (files.length >= max_files) break;
|
|
432
|
+
const full = join(d, entry.name as unknown as string);
|
|
433
|
+
if (entry.isDirectory()) {
|
|
434
|
+
if (['.git', 'node_modules', 'dist', 'build', '.next', '__pycache__', 'vendor'].includes(entry.name as unknown as string))
|
|
435
|
+
continue;
|
|
436
|
+
await walk(full);
|
|
437
|
+
} else if (entry.isFile() && ALLOWED_EXT.has(extname(entry.name as unknown as string).toLowerCase())) {
|
|
438
|
+
files.push(full);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
await walk(dir);
|
|
443
|
+
|
|
444
|
+
if (files.length === 0) {
|
|
445
|
+
return `❌ No matching files found in \`${dir}\`.\nExtensions checked: ${[...ALLOWED_EXT].join(', ')}`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
|
|
449
|
+
const vectorUrl =
|
|
450
|
+
process.env.CACHLY_VECTOR_URL ??
|
|
451
|
+
(inst.vector_token ? `https://api.cachly.dev/v1/sem/${inst.vector_token}` : null);
|
|
452
|
+
const canEmbed = vectorUrl && hasEmbedProvider();
|
|
453
|
+
|
|
454
|
+
let indexed = 0;
|
|
455
|
+
let skipped = 0;
|
|
456
|
+
let errors = 0;
|
|
457
|
+
let semanticIndexed = 0;
|
|
458
|
+
let unchanged = 0;
|
|
459
|
+
const details: string[] = [];
|
|
460
|
+
const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
|
|
461
|
+
const redis = await getConnection(instance_id);
|
|
462
|
+
|
|
463
|
+
for (const filePath of files) {
|
|
464
|
+
const relPath = relative(dir, filePath);
|
|
465
|
+
let content: string;
|
|
466
|
+
let fileSize: number;
|
|
467
|
+
try {
|
|
468
|
+
const s = await stat(filePath);
|
|
469
|
+
if (s.size > 200_000) { skipped++; continue; } // skip files >200 KB
|
|
470
|
+
fileSize = s.size;
|
|
471
|
+
content = await readFile(filePath, 'utf-8');
|
|
472
|
+
} catch {
|
|
473
|
+
errors++;
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ── Smart invalidation: hash-based change detection ──
|
|
478
|
+
// Compute a simple hash of file content to skip unchanged files
|
|
479
|
+
const hashKey = `cachly:idx:hash:${relPath}`;
|
|
480
|
+
const contentHash = `${fileSize}:${content.length}:${simpleHash(content)}`;
|
|
481
|
+
const existingHash = await redis.get(hashKey);
|
|
482
|
+
if (existingHash === contentHash) {
|
|
483
|
+
// File unchanged — refresh TTL but skip re-indexing
|
|
484
|
+
const idxKey = `cachly:idx:${relPath}`;
|
|
485
|
+
if (ttl > 0) await redis.expire(idxKey, ttl);
|
|
486
|
+
if (ttl > 0) await redis.expire(hashKey, ttl);
|
|
487
|
+
unchanged++;
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const excerpt = content.slice(0, summary_chars).replace(/\s+/g, ' ').trim();
|
|
492
|
+
|
|
493
|
+
// ── Layer 1: Keyword index in Valkey (always works, no embedding needed) ──
|
|
494
|
+
const idxKey = `cachly:idx:${relPath}`;
|
|
495
|
+
const idxValue = `File: ${relPath}\n${excerpt}`;
|
|
496
|
+
if (ttl > 0) {
|
|
497
|
+
await redis.set(idxKey, idxValue, 'EX', ttl);
|
|
498
|
+
} else {
|
|
499
|
+
await redis.set(idxKey, idxValue);
|
|
500
|
+
}
|
|
501
|
+
// Store content hash for smart invalidation on next run
|
|
502
|
+
if (ttl > 0) {
|
|
503
|
+
await redis.set(hashKey, contentHash, 'EX', ttl);
|
|
504
|
+
} else {
|
|
505
|
+
await redis.set(hashKey, contentHash);
|
|
506
|
+
}
|
|
507
|
+
indexed++;
|
|
508
|
+
details.push(` ✅ ${relPath}`);
|
|
509
|
+
|
|
510
|
+
// ── Layer 2: Semantic vector index (optional, only if embedding available) ──
|
|
511
|
+
if (canEmbed) {
|
|
512
|
+
try {
|
|
513
|
+
const prompt = `File: ${relPath}\n${excerpt}`;
|
|
514
|
+
const embedding = await computeEmbedding(prompt);
|
|
515
|
+
const id = randomUUID();
|
|
516
|
+
await fetch(`${vectorUrl}/entries`, {
|
|
517
|
+
method: 'POST',
|
|
518
|
+
headers: { 'Content-Type': 'application/json' },
|
|
519
|
+
body: JSON.stringify({ id, prompt, namespace: nsArg, embedding, expires_at: expiresAt }),
|
|
520
|
+
});
|
|
521
|
+
await redis.set(`${nsArg}:val:${id}`, relPath, 'EX', ttl);
|
|
522
|
+
semanticIndexed++;
|
|
523
|
+
} catch {
|
|
524
|
+
// Semantic indexing failed — keyword index is enough
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const mode = canEmbed ? '🔍 Keyword + 🎯 Semantic' : '🔍 Keyword only (no embedding provider)';
|
|
530
|
+
|
|
531
|
+
return [
|
|
532
|
+
`📂 **index_project Complete** — ${mode}`,
|
|
533
|
+
``,
|
|
534
|
+
` 📁 Dir: ${dir}`,
|
|
535
|
+
` ✅ Indexed: **${indexed}** files (new/changed)`,
|
|
536
|
+
` ♻️ Unchanged: ${unchanged} files (hash match — skipped)`,
|
|
537
|
+
...(canEmbed ? [` 🎯 Semantic: **${semanticIndexed}** files (vector-searchable)`] : []),
|
|
538
|
+
` ⏭️ Skipped: ${skipped} (too large or filtered)`,
|
|
539
|
+
` ❌ Errors: ${errors}`,
|
|
540
|
+
` ⏱️ TTL: ${ttl}s (${Math.round(ttl / 3600)}h)`,
|
|
541
|
+
``,
|
|
542
|
+
`💡 **Next steps:**`,
|
|
543
|
+
` 1. Use \`smart_recall("how does auth work")\` to find relevant files.`,
|
|
544
|
+
` 2. Re-run index_project after major refactors.`,
|
|
545
|
+
...(canEmbed ? [] : [` 3. Set OPENAI_API_KEY (or similar) in .env to also enable semantic search.`]),
|
|
546
|
+
``,
|
|
547
|
+
...(details.length <= 20 ? details : [...details.slice(0, 20), ` … and ${details.length - 20} more`]),
|
|
548
|
+
].join('\n');
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ── Bulk / Lock / Stream tools ──────────────────────────────────────────
|
|
552
|
+
case 'cache_mset': {
|
|
553
|
+
const { instance_id, items } = args as { instance_id: string; items: Array<{ key: string; value: unknown; ttl?: number }> };
|
|
554
|
+
if (!Array.isArray(items) || items.length === 0) return '⚠️ No items provided.';
|
|
555
|
+
const redis = await getConnection(instance_id);
|
|
556
|
+
const pipe = redis.pipeline();
|
|
557
|
+
for (const item of items) {
|
|
558
|
+
const serialized = typeof item.value === 'string' ? item.value : JSON.stringify(item.value);
|
|
559
|
+
if (item.ttl && item.ttl > 0) { pipe.set(item.key, serialized, 'EX', item.ttl); }
|
|
560
|
+
else { pipe.set(item.key, serialized); }
|
|
561
|
+
}
|
|
562
|
+
await pipe.exec();
|
|
563
|
+
return `✅ **cache_mset** – ${items.length} key(s) written in one pipeline round-trip.\n` +
|
|
564
|
+
items.map(i => ` • \`${i.key}\`${i.ttl ? ` (TTL ${i.ttl}s)` : ''}`).join('\n');
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
case 'cache_mget': {
|
|
568
|
+
const { instance_id, keys } = args as { instance_id: string; keys: string[] };
|
|
569
|
+
if (!Array.isArray(keys) || keys.length === 0) return '⚠️ No keys provided.';
|
|
570
|
+
const redis = await getConnection(instance_id);
|
|
571
|
+
const raws = await redis.mget(...keys);
|
|
572
|
+
const result = keys.map((k, i) => {
|
|
573
|
+
const raw = raws[i];
|
|
574
|
+
if (raw === null) return ` • \`${k}\`: _null (miss)_`;
|
|
575
|
+
return ` • \`${k}\`: ${raw}`;
|
|
576
|
+
});
|
|
577
|
+
return `✅ **cache_mget** – ${keys.length} key(s) fetched in one round-trip.\n` + result.join('\n');
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
case 'cache_lock_acquire': {
|
|
581
|
+
const { instance_id, key, ttl_ms = 5000, retries = 3, retry_delay_ms = 50 } =
|
|
582
|
+
args as { instance_id: string; key: string; ttl_ms?: number; retries?: number; retry_delay_ms?: number };
|
|
583
|
+
const redis = await getConnection(instance_id);
|
|
584
|
+
const lockKey = `cachly:lock:${key}`;
|
|
585
|
+
const token = randomUUID();
|
|
586
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
587
|
+
const result = await redis.set(lockKey, token, 'PX', ttl_ms, 'NX');
|
|
588
|
+
if (result === 'OK') {
|
|
589
|
+
return `🔒 **cache_lock_acquire** – Lock acquired!\n\n Key: \`${key}\`\n Token: \`${token}\`\n TTL: ${ttl_ms} ms\n\n💡 Use **cache_lock_release** with this token to release early.`;
|
|
590
|
+
}
|
|
591
|
+
if (attempt < retries) await new Promise(r => setTimeout(r, retry_delay_ms));
|
|
592
|
+
}
|
|
593
|
+
return `❌ **cache_lock_acquire** – Could not acquire lock for \`${key}\` after ${retries + 1} attempts.`;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
case 'cache_lock_release': {
|
|
597
|
+
const { instance_id, key, token } = args as { instance_id: string; key: string; token: string };
|
|
598
|
+
const redis = await getConnection(instance_id);
|
|
599
|
+
const script = `if redis.call("get",KEYS[1])==ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end`;
|
|
600
|
+
const released = await redis.eval(script, 1, `cachly:lock:${key}`, token);
|
|
601
|
+
return released === 1
|
|
602
|
+
? `🔓 **cache_lock_release** – Lock \`${key}\` released.`
|
|
603
|
+
: `⚠️ **cache_lock_release** – Lock \`${key}\` expired or token mismatch.`;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
case 'cache_stream_set': {
|
|
607
|
+
const { instance_id, key, chunks, ttl } = args as { instance_id: string; key: string; chunks: string[]; ttl?: number };
|
|
608
|
+
if (!Array.isArray(chunks) || chunks.length === 0) return '⚠️ No chunks provided.';
|
|
609
|
+
const redis = await getConnection(instance_id);
|
|
610
|
+
const listKey = `cachly:stream:${key}`;
|
|
611
|
+
await redis.del(listKey);
|
|
612
|
+
const pipe = redis.pipeline();
|
|
613
|
+
for (const chunk of chunks) pipe.rpush(listKey, chunk);
|
|
614
|
+
if (ttl && ttl > 0) pipe.expire(listKey, ttl);
|
|
615
|
+
await pipe.exec();
|
|
616
|
+
return `✅ **cache_stream_set** – ${chunks.length} chunk(s) stored.\n Key: \`${key}\`\n${ttl ? ` TTL: ${ttl}s\n` : ''} Total size: ${chunks.reduce((a, c) => a + c.length, 0)} chars`;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
case 'cache_stream_get': {
|
|
620
|
+
const { instance_id, key } = args as { instance_id: string; key: string };
|
|
621
|
+
const redis = await getConnection(instance_id);
|
|
622
|
+
const listKey = `cachly:stream:${key}`;
|
|
623
|
+
const len = await redis.llen(listKey);
|
|
624
|
+
if (len === 0) return `⚠️ **cache_stream_get** – Cache miss for key \`${key}\`.`;
|
|
625
|
+
const chunks = await redis.lrange(listKey, 0, -1);
|
|
626
|
+
const full = chunks.join('');
|
|
627
|
+
const preview = full.slice(0, 500);
|
|
628
|
+
return `✅ **cache_stream_get** – ${len} chunk(s) retrieved for \`${key}\`.\n\n**Preview** (first 500 chars):\n\`\`\`\n${preview}${preview.length < full.length ? '…' : ''}\n\`\`\``;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
default:
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
}
|