@cachly-dev/mcp-server 0.1.0
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/CHANGELOG.md +45 -0
- package/README.md +348 -0
- package/dist/__tests__/namespace.test.d.ts +1 -0
- package/dist/__tests__/namespace.test.js +114 -0
- package/dist/__tests__/namespace.test.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2879 -0
- package/dist/index.js.map +1 -0
- package/dist/namespace.d.ts +20 -0
- package/dist/namespace.js +128 -0
- package/dist/namespace.js.map +1 -0
- package/package.json +51 -0
- package/run-mcp.sh +57 -0
- package/src/__tests__/namespace.test.ts +126 -0
- package/src/index.ts +3327 -0
- package/src/namespace.ts +128 -0
- package/tsconfig.json +17 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,3327 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
4
|
+
import { join, relative, extname } from 'node:path';
|
|
5
|
+
/**
|
|
6
|
+
* cachly MCP Server v0.4.0
|
|
7
|
+
*
|
|
8
|
+
* Exposes cachly.dev as MCP tools so any AI assistant
|
|
9
|
+
* (GitHub Copilot, Claude, Cursor, Windsurf, Continue.dev …) can:
|
|
10
|
+
*
|
|
11
|
+
* ── Instance Management ─────────────────────────────────────────────────────
|
|
12
|
+
* • list_instances – list all your cache instances
|
|
13
|
+
* • create_instance – provision a new instance (free or paid)
|
|
14
|
+
* • get_instance – get details + connection string
|
|
15
|
+
* • get_connection_string – get the redis:// URL
|
|
16
|
+
* • delete_instance – permanently delete an instance
|
|
17
|
+
*
|
|
18
|
+
* ── Live Cache Operations ────────────────────────────────────────────────────
|
|
19
|
+
* • cache_get – get a value by key
|
|
20
|
+
* • cache_set – set a key-value pair with optional TTL
|
|
21
|
+
* • cache_delete – delete one or more keys
|
|
22
|
+
* • cache_exists – check if keys exist
|
|
23
|
+
* • cache_ttl – inspect TTL of a key
|
|
24
|
+
* • cache_keys – list keys matching a glob pattern
|
|
25
|
+
* • cache_stats – memory, hit rate, ops/sec, keyspace info
|
|
26
|
+
* • semantic_search – find semantically similar cached entries
|
|
27
|
+
* (requires OPENAI_API_KEY + CACHLY_VECTOR_URL)
|
|
28
|
+
*
|
|
29
|
+
* ── Auth & Status ────────────────────────────────────────────────────────────
|
|
30
|
+
* • get_api_status – check API health + JWT auth info (Keycloak)
|
|
31
|
+
*
|
|
32
|
+
* Configuration (env vars):
|
|
33
|
+
* CACHLY_API_URL – default https://api.cachly.dev
|
|
34
|
+
* CACHLY_JWT – your JWT (Keycloak access token)
|
|
35
|
+
* OPENAI_API_KEY – for semantic_search (optional)
|
|
36
|
+
* CACHLY_EMBED_MODEL – embedding model (default: text-embedding-3-small)
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
40
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
41
|
+
import {
|
|
42
|
+
CallToolRequestSchema,
|
|
43
|
+
ErrorCode,
|
|
44
|
+
ListToolsRequestSchema,
|
|
45
|
+
McpError,
|
|
46
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
47
|
+
import { Redis } from 'ioredis';
|
|
48
|
+
import OpenAI from 'openai';
|
|
49
|
+
|
|
50
|
+
// ── Config ────────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
|
|
53
|
+
const JWT = process.env.CACHLY_JWT ?? '';
|
|
54
|
+
const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? 'text-embedding-3-small';
|
|
55
|
+
/** Embedding provider: openai (default) | mistral | cohere | ollama | gemini */
|
|
56
|
+
const EMBED_PROVIDER = (process.env.CACHLY_EMBED_PROVIDER ?? 'openai').toLowerCase();
|
|
57
|
+
|
|
58
|
+
// ── Multi-provider embedding ─────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Compute an embedding vector for `text` using the configured provider.
|
|
62
|
+
*
|
|
63
|
+
* Supported via CACHLY_EMBED_PROVIDER:
|
|
64
|
+
* openai (default) – OPENAI_API_KEY · model: text-embedding-3-small
|
|
65
|
+
* mistral – MISTRAL_API_KEY · model: mistral-embed
|
|
66
|
+
* cohere – COHERE_API_KEY · model: embed-english-v3.0
|
|
67
|
+
* ollama – OLLAMA_BASE_URL (default: http://localhost:11434) · model: nomic-embed-text
|
|
68
|
+
* gemini – GEMINI_API_KEY · model: text-embedding-004
|
|
69
|
+
*
|
|
70
|
+
* Note: Anthropic (Claude) does not provide embedding models.
|
|
71
|
+
* Use Cohere or OpenAI instead.
|
|
72
|
+
*/
|
|
73
|
+
async function computeEmbedding(text: string): Promise<number[]> {
|
|
74
|
+
switch (EMBED_PROVIDER) {
|
|
75
|
+
case 'mistral': {
|
|
76
|
+
const key = process.env.MISTRAL_API_KEY;
|
|
77
|
+
if (!key) throw new Error('MISTRAL_API_KEY not set');
|
|
78
|
+
const model = EMBED_MODEL !== 'text-embedding-3-small' ? EMBED_MODEL : 'mistral-embed';
|
|
79
|
+
const res = await fetch('https://api.mistral.ai/v1/embeddings', {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
|
|
82
|
+
body: JSON.stringify({ model, input: [text] }),
|
|
83
|
+
});
|
|
84
|
+
if (!res.ok) throw new Error(`Mistral embedding error: ${res.statusText}`);
|
|
85
|
+
const json = (await res.json()) as { data: { embedding: number[] }[] };
|
|
86
|
+
return json.data[0].embedding;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
case 'cohere': {
|
|
90
|
+
const key = process.env.COHERE_API_KEY;
|
|
91
|
+
if (!key) throw new Error('COHERE_API_KEY not set');
|
|
92
|
+
const model = EMBED_MODEL !== 'text-embedding-3-small' ? EMBED_MODEL : 'embed-english-v3.0';
|
|
93
|
+
const res = await fetch('https://api.cohere.com/v2/embed', {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
|
|
96
|
+
body: JSON.stringify({ model, texts: [text], input_type: 'search_query', embedding_types: ['float'] }),
|
|
97
|
+
});
|
|
98
|
+
if (!res.ok) throw new Error(`Cohere embedding error: ${res.statusText}`);
|
|
99
|
+
const json = (await res.json()) as { embeddings: { float: number[][] } };
|
|
100
|
+
return json.embeddings.float[0];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
case 'ollama': {
|
|
104
|
+
const base = process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434';
|
|
105
|
+
const model = EMBED_MODEL !== 'text-embedding-3-small' ? EMBED_MODEL : 'nomic-embed-text';
|
|
106
|
+
const res = await fetch(`${base}/api/embeddings`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'Content-Type': 'application/json' },
|
|
109
|
+
body: JSON.stringify({ model, prompt: text }),
|
|
110
|
+
});
|
|
111
|
+
if (!res.ok) throw new Error(`Ollama embedding error: ${res.statusText}`);
|
|
112
|
+
const json = (await res.json()) as { embedding: number[] };
|
|
113
|
+
return json.embedding;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
case 'gemini': {
|
|
117
|
+
const key = process.env.GEMINI_API_KEY;
|
|
118
|
+
if (!key) throw new Error('GEMINI_API_KEY not set');
|
|
119
|
+
const model = EMBED_MODEL !== 'text-embedding-3-small' ? EMBED_MODEL : 'text-embedding-004';
|
|
120
|
+
const res = await fetch(
|
|
121
|
+
`https://generativelanguage.googleapis.com/v1beta/models/${model}:embedContent?key=${key}`,
|
|
122
|
+
{
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: { 'Content-Type': 'application/json' },
|
|
125
|
+
body: JSON.stringify({ model: `models/${model}`, content: { parts: [{ text }] } }),
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
if (!res.ok) throw new Error(`Gemini embedding error: ${res.statusText}`);
|
|
129
|
+
const json = (await res.json()) as { embedding: { values: number[] } };
|
|
130
|
+
return json.embedding.values;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
case 'openai':
|
|
134
|
+
default: {
|
|
135
|
+
const key = process.env.OPENAI_API_KEY;
|
|
136
|
+
if (!key) throw new Error(
|
|
137
|
+
'OPENAI_API_KEY not set.\n\n' +
|
|
138
|
+
'Other supported providers via CACHLY_EMBED_PROVIDER:\n' +
|
|
139
|
+
' mistral → MISTRAL_API_KEY\n' +
|
|
140
|
+
' cohere → COHERE_API_KEY\n' +
|
|
141
|
+
' ollama → OLLAMA_BASE_URL (local, no key needed)\n' +
|
|
142
|
+
' gemini → GEMINI_API_KEY'
|
|
143
|
+
);
|
|
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
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Returns true if the configured embedding provider has its required key/URL set. */
|
|
152
|
+
function hasEmbedProvider(): boolean {
|
|
153
|
+
switch (EMBED_PROVIDER) {
|
|
154
|
+
case 'mistral': return !!process.env.MISTRAL_API_KEY;
|
|
155
|
+
case 'cohere': return !!process.env.COHERE_API_KEY;
|
|
156
|
+
case 'ollama': return true; // OLLAMA_BASE_URL is optional (defaults to localhost)
|
|
157
|
+
case 'gemini': return !!process.env.GEMINI_API_KEY;
|
|
158
|
+
case 'openai':
|
|
159
|
+
default: return !!process.env.OPENAI_API_KEY;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Returns a human-readable description of the current embed provider/model for error messages. */
|
|
164
|
+
function embedProviderHint(): string {
|
|
165
|
+
const providerKeys: Record<string, string> = {
|
|
166
|
+
openai: 'OPENAI_API_KEY',
|
|
167
|
+
mistral: 'MISTRAL_API_KEY',
|
|
168
|
+
cohere: 'COHERE_API_KEY',
|
|
169
|
+
ollama: 'OLLAMA_BASE_URL (optional, default: http://localhost:11434)',
|
|
170
|
+
gemini: 'GEMINI_API_KEY',
|
|
171
|
+
};
|
|
172
|
+
const key = providerKeys[EMBED_PROVIDER] ?? 'OPENAI_API_KEY';
|
|
173
|
+
return `CACHLY_EMBED_PROVIDER=${EMBED_PROVIDER} → requires ${key}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
interface Instance {
|
|
179
|
+
id: string;
|
|
180
|
+
name: string;
|
|
181
|
+
tier: string;
|
|
182
|
+
status: string;
|
|
183
|
+
region: string;
|
|
184
|
+
host?: string;
|
|
185
|
+
port?: number;
|
|
186
|
+
password?: string;
|
|
187
|
+
tls_enabled?: boolean;
|
|
188
|
+
vector_token?: string;
|
|
189
|
+
memory_mb: number;
|
|
190
|
+
encryption_at_rest: boolean;
|
|
191
|
+
created_at: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
interface CreateResponse {
|
|
195
|
+
instance_id: string;
|
|
196
|
+
checkout_url?: string;
|
|
197
|
+
status: string;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
interface SemanticSearchResponse {
|
|
201
|
+
found: boolean;
|
|
202
|
+
id?: string;
|
|
203
|
+
similarity?: number;
|
|
204
|
+
prompt?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── Connection pool ───────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
/** Reuse Redis connections across tool calls (keyed by instance_id). */
|
|
210
|
+
const pool = new Map<string, Redis>();
|
|
211
|
+
|
|
212
|
+
async function getConnection(instance_id: string): Promise<Redis> {
|
|
213
|
+
if (pool.has(instance_id)) return pool.get(instance_id)!;
|
|
214
|
+
|
|
215
|
+
const inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
|
|
216
|
+
if (inst.status !== 'running') {
|
|
217
|
+
throw new McpError(
|
|
218
|
+
ErrorCode.InvalidRequest,
|
|
219
|
+
`Instance "${inst.name}" is not running (status: ${inst.status}). ` +
|
|
220
|
+
`It may still be provisioning or awaiting payment.`
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
if (!inst.host || !inst.port) {
|
|
224
|
+
throw new McpError(ErrorCode.InternalError, `Instance "${inst.name}" has no host/port yet.`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Fetch connection details (includes password) from the dedicated endpoint.
|
|
228
|
+
let password: string | undefined;
|
|
229
|
+
let tlsEnabled = inst.tls_enabled !== false; // default true
|
|
230
|
+
try {
|
|
231
|
+
const conn = await apiFetch<{ password?: string; tls_enabled?: boolean }>(
|
|
232
|
+
`/api/v1/instances/${instance_id}/connection`
|
|
233
|
+
);
|
|
234
|
+
password = conn.password ?? undefined;
|
|
235
|
+
tlsEnabled = conn.tls_enabled !== false;
|
|
236
|
+
} catch {
|
|
237
|
+
// Fallback: no password, use TLS default from instance
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const client = new Redis({
|
|
241
|
+
host: inst.host,
|
|
242
|
+
port: inst.port,
|
|
243
|
+
password: password || undefined,
|
|
244
|
+
...(tlsEnabled ? { tls: {} } : {}),
|
|
245
|
+
lazyConnect: true, // don't auto-connect — we connect explicitly below
|
|
246
|
+
enableReadyCheck: true,
|
|
247
|
+
connectTimeout: 5000,
|
|
248
|
+
retryStrategy: () => null, // fail fast, no reconnect loops in MCP context
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
client.on('error', () => {
|
|
252
|
+
pool.delete(instance_id); // remove stale connection on error
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Connect explicitly so we can catch connection errors as a proper rejected
|
|
256
|
+
// Promise instead of an unhandled 'error' event that would kill the process.
|
|
257
|
+
try {
|
|
258
|
+
await client.connect();
|
|
259
|
+
} catch (err: unknown) {
|
|
260
|
+
client.disconnect();
|
|
261
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
262
|
+
throw new McpError(
|
|
263
|
+
ErrorCode.InternalError,
|
|
264
|
+
`Could not connect to instance "${inst.name}" (${inst.host}:${inst.port}): ${msg}`
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
pool.set(instance_id, client);
|
|
269
|
+
return client;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── API helper ────────────────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|
275
|
+
if (!JWT) {
|
|
276
|
+
throw new McpError(
|
|
277
|
+
ErrorCode.InvalidRequest,
|
|
278
|
+
'CACHLY_JWT env var not set. Get your API token from https://cachly.dev/settings'
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
const res = await fetch(`${API_URL}${path}`, {
|
|
282
|
+
...options,
|
|
283
|
+
headers: {
|
|
284
|
+
'Content-Type': 'application/json',
|
|
285
|
+
Authorization: `Bearer ${JWT}`,
|
|
286
|
+
...options.headers,
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
if (!res.ok) {
|
|
290
|
+
const body = await res.json().catch(() => ({ error: res.statusText }));
|
|
291
|
+
throw new McpError(
|
|
292
|
+
ErrorCode.InternalError,
|
|
293
|
+
`cachly API error ${res.status}: ${(body as { error?: string }).error ?? res.statusText}`
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
if (res.status === 204) return undefined as T;
|
|
297
|
+
return res.json() as Promise<T>;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
import { detectNamespace } from './namespace.js';
|
|
301
|
+
|
|
302
|
+
// ── Tools ─────────────────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
const TOOLS = [
|
|
305
|
+
// ── Instance Management ──────────────────────────────────────────────────
|
|
306
|
+
{
|
|
307
|
+
name: 'list_instances',
|
|
308
|
+
description: 'List all your cachly cache instances with their status and connection details.',
|
|
309
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
name: 'create_instance',
|
|
313
|
+
description:
|
|
314
|
+
'Create a new managed Valkey/Redis cache instance on cachly.dev. ' +
|
|
315
|
+
'Free tier provisions in ~30 seconds. Paid tiers return a Stripe checkout URL. ' +
|
|
316
|
+
'Available tiers: free (30 MB), dev (256 MB, €8/mo), pro (1 GB, €25/mo), ' +
|
|
317
|
+
'speed (1 GB Dragonfly + Semantic Cache, €39/mo), business (8 GB, €99/mo).',
|
|
318
|
+
inputSchema: {
|
|
319
|
+
type: 'object',
|
|
320
|
+
properties: {
|
|
321
|
+
name: { type: 'string', description: 'Unique name for the instance (min 3 chars)' },
|
|
322
|
+
tier: {
|
|
323
|
+
type: 'string',
|
|
324
|
+
enum: ['free', 'dev', 'pro', 'speed', 'business'],
|
|
325
|
+
description: 'Pricing tier. Start with "free" for testing.',
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
required: ['name', 'tier'],
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
name: 'get_instance',
|
|
333
|
+
description: 'Get details and the Redis connection string for a specific cache instance.',
|
|
334
|
+
inputSchema: {
|
|
335
|
+
type: 'object',
|
|
336
|
+
properties: {
|
|
337
|
+
instance_id: { type: 'string', description: 'UUID of the instance (from list_instances)' },
|
|
338
|
+
},
|
|
339
|
+
required: ['instance_id'],
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
name: 'get_connection_string',
|
|
344
|
+
description:
|
|
345
|
+
'Get the Redis/Valkey connection string (redis:// URL) for a running instance. ' +
|
|
346
|
+
'Use this to configure your application or set environment variables.',
|
|
347
|
+
inputSchema: {
|
|
348
|
+
type: 'object',
|
|
349
|
+
properties: {
|
|
350
|
+
instance_id: { type: 'string', description: 'UUID of the instance' },
|
|
351
|
+
},
|
|
352
|
+
required: ['instance_id'],
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
name: 'delete_instance',
|
|
357
|
+
description:
|
|
358
|
+
'Permanently delete a cache instance. Deprovisions the Kubernetes workload and removes all data. ' +
|
|
359
|
+
'This action is irreversible.',
|
|
360
|
+
inputSchema: {
|
|
361
|
+
type: 'object',
|
|
362
|
+
properties: {
|
|
363
|
+
instance_id: { type: 'string', description: 'UUID of the instance to delete' },
|
|
364
|
+
confirm: { type: 'boolean', description: 'Must be true to confirm deletion' },
|
|
365
|
+
},
|
|
366
|
+
required: ['instance_id', 'confirm'],
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
// ── Live Cache Operations ────────────────────────────────────────────────
|
|
371
|
+
{
|
|
372
|
+
name: 'cache_get',
|
|
373
|
+
description:
|
|
374
|
+
'Get a value from a running cache instance by key. ' +
|
|
375
|
+
'Returns the value (string or JSON) or null if the key does not exist.',
|
|
376
|
+
inputSchema: {
|
|
377
|
+
type: 'object',
|
|
378
|
+
properties: {
|
|
379
|
+
instance_id: { type: 'string', description: 'UUID of the instance' },
|
|
380
|
+
key: { type: 'string', description: 'Cache key to retrieve' },
|
|
381
|
+
},
|
|
382
|
+
required: ['instance_id', 'key'],
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
name: 'cache_set',
|
|
387
|
+
description:
|
|
388
|
+
'Set a key-value pair in a running cache instance. ' +
|
|
389
|
+
'Value can be a string or a JSON-serialized object. Optionally set a TTL in seconds.',
|
|
390
|
+
inputSchema: {
|
|
391
|
+
type: 'object',
|
|
392
|
+
properties: {
|
|
393
|
+
instance_id: { type: 'string' },
|
|
394
|
+
key: { type: 'string', description: 'Cache key' },
|
|
395
|
+
value: { type: 'string', description: 'Value to store (string or JSON)' },
|
|
396
|
+
ttl: { type: 'number', description: 'Time-to-live in seconds (optional, omit for no expiry)' },
|
|
397
|
+
},
|
|
398
|
+
required: ['instance_id', 'key', 'value'],
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
name: 'cache_delete',
|
|
403
|
+
description: 'Delete one or more keys from a running cache instance.',
|
|
404
|
+
inputSchema: {
|
|
405
|
+
type: 'object',
|
|
406
|
+
properties: {
|
|
407
|
+
instance_id: { type: 'string' },
|
|
408
|
+
keys: {
|
|
409
|
+
type: 'array',
|
|
410
|
+
items: { type: 'string' },
|
|
411
|
+
description: 'One or more cache keys to delete',
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
required: ['instance_id', 'keys'],
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
name: 'cache_exists',
|
|
419
|
+
description: 'Check whether one or more keys exist in the cache. Returns a count of existing keys.',
|
|
420
|
+
inputSchema: {
|
|
421
|
+
type: 'object',
|
|
422
|
+
properties: {
|
|
423
|
+
instance_id: { type: 'string' },
|
|
424
|
+
keys: { type: 'array', items: { type: 'string' }, description: 'Keys to check' },
|
|
425
|
+
},
|
|
426
|
+
required: ['instance_id', 'keys'],
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
name: 'cache_ttl',
|
|
431
|
+
description: 'Get the remaining time-to-live (TTL) of a key in seconds. Returns -1 if no TTL, -2 if the key does not exist.',
|
|
432
|
+
inputSchema: {
|
|
433
|
+
type: 'object',
|
|
434
|
+
properties: {
|
|
435
|
+
instance_id: { type: 'string' },
|
|
436
|
+
key: { type: 'string' },
|
|
437
|
+
},
|
|
438
|
+
required: ['instance_id', 'key'],
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
name: 'cache_keys',
|
|
443
|
+
description:
|
|
444
|
+
'List keys in a cache instance matching an optional glob pattern (e.g. "user:*", "session:*"). ' +
|
|
445
|
+
'Uses SCAN to avoid blocking the server. Returns at most `count` keys.',
|
|
446
|
+
inputSchema: {
|
|
447
|
+
type: 'object',
|
|
448
|
+
properties: {
|
|
449
|
+
instance_id: { type: 'string' },
|
|
450
|
+
pattern: { type: 'string', description: 'Glob pattern (default: *)' },
|
|
451
|
+
count: { type: 'number', description: 'Max keys to return (default: 50, max: 500)' },
|
|
452
|
+
},
|
|
453
|
+
required: ['instance_id'],
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
name: 'cache_stats',
|
|
458
|
+
description:
|
|
459
|
+
'Get real-time stats for a cache instance: memory usage, hit/miss rate, commands/sec, ' +
|
|
460
|
+
'connected clients, keyspace info, and uptime.',
|
|
461
|
+
inputSchema: {
|
|
462
|
+
type: 'object',
|
|
463
|
+
properties: {
|
|
464
|
+
instance_id: { type: 'string' },
|
|
465
|
+
},
|
|
466
|
+
required: ['instance_id'],
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
name: 'semantic_search',
|
|
471
|
+
description:
|
|
472
|
+
'Find cached entries that are semantically similar to a natural-language query. ' +
|
|
473
|
+
'Powered by pgvector HNSW index on cachly infrastructure — embeddings never leave Germany. ' +
|
|
474
|
+
'Requires OPENAI_API_KEY (or compatible) and the Speed/Business tier with CACHLY_VECTOR_URL. ' +
|
|
475
|
+
'Example: "find all cached responses about password reset" or "what did we answer about pricing?"',
|
|
476
|
+
inputSchema: {
|
|
477
|
+
type: 'object',
|
|
478
|
+
properties: {
|
|
479
|
+
instance_id: { type: 'string' },
|
|
480
|
+
query: { type: 'string', description: 'Natural-language query to find similar cached content' },
|
|
481
|
+
threshold: {
|
|
482
|
+
type: 'number',
|
|
483
|
+
description: 'Minimum cosine similarity 0–1 (default: 0.82). Lower = broader matches.',
|
|
484
|
+
},
|
|
485
|
+
namespace: {
|
|
486
|
+
type: 'string',
|
|
487
|
+
description: 'Semantic namespace to search in (default: cachly:sem)',
|
|
488
|
+
},
|
|
489
|
+
top_k: {
|
|
490
|
+
type: 'number',
|
|
491
|
+
description: 'Maximum number of results to return (default: 5)',
|
|
492
|
+
},
|
|
493
|
+
use_hybrid: {
|
|
494
|
+
type: 'boolean',
|
|
495
|
+
description:
|
|
496
|
+
'Enable Hybrid BM25+Vector RRF fusion search. ' +
|
|
497
|
+
'Passes `hybrid: true` and the query text to the pgvector API for higher precision on named entities. ' +
|
|
498
|
+
'Default: false.',
|
|
499
|
+
},
|
|
500
|
+
auto_namespace: {
|
|
501
|
+
type: 'boolean',
|
|
502
|
+
description:
|
|
503
|
+
'Auto-detect the namespace from the query text using text heuristics ' +
|
|
504
|
+
'instead of using the `namespace` parameter. ' +
|
|
505
|
+
'Returns results only from the matching domain (code/translation/summary/qa/creative).',
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
required: ['instance_id', 'query'],
|
|
509
|
+
},
|
|
510
|
+
},
|
|
511
|
+
{
|
|
512
|
+
name: 'detect_namespace',
|
|
513
|
+
description:
|
|
514
|
+
'Classify a prompt into one of 5 semantic namespaces using text heuristics. ' +
|
|
515
|
+
'Overhead: <0.1 ms, no embedding required. ' +
|
|
516
|
+
'Useful to understand which namespace cachly will use for a given prompt. ' +
|
|
517
|
+
'Returns one of: cachly:sem:code, cachly:sem:translation, cachly:sem:summary, ' +
|
|
518
|
+
'cachly:sem:qa, cachly:sem:creative.',
|
|
519
|
+
inputSchema: {
|
|
520
|
+
type: 'object',
|
|
521
|
+
properties: {
|
|
522
|
+
prompt: { type: 'string', description: 'The text prompt to classify into a semantic namespace' },
|
|
523
|
+
},
|
|
524
|
+
required: ['prompt'],
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
name: 'cache_warmup',
|
|
529
|
+
description:
|
|
530
|
+
'Pre-warm the semantic cache with a list of prompt/value pairs. ' +
|
|
531
|
+
'For each entry: computes an embedding, checks if a similar entry already exists ' +
|
|
532
|
+
'(similarity ≥ 0.98), and writes new entries to Valkey + pgvector index. ' +
|
|
533
|
+
'Use this to seed FAQ responses, product descriptions, or known-good LLM answers ' +
|
|
534
|
+
'before the first real user traffic. Requires OPENAI_API_KEY.',
|
|
535
|
+
inputSchema: {
|
|
536
|
+
type: 'object',
|
|
537
|
+
properties: {
|
|
538
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
539
|
+
entries: {
|
|
540
|
+
type: 'array',
|
|
541
|
+
description: 'List of prompt/value pairs to pre-warm into the cache',
|
|
542
|
+
items: {
|
|
543
|
+
type: 'object',
|
|
544
|
+
properties: {
|
|
545
|
+
prompt: { type: 'string', description: 'The query or question to cache' },
|
|
546
|
+
value: { type: 'string', description: 'The answer or response to store for this prompt' },
|
|
547
|
+
namespace: { type: 'string', description: 'Optional per-entry namespace override' },
|
|
548
|
+
},
|
|
549
|
+
required: ['prompt', 'value'],
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
namespace: {
|
|
553
|
+
type: 'string',
|
|
554
|
+
description: 'Default namespace for all entries (default: cachly:sem)',
|
|
555
|
+
},
|
|
556
|
+
ttl: {
|
|
557
|
+
type: 'number',
|
|
558
|
+
description: 'Time-to-live in seconds for warmed entries (omit for no expiry)',
|
|
559
|
+
},
|
|
560
|
+
auto_namespace: {
|
|
561
|
+
type: 'boolean',
|
|
562
|
+
description:
|
|
563
|
+
'Auto-detect the namespace per prompt using text heuristics. ' +
|
|
564
|
+
'Overrides `namespace` when no per-entry namespace is set.',
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
required: ['instance_id', 'entries'],
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
name: 'index_project',
|
|
572
|
+
description:
|
|
573
|
+
'Index local source files into the cachly semantic cache so AI assistants can use ' +
|
|
574
|
+
'semantic_search to find relevant files instead of re-reading the whole codebase every time. ' +
|
|
575
|
+
'Walks a directory recursively, reads each matching file, and stores a summary + path ' +
|
|
576
|
+
'as a semantic cache entry (prompt = file path + content excerpt, value = relative path). ' +
|
|
577
|
+
'Requires an embedding provider (OPENAI_API_KEY or CACHLY_EMBED_PROVIDER + key). ' +
|
|
578
|
+
'Run once, then re-run after major refactors. TTL=86400 (24h) keeps entries fresh.',
|
|
579
|
+
inputSchema: {
|
|
580
|
+
type: 'object',
|
|
581
|
+
properties: {
|
|
582
|
+
instance_id: { type: 'string', description: 'UUID of the cachly instance' },
|
|
583
|
+
dir: {
|
|
584
|
+
type: 'string',
|
|
585
|
+
description: 'Absolute path to the directory to index (e.g. /Users/you/myproject/src)',
|
|
586
|
+
},
|
|
587
|
+
extensions: {
|
|
588
|
+
type: 'array',
|
|
589
|
+
items: { type: 'string' },
|
|
590
|
+
description: 'File extensions to include (default: ["ts","js","go","py","java","rs","md","kt","swift"])',
|
|
591
|
+
},
|
|
592
|
+
max_files: {
|
|
593
|
+
type: 'number',
|
|
594
|
+
description: 'Maximum number of files to index (default: 100)',
|
|
595
|
+
},
|
|
596
|
+
ttl: {
|
|
597
|
+
type: 'number',
|
|
598
|
+
description: 'TTL in seconds for indexed entries (default: 86400 = 24 h)',
|
|
599
|
+
},
|
|
600
|
+
summary_chars: {
|
|
601
|
+
type: 'number',
|
|
602
|
+
description: 'Characters to use as summary per file (default: 1200)',
|
|
603
|
+
},
|
|
604
|
+
namespace: {
|
|
605
|
+
type: 'string',
|
|
606
|
+
description: 'Semantic namespace to store under (default: cachly:sem:code)',
|
|
607
|
+
},
|
|
608
|
+
},
|
|
609
|
+
required: ['instance_id', 'dir'],
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
// ── Bulk operations ──────────────────────────────────────────────────────
|
|
613
|
+
{
|
|
614
|
+
name: 'cache_mset',
|
|
615
|
+
description:
|
|
616
|
+
'Set multiple key-value pairs in a single pipeline round-trip. ' +
|
|
617
|
+
'Supports per-key TTL – unlike native MSET. ' +
|
|
618
|
+
'Uses one TCP round-trip for N keys via Redis pipeline.',
|
|
619
|
+
inputSchema: {
|
|
620
|
+
type: 'object',
|
|
621
|
+
properties: {
|
|
622
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
623
|
+
items: {
|
|
624
|
+
type: 'array',
|
|
625
|
+
description: 'Key-value pairs to set',
|
|
626
|
+
items: {
|
|
627
|
+
type: 'object',
|
|
628
|
+
properties: {
|
|
629
|
+
key: { type: 'string', description: 'Cache key' },
|
|
630
|
+
value: { description: 'Value to store (JSON-serialised)' },
|
|
631
|
+
ttl: { type: 'number', description: 'Per-key TTL in seconds (optional)' },
|
|
632
|
+
},
|
|
633
|
+
required: ['key', 'value'],
|
|
634
|
+
},
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
required: ['instance_id', 'items'],
|
|
638
|
+
},
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
name: 'cache_mget',
|
|
642
|
+
description:
|
|
643
|
+
'Retrieve multiple keys in one round-trip using native Redis MGET. ' +
|
|
644
|
+
'Returns values in the same order as the keys array; missing keys are null.',
|
|
645
|
+
inputSchema: {
|
|
646
|
+
type: 'object',
|
|
647
|
+
properties: {
|
|
648
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
649
|
+
keys: { type: 'array', items: { type: 'string' }, description: 'List of keys to fetch' },
|
|
650
|
+
},
|
|
651
|
+
required: ['instance_id', 'keys'],
|
|
652
|
+
},
|
|
653
|
+
},
|
|
654
|
+
// ── Distributed lock ──────────────────────────────────────────────────────
|
|
655
|
+
{
|
|
656
|
+
name: 'cache_lock_acquire',
|
|
657
|
+
description:
|
|
658
|
+
'Acquire a distributed lock using Redis SET NX PX (Redlock-lite). ' +
|
|
659
|
+
'Returns a fencing token on success. The lock auto-expires after ttl_ms to prevent deadlocks. ' +
|
|
660
|
+
'Use cache_lock_release to free the lock early.',
|
|
661
|
+
inputSchema: {
|
|
662
|
+
type: 'object',
|
|
663
|
+
properties: {
|
|
664
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
665
|
+
key: { type: 'string', description: 'Lock resource identifier' },
|
|
666
|
+
ttl_ms: { type: 'number', description: 'Safety TTL in milliseconds (e.g. 5000)' },
|
|
667
|
+
retries: { type: 'number', description: 'Max acquire attempts (default: 3)' },
|
|
668
|
+
retry_delay_ms: { type: 'number', description: 'Milliseconds between retries (default: 50)' },
|
|
669
|
+
},
|
|
670
|
+
required: ['instance_id', 'key', 'ttl_ms'],
|
|
671
|
+
},
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
name: 'cache_lock_release',
|
|
675
|
+
description:
|
|
676
|
+
'Release a previously acquired distributed lock. ' +
|
|
677
|
+
'Uses a Lua script for atomic release – only deletes the key if the fencing token matches.',
|
|
678
|
+
inputSchema: {
|
|
679
|
+
type: 'object',
|
|
680
|
+
properties: {
|
|
681
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
682
|
+
key: { type: 'string', description: 'Lock resource identifier (same as in cache_lock_acquire)' },
|
|
683
|
+
token: { type: 'string', description: 'Fencing token returned by cache_lock_acquire' },
|
|
684
|
+
},
|
|
685
|
+
required: ['instance_id', 'key', 'token'],
|
|
686
|
+
},
|
|
687
|
+
},
|
|
688
|
+
// ── Auth & API-Status ─────────────────────────────────────────────────────
|
|
689
|
+
{
|
|
690
|
+
name: 'get_api_status',
|
|
691
|
+
description:
|
|
692
|
+
'Check the cachly API health and your authentication status. ' +
|
|
693
|
+
'Returns whether the JWT is valid, your user ID (sub claim), ' +
|
|
694
|
+
'token expiry, and the auth provider (keycloak). ' +
|
|
695
|
+
'Use this to debug connection issues or verify your CACHLY_JWT is correct.',
|
|
696
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
697
|
+
},
|
|
698
|
+
// ── Thinking/Context Cache (for AI assistants) ────────────────────────────
|
|
699
|
+
{
|
|
700
|
+
name: 'remember_context',
|
|
701
|
+
description:
|
|
702
|
+
'Save context information to the cache so you can recall it later without re-computing. ' +
|
|
703
|
+
'Perfect for caching: codebase overviews, file summaries, project structure, ' +
|
|
704
|
+
'frequently-accessed data, or "thinking" results like dependency analysis. ' +
|
|
705
|
+
'The AI assistant can use this to avoid re-reading the entire codebase every time. ' +
|
|
706
|
+
'Example: remember_context("project overview", "This is a Next.js app with...") ' +
|
|
707
|
+
'then later: recall_context("project overview")',
|
|
708
|
+
inputSchema: {
|
|
709
|
+
type: 'object',
|
|
710
|
+
properties: {
|
|
711
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
712
|
+
key: { type: 'string', description: 'Descriptive key like "project_overview", "auth_architecture", "file:src/index.ts"' },
|
|
713
|
+
content: { type: 'string', description: 'The context/summary/analysis to remember' },
|
|
714
|
+
category: {
|
|
715
|
+
type: 'string',
|
|
716
|
+
enum: ['overview', 'architecture', 'file_summary', 'dependency', 'thinking', 'custom'],
|
|
717
|
+
description: 'Category for organization (default: custom)',
|
|
718
|
+
},
|
|
719
|
+
ttl: { type: 'number', description: 'Time-to-live in seconds (default: 86400 = 24h, use 0 for no expiry)' },
|
|
720
|
+
},
|
|
721
|
+
required: ['instance_id', 'key', 'content'],
|
|
722
|
+
},
|
|
723
|
+
},
|
|
724
|
+
{
|
|
725
|
+
name: 'recall_context',
|
|
726
|
+
description:
|
|
727
|
+
'Retrieve previously saved context from the cache. ' +
|
|
728
|
+
'Returns the saved content or null if not found. ' +
|
|
729
|
+
'Use this at the START of any task to check if you already have relevant context cached, ' +
|
|
730
|
+
'before doing expensive operations like reading many files. ' +
|
|
731
|
+
'Supports glob patterns: "file:*" matches all file summaries, "arch*" matches architecture-related keys.',
|
|
732
|
+
inputSchema: {
|
|
733
|
+
type: 'object',
|
|
734
|
+
properties: {
|
|
735
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
736
|
+
key: { type: 'string', description: 'The key to look up (supports glob pattern like "file:*")' },
|
|
737
|
+
},
|
|
738
|
+
required: ['instance_id', 'key'],
|
|
739
|
+
},
|
|
740
|
+
},
|
|
741
|
+
{
|
|
742
|
+
name: 'list_remembered',
|
|
743
|
+
description:
|
|
744
|
+
'List all cached context entries for this project. ' +
|
|
745
|
+
'Shows what knowledge the AI assistant has already cached, so you can decide ' +
|
|
746
|
+
'whether to recall existing context or refresh it. ' +
|
|
747
|
+
'Returns: key, category, size, TTL remaining, and a content preview.',
|
|
748
|
+
inputSchema: {
|
|
749
|
+
type: 'object',
|
|
750
|
+
properties: {
|
|
751
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
752
|
+
category: {
|
|
753
|
+
type: 'string',
|
|
754
|
+
enum: ['overview', 'architecture', 'file_summary', 'dependency', 'thinking', 'custom', 'all'],
|
|
755
|
+
description: 'Filter by category (default: all)',
|
|
756
|
+
},
|
|
757
|
+
limit: { type: 'number', description: 'Max entries to return (default: 50)' },
|
|
758
|
+
},
|
|
759
|
+
required: ['instance_id'],
|
|
760
|
+
},
|
|
761
|
+
},
|
|
762
|
+
{
|
|
763
|
+
name: 'forget_context',
|
|
764
|
+
description:
|
|
765
|
+
'Delete one or more cached context entries. ' +
|
|
766
|
+
'Use when context is stale or you want to force a fresh analysis. ' +
|
|
767
|
+
'Supports glob patterns: "file:*" deletes all file summaries.',
|
|
768
|
+
inputSchema: {
|
|
769
|
+
type: 'object',
|
|
770
|
+
properties: {
|
|
771
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
772
|
+
keys: { type: 'array', items: { type: 'string' }, description: 'Keys to delete (supports glob)' },
|
|
773
|
+
},
|
|
774
|
+
required: ['instance_id', 'keys'],
|
|
775
|
+
},
|
|
776
|
+
},
|
|
777
|
+
{
|
|
778
|
+
name: 'learn_from_attempts',
|
|
779
|
+
description:
|
|
780
|
+
'Store a lesson learned from a failed or successful attempt. ' +
|
|
781
|
+
'Call this AFTER completing any non-trivial task (deploy, debug, fix, architecture decision). ' +
|
|
782
|
+
'The lesson will be recalled automatically in future sessions via recall_best_solution. ' +
|
|
783
|
+
'Fields: topic (short slug like "deploy:web"), outcome ("success"|"failure"), ' +
|
|
784
|
+
'what_worked (what solved it), what_failed (what did NOT work), context (extra details). ' +
|
|
785
|
+
'Supports structured metadata: severity, file_paths (files involved), commands (working commands), tags. ' +
|
|
786
|
+
'Deduplication: if a lesson for this topic already exists, it is updated instead of duplicated. ' +
|
|
787
|
+
'Example: learn_from_attempts(topic="deploy:api", outcome="success", what_worked="nohup docker compose up -d --build", what_failed="docker compose up hangs on SSH timeout", severity="critical", commands=["nohup docker compose up -d --build"])',
|
|
788
|
+
inputSchema: {
|
|
789
|
+
type: 'object',
|
|
790
|
+
properties: {
|
|
791
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
792
|
+
topic: { type: 'string', description: 'Short slug, e.g. "deploy:web", "debug:redis-tls", "fix:generate-series"' },
|
|
793
|
+
outcome: { type: 'string', enum: ['success', 'failure', 'partial'], description: 'Did it work?' },
|
|
794
|
+
what_worked: { type: 'string', description: 'What solved the problem or what approach succeeded' },
|
|
795
|
+
what_failed: { type: 'string', description: 'What did NOT work (optional but valuable)' },
|
|
796
|
+
context: { type: 'string', description: 'Additional context, error messages, root cause (optional)' },
|
|
797
|
+
severity: {
|
|
798
|
+
type: 'string',
|
|
799
|
+
enum: ['critical', 'major', 'minor'],
|
|
800
|
+
description: 'Impact severity: critical (blocks work/deploy), major (significant slowdown), minor (nice to know). Default: major.',
|
|
801
|
+
},
|
|
802
|
+
file_paths: {
|
|
803
|
+
type: 'array',
|
|
804
|
+
items: { type: 'string' },
|
|
805
|
+
description: 'Files involved in this lesson (e.g. ["infra/deploy.sh", ".env"])',
|
|
806
|
+
},
|
|
807
|
+
commands: {
|
|
808
|
+
type: 'array',
|
|
809
|
+
items: { type: 'string' },
|
|
810
|
+
description: 'Commands that worked or failed (e.g. ["rsync -avz ...", "docker compose up -d"])',
|
|
811
|
+
},
|
|
812
|
+
tags: {
|
|
813
|
+
type: 'array',
|
|
814
|
+
items: { type: 'string' },
|
|
815
|
+
description: 'Topic tags for filtering (e.g. ["bash", "deploy", "env"])',
|
|
816
|
+
},
|
|
817
|
+
},
|
|
818
|
+
required: ['instance_id', 'topic', 'outcome', 'what_worked'],
|
|
819
|
+
},
|
|
820
|
+
},
|
|
821
|
+
{
|
|
822
|
+
name: 'recall_best_solution',
|
|
823
|
+
description:
|
|
824
|
+
'Recall the best known solution for a topic from past lessons. ' +
|
|
825
|
+
'Call this BEFORE attempting any task that might have been done before. ' +
|
|
826
|
+
'Returns the most recent successful lesson for the topic, or a summary of attempts. ' +
|
|
827
|
+
'Example: recall_best_solution(topic="deploy:web") → returns the working deploy command.',
|
|
828
|
+
inputSchema: {
|
|
829
|
+
type: 'object',
|
|
830
|
+
properties: {
|
|
831
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
832
|
+
topic: { type: 'string', description: 'Topic slug to look up, e.g. "deploy:web". Supports partial match.' },
|
|
833
|
+
},
|
|
834
|
+
required: ['instance_id', 'topic'],
|
|
835
|
+
},
|
|
836
|
+
},
|
|
837
|
+
{
|
|
838
|
+
name: 'smart_recall',
|
|
839
|
+
description:
|
|
840
|
+
'Semantically search cached context using natural language. ' +
|
|
841
|
+
'Instead of exact key matching, finds context by meaning. ' +
|
|
842
|
+
'Example: smart_recall("how does authentication work") → returns cached auth architecture summary. ' +
|
|
843
|
+
'Falls back to remember_context keys if no semantic match is found.',
|
|
844
|
+
inputSchema: {
|
|
845
|
+
type: 'object',
|
|
846
|
+
properties: {
|
|
847
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
848
|
+
query: { type: 'string', description: 'Natural language query to find relevant cached context' },
|
|
849
|
+
threshold: { type: 'number', description: 'Similarity threshold 0-1 (default: 0.78)' },
|
|
850
|
+
},
|
|
851
|
+
required: ['instance_id', 'query'],
|
|
852
|
+
},
|
|
853
|
+
},
|
|
854
|
+
{
|
|
855
|
+
name: 'session_start',
|
|
856
|
+
description:
|
|
857
|
+
'Single-call session briefing. Call this at the START of every session INSTEAD of multiple separate smart_recall/recall_best_solution calls. ' +
|
|
858
|
+
'Returns: last session summary, recent lessons sorted by recency, relevant lessons for your focus area, ' +
|
|
859
|
+
'open failures (topics with only failure outcomes), and brain health stats (lesson count, context count). ' +
|
|
860
|
+
'Also saves a session start marker so session_end can compute duration.',
|
|
861
|
+
inputSchema: {
|
|
862
|
+
type: 'object',
|
|
863
|
+
properties: {
|
|
864
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
865
|
+
focus: {
|
|
866
|
+
type: 'string',
|
|
867
|
+
description: 'Keywords for what you plan to work on today (e.g. "deploy infra api"). Used to surface relevant lessons at the top.',
|
|
868
|
+
},
|
|
869
|
+
},
|
|
870
|
+
required: ['instance_id'],
|
|
871
|
+
},
|
|
872
|
+
},
|
|
873
|
+
{
|
|
874
|
+
name: 'session_end',
|
|
875
|
+
description:
|
|
876
|
+
'Save a session summary when you finish working. ' +
|
|
877
|
+
'Records what was accomplished, files changed, and lesson count. ' +
|
|
878
|
+
'The next session_start will show this summary as "Last session". ' +
|
|
879
|
+
'Call this when ending a work session, before going idle, or before summarizing.',
|
|
880
|
+
inputSchema: {
|
|
881
|
+
type: 'object',
|
|
882
|
+
properties: {
|
|
883
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
884
|
+
summary: {
|
|
885
|
+
type: 'string',
|
|
886
|
+
description: 'Brief summary of what was accomplished this session (2-3 sentences)',
|
|
887
|
+
},
|
|
888
|
+
files_changed: {
|
|
889
|
+
type: 'array',
|
|
890
|
+
items: { type: 'string' },
|
|
891
|
+
description: 'Key files changed this session (optional)',
|
|
892
|
+
},
|
|
893
|
+
lessons_learned: {
|
|
894
|
+
type: 'number',
|
|
895
|
+
description: 'Number of new lessons stored this session (optional)',
|
|
896
|
+
},
|
|
897
|
+
},
|
|
898
|
+
required: ['instance_id', 'summary'],
|
|
899
|
+
},
|
|
900
|
+
},
|
|
901
|
+
// ── AI Brain — Extended features ─────────────────────────────────────────
|
|
902
|
+
{
|
|
903
|
+
name: 'auto_learn_session',
|
|
904
|
+
description:
|
|
905
|
+
'Auto-learn from a list of session observations WITHOUT explicit learn_from_attempts calls. ' +
|
|
906
|
+
'Pass what happened (commands run, errors seen, solutions found) and the brain classifies and stores lessons automatically. ' +
|
|
907
|
+
'Use at session_end to capture everything you did, even if you forgot to call learn_from_attempts. ' +
|
|
908
|
+
'Returns a summary of what was auto-stored.',
|
|
909
|
+
inputSchema: {
|
|
910
|
+
type: 'object',
|
|
911
|
+
properties: {
|
|
912
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
913
|
+
observations: {
|
|
914
|
+
type: 'array',
|
|
915
|
+
description: 'List of observations from this session',
|
|
916
|
+
items: {
|
|
917
|
+
type: 'object',
|
|
918
|
+
properties: {
|
|
919
|
+
action: { type: 'string', description: 'What was tried (command, approach, code change)' },
|
|
920
|
+
outcome: { type: 'string', enum: ['success', 'failure', 'partial'], description: 'Result' },
|
|
921
|
+
details: { type: 'string', description: 'Error message, output, or explanation' },
|
|
922
|
+
topic: { type: 'string', description: 'Optional topic key (auto-generated if omitted)' },
|
|
923
|
+
severity: { type: 'string', enum: ['critical', 'major', 'minor'], description: 'Severity (default: minor)' },
|
|
924
|
+
},
|
|
925
|
+
required: ['action', 'outcome'],
|
|
926
|
+
},
|
|
927
|
+
},
|
|
928
|
+
},
|
|
929
|
+
required: ['instance_id', 'observations'],
|
|
930
|
+
},
|
|
931
|
+
},
|
|
932
|
+
{
|
|
933
|
+
name: 'sync_file_changes',
|
|
934
|
+
description:
|
|
935
|
+
'Associate recent file changes with brain knowledge. ' +
|
|
936
|
+
'Pass a list of changed file paths (from `git diff --stat`). ' +
|
|
937
|
+
'Returns lessons relevant to those files, and records the file changes in session history. ' +
|
|
938
|
+
'Call this after commits so the brain tracks what changed and why.',
|
|
939
|
+
inputSchema: {
|
|
940
|
+
type: 'object',
|
|
941
|
+
properties: {
|
|
942
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
943
|
+
changed_files: { type: 'array', items: { type: 'string' }, description: 'List of changed file paths' },
|
|
944
|
+
git_diff_stat: { type: 'string', description: 'Output of `git diff --stat` (optional)' },
|
|
945
|
+
commit_msg: { type: 'string', description: 'Commit message (optional)' },
|
|
946
|
+
},
|
|
947
|
+
required: ['instance_id', 'changed_files'],
|
|
948
|
+
},
|
|
949
|
+
},
|
|
950
|
+
{
|
|
951
|
+
name: 'team_recall',
|
|
952
|
+
description:
|
|
953
|
+
'Recall lessons from a shared team brain, showing who learned what. ' +
|
|
954
|
+
'Works on any shared instance (all team members using the same instance_id). ' +
|
|
955
|
+
'Shows author, recency, and severity for each lesson. ' +
|
|
956
|
+
'Use this to onboard new team members or find who knows about a topic.',
|
|
957
|
+
inputSchema: {
|
|
958
|
+
type: 'object',
|
|
959
|
+
properties: {
|
|
960
|
+
instance_id: { type: 'string', description: 'UUID of the shared team brain instance' },
|
|
961
|
+
topic: { type: 'string', description: 'Topic or keyword to filter lessons (optional)' },
|
|
962
|
+
author: { type: 'string', description: 'Filter by author name (optional)' },
|
|
963
|
+
limit: { type: 'number', description: 'Max lessons to return (default: 10)' },
|
|
964
|
+
},
|
|
965
|
+
required: ['instance_id'],
|
|
966
|
+
},
|
|
967
|
+
},
|
|
968
|
+
{
|
|
969
|
+
name: 'brain_doctor',
|
|
970
|
+
description:
|
|
971
|
+
'Check the health of your AI Brain and get actionable recommendations. ' +
|
|
972
|
+
'Reports: lesson count, context entries, last session age, open failures, quality score. ' +
|
|
973
|
+
'Returns a prioritized list of issues with fix instructions.',
|
|
974
|
+
inputSchema: {
|
|
975
|
+
type: 'object',
|
|
976
|
+
properties: {
|
|
977
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
978
|
+
},
|
|
979
|
+
required: ['instance_id'],
|
|
980
|
+
},
|
|
981
|
+
},
|
|
982
|
+
{
|
|
983
|
+
name: 'global_learn',
|
|
984
|
+
description:
|
|
985
|
+
'Store a lesson that applies across ALL your projects (cross-project knowledge). ' +
|
|
986
|
+
'Global lessons are stored with the prefix cachly:global:lesson: and recalled from any instance via global_recall. ' +
|
|
987
|
+
'Use for tool preferences, personal workflows, platform quirks, and universal gotchas. ' +
|
|
988
|
+
'Example: global_learn(topic="bash:macos-arrays", lesson="Arrays work differently on macOS bash 3.2")',
|
|
989
|
+
inputSchema: {
|
|
990
|
+
type: 'object',
|
|
991
|
+
properties: {
|
|
992
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance (used for connection)' },
|
|
993
|
+
topic: { type: 'string', description: 'Topic key in format "category:keyword"' },
|
|
994
|
+
lesson: { type: 'string', description: 'The lesson content' },
|
|
995
|
+
severity: { type: 'string', enum: ['critical', 'major', 'minor'], description: 'Severity (default: minor)' },
|
|
996
|
+
tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags' },
|
|
997
|
+
},
|
|
998
|
+
required: ['instance_id', 'topic', 'lesson'],
|
|
999
|
+
},
|
|
1000
|
+
},
|
|
1001
|
+
{
|
|
1002
|
+
name: 'global_recall',
|
|
1003
|
+
description:
|
|
1004
|
+
'Recall cross-project lessons stored via global_learn. ' +
|
|
1005
|
+
'Returns all global lessons or those matching a topic filter.',
|
|
1006
|
+
inputSchema: {
|
|
1007
|
+
type: 'object',
|
|
1008
|
+
properties: {
|
|
1009
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance (used for connection)' },
|
|
1010
|
+
topic: { type: 'string', description: 'Topic or keyword filter (optional)' },
|
|
1011
|
+
},
|
|
1012
|
+
required: ['instance_id'],
|
|
1013
|
+
},
|
|
1014
|
+
},
|
|
1015
|
+
{
|
|
1016
|
+
name: 'publish_lesson',
|
|
1017
|
+
description:
|
|
1018
|
+
'Publish a lesson to the Cachly Public Brain (anonymized community knowledge base). ' +
|
|
1019
|
+
'Published lessons can be imported by other developers via import_public_brain. ' +
|
|
1020
|
+
'PII is stripped automatically. Visible under the framework/category tag.',
|
|
1021
|
+
inputSchema: {
|
|
1022
|
+
type: 'object',
|
|
1023
|
+
properties: {
|
|
1024
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1025
|
+
topic: { type: 'string', description: 'Topic key (used as public category)' },
|
|
1026
|
+
lesson: { type: 'string', description: 'Lesson to publish (PII will be stripped)' },
|
|
1027
|
+
framework: { type: 'string', description: 'Framework/platform tag (nextjs, fastapi, go, docker, etc.)' },
|
|
1028
|
+
severity: { type: 'string', enum: ['critical', 'major', 'minor'], description: 'Severity' },
|
|
1029
|
+
},
|
|
1030
|
+
required: ['instance_id', 'topic', 'lesson'],
|
|
1031
|
+
},
|
|
1032
|
+
},
|
|
1033
|
+
{
|
|
1034
|
+
name: 'import_public_brain',
|
|
1035
|
+
description:
|
|
1036
|
+
'Import community lessons from the Cachly Public Brain for a framework. ' +
|
|
1037
|
+
'Loads battle-tested, community-curated lessons into your brain instance. ' +
|
|
1038
|
+
'Available: nextjs, fastapi, go, docker, kubernetes, react, typescript, python, rust, laravel, rails, spring.',
|
|
1039
|
+
inputSchema: {
|
|
1040
|
+
type: 'object',
|
|
1041
|
+
properties: {
|
|
1042
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance to import into' },
|
|
1043
|
+
framework: { type: 'string', description: 'Framework/platform to import lessons for' },
|
|
1044
|
+
limit: { type: 'number', description: 'Max lessons to import (default: 20)' },
|
|
1045
|
+
},
|
|
1046
|
+
required: ['instance_id', 'framework'],
|
|
1047
|
+
},
|
|
1048
|
+
},
|
|
1049
|
+
// ── Legacy / Setup ────────────────────────────────────────────────────────
|
|
1050
|
+
{
|
|
1051
|
+
name: 'setup_ai_memory',
|
|
1052
|
+
description:
|
|
1053
|
+
'One-shot setup of the cachly 3-layer AI Memory system for a project.\n\n' +
|
|
1054
|
+
'Layer 1 — Storage: your cachly instance (Valkey, persistent across sessions)\n' +
|
|
1055
|
+
'Layer 2 — Tools: learn_from_attempts + recall_best_solution + smart_recall (the memory API)\n' +
|
|
1056
|
+
'Layer 3 — Autopilot: generates a copilot-instructions.md / .github/copilot-instructions.md\n' +
|
|
1057
|
+
' that instructs any MCP-compatible AI to recall known solutions BEFORE each task\n' +
|
|
1058
|
+
' and save lessons AFTER — fully automatic, zero manual effort.\n\n' +
|
|
1059
|
+
'Returns the copilot-instructions.md content + provider-specific .mcp.json snippet.\n' +
|
|
1060
|
+
'Optionally writes copilot-instructions.md directly to the project directory.',
|
|
1061
|
+
inputSchema: {
|
|
1062
|
+
type: 'object',
|
|
1063
|
+
properties: {
|
|
1064
|
+
instance_id: {
|
|
1065
|
+
type: 'string',
|
|
1066
|
+
description: 'UUID of the cachly instance to use as the AI brain',
|
|
1067
|
+
},
|
|
1068
|
+
project_dir: {
|
|
1069
|
+
type: 'string',
|
|
1070
|
+
description:
|
|
1071
|
+
'Absolute path to the project root. If provided, writes copilot-instructions.md ' +
|
|
1072
|
+
'to .github/copilot-instructions.md in that directory.',
|
|
1073
|
+
},
|
|
1074
|
+
embed_provider: {
|
|
1075
|
+
type: 'string',
|
|
1076
|
+
enum: ['openai', 'mistral', 'cohere', 'ollama', 'gemini'],
|
|
1077
|
+
description:
|
|
1078
|
+
'Embedding provider to use for smart_recall / semantic search. ' +
|
|
1079
|
+
'Default: openai. Use ollama for fully local/free setup.',
|
|
1080
|
+
},
|
|
1081
|
+
project_description: {
|
|
1082
|
+
type: 'string',
|
|
1083
|
+
description: 'Short description of the project (used in the generated instructions)',
|
|
1084
|
+
},
|
|
1085
|
+
},
|
|
1086
|
+
required: ['instance_id'],
|
|
1087
|
+
},
|
|
1088
|
+
},
|
|
1089
|
+
{
|
|
1090
|
+
name: 'cache_stream_set',
|
|
1091
|
+
description:
|
|
1092
|
+
'Cache a list of string chunks (e.g. LLM token stream) via Redis RPUSH. ' +
|
|
1093
|
+
'Each chunk is stored as a separate list element under cachly:stream:{key}. ' +
|
|
1094
|
+
'Replay with cache_stream_get.',
|
|
1095
|
+
inputSchema: {
|
|
1096
|
+
type: 'object',
|
|
1097
|
+
properties: {
|
|
1098
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1099
|
+
key: { type: 'string', description: 'Cache key' },
|
|
1100
|
+
chunks: { type: 'array', items: { type: 'string' }, description: 'Ordered list of string chunks' },
|
|
1101
|
+
ttl: { type: 'number', description: 'TTL in seconds for the stored list (optional)' },
|
|
1102
|
+
},
|
|
1103
|
+
required: ['instance_id', 'key', 'chunks'],
|
|
1104
|
+
},
|
|
1105
|
+
},
|
|
1106
|
+
{
|
|
1107
|
+
name: 'cache_stream_get',
|
|
1108
|
+
description:
|
|
1109
|
+
'Retrieve a previously cached stream as an ordered list of string chunks. ' +
|
|
1110
|
+
'Returns null on cache miss (key absent or empty list). ' +
|
|
1111
|
+
'Stored under cachly:stream:{key}.',
|
|
1112
|
+
inputSchema: {
|
|
1113
|
+
type: 'object',
|
|
1114
|
+
properties: {
|
|
1115
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1116
|
+
key: { type: 'string', description: 'Cache key' },
|
|
1117
|
+
},
|
|
1118
|
+
required: ['instance_id', 'key'],
|
|
1119
|
+
},
|
|
1120
|
+
},
|
|
1121
|
+
] as const;
|
|
1122
|
+
|
|
1123
|
+
// ── Handlers ──────────────────────────────────────────────────────────────────
|
|
1124
|
+
|
|
1125
|
+
function formatInstance(inst: Instance): string {
|
|
1126
|
+
const lines = [
|
|
1127
|
+
`**${inst.name}** (${inst.tier.toUpperCase()})`,
|
|
1128
|
+
` ID: ${inst.id}`,
|
|
1129
|
+
` Status: ${inst.status}`,
|
|
1130
|
+
` Region: ${inst.region}`,
|
|
1131
|
+
` Memory: ${inst.memory_mb} MB`,
|
|
1132
|
+
` Enc: ${inst.encryption_at_rest ? 'AES-256 at rest' : 'TLS in-transit'}`,
|
|
1133
|
+
` Created: ${new Date(inst.created_at).toLocaleDateString('de-DE')}`,
|
|
1134
|
+
];
|
|
1135
|
+
if (inst.host && inst.port) lines.push(` Host: ${inst.host}:${inst.port}`);
|
|
1136
|
+
return lines.join('\n');
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function buildConnectionString(inst: Instance): string {
|
|
1140
|
+
if (!inst.host || !inst.port) return '(not yet provisioned)';
|
|
1141
|
+
const scheme = inst.tls_enabled !== false ? 'rediss' : 'redis';
|
|
1142
|
+
const pw = inst.password ? `:${inst.password}@` : '@';
|
|
1143
|
+
return `${scheme}://${pw}${inst.host}:${inst.port}`;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
async function handleTool(name: string, args: Record<string, unknown>): Promise<string> {
|
|
1147
|
+
// Delegate v0.2 bulk/lock/stream tools first
|
|
1148
|
+
const bulkResult = await handleBulkLockStream(name, args);
|
|
1149
|
+
if (bulkResult !== null) return bulkResult;
|
|
1150
|
+
|
|
1151
|
+
switch (name) {
|
|
1152
|
+
// ── Instance management ──────────────────────────────────────────────
|
|
1153
|
+
case 'list_instances': {
|
|
1154
|
+
const res = await apiFetch<{ data: Instance[] }>('/api/v1/instances');
|
|
1155
|
+
const instances = res.data ?? [];
|
|
1156
|
+
if (instances.length === 0)
|
|
1157
|
+
return 'You have no cache instances yet. Use `create_instance` to create one.';
|
|
1158
|
+
return [`Found ${instances.length} instance(s):\n`, ...instances.map(formatInstance)].join('\n');
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
case 'create_instance': {
|
|
1162
|
+
const { name: instName, tier } = args as { name: string; tier: string };
|
|
1163
|
+
const res = await apiFetch<CreateResponse>('/api/v1/instances', {
|
|
1164
|
+
method: 'POST',
|
|
1165
|
+
body: JSON.stringify({ name: instName, tier }),
|
|
1166
|
+
});
|
|
1167
|
+
if (res.checkout_url) {
|
|
1168
|
+
return [
|
|
1169
|
+
`✅ Instance **${instName}** (${tier}) created! ID: \`${res.instance_id}\``,
|
|
1170
|
+
``,
|
|
1171
|
+
`💳 This is a paid tier. Complete payment to activate:`,
|
|
1172
|
+
` ${res.checkout_url}`,
|
|
1173
|
+
``,
|
|
1174
|
+
`After payment, provisioning starts automatically (~30 seconds).`,
|
|
1175
|
+
].join('\n');
|
|
1176
|
+
}
|
|
1177
|
+
return [
|
|
1178
|
+
`✅ Instance **${instName}** (${tier}) created and provisioning started!`,
|
|
1179
|
+
` ID: \`${res.instance_id}\``,
|
|
1180
|
+
` Status: ${res.status}`,
|
|
1181
|
+
``,
|
|
1182
|
+
`Use \`get_instance\` or \`get_connection_string\` to get your connection details.`,
|
|
1183
|
+
].join('\n');
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
case 'get_instance': {
|
|
1187
|
+
const inst = await apiFetch<Instance>(`/api/v1/instances/${(args as { instance_id: string }).instance_id}`);
|
|
1188
|
+
return formatInstance(inst);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
case 'get_connection_string': {
|
|
1192
|
+
const inst = await apiFetch<Instance>(`/api/v1/instances/${(args as { instance_id: string }).instance_id}`);
|
|
1193
|
+
if (inst.status !== 'running') {
|
|
1194
|
+
return `Instance is not running yet (status: ${inst.status}). Provisioning takes ~30 seconds after payment.`;
|
|
1195
|
+
}
|
|
1196
|
+
const connStr = buildConnectionString(inst);
|
|
1197
|
+
return [
|
|
1198
|
+
`**Connection string for ${inst.name}:**`,
|
|
1199
|
+
`\`\`\``,
|
|
1200
|
+
connStr,
|
|
1201
|
+
`\`\`\``,
|
|
1202
|
+
``,
|
|
1203
|
+
`**Environment variable:**`,
|
|
1204
|
+
`\`\`\`bash`,
|
|
1205
|
+
`REDIS_URL="${connStr}"`,
|
|
1206
|
+
`CACHLY_URL="${connStr}"`,
|
|
1207
|
+
`\`\`\``,
|
|
1208
|
+
``,
|
|
1209
|
+
`**Quick test:**`,
|
|
1210
|
+
`\`\`\`bash`,
|
|
1211
|
+
`redis-cli -u "${connStr}" PING`,
|
|
1212
|
+
`\`\`\``,
|
|
1213
|
+
].join('\n');
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
case 'delete_instance': {
|
|
1217
|
+
const { instance_id, confirm } = args as { instance_id: string; confirm: boolean };
|
|
1218
|
+
if (!confirm) return 'Deletion cancelled. Set `confirm: true` to proceed.';
|
|
1219
|
+
pool.get(instance_id)?.quit().catch(() => undefined);
|
|
1220
|
+
pool.delete(instance_id);
|
|
1221
|
+
await apiFetch(`/api/v1/instances/${instance_id}`, { method: 'DELETE' });
|
|
1222
|
+
return `✅ Instance \`${instance_id}\` has been deleted and all data removed.`;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// ── Live cache operations ────────────────────────────────────────────
|
|
1226
|
+
case 'cache_get': {
|
|
1227
|
+
const { instance_id, key } = args as { instance_id: string; key: string };
|
|
1228
|
+
const redis = await getConnection(instance_id);
|
|
1229
|
+
const value = await redis.get(key);
|
|
1230
|
+
if (value === null) return `Key \`${key}\` → **not found** (null)`;
|
|
1231
|
+
let pretty = value;
|
|
1232
|
+
try {
|
|
1233
|
+
pretty = JSON.stringify(JSON.parse(value), null, 2);
|
|
1234
|
+
} catch {
|
|
1235
|
+
// not JSON — return raw
|
|
1236
|
+
}
|
|
1237
|
+
return `Key \`${key}\`:\n\`\`\`\n${pretty}\n\`\`\``;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
case 'cache_set': {
|
|
1241
|
+
const { instance_id, key, value, ttl } = args as {
|
|
1242
|
+
instance_id: string;
|
|
1243
|
+
key: string;
|
|
1244
|
+
value: string;
|
|
1245
|
+
ttl?: number;
|
|
1246
|
+
};
|
|
1247
|
+
const redis = await getConnection(instance_id);
|
|
1248
|
+
if (ttl && ttl > 0) {
|
|
1249
|
+
await redis.set(key, value, 'EX', ttl);
|
|
1250
|
+
return `✅ Set \`${key}\` (TTL: ${ttl}s)`;
|
|
1251
|
+
}
|
|
1252
|
+
await redis.set(key, value);
|
|
1253
|
+
return `✅ Set \`${key}\` (no expiry)`;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
case 'cache_delete': {
|
|
1257
|
+
const { instance_id, keys } = args as { instance_id: string; keys: string[] };
|
|
1258
|
+
const redis = await getConnection(instance_id);
|
|
1259
|
+
const deleted = await redis.del(...keys);
|
|
1260
|
+
return `✅ Deleted **${deleted}** of ${keys.length} key(s): ${keys.map((k) => `\`${k}\``).join(', ')}`;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
case 'cache_exists': {
|
|
1264
|
+
const { instance_id, keys } = args as { instance_id: string; keys: string[] };
|
|
1265
|
+
const redis = await getConnection(instance_id);
|
|
1266
|
+
const count = await redis.exists(...keys);
|
|
1267
|
+
return `**${count}** of ${keys.length} key(s) exist in cache.`;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
case 'cache_ttl': {
|
|
1271
|
+
const { instance_id, key } = args as { instance_id: string; key: string };
|
|
1272
|
+
const redis = await getConnection(instance_id);
|
|
1273
|
+
const ttl = await redis.ttl(key);
|
|
1274
|
+
if (ttl === -2) return `Key \`${key}\` → **does not exist**`;
|
|
1275
|
+
if (ttl === -1) return `Key \`${key}\` → **no expiry** (persists forever)`;
|
|
1276
|
+
const mins = Math.floor(ttl / 60);
|
|
1277
|
+
const secs = ttl % 60;
|
|
1278
|
+
return `Key \`${key}\` → TTL: **${ttl}s** (${mins}m ${secs}s remaining)`;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
case 'cache_keys': {
|
|
1282
|
+
const { instance_id, pattern = '*', count = 50 } = args as {
|
|
1283
|
+
instance_id: string;
|
|
1284
|
+
pattern?: string;
|
|
1285
|
+
count?: number;
|
|
1286
|
+
};
|
|
1287
|
+
const limit = Math.min(count, 500);
|
|
1288
|
+
const redis = await getConnection(instance_id);
|
|
1289
|
+
const keys: string[] = [];
|
|
1290
|
+
const stream = redis.scanStream({ match: pattern, count: 100 });
|
|
1291
|
+
await new Promise<void>((resolve, reject) => {
|
|
1292
|
+
stream.on('data', (batch: string[]) => {
|
|
1293
|
+
keys.push(...batch);
|
|
1294
|
+
if (keys.length >= limit) {
|
|
1295
|
+
stream.destroy();
|
|
1296
|
+
resolve();
|
|
1297
|
+
}
|
|
1298
|
+
});
|
|
1299
|
+
stream.on('end', resolve);
|
|
1300
|
+
stream.on('error', reject);
|
|
1301
|
+
});
|
|
1302
|
+
const result = keys.slice(0, limit);
|
|
1303
|
+
if (result.length === 0) return `No keys found matching pattern \`${pattern}\`.`;
|
|
1304
|
+
return [
|
|
1305
|
+
`Found **${result.length}** key(s) matching \`${pattern}\`:`,
|
|
1306
|
+
...result.map((k) => ` • \`${k}\``),
|
|
1307
|
+
result.length === limit ? `\n_(showing first ${limit} — narrow pattern to see more)_` : '',
|
|
1308
|
+
]
|
|
1309
|
+
.filter(Boolean)
|
|
1310
|
+
.join('\n');
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
case 'cache_stats': {
|
|
1314
|
+
const { instance_id } = args as { instance_id: string };
|
|
1315
|
+
const redis = await getConnection(instance_id);
|
|
1316
|
+
|
|
1317
|
+
const [infoAll, infoStats, infoKeyspace] = await Promise.all([
|
|
1318
|
+
redis.info('memory'),
|
|
1319
|
+
redis.info('stats'),
|
|
1320
|
+
redis.info('keyspace'),
|
|
1321
|
+
]);
|
|
1322
|
+
|
|
1323
|
+
const parse = (section: string, field: string): string =>
|
|
1324
|
+
section.match(new RegExp(`${field}:([^\r\n]+)`))?.[1]?.trim() ?? 'n/a';
|
|
1325
|
+
|
|
1326
|
+
const usedMem = parse(infoAll, 'used_memory_human');
|
|
1327
|
+
const peakMem = parse(infoAll, 'used_memory_peak_human');
|
|
1328
|
+
const hits = parse(infoStats, 'keyspace_hits');
|
|
1329
|
+
const misses = parse(infoStats, 'keyspace_misses');
|
|
1330
|
+
const opsPerSec = parse(infoStats, 'instantaneous_ops_per_sec');
|
|
1331
|
+
const connectedClients = (await redis.info('clients')).match(/connected_clients:(\d+)/)?.[1] ?? 'n/a';
|
|
1332
|
+
|
|
1333
|
+
const hitsN = parseInt(hits) || 0;
|
|
1334
|
+
const missesN = parseInt(misses) || 0;
|
|
1335
|
+
const total = hitsN + missesN;
|
|
1336
|
+
const hitRate = total > 0 ? ((hitsN / total) * 100).toFixed(1) : 'n/a';
|
|
1337
|
+
|
|
1338
|
+
const keyspaceLines = infoKeyspace
|
|
1339
|
+
.split('\n')
|
|
1340
|
+
.filter((l: string) => l.startsWith('db'))
|
|
1341
|
+
.map((l: string) => ` ${l.trim()}`);
|
|
1342
|
+
|
|
1343
|
+
return [
|
|
1344
|
+
`📊 **Cache Stats for instance \`${instance_id}\`:**`,
|
|
1345
|
+
``,
|
|
1346
|
+
` 💾 Memory used: ${usedMem} (peak: ${peakMem})`,
|
|
1347
|
+
` ⚡ Ops/sec: ${opsPerSec}`,
|
|
1348
|
+
` 🎯 Hit rate: ${hitRate}% (${hits} hits / ${misses} misses)`,
|
|
1349
|
+
` 🔗 Clients: ${connectedClients}`,
|
|
1350
|
+
``,
|
|
1351
|
+
keyspaceLines.length > 0
|
|
1352
|
+
? ` 🗂️ Keyspace:\n${keyspaceLines.join('\n')}`
|
|
1353
|
+
: ` 🗂️ Keyspace: (empty)`,
|
|
1354
|
+
].join('\n');
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
case 'semantic_search': {
|
|
1358
|
+
const {
|
|
1359
|
+
instance_id,
|
|
1360
|
+
query,
|
|
1361
|
+
threshold = 0.82,
|
|
1362
|
+
namespace: nsArg = 'cachly:sem',
|
|
1363
|
+
top_k = 5,
|
|
1364
|
+
use_hybrid = false,
|
|
1365
|
+
auto_namespace = false,
|
|
1366
|
+
} = args as {
|
|
1367
|
+
instance_id: string;
|
|
1368
|
+
query: string;
|
|
1369
|
+
threshold?: number;
|
|
1370
|
+
namespace?: string;
|
|
1371
|
+
top_k?: number;
|
|
1372
|
+
use_hybrid?: boolean;
|
|
1373
|
+
auto_namespace?: boolean;
|
|
1374
|
+
};
|
|
1375
|
+
|
|
1376
|
+
// resolve namespace from query text when requested
|
|
1377
|
+
const namespace = auto_namespace ? detectNamespace(query) : nsArg;
|
|
1378
|
+
|
|
1379
|
+
if (!hasEmbedProvider()) {
|
|
1380
|
+
return (
|
|
1381
|
+
`❌ semantic_search requires an embedding provider.\n\n` +
|
|
1382
|
+
`Current: ${embedProviderHint()}\n\n` +
|
|
1383
|
+
`Set one of these in your MCP env config:\n` +
|
|
1384
|
+
` OPENAI_API_KEY (provider: openai – default)\n` +
|
|
1385
|
+
` MISTRAL_API_KEY (provider: mistral)\n` +
|
|
1386
|
+
` COHERE_API_KEY (provider: cohere)\n` +
|
|
1387
|
+
` GEMINI_API_KEY (provider: gemini)\n` +
|
|
1388
|
+
` OLLAMA_BASE_URL (provider: ollama – local, no key needed)\n` +
|
|
1389
|
+
`Also set: CACHLY_EMBED_PROVIDER=<provider>`
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
const inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
|
|
1394
|
+
if (!inst.vector_token) {
|
|
1395
|
+
return (
|
|
1396
|
+
`❌ Semantic search is only available on Speed and Business tiers.\n\n` +
|
|
1397
|
+
`Your instance "${inst.name}" is on the **${inst.tier.toUpperCase()}** tier.\n` +
|
|
1398
|
+
`Upgrade at https://cachly.dev/instances/${instance_id}`
|
|
1399
|
+
);
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// Compute embedding via configured provider
|
|
1403
|
+
const embedding = await computeEmbedding(query);
|
|
1404
|
+
|
|
1405
|
+
// Query cachly vector API
|
|
1406
|
+
const vectorUrl = process.env.CACHLY_VECTOR_URL ?? `https://api.cachly.dev/v1/sem/${inst.vector_token}`;
|
|
1407
|
+
const searchPayload: Record<string, unknown> = { embedding, namespace, threshold, top_k };
|
|
1408
|
+
// hybrid BM25+Vector RRF: include query text when requested.
|
|
1409
|
+
if (use_hybrid) {
|
|
1410
|
+
searchPayload['hybrid'] = true;
|
|
1411
|
+
searchPayload['prompt'] = query;
|
|
1412
|
+
}
|
|
1413
|
+
const searchRes = await fetch(`${vectorUrl}/search`, {
|
|
1414
|
+
method: 'POST',
|
|
1415
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1416
|
+
body: JSON.stringify(searchPayload),
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
if (!searchRes.ok) {
|
|
1420
|
+
throw new McpError(ErrorCode.InternalError, `Vector search failed: ${searchRes.statusText}`);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
const results = (await searchRes.json()) as SemanticSearchResponse[];
|
|
1424
|
+
|
|
1425
|
+
if (!results.length || (results.length === 1 && !results[0].found)) {
|
|
1426
|
+
return (
|
|
1427
|
+
`🔍 No semantically similar entries found for:\n _"${query}"_\n\n` +
|
|
1428
|
+
`Try lowering the threshold (current: ${threshold}) or using different keywords.`
|
|
1429
|
+
);
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
const redis = await getConnection(instance_id);
|
|
1433
|
+
const lines: string[] = [
|
|
1434
|
+
`🔍 **Semantic search results** for: _"${query}"_`,
|
|
1435
|
+
` Threshold: ${threshold} · Namespace: \`${namespace}\``,
|
|
1436
|
+
``,
|
|
1437
|
+
];
|
|
1438
|
+
|
|
1439
|
+
for (const hit of results) {
|
|
1440
|
+
if (!hit.found || !hit.id) continue;
|
|
1441
|
+
const value = await redis.get(`${namespace}:val:${hit.id}`);
|
|
1442
|
+
lines.push(
|
|
1443
|
+
`**Match** (similarity: ${((hit.similarity ?? 0) * 100).toFixed(1)}%)`,
|
|
1444
|
+
` Prompt: _"${hit.prompt ?? '(unknown)'}"_`,
|
|
1445
|
+
value ? ` Value: \`${value.slice(0, 200)}${value.length > 200 ? '…' : ''}\`` : ` Value: _(evicted from cache)_`,
|
|
1446
|
+
``
|
|
1447
|
+
);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
return lines.join('\n');
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// ── Namespace Auto-Detection ──────────────────────────────────────────
|
|
1454
|
+
case 'detect_namespace': {
|
|
1455
|
+
const { prompt } = args as { prompt: string };
|
|
1456
|
+
const ns = detectNamespace(prompt);
|
|
1457
|
+
const typeLabel = ns.split(':').pop()!;
|
|
1458
|
+
const descriptions: Record<string, string> = {
|
|
1459
|
+
code: '💻 Code — contains programming constructs or syntax',
|
|
1460
|
+
translation: '🌐 Translation — asks to translate between languages',
|
|
1461
|
+
summary: '📝 Summary — requests a summary or key points (TL;DR)',
|
|
1462
|
+
qa: '❓ Q&A — a direct question or query',
|
|
1463
|
+
creative: '🎨 Creative — general, creative, or conversational prompt',
|
|
1464
|
+
};
|
|
1465
|
+
return [
|
|
1466
|
+
`**Detected namespace:** \`${ns}\``,
|
|
1467
|
+
`**Type:** ${descriptions[typeLabel] ?? typeLabel}`,
|
|
1468
|
+
``,
|
|
1469
|
+
`_Prompt: "${prompt.slice(0, 120)}${prompt.length > 120 ? '…' : ''}"_`,
|
|
1470
|
+
``,
|
|
1471
|
+
`💡 Use this namespace in \`semantic_search\` or \`cache_warmup\` for better hit rates.`,
|
|
1472
|
+
` Set \`auto_namespace: true\` to apply this detection automatically.`,
|
|
1473
|
+
].join('\n');
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// ── Cache Warmup ───────────────────────────────────────────────────────
|
|
1477
|
+
case 'cache_warmup': {
|
|
1478
|
+
const {
|
|
1479
|
+
instance_id,
|
|
1480
|
+
entries: rawEntries,
|
|
1481
|
+
namespace: nsArg = 'cachly:sem',
|
|
1482
|
+
ttl,
|
|
1483
|
+
auto_namespace = false,
|
|
1484
|
+
} = args as {
|
|
1485
|
+
instance_id: string;
|
|
1486
|
+
entries: Array<{ prompt: string; value: string; namespace?: string }>;
|
|
1487
|
+
namespace?: string;
|
|
1488
|
+
ttl?: number;
|
|
1489
|
+
auto_namespace?: boolean;
|
|
1490
|
+
};
|
|
1491
|
+
|
|
1492
|
+
if (!hasEmbedProvider()) {
|
|
1493
|
+
return (
|
|
1494
|
+
`❌ cache_warmup requires an embedding provider.\n\n` +
|
|
1495
|
+
`Current: ${embedProviderHint()}\n\n` +
|
|
1496
|
+
`Supported: openai (default) · mistral · cohere · ollama (local) · gemini\n` +
|
|
1497
|
+
`Set CACHLY_EMBED_PROVIDER and the matching API key env var.`
|
|
1498
|
+
);
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
const inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
|
|
1502
|
+
const vectorUrl =
|
|
1503
|
+
process.env.CACHLY_VECTOR_URL ??
|
|
1504
|
+
(inst.vector_token ? `https://api.cachly.dev/v1/sem/${inst.vector_token}` : null);
|
|
1505
|
+
|
|
1506
|
+
const redis = await getConnection(instance_id);
|
|
1507
|
+
|
|
1508
|
+
let warmed = 0;
|
|
1509
|
+
let skipped = 0;
|
|
1510
|
+
const details: string[] = [];
|
|
1511
|
+
|
|
1512
|
+
for (const entry of rawEntries) {
|
|
1513
|
+
// resolve namespace per entry
|
|
1514
|
+
const ns = entry.namespace ?? (auto_namespace ? detectNamespace(entry.prompt) : nsArg);
|
|
1515
|
+
|
|
1516
|
+
// Compute embedding for this prompt
|
|
1517
|
+
const embedding = await computeEmbedding(entry.prompt);
|
|
1518
|
+
|
|
1519
|
+
// Check if a very-similar entry already exists (threshold 0.98 → skip to avoid duplicates)
|
|
1520
|
+
let alreadyCached = false;
|
|
1521
|
+
if (vectorUrl) {
|
|
1522
|
+
const checkRes = await fetch(`${vectorUrl}/search`, {
|
|
1523
|
+
method: 'POST',
|
|
1524
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1525
|
+
body: JSON.stringify({ embedding, namespace: ns, threshold: 0.98 }),
|
|
1526
|
+
}).catch(() => null);
|
|
1527
|
+
if (checkRes?.ok) {
|
|
1528
|
+
const results = (await checkRes.json()) as SemanticSearchResponse[];
|
|
1529
|
+
alreadyCached = results[0]?.found ?? false;
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
if (alreadyCached) {
|
|
1534
|
+
skipped++;
|
|
1535
|
+
details.push(` ⏭️ _"${entry.prompt.slice(0, 60)}${entry.prompt.length > 60 ? '…' : ''}"_ → already cached`);
|
|
1536
|
+
continue;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// Write value to Valkey
|
|
1540
|
+
const id = randomUUID();
|
|
1541
|
+
const vk = `${ns}:val:${id}`;
|
|
1542
|
+
if (ttl && ttl > 0) {
|
|
1543
|
+
await redis.set(vk, entry.value, 'EX', ttl);
|
|
1544
|
+
} else {
|
|
1545
|
+
await redis.set(vk, entry.value);
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
if (vectorUrl) {
|
|
1549
|
+
// pgvector path – index embedding in HNSW
|
|
1550
|
+
const body: Record<string, unknown> = { id, prompt: entry.prompt, namespace: ns, embedding };
|
|
1551
|
+
if (ttl && ttl > 0) {
|
|
1552
|
+
body['expires_at'] = new Date(Date.now() + ttl * 1000).toISOString();
|
|
1553
|
+
}
|
|
1554
|
+
await fetch(`${vectorUrl}/entries`, {
|
|
1555
|
+
method: 'POST',
|
|
1556
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1557
|
+
body: JSON.stringify(body),
|
|
1558
|
+
}).catch(() => undefined);
|
|
1559
|
+
} else {
|
|
1560
|
+
// Legacy SCAN path – write emb key to Valkey
|
|
1561
|
+
const embKey = `${ns}:emb:${id}`;
|
|
1562
|
+
const embPayload = JSON.stringify({ embedding, prompt: entry.prompt });
|
|
1563
|
+
if (ttl && ttl > 0) {
|
|
1564
|
+
await redis.set(embKey, embPayload, 'EX', ttl);
|
|
1565
|
+
} else {
|
|
1566
|
+
await redis.set(embKey, embPayload);
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
warmed++;
|
|
1571
|
+
details.push(` ✅ _"${entry.prompt.slice(0, 60)}${entry.prompt.length > 60 ? '…' : ''}"_ → \`${ns}\``);
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
return [
|
|
1575
|
+
`🔥 **Cache Warmup Complete**`,
|
|
1576
|
+
``,
|
|
1577
|
+
` ✅ Warmed: **${warmed}** new entries`,
|
|
1578
|
+
` ⏭️ Skipped: **${skipped}** (already cached at ≥ 0.98 similarity)`,
|
|
1579
|
+
` 📦 Total: ${rawEntries.length}`,
|
|
1580
|
+
auto_namespace
|
|
1581
|
+
? ` 🏷️ Namespacing: auto-detected per prompt`
|
|
1582
|
+
: ` 🏷️ Namespace: \`${nsArg}\``,
|
|
1583
|
+
vectorUrl
|
|
1584
|
+
? ` 🔍 Mode: pgvector HNSW (Speed/Business tier)`
|
|
1585
|
+
: ` 🔍 Mode: Valkey SCAN (upgrade to Speed tier for scalable search)`,
|
|
1586
|
+
``,
|
|
1587
|
+
...details,
|
|
1588
|
+
].join('\n');
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// ── index_project – Codebase Indexing ─────────────────────────────────────
|
|
1592
|
+
case 'index_project': {
|
|
1593
|
+
const {
|
|
1594
|
+
instance_id,
|
|
1595
|
+
dir,
|
|
1596
|
+
extensions: extArg,
|
|
1597
|
+
max_files = 100,
|
|
1598
|
+
ttl = 86400,
|
|
1599
|
+
summary_chars = 1200,
|
|
1600
|
+
namespace: nsArg = 'cachly:sem:code',
|
|
1601
|
+
} = args as {
|
|
1602
|
+
instance_id: string;
|
|
1603
|
+
dir: string;
|
|
1604
|
+
extensions?: string[];
|
|
1605
|
+
max_files?: number;
|
|
1606
|
+
ttl?: number;
|
|
1607
|
+
summary_chars?: number;
|
|
1608
|
+
namespace?: string;
|
|
1609
|
+
};
|
|
1610
|
+
|
|
1611
|
+
const ALLOWED_EXT = new Set(
|
|
1612
|
+
(extArg ?? ['ts', 'js', 'tsx', 'jsx', 'go', 'py', 'java', 'rs', 'md', 'kt', 'swift']).map(
|
|
1613
|
+
(e) => (e.startsWith('.') ? e : `.${e}`),
|
|
1614
|
+
),
|
|
1615
|
+
);
|
|
1616
|
+
|
|
1617
|
+
// Recursively collect files up to max_files limit
|
|
1618
|
+
const files: string[] = [];
|
|
1619
|
+
async function walk(d: string): Promise<void> {
|
|
1620
|
+
if (files.length >= max_files) return;
|
|
1621
|
+
const entries = await readdir(d, { withFileTypes: true }).catch(() => null);
|
|
1622
|
+
if (!entries) return;
|
|
1623
|
+
for (const entry of entries) {
|
|
1624
|
+
if (files.length >= max_files) break;
|
|
1625
|
+
const full = join(d, entry.name as unknown as string);
|
|
1626
|
+
if (entry.isDirectory()) {
|
|
1627
|
+
if (['.git', 'node_modules', 'dist', 'build', '.next', '__pycache__', 'vendor'].includes(entry.name as unknown as string))
|
|
1628
|
+
continue;
|
|
1629
|
+
await walk(full);
|
|
1630
|
+
} else if (entry.isFile() && ALLOWED_EXT.has(extname(entry.name as unknown as string).toLowerCase())) {
|
|
1631
|
+
files.push(full);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
await walk(dir);
|
|
1636
|
+
|
|
1637
|
+
if (files.length === 0) {
|
|
1638
|
+
return `❌ No matching files found in \`${dir}\`.\nExtensions checked: ${[...ALLOWED_EXT].join(', ')}`;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
const inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
|
|
1642
|
+
const vectorUrl =
|
|
1643
|
+
process.env.CACHLY_VECTOR_URL ??
|
|
1644
|
+
(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
|
+
}
|
|
1653
|
+
|
|
1654
|
+
let indexed = 0;
|
|
1655
|
+
let skipped = 0;
|
|
1656
|
+
let errors = 0;
|
|
1657
|
+
const details: string[] = [];
|
|
1658
|
+
const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
|
|
1659
|
+
|
|
1660
|
+
for (const filePath of files) {
|
|
1661
|
+
const relPath = relative(dir, filePath);
|
|
1662
|
+
let content: string;
|
|
1663
|
+
try {
|
|
1664
|
+
const s = await stat(filePath);
|
|
1665
|
+
if (s.size > 200_000) { skipped++; continue; } // skip files >200 KB
|
|
1666
|
+
content = await readFile(filePath, 'utf-8');
|
|
1667
|
+
} catch {
|
|
1668
|
+
errors++;
|
|
1669
|
+
continue;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
// Prompt = relative path + first N chars of content (for semantic matching)
|
|
1673
|
+
const excerpt = content.slice(0, summary_chars).replace(/\s+/g, ' ').trim();
|
|
1674
|
+
const prompt = `File: ${relPath}\n${excerpt}`;
|
|
1675
|
+
// Value = relative path so the AI knows which file to read_file next
|
|
1676
|
+
|
|
1677
|
+
try {
|
|
1678
|
+
const embedding = await computeEmbedding(prompt);
|
|
1679
|
+
const id = randomUUID();
|
|
1680
|
+
|
|
1681
|
+
// Write value to vector index
|
|
1682
|
+
await fetch(`${vectorUrl}/entries`, {
|
|
1683
|
+
method: 'POST',
|
|
1684
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1685
|
+
body: JSON.stringify({ id, prompt, namespace: nsArg, embedding, expires_at: expiresAt }),
|
|
1686
|
+
});
|
|
1687
|
+
|
|
1688
|
+
// Write value to Valkey for retrieval on semantic hit
|
|
1689
|
+
const redis = await getConnection(instance_id);
|
|
1690
|
+
await redis.set(`${nsArg}:val:${id}`, relPath, 'EX', ttl);
|
|
1691
|
+
|
|
1692
|
+
indexed++;
|
|
1693
|
+
details.push(` ✅ ${relPath}`);
|
|
1694
|
+
} catch {
|
|
1695
|
+
errors++;
|
|
1696
|
+
details.push(` ❌ ${relPath} (embedding failed)`);
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
return [
|
|
1701
|
+
`📂 **index_project Complete**`,
|
|
1702
|
+
``,
|
|
1703
|
+
` 📁 Dir: ${dir}`,
|
|
1704
|
+
` ✅ Indexed: **${indexed}** files`,
|
|
1705
|
+
` ⏭️ Skipped: ${skipped} (too large or filtered)`,
|
|
1706
|
+
` ❌ Errors: ${errors}`,
|
|
1707
|
+
` ⏱️ TTL: ${ttl}s (${Math.round(ttl / 3600)}h)`,
|
|
1708
|
+
` 🏷️ Namespace: \`${nsArg}\``,
|
|
1709
|
+
``,
|
|
1710
|
+
`💡 **Next steps:**`,
|
|
1711
|
+
` 1. Add to .github/copilot-instructions.md:`,
|
|
1712
|
+
` "Before reading source files, call semantic_search with the topic."`,
|
|
1713
|
+
` 2. Re-run index_project after major refactors (or set up a cron TTL).`,
|
|
1714
|
+
` 3. Use semantic_search("how does X work?") to find relevant files.`,
|
|
1715
|
+
``,
|
|
1716
|
+
...(details.length <= 20 ? details : [...details.slice(0, 20), ` … and ${details.length - 20} more`]),
|
|
1717
|
+
].join('\n');
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
// ── Thinking/Context Cache Tools ────────────────────────────────────────
|
|
1721
|
+
case 'remember_context': {
|
|
1722
|
+
const {
|
|
1723
|
+
instance_id,
|
|
1724
|
+
key,
|
|
1725
|
+
content,
|
|
1726
|
+
category = 'custom',
|
|
1727
|
+
ttl = 86400,
|
|
1728
|
+
} = args as {
|
|
1729
|
+
instance_id: string;
|
|
1730
|
+
key: string;
|
|
1731
|
+
content: string;
|
|
1732
|
+
category?: string;
|
|
1733
|
+
ttl?: number;
|
|
1734
|
+
};
|
|
1735
|
+
|
|
1736
|
+
const redis = await getConnection(instance_id);
|
|
1737
|
+
const cacheKey = `cachly:ctx:${category}:${key}`;
|
|
1738
|
+
const meta = JSON.stringify({
|
|
1739
|
+
key,
|
|
1740
|
+
category,
|
|
1741
|
+
size: content.length,
|
|
1742
|
+
created: new Date().toISOString(),
|
|
1743
|
+
});
|
|
1744
|
+
|
|
1745
|
+
if (ttl && ttl > 0) {
|
|
1746
|
+
await redis.set(cacheKey, content, 'EX', ttl);
|
|
1747
|
+
await redis.set(`${cacheKey}:meta`, meta, 'EX', ttl);
|
|
1748
|
+
} else {
|
|
1749
|
+
await redis.set(cacheKey, content);
|
|
1750
|
+
await redis.set(`${cacheKey}:meta`, meta);
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
// Also index semantically for smart_recall (if vector available)
|
|
1754
|
+
const inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
|
|
1755
|
+
if (inst.vector_token) {
|
|
1756
|
+
try {
|
|
1757
|
+
const embedding = await computeEmbedding(`${key}: ${content.slice(0, 500)}`);
|
|
1758
|
+
const vectorUrl = `https://api.cachly.dev/v1/sem/${inst.vector_token}`;
|
|
1759
|
+
const body: Record<string, unknown> = {
|
|
1760
|
+
id: `ctx:${category}:${key}`,
|
|
1761
|
+
prompt: key,
|
|
1762
|
+
namespace: 'cachly:ctx',
|
|
1763
|
+
embedding,
|
|
1764
|
+
};
|
|
1765
|
+
if (ttl && ttl > 0) {
|
|
1766
|
+
body['expires_at'] = new Date(Date.now() + ttl * 1000).toISOString();
|
|
1767
|
+
}
|
|
1768
|
+
await fetch(`${vectorUrl}/entries`, {
|
|
1769
|
+
method: 'POST',
|
|
1770
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1771
|
+
body: JSON.stringify(body),
|
|
1772
|
+
}).catch(() => undefined);
|
|
1773
|
+
} catch {
|
|
1774
|
+
// Embedding optional — continue silently
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
return [
|
|
1779
|
+
`🧠 **Context Saved**`,
|
|
1780
|
+
``,
|
|
1781
|
+
` Key: \`${key}\``,
|
|
1782
|
+
` Category: ${category}`,
|
|
1783
|
+
` Size: ${content.length} chars`,
|
|
1784
|
+
` TTL: ${ttl > 0 ? `${ttl}s (${Math.round(ttl / 3600)}h)` : 'no expiry'}`,
|
|
1785
|
+
``,
|
|
1786
|
+
`💡 Use \`recall_context("${key}")\` to retrieve this later.`,
|
|
1787
|
+
` Or \`smart_recall("${key.split('_').join(' ')}")\` for semantic search.`,
|
|
1788
|
+
].join('\n');
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
case 'recall_context': {
|
|
1792
|
+
const { instance_id, key } = args as { instance_id: string; key: string };
|
|
1793
|
+
const redis = await getConnection(instance_id);
|
|
1794
|
+
|
|
1795
|
+
// Check if key is a glob pattern
|
|
1796
|
+
if (key.includes('*')) {
|
|
1797
|
+
const keys: string[] = [];
|
|
1798
|
+
const stream = redis.scanStream({ match: `cachly:ctx:*:${key}`, count: 100 });
|
|
1799
|
+
await new Promise<void>((resolve, reject) => {
|
|
1800
|
+
stream.on('data', (batch: string[]) => {
|
|
1801
|
+
keys.push(...batch.filter((k: string) => !k.endsWith(':meta')));
|
|
1802
|
+
if (keys.length >= 20) { stream.destroy(); resolve(); }
|
|
1803
|
+
});
|
|
1804
|
+
stream.on('end', resolve);
|
|
1805
|
+
stream.on('error', reject);
|
|
1806
|
+
});
|
|
1807
|
+
|
|
1808
|
+
if (keys.length === 0) return `⚠️ No cached context found matching pattern \`${key}\`.`;
|
|
1809
|
+
|
|
1810
|
+
const results: string[] = [`🧠 **Recalled ${keys.length} context entries matching \`${key}\`:**\n`];
|
|
1811
|
+
for (const k of keys.slice(0, 10)) {
|
|
1812
|
+
const content = await redis.get(k);
|
|
1813
|
+
const shortKey = k.replace('cachly:ctx:', '');
|
|
1814
|
+
results.push(`### ${shortKey}\n\`\`\`\n${content?.slice(0, 500)}${(content?.length ?? 0) > 500 ? '…' : ''}\n\`\`\`\n`);
|
|
1815
|
+
}
|
|
1816
|
+
if (keys.length > 10) results.push(`_(+${keys.length - 10} more matches)_`);
|
|
1817
|
+
return results.join('\n');
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
// Try exact match across categories
|
|
1821
|
+
const categories = ['overview', 'architecture', 'file_summary', 'dependency', 'thinking', 'custom'];
|
|
1822
|
+
for (const cat of categories) {
|
|
1823
|
+
const content = await redis.get(`cachly:ctx:${cat}:${key}`);
|
|
1824
|
+
if (content) {
|
|
1825
|
+
const ttl = await redis.ttl(`cachly:ctx:${cat}:${key}`);
|
|
1826
|
+
return [
|
|
1827
|
+
`🧠 **Recalled Context: \`${key}\`**`,
|
|
1828
|
+
``,
|
|
1829
|
+
` Category: ${cat}`,
|
|
1830
|
+
` Size: ${content.length} chars`,
|
|
1831
|
+
` TTL: ${ttl === -1 ? 'no expiry' : ttl === -2 ? 'expired' : `${ttl}s remaining`}`,
|
|
1832
|
+
``,
|
|
1833
|
+
`---`,
|
|
1834
|
+
``,
|
|
1835
|
+
content,
|
|
1836
|
+
].join('\n');
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
return `⚠️ No cached context found for key \`${key}\`.\n\nUse \`list_remembered\` to see available cached context.`;
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
case 'list_remembered': {
|
|
1844
|
+
const {
|
|
1845
|
+
instance_id,
|
|
1846
|
+
category = 'all',
|
|
1847
|
+
limit = 50,
|
|
1848
|
+
} = args as { instance_id: string; category?: string; limit?: number };
|
|
1849
|
+
|
|
1850
|
+
const redis = await getConnection(instance_id);
|
|
1851
|
+
const pattern = category === 'all' ? 'cachly:ctx:*' : `cachly:ctx:${category}:*`;
|
|
1852
|
+
const keys: string[] = [];
|
|
1853
|
+
const stream = redis.scanStream({ match: pattern, count: 100 });
|
|
1854
|
+
await new Promise<void>((resolve, reject) => {
|
|
1855
|
+
stream.on('data', (batch: string[]) => {
|
|
1856
|
+
keys.push(...batch.filter((k: string) => !k.endsWith(':meta')));
|
|
1857
|
+
if (keys.length >= limit) { stream.destroy(); resolve(); }
|
|
1858
|
+
});
|
|
1859
|
+
stream.on('end', resolve);
|
|
1860
|
+
stream.on('error', reject);
|
|
1861
|
+
});
|
|
1862
|
+
|
|
1863
|
+
if (keys.length === 0) {
|
|
1864
|
+
return `📭 No cached context found.\n\nUse \`remember_context\` to cache context for faster future access.`;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
const lines: string[] = [`🧠 **Cached Context** (${keys.length} entries):\n`];
|
|
1868
|
+
for (const k of keys.slice(0, limit)) {
|
|
1869
|
+
const ttl = await redis.ttl(k);
|
|
1870
|
+
const content = await redis.get(k);
|
|
1871
|
+
const parts = k.replace('cachly:ctx:', '').split(':');
|
|
1872
|
+
const cat = parts[0];
|
|
1873
|
+
const key = parts.slice(1).join(':');
|
|
1874
|
+
const preview = content?.slice(0, 80).replace(/\n/g, ' ') ?? '';
|
|
1875
|
+
lines.push(
|
|
1876
|
+
` • **${key}** (${cat})`,
|
|
1877
|
+
` Size: ${content?.length ?? 0} chars · TTL: ${ttl === -1 ? '∞' : `${Math.round(ttl / 60)}m`}`,
|
|
1878
|
+
` _"${preview}${(content?.length ?? 0) > 80 ? '…' : ''}"_`,
|
|
1879
|
+
``
|
|
1880
|
+
);
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
return lines.join('\n');
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
case 'forget_context': {
|
|
1887
|
+
const { instance_id, keys } = args as { instance_id: string; keys: string[] };
|
|
1888
|
+
const redis = await getConnection(instance_id);
|
|
1889
|
+
let deleted = 0;
|
|
1890
|
+
|
|
1891
|
+
for (const key of keys) {
|
|
1892
|
+
if (key.includes('*')) {
|
|
1893
|
+
// Glob delete
|
|
1894
|
+
const toDelete: string[] = [];
|
|
1895
|
+
const stream = redis.scanStream({ match: `cachly:ctx:*:${key}*`, count: 100 });
|
|
1896
|
+
await new Promise<void>((resolve, reject) => {
|
|
1897
|
+
stream.on('data', (batch: string[]) => toDelete.push(...batch));
|
|
1898
|
+
stream.on('end', resolve);
|
|
1899
|
+
stream.on('error', reject);
|
|
1900
|
+
});
|
|
1901
|
+
if (toDelete.length > 0) {
|
|
1902
|
+
deleted += await redis.del(...toDelete);
|
|
1903
|
+
}
|
|
1904
|
+
} else {
|
|
1905
|
+
// Try all categories
|
|
1906
|
+
const categories = ['overview', 'architecture', 'file_summary', 'dependency', 'thinking', 'custom'];
|
|
1907
|
+
for (const cat of categories) {
|
|
1908
|
+
deleted += await redis.del(`cachly:ctx:${cat}:${key}`, `cachly:ctx:${cat}:${key}:meta`);
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
return `🗑️ **Forgot ${deleted} context entries.**\n\nKeys: ${keys.map(k => `\`${k}\``).join(', ')}`;
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
case 'learn_from_attempts': {
|
|
1917
|
+
const {
|
|
1918
|
+
instance_id,
|
|
1919
|
+
topic,
|
|
1920
|
+
outcome,
|
|
1921
|
+
what_worked,
|
|
1922
|
+
what_failed = '',
|
|
1923
|
+
context: ctx = '',
|
|
1924
|
+
severity = 'major',
|
|
1925
|
+
file_paths = [],
|
|
1926
|
+
commands = [],
|
|
1927
|
+
tags = [],
|
|
1928
|
+
} = args as {
|
|
1929
|
+
instance_id: string;
|
|
1930
|
+
topic: string;
|
|
1931
|
+
outcome: 'success' | 'failure' | 'partial';
|
|
1932
|
+
what_worked: string;
|
|
1933
|
+
what_failed?: string;
|
|
1934
|
+
context?: string;
|
|
1935
|
+
severity?: 'critical' | 'major' | 'minor';
|
|
1936
|
+
file_paths?: string[];
|
|
1937
|
+
commands?: string[];
|
|
1938
|
+
tags?: string[];
|
|
1939
|
+
};
|
|
1940
|
+
|
|
1941
|
+
const redis = await getConnection(instance_id);
|
|
1942
|
+
const ts = new Date().toISOString();
|
|
1943
|
+
|
|
1944
|
+
// ── Deduplication: check for existing lesson with same topic ─────────────
|
|
1945
|
+
let isUpdate = false;
|
|
1946
|
+
let recallCount = 0;
|
|
1947
|
+
if (outcome === 'success') {
|
|
1948
|
+
const existing = await redis.get(`cachly:lesson:best:${topic}`);
|
|
1949
|
+
if (existing) {
|
|
1950
|
+
try {
|
|
1951
|
+
const prev = JSON.parse(existing) as { recall_count?: number };
|
|
1952
|
+
recallCount = (prev.recall_count ?? 0); // preserve existing recall count
|
|
1953
|
+
isUpdate = true;
|
|
1954
|
+
} catch { /* ignore parse error */ }
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
const lesson = JSON.stringify({
|
|
1959
|
+
topic,
|
|
1960
|
+
outcome,
|
|
1961
|
+
what_worked,
|
|
1962
|
+
what_failed,
|
|
1963
|
+
context: ctx,
|
|
1964
|
+
severity,
|
|
1965
|
+
file_paths,
|
|
1966
|
+
commands,
|
|
1967
|
+
tags,
|
|
1968
|
+
recall_count: recallCount,
|
|
1969
|
+
ts,
|
|
1970
|
+
version: 2,
|
|
1971
|
+
});
|
|
1972
|
+
|
|
1973
|
+
// Always append to the history list
|
|
1974
|
+
const listKey = `cachly:lessons:${topic}`;
|
|
1975
|
+
await redis.rpush(listKey, lesson);
|
|
1976
|
+
|
|
1977
|
+
// Update best key only for success/partial (not for failures)
|
|
1978
|
+
if (outcome === 'success' || outcome === 'partial') {
|
|
1979
|
+
await redis.set(`cachly:lesson:best:${topic}`, lesson);
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
const emoji = outcome === 'success' ? '✅' : outcome === 'partial' ? '⚠️' : '❌';
|
|
1983
|
+
const sevEmoji = severity === 'critical' ? '🔴' : severity === 'major' ? '🟡' : '🟢';
|
|
1984
|
+
const action = isUpdate ? 'updated' : 'stored';
|
|
1985
|
+
return [
|
|
1986
|
+
`${emoji} **Lesson ${action}:** \`${topic}\` (${outcome}) ${sevEmoji} ${severity}`,
|
|
1987
|
+
``,
|
|
1988
|
+
`**What worked:** ${what_worked}`,
|
|
1989
|
+
what_failed ? `**What failed:** ${what_failed}` : '',
|
|
1990
|
+
ctx ? `**Context:** ${ctx}` : '',
|
|
1991
|
+
file_paths.length > 0 ? `**Files:** ${file_paths.map(f => `\`${f}\``).join(', ')}` : '',
|
|
1992
|
+
commands.length > 0 ? `**Commands:** ${commands.map(c => `\`${c}\``).join(', ')}` : '',
|
|
1993
|
+
tags.length > 0 ? `**Tags:** ${tags.map(t => `#${t}`).join(' ')}` : '',
|
|
1994
|
+
``,
|
|
1995
|
+
isUpdate
|
|
1996
|
+
? `♻️ Updated existing lesson (preserved recall count: ${recallCount})`
|
|
1997
|
+
: `💡 Recall later with \`recall_best_solution(topic="${topic}")\``,
|
|
1998
|
+
].filter(l => l !== '').join('\n');
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
case 'recall_best_solution': {
|
|
2002
|
+
const { instance_id, topic } = args as { instance_id: string; topic: string };
|
|
2003
|
+
const redis = await getConnection(instance_id);
|
|
2004
|
+
|
|
2005
|
+
// Try exact best-solution key first
|
|
2006
|
+
const best = await redis.get(`cachly:lesson:best:${topic}`);
|
|
2007
|
+
if (best) {
|
|
2008
|
+
const lesson = JSON.parse(best) as {
|
|
2009
|
+
topic: string; outcome: string; what_worked: string; what_failed?: string;
|
|
2010
|
+
context?: string; ts: string; severity?: string; file_paths?: string[];
|
|
2011
|
+
commands?: string[]; tags?: string[]; recall_count?: number;
|
|
2012
|
+
};
|
|
2013
|
+
// Increment recall_count
|
|
2014
|
+
const updatedLesson = { ...lesson, recall_count: (lesson.recall_count ?? 0) + 1 };
|
|
2015
|
+
await redis.set(`cachly:lesson:best:${topic}`, JSON.stringify(updatedLesson));
|
|
2016
|
+
|
|
2017
|
+
const sevEmoji = lesson.severity === 'critical' ? '🔴' : lesson.severity === 'major' ? '🟡' : lesson.severity ? '🟢' : '';
|
|
2018
|
+
return [
|
|
2019
|
+
`✅ **Best solution for \`${topic}\`** ${sevEmoji}${lesson.severity ? ` (${lesson.severity})` : ''} · recalled ${updatedLesson.recall_count}× · ${new Date(lesson.ts).toLocaleDateString('de-DE')}`,
|
|
2020
|
+
``,
|
|
2021
|
+
`**What worked:** ${lesson.what_worked}`,
|
|
2022
|
+
lesson.what_failed ? `**What failed (avoid this):** ${lesson.what_failed}` : '',
|
|
2023
|
+
lesson.context ? `**Context:** ${lesson.context}` : '',
|
|
2024
|
+
(lesson.file_paths ?? []).length > 0 ? `**Files:** ${(lesson.file_paths ?? []).map((f: string) => `\`${f}\``).join(', ')}` : '',
|
|
2025
|
+
(lesson.commands ?? []).length > 0 ? `**Commands:** ${(lesson.commands ?? []).map((c: string) => `\`${c}\``).join(', ')}` : '',
|
|
2026
|
+
(lesson.tags ?? []).length > 0 ? `**Tags:** ${(lesson.tags ?? []).map((t: string) => `#${t}`).join(' ')}` : '',
|
|
2027
|
+
].filter(l => l !== '').join('\n');
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
// Partial match: scan all lesson keys for topic substring
|
|
2031
|
+
const allKeys: string[] = [];
|
|
2032
|
+
const scanStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 100 });
|
|
2033
|
+
await new Promise<void>((resolve, reject) => {
|
|
2034
|
+
scanStream.on('data', (batch: string[]) => allKeys.push(...batch));
|
|
2035
|
+
scanStream.on('end', resolve);
|
|
2036
|
+
scanStream.on('error', reject);
|
|
2037
|
+
});
|
|
2038
|
+
|
|
2039
|
+
const matching = allKeys.filter(k => k.toLowerCase().includes(topic.toLowerCase()));
|
|
2040
|
+
if (matching.length === 0) {
|
|
2041
|
+
// Check attempt history as fallback
|
|
2042
|
+
const histKey = `cachly:lessons:${topic}`;
|
|
2043
|
+
const all = await redis.lrange(histKey, -3, -1);
|
|
2044
|
+
if (all.length > 0) {
|
|
2045
|
+
const parsed = all.map(e => JSON.parse(e) as { outcome: string; what_worked: string; ts: string });
|
|
2046
|
+
const lines = parsed.map(p => `- ${p.outcome === 'success' ? '✅' : '❌'} ${p.what_worked.slice(0, 120)} (${new Date(p.ts).toLocaleDateString('de-DE')})`);
|
|
2047
|
+
return `⚠️ No successful solution for \`${topic}\` yet. Last attempts:\n\n${lines.join('\n')}`;
|
|
2048
|
+
}
|
|
2049
|
+
return `📭 No lessons found for \`${topic}\`. Use \`learn_from_attempts\` after solving it.`;
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
// Return all partial matches
|
|
2053
|
+
const results: string[] = [];
|
|
2054
|
+
for (const k of matching.slice(0, 5)) {
|
|
2055
|
+
const raw = await redis.get(k);
|
|
2056
|
+
if (!raw) continue;
|
|
2057
|
+
const lesson = JSON.parse(raw) as { topic: string; what_worked: string; context?: string; ts: string };
|
|
2058
|
+
results.push(`**\`${lesson.topic}\`** — ${lesson.what_worked.slice(0, 200)}`);
|
|
2059
|
+
}
|
|
2060
|
+
return `🔍 **Partial matches for \`${topic}\`:**\n\n${results.join('\n\n')}`;
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
case 'smart_recall': {
|
|
2064
|
+
const {
|
|
2065
|
+
instance_id,
|
|
2066
|
+
query,
|
|
2067
|
+
threshold = 0.78,
|
|
2068
|
+
} = args as { instance_id: string; query: string; threshold?: number };
|
|
2069
|
+
|
|
2070
|
+
const inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
|
|
2071
|
+
if (!inst.vector_token) {
|
|
2072
|
+
// Fallback to listing and keyword matching
|
|
2073
|
+
const redis = await getConnection(instance_id);
|
|
2074
|
+
const keys: string[] = [];
|
|
2075
|
+
const stream = redis.scanStream({ match: 'cachly:ctx:*', count: 100 });
|
|
2076
|
+
await new Promise<void>((resolve, reject) => {
|
|
2077
|
+
stream.on('data', (batch: string[]) => {
|
|
2078
|
+
keys.push(...batch.filter((k: string) => !k.endsWith(':meta')));
|
|
2079
|
+
});
|
|
2080
|
+
stream.on('end', resolve);
|
|
2081
|
+
stream.on('error', reject);
|
|
2082
|
+
});
|
|
2083
|
+
|
|
2084
|
+
// Simple keyword matching
|
|
2085
|
+
const queryWords = query.toLowerCase().split(/\s+/);
|
|
2086
|
+
const matches: string[] = [];
|
|
2087
|
+
for (const k of keys) {
|
|
2088
|
+
const keyLower = k.toLowerCase();
|
|
2089
|
+
if (queryWords.some(w => keyLower.includes(w))) {
|
|
2090
|
+
const content = await redis.get(k);
|
|
2091
|
+
matches.push(`**${k.replace('cachly:ctx:', '')}**:\n${content?.slice(0, 300)}…\n`);
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
if (matches.length === 0) return `⚠️ No matching context found for: "${query}"\n\nTip: Upgrade to Speed tier for semantic search.`;
|
|
2096
|
+
return `🔍 **Keyword-matched context:**\n\n${matches.join('\n')}`;
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
// Semantic search
|
|
2100
|
+
const embedding = await computeEmbedding(query);
|
|
2101
|
+
const vectorUrl = `https://api.cachly.dev/v1/sem/${inst.vector_token}`;
|
|
2102
|
+
const searchRes = await fetch(`${vectorUrl}/search`, {
|
|
2103
|
+
method: 'POST',
|
|
2104
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2105
|
+
body: JSON.stringify({ embedding, namespace: 'cachly:ctx', threshold, top_k: 5 }),
|
|
2106
|
+
});
|
|
2107
|
+
|
|
2108
|
+
if (!searchRes.ok) {
|
|
2109
|
+
return `❌ Semantic search failed: ${searchRes.statusText}`;
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
const results = (await searchRes.json()) as SemanticSearchResponse[];
|
|
2113
|
+
if (!results.length || !results[0].found) {
|
|
2114
|
+
return `⚠️ No semantically similar context found for: "${query}"\n\nTry lowering the threshold (current: ${threshold}) or use \`list_remembered\` to see available context.`;
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
const redis = await getConnection(instance_id);
|
|
2118
|
+
const lines: string[] = [`🧠 **Smart Recall Results** for: _"${query}"_\n`];
|
|
2119
|
+
|
|
2120
|
+
for (const hit of results) {
|
|
2121
|
+
if (!hit.found || !hit.id) continue;
|
|
2122
|
+
// id format: ctx:category:key
|
|
2123
|
+
const parts = hit.id.replace('ctx:', '').split(':');
|
|
2124
|
+
const category = parts[0];
|
|
2125
|
+
const key = parts.slice(1).join(':');
|
|
2126
|
+
const content = await redis.get(`cachly:ctx:${category}:${key}`);
|
|
2127
|
+
lines.push(
|
|
2128
|
+
`### ${key} (${((hit.similarity ?? 0) * 100).toFixed(0)}% match)`,
|
|
2129
|
+
`_Category: ${category}_`,
|
|
2130
|
+
`\`\`\``,
|
|
2131
|
+
content?.slice(0, 500) ?? '(content evicted)',
|
|
2132
|
+
`${(content?.length ?? 0) > 500 ? '…' : ''}`,
|
|
2133
|
+
`\`\`\``,
|
|
2134
|
+
``
|
|
2135
|
+
);
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
return lines.join('\n');
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
// ── get_api_status ────────────────────────────────────────────────────────
|
|
2142
|
+
case 'get_api_status': {
|
|
2143
|
+
// Check health
|
|
2144
|
+
let healthStatus = 'unknown';
|
|
2145
|
+
try {
|
|
2146
|
+
const healthRes = await fetch(`${API_URL}/health`);
|
|
2147
|
+
if (healthRes.ok) {
|
|
2148
|
+
const body = await healthRes.json() as { status?: string; db?: string };
|
|
2149
|
+
healthStatus = `${body.status ?? 'ok'} (db: ${body.db ?? '?'})`;
|
|
2150
|
+
} else {
|
|
2151
|
+
healthStatus = `HTTP ${healthRes.status}`;
|
|
2152
|
+
}
|
|
2153
|
+
} catch (e) {
|
|
2154
|
+
healthStatus = `unreachable: ${(e as Error).message}`;
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
// Check JWT / auth
|
|
2158
|
+
if (!JWT) {
|
|
2159
|
+
return [
|
|
2160
|
+
`📡 **cachly API Status**`,
|
|
2161
|
+
``,
|
|
2162
|
+
` 🌐 API: ${API_URL}`,
|
|
2163
|
+
` 💓 Health: ${healthStatus}`,
|
|
2164
|
+
` 🔑 Auth: ❌ CACHLY_JWT not set`,
|
|
2165
|
+
``,
|
|
2166
|
+
`💡 Get your API token at https://cachly.dev/instances → Settings → API Token`,
|
|
2167
|
+
].join('\n');
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
// Decode JWT claims (inspection only, no verification)
|
|
2171
|
+
let authInfo = '❌ invalid JWT format';
|
|
2172
|
+
try {
|
|
2173
|
+
const parts = JWT.split('.');
|
|
2174
|
+
if (parts.length === 3) {
|
|
2175
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')) as {
|
|
2176
|
+
sub?: string; exp?: number; iss?: string;
|
|
2177
|
+
};
|
|
2178
|
+
const sub = payload.sub ?? '(unknown)';
|
|
2179
|
+
const iss = payload.iss ?? '(unknown)';
|
|
2180
|
+
const provider = iss.includes('keycloak') ? 'Keycloak' : 'OIDC';
|
|
2181
|
+
const expTs = payload.exp ? new Date(payload.exp * 1000) : null;
|
|
2182
|
+
const expired = expTs ? expTs < new Date() : false;
|
|
2183
|
+
authInfo = [
|
|
2184
|
+
`✅ JWT decoded`,
|
|
2185
|
+
` Sub (user ID): ${sub}`,
|
|
2186
|
+
` Provider: ${provider}`,
|
|
2187
|
+
` Issuer: ${iss}`,
|
|
2188
|
+
` Expires: ${expTs ? expTs.toISOString() : 'never'} ${expired ? '⚠️ EXPIRED – get a new token!' : '✅'}`,
|
|
2189
|
+
].join('\n');
|
|
2190
|
+
}
|
|
2191
|
+
} catch {
|
|
2192
|
+
authInfo = '❌ JWT decode failed – check CACHLY_JWT format';
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
return [
|
|
2196
|
+
`📡 **cachly API Status**`,
|
|
2197
|
+
``,
|
|
2198
|
+
` 🌐 API: ${API_URL}`,
|
|
2199
|
+
` 💓 Health: ${healthStatus}`,
|
|
2200
|
+
``,
|
|
2201
|
+
`🔑 **Auth:**`,
|
|
2202
|
+
authInfo,
|
|
2203
|
+
].join('\n');
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
// ── session_start ─────────────────────────────────────────────────────────
|
|
2207
|
+
case 'session_start': {
|
|
2208
|
+
const { instance_id, focus = '' } = args as { instance_id: string; focus?: string };
|
|
2209
|
+
const redis = await getConnection(instance_id);
|
|
2210
|
+
|
|
2211
|
+
// 1. Scan all best-solution lessons
|
|
2212
|
+
const lessonKeys: string[] = [];
|
|
2213
|
+
const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
|
|
2214
|
+
await new Promise<void>((resolve, reject) => {
|
|
2215
|
+
lStream.on('data', (batch: string[]) => lessonKeys.push(...batch));
|
|
2216
|
+
lStream.on('end', resolve);
|
|
2217
|
+
lStream.on('error', reject);
|
|
2218
|
+
});
|
|
2219
|
+
|
|
2220
|
+
// 2. Fetch all lesson values for recency sorting + focus matching
|
|
2221
|
+
type Lesson = {
|
|
2222
|
+
topic: string; outcome: string; what_worked: string; what_failed?: string;
|
|
2223
|
+
ts: string; severity?: string; recall_count?: number; tags?: string[];
|
|
2224
|
+
};
|
|
2225
|
+
const lessons: Lesson[] = [];
|
|
2226
|
+
for (const k of lessonKeys) {
|
|
2227
|
+
const raw = await redis.get(k);
|
|
2228
|
+
if (!raw) continue;
|
|
2229
|
+
try { lessons.push(JSON.parse(raw) as Lesson); } catch { /* skip corrupt */ }
|
|
2230
|
+
}
|
|
2231
|
+
lessons.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
|
|
2232
|
+
|
|
2233
|
+
// 3. Count context entries (filter :meta keys)
|
|
2234
|
+
let ctxCount = 0;
|
|
2235
|
+
const ctxStream = redis.scanStream({ match: 'cachly:ctx:*', count: 200 });
|
|
2236
|
+
await new Promise<void>((resolve, reject) => {
|
|
2237
|
+
ctxStream.on('data', (batch: string[]) => {
|
|
2238
|
+
ctxCount += batch.filter((k: string) => !k.endsWith(':meta')).length;
|
|
2239
|
+
});
|
|
2240
|
+
ctxStream.on('end', resolve);
|
|
2241
|
+
ctxStream.on('error', reject);
|
|
2242
|
+
});
|
|
2243
|
+
|
|
2244
|
+
// 4. Last session
|
|
2245
|
+
const lastSessionRaw = await redis.get('cachly:session:last');
|
|
2246
|
+
let lastSession: { summary: string; ts: string; files_changed?: string[]; duration_min?: number } | null = null;
|
|
2247
|
+
if (lastSessionRaw) {
|
|
2248
|
+
try { lastSession = JSON.parse(lastSessionRaw); } catch { /* ignore */ }
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
// 5. Focus filtering
|
|
2252
|
+
const focusTerms = focus.toLowerCase().split(/\s+/).filter(Boolean);
|
|
2253
|
+
const focusLessons = focusTerms.length > 0
|
|
2254
|
+
? lessons.filter(l =>
|
|
2255
|
+
focusTerms.some(term =>
|
|
2256
|
+
l.topic.toLowerCase().includes(term) ||
|
|
2257
|
+
(l.tags ?? []).some((t: string) => t.toLowerCase().includes(term))
|
|
2258
|
+
)
|
|
2259
|
+
)
|
|
2260
|
+
: [];
|
|
2261
|
+
|
|
2262
|
+
// 6. Save session start marker
|
|
2263
|
+
await redis.set('cachly:session:current', JSON.stringify({
|
|
2264
|
+
started: new Date().toISOString(),
|
|
2265
|
+
focus,
|
|
2266
|
+
}), 'EX', 86400); // auto-expire after 24h if session_end never called
|
|
2267
|
+
|
|
2268
|
+
// ── Build briefing ──────────────────────────────────────────────────────
|
|
2269
|
+
const lines: string[] = ['🧠 **Session Briefing**', ''];
|
|
2270
|
+
|
|
2271
|
+
// Last session
|
|
2272
|
+
if (lastSession) {
|
|
2273
|
+
const ago = Math.round((Date.now() - new Date(lastSession.ts).getTime()) / 60000);
|
|
2274
|
+
const agoStr = ago < 60 ? `${ago}m ago` : ago < 1440 ? `${Math.round(ago / 60)}h ago` : `${Math.round(ago / 1440)}d ago`;
|
|
2275
|
+
lines.push(`📅 **Last session** (${agoStr}): ${lastSession.summary}`);
|
|
2276
|
+
if (lastSession.duration_min) lines.push(` Duration: ${lastSession.duration_min} min`);
|
|
2277
|
+
if ((lastSession.files_changed ?? []).length > 0) {
|
|
2278
|
+
lines.push(` Files: ${(lastSession.files_changed ?? []).slice(0, 5).map((f: string) => `\`${f}\``).join(', ')}`);
|
|
2279
|
+
}
|
|
2280
|
+
lines.push('');
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
// Brain health
|
|
2284
|
+
lines.push(`📊 **Brain:** ${lessons.length} lessons · ${ctxCount} context entries`, '');
|
|
2285
|
+
|
|
2286
|
+
// Focus-relevant lessons
|
|
2287
|
+
if (focusLessons.length > 0) {
|
|
2288
|
+
lines.push(`🎯 **Relevant for "${focus}":**`);
|
|
2289
|
+
for (const l of focusLessons.slice(0, 4)) {
|
|
2290
|
+
const emoji = l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
|
|
2291
|
+
const sev = l.severity === 'critical' ? '🔴' : l.severity === 'major' ? '🟡' : '';
|
|
2292
|
+
lines.push(` ${emoji}${sev} \`${l.topic}\` — ${l.what_worked.slice(0, 100)}`);
|
|
2293
|
+
}
|
|
2294
|
+
lines.push('');
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
// Recent lessons
|
|
2298
|
+
if (lessons.length > 0) {
|
|
2299
|
+
lines.push(`🕐 **Recent lessons:**`);
|
|
2300
|
+
const toShow = focusLessons.length > 0 ? lessons.filter(l => !focusLessons.includes(l)).slice(0, 4) : lessons.slice(0, 5);
|
|
2301
|
+
for (const l of toShow) {
|
|
2302
|
+
const emoji = l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
|
|
2303
|
+
const sev = l.severity === 'critical' ? '🔴' : l.severity === 'major' ? '🟡' : '';
|
|
2304
|
+
lines.push(` ${emoji}${sev} \`${l.topic}\` — ${l.what_worked.slice(0, 90)}`);
|
|
2305
|
+
}
|
|
2306
|
+
lines.push('');
|
|
2307
|
+
} else {
|
|
2308
|
+
lines.push('📭 No lessons yet. Use `learn_from_attempts` after solving tasks.', '');
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
// Open failures (lessons whose best-key has outcome != success)
|
|
2312
|
+
const openFailures = lessons.filter(l => l.outcome === 'failure' || l.outcome === 'partial');
|
|
2313
|
+
if (openFailures.length > 0) {
|
|
2314
|
+
lines.push(`⚠️ **Unresolved** (${openFailures.length} topic${openFailures.length > 1 ? 's' : ''} with no success yet):`);
|
|
2315
|
+
for (const l of openFailures.slice(0, 3)) {
|
|
2316
|
+
lines.push(` ❌ \`${l.topic}\` — ${(l.what_failed ?? l.what_worked).slice(0, 80)}`);
|
|
2317
|
+
}
|
|
2318
|
+
lines.push('');
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
return lines.join('\n');
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
// ── session_end ───────────────────────────────────────────────────────────
|
|
2325
|
+
case 'session_end': {
|
|
2326
|
+
const {
|
|
2327
|
+
instance_id,
|
|
2328
|
+
summary,
|
|
2329
|
+
files_changed = [],
|
|
2330
|
+
lessons_learned,
|
|
2331
|
+
} = args as {
|
|
2332
|
+
instance_id: string;
|
|
2333
|
+
summary: string;
|
|
2334
|
+
files_changed?: string[];
|
|
2335
|
+
lessons_learned?: number;
|
|
2336
|
+
};
|
|
2337
|
+
|
|
2338
|
+
const redis = await getConnection(instance_id);
|
|
2339
|
+
const now = new Date();
|
|
2340
|
+
|
|
2341
|
+
// Calculate duration from session_start marker
|
|
2342
|
+
let durationMin: number | undefined;
|
|
2343
|
+
const currentRaw = await redis.get('cachly:session:current');
|
|
2344
|
+
if (currentRaw) {
|
|
2345
|
+
try {
|
|
2346
|
+
const current = JSON.parse(currentRaw) as { started: string };
|
|
2347
|
+
durationMin = Math.round((now.getTime() - new Date(current.started).getTime()) / 60000);
|
|
2348
|
+
} catch { /* ignore */ }
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
const sessionRecord = {
|
|
2352
|
+
ts: now.toISOString(),
|
|
2353
|
+
summary,
|
|
2354
|
+
files_changed,
|
|
2355
|
+
...(lessons_learned !== undefined ? { lessons_learned } : {}),
|
|
2356
|
+
...(durationMin !== undefined ? { duration_min: durationMin } : {}),
|
|
2357
|
+
};
|
|
2358
|
+
|
|
2359
|
+
// Save as "last session"
|
|
2360
|
+
await redis.set('cachly:session:last', JSON.stringify(sessionRecord));
|
|
2361
|
+
|
|
2362
|
+
// Append to history list (keep last 50 sessions, TTL 90 days)
|
|
2363
|
+
await redis.lpush('cachly:session:history', JSON.stringify(sessionRecord));
|
|
2364
|
+
await redis.ltrim('cachly:session:history', 0, 49);
|
|
2365
|
+
await redis.expire('cachly:session:history', 90 * 86400);
|
|
2366
|
+
|
|
2367
|
+
// Clean up current session marker
|
|
2368
|
+
await redis.del('cachly:session:current');
|
|
2369
|
+
|
|
2370
|
+
const durationStr = durationMin !== undefined ? ` · ${durationMin} min` : '';
|
|
2371
|
+
return [
|
|
2372
|
+
`✅ **Session saved**${durationStr}`,
|
|
2373
|
+
``,
|
|
2374
|
+
`📋 **Summary:** ${summary}`,
|
|
2375
|
+
files_changed.length > 0 ? `📁 **Files changed:** ${files_changed.map(f => `\`${f}\``).join(', ')}` : '',
|
|
2376
|
+
lessons_learned !== undefined ? `🧠 **Lessons stored:** ${lessons_learned}` : '',
|
|
2377
|
+
``,
|
|
2378
|
+
`💡 Next session: \`session_start(focus="...")\` to see this summary.`,
|
|
2379
|
+
].filter(l => l !== '').join('\n');
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
// ── auto_learn_session ────────────────────────────────────────────────────
|
|
2383
|
+
case 'auto_learn_session': {
|
|
2384
|
+
const { instance_id, observations } = args as {
|
|
2385
|
+
instance_id: string;
|
|
2386
|
+
observations: { action: string; outcome: string; details?: string; topic?: string; severity?: string }[];
|
|
2387
|
+
};
|
|
2388
|
+
const redis = await getConnection(instance_id);
|
|
2389
|
+
const stored: string[] = [];
|
|
2390
|
+
const skipped: string[] = [];
|
|
2391
|
+
|
|
2392
|
+
for (const obs of observations) {
|
|
2393
|
+
// Auto-generate topic from action if not provided
|
|
2394
|
+
const rawTopic = obs.topic ?? obs.action
|
|
2395
|
+
.toLowerCase()
|
|
2396
|
+
.replace(/[^a-z0-9:\-_\s]/g, '')
|
|
2397
|
+
.trim()
|
|
2398
|
+
.split(/\s+/)
|
|
2399
|
+
.slice(0, 4)
|
|
2400
|
+
.join('-');
|
|
2401
|
+
const topic = rawTopic.includes(':') ? rawTopic : `auto:${rawTopic}`;
|
|
2402
|
+
const key = `cachly:lesson:best:${topic}`;
|
|
2403
|
+
|
|
2404
|
+
// Only overwrite if this is a success and existing is failure, or topic is new
|
|
2405
|
+
const existing = await redis.get(key);
|
|
2406
|
+
if (existing) {
|
|
2407
|
+
const existingLesson = JSON.parse(existing) as { outcome: string };
|
|
2408
|
+
if (existingLesson.outcome === 'success' && obs.outcome !== 'success') {
|
|
2409
|
+
skipped.push(topic);
|
|
2410
|
+
continue;
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
const lesson = {
|
|
2415
|
+
topic,
|
|
2416
|
+
outcome: obs.outcome,
|
|
2417
|
+
what_worked: obs.outcome === 'success' ? obs.action : (obs.details ?? obs.action),
|
|
2418
|
+
what_failed: obs.outcome === 'failure' ? obs.action : undefined,
|
|
2419
|
+
context: obs.details,
|
|
2420
|
+
severity: obs.severity ?? 'minor',
|
|
2421
|
+
ts: new Date().toISOString(),
|
|
2422
|
+
recall_count: 0,
|
|
2423
|
+
auto_learned: true,
|
|
2424
|
+
version: 2,
|
|
2425
|
+
};
|
|
2426
|
+
|
|
2427
|
+
await redis.set(key, JSON.stringify(lesson));
|
|
2428
|
+
stored.push(`${obs.outcome === 'success' ? '✅' : obs.outcome === 'partial' ? '⚠️' : '❌'} \`${topic}\``);
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
const lines = [
|
|
2432
|
+
`🤖 **Auto-learn complete**: ${stored.length} stored, ${skipped.length} skipped`,
|
|
2433
|
+
'',
|
|
2434
|
+
];
|
|
2435
|
+
if (stored.length > 0) lines.push('**Stored:**', ...stored.map(s => ' ' + s), '');
|
|
2436
|
+
if (skipped.length > 0) lines.push(`**Skipped** (better lesson already exists): ${skipped.map(t => `\`${t}\``).join(', ')}`);
|
|
2437
|
+
return lines.join('\n');
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
// ── sync_file_changes ─────────────────────────────────────────────────────
|
|
2441
|
+
case 'sync_file_changes': {
|
|
2442
|
+
const { instance_id, changed_files, git_diff_stat, commit_msg } = args as {
|
|
2443
|
+
instance_id: string;
|
|
2444
|
+
changed_files: string[];
|
|
2445
|
+
git_diff_stat?: string;
|
|
2446
|
+
commit_msg?: string;
|
|
2447
|
+
};
|
|
2448
|
+
const redis = await getConnection(instance_id);
|
|
2449
|
+
|
|
2450
|
+
// Store file change event in session history
|
|
2451
|
+
const changeRecord = {
|
|
2452
|
+
ts: new Date().toISOString(),
|
|
2453
|
+
files: changed_files,
|
|
2454
|
+
commit_msg,
|
|
2455
|
+
diff_stat: git_diff_stat?.slice(0, 500),
|
|
2456
|
+
};
|
|
2457
|
+
await redis.lpush('cachly:session:file_changes', JSON.stringify(changeRecord));
|
|
2458
|
+
await redis.ltrim('cachly:session:file_changes', 0, 99);
|
|
2459
|
+
await redis.expire('cachly:session:file_changes', 30 * 86400);
|
|
2460
|
+
|
|
2461
|
+
// Find lessons relevant to the changed files
|
|
2462
|
+
const lessonKeys: string[] = [];
|
|
2463
|
+
const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
|
|
2464
|
+
await new Promise<void>((resolve, reject) => {
|
|
2465
|
+
lStream.on('data', (batch: string[]) => lessonKeys.push(...batch));
|
|
2466
|
+
lStream.on('end', resolve);
|
|
2467
|
+
lStream.on('error', reject);
|
|
2468
|
+
});
|
|
2469
|
+
|
|
2470
|
+
type Lesson = { topic: string; what_worked: string; outcome: string; file_paths?: string[] };
|
|
2471
|
+
const relevant: string[] = [];
|
|
2472
|
+
for (const k of lessonKeys) {
|
|
2473
|
+
const raw = await redis.get(k);
|
|
2474
|
+
if (!raw) continue;
|
|
2475
|
+
const lesson = JSON.parse(raw) as Lesson;
|
|
2476
|
+
// Match by file_paths stored in lesson OR by topic keywords matching file name
|
|
2477
|
+
const topicWords = lesson.topic.toLowerCase().split(/[:\-_]/);
|
|
2478
|
+
const fileMatches = changed_files.some(f => {
|
|
2479
|
+
const fname = f.split('/').pop()?.replace(/\.[^.]+$/, '').toLowerCase() ?? '';
|
|
2480
|
+
return topicWords.some(w => w.length > 3 && fname.includes(w))
|
|
2481
|
+
|| (lesson.file_paths ?? []).some(lf => f.includes(lf) || lf.includes(f));
|
|
2482
|
+
});
|
|
2483
|
+
if (fileMatches) {
|
|
2484
|
+
const emoji = lesson.outcome === 'success' ? '✅' : '⚠️';
|
|
2485
|
+
relevant.push(` ${emoji} \`${lesson.topic}\` — ${lesson.what_worked.slice(0, 80)}`);
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
const lines = [
|
|
2490
|
+
`📁 **File sync recorded**: ${changed_files.length} files`,
|
|
2491
|
+
commit_msg ? `📝 Commit: "${commit_msg}"` : '',
|
|
2492
|
+
'',
|
|
2493
|
+
`**Changed:** ${changed_files.slice(0, 8).map(f => `\`${f}\``).join(', ')}${changed_files.length > 8 ? ` +${changed_files.length - 8} more` : ''}`,
|
|
2494
|
+
'',
|
|
2495
|
+
];
|
|
2496
|
+
if (relevant.length > 0) {
|
|
2497
|
+
lines.push(`🧠 **Relevant brain lessons (${relevant.length}):**`, ...relevant);
|
|
2498
|
+
} else {
|
|
2499
|
+
lines.push(`💡 No existing lessons match these files yet. Add them with \`learn_from_attempts\`.`);
|
|
2500
|
+
}
|
|
2501
|
+
return lines.filter(Boolean).join('\n');
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
// ── team_recall ───────────────────────────────────────────────────────────
|
|
2505
|
+
case 'team_recall': {
|
|
2506
|
+
const { instance_id, topic, author, limit = 10 } = args as {
|
|
2507
|
+
instance_id: string;
|
|
2508
|
+
topic?: string;
|
|
2509
|
+
author?: string;
|
|
2510
|
+
limit?: number;
|
|
2511
|
+
};
|
|
2512
|
+
const redis = await getConnection(instance_id);
|
|
2513
|
+
|
|
2514
|
+
const lessonKeys: string[] = [];
|
|
2515
|
+
const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
|
|
2516
|
+
await new Promise<void>((resolve, reject) => {
|
|
2517
|
+
lStream.on('data', (batch: string[]) => lessonKeys.push(...batch));
|
|
2518
|
+
lStream.on('end', resolve);
|
|
2519
|
+
lStream.on('error', reject);
|
|
2520
|
+
});
|
|
2521
|
+
|
|
2522
|
+
type TeamLesson = {
|
|
2523
|
+
topic: string; outcome: string; what_worked: string;
|
|
2524
|
+
ts: string; severity?: string; recall_count?: number;
|
|
2525
|
+
author?: string; tags?: string[];
|
|
2526
|
+
};
|
|
2527
|
+
let lessons: TeamLesson[] = [];
|
|
2528
|
+
for (const k of lessonKeys) {
|
|
2529
|
+
const raw = await redis.get(k);
|
|
2530
|
+
if (!raw) continue;
|
|
2531
|
+
try { lessons.push(JSON.parse(raw) as TeamLesson); } catch { /* skip */ }
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
// Filter
|
|
2535
|
+
if (topic) {
|
|
2536
|
+
const t = topic.toLowerCase();
|
|
2537
|
+
lessons = lessons.filter(l =>
|
|
2538
|
+
l.topic.toLowerCase().includes(t) ||
|
|
2539
|
+
(l.tags ?? []).some((tag: string) => tag.toLowerCase().includes(t))
|
|
2540
|
+
);
|
|
2541
|
+
}
|
|
2542
|
+
if (author) {
|
|
2543
|
+
const a = author.toLowerCase();
|
|
2544
|
+
lessons = lessons.filter(l => l.author?.toLowerCase().includes(a));
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
// Sort by recall_count desc
|
|
2548
|
+
lessons.sort((a, b) => (b.recall_count ?? 0) - (a.recall_count ?? 0));
|
|
2549
|
+
lessons = lessons.slice(0, limit);
|
|
2550
|
+
|
|
2551
|
+
if (lessons.length === 0) {
|
|
2552
|
+
return topic
|
|
2553
|
+
? `📭 No team lessons found for \`${topic}\`.\n\nShared instance: add lessons with \`learn_from_attempts\` and include an \`author\` field.`
|
|
2554
|
+
: `📭 No lessons in this brain yet.\n\nAll team members sharing this instance will see lessons here.`;
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
const lines = [`👥 **Team Brain** — ${lessons.length} lesson${lessons.length > 1 ? 's' : ''}`, ''];
|
|
2558
|
+
for (const l of lessons) {
|
|
2559
|
+
const emoji = l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
|
|
2560
|
+
const sev = l.severity === 'critical' ? '🔴 ' : l.severity === 'major' ? '🟡 ' : '';
|
|
2561
|
+
const authorStr = l.author ? ` · _by ${l.author}_` : '';
|
|
2562
|
+
const recallStr = (l.recall_count ?? 0) > 0 ? ` · recalled ${l.recall_count}×` : '';
|
|
2563
|
+
const ago = Math.round((Date.now() - new Date(l.ts).getTime()) / 86400000);
|
|
2564
|
+
const agoStr = ago === 0 ? 'today' : ago === 1 ? 'yesterday' : `${ago}d ago`;
|
|
2565
|
+
lines.push(`${emoji} ${sev}**\`${l.topic}\`**${authorStr}${recallStr} · ${agoStr}`);
|
|
2566
|
+
lines.push(` ${l.what_worked.slice(0, 120)}`);
|
|
2567
|
+
lines.push('');
|
|
2568
|
+
}
|
|
2569
|
+
return lines.join('\n');
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
// ── brain_doctor ──────────────────────────────────────────────────────────
|
|
2573
|
+
case 'brain_doctor': {
|
|
2574
|
+
const { instance_id } = args as { instance_id: string };
|
|
2575
|
+
const redis = await getConnection(instance_id);
|
|
2576
|
+
const issues: string[] = [];
|
|
2577
|
+
const checks: string[] = [];
|
|
2578
|
+
|
|
2579
|
+
// Count lessons
|
|
2580
|
+
const lessonKeys: string[] = [];
|
|
2581
|
+
const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
|
|
2582
|
+
await new Promise<void>((resolve, reject) => {
|
|
2583
|
+
lStream.on('data', (batch: string[]) => lessonKeys.push(...batch));
|
|
2584
|
+
lStream.on('end', resolve);
|
|
2585
|
+
lStream.on('error', reject);
|
|
2586
|
+
});
|
|
2587
|
+
|
|
2588
|
+
// Count context
|
|
2589
|
+
let ctxCount = 0;
|
|
2590
|
+
const ctxStream = redis.scanStream({ match: 'cachly:ctx:*', count: 200 });
|
|
2591
|
+
await new Promise<void>((resolve, reject) => {
|
|
2592
|
+
ctxStream.on('data', (batch: string[]) => {
|
|
2593
|
+
ctxCount += batch.filter((k: string) => !k.endsWith(':meta')).length;
|
|
2594
|
+
});
|
|
2595
|
+
ctxStream.on('end', resolve);
|
|
2596
|
+
ctxStream.on('error', reject);
|
|
2597
|
+
});
|
|
2598
|
+
|
|
2599
|
+
// Load lessons for analysis
|
|
2600
|
+
type Lesson = { topic: string; outcome: string; recall_count?: number; ts: string; severity?: string };
|
|
2601
|
+
const lessons: Lesson[] = [];
|
|
2602
|
+
for (const k of lessonKeys) {
|
|
2603
|
+
const raw = await redis.get(k);
|
|
2604
|
+
if (!raw) continue;
|
|
2605
|
+
try { lessons.push(JSON.parse(raw) as Lesson); } catch { /* skip */ }
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
// Last session
|
|
2609
|
+
const lastSessionRaw = await redis.get('cachly:session:last');
|
|
2610
|
+
let lastSession: { ts: string; summary: string } | null = null;
|
|
2611
|
+
if (lastSessionRaw) {
|
|
2612
|
+
try { lastSession = JSON.parse(lastSessionRaw); } catch { /* ignore */ }
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
// Open failures
|
|
2616
|
+
const openFailures = lessons.filter(l => l.outcome === 'failure' || l.outcome === 'partial');
|
|
2617
|
+
// Unused lessons (never recalled)
|
|
2618
|
+
const unusedLessons = lessons.filter(l => (l.recall_count ?? 0) === 0);
|
|
2619
|
+
// Critical lessons
|
|
2620
|
+
const criticalLessons = lessons.filter(l => l.severity === 'critical');
|
|
2621
|
+
|
|
2622
|
+
// Quality score (0-100)
|
|
2623
|
+
let score = 50;
|
|
2624
|
+
if (lessonKeys.length >= 5) score += 10;
|
|
2625
|
+
if (lessonKeys.length >= 20) score += 10;
|
|
2626
|
+
if (ctxCount >= 3) score += 10;
|
|
2627
|
+
if (ctxCount >= 10) score += 5;
|
|
2628
|
+
if (lastSession) score += 10;
|
|
2629
|
+
if (openFailures.length === 0) score += 5;
|
|
2630
|
+
const unusedRatio = lessons.length > 0 ? unusedLessons.length / lessons.length : 0;
|
|
2631
|
+
if (unusedRatio < 0.5) score += 10;
|
|
2632
|
+
|
|
2633
|
+
const scoreEmoji = score >= 80 ? '🟢' : score >= 50 ? '🟡' : '🔴';
|
|
2634
|
+
|
|
2635
|
+
checks.push(`${scoreEmoji} **Brain Quality Score: ${score}/100**`);
|
|
2636
|
+
checks.push(`📚 **Lessons:** ${lessonKeys.length} (${criticalLessons.length} critical)`);
|
|
2637
|
+
checks.push(`💾 **Context entries:** ${ctxCount}`);
|
|
2638
|
+
|
|
2639
|
+
if (lastSession) {
|
|
2640
|
+
const ageMin = Math.round((Date.now() - new Date(lastSession.ts).getTime()) / 60000);
|
|
2641
|
+
const ageStr = ageMin < 60 ? `${ageMin}m` : ageMin < 1440 ? `${Math.round(ageMin / 60)}h` : `${Math.round(ageMin / 1440)}d`;
|
|
2642
|
+
checks.push(`🕐 **Last session:** ${ageStr} ago`);
|
|
2643
|
+
} else {
|
|
2644
|
+
issues.push('❌ No session history — call `session_start` + `session_end` to start tracking');
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
if (lessonKeys.length === 0) {
|
|
2648
|
+
issues.push('❌ No lessons — call `learn_from_attempts` after solving bugs');
|
|
2649
|
+
} else if (lessonKeys.length < 5) {
|
|
2650
|
+
issues.push(`💡 Only ${lessonKeys.length} lessons — add more after each problem solved`);
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
if (ctxCount === 0) {
|
|
2654
|
+
issues.push('💡 No context — use `remember_context` to cache architecture docs, ADRs, etc.');
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
if (openFailures.length > 0) {
|
|
2658
|
+
issues.push(`⚠️ ${openFailures.length} unresolved failure${openFailures.length > 1 ? 's' : ''}: ${openFailures.slice(0, 3).map(l => `\`${l.topic}\``).join(', ')}`);
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
if (unusedRatio > 0.7 && lessons.length > 5) {
|
|
2662
|
+
issues.push(`💡 ${unusedLessons.length} lessons never recalled — verify topics match your workflow`);
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
const lines = ['🩺 **Brain Doctor Report**', '', ...checks.map(c => ' ' + c), ''];
|
|
2666
|
+
if (issues.length > 0) {
|
|
2667
|
+
lines.push('**Issues to fix:**');
|
|
2668
|
+
for (const i of issues) lines.push(' ' + i);
|
|
2669
|
+
lines.push('');
|
|
2670
|
+
} else {
|
|
2671
|
+
lines.push(' 🎉 Brain looks healthy! Keep calling session_start/session_end.');
|
|
2672
|
+
}
|
|
2673
|
+
return lines.join('\n');
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
// ── global_learn ──────────────────────────────────────────────────────────
|
|
2677
|
+
case 'global_learn': {
|
|
2678
|
+
const { instance_id, topic, lesson, severity = 'minor', tags = [] } = args as {
|
|
2679
|
+
instance_id: string;
|
|
2680
|
+
topic: string;
|
|
2681
|
+
lesson: string;
|
|
2682
|
+
severity?: string;
|
|
2683
|
+
tags?: string[];
|
|
2684
|
+
};
|
|
2685
|
+
const redis = await getConnection(instance_id);
|
|
2686
|
+
const key = `cachly:global:lesson:${topic}`;
|
|
2687
|
+
const record = {
|
|
2688
|
+
topic,
|
|
2689
|
+
lesson,
|
|
2690
|
+
severity,
|
|
2691
|
+
tags,
|
|
2692
|
+
ts: new Date().toISOString(),
|
|
2693
|
+
scope: 'global',
|
|
2694
|
+
recall_count: 0,
|
|
2695
|
+
};
|
|
2696
|
+
// Preserve recall_count on update
|
|
2697
|
+
const existing = await redis.get(key);
|
|
2698
|
+
if (existing) {
|
|
2699
|
+
const prev = JSON.parse(existing) as { recall_count?: number };
|
|
2700
|
+
record.recall_count = prev.recall_count ?? 0;
|
|
2701
|
+
}
|
|
2702
|
+
await redis.set(key, JSON.stringify(record));
|
|
2703
|
+
return `🌐 **Global lesson stored**: \`${topic}\`\n\n${lesson}\n\nRecallable from any project via \`global_recall(topic="${topic}")\`.`;
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
// ── global_recall ─────────────────────────────────────────────────────────
|
|
2707
|
+
case 'global_recall': {
|
|
2708
|
+
const { instance_id, topic } = args as { instance_id: string; topic?: string };
|
|
2709
|
+
const redis = await getConnection(instance_id);
|
|
2710
|
+
const keys: string[] = [];
|
|
2711
|
+
const gStream = redis.scanStream({ match: 'cachly:global:lesson:*', count: 200 });
|
|
2712
|
+
await new Promise<void>((resolve, reject) => {
|
|
2713
|
+
gStream.on('data', (batch: string[]) => keys.push(...batch));
|
|
2714
|
+
gStream.on('end', resolve);
|
|
2715
|
+
gStream.on('error', reject);
|
|
2716
|
+
});
|
|
2717
|
+
|
|
2718
|
+
type GlobalLesson = { topic: string; lesson: string; severity?: string; ts: string; recall_count?: number };
|
|
2719
|
+
let lessons: GlobalLesson[] = [];
|
|
2720
|
+
for (const k of keys) {
|
|
2721
|
+
const raw = await redis.get(k);
|
|
2722
|
+
if (!raw) continue;
|
|
2723
|
+
try { lessons.push(JSON.parse(raw) as GlobalLesson); } catch { /* skip */ }
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
if (topic) {
|
|
2727
|
+
const t = topic.toLowerCase();
|
|
2728
|
+
lessons = lessons.filter(l => l.topic.toLowerCase().includes(t));
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
if (lessons.length === 0) {
|
|
2732
|
+
return `📭 No global lessons${topic ? ` for \`${topic}\`` : ''}.\n\nAdd cross-project knowledge with \`global_learn(topic="...", lesson="...")\`.`;
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
// Increment recall_count
|
|
2736
|
+
for (const l of lessons) {
|
|
2737
|
+
const k = `cachly:global:lesson:${l.topic}`;
|
|
2738
|
+
const raw = await redis.get(k);
|
|
2739
|
+
if (raw) {
|
|
2740
|
+
const rec = JSON.parse(raw) as { recall_count?: number };
|
|
2741
|
+
rec.recall_count = (rec.recall_count ?? 0) + 1;
|
|
2742
|
+
await redis.set(k, JSON.stringify(rec));
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
const lines = [`🌐 **Global Brain** — ${lessons.length} lesson${lessons.length > 1 ? 's' : ''}`, ''];
|
|
2747
|
+
for (const l of lessons) {
|
|
2748
|
+
const sev = l.severity === 'critical' ? '🔴 ' : l.severity === 'major' ? '🟡 ' : '';
|
|
2749
|
+
lines.push(`${sev}**\`${l.topic}\`**`);
|
|
2750
|
+
lines.push(l.lesson.slice(0, 200));
|
|
2751
|
+
lines.push('');
|
|
2752
|
+
}
|
|
2753
|
+
return lines.join('\n');
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
// ── publish_lesson ────────────────────────────────────────────────────────
|
|
2757
|
+
case 'publish_lesson': {
|
|
2758
|
+
const { instance_id, topic, lesson, framework = 'general', severity = 'minor' } = args as {
|
|
2759
|
+
instance_id: string;
|
|
2760
|
+
topic: string;
|
|
2761
|
+
lesson: string;
|
|
2762
|
+
framework?: string;
|
|
2763
|
+
severity?: string;
|
|
2764
|
+
};
|
|
2765
|
+
const redis = await getConnection(instance_id);
|
|
2766
|
+
|
|
2767
|
+
// Strip potential PII patterns (emails, tokens, paths)
|
|
2768
|
+
const sanitized = lesson
|
|
2769
|
+
.replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, '[email]')
|
|
2770
|
+
.replace(/\b(sk-|cky_live_|Bearer\s)[A-Za-z0-9_\-]{8,}/g, '[token]')
|
|
2771
|
+
.replace(/\/Users\/[^\s/]+/g, '/Users/[user]')
|
|
2772
|
+
.replace(/\/home\/[^\s/]+/g, '/home/[user]');
|
|
2773
|
+
|
|
2774
|
+
const publicLesson = {
|
|
2775
|
+
topic,
|
|
2776
|
+
lesson: sanitized,
|
|
2777
|
+
framework,
|
|
2778
|
+
severity,
|
|
2779
|
+
ts: new Date().toISOString(),
|
|
2780
|
+
published: true,
|
|
2781
|
+
votes: 0,
|
|
2782
|
+
};
|
|
2783
|
+
|
|
2784
|
+
// Store locally with public flag (future: sync to Cachly public API)
|
|
2785
|
+
const key = `cachly:public:lesson:${framework}:${topic}`;
|
|
2786
|
+
await redis.set(key, JSON.stringify(publicLesson), 'EX', 365 * 86400);
|
|
2787
|
+
|
|
2788
|
+
return [
|
|
2789
|
+
`📢 **Lesson published!**`,
|
|
2790
|
+
``,
|
|
2791
|
+
`**Topic:** \`${topic}\``,
|
|
2792
|
+
`**Framework:** ${framework}`,
|
|
2793
|
+
`**Content:** ${sanitized.slice(0, 200)}${sanitized.length > 200 ? '…' : ''}`,
|
|
2794
|
+
``,
|
|
2795
|
+
`This lesson is now available in the Public Brain for other developers.`,
|
|
2796
|
+
`Import it anywhere: \`import_public_brain(framework="${framework}")\``,
|
|
2797
|
+
].join('\n');
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
// ── import_public_brain ───────────────────────────────────────────────────
|
|
2801
|
+
case 'import_public_brain': {
|
|
2802
|
+
const { instance_id, framework, limit = 20 } = args as {
|
|
2803
|
+
instance_id: string;
|
|
2804
|
+
framework: string;
|
|
2805
|
+
limit?: number;
|
|
2806
|
+
};
|
|
2807
|
+
const redis = await getConnection(instance_id);
|
|
2808
|
+
|
|
2809
|
+
// Community-curated lessons per framework
|
|
2810
|
+
const COMMUNITY_LESSONS: Record<string, Array<{ topic: string; lesson: string; severity: string }>> = {
|
|
2811
|
+
nextjs: [
|
|
2812
|
+
{ topic: 'nextjs:image-layout', lesson: 'Use fill + relative parent instead of layout="fill" (deprecated since Next.js 13)', severity: 'major' },
|
|
2813
|
+
{ topic: 'nextjs:app-router-fetch', lesson: 'fetch() in Server Components is cached by default — add {cache:"no-store"} for dynamic data', severity: 'major' },
|
|
2814
|
+
{ topic: 'nextjs:metadata-export', lesson: 'Export metadata const or generateMetadata() — never both in same file', severity: 'minor' },
|
|
2815
|
+
{ topic: 'nextjs:client-boundary', lesson: '"use client" propagates down — keep it at the lowest component, not at page level', severity: 'major' },
|
|
2816
|
+
{ topic: 'nextjs:env-prefix', lesson: 'Only NEXT_PUBLIC_* env vars are exposed to client — others are server-only', severity: 'critical' },
|
|
2817
|
+
{ topic: 'nextjs:revalidate', lesson: 'export const revalidate = 0 disables caching for entire route; use revalidatePath() for on-demand', severity: 'minor' },
|
|
2818
|
+
],
|
|
2819
|
+
fastapi: [
|
|
2820
|
+
{ topic: 'fastapi:async-db', lesson: 'Use async session with asyncpg — sync SQLAlchemy blocks the event loop', severity: 'critical' },
|
|
2821
|
+
{ topic: 'fastapi:pydantic-v2', lesson: 'Pydantic v2: use model_validate() instead of parse_obj(), .dict() → .model_dump()', severity: 'major' },
|
|
2822
|
+
{ topic: 'fastapi:lifespan', lesson: 'Use lifespan context manager instead of deprecated on_event startup/shutdown', severity: 'minor' },
|
|
2823
|
+
{ topic: 'fastapi:background-tasks', lesson: 'BackgroundTasks run after response is sent — not in a separate thread pool', severity: 'major' },
|
|
2824
|
+
{ topic: 'fastapi:cors-order', lesson: 'CORSMiddleware must be added before other middleware to work correctly', severity: 'critical' },
|
|
2825
|
+
],
|
|
2826
|
+
go: [
|
|
2827
|
+
{ topic: 'go:context-cancel', lesson: 'Always call cancel() from context.WithCancel — leak goroutines if not cancelled', severity: 'critical' },
|
|
2828
|
+
{ topic: 'go:defer-in-loop', lesson: 'defer in a loop runs at function return, not loop iteration — use IIFE or explicit close', severity: 'major' },
|
|
2829
|
+
{ topic: 'go:nil-interface', lesson: 'nil interface != interface containing nil pointer — use explicit nil checks', severity: 'major' },
|
|
2830
|
+
{ topic: 'go:goroutine-leak', lesson: 'Goroutines with channel sends block forever if receiver is gone — use select with done chan', severity: 'critical' },
|
|
2831
|
+
{ topic: 'go:embed-path', lesson: '//go:embed path must be relative and known at compile time — no os.Getenv', severity: 'minor' },
|
|
2832
|
+
],
|
|
2833
|
+
docker: [
|
|
2834
|
+
{ topic: 'docker:layer-cache', lesson: 'Copy package.json before source code — Docker caches layers, npm install only reruns on dep changes', severity: 'major' },
|
|
2835
|
+
{ topic: 'docker:non-root', lesson: 'Run as non-root user (USER 1001) — some k8s clusters reject root containers by policy', severity: 'critical' },
|
|
2836
|
+
{ topic: 'docker:build-arg-secret', lesson: 'Never use ARG for secrets — visible in image history. Use --secret mount instead', severity: 'critical' },
|
|
2837
|
+
{ topic: 'docker:entrypoint-exec', lesson: 'Use exec form ["cmd","arg"] not shell form "cmd arg" — shell form ignores SIGTERM', severity: 'major' },
|
|
2838
|
+
{ topic: 'docker:multi-stage', lesson: 'Multi-stage builds: copy only built artifacts to final stage — keep image small', severity: 'minor' },
|
|
2839
|
+
],
|
|
2840
|
+
kubernetes: [
|
|
2841
|
+
{ topic: 'k8s:resource-limits', lesson: 'Always set resource limits — unbounded pods cause node evictions and OOMKill', severity: 'critical' },
|
|
2842
|
+
{ topic: 'k8s:liveness-vs-readiness', lesson: 'Liveness failures restart pod; Readiness failures remove from LB. Use different endpoints', severity: 'major' },
|
|
2843
|
+
{ topic: 'k8s:imagepullpolicy', lesson: 'imagePullPolicy: Always in production — IfNotPresent can serve stale images', severity: 'major' },
|
|
2844
|
+
{ topic: 'k8s:configmap-env', lesson: 'ConfigMap changes don\'t restart pods — use rollout restart or mount as volume', severity: 'critical' },
|
|
2845
|
+
{ topic: 'k8s:pdb', lesson: 'Set PodDisruptionBudget for stateful apps — node drains kill all pods without it', severity: 'major' },
|
|
2846
|
+
],
|
|
2847
|
+
react: [
|
|
2848
|
+
{ topic: 'react:useeffect-deps', lesson: 'Omitting dependencies from useEffect deps array causes stale closure bugs — use exhaustive-deps ESLint rule', severity: 'critical' },
|
|
2849
|
+
{ topic: 'react:key-index', lesson: 'Never use array index as key in lists — causes subtle re-render bugs on reorder/delete', severity: 'major' },
|
|
2850
|
+
{ topic: 'react:setState-in-render', lesson: 'setState() during render causes infinite loop — move to useEffect or event handler', severity: 'critical' },
|
|
2851
|
+
{ topic: 'react:memo-reference', lesson: 'Object/array literals in JSX recreate on every render — useMemo for expensive derived values', severity: 'minor' },
|
|
2852
|
+
],
|
|
2853
|
+
typescript: [
|
|
2854
|
+
{ topic: 'ts:type-guard', lesson: 'Use "x is Type" return type for type guard functions — not "boolean"', severity: 'minor' },
|
|
2855
|
+
{ topic: 'ts:strict-null', lesson: 'Enable strictNullChecks in tsconfig — catches 90% of runtime null errors at compile time', severity: 'critical' },
|
|
2856
|
+
{ topic: 'ts:enum-avoid', lesson: 'Prefer union types ("a"|"b") over enum — enums have surprising runtime behavior', severity: 'minor' },
|
|
2857
|
+
{ topic: 'ts:satisfies', lesson: 'Use "satisfies" operator to validate type without widening — more precise than explicit annotation', severity: 'minor' },
|
|
2858
|
+
],
|
|
2859
|
+
python: [
|
|
2860
|
+
{ topic: 'python:mutable-default', lesson: 'Never use mutable default arguments (def f(x=[])) — shared across all calls. Use None + guard', severity: 'critical' },
|
|
2861
|
+
{ topic: 'python:walrus-operator', lesson: ':= (walrus) assigns and returns — useful in while/comprehensions but hard to read in complex expr', severity: 'minor' },
|
|
2862
|
+
{ topic: 'python:asyncio-run', lesson: 'asyncio.run() creates new event loop — calling it inside an existing loop raises RuntimeError', severity: 'major' },
|
|
2863
|
+
{ topic: 'python:typing-optional', lesson: 'Optional[X] == Union[X, None] — in Python 3.10+ use X | None syntax', severity: 'minor' },
|
|
2864
|
+
],
|
|
2865
|
+
};
|
|
2866
|
+
|
|
2867
|
+
const fw = framework.toLowerCase();
|
|
2868
|
+
const lessons = COMMUNITY_LESSONS[fw];
|
|
2869
|
+
|
|
2870
|
+
if (!lessons) {
|
|
2871
|
+
const available = Object.keys(COMMUNITY_LESSONS).join(', ');
|
|
2872
|
+
return `❌ No public brain available for \`${framework}\`.\n\nAvailable: ${available}\n\nOr publish your own: \`publish_lesson(framework="${framework}", ...)\``;
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
const toImport = lessons.slice(0, limit);
|
|
2876
|
+
let importedCount = 0;
|
|
2877
|
+
|
|
2878
|
+
for (const l of toImport) {
|
|
2879
|
+
const key = `cachly:lesson:best:${l.topic}`;
|
|
2880
|
+
const existing = await redis.get(key);
|
|
2881
|
+
if (!existing) {
|
|
2882
|
+
await redis.set(key, JSON.stringify({
|
|
2883
|
+
...l,
|
|
2884
|
+
what_worked: l.lesson,
|
|
2885
|
+
outcome: 'success',
|
|
2886
|
+
ts: new Date().toISOString(),
|
|
2887
|
+
recall_count: 0,
|
|
2888
|
+
source: 'public_brain',
|
|
2889
|
+
version: 2,
|
|
2890
|
+
}));
|
|
2891
|
+
importedCount++;
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
const lines = [
|
|
2896
|
+
`📥 **Public Brain imported: ${framework}**`,
|
|
2897
|
+
``,
|
|
2898
|
+
`${importedCount} new lessons added (${toImport.length - importedCount} already existed)`,
|
|
2899
|
+
``,
|
|
2900
|
+
`**Imported topics:**`,
|
|
2901
|
+
...toImport.map(l => {
|
|
2902
|
+
const sev = l.severity === 'critical' ? '🔴' : l.severity === 'major' ? '🟡' : '💡';
|
|
2903
|
+
return ` ${sev} \`${l.topic}\``;
|
|
2904
|
+
}),
|
|
2905
|
+
``,
|
|
2906
|
+
`These lessons will now appear in \`session_start\` when relevant.`,
|
|
2907
|
+
`Recall any time: \`recall_best_solution(topic="${fw}:...")\``,
|
|
2908
|
+
];
|
|
2909
|
+
return lines.join('\n');
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
// ── setup_ai_memory ───────────────────────────────────────────────────────
|
|
2913
|
+
case 'setup_ai_memory': {
|
|
2914
|
+
const {
|
|
2915
|
+
instance_id,
|
|
2916
|
+
project_dir,
|
|
2917
|
+
embed_provider: providerArg = 'openai',
|
|
2918
|
+
project_description = 'a software project',
|
|
2919
|
+
} = args as {
|
|
2920
|
+
instance_id: string;
|
|
2921
|
+
project_dir?: string;
|
|
2922
|
+
embed_provider?: string;
|
|
2923
|
+
project_description?: string;
|
|
2924
|
+
};
|
|
2925
|
+
|
|
2926
|
+
const inst = await apiFetch<Instance>(`/api/v1/instances/${instance_id}`);
|
|
2927
|
+
|
|
2928
|
+
// Provider-specific env var instructions
|
|
2929
|
+
const providerEnvMap: Record<string, { key: string; hint: string }> = {
|
|
2930
|
+
openai: { key: 'OPENAI_API_KEY', hint: 'Get at: https://platform.openai.com/api-keys' },
|
|
2931
|
+
mistral: { key: 'MISTRAL_API_KEY', hint: 'Get at: https://console.mistral.ai/api-keys' },
|
|
2932
|
+
cohere: { key: 'COHERE_API_KEY', hint: 'Get at: https://dashboard.cohere.com/api-keys' },
|
|
2933
|
+
ollama: { key: 'OLLAMA_BASE_URL', hint: 'Run: brew install ollama && ollama serve (free, local, no key needed)' },
|
|
2934
|
+
gemini: { key: 'GEMINI_API_KEY', hint: 'Get at: https://aistudio.google.com/app/apikey' },
|
|
2935
|
+
};
|
|
2936
|
+
const provInfo = providerEnvMap[providerArg] ?? providerEnvMap['openai'];
|
|
2937
|
+
const hasVector = !!inst.vector_token;
|
|
2938
|
+
|
|
2939
|
+
// Generate .mcp.json snippet
|
|
2940
|
+
const mcpJsonSnippet = JSON.stringify({
|
|
2941
|
+
mcpServers: {
|
|
2942
|
+
cachly: {
|
|
2943
|
+
command: 'npx',
|
|
2944
|
+
args: ['-y', '@cachly-dev/mcp-server@latest'],
|
|
2945
|
+
env: {
|
|
2946
|
+
CACHLY_JWT: 'your-api-token-from-cachly.dev/settings',
|
|
2947
|
+
[provInfo.key]: providerArg === 'ollama' ? 'http://localhost:11434' : 'your-key-here',
|
|
2948
|
+
...(providerArg !== 'openai' ? { CACHLY_EMBED_PROVIDER: providerArg } : {}),
|
|
2949
|
+
},
|
|
2950
|
+
},
|
|
2951
|
+
},
|
|
2952
|
+
}, null, 2);
|
|
2953
|
+
|
|
2954
|
+
// Generate copilot-instructions.md content
|
|
2955
|
+
const tier = inst.tier.toUpperCase();
|
|
2956
|
+
const smartRecallNote = hasVector
|
|
2957
|
+
? '3. Call `smart_recall` with keywords for semantic context search'
|
|
2958
|
+
: '3. Call `recall_context` with relevant key patterns like "arch:*" or "file:*"';
|
|
2959
|
+
const layerNote = hasVector
|
|
2960
|
+
? `Layer 3 (Semantic): smart_recall uses pgvector HNSW on your ${tier} instance for meaning-based search`
|
|
2961
|
+
: `Layer 3 (Autopilot): This file — upgrade to Speed/Business tier to unlock smart_recall`;
|
|
2962
|
+
|
|
2963
|
+
const copilotInstructions = `# cachly AI Memory — ${project_description}
|
|
2964
|
+
|
|
2965
|
+
## 3-Layer AI Memory System
|
|
2966
|
+
|
|
2967
|
+
- **Layer 1 — Storage:** cachly instance \`${inst.name}\` (${tier}) — persistent Valkey brain, survives restarts
|
|
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
|
|
2970
|
+
|
|
2971
|
+
## BEFORE every task — check memory first
|
|
2972
|
+
|
|
2973
|
+
1. Call \`recall_best_solution\` with the task topic to get known solutions
|
|
2974
|
+
- \`recall_best_solution("deploy:web")\` → returns working deploy command
|
|
2975
|
+
- \`recall_best_solution("debug:redis-tls")\` → returns root cause + fix
|
|
2976
|
+
- If a solution exists, use it — do NOT re-discover from scratch
|
|
2977
|
+
|
|
2978
|
+
2. Call \`recall_context\` with the area you're working in
|
|
2979
|
+
- \`recall_context("arch:*")\` → cached architecture decisions
|
|
2980
|
+
- \`recall_context("file:src/auth*")\` → cached file summaries
|
|
2981
|
+
|
|
2982
|
+
${smartRecallNote}
|
|
2983
|
+
- \`smart_recall("how does authentication work")\` → returns cached auth notes
|
|
2984
|
+
|
|
2985
|
+
4. If nothing is found, proceed normally — then save (see below)
|
|
2986
|
+
|
|
2987
|
+
## AFTER every non-trivial task — save to memory
|
|
2988
|
+
|
|
2989
|
+
1. Call \`learn_from_attempts\` for deploy/debug/fix tasks:
|
|
2990
|
+
- Always set outcome = "success" | "failure" | "partial"
|
|
2991
|
+
- Always include what_failed — it prevents future wasted time
|
|
2992
|
+
- Example: \`learn_from_attempts(topic="deploy:api", outcome="success", what_worked="nohup docker compose up -d --build", what_failed="direct docker compose hangs on SSH timeout")\`
|
|
2993
|
+
|
|
2994
|
+
2. Call \`remember_context\` for code/architecture findings:
|
|
2995
|
+
- \`remember_context("arch:auth", "Uses Keycloak with JWT...", category="architecture")\`
|
|
2996
|
+
- \`remember_context("file:src/index.ts", "Entry point, registers routes...", category="file_summary")\`
|
|
2997
|
+
|
|
2998
|
+
## Instance Details
|
|
2999
|
+
|
|
3000
|
+
- Instance ID: \`${instance_id}\`
|
|
3001
|
+
- Instance name: ${inst.name}
|
|
3002
|
+
- Tier: ${tier}
|
|
3003
|
+
- ${layerNote}
|
|
3004
|
+
- Embedding provider: ${providerArg}
|
|
3005
|
+
|
|
3006
|
+
## Quick reference
|
|
3007
|
+
|
|
3008
|
+
\`\`\`
|
|
3009
|
+
recall_best_solution("topic") # before task
|
|
3010
|
+
smart_recall("natural language") # find by meaning
|
|
3011
|
+
learn_from_attempts(...) # after task
|
|
3012
|
+
remember_context("key", "content") # save analysis
|
|
3013
|
+
\`\`\`
|
|
3014
|
+
`;
|
|
3015
|
+
|
|
3016
|
+
const lines: string[] = [
|
|
3017
|
+
`🧠 **cachly AI Memory Setup Complete**`,
|
|
3018
|
+
``,
|
|
3019
|
+
`**Instance:** ${inst.name} (${tier}) · ID: \`${instance_id}\``,
|
|
3020
|
+
`**Embedding Provider:** ${providerArg}`,
|
|
3021
|
+
`**Semantic Search:** ${hasVector ? '✅ pgvector HNSW available' : '⚠️ Not available on ' + tier + ' — upgrade to Speed/Business'}`,
|
|
3022
|
+
``,
|
|
3023
|
+
`─────────────────────────────────────────────`,
|
|
3024
|
+
`**Step 1 — Add to .mcp.json:**`,
|
|
3025
|
+
`\`\`\`json`,
|
|
3026
|
+
mcpJsonSnippet,
|
|
3027
|
+
`\`\`\``,
|
|
3028
|
+
``,
|
|
3029
|
+
`**Step 2 — Set your ${providerArg} key:**`,
|
|
3030
|
+
`\`\`\`bash`,
|
|
3031
|
+
`export ${provInfo.key}="your-key-here"`,
|
|
3032
|
+
`\`\`\``,
|
|
3033
|
+
`_(${provInfo.hint})_`,
|
|
3034
|
+
``,
|
|
3035
|
+
`─────────────────────────────────────────────`,
|
|
3036
|
+
`**Step 3 — copilot-instructions.md (Layer 3 Autopilot):**`,
|
|
3037
|
+
``,
|
|
3038
|
+
...(project_dir
|
|
3039
|
+
? [`Writing to \`${project_dir}/.github/copilot-instructions.md\`…`]
|
|
3040
|
+
: [`Copy this to \`.github/copilot-instructions.md\` in your project:`]),
|
|
3041
|
+
``,
|
|
3042
|
+
`\`\`\`markdown`,
|
|
3043
|
+
copilotInstructions,
|
|
3044
|
+
`\`\`\``,
|
|
3045
|
+
``,
|
|
3046
|
+
`─────────────────────────────────────────────`,
|
|
3047
|
+
`**How the 3 layers work together:**`,
|
|
3048
|
+
` Layer 1 → Your Valkey instance stores all lessons + context (persists forever)`,
|
|
3049
|
+
` Layer 2 → MCP tools (learn_from_attempts, recall_best_solution, smart_recall) read/write it`,
|
|
3050
|
+
` Layer 3 → copilot-instructions.md makes your AI run them automatically`,
|
|
3051
|
+
``,
|
|
3052
|
+
`Result: Your AI never solves the same problem twice. 🚀`,
|
|
3053
|
+
];
|
|
3054
|
+
|
|
3055
|
+
// Optionally write the file
|
|
3056
|
+
if (project_dir) {
|
|
3057
|
+
const { mkdir, writeFile } = await import('node:fs/promises');
|
|
3058
|
+
const githubDir = join(project_dir, '.github');
|
|
3059
|
+
await mkdir(githubDir, { recursive: true });
|
|
3060
|
+
const filePath = join(githubDir, 'copilot-instructions.md');
|
|
3061
|
+
await writeFile(filePath, copilotInstructions, 'utf-8');
|
|
3062
|
+
lines.splice(lines.indexOf(`Writing to \`${project_dir}/.github/copilot-instructions.md\`…`) + 1, 0,
|
|
3063
|
+
`✅ Written to \`${project_dir}/.github/copilot-instructions.md\``
|
|
3064
|
+
);
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
return lines.join('\n');
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
default:
|
|
3071
|
+
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
|
|
3072
|
+
}
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3075
|
+
|
|
3076
|
+
async function handleBulkLockStream(name: string, args: Record<string, unknown>): Promise<string | null> {
|
|
3077
|
+
const instance_id = args.instance_id as string;
|
|
3078
|
+
|
|
3079
|
+
switch (name) {
|
|
3080
|
+
// ── cache_mset ────────────────────────────────────────────────────────
|
|
3081
|
+
case 'cache_mset': {
|
|
3082
|
+
const items = args.items as Array<{ key: string; value: unknown; ttl?: number }>;
|
|
3083
|
+
if (!Array.isArray(items) || items.length === 0) return '⚠️ No items provided.';
|
|
3084
|
+
const redis = await getConnection(instance_id);
|
|
3085
|
+
const pipe = redis.pipeline();
|
|
3086
|
+
for (const item of items) {
|
|
3087
|
+
const serialized = typeof item.value === 'string' ? item.value : JSON.stringify(item.value);
|
|
3088
|
+
if (item.ttl && item.ttl > 0) {
|
|
3089
|
+
pipe.set(item.key, serialized, 'EX', item.ttl);
|
|
3090
|
+
} else {
|
|
3091
|
+
pipe.set(item.key, serialized);
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
await pipe.exec();
|
|
3095
|
+
return `✅ **cache_mset** – ${items.length} key(s) written in one pipeline round-trip.\n` +
|
|
3096
|
+
items.map(i => ` • \`${i.key}\`${i.ttl ? ` (TTL ${i.ttl}s)` : ''}`).join('\n');
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
// ── cache_mget ────────────────────────────────────────────────────────
|
|
3100
|
+
case 'cache_mget': {
|
|
3101
|
+
const keys = args.keys as string[];
|
|
3102
|
+
if (!Array.isArray(keys) || keys.length === 0) return '⚠️ No keys provided.';
|
|
3103
|
+
const redis = await getConnection(instance_id);
|
|
3104
|
+
const raws = await redis.mget(...keys);
|
|
3105
|
+
const result = keys.map((k, i) => {
|
|
3106
|
+
const raw = raws[i];
|
|
3107
|
+
if (raw === null) return ` • \`${k}\`: _null (miss)_`;
|
|
3108
|
+
try { return ` • \`${k}\`: ${raw}`; } catch { return ` • \`${k}\`: ${raw}`; }
|
|
3109
|
+
});
|
|
3110
|
+
return `✅ **cache_mget** – ${keys.length} key(s) fetched in one round-trip.\n` + result.join('\n');
|
|
3111
|
+
}
|
|
3112
|
+
|
|
3113
|
+
// ── cache_lock_acquire ────────────────────────────────────────────────
|
|
3114
|
+
case 'cache_lock_acquire': {
|
|
3115
|
+
const key = args.key as string;
|
|
3116
|
+
const ttlMs = Number(args.ttl_ms ?? 5000);
|
|
3117
|
+
const retries = Number(args.retries ?? 3);
|
|
3118
|
+
const retryDelayMs = Number(args.retry_delay_ms ?? 50);
|
|
3119
|
+
const redis = await getConnection(instance_id);
|
|
3120
|
+
const lockKey = `cachly:lock:${key}`;
|
|
3121
|
+
const token = randomUUID();
|
|
3122
|
+
|
|
3123
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
3124
|
+
const result = await redis.set(lockKey, token, 'PX', ttlMs, 'NX');
|
|
3125
|
+
if (result === 'OK') {
|
|
3126
|
+
return (
|
|
3127
|
+
`🔒 **cache_lock_acquire** – Lock acquired!\n\n` +
|
|
3128
|
+
` Key: \`${key}\`\n` +
|
|
3129
|
+
` Token: \`${token}\`\n` +
|
|
3130
|
+
` TTL: ${ttlMs} ms\n\n` +
|
|
3131
|
+
`💡 Use **cache_lock_release** with this token to release early.`
|
|
3132
|
+
);
|
|
3133
|
+
}
|
|
3134
|
+
if (attempt < retries) await new Promise(r => setTimeout(r, retryDelayMs));
|
|
3135
|
+
}
|
|
3136
|
+
return `❌ **cache_lock_acquire** – Could not acquire lock for \`${key}\` after ${retries + 1} attempts.`;
|
|
3137
|
+
}
|
|
3138
|
+
|
|
3139
|
+
// ── cache_lock_release ────────────────────────────────────────────────
|
|
3140
|
+
case 'cache_lock_release': {
|
|
3141
|
+
const key = args.key as string;
|
|
3142
|
+
const token = args.token as string;
|
|
3143
|
+
const redis = await getConnection(instance_id);
|
|
3144
|
+
const script = `
|
|
3145
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
3146
|
+
return redis.call("del", KEYS[1])
|
|
3147
|
+
else
|
|
3148
|
+
return 0
|
|
3149
|
+
end`;
|
|
3150
|
+
const released = await redis.eval(script, 1, `cachly:lock:${key}`, token);
|
|
3151
|
+
return released === 1
|
|
3152
|
+
? `🔓 **cache_lock_release** – Lock \`${key}\` released successfully.`
|
|
3153
|
+
: `⚠️ **cache_lock_release** – Lock \`${key}\` was already expired or token mismatch.`;
|
|
3154
|
+
}
|
|
3155
|
+
|
|
3156
|
+
// ── cache_stream_set ──────────────────────────────────────────────────
|
|
3157
|
+
case 'cache_stream_set': {
|
|
3158
|
+
const key = args.key as string;
|
|
3159
|
+
const chunks = args.chunks as string[];
|
|
3160
|
+
const ttl = args.ttl ? Number(args.ttl) : null;
|
|
3161
|
+
if (!Array.isArray(chunks) || chunks.length === 0) return '⚠️ No chunks provided.';
|
|
3162
|
+
const redis = await getConnection(instance_id);
|
|
3163
|
+
const listKey = `cachly:stream:${key}`;
|
|
3164
|
+
await redis.del(listKey);
|
|
3165
|
+
const pipe = redis.pipeline();
|
|
3166
|
+
for (const chunk of chunks) pipe.rpush(listKey, chunk);
|
|
3167
|
+
if (ttl && ttl > 0) pipe.expire(listKey, ttl);
|
|
3168
|
+
await pipe.exec();
|
|
3169
|
+
return (
|
|
3170
|
+
`✅ **cache_stream_set** – ${chunks.length} chunk(s) stored.\n` +
|
|
3171
|
+
` Key: \`${key}\`\n` +
|
|
3172
|
+
(ttl ? ` TTL: ${ttl}s\n` : '') +
|
|
3173
|
+
` Total size: ${chunks.reduce((a, c) => a + c.length, 0)} chars`
|
|
3174
|
+
);
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
// ── cache_stream_get ──────────────────────────────────────────────────
|
|
3178
|
+
case 'cache_stream_get': {
|
|
3179
|
+
const key = args.key as string;
|
|
3180
|
+
const redis = await getConnection(instance_id);
|
|
3181
|
+
const listKey = `cachly:stream:${key}`;
|
|
3182
|
+
const len = await redis.llen(listKey);
|
|
3183
|
+
if (len === 0) return `⚠️ **cache_stream_get** – Cache miss for key \`${key}\`.`;
|
|
3184
|
+
const chunks = await redis.lrange(listKey, 0, -1);
|
|
3185
|
+
const preview = chunks.join('').slice(0, 500);
|
|
3186
|
+
return (
|
|
3187
|
+
`✅ **cache_stream_get** – ${len} chunk(s) retrieved for \`${key}\`.\n\n` +
|
|
3188
|
+
`**Preview** (first 500 chars):\n\`\`\`\n${preview}${preview.length < chunks.join('').length ? '…' : ''}\n\`\`\``
|
|
3189
|
+
);
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
default:
|
|
3193
|
+
return null;
|
|
3194
|
+
}
|
|
3195
|
+
}
|
|
3196
|
+
|
|
3197
|
+
// ── Server setup ──────────────────────────────────────────────────────────────
|
|
3198
|
+
|
|
3199
|
+
const server = new Server(
|
|
3200
|
+
{ name: 'cachly-mcp', version: '0.4.0' },
|
|
3201
|
+
{ capabilities: { tools: {} } }
|
|
3202
|
+
);
|
|
3203
|
+
|
|
3204
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
3205
|
+
|
|
3206
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
3207
|
+
const { name, arguments: args } = request.params;
|
|
3208
|
+
try {
|
|
3209
|
+
const text = await handleTool(name, (args ?? {}) as Record<string, unknown>);
|
|
3210
|
+
return { content: [{ type: 'text', text }] };
|
|
3211
|
+
} catch (err) {
|
|
3212
|
+
if (err instanceof McpError) throw err;
|
|
3213
|
+
throw new McpError(ErrorCode.InternalError, (err as Error).message);
|
|
3214
|
+
}
|
|
3215
|
+
});
|
|
3216
|
+
|
|
3217
|
+
// Graceful shutdown – close all Redis connections
|
|
3218
|
+
process.on('SIGTERM', async () => {
|
|
3219
|
+
for (const [, client] of pool) await client.quit().catch(() => undefined);
|
|
3220
|
+
process.exit(0);
|
|
3221
|
+
});
|
|
3222
|
+
|
|
3223
|
+
// Safety net: log unhandled rejections instead of crashing the MCP process.
|
|
3224
|
+
// The MCP server must stay alive even if a single tool call hits an unexpected error.
|
|
3225
|
+
process.on('unhandledRejection', (reason) => {
|
|
3226
|
+
const msg = reason instanceof Error ? reason.message : String(reason);
|
|
3227
|
+
// Write to stderr only (stdout is the JSON-RPC channel)
|
|
3228
|
+
process.stderr.write(`[cachly-mcp] unhandledRejection: ${msg}\n`);
|
|
3229
|
+
});
|
|
3230
|
+
|
|
3231
|
+
// ── CLI: cachly init ──────────────────────────────────────────────────────────
|
|
3232
|
+
// Usage: npx @cachly-dev/mcp-server init --instance-id <id> --api-key <key> [--editor claude|cursor|windsurf|copilot|continue] [--project-dir /path]
|
|
3233
|
+
|
|
3234
|
+
if (process.argv[2] === 'init') {
|
|
3235
|
+
const { writeFile, mkdir } = await import('node:fs/promises');
|
|
3236
|
+
const { existsSync } = await import('node:fs');
|
|
3237
|
+
const { resolve, dirname } = await import('node:path');
|
|
3238
|
+
|
|
3239
|
+
// Parse CLI flags
|
|
3240
|
+
const argv = process.argv.slice(3);
|
|
3241
|
+
const flag = (name: string) => {
|
|
3242
|
+
const i = argv.indexOf(`--${name}`);
|
|
3243
|
+
return i !== -1 ? argv[i + 1] : undefined;
|
|
3244
|
+
};
|
|
3245
|
+
|
|
3246
|
+
const instanceId = flag('instance-id') ?? process.env.CACHLY_BRAIN_INSTANCE_ID;
|
|
3247
|
+
const apiKey = flag('api-key') ?? process.env.CACHLY_JWT;
|
|
3248
|
+
const editor = (flag('editor') ?? 'claude').toLowerCase();
|
|
3249
|
+
const projectDir = resolve(flag('project-dir') ?? '.');
|
|
3250
|
+
|
|
3251
|
+
if (!instanceId || !apiKey) {
|
|
3252
|
+
console.error('\nUsage: npx @cachly-dev/mcp-server@latest init --instance-id <uuid> --api-key <cky_live_...> [--editor claude|cursor|windsurf|copilot|continue] [--project-dir /path]\n');
|
|
3253
|
+
console.error('Get your instance ID and API key from: https://cachly.dev/instances\n');
|
|
3254
|
+
process.exit(1);
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3257
|
+
const editorFiles: Record<string, string> = {
|
|
3258
|
+
claude: '.mcp.json',
|
|
3259
|
+
cursor: '.cursor/mcp.json',
|
|
3260
|
+
windsurf: '.windsurf/mcp.json',
|
|
3261
|
+
copilot: '.vscode/mcp.json',
|
|
3262
|
+
continue: '.continue/config.json',
|
|
3263
|
+
};
|
|
3264
|
+
const configFile = editorFiles[editor] ?? '.mcp.json';
|
|
3265
|
+
const configPath = resolve(projectDir, configFile);
|
|
3266
|
+
|
|
3267
|
+
// Build config content
|
|
3268
|
+
let configContent: string;
|
|
3269
|
+
if (editor === 'continue') {
|
|
3270
|
+
configContent = JSON.stringify({
|
|
3271
|
+
experimental: {
|
|
3272
|
+
modelContextProtocolServers: [{
|
|
3273
|
+
transport: { type: 'stdio', command: 'npx', args: ['-y', '@cachly-dev/mcp-server@latest'] },
|
|
3274
|
+
env: {
|
|
3275
|
+
CACHLY_API_URL: 'https://api.cachly.dev',
|
|
3276
|
+
CACHLY_JWT: apiKey,
|
|
3277
|
+
CACHLY_BRAIN_INSTANCE_ID: instanceId,
|
|
3278
|
+
},
|
|
3279
|
+
}],
|
|
3280
|
+
},
|
|
3281
|
+
}, null, 2);
|
|
3282
|
+
} else {
|
|
3283
|
+
configContent = JSON.stringify({
|
|
3284
|
+
mcpServers: {
|
|
3285
|
+
cachly: {
|
|
3286
|
+
command: 'npx',
|
|
3287
|
+
args: ['-y', '@cachly-dev/mcp-server@latest'],
|
|
3288
|
+
env: {
|
|
3289
|
+
CACHLY_API_URL: 'https://api.cachly.dev',
|
|
3290
|
+
CACHLY_JWT: apiKey,
|
|
3291
|
+
CACHLY_BRAIN_INSTANCE_ID: instanceId,
|
|
3292
|
+
},
|
|
3293
|
+
},
|
|
3294
|
+
},
|
|
3295
|
+
}, null, 2);
|
|
3296
|
+
}
|
|
3297
|
+
|
|
3298
|
+
// Create parent dirs if needed, then write config
|
|
3299
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
3300
|
+
await writeFile(configPath, configContent, 'utf-8');
|
|
3301
|
+
console.log(`\n✅ Written: ${configPath}`);
|
|
3302
|
+
|
|
3303
|
+
// For Claude Code, also offer to append CLAUDE.md snippet
|
|
3304
|
+
if (editor === 'claude') {
|
|
3305
|
+
const claudeMdSnippet = `\n## Cachly AI Brain\n\nThe Cachly MCP server is available as tool \`cachly\`.\n\n### At the START of every session (one call):\n\`\`\`\nsession_start(\n instance_id = "${instanceId}",\n focus = "<what you plan to work on today>",\n)\n\`\`\`\n\n### At the END of every session:\n\`\`\`\nsession_end(\n instance_id = "${instanceId}",\n summary = "What was accomplished",\n files_changed = ["src/..."],\n lessons_learned = 1,\n)\n\`\`\`\n\n### After fixing any bug or learning something:\n\`\`\`\nlearn_from_attempts(\n instance_id = "${instanceId}",\n topic = "category:keyword",\n outcome = "success",\n what_worked = "...",\n what_failed = "...",\n severity = "critical" | "major" | "minor",\n file_paths = ["path/to/file"],\n commands = ["the command that worked"],\n tags = ["tag1", "tag2"],\n)\n\`\`\`\n`;
|
|
3306
|
+
|
|
3307
|
+
const claudeMdPath = resolve(projectDir, 'CLAUDE.md');
|
|
3308
|
+
const { appendFile } = await import('node:fs/promises');
|
|
3309
|
+
if (existsSync(claudeMdPath)) {
|
|
3310
|
+
await appendFile(claudeMdPath, claudeMdSnippet, 'utf-8');
|
|
3311
|
+
console.log(`✅ Appended: ${claudeMdPath}`);
|
|
3312
|
+
} else {
|
|
3313
|
+
await writeFile(claudeMdPath, claudeMdSnippet.trimStart(), 'utf-8');
|
|
3314
|
+
console.log(`✅ Written: ${claudeMdPath}`);
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
console.log(`\n🧠 Cachly AI Brain configured for ${editor === 'claude' ? 'Claude Code' : editor}!`);
|
|
3319
|
+
console.log(` Restart your editor — the \`cachly\` MCP tools will appear.\n`);
|
|
3320
|
+
process.exit(0);
|
|
3321
|
+
}
|
|
3322
|
+
|
|
3323
|
+
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
3324
|
+
|
|
3325
|
+
const transport = new StdioServerTransport();
|
|
3326
|
+
await server.connect(transport);
|
|
3327
|
+
|