@cachly-dev/mcp-server 0.10.27 → 0.10.40
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/.env.test.example +23 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +50 -0
- package/.github/ISSUE_TEMPLATE/config.yml +8 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +25 -0
- package/.github/workflows/publish.yml +72 -0
- package/CHANGELOG.md +252 -0
- package/LAUNCH_TEXTS.md +182 -0
- package/LICENSE +202 -0
- package/README.md +142 -116
- package/dist/__tests__/auth.test.d.ts +1 -0
- package/dist/__tests__/auth.test.js +107 -0
- package/dist/__tests__/auth.test.js.map +1 -0
- package/dist/__tests__/e2e/api-auth.test.d.ts +7 -0
- package/dist/__tests__/e2e/api-auth.test.js +162 -0
- package/dist/__tests__/e2e/api-auth.test.js.map +1 -0
- package/dist/__tests__/e2e/api-health.test.d.ts +7 -0
- package/dist/__tests__/e2e/api-health.test.js +95 -0
- package/dist/__tests__/e2e/api-health.test.js.map +1 -0
- package/dist/__tests__/e2e/cli.test.d.ts +10 -0
- package/dist/__tests__/e2e/cli.test.js +204 -0
- package/dist/__tests__/e2e/cli.test.js.map +1 -0
- package/dist/__tests__/e2e/config.d.ts +35 -0
- package/dist/__tests__/e2e/config.js +53 -0
- package/dist/__tests__/e2e/config.js.map +1 -0
- package/dist/__tests__/e2e/funnel.test.d.ts +13 -0
- package/dist/__tests__/e2e/funnel.test.js +90 -0
- package/dist/__tests__/e2e/funnel.test.js.map +1 -0
- package/dist/__tests__/e2e/mcp-tools.test.d.ts +7 -0
- package/dist/__tests__/e2e/mcp-tools.test.js +129 -0
- package/dist/__tests__/e2e/mcp-tools.test.js.map +1 -0
- package/dist/__tests__/e2e/provisioning.test.d.ts +15 -0
- package/dist/__tests__/e2e/provisioning.test.js +123 -0
- package/dist/__tests__/e2e/provisioning.test.js.map +1 -0
- package/dist/handlers/advanced.js +38 -227
- package/dist/handlers/advanced.js.map +1 -1
- package/dist/handlers/brain.js +119 -82
- package/dist/handlers/brain.js.map +1 -1
- package/dist/handlers/fedbrain.js +93 -44
- package/dist/handlers/fedbrain.js.map +1 -1
- package/dist/handlers/roadmap.js +8 -2
- package/dist/handlers/roadmap.js.map +1 -1
- package/dist/handlers/syndicate.js +15 -7
- package/dist/handlers/syndicate.js.map +1 -1
- package/dist/handlers/team.js +62 -50
- package/dist/handlers/team.js.map +1 -1
- package/dist/index.js +1264 -234
- package/dist/index.js.map +1 -1
- package/dist/tools.d.ts +3 -54
- package/dist/tools.js +8 -62
- package/dist/tools.js.map +1 -1
- package/eslint.config.js +16 -0
- package/glama.json +36 -0
- package/llms.txt +155 -0
- package/package.json +38 -32
- package/scripts/e2e-run.sh +160 -0
- package/scripts/postinstall.js +12 -12
- package/server.json +3 -9
- package/smithery.yaml +15 -8
- package/src/__tests__/auth.test.ts +147 -0
- package/src/__tests__/e2e/api-auth.test.ts +182 -0
- package/src/__tests__/e2e/api-health.test.ts +103 -0
- package/src/__tests__/e2e/cli.test.ts +228 -0
- package/src/__tests__/e2e/config.ts +53 -0
- package/src/__tests__/e2e/funnel.test.ts +99 -0
- package/src/__tests__/e2e/mcp-tools.test.ts +191 -0
- package/src/__tests__/e2e/provisioning.test.ts +128 -0
- package/src/handlers/advanced.ts +37 -217
- package/src/handlers/brain.ts +118 -78
- package/src/handlers/fedbrain.ts +88 -38
- package/src/handlers/roadmap.ts +7 -2
- package/src/handlers/syndicate.ts +13 -7
- package/src/handlers/team.ts +57 -33
- package/src/index.ts +1312 -257
- package/src/tools.ts +8 -66
- package/vitest.config.ts +12 -0
- package/dist/__tests__/brain-predict-failures.test.d.ts +0 -17
- package/dist/__tests__/brain-predict-failures.test.js +0 -339
- package/dist/__tests__/brain-predict-failures.test.js.map +0 -1
- package/src/__tests__/brain-predict-failures.test.ts +0 -430
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E: Provisioning flow
|
|
3
|
+
*
|
|
4
|
+
* Tests the full instance lifecycle:
|
|
5
|
+
* create → poll until active (or timeout) → connect → delete
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ This test creates a real instance and WILL be billed if not deleted.
|
|
8
|
+
* It always cleans up in afterAll, even on failure.
|
|
9
|
+
*
|
|
10
|
+
* Skipped if E2E_SKIP_SLOW=true.
|
|
11
|
+
*
|
|
12
|
+
* Requires: E2E_JWT
|
|
13
|
+
* Run: npx vitest run src/__tests__/e2e/provisioning.test.ts --reporter=verbose
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
17
|
+
import { cfg, requireAuth, apiGet, apiPost } from './config.js';
|
|
18
|
+
|
|
19
|
+
beforeAll(() => {
|
|
20
|
+
requireAuth();
|
|
21
|
+
if (cfg.skipSlow) {
|
|
22
|
+
console.log('Skipping provisioning tests (E2E_SKIP_SLOW=true)');
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
let createdInstanceId: string | null = null;
|
|
27
|
+
|
|
28
|
+
afterAll(async () => {
|
|
29
|
+
if (!createdInstanceId) return;
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch(`${cfg.apiUrl}/api/v1/instances/${createdInstanceId}`, {
|
|
32
|
+
method: 'DELETE',
|
|
33
|
+
headers: { Authorization: `Bearer ${cfg.jwt}` },
|
|
34
|
+
});
|
|
35
|
+
console.log(`Cleanup: deleted instance ${createdInstanceId} → ${res.status}`);
|
|
36
|
+
} catch (e) {
|
|
37
|
+
console.warn(`Cleanup failed for ${createdInstanceId}:`, e);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('Instance provisioning lifecycle', () => {
|
|
42
|
+
it.skipIf(cfg.skipSlow)('POST /api/v1/instances creates a new free instance', async () => {
|
|
43
|
+
const { status, body } = await apiPost('/api/v1/instances', {
|
|
44
|
+
name: `e2e-test-${Date.now()}`,
|
|
45
|
+
tier: 'free',
|
|
46
|
+
});
|
|
47
|
+
expect([200, 201]).toContain(status);
|
|
48
|
+
const inst = body as Record<string, unknown>;
|
|
49
|
+
expect(typeof inst.id).toBe('string');
|
|
50
|
+
expect(inst.status).toMatch(/^(provisioning|active)$/);
|
|
51
|
+
createdInstanceId = inst.id as string;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it.skipIf(cfg.skipSlow)('instance status transitions from provisioning → active within 5 min', async () => {
|
|
55
|
+
if (!createdInstanceId) return;
|
|
56
|
+
|
|
57
|
+
const maxWaitMs = 5 * 60 * 1000; // 5 minutes
|
|
58
|
+
const pollMs = 10_000; // poll every 10 seconds
|
|
59
|
+
const start = Date.now();
|
|
60
|
+
let status = 'provisioning';
|
|
61
|
+
|
|
62
|
+
while (Date.now() - start < maxWaitMs) {
|
|
63
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
64
|
+
const { body } = await apiGet(`/api/v1/instances/${createdInstanceId}`);
|
|
65
|
+
status = (body as Record<string, unknown>).status as string;
|
|
66
|
+
console.log(` [${Math.round((Date.now() - start) / 1000)}s] status: ${status}`);
|
|
67
|
+
if (status === 'active') break;
|
|
68
|
+
if (status === 'failed') break;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
expect(status).toBe('active');
|
|
72
|
+
}, 6 * 60 * 1000); // vitest timeout: 6 min
|
|
73
|
+
|
|
74
|
+
it.skipIf(cfg.skipSlow)('active instance returns valid connection details', async () => {
|
|
75
|
+
if (!createdInstanceId) return;
|
|
76
|
+
const { status, body } = await apiGet(`/api/v1/instances/${createdInstanceId}/connection`);
|
|
77
|
+
expect(status).toBe(200);
|
|
78
|
+
const conn = body as Record<string, unknown>;
|
|
79
|
+
expect(typeof conn.host).toBe('string');
|
|
80
|
+
expect(Number(conn.port)).toBeGreaterThan(0);
|
|
81
|
+
expect(typeof conn.password).toBe('string');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it.skipIf(cfg.skipSlow)('can PING the provisioned Redis', async () => {
|
|
85
|
+
if (!createdInstanceId) return;
|
|
86
|
+
const { body } = await apiGet(`/api/v1/instances/${createdInstanceId}/connection`);
|
|
87
|
+
const conn = body as Record<string, unknown>;
|
|
88
|
+
|
|
89
|
+
const { Redis: RedisClass } = await import('ioredis');
|
|
90
|
+
const r = new RedisClass({
|
|
91
|
+
host: conn.host as string,
|
|
92
|
+
port: Number(conn.port),
|
|
93
|
+
password: conn.password as string,
|
|
94
|
+
tls: cfg.redisTls ? {} : undefined,
|
|
95
|
+
connectTimeout: 8000,
|
|
96
|
+
lazyConnect: true,
|
|
97
|
+
});
|
|
98
|
+
try {
|
|
99
|
+
await r.connect();
|
|
100
|
+
expect(await r.ping()).toBe('PONG');
|
|
101
|
+
} finally {
|
|
102
|
+
await r.quit();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('Provisioning status — failed instance handling', () => {
|
|
108
|
+
it('failed status returns meaningful error (not 500) from connection endpoint', async () => {
|
|
109
|
+
// We use a known-bad UUID to simulate a missing/failed instance
|
|
110
|
+
const { status } = await apiGet('/api/v1/instances/00000000-0000-0000-0000-000000000000/connection');
|
|
111
|
+
// Should be a client error (4xx), never a server error (5xx)
|
|
112
|
+
expect(status).toBeGreaterThanOrEqual(400);
|
|
113
|
+
expect(status).toBeLessThan(500);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('Auto-provision via /instances/auto', () => {
|
|
118
|
+
it.skipIf(cfg.skipSlow)('POST /api/v1/instances/auto returns instance id', async () => {
|
|
119
|
+
const { status, body } = await apiPost('/api/v1/instances/auto', {});
|
|
120
|
+
// 200 = existing instance returned
|
|
121
|
+
// 201 = new instance created
|
|
122
|
+
// 409 = already has an instance (also fine)
|
|
123
|
+
expect([200, 201, 409]).toContain(status);
|
|
124
|
+
if (status !== 409) {
|
|
125
|
+
expect(typeof (body as Record<string, unknown>).id).toBe('string');
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
});
|
package/src/handlers/advanced.ts
CHANGED
|
@@ -4,13 +4,13 @@ import { calculateConfidence, confidenceBadge, CONFIDENCE_STALE_VALUE, CONFIDENC
|
|
|
4
4
|
import { ckgSlug, ckgUpdateEdge } from '../ckg.js';
|
|
5
5
|
import type { CKGEdge, CKGNode } from '../ckg.js';
|
|
6
6
|
import { keywordSearch } from '../search.js';
|
|
7
|
+
import { safeJsonParse } from '../utils.js';
|
|
7
8
|
|
|
8
9
|
type GetConnection = (instanceId: string) => Promise<Redis>;
|
|
9
10
|
type ApiFetch = <T>(path: string, options?: RequestInit) => Promise<T>;
|
|
10
11
|
|
|
11
12
|
export const ADVANCED_TOOL_NAMES = new Set([
|
|
12
13
|
'memory_consolidate', 'brain_diff', 'causal_trace', 'knowledge_decay', 'autopilot',
|
|
13
|
-
'brain_stats', 'pin_lesson', 'unpin_lesson',
|
|
14
14
|
]);
|
|
15
15
|
|
|
16
16
|
export async function handleAdvancedTool(
|
|
@@ -46,7 +46,8 @@ export async function handleAdvancedTool(
|
|
|
46
46
|
for (const k of lessonKeys) {
|
|
47
47
|
const raw = await redis.get(k);
|
|
48
48
|
if (!raw) continue;
|
|
49
|
-
|
|
49
|
+
const lesson = safeJsonParse<Lesson | null>(raw, null);
|
|
50
|
+
if (lesson) lessons.set(k, lesson);
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
// Detect duplicates: same topic prefix (e.g. deploy:api vs deploy:api-v2)
|
|
@@ -104,25 +105,6 @@ export async function handleAdvancedTool(
|
|
|
104
105
|
for (const k of stale) {
|
|
105
106
|
await redis.expire(k, 86400 * 30);
|
|
106
107
|
}
|
|
107
|
-
// Auto-unpin lessons whose lesson key was deleted (merged or stale-expired)
|
|
108
|
-
// Also unpin if the lesson outcome changed to 'failure' (auto-pin only fires on success/partial)
|
|
109
|
-
const pinKeys: string[] = [];
|
|
110
|
-
const pinStream = redis.scanStream({ match: 'cachly:lesson:pin:*', count: 100 });
|
|
111
|
-
await new Promise<void>((res, rej) => { pinStream.on('data', (b: string[]) => pinKeys.push(...b)); pinStream.on('end', res); pinStream.on('error', rej); });
|
|
112
|
-
let unpinned = 0;
|
|
113
|
-
for (const pk of pinKeys) {
|
|
114
|
-
const pinnedTopic = pk.replace('cachly:lesson:pin:', '');
|
|
115
|
-
const lessonStillExists = await redis.exists(`cachly:lesson:best:${pinnedTopic}`);
|
|
116
|
-
if (!lessonStillExists) { await redis.del(pk); unpinned++; continue; }
|
|
117
|
-
const raw = await redis.get(`cachly:lesson:best:${pinnedTopic}`);
|
|
118
|
-
if (raw) {
|
|
119
|
-
try {
|
|
120
|
-
const l = JSON.parse(raw) as { outcome: string };
|
|
121
|
-
if (l.outcome === 'failure') { await redis.del(pk); unpinned++; }
|
|
122
|
-
} catch { /* skip */ }
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
if (unpinned > 0) stale.push(...Array(unpinned).fill('__unpin_placeholder__')); // just for count
|
|
126
108
|
}
|
|
127
109
|
|
|
128
110
|
const lines = [
|
|
@@ -186,8 +168,9 @@ export async function handleAdvancedTool(
|
|
|
186
168
|
for (const k of lessonKeys) {
|
|
187
169
|
const raw = await redis.get(k);
|
|
188
170
|
if (!raw) continue;
|
|
189
|
-
|
|
190
|
-
const l =
|
|
171
|
+
{
|
|
172
|
+
const l = safeJsonParse<Lesson | null>(raw, null);
|
|
173
|
+
if (!l) continue;
|
|
191
174
|
const ts = new Date(l.ts).getTime();
|
|
192
175
|
if (ts >= sinceMs) {
|
|
193
176
|
// Check history to determine add vs update
|
|
@@ -201,7 +184,7 @@ export async function handleAdvancedTool(
|
|
|
201
184
|
// lessons with recalls as "active"
|
|
202
185
|
recalled.push(l);
|
|
203
186
|
}
|
|
204
|
-
}
|
|
187
|
+
}
|
|
205
188
|
}
|
|
206
189
|
|
|
207
190
|
const sinceLabel = match ? `last ${since}` : new Date(sinceMs).toLocaleDateString('de-DE');
|
|
@@ -235,10 +218,10 @@ export async function handleAdvancedTool(
|
|
|
235
218
|
for (const rk of resolutionKeys) {
|
|
236
219
|
const rr = await redis.get(rk);
|
|
237
220
|
if (!rr) continue;
|
|
238
|
-
|
|
239
|
-
const rn =
|
|
240
|
-
if (rn
|
|
241
|
-
}
|
|
221
|
+
{
|
|
222
|
+
const rn = safeJsonParse<{ topic?: string; resolution?: string; ts?: string } | null>(rr, null);
|
|
223
|
+
if (rn?.ts && new Date(rn.ts).getTime() >= sinceMs) recentResolutions.push({ topic: rn.topic ?? rk, resolution: rn.resolution ?? 'unknown', ts: rn.ts });
|
|
224
|
+
}
|
|
242
225
|
}
|
|
243
226
|
if (recentResolutions.length > 0) {
|
|
244
227
|
lines.push(`🗳️ **MADC Resolutions** (${recentResolutions.length} contested beliefs resolved):`);
|
|
@@ -258,8 +241,10 @@ export async function handleAdvancedTool(
|
|
|
258
241
|
|
|
259
242
|
// 3. FedBrain transfers received in window
|
|
260
243
|
const fedHistRaw = await redis.lrange('cachly:fedbrain:federations', -20, -1);
|
|
261
|
-
|
|
262
|
-
|
|
244
|
+
type FedEntry = { source: string; domain: string; transferred_at: string; nodes: number; edges: number };
|
|
245
|
+
const recentFeds = fedHistRaw
|
|
246
|
+
.map(r => safeJsonParse<FedEntry | null>(r, null))
|
|
247
|
+
.filter((f): f is FedEntry => f !== null && new Date(f.transferred_at).getTime() >= sinceMs);
|
|
263
248
|
if (recentFeds.length > 0) {
|
|
264
249
|
lines.push(`🧠 **FedBrain transfers received (${recentFeds.length}):**`);
|
|
265
250
|
for (const f of recentFeds.slice(0, 3)) {
|
|
@@ -302,27 +287,30 @@ export async function handleAdvancedTool(
|
|
|
302
287
|
for (const nodeKey of nodeKeys.slice(0, 10)) {
|
|
303
288
|
const nodeRaw = await redis.get(nodeKey);
|
|
304
289
|
if (!nodeRaw) continue;
|
|
305
|
-
const node
|
|
290
|
+
const node = safeJsonParse<CKGNode | null>(nodeRaw, null);
|
|
291
|
+
if (!node) continue;
|
|
306
292
|
// Get edges from this node
|
|
307
293
|
const edgeKeys = await redis.smembers(`cachly:ckg:idx:from:${node.id}`);
|
|
308
294
|
for (const ek of edgeKeys.slice(0, 20)) {
|
|
309
295
|
const edgeRaw = await redis.get(ek);
|
|
310
296
|
if (!edgeRaw) continue;
|
|
311
|
-
const edge
|
|
297
|
+
const edge = safeJsonParse<CKGEdge | null>(edgeRaw, null);
|
|
298
|
+
if (!edge) continue;
|
|
312
299
|
if (edge.edgeType !== 'fixes' && edge.edgeType !== 'requires') continue;
|
|
313
300
|
// Find lesson for this concept
|
|
314
301
|
const lessonRaw = await redis.get(`cachly:lesson:best:${edge.from.replace(/-/g, ':').replace(/^fix:/, 'fix:')}`);
|
|
315
|
-
const lesson = lessonRaw ?
|
|
316
|
-
ckgResults.push({ conceptId: node.id, edge, lesson });
|
|
302
|
+
const lesson = lessonRaw ? safeJsonParse<{ topic: string; what_worked?: string; ts: string; outcome: string; recall_count?: number; severity?: string } | null>(lessonRaw, null) ?? undefined : undefined;
|
|
303
|
+
ckgResults.push({ conceptId: node.id, edge, lesson: lesson ?? undefined });
|
|
317
304
|
}
|
|
318
305
|
}
|
|
319
306
|
for (const ek of [...fromKeys, ...toKeys].slice(0, 20)) {
|
|
320
307
|
const edgeRaw = await redis.get(ek);
|
|
321
308
|
if (!edgeRaw) continue;
|
|
322
|
-
const edge
|
|
309
|
+
const edge = safeJsonParse<CKGEdge | null>(edgeRaw, null);
|
|
310
|
+
if (!edge) continue;
|
|
323
311
|
const lessonRaw = await redis.get(`cachly:lesson:best:${edge.from}`);
|
|
324
|
-
const lesson = lessonRaw ?
|
|
325
|
-
ckgResults.push({ conceptId: edge.from, edge, lesson });
|
|
312
|
+
const lesson = lessonRaw ? safeJsonParse<{ topic: string; what_worked?: string; ts: string; outcome: string; recall_count?: number; severity?: string } | null>(lessonRaw, null) ?? undefined : undefined;
|
|
313
|
+
ckgResults.push({ conceptId: edge.from, edge, lesson: lesson ?? undefined });
|
|
326
314
|
}
|
|
327
315
|
}
|
|
328
316
|
} catch { /* CKG traversal non-critical */ }
|
|
@@ -356,14 +344,15 @@ export async function handleAdvancedTool(
|
|
|
356
344
|
for (const k of lessonKeys) {
|
|
357
345
|
const raw = await redis.get(k);
|
|
358
346
|
if (!raw) continue;
|
|
359
|
-
|
|
360
|
-
const l =
|
|
347
|
+
{
|
|
348
|
+
const l = safeJsonParse<Lesson | null>(raw, null);
|
|
349
|
+
if (!l) continue;
|
|
361
350
|
if (filterTags.length > 0 && !(l.tags ?? []).some((t: string) => filterTags.includes(t))) continue;
|
|
362
351
|
const haystack = [l.topic, l.what_failed ?? '', l.what_worked ?? '', l.context ?? '']
|
|
363
352
|
.join(' ').toLowerCase();
|
|
364
353
|
const score = tokens.reduce((s, t) => s + (haystack.includes(t) ? 1 : 0), 0);
|
|
365
354
|
if (score > 0) scored.push({ score, lesson: l, key: k });
|
|
366
|
-
}
|
|
355
|
+
}
|
|
367
356
|
}
|
|
368
357
|
scored.sort((a, b) => b.score - a.score);
|
|
369
358
|
const chain = scored.slice(0, max_depth);
|
|
@@ -440,6 +429,10 @@ export async function handleAdvancedTool(
|
|
|
440
429
|
lines.push(topSolution.what_worked.slice(0, 500));
|
|
441
430
|
lines.push(`\`\`\``);
|
|
442
431
|
lines.push('');
|
|
432
|
+
|
|
433
|
+
// Track estimated time saved — causal_trace finding a known fix is worth 1-4h
|
|
434
|
+
const savedMins = topSolution.severity === 'critical' ? 240 : topSolution.severity === 'major' ? 120 : 60;
|
|
435
|
+
redis.incrbyfloat(`cachly:stats:time_saved_mins:${instance_id}`, savedMins).catch(() => {});
|
|
443
436
|
}
|
|
444
437
|
}
|
|
445
438
|
|
|
@@ -472,8 +465,9 @@ export async function handleAdvancedTool(
|
|
|
472
465
|
for (const k of lessonKeys) {
|
|
473
466
|
const raw = await redis.get(k);
|
|
474
467
|
if (!raw) continue;
|
|
475
|
-
|
|
476
|
-
const l =
|
|
468
|
+
{
|
|
469
|
+
const l = safeJsonParse<Lesson | null>(raw, null);
|
|
470
|
+
if (!l) continue;
|
|
477
471
|
const ageMs = now - new Date(l.ts).getTime();
|
|
478
472
|
if (ageMs < minAgeMs) continue;
|
|
479
473
|
const age_days = Math.floor(ageMs / 86400000);
|
|
@@ -490,7 +484,7 @@ export async function handleAdvancedTool(
|
|
|
490
484
|
const confidence = Math.min(100, Math.max(0, base + recallBoost + outcomePenalty));
|
|
491
485
|
|
|
492
486
|
scores.push({ topic: l.topic, confidence, age_days, recalls, outcome: l.outcome });
|
|
493
|
-
}
|
|
487
|
+
}
|
|
494
488
|
}
|
|
495
489
|
|
|
496
490
|
// Sort by lowest confidence first
|
|
@@ -547,12 +541,6 @@ export async function handleAdvancedTool(
|
|
|
547
541
|
- **Window opens** → call \`session_start(instance_id="${instance_id}", focus="<today's goal>")\`
|
|
548
542
|
- **Bug fixed / deployed / discovered** → call \`learn_from_attempts(instance_id="${instance_id}", topic="fix:<area>", outcome="success", what_worked="...")\`
|
|
549
543
|
- **Window closes** → call \`session_end(instance_id="${instance_id}", summary="...", files_changed=[...])\`
|
|
550
|
-
|
|
551
|
-
### New team member setup:
|
|
552
|
-
\`\`\`bash
|
|
553
|
-
npx @cachly-dev/init
|
|
554
|
-
\`\`\`
|
|
555
|
-
Run once — auto-detects all AI editors and gives instant full context.
|
|
556
544
|
`;
|
|
557
545
|
|
|
558
546
|
const full = `## Cachly AI Brain — Autopilot for ${project_name}
|
|
@@ -624,22 +612,9 @@ smart_recall(
|
|
|
624
612
|
|
|
625
613
|
---
|
|
626
614
|
|
|
627
|
-
## 👋 New team member?
|
|
628
|
-
|
|
629
|
-
\`\`\`bash
|
|
630
|
-
npx @cachly-dev/init
|
|
631
|
-
\`\`\`
|
|
632
|
-
|
|
633
|
-
Run once — auto-detects Claude Code, Cursor, Copilot, Windsurf, Continue and writes all MCP configs. Free, no credit card.
|
|
634
|
-
|
|
635
|
-
Demo: [cachly.dev](https://cachly.dev)
|
|
636
|
-
|
|
637
|
-
---
|
|
638
|
-
|
|
639
615
|
*Cachly v0.6 · Generated ${new Date().toISOString().slice(0, 10)}*
|
|
640
616
|
`;
|
|
641
617
|
|
|
642
|
-
|
|
643
618
|
const content = style === 'minimal' ? minimal : full;
|
|
644
619
|
const filename = editor === 'copilot'
|
|
645
620
|
? '.github/copilot-instructions.md'
|
|
@@ -668,161 +643,6 @@ Demo: [cachly.dev](https://cachly.dev)
|
|
|
668
643
|
].join('\n');
|
|
669
644
|
}
|
|
670
645
|
|
|
671
|
-
// ── brain_stats ─────────────────────────────────────────────────────────
|
|
672
|
-
case 'brain_stats': {
|
|
673
|
-
const { instance_id } = args as { instance_id: string };
|
|
674
|
-
const redis = await getConnection(instance_id);
|
|
675
|
-
|
|
676
|
-
// Lessons
|
|
677
|
-
const lessonKeys: string[] = [];
|
|
678
|
-
const lStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
|
|
679
|
-
await new Promise<void>((res, rej) => { lStream.on('data', (b: string[]) => lessonKeys.push(...b)); lStream.on('end', res); lStream.on('error', rej); });
|
|
680
|
-
|
|
681
|
-
type LessonMeta = { topic: string; outcome: string; ts: string; severity?: string; recall_count?: number };
|
|
682
|
-
const lessons: LessonMeta[] = [];
|
|
683
|
-
if (lessonKeys.length > 0) {
|
|
684
|
-
const raws = await redis.mget(...lessonKeys);
|
|
685
|
-
for (const r of raws) {
|
|
686
|
-
if (!r) continue;
|
|
687
|
-
try { lessons.push(JSON.parse(r) as LessonMeta); } catch { /* skip */ }
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
// Context entries
|
|
692
|
-
let ctxCount = 0;
|
|
693
|
-
const ctxStream = redis.scanStream({ match: 'cachly:ctx:*', count: 200 });
|
|
694
|
-
await new Promise<void>((res, rej) => {
|
|
695
|
-
ctxStream.on('data', (b: string[]) => { ctxCount += (b as string[]).filter(k => !k.endsWith(':meta')).length; });
|
|
696
|
-
ctxStream.on('end', res); ctxStream.on('error', rej);
|
|
697
|
-
});
|
|
698
|
-
|
|
699
|
-
// Roadmap items
|
|
700
|
-
let roadmapOpen = 0; let roadmapDone = 0;
|
|
701
|
-
try {
|
|
702
|
-
const rm = await redis.hgetall(`cachly:roadmap:${instance_id}`);
|
|
703
|
-
for (const v of Object.values(rm ?? {})) {
|
|
704
|
-
try {
|
|
705
|
-
const item = JSON.parse(v as string) as { status: string };
|
|
706
|
-
if (item.status === 'done') roadmapDone++;
|
|
707
|
-
else if (['planned', 'in-progress', 'blocked'].includes(item.status)) roadmapOpen++;
|
|
708
|
-
} catch { /* skip */ }
|
|
709
|
-
}
|
|
710
|
-
} catch { /* non-critical */ }
|
|
711
|
-
|
|
712
|
-
// Pinned lessons
|
|
713
|
-
const pinKeys: string[] = [];
|
|
714
|
-
const pStream = redis.scanStream({ match: 'cachly:lesson:pin:*', count: 100 });
|
|
715
|
-
await new Promise<void>((res, rej) => { pStream.on('data', (b: string[]) => pinKeys.push(...b)); pStream.on('end', res); pStream.on('error', rej); });
|
|
716
|
-
const pinnedTopics = pinKeys.map(k => k.replace('cachly:lesson:pin:', ''));
|
|
717
|
-
|
|
718
|
-
// Streak
|
|
719
|
-
let streakDays = 0;
|
|
720
|
-
try {
|
|
721
|
-
const sr = await redis.get('cachly:streak:current');
|
|
722
|
-
if (sr) streakDays = (JSON.parse(sr) as { days: number }).days;
|
|
723
|
-
} catch { /* skip */ }
|
|
724
|
-
|
|
725
|
-
// Last session
|
|
726
|
-
let lastSessionSummary = 'none yet';
|
|
727
|
-
let lastSessionAgo = '';
|
|
728
|
-
try {
|
|
729
|
-
const lsr = await redis.get('cachly:session:last');
|
|
730
|
-
if (lsr) {
|
|
731
|
-
const ls = JSON.parse(lsr) as { summary: string; ts: string };
|
|
732
|
-
const ago = Math.round((Date.now() - new Date(ls.ts).getTime()) / 60000);
|
|
733
|
-
lastSessionAgo = ago < 60 ? `${ago}m ago` : ago < 1440 ? `${Math.round(ago / 60)}h ago` : `${Math.round(ago / 1440)}d ago`;
|
|
734
|
-
lastSessionSummary = ls.summary;
|
|
735
|
-
}
|
|
736
|
-
} catch { /* skip */ }
|
|
737
|
-
|
|
738
|
-
// Crystal
|
|
739
|
-
let crystalLabel = '';
|
|
740
|
-
try {
|
|
741
|
-
const cr = await redis.get('cachly:crystal:latest');
|
|
742
|
-
if (cr) crystalLabel = (JSON.parse(cr) as { label: string }).label;
|
|
743
|
-
} catch { /* skip */ }
|
|
744
|
-
|
|
745
|
-
// Top topics
|
|
746
|
-
const topicFreq = new Map<string, number>();
|
|
747
|
-
for (const l of lessons) {
|
|
748
|
-
const cat = l.topic.split(':')[0] ?? 'other';
|
|
749
|
-
topicFreq.set(cat, (topicFreq.get(cat) ?? 0) + 1);
|
|
750
|
-
}
|
|
751
|
-
const topTopics = [...topicFreq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
752
|
-
|
|
753
|
-
// Outcomes
|
|
754
|
-
const successCount = lessons.filter(l => l.outcome === 'success').length;
|
|
755
|
-
const failCount = lessons.filter(l => l.outcome === 'failure').length;
|
|
756
|
-
const totalRecalls = lessons.reduce((s, l) => s + (l.recall_count ?? 0), 0);
|
|
757
|
-
|
|
758
|
-
const lines: string[] = [
|
|
759
|
-
'🧠 **Brain Stats**',
|
|
760
|
-
'',
|
|
761
|
-
`📚 **Lessons:** ${lessons.length} (✅ ${successCount} success · ❌ ${failCount} open · recalled ${totalRecalls}×)`,
|
|
762
|
-
`📁 **Context:** ${ctxCount} entries`,
|
|
763
|
-
`🗺️ **Roadmap:** ${roadmapOpen} open · ${roadmapDone} done`,
|
|
764
|
-
`🔥 **Streak:** ${streakDays > 0 ? `${streakDays} day${streakDays > 1 ? 's' : ''}` : 'no streak yet'}`,
|
|
765
|
-
'',
|
|
766
|
-
];
|
|
767
|
-
|
|
768
|
-
if (pinnedTopics.length > 0) {
|
|
769
|
-
lines.push(`📌 **Pinned lessons (${pinnedTopics.length}):** ${pinnedTopics.map(t => `\`${t}\``).join(' · ')}`);
|
|
770
|
-
lines.push('');
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
if (topTopics.length > 0) {
|
|
774
|
-
lines.push('📊 **Top knowledge domains:**');
|
|
775
|
-
for (const [cat, count] of topTopics) {
|
|
776
|
-
lines.push(` · ${cat.padEnd(20)} ${count} lesson${count > 1 ? 's' : ''}`);
|
|
777
|
-
}
|
|
778
|
-
lines.push('');
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
if (lastSessionSummary !== 'none yet') {
|
|
782
|
-
lines.push(`📅 **Last session** (${lastSessionAgo}): ${lastSessionSummary}`);
|
|
783
|
-
lines.push('');
|
|
784
|
-
}
|
|
785
|
-
if (crystalLabel) {
|
|
786
|
-
lines.push(`💎 **Memory Crystal:** ${crystalLabel}`);
|
|
787
|
-
lines.push('');
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
lines.push('💡 `brain_doctor` for health recommendations · `brain_diff(since="7d")` for what changed');
|
|
791
|
-
return lines.join('\n');
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
// ── pin_lesson ──────────────────────────────────────────────────────────
|
|
795
|
-
case 'pin_lesson': {
|
|
796
|
-
const { instance_id, topic } = args as { instance_id: string; topic: string };
|
|
797
|
-
const redis = await getConnection(instance_id);
|
|
798
|
-
|
|
799
|
-
// Verify the lesson exists
|
|
800
|
-
const lessonRaw = await redis.get(`cachly:lesson:best:${topic}`);
|
|
801
|
-
if (!lessonRaw) {
|
|
802
|
-
return `❌ No lesson found for topic \`${topic}\`.\n\nFirst create it with \`learn_from_attempts(topic="${topic}", ...)\`.`;
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
await redis.set(`cachly:lesson:pin:${topic}`, '1');
|
|
806
|
-
return [
|
|
807
|
-
`📌 **Pinned** \`${topic}\``,
|
|
808
|
-
'',
|
|
809
|
-
'This lesson will now appear at the top of every `session_start` briefing, regardless of age.',
|
|
810
|
-
'',
|
|
811
|
-
`Remove with: \`unpin_lesson(instance_id="...", topic="${topic}")\``,
|
|
812
|
-
].join('\n');
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
// ── unpin_lesson ─────────────────────────────────────────────────────────
|
|
816
|
-
case 'unpin_lesson': {
|
|
817
|
-
const { instance_id, topic } = args as { instance_id: string; topic: string };
|
|
818
|
-
const redis = await getConnection(instance_id);
|
|
819
|
-
const deleted = await redis.del(`cachly:lesson:pin:${topic}`);
|
|
820
|
-
if (deleted === 0) {
|
|
821
|
-
return `ℹ️ \`${topic}\` was not pinned. Use \`brain_stats\` to see all pinned lessons.`;
|
|
822
|
-
}
|
|
823
|
-
return `📌 Unpinned \`${topic}\`. It will now follow normal recency/confidence decay in session_start.`;
|
|
824
|
-
}
|
|
825
|
-
|
|
826
646
|
// ── v0.7 Knowledge Syndication: syndicate ────────────────────────────────
|
|
827
647
|
|
|
828
648
|
default:
|