@cachly-dev/mcp-server 0.5.36 → 0.5.38
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/.github/workflows/publish.yml +28 -0
- package/Dockerfile +15 -12
- package/LICENSE +202 -0
- package/README.md +2 -8
- package/dist/index.d.ts +2 -7
- package/dist/index.js +319 -2096
- package/dist/index.js.map +1 -1
- package/glama.json +6 -0
- package/package.json +1 -1
- package/smithery.yaml +5 -10
- package/src/index.ts +328 -2202
- package/CHANGELOG.md +0 -65
- package/dist/__tests__/namespace.test.d.ts +0 -1
- package/dist/__tests__/namespace.test.js +0 -114
- package/dist/__tests__/namespace.test.js.map +0 -1
- package/run-mcp.sh +0 -57
- package/scripts/postinstall.js +0 -36
- package/src/__tests__/namespace.test.ts +0 -126
- package/test-features.cjs +0 -23
package/src/index.ts
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { createHash } from 'node:crypto';
|
|
4
3
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
5
|
-
import { readFileSync } from 'node:fs';
|
|
6
|
-
import { execSync } from 'node:child_process';
|
|
7
4
|
import { join, relative, extname } from 'node:path';
|
|
8
5
|
/**
|
|
9
|
-
* cachly MCP Server v0.
|
|
6
|
+
* cachly MCP Server v0.5.38
|
|
10
7
|
*
|
|
11
8
|
* Exposes cachly.dev as MCP tools so any AI assistant
|
|
12
9
|
* (GitHub Copilot, Claude, Cursor, Windsurf, Continue.dev …) can:
|
|
@@ -34,6 +31,7 @@ import { join, relative, extname } from 'node:path';
|
|
|
34
31
|
*
|
|
35
32
|
* Configuration (env vars):
|
|
36
33
|
* CACHLY_API_URL – default https://api.cachly.dev
|
|
34
|
+
* CACHLY_AUTH_URL – default https://auth.cachly.dev (Keycloak base URL for health checks)
|
|
37
35
|
* CACHLY_JWT – your JWT (Keycloak access token)
|
|
38
36
|
* CACHLY_EMBED_PROVIDER – embedding backend: openai (default), gemini, mistral, cohere, ollama, cachly (server fallback)
|
|
39
37
|
* CACHLY_EMBED_MODEL – override embedding model (optional)
|
|
@@ -41,7 +39,6 @@ import { join, relative, extname } from 'node:path';
|
|
|
41
39
|
|
|
42
40
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
43
41
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
44
|
-
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
45
42
|
import {
|
|
46
43
|
CallToolRequestSchema,
|
|
47
44
|
ErrorCode,
|
|
@@ -53,9 +50,9 @@ import { Redis } from 'ioredis';
|
|
|
53
50
|
// ── Config ────────────────────────────────────────────────────────────────────
|
|
54
51
|
|
|
55
52
|
const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
|
|
53
|
+
const AUTH_URL = process.env.CACHLY_AUTH_URL ?? 'https://auth.cachly.dev';
|
|
56
54
|
const JWT = process.env.CACHLY_JWT ?? '';
|
|
57
55
|
const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
|
|
58
|
-
const CURRENT_VERSION = '0.5.26';
|
|
59
56
|
/**
|
|
60
57
|
* ┌──────────────────────────────────────────────────────────────────────┐
|
|
61
58
|
* │ EMBEDDING PROVIDER — pluggable, client-side first │
|
|
@@ -75,44 +72,6 @@ const CURRENT_VERSION = '0.5.26';
|
|
|
75
72
|
*/
|
|
76
73
|
const EMBED_PROVIDER = (process.env.CACHLY_EMBED_PROVIDER ?? detectEmbedProvider()).toLowerCase();
|
|
77
74
|
|
|
78
|
-
// ── Confidence Decay Config (all values configurable via env) ─────────────────
|
|
79
|
-
const CONFIDENCE_WARN_DAYS = Number(process.env.CACHLY_CONFIDENCE_WARN_DAYS ?? 5); // → 0.7
|
|
80
|
-
const CONFIDENCE_STALE_DAYS = Number(process.env.CACHLY_CONFIDENCE_STALE_DAYS ?? 10); // → 0.5
|
|
81
|
-
const CONFIDENCE_WARN_VALUE = 0.7;
|
|
82
|
-
const CONFIDENCE_STALE_VALUE = 0.5;
|
|
83
|
-
|
|
84
|
-
/** Calculate current confidence for a lesson based on how long since last verified. */
|
|
85
|
-
function calculateConfidence(lesson: { verified_at?: string; ts: string; recall_count?: number }): number {
|
|
86
|
-
const ref = lesson.verified_at ?? lesson.ts;
|
|
87
|
-
const ageMs = Date.now() - new Date(ref).getTime();
|
|
88
|
-
const ageDays = ageMs / 86400000;
|
|
89
|
-
if (ageDays >= CONFIDENCE_STALE_DAYS) return CONFIDENCE_STALE_VALUE;
|
|
90
|
-
if (ageDays >= CONFIDENCE_WARN_DAYS) return CONFIDENCE_WARN_VALUE;
|
|
91
|
-
// Linear interpolation between fresh (1.0) and warn threshold
|
|
92
|
-
return 1.0 - (ageDays / CONFIDENCE_WARN_DAYS) * (1.0 - CONFIDENCE_WARN_VALUE);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Render a confidence badge string. */
|
|
96
|
-
function confidenceBadge(confidence: number, ageDays: number): string {
|
|
97
|
-
if (confidence >= 0.9) return '✅';
|
|
98
|
-
if (confidence >= 0.7) return `⚠️ (${Math.round(ageDays)}d old, confidence ${(confidence * 100).toFixed(0)}% — verify before applying)`;
|
|
99
|
-
return `🔴 STALE (${Math.round(ageDays)}d old, confidence ${(confidence * 100).toFixed(0)}% — likely outdated!)`;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** Category-specific required fields for structured lessons. */
|
|
103
|
-
const STRUCTURED_TEMPLATES: Record<string, { required: string[]; hint: string }> = {
|
|
104
|
-
'deploy': { required: ['commands'], hint: 'deploy:* needs commands[]' },
|
|
105
|
-
'bash': { required: ['commands'], hint: 'bash:* needs commands[]' },
|
|
106
|
-
'infra': { required: ['commands'], hint: 'infra:* needs commands[] + verified on real system' },
|
|
107
|
-
'pricing': { required: [], hint: 'pricing:* — add context with source (e.g. "Stripe Dashboard Apr 2026")' },
|
|
108
|
-
'stripe': { required: [], hint: 'stripe:* — add context with Stripe API version' },
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
/** Fast content hash for index invalidation (not cryptographic, just change detection) */
|
|
112
|
-
function simpleHash(text: string): string {
|
|
113
|
-
return createHash('md5').update(text).digest('hex').slice(0, 12);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
75
|
function detectEmbedProvider(): string {
|
|
117
76
|
// Client-side keys first (direct, fast, no server roundtrip)
|
|
118
77
|
if (process.env.OPENAI_API_KEY) return 'openai';
|
|
@@ -301,15 +260,13 @@ function embedProviderHint(): string {
|
|
|
301
260
|
// • Recency boost — newer entries rank higher (exponential decay, 7-day half-life)
|
|
302
261
|
// • Multi-query splitting — numbered lists, semicolons, conjunctions
|
|
303
262
|
// • Fuzzy matching with Levenshtein distance ≤ 2
|
|
304
|
-
// • Multilingual stopwords (EN, DE, FR, ES, IT, PT
|
|
305
|
-
// • CJK character bigram extraction — handles Chinese, Japanese, Korean
|
|
263
|
+
// • Multilingual stopwords (EN, DE, FR, ES, IT, PT)
|
|
306
264
|
// • Pipeline Redis reads for performance
|
|
307
265
|
//
|
|
308
266
|
|
|
309
267
|
/**
|
|
310
268
|
* Stopwords — filtered out during tokenization.
|
|
311
|
-
* Covers: English, German, French, Spanish, Italian, Portuguese
|
|
312
|
-
* Chinese (Simplified + Traditional), Japanese, Korean.
|
|
269
|
+
* Covers: English, German, French, Spanish, Italian, Portuguese.
|
|
313
270
|
* Keeps the index small and scores meaningful.
|
|
314
271
|
*/
|
|
315
272
|
const STOPWORDS = new Set([
|
|
@@ -383,63 +340,15 @@ const STOPWORDS = new Set([
|
|
|
383
340
|
'eu', 'tu', 'ele', 'ela', 'nós', 'eles', 'elas', 'você', 'vocês',
|
|
384
341
|
'não', 'já', 'sim', 'bem', 'muito', 'também', 'ainda', 'sempre',
|
|
385
342
|
'todo', 'toda', 'todos', 'cada', 'outro', 'outra', 'quando', 'porque',
|
|
386
|
-
// ── Chinese (Simplified + Traditional) — high-frequency function characters ──
|
|
387
|
-
'的', '了', '是', '在', '不', '和', '我', '他', '这', '中', '大', '为',
|
|
388
|
-
'上', '个', '国', '以', '要', '就', '出', '说', '们', '有', '来', '到',
|
|
389
|
-
'时', '地', '年', '得', '着', '那', '过', '后', '还', '与', '也', '可',
|
|
390
|
-
'于', '从', '但', '而', '被', '把', '让', '使', '对', '很', '都', '一',
|
|
391
|
-
'会', '没', '人', '它', '这个', '那个', '什么', '如果', '因为', '所以',
|
|
392
|
-
'已经', '可以', '这些', '那些', '我们', '他们', '她们', '它们',
|
|
393
|
-
// ── Japanese — common hiragana particles and auxiliary verbs ──
|
|
394
|
-
'の', 'は', 'が', 'を', 'に', 'で', 'と', 'も', 'や', 'か', 'な', 'ね',
|
|
395
|
-
'よ', 'わ', 'て', 'い', 'う', 'え', 'お', 'き', 'く', 'け', 'こ', 'さ',
|
|
396
|
-
'し', 'す', 'せ', 'そ', 'た', 'ち', 'つ', 'ぬ', 'ん', 'から', 'まで',
|
|
397
|
-
'より', 'へ', 'です', 'ます', 'ない', 'ある', 'いる', 'する', 'こと',
|
|
398
|
-
'もの', 'ので', 'では', 'には', 'との', 'への', 'から', 'まで',
|
|
399
|
-
// ── Korean — common particles and auxiliary forms ──
|
|
400
|
-
'이', '가', '은', '는', '을', '를', '의', '에', '와', '과', '도',
|
|
401
|
-
'로', '에서', '한', '하', '있', '없', '것', '수', '않', '들',
|
|
402
|
-
'이다', '하다', '이고', '이며', '라고', '에게', '에서', '으로',
|
|
403
343
|
]);
|
|
404
344
|
|
|
405
|
-
|
|
406
|
-
const CJK_RE = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]/;
|
|
407
|
-
const SEGMENT_RE = /([\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]+|[^\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]+)/g;
|
|
408
|
-
|
|
409
|
-
/**
|
|
410
|
-
* Tokenize text into meaningful keywords.
|
|
411
|
-
* Handles accented Latin characters and CJK (Chinese, Japanese, Korean).
|
|
412
|
-
* CJK text is split into character bigrams (the standard for indexing logographic languages).
|
|
413
|
-
*/
|
|
345
|
+
/** Tokenize text into meaningful keywords. Handles unicode (accented chars). */
|
|
414
346
|
function tokenize(text: string): string[] {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
// CJK segment — extract overlapping character bigrams
|
|
421
|
-
for (let i = 0; i < seg.length - 1; i++) {
|
|
422
|
-
const bigram = seg[i] + seg[i + 1];
|
|
423
|
-
if (!STOPWORDS.has(seg[i]) && !STOPWORDS.has(bigram)) {
|
|
424
|
-
tokens.push(bigram);
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
// Trailing single character (for 1-char CJK terms)
|
|
428
|
-
if (seg.length >= 1) {
|
|
429
|
-
const last = seg[seg.length - 1];
|
|
430
|
-
if (!STOPWORDS.has(last)) tokens.push(last);
|
|
431
|
-
}
|
|
432
|
-
} else {
|
|
433
|
-
// Latin / other — existing logic
|
|
434
|
-
const words = seg
|
|
435
|
-
.replace(/[^a-záàâãäåæçéèêëíìîïñóòôõöúùûüýÿßœ0-9:_\-./]/g, ' ')
|
|
436
|
-
.split(/\s+/)
|
|
437
|
-
.filter(w => w.length > 1 && !STOPWORDS.has(w));
|
|
438
|
-
tokens.push(...words);
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
return tokens;
|
|
347
|
+
return text
|
|
348
|
+
.toLowerCase()
|
|
349
|
+
.replace(/[^a-záàâãäåæçéèêëíìîïñóòôõöúùûüýÿßœ0-9:_\-./]/g, ' ')
|
|
350
|
+
.split(/\s+/)
|
|
351
|
+
.filter(w => w.length > 1 && !STOPWORDS.has(w));
|
|
443
352
|
}
|
|
444
353
|
|
|
445
354
|
/**
|
|
@@ -868,6 +777,20 @@ async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T>
|
|
|
868
777
|
},
|
|
869
778
|
});
|
|
870
779
|
if (!res.ok) {
|
|
780
|
+
if (res.status === 401 || res.status === 403) {
|
|
781
|
+
throw new McpError(
|
|
782
|
+
ErrorCode.InvalidRequest,
|
|
783
|
+
`cachly API: authentication failed (${res.status}) — your JWT may be expired or invalid.\n` +
|
|
784
|
+
`Get a new API token at https://cachly.dev/settings or run \`get_api_status\` to check token expiry.`
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
if (res.status === 503 || res.status === 502) {
|
|
788
|
+
throw new McpError(
|
|
789
|
+
ErrorCode.InternalError,
|
|
790
|
+
`cachly API: auth service unavailable (${res.status}) — the authentication service may be temporarily down.\n` +
|
|
791
|
+
`Please try again in a moment or contact support@cachly.dev`
|
|
792
|
+
);
|
|
793
|
+
}
|
|
871
794
|
const body = await res.json().catch(() => ({ error: res.statusText }));
|
|
872
795
|
throw new McpError(
|
|
873
796
|
ErrorCode.InternalError,
|
|
@@ -894,8 +817,8 @@ const TOOLS = [
|
|
|
894
817
|
description:
|
|
895
818
|
'Create a new managed Valkey/Redis cache instance on cachly.dev. ' +
|
|
896
819
|
'Free tier provisions in ~30 seconds. Paid tiers return a Stripe checkout URL. ' +
|
|
897
|
-
'Available tiers: free (
|
|
898
|
-
'speed (
|
|
820
|
+
'Available tiers: free (30 MB), dev (256 MB, €8/mo), pro (1 GB, €25/mo), ' +
|
|
821
|
+
'speed (1 GB Dragonfly + Semantic Cache, €39/mo), business (8 GB, €99/mo).',
|
|
899
822
|
inputSchema: {
|
|
900
823
|
type: 'object',
|
|
901
824
|
properties: {
|
|
@@ -1270,10 +1193,10 @@ const TOOLS = [
|
|
|
1270
1193
|
{
|
|
1271
1194
|
name: 'get_api_status',
|
|
1272
1195
|
description:
|
|
1273
|
-
'Check the cachly API health and your authentication status. ' +
|
|
1274
|
-
'Returns whether the
|
|
1275
|
-
'token expiry, and the auth provider
|
|
1276
|
-
'Use this to debug connection issues
|
|
1196
|
+
'Check the cachly API health, auth service reachability, and your authentication status. ' +
|
|
1197
|
+
'Returns API health, whether the Keycloak auth service is reachable, JWT validity, ' +
|
|
1198
|
+
'user ID (sub claim), token expiry, and the auth provider. ' +
|
|
1199
|
+
'Use this to debug connection issues, verify your CACHLY_JWT, or diagnose "Auth Service Unavailable" errors.',
|
|
1277
1200
|
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
1278
1201
|
},
|
|
1279
1202
|
// ── Thinking/Context Cache (for AI assistants) ────────────────────────────
|
|
@@ -1364,9 +1287,7 @@ const TOOLS = [
|
|
|
1364
1287
|
'Fields: topic (short slug like "deploy:web"), outcome ("success"|"failure"), ' +
|
|
1365
1288
|
'what_worked (what solved it), what_failed (what did NOT work), context (extra details). ' +
|
|
1366
1289
|
'Supports structured metadata: severity, file_paths (files involved), commands (working commands), tags. ' +
|
|
1367
|
-
'Deduplication: if a lesson for this topic already exists, it is updated
|
|
1368
|
-
'Contradiction detection: warns if new outcome conflicts with existing lesson outcome. ' +
|
|
1369
|
-
'Confidence: lesson starts at 1.0, decays after 5d (→0.7) and 10d (→0.5) without recall. ' +
|
|
1290
|
+
'Deduplication: if a lesson for this topic already exists, it is updated instead of duplicated. ' +
|
|
1370
1291
|
'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"])',
|
|
1371
1292
|
inputSchema: {
|
|
1372
1293
|
type: 'object',
|
|
@@ -1397,15 +1318,6 @@ const TOOLS = [
|
|
|
1397
1318
|
items: { type: 'string' },
|
|
1398
1319
|
description: 'Topic tags for filtering (e.g. ["bash", "deploy", "env"])',
|
|
1399
1320
|
},
|
|
1400
|
-
depends_on: {
|
|
1401
|
-
type: 'array',
|
|
1402
|
-
items: { type: 'string' },
|
|
1403
|
-
description: 'Prerequisites this lesson depends on (e.g. ["node:>=20", "docker:running", "wireguard:active"]). When a dependency is marked stale, all dependent lessons get needs_review.',
|
|
1404
|
-
},
|
|
1405
|
-
author: {
|
|
1406
|
-
type: 'string',
|
|
1407
|
-
description: 'Name or handle of the person storing this lesson (e.g. "alice", "bob"). Used for Team Telepathy — teammates see each other\'s lessons in session_start.',
|
|
1408
|
-
},
|
|
1409
1321
|
},
|
|
1410
1322
|
required: ['instance_id', 'topic', 'outcome', 'what_worked'],
|
|
1411
1323
|
},
|
|
@@ -1415,9 +1327,7 @@ const TOOLS = [
|
|
|
1415
1327
|
description:
|
|
1416
1328
|
'Recall the best known solution for a topic from past lessons. ' +
|
|
1417
1329
|
'Call this BEFORE attempting any task that might have been done before. ' +
|
|
1418
|
-
'Returns the most recent successful lesson for the topic,
|
|
1419
|
-
'⚠️ badge = lesson is >5d old (verify before applying). 🔴 = >10d old (likely stale!). ' +
|
|
1420
|
-
'Recalling a lesson resets its confidence clock to 1.0 (marks as recently verified). ' +
|
|
1330
|
+
'Returns the most recent successful lesson for the topic, or a summary of attempts. ' +
|
|
1421
1331
|
'Example: recall_best_solution(topic="deploy:web") → returns the working deploy command.',
|
|
1422
1332
|
inputSchema: {
|
|
1423
1333
|
type: 'object',
|
|
@@ -1428,6 +1338,21 @@ const TOOLS = [
|
|
|
1428
1338
|
required: ['instance_id', 'topic'],
|
|
1429
1339
|
},
|
|
1430
1340
|
},
|
|
1341
|
+
{
|
|
1342
|
+
name: 'forget_lesson',
|
|
1343
|
+
description:
|
|
1344
|
+
'Delete a stored lesson from the Brain. Use this when a lesson is wrong, outdated, or was learned incorrectly. ' +
|
|
1345
|
+
'Removes both the best-solution entry and the attempt history for the topic. ' +
|
|
1346
|
+
'Example: forget_lesson(topic="deploy:web") → deletes the wrong deployment lesson so a correct one can be stored.',
|
|
1347
|
+
inputSchema: {
|
|
1348
|
+
type: 'object',
|
|
1349
|
+
properties: {
|
|
1350
|
+
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1351
|
+
topic: { type: 'string', description: 'Exact topic slug to delete, e.g. "deploy:web"' },
|
|
1352
|
+
},
|
|
1353
|
+
required: ['instance_id', 'topic'],
|
|
1354
|
+
},
|
|
1355
|
+
},
|
|
1431
1356
|
{
|
|
1432
1357
|
name: 'smart_recall',
|
|
1433
1358
|
description:
|
|
@@ -1450,8 +1375,7 @@ const TOOLS = [
|
|
|
1450
1375
|
description:
|
|
1451
1376
|
'Single-call session briefing. Call this at the START of every session INSTEAD of multiple separate smart_recall/recall_best_solution calls. ' +
|
|
1452
1377
|
'Returns: last session summary, recent lessons sorted by recency, relevant lessons for your focus area, ' +
|
|
1453
|
-
'open failures (topics with only failure outcomes), brain health stats
|
|
1454
|
-
'predictive pre-warnings (if your focus area has known failure patterns), and memory crystals (compressed wisdom from old sessions). ' +
|
|
1378
|
+
'open failures (topics with only failure outcomes), and brain health stats (lesson count, context count). ' +
|
|
1455
1379
|
'Also saves a session start marker so session_end can compute duration.',
|
|
1456
1380
|
inputSchema: {
|
|
1457
1381
|
type: 'object',
|
|
@@ -1461,10 +1385,6 @@ const TOOLS = [
|
|
|
1461
1385
|
type: 'string',
|
|
1462
1386
|
description: 'Keywords for what you plan to work on today (e.g. "deploy infra api"). Used to surface relevant lessons at the top.',
|
|
1463
1387
|
},
|
|
1464
|
-
author: {
|
|
1465
|
-
type: 'string',
|
|
1466
|
-
description: 'Your name or handle (e.g. "alice"). Enables Team Telepathy — filters YOUR lessons vs TEAM lessons from past 7 days.',
|
|
1467
|
-
},
|
|
1468
1388
|
},
|
|
1469
1389
|
required: ['instance_id'],
|
|
1470
1390
|
},
|
|
@@ -1475,8 +1395,7 @@ const TOOLS = [
|
|
|
1475
1395
|
'Save a session summary when you finish working. ' +
|
|
1476
1396
|
'Records what was accomplished, files changed, and lesson count. ' +
|
|
1477
1397
|
'The next session_start will show this summary as "Last session". ' +
|
|
1478
|
-
'Call this when ending a work session, before going idle, or before summarizing.
|
|
1479
|
-
'Ambient Learning: if workspace_path is provided, reads git log since session start and auto-learns from commits.',
|
|
1398
|
+
'Call this when ending a work session, before going idle, or before summarizing.',
|
|
1480
1399
|
inputSchema: {
|
|
1481
1400
|
type: 'object',
|
|
1482
1401
|
properties: {
|
|
@@ -1494,67 +1413,10 @@ const TOOLS = [
|
|
|
1494
1413
|
type: 'number',
|
|
1495
1414
|
description: 'Number of new lessons stored this session (optional)',
|
|
1496
1415
|
},
|
|
1497
|
-
workspace_path: {
|
|
1498
|
-
type: 'string',
|
|
1499
|
-
description: 'Absolute path to the project root (e.g. "/Users/you/myproject"). Enables Ambient Learning — reads git log since session start and auto-learns from commit messages.',
|
|
1500
|
-
},
|
|
1501
1416
|
},
|
|
1502
1417
|
required: ['instance_id', 'summary'],
|
|
1503
1418
|
},
|
|
1504
1419
|
},
|
|
1505
|
-
// ── Session Handoff — cross-window continuity ─────────────────────────────
|
|
1506
|
-
{
|
|
1507
|
-
name: 'session_handoff',
|
|
1508
|
-
description:
|
|
1509
|
-
'Save a detailed handoff for the NEXT chat window / session. ' +
|
|
1510
|
-
'Stores: current progress, TODO list (done + remaining), changed files with descriptions, ' +
|
|
1511
|
-
'instructions for the next assistant, and any incomplete work. ' +
|
|
1512
|
-
'The next session_start automatically includes this handoff so the new window knows EXACTLY what happened and what remains. ' +
|
|
1513
|
-
'Call this BEFORE closing a chat window, especially if work is incomplete. ' +
|
|
1514
|
-
'This prevents the "continue" problem where new windows lose context, skip tasks, or produce broken code.',
|
|
1515
|
-
inputSchema: {
|
|
1516
|
-
type: 'object',
|
|
1517
|
-
properties: {
|
|
1518
|
-
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1519
|
-
completed_tasks: {
|
|
1520
|
-
type: 'array',
|
|
1521
|
-
items: { type: 'string' },
|
|
1522
|
-
description: 'Tasks that were fully completed (e.g. "Implemented brainSearch() in JS SDK")',
|
|
1523
|
-
},
|
|
1524
|
-
remaining_tasks: {
|
|
1525
|
-
type: 'array',
|
|
1526
|
-
items: { type: 'string' },
|
|
1527
|
-
description: 'Tasks NOT yet done — the next window MUST pick these up',
|
|
1528
|
-
},
|
|
1529
|
-
files_changed: {
|
|
1530
|
-
type: 'array',
|
|
1531
|
-
items: {
|
|
1532
|
-
type: 'object',
|
|
1533
|
-
properties: {
|
|
1534
|
-
path: { type: 'string', description: 'File path relative to project root' },
|
|
1535
|
-
status: { type: 'string', enum: ['complete', 'partial', 'broken'], description: 'State of this file' },
|
|
1536
|
-
description: { type: 'string', description: 'What was changed and what still needs work' },
|
|
1537
|
-
},
|
|
1538
|
-
required: ['path', 'status'],
|
|
1539
|
-
},
|
|
1540
|
-
description: 'Changed files with their current state — marks partial/broken files so next window knows to fix them',
|
|
1541
|
-
},
|
|
1542
|
-
instructions: {
|
|
1543
|
-
type: 'string',
|
|
1544
|
-
description: 'Free-form instructions for the next assistant. Be specific: what to do next, what to avoid, what broke.',
|
|
1545
|
-
},
|
|
1546
|
-
context_summary: {
|
|
1547
|
-
type: 'string',
|
|
1548
|
-
description: 'Brief summary of what happened this session (architecture decisions, key findings, blockers)',
|
|
1549
|
-
},
|
|
1550
|
-
blocked_on: {
|
|
1551
|
-
type: 'string',
|
|
1552
|
-
description: 'If work is blocked, describe what is needed to unblock (e.g. "waiting for API deploy", "needs user input on design")',
|
|
1553
|
-
},
|
|
1554
|
-
},
|
|
1555
|
-
required: ['instance_id', 'completed_tasks', 'remaining_tasks'],
|
|
1556
|
-
},
|
|
1557
|
-
},
|
|
1558
1420
|
// ── AI Brain — Extended features ─────────────────────────────────────────
|
|
1559
1421
|
{
|
|
1560
1422
|
name: 'auto_learn_session',
|
|
@@ -1645,154 +1507,16 @@ const TOOLS = [
|
|
|
1645
1507
|
required: ['instance_id'],
|
|
1646
1508
|
},
|
|
1647
1509
|
},
|
|
1648
|
-
{
|
|
1649
|
-
name: 'team_synthesize',
|
|
1650
|
-
description:
|
|
1651
|
-
'Team Brain Synthesis — merge multiple contributors\' lessons on the same topic into one canonical version. ' +
|
|
1652
|
-
'When 2+ developers store lessons for the same topic with different details, this proposes the best merged version. ' +
|
|
1653
|
-
'Shows: all contributions by author, what worked (consensus), what failed (union), canonical lesson to store. ' +
|
|
1654
|
-
'Use this when onboarding new team members or before documenting a process.',
|
|
1655
|
-
inputSchema: {
|
|
1656
|
-
type: 'object',
|
|
1657
|
-
properties: {
|
|
1658
|
-
instance_id: { type: 'string', description: 'UUID of the shared team brain instance' },
|
|
1659
|
-
topic: { type: 'string', description: 'Topic slug to synthesize (e.g. "deploy:api")' },
|
|
1660
|
-
},
|
|
1661
|
-
required: ['instance_id', 'topic'],
|
|
1662
|
-
},
|
|
1663
|
-
},
|
|
1664
|
-
{
|
|
1665
|
-
name: 'memory_crystalize',
|
|
1666
|
-
description:
|
|
1667
|
-
'Compress the last 30-50 sessions and auto-learned lessons into a dense Memory Crystal. ' +
|
|
1668
|
-
'A crystal is a compact, structured summary of everything the brain learned — grouped by category (deploy, fix, debug, …). ' +
|
|
1669
|
-
'Crystals survive session cleanup and appear in session_start once enough sessions have accumulated. ' +
|
|
1670
|
-
'Run this monthly or after a big milestone to preserve institutional knowledge. ' +
|
|
1671
|
-
'Returns a digest of what was crystallized.',
|
|
1672
|
-
inputSchema: {
|
|
1673
|
-
type: 'object',
|
|
1674
|
-
properties: {
|
|
1675
|
-
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1676
|
-
label: {
|
|
1677
|
-
type: 'string',
|
|
1678
|
-
description: 'Optional label for this crystal (e.g. "Q1 2026", "v2 launch"). Auto-generated from date if omitted.',
|
|
1679
|
-
},
|
|
1680
|
-
},
|
|
1681
|
-
required: ['instance_id'],
|
|
1682
|
-
},
|
|
1683
|
-
},
|
|
1684
|
-
// ── Roadmap — Persistent project plan tracker ───────────────────────────
|
|
1685
|
-
{
|
|
1686
|
-
name: 'roadmap_add',
|
|
1687
|
-
description:
|
|
1688
|
-
'Add a new item to the persistent project roadmap stored in the Brain. ' +
|
|
1689
|
-
'Items survive across sessions and editors — the roadmap is always up to date. ' +
|
|
1690
|
-
'Use for features, bugs, refactors, or any planned work. ' +
|
|
1691
|
-
'Call roadmap_list to see all open items, roadmap_next to get the next actionable item.',
|
|
1692
|
-
inputSchema: {
|
|
1693
|
-
type: 'object',
|
|
1694
|
-
properties: {
|
|
1695
|
-
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1696
|
-
title: { type: 'string', description: 'Short title of the task/feature (3–10 words)' },
|
|
1697
|
-
description: { type: 'string', description: 'What needs to be done, acceptance criteria, context' },
|
|
1698
|
-
priority: {
|
|
1699
|
-
type: 'string',
|
|
1700
|
-
enum: ['critical', 'high', 'medium', 'low'],
|
|
1701
|
-
description: 'Priority level (default: medium)',
|
|
1702
|
-
},
|
|
1703
|
-
tags: {
|
|
1704
|
-
type: 'array',
|
|
1705
|
-
items: { type: 'string' },
|
|
1706
|
-
description: 'Tags for filtering (e.g. ["api", "web", "sdk", "infra"])',
|
|
1707
|
-
},
|
|
1708
|
-
milestone: { type: 'string', description: 'Milestone/epic this belongs to (optional)' },
|
|
1709
|
-
},
|
|
1710
|
-
required: ['instance_id', 'title'],
|
|
1711
|
-
},
|
|
1712
|
-
},
|
|
1713
|
-
{
|
|
1714
|
-
name: 'roadmap_update',
|
|
1715
|
-
description:
|
|
1716
|
-
'Update the status, priority, or details of a roadmap item. ' +
|
|
1717
|
-
'Use to move items through the lifecycle: planned → in-progress → done (or blocked/cancelled). ' +
|
|
1718
|
-
'Also use to add notes/findings while working on an item.',
|
|
1719
|
-
inputSchema: {
|
|
1720
|
-
type: 'object',
|
|
1721
|
-
properties: {
|
|
1722
|
-
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1723
|
-
id: { type: 'string', description: 'Item ID returned by roadmap_add or roadmap_list' },
|
|
1724
|
-
status: {
|
|
1725
|
-
type: 'string',
|
|
1726
|
-
enum: ['planned', 'in-progress', 'done', 'blocked', 'cancelled'],
|
|
1727
|
-
description: 'New status for the item',
|
|
1728
|
-
},
|
|
1729
|
-
priority: {
|
|
1730
|
-
type: 'string',
|
|
1731
|
-
enum: ['critical', 'high', 'medium', 'low'],
|
|
1732
|
-
description: 'Updated priority (optional)',
|
|
1733
|
-
},
|
|
1734
|
-
notes: { type: 'string', description: 'Progress notes, findings, or blockers (appended to existing notes)' },
|
|
1735
|
-
title: { type: 'string', description: 'Updated title (optional)' },
|
|
1736
|
-
description: { type: 'string', description: 'Updated description (optional)' },
|
|
1737
|
-
},
|
|
1738
|
-
required: ['instance_id', 'id'],
|
|
1739
|
-
},
|
|
1740
|
-
},
|
|
1741
|
-
{
|
|
1742
|
-
name: 'roadmap_list',
|
|
1743
|
-
description:
|
|
1744
|
-
'List all roadmap items, optionally filtered by status, priority, tag, or milestone. ' +
|
|
1745
|
-
'Returns items sorted by priority then creation date. ' +
|
|
1746
|
-
'Called automatically by session_start to show open work.',
|
|
1747
|
-
inputSchema: {
|
|
1748
|
-
type: 'object',
|
|
1749
|
-
properties: {
|
|
1750
|
-
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1751
|
-
status: {
|
|
1752
|
-
type: 'string',
|
|
1753
|
-
enum: ['planned', 'in-progress', 'done', 'blocked', 'cancelled', 'open'],
|
|
1754
|
-
description: 'Filter by status. Use \'open\' to see planned+in-progress+blocked (default: open)',
|
|
1755
|
-
},
|
|
1756
|
-
tag: { type: 'string', description: 'Filter by tag (optional)' },
|
|
1757
|
-
milestone: { type: 'string', description: 'Filter by milestone (optional)' },
|
|
1758
|
-
priority: {
|
|
1759
|
-
type: 'string',
|
|
1760
|
-
enum: ['critical', 'high', 'medium', 'low'],
|
|
1761
|
-
description: 'Filter by minimum priority (optional)',
|
|
1762
|
-
},
|
|
1763
|
-
},
|
|
1764
|
-
required: ['instance_id'],
|
|
1765
|
-
},
|
|
1766
|
-
},
|
|
1767
|
-
{
|
|
1768
|
-
name: 'roadmap_next',
|
|
1769
|
-
description:
|
|
1770
|
-
'Get the single most important next actionable roadmap item. ' +
|
|
1771
|
-
'Returns the highest-priority in-progress item first, then planned items, sorted by priority. ' +
|
|
1772
|
-
'Call at session start to immediately know what to work on next.',
|
|
1773
|
-
inputSchema: {
|
|
1774
|
-
type: 'object',
|
|
1775
|
-
properties: {
|
|
1776
|
-
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1777
|
-
tag: { type: 'string', description: 'Filter by tag (optional)' },
|
|
1778
|
-
},
|
|
1779
|
-
required: ['instance_id'],
|
|
1780
|
-
},
|
|
1781
|
-
},
|
|
1782
1510
|
{
|
|
1783
1511
|
name: 'brain_doctor',
|
|
1784
1512
|
description:
|
|
1785
1513
|
'Check the health of your AI Brain and get actionable recommendations. ' +
|
|
1786
|
-
'Reports: lesson count, context entries, last session age, open failures, quality score
|
|
1514
|
+
'Reports: lesson count, context entries, last session age, open failures, quality score. ' +
|
|
1787
1515
|
'Returns a prioritized list of issues with fix instructions.',
|
|
1788
1516
|
inputSchema: {
|
|
1789
1517
|
type: 'object',
|
|
1790
1518
|
properties: {
|
|
1791
1519
|
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1792
|
-
workspace_path: {
|
|
1793
|
-
type: 'string',
|
|
1794
|
-
description: 'Absolute path to workspace root — enables package.json analysis for openclaw cross-promo (optional)',
|
|
1795
|
-
},
|
|
1796
1520
|
},
|
|
1797
1521
|
required: ['instance_id'],
|
|
1798
1522
|
},
|
|
@@ -1864,100 +1588,6 @@ const TOOLS = [
|
|
|
1864
1588
|
required: ['instance_id', 'framework'],
|
|
1865
1589
|
},
|
|
1866
1590
|
},
|
|
1867
|
-
// ── Brain Archaeology + Causal Chain ────────────────────────────────────
|
|
1868
|
-
{
|
|
1869
|
-
name: 'recall_at',
|
|
1870
|
-
description:
|
|
1871
|
-
'Brain Archaeology — see what a lesson looked like at a specific point in time. ' +
|
|
1872
|
-
'"What did we know about deployments 3 months ago?" ' +
|
|
1873
|
-
'Returns the history of a topic filtered to entries before the given date. ' +
|
|
1874
|
-
'Shows how the lesson evolved: failure → partial → success. ' +
|
|
1875
|
-
'Also useful to understand WHY old code decisions were made.',
|
|
1876
|
-
inputSchema: {
|
|
1877
|
-
type: 'object',
|
|
1878
|
-
properties: {
|
|
1879
|
-
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1880
|
-
topic: { type: 'string', description: 'Topic slug to look up, e.g. "deploy:api"' },
|
|
1881
|
-
date: { type: 'string', description: 'ISO date string (e.g. "2026-01-15") — returns entries stored BEFORE this date' },
|
|
1882
|
-
},
|
|
1883
|
-
required: ['instance_id', 'topic', 'date'],
|
|
1884
|
-
},
|
|
1885
|
-
},
|
|
1886
|
-
{
|
|
1887
|
-
name: 'trace_dependency',
|
|
1888
|
-
description:
|
|
1889
|
-
'Causal Chain — find all lessons that depend on a given prerequisite. ' +
|
|
1890
|
-
'"What lessons are affected if node version changes?" ' +
|
|
1891
|
-
'When a dependency changes (new version, different provider, new OS), call this to see which lessons need review. ' +
|
|
1892
|
-
'Lessons store dependencies via the depends_on field in learn_from_attempts.',
|
|
1893
|
-
inputSchema: {
|
|
1894
|
-
type: 'object',
|
|
1895
|
-
properties: {
|
|
1896
|
-
instance_id: { type: 'string', description: 'UUID of the cache instance' },
|
|
1897
|
-
dependency: { type: 'string', description: 'Dependency to trace (e.g. "node:>=20", "docker:running", "wireguard:active")' },
|
|
1898
|
-
mark_review: { type: 'boolean', description: 'If true, marks all dependent lessons as needs_review (default: false)' },
|
|
1899
|
-
},
|
|
1900
|
-
required: ['instance_id', 'dependency'],
|
|
1901
|
-
},
|
|
1902
|
-
},
|
|
1903
|
-
// ── Team / Org Management ────────────────────────────────────────────────
|
|
1904
|
-
{
|
|
1905
|
-
name: 'list_orgs',
|
|
1906
|
-
description:
|
|
1907
|
-
'List your Cachly organizations (team/org plans). ' +
|
|
1908
|
-
'Returns each org with plan, seat count, and member info. ' +
|
|
1909
|
-
'Org plans (Team €99, Business €299, Enterprise custom) are billed separately from cache tiers.',
|
|
1910
|
-
inputSchema: {
|
|
1911
|
-
type: 'object',
|
|
1912
|
-
properties: {},
|
|
1913
|
-
required: [],
|
|
1914
|
-
},
|
|
1915
|
-
},
|
|
1916
|
-
{
|
|
1917
|
-
name: 'create_org',
|
|
1918
|
-
description:
|
|
1919
|
-
'Create a new Cachly organization for team collaboration. ' +
|
|
1920
|
-
'After creation, invite team members with invite_member and upgrade the plan via the billing portal. ' +
|
|
1921
|
-
'Org plans: Team (€99/mo, 10 seats), Business (€299/mo, 50 seats), Enterprise (custom).',
|
|
1922
|
-
inputSchema: {
|
|
1923
|
-
type: 'object',
|
|
1924
|
-
properties: {
|
|
1925
|
-
name: { type: 'string', description: 'Organization display name (e.g. "Acme Engineering")' },
|
|
1926
|
-
slug: { type: 'string', description: 'URL-safe slug (e.g. "acme-eng"). Auto-generated from name if omitted.' },
|
|
1927
|
-
},
|
|
1928
|
-
required: ['name'],
|
|
1929
|
-
},
|
|
1930
|
-
},
|
|
1931
|
-
{
|
|
1932
|
-
name: 'invite_member',
|
|
1933
|
-
description:
|
|
1934
|
-
'Invite a team member to a Cachly organization by email. ' +
|
|
1935
|
-
'They will receive an invite email and can join via the dashboard. ' +
|
|
1936
|
-
'Roles: owner (full access), admin (manage members + instances), member (read + cache ops).',
|
|
1937
|
-
inputSchema: {
|
|
1938
|
-
type: 'object',
|
|
1939
|
-
properties: {
|
|
1940
|
-
org_id: { type: 'string', description: 'UUID of the organization' },
|
|
1941
|
-
email: { type: 'string', description: 'Email address to invite' },
|
|
1942
|
-
role: { type: 'string', enum: ['admin', 'member'], description: 'Role for the invited member (default: member)' },
|
|
1943
|
-
},
|
|
1944
|
-
required: ['org_id', 'email'],
|
|
1945
|
-
},
|
|
1946
|
-
},
|
|
1947
|
-
{
|
|
1948
|
-
name: 'get_org_plan',
|
|
1949
|
-
description:
|
|
1950
|
-
'Get the current org plan, seat usage, and billing info for an organization. ' +
|
|
1951
|
-
'Shows: plan name, price, seats used/max, next billing date. ' +
|
|
1952
|
-
'To upgrade: use the billing portal URL returned by this tool.',
|
|
1953
|
-
inputSchema: {
|
|
1954
|
-
type: 'object',
|
|
1955
|
-
properties: {
|
|
1956
|
-
org_id: { type: 'string', description: 'UUID of the organization' },
|
|
1957
|
-
},
|
|
1958
|
-
required: ['org_id'],
|
|
1959
|
-
},
|
|
1960
|
-
},
|
|
1961
1591
|
// ── Legacy / Setup ────────────────────────────────────────────────────────
|
|
1962
1592
|
{
|
|
1963
1593
|
name: 'setup_ai_memory',
|
|
@@ -2055,50 +1685,10 @@ function buildConnectionString(inst: Instance): string {
|
|
|
2055
1685
|
return `${scheme}://${pw}${inst.host}:${inst.port}`;
|
|
2056
1686
|
}
|
|
2057
1687
|
|
|
2058
|
-
// Fires once per process when no JWT is set (anonymous, opt-out via CACHLY_NO_TELEMETRY=1)
|
|
2059
|
-
let _telemetryPingSent = false;
|
|
2060
|
-
async function sendAnonymousTelemetry(toolName: string): Promise<void> {
|
|
2061
|
-
if (_telemetryPingSent) return;
|
|
2062
|
-
if (process.env.CACHLY_NO_TELEMETRY === '1') return;
|
|
2063
|
-
_telemetryPingSent = true;
|
|
2064
|
-
// Detect editor from common env vars injected by IDE extensions
|
|
2065
|
-
const editor = process.env.CURSOR_TRACE_ID ? 'cursor'
|
|
2066
|
-
: process.env.WINDSURF_SESSION_ID ? 'windsurf'
|
|
2067
|
-
: process.env.GITHUB_COPILOT_WORKSPACE ? 'copilot'
|
|
2068
|
-
: process.env.CLAUDE_CODE_ENTRYPOINT ? 'claude'
|
|
2069
|
-
: 'unknown';
|
|
2070
|
-
try {
|
|
2071
|
-
await fetch(`${API_URL}/api/v1/telemetry/mcp`, {
|
|
2072
|
-
method: 'POST',
|
|
2073
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2074
|
-
body: JSON.stringify({ event: 'first_call_no_jwt', version: CURRENT_VERSION, editor, tool: toolName }),
|
|
2075
|
-
signal: AbortSignal.timeout(3000),
|
|
2076
|
-
});
|
|
2077
|
-
} catch { /* fire-and-forget, never block the user */ }
|
|
2078
|
-
}
|
|
2079
|
-
|
|
2080
1688
|
async function handleTool(name: string, args: Record<string, unknown>): Promise<string> {
|
|
2081
|
-
//
|
|
2082
|
-
if (!
|
|
2083
|
-
|
|
2084
|
-
return [
|
|
2085
|
-
'🧠 **cachly AI Brain — Setup required**',
|
|
2086
|
-
'',
|
|
2087
|
-
'You have no API credentials configured. Get started in 30 seconds:',
|
|
2088
|
-
'',
|
|
2089
|
-
'**Step 1:** Register for free (no credit card):',
|
|
2090
|
-
' 👉 https://cachly.dev/setup-ai',
|
|
2091
|
-
'',
|
|
2092
|
-
'**Step 2:** Run the setup wizard in your terminal:',
|
|
2093
|
-
' ```',
|
|
2094
|
-
' npx @cachly-dev/mcp-server@latest setup',
|
|
2095
|
-
' ```',
|
|
2096
|
-
'',
|
|
2097
|
-
'The wizard auto-detects your editor (Claude Code, Cursor, Copilot, Windsurf)',
|
|
2098
|
-
'and writes the config file with your credentials.',
|
|
2099
|
-
'',
|
|
2100
|
-
'✨ Free tier includes: 1 Brain instance, persistent memory, semantic search.',
|
|
2101
|
-
].join('\n');
|
|
1689
|
+
// Default instance_id from env so tools work even if AI omits it
|
|
1690
|
+
if (!args.instance_id && process.env.CACHLY_BRAIN_INSTANCE_ID) {
|
|
1691
|
+
args = { ...args, instance_id: process.env.CACHLY_BRAIN_INSTANCE_ID };
|
|
2102
1692
|
}
|
|
2103
1693
|
|
|
2104
1694
|
// Delegate v0.2 bulk/lock/stream tools first
|
|
@@ -2119,7 +1709,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
2119
1709
|
const { name: instName, tier } = args as { name: string; tier: string };
|
|
2120
1710
|
const res = await apiFetch<CreateResponse>('/api/v1/instances', {
|
|
2121
1711
|
method: 'POST',
|
|
2122
|
-
body: JSON.stringify({ name: instName, tier
|
|
1712
|
+
body: JSON.stringify({ name: instName, tier }),
|
|
2123
1713
|
});
|
|
2124
1714
|
if (res.checkout_url) {
|
|
2125
1715
|
return [
|
|
@@ -2179,69 +1769,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
2179
1769
|
return `✅ Instance \`${instance_id}\` has been deleted and all data removed.`;
|
|
2180
1770
|
}
|
|
2181
1771
|
|
|
2182
|
-
// ── Org / Team management ────────────────────────────────────────────
|
|
2183
|
-
case 'list_orgs': {
|
|
2184
|
-
const res = await apiFetch<{ orgs: Array<{ id: string; name: string; slug: string; plan: string; max_members: number; member_count?: number }> }>('/api/v1/orgs');
|
|
2185
|
-
const orgs = res.orgs ?? [];
|
|
2186
|
-
if (orgs.length === 0) return `📭 No organizations yet.\n\nCreate one with \`create_org(name="My Team")\`.\nOrg plans: Team €99/mo (10 seats), Business €299/mo (50 seats), Enterprise custom.`;
|
|
2187
|
-
return [
|
|
2188
|
-
`🏢 **Your organizations (${orgs.length})**\n`,
|
|
2189
|
-
...orgs.map(o => `• **${o.name}** (\`${o.slug}\`) — plan: ${o.plan} · seats: ${o.member_count ?? '?'}/${o.max_members}\n ID: \`${o.id}\``),
|
|
2190
|
-
`\n_Manage: \`get_org_plan\`, \`invite_member\`, dashboard → /team_`,
|
|
2191
|
-
].join('\n');
|
|
2192
|
-
}
|
|
2193
|
-
|
|
2194
|
-
case 'create_org': {
|
|
2195
|
-
const { name: orgName, slug } = args as { name: string; slug?: string };
|
|
2196
|
-
const body: Record<string, string> = { name: orgName };
|
|
2197
|
-
if (slug) body.slug = slug;
|
|
2198
|
-
const res = await apiFetch<{ id: string; name: string; slug: string; plan: string }>('/api/v1/orgs', {
|
|
2199
|
-
method: 'POST',
|
|
2200
|
-
body: JSON.stringify(body),
|
|
2201
|
-
});
|
|
2202
|
-
return [
|
|
2203
|
-
`✅ **Organization created:** ${res.name}`,
|
|
2204
|
-
` ID: \`${res.id}\` · Slug: \`${res.slug}\` · Plan: ${res.plan}`,
|
|
2205
|
-
``,
|
|
2206
|
-
`**Next steps:**`,
|
|
2207
|
-
`1. Invite team members: \`invite_member(org_id="${res.id}", email="dev@example.com")\``,
|
|
2208
|
-
`2. Upgrade plan: open billing portal via dashboard → /team`,
|
|
2209
|
-
` Team: €99/mo (10 seats) · Business: €299/mo (50 seats)`,
|
|
2210
|
-
].join('\n');
|
|
2211
|
-
}
|
|
2212
|
-
|
|
2213
|
-
case 'invite_member': {
|
|
2214
|
-
const { org_id, email, role = 'member' } = args as { org_id: string; email: string; role?: string };
|
|
2215
|
-
await apiFetch(`/api/v1/orgs/${org_id}/members`, {
|
|
2216
|
-
method: 'POST',
|
|
2217
|
-
body: JSON.stringify({ email, role }),
|
|
2218
|
-
});
|
|
2219
|
-
return `✅ Invite sent to **${email}** as \`${role}\` in org \`${org_id}\`.\n\nThey will receive an email to join the organization.`;
|
|
2220
|
-
}
|
|
2221
|
-
|
|
2222
|
-
case 'get_org_plan': {
|
|
2223
|
-
const { org_id } = args as { org_id: string };
|
|
2224
|
-
const org = await apiFetch<{
|
|
2225
|
-
id: string; name: string; plan: string; max_members: number;
|
|
2226
|
-
members: Array<{ role: string; invite_email: string; accepted_at?: string }>;
|
|
2227
|
-
stripe_customer_id?: string;
|
|
2228
|
-
}>(`/api/v1/orgs/${org_id}`);
|
|
2229
|
-
const accepted = (org.members ?? []).filter(m => m.accepted_at).length;
|
|
2230
|
-
const pending = (org.members ?? []).filter(m => !m.accepted_at).length;
|
|
2231
|
-
const planPrice: Record<string, string> = { free: '€0', team: '€99/mo', business: '€299/mo', enterprise: 'custom' };
|
|
2232
|
-
return [
|
|
2233
|
-
`🏢 **${org.name}** — Plan: **${org.plan}** (${planPrice[org.plan] ?? org.plan})`,
|
|
2234
|
-
` Seats: ${accepted} active + ${pending} pending / ${org.max_members} max`,
|
|
2235
|
-
``,
|
|
2236
|
-
`**Members:**`,
|
|
2237
|
-
...(org.members ?? []).map(m => ` • ${m.invite_email} (${m.role})${m.accepted_at ? '' : ' — pending'}`),
|
|
2238
|
-
``,
|
|
2239
|
-
org.stripe_customer_id
|
|
2240
|
-
? `💳 Billing: managed via Stripe. Upgrade/cancel: dashboard → /billing`
|
|
2241
|
-
: `💳 No payment method yet. Upgrade: dashboard → /billing → Team Plans`,
|
|
2242
|
-
].join('\n');
|
|
2243
|
-
}
|
|
2244
|
-
|
|
2245
1772
|
// ── Live cache operations ────────────────────────────────────────────
|
|
2246
1773
|
case 'cache_get': {
|
|
2247
1774
|
const { instance_id, key } = args as { instance_id: string; key: string };
|
|
@@ -2668,7 +2195,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
2668
2195
|
let skipped = 0;
|
|
2669
2196
|
let errors = 0;
|
|
2670
2197
|
let semanticIndexed = 0;
|
|
2671
|
-
let unchanged = 0;
|
|
2672
2198
|
const details: string[] = [];
|
|
2673
2199
|
const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
|
|
2674
2200
|
const redis = await getConnection(instance_id);
|
|
@@ -2676,31 +2202,15 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
2676
2202
|
for (const filePath of files) {
|
|
2677
2203
|
const relPath = relative(dir, filePath);
|
|
2678
2204
|
let content: string;
|
|
2679
|
-
let fileSize: number;
|
|
2680
2205
|
try {
|
|
2681
2206
|
const s = await stat(filePath);
|
|
2682
2207
|
if (s.size > 200_000) { skipped++; continue; } // skip files >200 KB
|
|
2683
|
-
fileSize = s.size;
|
|
2684
2208
|
content = await readFile(filePath, 'utf-8');
|
|
2685
2209
|
} catch {
|
|
2686
2210
|
errors++;
|
|
2687
2211
|
continue;
|
|
2688
2212
|
}
|
|
2689
2213
|
|
|
2690
|
-
// ── Smart invalidation: hash-based change detection ──
|
|
2691
|
-
// Compute a simple hash of file content to skip unchanged files
|
|
2692
|
-
const hashKey = `cachly:idx:hash:${relPath}`;
|
|
2693
|
-
const contentHash = `${fileSize}:${content.length}:${simpleHash(content)}`;
|
|
2694
|
-
const existingHash = await redis.get(hashKey);
|
|
2695
|
-
if (existingHash === contentHash) {
|
|
2696
|
-
// File unchanged — refresh TTL but skip re-indexing
|
|
2697
|
-
const idxKey = `cachly:idx:${relPath}`;
|
|
2698
|
-
if (ttl > 0) await redis.expire(idxKey, ttl);
|
|
2699
|
-
if (ttl > 0) await redis.expire(hashKey, ttl);
|
|
2700
|
-
unchanged++;
|
|
2701
|
-
continue;
|
|
2702
|
-
}
|
|
2703
|
-
|
|
2704
2214
|
const excerpt = content.slice(0, summary_chars).replace(/\s+/g, ' ').trim();
|
|
2705
2215
|
|
|
2706
2216
|
// ── Layer 1: Keyword index in Valkey (always works, no embedding needed) ──
|
|
@@ -2711,12 +2221,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
2711
2221
|
} else {
|
|
2712
2222
|
await redis.set(idxKey, idxValue);
|
|
2713
2223
|
}
|
|
2714
|
-
// Store content hash for smart invalidation on next run
|
|
2715
|
-
if (ttl > 0) {
|
|
2716
|
-
await redis.set(hashKey, contentHash, 'EX', ttl);
|
|
2717
|
-
} else {
|
|
2718
|
-
await redis.set(hashKey, contentHash);
|
|
2719
|
-
}
|
|
2720
2224
|
indexed++;
|
|
2721
2225
|
details.push(` ✅ ${relPath}`);
|
|
2722
2226
|
|
|
@@ -2745,8 +2249,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
2745
2249
|
`📂 **index_project Complete** — ${mode}`,
|
|
2746
2250
|
``,
|
|
2747
2251
|
` 📁 Dir: ${dir}`,
|
|
2748
|
-
` ✅ Indexed: **${indexed}** files (
|
|
2749
|
-
` ♻️ Unchanged: ${unchanged} files (hash match — skipped)`,
|
|
2252
|
+
` ✅ Indexed: **${indexed}** files (keyword-searchable via smart_recall)`,
|
|
2750
2253
|
...(canEmbed ? [` 🎯 Semantic: **${semanticIndexed}** files (vector-searchable)`] : []),
|
|
2751
2254
|
` ⏭️ Skipped: ${skipped} (too large or filtered)`,
|
|
2752
2255
|
` ❌ Errors: ${errors}`,
|
|
@@ -2969,8 +2472,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
2969
2472
|
file_paths = [],
|
|
2970
2473
|
commands = [],
|
|
2971
2474
|
tags = [],
|
|
2972
|
-
depends_on = [],
|
|
2973
|
-
author = '',
|
|
2974
2475
|
} = args as {
|
|
2975
2476
|
instance_id: string;
|
|
2976
2477
|
topic: string;
|
|
@@ -2982,107 +2483,26 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
2982
2483
|
file_paths?: string[];
|
|
2983
2484
|
commands?: string[];
|
|
2984
2485
|
tags?: string[];
|
|
2985
|
-
depends_on?: string[];
|
|
2986
|
-
author?: string;
|
|
2987
2486
|
};
|
|
2988
2487
|
|
|
2989
2488
|
const redis = await getConnection(instance_id);
|
|
2990
2489
|
const ts = new Date().toISOString();
|
|
2991
2490
|
|
|
2992
|
-
// ──
|
|
2993
|
-
const category = topic.split(':')[0];
|
|
2994
|
-
const template = STRUCTURED_TEMPLATES[category];
|
|
2995
|
-
const templateWarnings: string[] = [];
|
|
2996
|
-
if (template) {
|
|
2997
|
-
for (const req of template.required) {
|
|
2998
|
-
if (req === 'commands' && commands.length === 0) {
|
|
2999
|
-
templateWarnings.push(`📋 ${template.hint}`);
|
|
3000
|
-
}
|
|
3001
|
-
}
|
|
3002
|
-
}
|
|
3003
|
-
|
|
3004
|
-
// ── Deduplication + audit trail ────────────────────────────────────────
|
|
2491
|
+
// ── Deduplication: check for existing lesson with same topic ─────────────
|
|
3005
2492
|
let isUpdate = false;
|
|
3006
2493
|
let recallCount = 0;
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
try {
|
|
3011
|
-
const prev = JSON.parse(existingRaw) as {
|
|
3012
|
-
recall_count?: number;
|
|
3013
|
-
outcome?: string;
|
|
3014
|
-
audit_trail?: Array<{ ts: string; action: string; prev_outcome?: string }>;
|
|
3015
|
-
};
|
|
3016
|
-
recallCount = prev.recall_count ?? 0;
|
|
3017
|
-
auditTrail = prev.audit_trail ?? [];
|
|
3018
|
-
auditTrail.push({ ts, action: 'updated', prev_outcome: prev.outcome });
|
|
3019
|
-
if (auditTrail.length > 20) auditTrail = auditTrail.slice(-20);
|
|
3020
|
-
isUpdate = true;
|
|
3021
|
-
|
|
3022
|
-
// ── Contradiction detection ─────────────────────────────────────────
|
|
3023
|
-
const contradictionWarning: string[] = [];
|
|
3024
|
-
if (prev.outcome === 'success' && outcome === 'failure') {
|
|
3025
|
-
contradictionWarning.push(
|
|
3026
|
-
`⚠️ **Contradiction detected!** Existing lesson has outcome: \`success\`, but you're storing \`failure\`.`,
|
|
3027
|
-
`The existing "success" lesson will be preserved. Only the audit trail is updated.`,
|
|
3028
|
-
`If you meant to mark this as failed permanently, store a new lesson with a distinct topic slug.`,
|
|
3029
|
-
);
|
|
3030
|
-
} else if (prev.outcome === 'failure' && outcome === 'success') {
|
|
3031
|
-
contradictionWarning.push(
|
|
3032
|
-
`✅ **Conflict resolved!** Previous lesson was \`failure\` — now overwriting with \`success\`.`,
|
|
3033
|
-
);
|
|
3034
|
-
}
|
|
3035
|
-
if (contradictionWarning.length > 0) {
|
|
3036
|
-
// Store contradiction audit but don't block
|
|
3037
|
-
auditTrail[auditTrail.length - 1].action = 'contradiction-resolved';
|
|
3038
|
-
}
|
|
3039
|
-
} catch { /* ignore parse error */ }
|
|
3040
|
-
} else {
|
|
3041
|
-
auditTrail = [{ ts, action: 'created' }];
|
|
3042
|
-
}
|
|
3043
|
-
|
|
3044
|
-
// ── "I Was Wrong" Protocol — failure attribution ───────────────────────
|
|
3045
|
-
const iWasWrongWarning: string[] = [];
|
|
3046
|
-
if (outcome === 'failure') {
|
|
3047
|
-
// Search for related success lessons that might have prevented this failure
|
|
3048
|
-
const scanKeys: string[] = [];
|
|
3049
|
-
const scanStream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 100 });
|
|
3050
|
-
await new Promise<void>((res, rej) => {
|
|
3051
|
-
scanStream.on('data', (b: string[]) => scanKeys.push(...b));
|
|
3052
|
-
scanStream.on('end', res);
|
|
3053
|
-
scanStream.on('error', rej);
|
|
3054
|
-
});
|
|
3055
|
-
const topicWords = topic.split(/[:\-_]/).filter(w => w.length > 2);
|
|
3056
|
-
for (const k of scanKeys.slice(0, 50)) {
|
|
3057
|
-
const raw = await redis.get(k);
|
|
3058
|
-
if (!raw) continue;
|
|
2494
|
+
if (outcome === 'success') {
|
|
2495
|
+
const existing = await redis.get(`cachly:lesson:best:${topic}`);
|
|
2496
|
+
if (existing) {
|
|
3059
2497
|
try {
|
|
3060
|
-
const
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
if (overlap >= 1 && l.topic !== topic) {
|
|
3065
|
-
iWasWrongWarning.push(
|
|
3066
|
-
`⚠️ **"I Was Wrong"**: lesson \`${l.topic}\` (success, ${l.severity ?? 'major'}) might have prevented this failure.`,
|
|
3067
|
-
` → Use \`recall_best_solution(topic="${l.topic}")\` before next attempt.`,
|
|
3068
|
-
` → To mark it critical: \`learn_from_attempts(topic="${l.topic}", ..., severity="critical")\``,
|
|
3069
|
-
);
|
|
3070
|
-
break; // only show the most relevant match
|
|
3071
|
-
}
|
|
3072
|
-
} catch { /* skip */ }
|
|
2498
|
+
const prev = JSON.parse(existing) as { recall_count?: number };
|
|
2499
|
+
recallCount = (prev.recall_count ?? 0); // preserve existing recall count
|
|
2500
|
+
isUpdate = true;
|
|
2501
|
+
} catch { /* ignore parse error */ }
|
|
3073
2502
|
}
|
|
3074
2503
|
}
|
|
3075
2504
|
|
|
3076
|
-
|
|
3077
|
-
for (const dep of depends_on) {
|
|
3078
|
-
const depKey = `cachly:dep:${dep}`;
|
|
3079
|
-
const existing = await redis.get(depKey);
|
|
3080
|
-
const depTopics: string[] = existing ? JSON.parse(existing) : [];
|
|
3081
|
-
if (!depTopics.includes(topic)) depTopics.push(topic);
|
|
3082
|
-
await redis.set(depKey, JSON.stringify(depTopics));
|
|
3083
|
-
}
|
|
3084
|
-
|
|
3085
|
-
const lessonObj = {
|
|
2505
|
+
const lesson = JSON.stringify({
|
|
3086
2506
|
topic,
|
|
3087
2507
|
outcome,
|
|
3088
2508
|
what_worked,
|
|
@@ -3092,36 +2512,20 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3092
2512
|
file_paths,
|
|
3093
2513
|
commands,
|
|
3094
2514
|
tags,
|
|
3095
|
-
depends_on,
|
|
3096
|
-
...(author ? { author } : {}),
|
|
3097
2515
|
recall_count: recallCount,
|
|
3098
2516
|
ts,
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
audit_trail: auditTrail,
|
|
3102
|
-
version: 3,
|
|
3103
|
-
};
|
|
3104
|
-
const lesson = JSON.stringify(lessonObj);
|
|
2517
|
+
version: 2,
|
|
2518
|
+
});
|
|
3105
2519
|
|
|
3106
|
-
// Always append to the history list
|
|
2520
|
+
// Always append to the history list
|
|
3107
2521
|
const listKey = `cachly:lessons:${topic}`;
|
|
3108
2522
|
await redis.rpush(listKey, lesson);
|
|
3109
2523
|
|
|
3110
|
-
// Update best key for success/partial
|
|
2524
|
+
// Update best key only for success/partial (not for failures)
|
|
3111
2525
|
if (outcome === 'success' || outcome === 'partial') {
|
|
3112
2526
|
await redis.set(`cachly:lesson:best:${topic}`, lesson);
|
|
3113
|
-
} else if (!existingRaw) {
|
|
3114
|
-
await redis.set(`cachly:lesson:best:${topic}`, lesson);
|
|
3115
2527
|
}
|
|
3116
2528
|
|
|
3117
|
-
// Track in decision log for session replay
|
|
3118
|
-
try {
|
|
3119
|
-
const dlKey = 'cachly:session:decision-log';
|
|
3120
|
-
const dlEntry = JSON.stringify({ ts, topic, outcome, what_worked: what_worked.slice(0, 120) });
|
|
3121
|
-
await redis.rpush(dlKey, dlEntry);
|
|
3122
|
-
await redis.ltrim(dlKey, -50, -1);
|
|
3123
|
-
} catch { /* non-critical */ }
|
|
3124
|
-
|
|
3125
2529
|
const emoji = outcome === 'success' ? '✅' : outcome === 'partial' ? '⚠️' : '❌';
|
|
3126
2530
|
const sevEmoji = severity === 'critical' ? '🔴' : severity === 'major' ? '🟡' : '🟢';
|
|
3127
2531
|
const action = isUpdate ? 'updated' : 'stored';
|
|
@@ -3136,13 +2540,8 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3136
2540
|
tags.length > 0 ? `**Tags:** ${tags.map(t => `#${t}`).join(' ')}` : '',
|
|
3137
2541
|
``,
|
|
3138
2542
|
isUpdate
|
|
3139
|
-
? `♻️ Updated (recall count: ${recallCount}
|
|
2543
|
+
? `♻️ Updated existing lesson (preserved recall count: ${recallCount})`
|
|
3140
2544
|
: `💡 Recall later with \`recall_best_solution(topic="${topic}")\``,
|
|
3141
|
-
depends_on.length > 0
|
|
3142
|
-
? `🔗 Depends on: ${depends_on.map(d => `\`${d}\``).join(', ')} → trace with \`trace_dependency\``
|
|
3143
|
-
: '',
|
|
3144
|
-
...templateWarnings,
|
|
3145
|
-
...iWasWrongWarning,
|
|
3146
2545
|
].filter(l => l !== '').join('\n');
|
|
3147
2546
|
}
|
|
3148
2547
|
|
|
@@ -3153,47 +2552,43 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3153
2552
|
// Try exact best-solution key first
|
|
3154
2553
|
const best = await redis.get(`cachly:lesson:best:${topic}`);
|
|
3155
2554
|
if (best) {
|
|
3156
|
-
|
|
2555
|
+
let lesson: {
|
|
3157
2556
|
topic: string; outcome: string; what_worked: string; what_failed?: string;
|
|
3158
|
-
context?: string; ts: string;
|
|
3159
|
-
|
|
3160
|
-
recall_count?: number; audit_trail?: unknown[];
|
|
3161
|
-
};
|
|
3162
|
-
|
|
3163
|
-
// ── Confidence decay check ───────────────────────────────────────────
|
|
3164
|
-
const confidence = calculateConfidence(lesson);
|
|
3165
|
-
const ref = lesson.verified_at ?? lesson.ts;
|
|
3166
|
-
const ageDays = (Date.now() - new Date(ref).getTime()) / 86400000;
|
|
3167
|
-
const badge = confidenceBadge(confidence, ageDays);
|
|
3168
|
-
|
|
3169
|
-
// Recall resets verified_at (confidence clock restart)
|
|
3170
|
-
const updatedLesson = {
|
|
3171
|
-
...lesson,
|
|
3172
|
-
recall_count: (lesson.recall_count ?? 0) + 1,
|
|
3173
|
-
verified_at: new Date().toISOString(),
|
|
3174
|
-
confidence: 1.0,
|
|
2557
|
+
context?: string; ts: string; severity?: string; file_paths?: string[];
|
|
2558
|
+
commands?: string[]; tags?: string[]; recall_count?: number;
|
|
3175
2559
|
};
|
|
2560
|
+
try { lesson = JSON.parse(best); } catch {
|
|
2561
|
+
return `⚠️ Lesson for \`${topic}\` could not be read (corrupted data). Use \`learn_from_attempts\` to overwrite it.`;
|
|
2562
|
+
}
|
|
2563
|
+
// Increment recall_count
|
|
2564
|
+
const updatedLesson = { ...lesson, recall_count: (lesson.recall_count ?? 0) + 1 };
|
|
3176
2565
|
await redis.set(`cachly:lesson:best:${topic}`, JSON.stringify(updatedLesson));
|
|
3177
2566
|
|
|
3178
|
-
|
|
3179
|
-
const
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
2567
|
+
// Fire-and-forget: track Magic Moment in Plausible + API engagement counter
|
|
2568
|
+
const isFirstRecall = (lesson.recall_count ?? 0) === 0;
|
|
2569
|
+
const recallEvent = isFirstRecall ? 'First Brain Recall' : 'Brain Recall Hit';
|
|
2570
|
+
fetch('https://analytics.cachly.dev/api/event', {
|
|
2571
|
+
method: 'POST',
|
|
2572
|
+
headers: { 'Content-Type': 'application/json', 'User-Agent': 'cachly-mcp/recall' },
|
|
2573
|
+
body: JSON.stringify({
|
|
2574
|
+
domain: 'cachly.dev', name: recallEvent,
|
|
2575
|
+
url: 'https://cachly.dev/mcp/recall', props: { topic },
|
|
2576
|
+
}),
|
|
2577
|
+
}).catch(() => { /* non-critical */ });
|
|
2578
|
+
if (JWT) {
|
|
2579
|
+
fetch(`${API_URL}/api/v1/telemetry/mcp`, {
|
|
2580
|
+
method: 'POST',
|
|
2581
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2582
|
+
body: JSON.stringify({ event: isFirstRecall ? 'first_recall' : 'recall', api_key: JWT }),
|
|
2583
|
+
}).catch(() => { /* non-critical */ });
|
|
2584
|
+
}
|
|
3193
2585
|
|
|
2586
|
+
const sevEmoji = lesson.severity === 'critical' ? '🔴' : lesson.severity === 'major' ? '🟡' : lesson.severity ? '🟢' : '';
|
|
2587
|
+
const sessionAgeMs = Date.now() - new Date(lesson.ts).getTime();
|
|
2588
|
+
const sessionAgeDays = Math.round(sessionAgeMs / 86_400_000);
|
|
2589
|
+
const ageLabel = sessionAgeDays === 0 ? 'today' : sessionAgeDays === 1 ? 'yesterday' : `${sessionAgeDays} days ago`;
|
|
3194
2590
|
return [
|
|
3195
|
-
|
|
3196
|
-
`${badge} **Best solution for \`${topic}\`** ${sevEmoji}${lesson.severity ? ` (${lesson.severity})` : ''} · recalled ${updatedLesson.recall_count}×`,
|
|
2591
|
+
`✅ **Best solution for \`${topic}\`** ${sevEmoji}${lesson.severity ? ` (${lesson.severity})` : ''} · recalled ${updatedLesson.recall_count}× · stored ${ageLabel}`,
|
|
3197
2592
|
``,
|
|
3198
2593
|
`**What worked:** ${lesson.what_worked}`,
|
|
3199
2594
|
lesson.what_failed ? `**What failed (avoid this):** ${lesson.what_failed}` : '',
|
|
@@ -3201,8 +2596,8 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3201
2596
|
(lesson.file_paths ?? []).length > 0 ? `**Files:** ${(lesson.file_paths ?? []).map((f: string) => `\`${f}\``).join(', ')}` : '',
|
|
3202
2597
|
(lesson.commands ?? []).length > 0 ? `**Commands:** ${(lesson.commands ?? []).map((c: string) => `\`${c}\``).join(', ')}` : '',
|
|
3203
2598
|
(lesson.tags ?? []).length > 0 ? `**Tags:** ${(lesson.tags ?? []).map((t: string) => `#${t}`).join(' ')}` : '',
|
|
3204
|
-
|
|
3205
|
-
|
|
2599
|
+
``,
|
|
2600
|
+
`> 🧠 *Your AI remembered this from ${ageLabel} — persistent memory by [cachly.dev](https://cachly.dev)*`,
|
|
3206
2601
|
].filter(l => l !== '').join('\n');
|
|
3207
2602
|
}
|
|
3208
2603
|
|
|
@@ -3221,7 +2616,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3221
2616
|
const histKey = `cachly:lessons:${topic}`;
|
|
3222
2617
|
const all = await redis.lrange(histKey, -3, -1);
|
|
3223
2618
|
if (all.length > 0) {
|
|
3224
|
-
const parsed = all.
|
|
2619
|
+
const parsed = all.flatMap(e => { try { return [JSON.parse(e) as { outcome: string; what_worked: string; ts: string }]; } catch { return []; } });
|
|
3225
2620
|
const lines = parsed.map(p => `- ${p.outcome === 'success' ? '✅' : '❌'} ${p.what_worked.slice(0, 120)} (${new Date(p.ts).toLocaleDateString('de-DE')})`);
|
|
3226
2621
|
return `⚠️ No successful solution for \`${topic}\` yet. Last attempts:\n\n${lines.join('\n')}`;
|
|
3227
2622
|
}
|
|
@@ -3233,12 +2628,28 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3233
2628
|
for (const k of matching.slice(0, 5)) {
|
|
3234
2629
|
const raw = await redis.get(k);
|
|
3235
2630
|
if (!raw) continue;
|
|
3236
|
-
|
|
2631
|
+
let lesson: { topic: string; what_worked: string; context?: string; ts: string };
|
|
2632
|
+
try { lesson = JSON.parse(raw); } catch { continue; }
|
|
3237
2633
|
results.push(`**\`${lesson.topic}\`** — ${lesson.what_worked.slice(0, 200)}`);
|
|
3238
2634
|
}
|
|
3239
2635
|
return `🔍 **Partial matches for \`${topic}\`:**\n\n${results.join('\n\n')}`;
|
|
3240
2636
|
}
|
|
3241
2637
|
|
|
2638
|
+
case 'forget_lesson': {
|
|
2639
|
+
const { instance_id, topic } = args as { instance_id: string; topic: string };
|
|
2640
|
+
const redis = await getConnection(instance_id);
|
|
2641
|
+
const bestKey = `cachly:lesson:best:${topic}`;
|
|
2642
|
+
const histKey = `cachly:lessons:${topic}`;
|
|
2643
|
+
const [delBest, delHist] = await Promise.all([
|
|
2644
|
+
redis.del(bestKey),
|
|
2645
|
+
redis.del(histKey),
|
|
2646
|
+
]);
|
|
2647
|
+
if (delBest === 0 && delHist === 0) {
|
|
2648
|
+
return `📭 No lesson found for \`${topic}\` — nothing to delete.`;
|
|
2649
|
+
}
|
|
2650
|
+
return `🗑️ Lesson for \`${topic}\` deleted (best-solution: ${delBest > 0 ? 'removed' : 'not found'}, history: ${delHist > 0 ? 'removed' : 'not found'}). You can now store a corrected lesson with \`learn_from_attempts\`.`;
|
|
2651
|
+
}
|
|
2652
|
+
|
|
3242
2653
|
case 'smart_recall': {
|
|
3243
2654
|
const {
|
|
3244
2655
|
instance_id,
|
|
@@ -3353,18 +2764,32 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3353
2764
|
|
|
3354
2765
|
// ── get_api_status ────────────────────────────────────────────────────────
|
|
3355
2766
|
case 'get_api_status': {
|
|
3356
|
-
// Check health
|
|
2767
|
+
// Check API health and auth service in parallel
|
|
2768
|
+
const [healthResult, authServiceResult] = await Promise.allSettled([
|
|
2769
|
+
fetch(`${API_URL}/health`, { signal: AbortSignal.timeout(5000) }),
|
|
2770
|
+
fetch(`${AUTH_URL}/.well-known/openid-configuration`, { signal: AbortSignal.timeout(5000) }),
|
|
2771
|
+
]);
|
|
2772
|
+
|
|
3357
2773
|
let healthStatus = 'unknown';
|
|
3358
|
-
|
|
3359
|
-
const healthRes =
|
|
2774
|
+
if (healthResult.status === 'fulfilled') {
|
|
2775
|
+
const healthRes = healthResult.value;
|
|
3360
2776
|
if (healthRes.ok) {
|
|
3361
|
-
const body = await healthRes.json() as { status?: string; db?: string };
|
|
3362
|
-
healthStatus =
|
|
2777
|
+
const body = await healthRes.json().catch(() => ({})) as { status?: string; db?: string };
|
|
2778
|
+
healthStatus = `✅ ${body.status ?? 'ok'}${body.db ? ` (db: ${body.db})` : ''}`;
|
|
3363
2779
|
} else {
|
|
3364
|
-
healthStatus =
|
|
2780
|
+
healthStatus = `❌ HTTP ${healthRes.status}`;
|
|
3365
2781
|
}
|
|
3366
|
-
}
|
|
3367
|
-
healthStatus =
|
|
2782
|
+
} else {
|
|
2783
|
+
healthStatus = `❌ unreachable: ${healthResult.reason?.message ?? 'timeout'}`;
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
let authSvcStatus = 'unknown';
|
|
2787
|
+
if (authServiceResult.status === 'fulfilled') {
|
|
2788
|
+
authSvcStatus = authServiceResult.value.ok
|
|
2789
|
+
? `✅ reachable (${AUTH_URL})`
|
|
2790
|
+
: `❌ HTTP ${authServiceResult.value.status} — auth service may be down`;
|
|
2791
|
+
} else {
|
|
2792
|
+
authSvcStatus = `❌ unreachable: ${authServiceResult.reason?.message ?? 'timeout'} — auth service may be down`;
|
|
3368
2793
|
}
|
|
3369
2794
|
|
|
3370
2795
|
// Check JWT / auth
|
|
@@ -3372,16 +2797,18 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3372
2797
|
return [
|
|
3373
2798
|
`📡 **cachly API Status**`,
|
|
3374
2799
|
``,
|
|
3375
|
-
` 🌐 API:
|
|
3376
|
-
` 💓 Health: ${healthStatus}`,
|
|
3377
|
-
`
|
|
2800
|
+
` 🌐 API: ${API_URL}`,
|
|
2801
|
+
` 💓 API Health: ${healthStatus}`,
|
|
2802
|
+
` 🔐 Auth Service: ${authSvcStatus}`,
|
|
2803
|
+
` 🔑 JWT: ❌ CACHLY_JWT not set`,
|
|
3378
2804
|
``,
|
|
3379
|
-
`💡 Get your API token at https://cachly.dev/
|
|
2805
|
+
`💡 Get your API token at https://cachly.dev/settings`,
|
|
3380
2806
|
].join('\n');
|
|
3381
2807
|
}
|
|
3382
2808
|
|
|
3383
2809
|
// Decode JWT claims (inspection only, no verification)
|
|
3384
2810
|
let authInfo = '❌ invalid JWT format';
|
|
2811
|
+
let isExpired = false;
|
|
3385
2812
|
try {
|
|
3386
2813
|
const parts = JWT.split('.');
|
|
3387
2814
|
if (parts.length === 3) {
|
|
@@ -3392,33 +2819,44 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3392
2819
|
const iss = payload.iss ?? '(unknown)';
|
|
3393
2820
|
const provider = iss.includes('keycloak') ? 'Keycloak' : 'OIDC';
|
|
3394
2821
|
const expTs = payload.exp ? new Date(payload.exp * 1000) : null;
|
|
3395
|
-
|
|
2822
|
+
isExpired = expTs ? expTs < new Date() : false;
|
|
3396
2823
|
authInfo = [
|
|
3397
|
-
|
|
2824
|
+
`${isExpired ? '⚠️ ' : '✅'} JWT decoded`,
|
|
3398
2825
|
` Sub (user ID): ${sub}`,
|
|
3399
2826
|
` Provider: ${provider}`,
|
|
3400
2827
|
` Issuer: ${iss}`,
|
|
3401
|
-
` Expires: ${expTs ? expTs.toISOString() : 'never'} ${
|
|
2828
|
+
` Expires: ${expTs ? expTs.toISOString() : 'never'} ${isExpired ? '⚠️ EXPIRED' : '✅'}`,
|
|
3402
2829
|
].join('\n');
|
|
3403
2830
|
}
|
|
3404
2831
|
} catch {
|
|
3405
2832
|
authInfo = '❌ JWT decode failed – check CACHLY_JWT format';
|
|
3406
2833
|
}
|
|
3407
2834
|
|
|
3408
|
-
|
|
2835
|
+
const lines = [
|
|
3409
2836
|
`📡 **cachly API Status**`,
|
|
3410
2837
|
``,
|
|
3411
|
-
` 🌐 API:
|
|
3412
|
-
` 💓 Health:
|
|
2838
|
+
` 🌐 API: ${API_URL}`,
|
|
2839
|
+
` 💓 API Health: ${healthStatus}`,
|
|
2840
|
+
` 🔐 Auth Service: ${authSvcStatus}`,
|
|
3413
2841
|
``,
|
|
3414
|
-
`🔑 **Auth:**`,
|
|
2842
|
+
`🔑 **Auth (JWT):**`,
|
|
3415
2843
|
authInfo,
|
|
3416
|
-
]
|
|
2844
|
+
];
|
|
2845
|
+
|
|
2846
|
+
if (isExpired) {
|
|
2847
|
+
lines.push(``, `⚠️ Your token is expired — get a new one at https://cachly.dev/settings`);
|
|
2848
|
+
}
|
|
2849
|
+
if (authSvcStatus.startsWith('❌')) {
|
|
2850
|
+
lines.push(``, `⚠️ Auth service appears down. Registration and login on cachly.dev may be unavailable.`);
|
|
2851
|
+
lines.push(` Contact support@cachly.dev if this persists.`);
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
return lines.join('\n');
|
|
3417
2855
|
}
|
|
3418
2856
|
|
|
3419
2857
|
// ── session_start ─────────────────────────────────────────────────────────
|
|
3420
2858
|
case 'session_start': {
|
|
3421
|
-
const { instance_id, focus = ''
|
|
2859
|
+
const { instance_id, focus = '' } = args as { instance_id: string; focus?: string };
|
|
3422
2860
|
const redis = await getConnection(instance_id);
|
|
3423
2861
|
|
|
3424
2862
|
// 1. Scan all best-solution lessons
|
|
@@ -3433,8 +2871,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3433
2871
|
// 2. Fetch all lesson values for recency sorting + focus matching
|
|
3434
2872
|
type Lesson = {
|
|
3435
2873
|
topic: string; outcome: string; what_worked: string; what_failed?: string;
|
|
3436
|
-
ts: string;
|
|
3437
|
-
tags?: string[]; confidence?: number; audit_trail?: unknown[];
|
|
2874
|
+
ts: string; severity?: string; recall_count?: number; tags?: string[];
|
|
3438
2875
|
};
|
|
3439
2876
|
const lessons: Lesson[] = [];
|
|
3440
2877
|
for (const k of lessonKeys) {
|
|
@@ -3473,43 +2910,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3473
2910
|
)
|
|
3474
2911
|
: [];
|
|
3475
2912
|
|
|
3476
|
-
// 6.
|
|
3477
|
-
let streakDays = 0;
|
|
3478
|
-
let streakRecord = 0;
|
|
3479
|
-
let streakMessage = '';
|
|
3480
|
-
try {
|
|
3481
|
-
const streakRaw = await redis.get('cachly:streak:current');
|
|
3482
|
-
const streak = streakRaw ? JSON.parse(streakRaw) as { days: number; last_date: string; record: number } : null;
|
|
3483
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
3484
|
-
if (streak) {
|
|
3485
|
-
const lastDate = streak.last_date;
|
|
3486
|
-
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
|
3487
|
-
if (lastDate === today) {
|
|
3488
|
-
// Already counted today
|
|
3489
|
-
streakDays = streak.days;
|
|
3490
|
-
streakRecord = streak.record;
|
|
3491
|
-
} else if (lastDate === yesterday) {
|
|
3492
|
-
// Continuing streak
|
|
3493
|
-
streakDays = streak.days + 1;
|
|
3494
|
-
streakRecord = Math.max(streakDays, streak.record);
|
|
3495
|
-
await redis.set('cachly:streak:current', JSON.stringify({ days: streakDays, last_date: today, record: streakRecord }));
|
|
3496
|
-
} else {
|
|
3497
|
-
// Streak broken
|
|
3498
|
-
streakDays = 1;
|
|
3499
|
-
streakRecord = streak.record;
|
|
3500
|
-
await redis.set('cachly:streak:current', JSON.stringify({ days: 1, last_date: today, record: streakRecord }));
|
|
3501
|
-
}
|
|
3502
|
-
} else {
|
|
3503
|
-
// First session ever
|
|
3504
|
-
streakDays = 1;
|
|
3505
|
-
streakRecord = 1;
|
|
3506
|
-
await redis.set('cachly:streak:current', JSON.stringify({ days: 1, last_date: today, record: 1 }));
|
|
3507
|
-
}
|
|
3508
|
-
if (streakDays >= 7) streakMessage = `🔥 **${streakDays}-day streak!** ${streakDays === streakRecord ? ' New record!' : `Best: ${streakRecord}d`}`;
|
|
3509
|
-
else if (streakDays > 1) streakMessage = `🔥 ${streakDays}-day streak`;
|
|
3510
|
-
} catch { /* non-critical */ }
|
|
3511
|
-
|
|
3512
|
-
// 7. Save session start marker
|
|
2913
|
+
// 6. Save session start marker
|
|
3513
2914
|
await redis.set('cachly:session:current', JSON.stringify({
|
|
3514
2915
|
started: new Date().toISOString(),
|
|
3515
2916
|
focus,
|
|
@@ -3517,38 +2918,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3517
2918
|
|
|
3518
2919
|
// ── Build briefing ──────────────────────────────────────────────────────
|
|
3519
2920
|
const lines: string[] = ['🧠 **Session Briefing**', ''];
|
|
3520
|
-
if (streakMessage) lines.push(streakMessage, '');
|
|
3521
|
-
|
|
3522
|
-
// Handoff from previous window (if any)
|
|
3523
|
-
const handoffRaw = await redis.get('cachly:session:handoff');
|
|
3524
|
-
if (handoffRaw) {
|
|
3525
|
-
try {
|
|
3526
|
-
const handoff = JSON.parse(handoffRaw) as {
|
|
3527
|
-
ts: string; completed_tasks: string[]; remaining_tasks: string[];
|
|
3528
|
-
files_changed?: { path: string; status: string; description?: string }[];
|
|
3529
|
-
instructions?: string; context_summary?: string; blocked_on?: string;
|
|
3530
|
-
};
|
|
3531
|
-
const ago = Math.round((Date.now() - new Date(handoff.ts).getTime()) / 60000);
|
|
3532
|
-
const agoStr = ago < 60 ? `${ago}m ago` : ago < 1440 ? `${Math.round(ago / 60)}h ago` : `${Math.round(ago / 1440)}d ago`;
|
|
3533
|
-
|
|
3534
|
-
lines.push(`🤝 **Handoff from previous window** (${agoStr}):`);
|
|
3535
|
-
if (handoff.context_summary) lines.push(` ${handoff.context_summary}`);
|
|
3536
|
-
if (handoff.remaining_tasks.length > 0) {
|
|
3537
|
-
lines.push(` ⏳ **Remaining tasks:**`);
|
|
3538
|
-
for (const t of handoff.remaining_tasks) lines.push(` - ${t}`);
|
|
3539
|
-
}
|
|
3540
|
-
if (handoff.completed_tasks.length > 0) {
|
|
3541
|
-
lines.push(` ✅ **Already done:** ${handoff.completed_tasks.join(', ')}`);
|
|
3542
|
-
}
|
|
3543
|
-
const brokenFiles = (handoff.files_changed ?? []).filter(f => f.status === 'broken' || f.status === 'partial');
|
|
3544
|
-
if (brokenFiles.length > 0) {
|
|
3545
|
-
lines.push(` ⚠️ **Needs fix:** ${brokenFiles.map(f => `\`${f.path}\` (${f.status}${f.description ? ': ' + f.description : ''})`).join(', ')}`);
|
|
3546
|
-
}
|
|
3547
|
-
if (handoff.blocked_on) lines.push(` 🚫 **Blocked on:** ${handoff.blocked_on}`);
|
|
3548
|
-
if (handoff.instructions) lines.push(` 📝 **Instructions:** ${handoff.instructions}`);
|
|
3549
|
-
lines.push('');
|
|
3550
|
-
} catch { /* ignore corrupt handoff */ }
|
|
3551
|
-
}
|
|
3552
2921
|
|
|
3553
2922
|
// Last session
|
|
3554
2923
|
if (lastSession) {
|
|
@@ -3600,136 +2969,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3600
2969
|
lines.push('');
|
|
3601
2970
|
}
|
|
3602
2971
|
|
|
3603
|
-
// ── Stale / low-confidence lessons (confidence decay) ─────────────────
|
|
3604
|
-
const staleSuccessLessons = lessons.filter(l => {
|
|
3605
|
-
if (l.outcome !== 'success' && l.outcome !== 'partial') return false;
|
|
3606
|
-
return calculateConfidence(l) < CONFIDENCE_WARN_VALUE;
|
|
3607
|
-
});
|
|
3608
|
-
if (staleSuccessLessons.length > 0) {
|
|
3609
|
-
lines.push(`🔴 **Stale lessons** (not recalled in >${CONFIDENCE_WARN_DAYS}d — verify before applying):`);
|
|
3610
|
-
for (const l of staleSuccessLessons.slice(0, 4)) {
|
|
3611
|
-
const conf = calculateConfidence(l);
|
|
3612
|
-
const ageDays = Math.round((Date.now() - new Date(l.verified_at ?? l.ts).getTime()) / 86400000);
|
|
3613
|
-
const flag = conf < CONFIDENCE_STALE_VALUE ? '🔴' : '⚠️';
|
|
3614
|
-
lines.push(` ${flag} \`${l.topic}\` — ${ageDays}d stale, ${(conf * 100).toFixed(0)}% confidence`);
|
|
3615
|
-
}
|
|
3616
|
-
lines.push(` _Run \`recall_best_solution\` on these to reset their confidence clock._`);
|
|
3617
|
-
lines.push('');
|
|
3618
|
-
}
|
|
3619
|
-
|
|
3620
|
-
// ── Session Replay: show last session's decision log ──────────────────
|
|
3621
|
-
const lastSessionAny = lastSession as unknown as { decision_log?: Array<{ topic: string; outcome: string; what_worked: string }> } | null;
|
|
3622
|
-
if (lastSessionAny?.decision_log?.length) {
|
|
3623
|
-
const dl = lastSessionAny.decision_log;
|
|
3624
|
-
const successes = dl.filter(d => d.outcome === 'success');
|
|
3625
|
-
const failures = dl.filter(d => d.outcome === 'failure');
|
|
3626
|
-
lines.push(`🎬 **Last session decisions** (${dl.length} lessons stored):`);
|
|
3627
|
-
if (successes.length > 0) lines.push(` ✅ Worked: ${successes.slice(0, 3).map(d => `\`${d.topic}\``).join(', ')}`);
|
|
3628
|
-
if (failures.length > 0) lines.push(` ❌ Failed: ${failures.slice(0, 3).map(d => `\`${d.topic}\``).join(', ')}`);
|
|
3629
|
-
lines.push('');
|
|
3630
|
-
}
|
|
3631
|
-
|
|
3632
|
-
// ── 🔮 Predictive Pre-Warning — intent-based danger detection ────────────
|
|
3633
|
-
// Fires BEFORE work starts. If focus area has known failure patterns → warn loudly.
|
|
3634
|
-
if (focusTerms.length > 0) {
|
|
3635
|
-
type LessonAny = typeof lessons[0] & { author?: string; tags?: string[] };
|
|
3636
|
-
const dangerLessons = (lessons as LessonAny[]).filter(l => {
|
|
3637
|
-
if (l.outcome === 'success') return false;
|
|
3638
|
-
const topicCategory = l.topic.split(':')[0];
|
|
3639
|
-
return focusTerms.some(term =>
|
|
3640
|
-
l.topic.toLowerCase().includes(term) ||
|
|
3641
|
-
topicCategory === term ||
|
|
3642
|
-
(l.tags ?? []).some((t: string) => t.toLowerCase() === term),
|
|
3643
|
-
);
|
|
3644
|
-
});
|
|
3645
|
-
if (dangerLessons.length >= 1) {
|
|
3646
|
-
// Insert warning block right after the title line (index 1 = blank line after title)
|
|
3647
|
-
const warning = [
|
|
3648
|
-
`🚨 **PRE-WARNING** — Read this BEFORE starting:`,
|
|
3649
|
-
` Known pitfalls for **"${focus}"** (${dangerLessons.length} past failure${dangerLessons.length > 1 ? 's' : ''}):`,
|
|
3650
|
-
...dangerLessons.slice(0, 3).map(l => ` ❌ \`${l.topic}\` — ${(l.what_failed ?? l.what_worked).slice(0, 80)}`),
|
|
3651
|
-
'',
|
|
3652
|
-
];
|
|
3653
|
-
lines.splice(2, 0, ...warning); // after '🧠 **Session Briefing**' + empty line
|
|
3654
|
-
}
|
|
3655
|
-
}
|
|
3656
|
-
|
|
3657
|
-
// ── 👥 Team Telepathy — what teammates learned this week ─────────────────
|
|
3658
|
-
if (author) {
|
|
3659
|
-
type LessonAny = typeof lessons[0] & { author?: string };
|
|
3660
|
-
const oneWeekAgo = Date.now() - 7 * 86_400_000;
|
|
3661
|
-
const teamLessons = (lessons as LessonAny[]).filter(l =>
|
|
3662
|
-
l.author && l.author !== author && new Date(l.ts).getTime() > oneWeekAgo,
|
|
3663
|
-
);
|
|
3664
|
-
if (teamLessons.length > 0) {
|
|
3665
|
-
// Group by author
|
|
3666
|
-
const byAuthor = new Map<string, LessonAny[]>();
|
|
3667
|
-
for (const l of teamLessons) {
|
|
3668
|
-
const a = l.author!;
|
|
3669
|
-
if (!byAuthor.has(a)) byAuthor.set(a, []);
|
|
3670
|
-
byAuthor.get(a)!.push(l);
|
|
3671
|
-
}
|
|
3672
|
-
lines.push(`👥 **Team this week** (${teamLessons.length} lesson${teamLessons.length > 1 ? 's' : ''} from teammates):`);
|
|
3673
|
-
for (const [teamAuthor, tls] of byAuthor) {
|
|
3674
|
-
lines.push(` 👤 **${teamAuthor}**:`);
|
|
3675
|
-
for (const l of tls.slice(0, 3)) {
|
|
3676
|
-
const emoji = l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
|
|
3677
|
-
lines.push(` ${emoji} \`${l.topic}\` — ${l.what_worked.slice(0, 80)}`);
|
|
3678
|
-
}
|
|
3679
|
-
if (tls.length > 3) lines.push(` … and ${tls.length - 3} more`);
|
|
3680
|
-
}
|
|
3681
|
-
lines.push('');
|
|
3682
|
-
}
|
|
3683
|
-
}
|
|
3684
|
-
|
|
3685
|
-
// ── 💎 Memory Crystal — compressed wisdom from old sessions ──────────────
|
|
3686
|
-
try {
|
|
3687
|
-
const crystalRaw = await redis.get('cachly:crystal:latest');
|
|
3688
|
-
if (crystalRaw) {
|
|
3689
|
-
const crystal = JSON.parse(crystalRaw) as {
|
|
3690
|
-
label: string; ts: string; session_count: number;
|
|
3691
|
-
top_patterns: Array<{ category: string; insight: string; count: number }>;
|
|
3692
|
-
};
|
|
3693
|
-
const crystalAge = Math.round((Date.now() - new Date(crystal.ts).getTime()) / 86_400_000);
|
|
3694
|
-
if (crystalAge <= 90) {
|
|
3695
|
-
lines.push(`💎 **Memory Crystal** (${crystal.label} · ${crystal.session_count} sessions compressed):`);
|
|
3696
|
-
for (const p of crystal.top_patterns.slice(0, 3)) {
|
|
3697
|
-
lines.push(` • **${p.category}** (${p.count}×): ${p.insight.slice(0, 90)}`);
|
|
3698
|
-
}
|
|
3699
|
-
lines.push('');
|
|
3700
|
-
}
|
|
3701
|
-
}
|
|
3702
|
-
} catch { /* non-critical */ }
|
|
3703
|
-
|
|
3704
|
-
// ── 🗺️ Roadmap — open items at session start ────────────────────────────
|
|
3705
|
-
try {
|
|
3706
|
-
const roadmapAll = await redis.hgetall(`cachly:roadmap:${instance_id}`);
|
|
3707
|
-
if (roadmapAll && Object.keys(roadmapAll).length > 0) {
|
|
3708
|
-
const PRIORITY_ORDER: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
3709
|
-
const PRIORITY_ICON: Record<string, string> = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
|
|
3710
|
-
const openStatuses = new Set(['planned', 'in-progress', 'blocked']);
|
|
3711
|
-
const allItems = Object.values(roadmapAll).map(v => JSON.parse(v as string) as Record<string, unknown>);
|
|
3712
|
-
const openItems = allItems
|
|
3713
|
-
.filter(i => openStatuses.has(i.status as string))
|
|
3714
|
-
.sort((a, b) => {
|
|
3715
|
-
if (a.status === 'in-progress' && b.status !== 'in-progress') return -1;
|
|
3716
|
-
if (b.status === 'in-progress' && a.status !== 'in-progress') return 1;
|
|
3717
|
-
return (PRIORITY_ORDER[a.priority as string] ?? 99) - (PRIORITY_ORDER[b.priority as string] ?? 99);
|
|
3718
|
-
});
|
|
3719
|
-
const doneCount = allItems.filter(i => i.status === 'done').length;
|
|
3720
|
-
if (openItems.length > 0) {
|
|
3721
|
-
lines.push(`🗺️ **Roadmap** (${openItems.length} open · ${doneCount} done):`);
|
|
3722
|
-
for (const it of openItems.slice(0, 5)) {
|
|
3723
|
-
const statusIcon = it.status === 'in-progress' ? '⚡' : it.status === 'blocked' ? '🚫' : '📋';
|
|
3724
|
-
lines.push(` ${statusIcon} ${PRIORITY_ICON[it.priority as string] ?? '⚪'} \`${it.id}\` **${it.title}**`);
|
|
3725
|
-
}
|
|
3726
|
-
if (openItems.length > 5) lines.push(` … and ${openItems.length - 5} more`);
|
|
3727
|
-
lines.push(` _Use \`roadmap_next\` for the top priority item · \`roadmap_list\` for full view_`);
|
|
3728
|
-
lines.push('');
|
|
3729
|
-
}
|
|
3730
|
-
}
|
|
3731
|
-
} catch { /* non-critical */ }
|
|
3732
|
-
|
|
3733
2972
|
return lines.join('\n');
|
|
3734
2973
|
}
|
|
3735
2974
|
|
|
@@ -3740,13 +2979,11 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3740
2979
|
summary,
|
|
3741
2980
|
files_changed = [],
|
|
3742
2981
|
lessons_learned,
|
|
3743
|
-
workspace_path = '',
|
|
3744
2982
|
} = args as {
|
|
3745
2983
|
instance_id: string;
|
|
3746
2984
|
summary: string;
|
|
3747
2985
|
files_changed?: string[];
|
|
3748
2986
|
lessons_learned?: number;
|
|
3749
|
-
workspace_path?: string;
|
|
3750
2987
|
};
|
|
3751
2988
|
|
|
3752
2989
|
const redis = await getConnection(instance_id);
|
|
@@ -3762,21 +2999,12 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3762
2999
|
} catch { /* ignore */ }
|
|
3763
3000
|
}
|
|
3764
3001
|
|
|
3765
|
-
// ── Session Replay: capture decision log ─────────────────────────────
|
|
3766
|
-
let decisionLog: Array<{ ts: string; topic: string; outcome: string; what_worked: string }> = [];
|
|
3767
|
-
try {
|
|
3768
|
-
const dlEntries = await redis.lrange('cachly:session:decision-log', 0, -1);
|
|
3769
|
-
decisionLog = dlEntries.map(e => JSON.parse(e) as { ts: string; topic: string; outcome: string; what_worked: string });
|
|
3770
|
-
await redis.del('cachly:session:decision-log');
|
|
3771
|
-
} catch { /* non-critical */ }
|
|
3772
|
-
|
|
3773
3002
|
const sessionRecord = {
|
|
3774
3003
|
ts: now.toISOString(),
|
|
3775
3004
|
summary,
|
|
3776
3005
|
files_changed,
|
|
3777
3006
|
...(lessons_learned !== undefined ? { lessons_learned } : {}),
|
|
3778
3007
|
...(durationMin !== undefined ? { duration_min: durationMin } : {}),
|
|
3779
|
-
...(decisionLog.length > 0 ? { decision_log: decisionLog } : {}),
|
|
3780
3008
|
};
|
|
3781
3009
|
|
|
3782
3010
|
// Save as "last session"
|
|
@@ -3790,127 +3018,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3790
3018
|
// Clean up current session marker
|
|
3791
3019
|
await redis.del('cachly:session:current');
|
|
3792
3020
|
|
|
3793
|
-
// ── AUTO-LEARN from session summary (no manual call needed) ─────────────
|
|
3794
|
-
// Parse the summary for actionable lessons and store them automatically.
|
|
3795
|
-
const autoLearned: string[] = [];
|
|
3796
|
-
try {
|
|
3797
|
-
// Extract key sentences from the summary that contain action verbs
|
|
3798
|
-
const actionVerbs = /\b(fixed|deployed|added|removed|refactored|migrated|updated|resolved|implemented|improved|optimized|configured|created|deleted|disabled|enabled|discovered|found|learned|debugged|patched|upgraded|installed|tested|built|rewrote|moved|renamed|split|merged|extracted)\b/i;
|
|
3799
|
-
const sentences = summary
|
|
3800
|
-
.split(/[.!\n]+/)
|
|
3801
|
-
.map(s => s.trim())
|
|
3802
|
-
.filter(s => s.length > 20 && actionVerbs.test(s));
|
|
3803
|
-
|
|
3804
|
-
for (const sentence of sentences.slice(0, 6)) {
|
|
3805
|
-
// Build a topic slug from the first meaningful words
|
|
3806
|
-
const words = sentence.toLowerCase()
|
|
3807
|
-
.replace(/[^a-z0-9\s]/g, ' ')
|
|
3808
|
-
.split(/\s+/)
|
|
3809
|
-
.filter(w => w.length > 3 && !['that', 'this', 'with', 'from', 'have', 'been', 'were', 'they', 'then', 'when', 'also', 'into', 'will', 'would', 'could', 'should'].includes(w));
|
|
3810
|
-
const slug = words.slice(0, 4).join('-');
|
|
3811
|
-
if (!slug) continue;
|
|
3812
|
-
const topic = `auto:${slug}`;
|
|
3813
|
-
const key = `cachly:lesson:best:${topic}`;
|
|
3814
|
-
|
|
3815
|
-
// Don't overwrite existing successful lessons
|
|
3816
|
-
const existing = await redis.get(key);
|
|
3817
|
-
if (existing) {
|
|
3818
|
-
try {
|
|
3819
|
-
const ex = JSON.parse(existing) as { outcome: string };
|
|
3820
|
-
if (ex.outcome === 'success') continue;
|
|
3821
|
-
} catch { /* ignore */ }
|
|
3822
|
-
}
|
|
3823
|
-
|
|
3824
|
-
const lesson = {
|
|
3825
|
-
topic,
|
|
3826
|
-
outcome: 'success',
|
|
3827
|
-
what_worked: sentence,
|
|
3828
|
-
context: `Auto-learned from session summary. Full summary: ${summary.slice(0, 300)}`,
|
|
3829
|
-
severity: 'minor',
|
|
3830
|
-
ts: now.toISOString(),
|
|
3831
|
-
recall_count: 0,
|
|
3832
|
-
auto_learned: true,
|
|
3833
|
-
session_ts: now.toISOString(),
|
|
3834
|
-
version: 2,
|
|
3835
|
-
};
|
|
3836
|
-
await redis.set(key, JSON.stringify(lesson));
|
|
3837
|
-
// 90-day TTL for auto-learned lessons
|
|
3838
|
-
await redis.expire(key, 90 * 86400);
|
|
3839
|
-
autoLearned.push(topic);
|
|
3840
|
-
}
|
|
3841
|
-
|
|
3842
|
-
// Also store a lesson per changed file area if files were changed
|
|
3843
|
-
if (files_changed.length > 0) {
|
|
3844
|
-
const areas = [...new Set(files_changed.map(f => f.split('/').slice(0, 2).join('/')))].slice(0, 3);
|
|
3845
|
-
for (const area of areas) {
|
|
3846
|
-
const slug = area.replace(/[^a-z0-9]/gi, '-').toLowerCase().replace(/-+/g, '-').slice(0, 30);
|
|
3847
|
-
const topic = `auto:changed:${slug}`;
|
|
3848
|
-
const key = `cachly:lesson:best:${topic}`;
|
|
3849
|
-
const lesson = {
|
|
3850
|
-
topic,
|
|
3851
|
-
outcome: 'success',
|
|
3852
|
-
what_worked: `Files changed in ${area}: ${files_changed.filter(f => f.startsWith(area.split('/')[0])).slice(0, 5).join(', ')}`,
|
|
3853
|
-
context: summary.slice(0, 200),
|
|
3854
|
-
severity: 'minor',
|
|
3855
|
-
ts: now.toISOString(),
|
|
3856
|
-
recall_count: 0,
|
|
3857
|
-
auto_learned: true,
|
|
3858
|
-
version: 2,
|
|
3859
|
-
};
|
|
3860
|
-
await redis.set(key, JSON.stringify(lesson));
|
|
3861
|
-
await redis.expire(key, 90 * 86400);
|
|
3862
|
-
autoLearned.push(topic);
|
|
3863
|
-
}
|
|
3864
|
-
}
|
|
3865
|
-
} catch { /* auto-learn errors must never break session_end */ }
|
|
3866
|
-
|
|
3867
|
-
// ── 🌿 Ambient Git Learning ────────────────────────────────────────────────
|
|
3868
|
-
// Read git commits since session start → auto-learn each meaningful commit.
|
|
3869
|
-
const ambientLearned: string[] = [];
|
|
3870
|
-
if (workspace_path) {
|
|
3871
|
-
try {
|
|
3872
|
-
// Get the session start time (stored by session_start)
|
|
3873
|
-
const sessionStartTs = currentRaw
|
|
3874
|
-
? (() => { try { return (JSON.parse(currentRaw) as { started?: string }).started ?? ''; } catch { return ''; } })()
|
|
3875
|
-
: '';
|
|
3876
|
-
const sinceArg = sessionStartTs ? `--since="${sessionStartTs}"` : '--since="1 hour ago"';
|
|
3877
|
-
const gitOut = execSync(
|
|
3878
|
-
`git -C "${workspace_path}" log ${sinceArg} --oneline --format="%H|||%s|||%ai"`,
|
|
3879
|
-
{ timeout: 5000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
3880
|
-
).trim();
|
|
3881
|
-
if (gitOut) {
|
|
3882
|
-
const commitActionRe = /\b(fix|add|remove|refactor|migrate|update|resolve|implement|improve|optimize|configure|create|delete|disable|enable|debug|patch|upgrade|build|rewrite|deploy|feat|chore|docs|test|perf|ci)\b/i;
|
|
3883
|
-
for (const line of gitOut.split('\n').slice(0, 10)) {
|
|
3884
|
-
const [hash, msg, dateStr] = line.split('|||');
|
|
3885
|
-
if (!msg || !commitActionRe.test(msg)) continue;
|
|
3886
|
-
const slug = msg
|
|
3887
|
-
.toLowerCase().replace(/^(fix|feat|chore|docs|test|ci|perf|refactor|build|revert)[:(\s]/i, '')
|
|
3888
|
-
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
|
|
3889
|
-
if (!slug) continue;
|
|
3890
|
-
const topic = `git:${slug}`;
|
|
3891
|
-
const key = `cachly:lesson:best:${topic}`;
|
|
3892
|
-
const existing = await redis.get(key);
|
|
3893
|
-
if (existing) continue; // don't overwrite existing
|
|
3894
|
-
const commitLesson = {
|
|
3895
|
-
topic,
|
|
3896
|
-
outcome: 'success' as const,
|
|
3897
|
-
what_worked: msg.slice(0, 200),
|
|
3898
|
-
context: `Auto-learned from git commit ${(hash ?? '').slice(0, 7)} at ${dateStr ?? ''} in ${workspace_path}`,
|
|
3899
|
-
severity: 'minor' as const,
|
|
3900
|
-
ts: now.toISOString(),
|
|
3901
|
-
recall_count: 0,
|
|
3902
|
-
auto_learned: true,
|
|
3903
|
-
source: 'ambient-git',
|
|
3904
|
-
version: 3,
|
|
3905
|
-
};
|
|
3906
|
-
await redis.set(key, JSON.stringify(commitLesson));
|
|
3907
|
-
await redis.expire(key, 60 * 86400); // 60 day TTL for git lessons
|
|
3908
|
-
ambientLearned.push(topic);
|
|
3909
|
-
}
|
|
3910
|
-
}
|
|
3911
|
-
} catch { /* git not available or not a repo — silent skip */ }
|
|
3912
|
-
}
|
|
3913
|
-
|
|
3914
3021
|
const durationStr = durationMin !== undefined ? ` · ${durationMin} min` : '';
|
|
3915
3022
|
return [
|
|
3916
3023
|
`✅ **Session saved**${durationStr}`,
|
|
@@ -3918,70 +3025,11 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
3918
3025
|
`📋 **Summary:** ${summary}`,
|
|
3919
3026
|
files_changed.length > 0 ? `📁 **Files changed:** ${files_changed.map(f => `\`${f}\``).join(', ')}` : '',
|
|
3920
3027
|
lessons_learned !== undefined ? `🧠 **Lessons stored:** ${lessons_learned}` : '',
|
|
3921
|
-
autoLearned.length > 0 ? `🤖 **Auto-learned:** ${autoLearned.length} lessons extracted from summary (${autoLearned.slice(0, 3).map(t => `\`${t}\``).join(', ')}${autoLearned.length > 3 ? '…' : ''})` : '',
|
|
3922
|
-
ambientLearned.length > 0 ? `🌿 **Ambient git learning:** ${ambientLearned.length} commit${ambientLearned.length > 1 ? 's' : ''} auto-learned (${ambientLearned.slice(0, 3).map(t => `\`${t}\``).join(', ')}${ambientLearned.length > 3 ? '…' : ''})` : '',
|
|
3923
3028
|
``,
|
|
3924
3029
|
`💡 Next session: \`session_start(focus="...")\` to see this summary.`,
|
|
3925
3030
|
].filter(l => l !== '').join('\n');
|
|
3926
3031
|
}
|
|
3927
3032
|
|
|
3928
|
-
// ── session_handoff — cross-window continuity ─────────────────────────────
|
|
3929
|
-
case 'session_handoff': {
|
|
3930
|
-
const {
|
|
3931
|
-
instance_id,
|
|
3932
|
-
completed_tasks = [],
|
|
3933
|
-
remaining_tasks = [],
|
|
3934
|
-
files_changed = [],
|
|
3935
|
-
instructions = '',
|
|
3936
|
-
context_summary = '',
|
|
3937
|
-
blocked_on = '',
|
|
3938
|
-
} = args as {
|
|
3939
|
-
instance_id: string;
|
|
3940
|
-
completed_tasks: string[];
|
|
3941
|
-
remaining_tasks: string[];
|
|
3942
|
-
files_changed?: { path: string; status: string; description?: string }[];
|
|
3943
|
-
instructions?: string;
|
|
3944
|
-
context_summary?: string;
|
|
3945
|
-
blocked_on?: string;
|
|
3946
|
-
};
|
|
3947
|
-
|
|
3948
|
-
const redis = await getConnection(instance_id);
|
|
3949
|
-
const now = new Date();
|
|
3950
|
-
|
|
3951
|
-
const handoff = {
|
|
3952
|
-
ts: now.toISOString(),
|
|
3953
|
-
completed_tasks,
|
|
3954
|
-
remaining_tasks,
|
|
3955
|
-
files_changed,
|
|
3956
|
-
instructions,
|
|
3957
|
-
context_summary,
|
|
3958
|
-
blocked_on,
|
|
3959
|
-
};
|
|
3960
|
-
|
|
3961
|
-
// Store handoff — never expires until next handoff overwrites it
|
|
3962
|
-
await redis.set('cachly:session:handoff', JSON.stringify(handoff));
|
|
3963
|
-
|
|
3964
|
-
// Also append to history
|
|
3965
|
-
await redis.lpush('cachly:session:handoff:history', JSON.stringify(handoff));
|
|
3966
|
-
await redis.ltrim('cachly:session:handoff:history', 0, 19);
|
|
3967
|
-
|
|
3968
|
-
const totalTasks = completed_tasks.length + remaining_tasks.length;
|
|
3969
|
-
const pct = totalTasks > 0 ? Math.round((completed_tasks.length / totalTasks) * 100) : 0;
|
|
3970
|
-
const brokenFiles = files_changed.filter(f => f.status === 'broken' || f.status === 'partial');
|
|
3971
|
-
|
|
3972
|
-
return [
|
|
3973
|
-
`🤝 **Handoff saved** — ${completed_tasks.length}/${totalTasks} tasks done (${pct}%)`,
|
|
3974
|
-
``,
|
|
3975
|
-
completed_tasks.length > 0 ? `✅ **Completed:**\n${completed_tasks.map(t => ` - ${t}`).join('\n')}` : '',
|
|
3976
|
-
remaining_tasks.length > 0 ? `\n⏳ **Remaining for next window:**\n${remaining_tasks.map(t => ` - ${t}`).join('\n')}` : '',
|
|
3977
|
-
brokenFiles.length > 0 ? `\n⚠️ **Needs attention:** ${brokenFiles.map(f => `\`${f.path}\` (${f.status})`).join(', ')}` : '',
|
|
3978
|
-
blocked_on ? `\n🚫 **Blocked on:** ${blocked_on}` : '',
|
|
3979
|
-
instructions ? `\n📝 **Instructions:** ${instructions}` : '',
|
|
3980
|
-
``,
|
|
3981
|
-
`💡 The next \`session_start\` will include this handoff automatically.`,
|
|
3982
|
-
].filter(l => l !== '').join('\n');
|
|
3983
|
-
}
|
|
3984
|
-
|
|
3985
3033
|
// ── auto_learn_session ────────────────────────────────────────────────────
|
|
3986
3034
|
case 'auto_learn_session': {
|
|
3987
3035
|
const { instance_id, observations } = args as {
|
|
@@ -4209,153 +3257,9 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
4209
3257
|
return lines.join('\n');
|
|
4210
3258
|
}
|
|
4211
3259
|
|
|
4212
|
-
// ── team_synthesize — Team Brain Synthesis ────────────────────────────────
|
|
4213
|
-
case 'team_synthesize': {
|
|
4214
|
-
const { instance_id, topic } = args as { instance_id: string; topic: string };
|
|
4215
|
-
const redis = await getConnection(instance_id);
|
|
4216
|
-
|
|
4217
|
-
// Load history list for this topic (all authors' contributions)
|
|
4218
|
-
const listKey = `cachly:lessons:${topic}`;
|
|
4219
|
-
const all = await redis.lrange(listKey, 0, -1);
|
|
4220
|
-
if (all.length < 2) {
|
|
4221
|
-
return `📭 Need at least 2 entries for topic \`${topic}\` to synthesize.\n\nCurrently: ${all.length} entr${all.length === 1 ? 'y' : 'ies'}.\n\nHave team members store lessons via \`learn_from_attempts(topic="${topic}", ...)\`.`;
|
|
4222
|
-
}
|
|
4223
|
-
|
|
4224
|
-
type Entry = { outcome: string; what_worked: string; what_failed?: string; author?: string; ts: string; severity?: string };
|
|
4225
|
-
const entries: Entry[] = all.map(r => { try { return JSON.parse(r) as Entry; } catch { return null; } }).filter((e): e is Entry => e !== null);
|
|
4226
|
-
|
|
4227
|
-
// Group by outcome
|
|
4228
|
-
const successes = entries.filter(e => e.outcome === 'success');
|
|
4229
|
-
const failures = entries.filter(e => e.outcome === 'failure');
|
|
4230
|
-
const partials = entries.filter(e => e.outcome === 'partial');
|
|
4231
|
-
|
|
4232
|
-
const authors = [...new Set(entries.map(e => e.author).filter(Boolean))];
|
|
4233
|
-
const hasMultiAuthor = authors.length > 1;
|
|
4234
|
-
|
|
4235
|
-
// Build canonical merged version
|
|
4236
|
-
// what_worked: pick the most recent success, or longest for most detail
|
|
4237
|
-
const bestSuccess = successes.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime())[0];
|
|
4238
|
-
const whatWorkedCandidates = successes.map(e => e.what_worked).filter(w => w && w.length > 10);
|
|
4239
|
-
const canonicalWorked = whatWorkedCandidates.sort((a, b) => b.length - a.length)[0] ?? bestSuccess?.what_worked ?? '';
|
|
4240
|
-
|
|
4241
|
-
// what_failed: union of all unique failure reasons
|
|
4242
|
-
const allFailed = [...new Set(
|
|
4243
|
-
[...failures, ...partials].map(e => e.what_failed).filter((w): w is string => !!w && w.length > 5)
|
|
4244
|
-
)];
|
|
4245
|
-
|
|
4246
|
-
const severities = entries.map(e => e.severity).filter(Boolean);
|
|
4247
|
-
const canonicalSeverity = severities.includes('critical') ? 'critical' : severities.includes('major') ? 'major' : 'minor';
|
|
4248
|
-
|
|
4249
|
-
const lines = [
|
|
4250
|
-
`🧬 **Team Brain Synthesis: \`${topic}\`**`,
|
|
4251
|
-
`_${entries.length} entries from ${authors.length} author${authors.length === 1 ? '' : 's'}${hasMultiAuthor ? ` (${authors.join(', ')})` : ''} · ${successes.length} success · ${failures.length} failure · ${partials.length} partial_`,
|
|
4252
|
-
'',
|
|
4253
|
-
`**Canonical "what worked":**`,
|
|
4254
|
-
`> ${canonicalWorked}`,
|
|
4255
|
-
'',
|
|
4256
|
-
allFailed.length > 0 ? `**Avoid (combined failures):**` : '',
|
|
4257
|
-
...allFailed.map(f => `> ❌ ${f}`),
|
|
4258
|
-
allFailed.length > 0 ? '' : '',
|
|
4259
|
-
`**Suggested canonical lesson:**`,
|
|
4260
|
-
'```',
|
|
4261
|
-
`learn_from_attempts(`,
|
|
4262
|
-
` topic = "${topic}",`,
|
|
4263
|
-
` outcome = "success",`,
|
|
4264
|
-
` what_worked = "${canonicalWorked.replace(/"/g, "'")}",`,
|
|
4265
|
-
allFailed.length > 0 ? ` what_failed = "${allFailed[0].replace(/"/g, "'")}",` : '',
|
|
4266
|
-
` severity = "${canonicalSeverity}",`,
|
|
4267
|
-
`)`,
|
|
4268
|
-
'```',
|
|
4269
|
-
'',
|
|
4270
|
-
hasMultiAuthor
|
|
4271
|
-
? `💡 _${authors.length} team members contributed to this synthesis. Store the canonical version to replace individual entries._`
|
|
4272
|
-
: `💡 _Single author — more value when multiple team members contribute to the same topic._`,
|
|
4273
|
-
].filter(l => l !== undefined).join('\n');
|
|
4274
|
-
return lines;
|
|
4275
|
-
}
|
|
4276
|
-
|
|
4277
3260
|
// ── brain_doctor ──────────────────────────────────────────────────────────
|
|
4278
|
-
// ── memory_crystalize ─────────────────────────────────────────────────────
|
|
4279
|
-
case 'memory_crystalize': {
|
|
4280
|
-
const { instance_id, label: crystalLabel = '' } = args as { instance_id: string; label?: string };
|
|
4281
|
-
const redis = await getConnection(instance_id);
|
|
4282
|
-
const now = new Date();
|
|
4283
|
-
const week = `${now.getFullYear()}-W${String(Math.ceil((now.getDate() - now.getDay() + 10) / 7)).padStart(2, '0')}`;
|
|
4284
|
-
const effectiveLabel = crystalLabel || `${now.toISOString().slice(0, 7)} Crystal`;
|
|
4285
|
-
|
|
4286
|
-
// Read session history
|
|
4287
|
-
const sessionHistory = await redis.lrange('cachly:session:history', 0, 49);
|
|
4288
|
-
|
|
4289
|
-
// Read all auto-learned lessons
|
|
4290
|
-
const allLessonKeys: string[] = [];
|
|
4291
|
-
const ls = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
|
|
4292
|
-
await new Promise<void>((res, rej) => {
|
|
4293
|
-
ls.on('data', (b: string[]) => allLessonKeys.push(...b));
|
|
4294
|
-
ls.on('end', res);
|
|
4295
|
-
ls.on('error', rej);
|
|
4296
|
-
});
|
|
4297
|
-
|
|
4298
|
-
type RawLesson = { topic: string; outcome: string; what_worked: string; severity?: string; ts: string; auto_learned?: boolean };
|
|
4299
|
-
const allLessons: RawLesson[] = [];
|
|
4300
|
-
for (const k of allLessonKeys) {
|
|
4301
|
-
const raw = await redis.get(k);
|
|
4302
|
-
if (!raw) continue;
|
|
4303
|
-
try { allLessons.push(JSON.parse(raw) as RawLesson); } catch { /* skip */ }
|
|
4304
|
-
}
|
|
4305
|
-
|
|
4306
|
-
// Group lessons by top-level category
|
|
4307
|
-
const categoryMap = new Map<string, RawLesson[]>();
|
|
4308
|
-
for (const l of allLessons) {
|
|
4309
|
-
const cat = l.topic.split(':')[0] || 'misc';
|
|
4310
|
-
if (!categoryMap.has(cat)) categoryMap.set(cat, []);
|
|
4311
|
-
categoryMap.get(cat)!.push(l);
|
|
4312
|
-
}
|
|
4313
|
-
|
|
4314
|
-
// Build top patterns (most frequent categories with a representative insight)
|
|
4315
|
-
const topPatterns: Array<{ category: string; insight: string; count: number }> = [];
|
|
4316
|
-
for (const [cat, catLessons] of [...categoryMap.entries()].sort((a, b) => b[1].length - a[1].length).slice(0, 8)) {
|
|
4317
|
-
const successLessons = catLessons.filter(l => l.outcome === 'success');
|
|
4318
|
-
const best = successLessons[0] ?? catLessons[0];
|
|
4319
|
-
if (!best) continue;
|
|
4320
|
-
topPatterns.push({
|
|
4321
|
-
category: cat,
|
|
4322
|
-
insight: best.what_worked.slice(0, 120),
|
|
4323
|
-
count: catLessons.length,
|
|
4324
|
-
});
|
|
4325
|
-
}
|
|
4326
|
-
|
|
4327
|
-
const crystal = {
|
|
4328
|
-
label: effectiveLabel,
|
|
4329
|
-
ts: now.toISOString(),
|
|
4330
|
-
session_count: sessionHistory.length,
|
|
4331
|
-
lesson_count: allLessons.length,
|
|
4332
|
-
top_patterns: topPatterns,
|
|
4333
|
-
categories: [...categoryMap.keys()],
|
|
4334
|
-
created_from: `${sessionHistory.length} sessions, ${allLessons.length} lessons`,
|
|
4335
|
-
};
|
|
4336
|
-
|
|
4337
|
-
const crystalJson = JSON.stringify(crystal);
|
|
4338
|
-
await redis.set('cachly:crystal:latest', crystalJson);
|
|
4339
|
-
await redis.expire('cachly:crystal:latest', 90 * 86400);
|
|
4340
|
-
await redis.set(`cachly:crystal:${week}`, crystalJson);
|
|
4341
|
-
await redis.expire(`cachly:crystal:${week}`, 365 * 86400);
|
|
4342
|
-
|
|
4343
|
-
const lines = [
|
|
4344
|
-
`💎 **Memory Crystal created: ${effectiveLabel}**`,
|
|
4345
|
-
``,
|
|
4346
|
-
`📊 Compressed: **${sessionHistory.length} sessions** + **${allLessons.length} lessons** → ${topPatterns.length} top patterns`,
|
|
4347
|
-
``,
|
|
4348
|
-
`**Top patterns by category:**`,
|
|
4349
|
-
...topPatterns.slice(0, 6).map(p => ` • **${p.category}** (${p.count}×): ${p.insight.slice(0, 90)}`),
|
|
4350
|
-
``,
|
|
4351
|
-
`💡 This crystal will appear in every future \`session_start\` briefing.`,
|
|
4352
|
-
`💡 Re-run \`memory_crystalize\` monthly to keep it fresh.`,
|
|
4353
|
-
];
|
|
4354
|
-
return lines.join('\n');
|
|
4355
|
-
}
|
|
4356
|
-
|
|
4357
3261
|
case 'brain_doctor': {
|
|
4358
|
-
const { instance_id
|
|
3262
|
+
const { instance_id } = args as { instance_id: string };
|
|
4359
3263
|
const redis = await getConnection(instance_id);
|
|
4360
3264
|
const issues: string[] = [];
|
|
4361
3265
|
const checks: string[] = [];
|
|
@@ -4381,15 +3285,12 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
4381
3285
|
});
|
|
4382
3286
|
|
|
4383
3287
|
// Load lessons for analysis
|
|
4384
|
-
type
|
|
4385
|
-
|
|
4386
|
-
verified_at?: string; severity?: string; audit_trail?: unknown[];
|
|
4387
|
-
};
|
|
4388
|
-
const lessons: DrLesson[] = [];
|
|
3288
|
+
type Lesson = { topic: string; outcome: string; recall_count?: number; ts: string; severity?: string };
|
|
3289
|
+
const lessons: Lesson[] = [];
|
|
4389
3290
|
for (const k of lessonKeys) {
|
|
4390
3291
|
const raw = await redis.get(k);
|
|
4391
3292
|
if (!raw) continue;
|
|
4392
|
-
try { lessons.push(JSON.parse(raw) as
|
|
3293
|
+
try { lessons.push(JSON.parse(raw) as Lesson); } catch { /* skip */ }
|
|
4393
3294
|
}
|
|
4394
3295
|
|
|
4395
3296
|
// Last session
|
|
@@ -4405,17 +3306,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
4405
3306
|
const unusedLessons = lessons.filter(l => (l.recall_count ?? 0) === 0);
|
|
4406
3307
|
// Critical lessons
|
|
4407
3308
|
const criticalLessons = lessons.filter(l => l.severity === 'critical');
|
|
4408
|
-
// Confidence decay analysis
|
|
4409
|
-
const staleLessons = lessons.filter(l => l.outcome === 'success' && calculateConfidence(l) < CONFIDENCE_STALE_VALUE);
|
|
4410
|
-
const warnLessons = lessons.filter(l => l.outcome === 'success' && calculateConfidence(l) >= CONFIDENCE_STALE_VALUE && calculateConfidence(l) < CONFIDENCE_WARN_VALUE);
|
|
4411
|
-
const withAudit = lessons.filter(l => (l.audit_trail ?? []).length > 1);
|
|
4412
|
-
// Team lessons
|
|
4413
|
-
type DrLessonWithAuthor = DrLesson & { author?: string };
|
|
4414
|
-
const teamLessons = (lessons as DrLessonWithAuthor[]).filter(l => l.author);
|
|
4415
|
-
const uniqueAuthors = new Set((lessons as DrLessonWithAuthor[]).map(l => l.author).filter(Boolean));
|
|
4416
|
-
// Effective IQ boost: total recalls / lessons (how much the brain actually helped)
|
|
4417
|
-
const totalRecalls = lessons.reduce((sum, l) => sum + (l.recall_count ?? 0), 0);
|
|
4418
|
-
const iqBoostPct = lessons.length > 0 ? Math.min(100, Math.round((totalRecalls / lessons.length) * 10)) : 0;
|
|
4419
3309
|
|
|
4420
3310
|
// Quality score (0-100)
|
|
4421
3311
|
let score = 50;
|
|
@@ -4427,67 +3317,12 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
4427
3317
|
if (openFailures.length === 0) score += 5;
|
|
4428
3318
|
const unusedRatio = lessons.length > 0 ? unusedLessons.length / lessons.length : 0;
|
|
4429
3319
|
if (unusedRatio < 0.5) score += 10;
|
|
4430
|
-
if (staleLessons.length === 0) score += 5;
|
|
4431
|
-
if (uniqueAuthors.size >= 2) score += 5; // team collaboration bonus
|
|
4432
3320
|
|
|
4433
3321
|
const scoreEmoji = score >= 80 ? '🟢' : score >= 50 ? '🟡' : '🔴';
|
|
4434
|
-
const iqEmoji = iqBoostPct >= 50 ? '🚀' : iqBoostPct >= 20 ? '📈' : '💤';
|
|
4435
3322
|
|
|
4436
3323
|
checks.push(`${scoreEmoji} **Brain Quality Score: ${score}/100**`);
|
|
4437
|
-
checks.push(
|
|
4438
|
-
checks.push(`📚 **Lessons:** ${lessonKeys.length} (${criticalLessons.length} critical · ${withAudit.length} with audit trail · ${teamLessons.length} from team)`);
|
|
3324
|
+
checks.push(`📚 **Lessons:** ${lessonKeys.length} (${criticalLessons.length} critical)`);
|
|
4439
3325
|
checks.push(`💾 **Context entries:** ${ctxCount}`);
|
|
4440
|
-
checks.push(`🎯 **Confidence:** ${lessons.length - staleLessons.length - warnLessons.length} fresh · ${warnLessons.length} warn · ${staleLessons.length} stale`);
|
|
4441
|
-
checks.push(`⏱️ **Decay config:** warn after ${CONFIDENCE_WARN_DAYS}d · stale after ${CONFIDENCE_STALE_DAYS}d`);
|
|
4442
|
-
if (uniqueAuthors.size >= 2) {
|
|
4443
|
-
checks.push(`👥 **Team:** ${uniqueAuthors.size} contributors (${[...uniqueAuthors].join(', ')})`);
|
|
4444
|
-
}
|
|
4445
|
-
|
|
4446
|
-
// Stale index detection
|
|
4447
|
-
try {
|
|
4448
|
-
const lastIndexRaw = await redis.get('cachly:index:last_run');
|
|
4449
|
-
if (lastIndexRaw) {
|
|
4450
|
-
const lastIndexAge = Math.round((Date.now() - new Date(lastIndexRaw).getTime()) / 86_400_000);
|
|
4451
|
-
if (lastIndexAge > 7) {
|
|
4452
|
-
issues.push(`🔄 Index is ${lastIndexAge}d stale — run \`index_project\` to re-sync semantic search`);
|
|
4453
|
-
} else {
|
|
4454
|
-
checks.push(`🗂️ **Semantic index:** ${lastIndexAge}d old (fresh)`);
|
|
4455
|
-
}
|
|
4456
|
-
} else {
|
|
4457
|
-
issues.push(`💡 No semantic index — run \`index_project(dir="<your-src>")\` to enable semantic search`);
|
|
4458
|
-
}
|
|
4459
|
-
} catch { /* non-critical */ }
|
|
4460
|
-
|
|
4461
|
-
// Memory crystal status
|
|
4462
|
-
try {
|
|
4463
|
-
const crystalRaw = await redis.get('cachly:crystal:latest');
|
|
4464
|
-
if (crystalRaw) {
|
|
4465
|
-
const crystal = JSON.parse(crystalRaw) as { ts: string; label: string };
|
|
4466
|
-
const crystalAge = Math.round((Date.now() - new Date(crystal.ts).getTime()) / 86_400_000);
|
|
4467
|
-
checks.push(`💎 **Memory Crystal:** ${crystal.label} (${crystalAge}d ago)`);
|
|
4468
|
-
if (crystalAge > 30) issues.push(`💡 Memory Crystal is ${crystalAge}d old — re-run \`memory_crystalize\` to compress new sessions`);
|
|
4469
|
-
} else if (lessonKeys.length >= 10) {
|
|
4470
|
-
issues.push(`💡 ${lessonKeys.length} lessons but no Memory Crystal — run \`memory_crystalize\` to compress wisdom`);
|
|
4471
|
-
}
|
|
4472
|
-
} catch { /* non-critical */ }
|
|
4473
|
-
|
|
4474
|
-
// openclaw cross-promo (check package.json in workspace)
|
|
4475
|
-
if (drWorkspacePath) {
|
|
4476
|
-
try {
|
|
4477
|
-
const pkgPath = drWorkspacePath.replace(/\/$/, '') + '/package.json';
|
|
4478
|
-
const pkgRaw = readFileSync(pkgPath, 'utf-8');
|
|
4479
|
-
const pkg = JSON.parse(pkgRaw) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
|
|
4480
|
-
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
4481
|
-
const hasLLMDep = ['openai', '@anthropic-ai/sdk', '@google/generative-ai', 'mistralai', 'cohere-ai'].some(d => d in allDeps);
|
|
4482
|
-
const hasOpenclaw = '@cachly-dev/openclaw' in allDeps;
|
|
4483
|
-
if (hasLLMDep && !hasOpenclaw) {
|
|
4484
|
-
issues.push(`💡 **openclaw missing:** you use LLM APIs (${Object.keys(allDeps).filter(d => ['openai','@anthropic-ai/sdk'].includes(d)).join(', ')}) but not \`@cachly-dev/openclaw\``);
|
|
4485
|
-
issues.push(` → \`npm install @cachly-dev/openclaw\` cuts LLM costs 60–90% with 3 lines of code`);
|
|
4486
|
-
} else if (hasOpenclaw) {
|
|
4487
|
-
checks.push(`✅ **@cachly-dev/openclaw installed** (LLM cost caching active)`);
|
|
4488
|
-
}
|
|
4489
|
-
} catch { /* no package.json or unreadable */ }
|
|
4490
|
-
}
|
|
4491
3326
|
|
|
4492
3327
|
if (lastSession) {
|
|
4493
3328
|
const ageMin = Math.round((Date.now() - new Date(lastSession.ts).getTime()) / 60000);
|
|
@@ -4503,10 +3338,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
4503
3338
|
issues.push(`💡 Only ${lessonKeys.length} lessons — add more after each problem solved`);
|
|
4504
3339
|
}
|
|
4505
3340
|
|
|
4506
|
-
if (iqBoostPct === 0 && lessons.length >= 5) {
|
|
4507
|
-
issues.push(`💤 **IQ Boost is 0%** — lessons exist but are never recalled. Use \`recall_best_solution\` BEFORE tasks.`);
|
|
4508
|
-
}
|
|
4509
|
-
|
|
4510
3341
|
if (ctxCount === 0) {
|
|
4511
3342
|
issues.push('💡 No context — use `remember_context` to cache architecture docs, ADRs, etc.');
|
|
4512
3343
|
}
|
|
@@ -4515,15 +3346,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
4515
3346
|
issues.push(`⚠️ ${openFailures.length} unresolved failure${openFailures.length > 1 ? 's' : ''}: ${openFailures.slice(0, 3).map(l => `\`${l.topic}\``).join(', ')}`);
|
|
4516
3347
|
}
|
|
4517
3348
|
|
|
4518
|
-
if (staleLessons.length > 0) {
|
|
4519
|
-
issues.push(`🔴 ${staleLessons.length} STALE lesson${staleLessons.length > 1 ? 's' : ''} (>${CONFIDENCE_STALE_DAYS}d, confidence <${CONFIDENCE_STALE_VALUE * 100}%): ${staleLessons.slice(0, 3).map(l => `\`${l.topic}\``).join(', ')}`);
|
|
4520
|
-
issues.push(` → Re-verify with \`recall_best_solution\` to reset confidence clock`);
|
|
4521
|
-
}
|
|
4522
|
-
|
|
4523
|
-
if (warnLessons.length > 0) {
|
|
4524
|
-
issues.push(`⚠️ ${warnLessons.length} lesson${warnLessons.length > 1 ? 's' : ''} aging (>${CONFIDENCE_WARN_DAYS}d): ${warnLessons.slice(0, 3).map(l => `\`${l.topic}\``).join(', ')}`);
|
|
4525
|
-
}
|
|
4526
|
-
|
|
4527
3349
|
if (unusedRatio > 0.7 && lessons.length > 5) {
|
|
4528
3350
|
issues.push(`💡 ${unusedLessons.length} lessons never recalled — verify topics match your workflow`);
|
|
4529
3351
|
}
|
|
@@ -4539,77 +3361,6 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
4539
3361
|
return lines.join('\n');
|
|
4540
3362
|
}
|
|
4541
3363
|
|
|
4542
|
-
// ── recall_at — Brain Archaeology ────────────────────────────────────────
|
|
4543
|
-
case 'recall_at': {
|
|
4544
|
-
const { instance_id, topic, date } = args as { instance_id: string; topic: string; date: string };
|
|
4545
|
-
const redis = await getConnection(instance_id);
|
|
4546
|
-
const cutoff = new Date(date).getTime();
|
|
4547
|
-
if (isNaN(cutoff)) return `❌ Invalid date "${date}". Use ISO format: "2026-01-15"`;
|
|
4548
|
-
|
|
4549
|
-
const listKey = `cachly:lessons:${topic}`;
|
|
4550
|
-
const all = await redis.lrange(listKey, 0, -1);
|
|
4551
|
-
if (all.length === 0) return `📭 No history found for \`${topic}\`. Lessons are stored via \`learn_from_attempts\`.`;
|
|
4552
|
-
|
|
4553
|
-
const before = all
|
|
4554
|
-
.map(raw => { try { return JSON.parse(raw) as { ts: string; outcome?: string; what_worked?: string; what_failed?: string }; } catch { return null; } })
|
|
4555
|
-
.filter((l): l is NonNullable<typeof l> => l !== null && new Date(l.ts).getTime() <= cutoff)
|
|
4556
|
-
.sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
|
|
4557
|
-
|
|
4558
|
-
if (before.length === 0) return `📭 No entries for \`${topic}\` found before **${date}**. Earliest entry: ${new Date(JSON.parse(all[0]).ts).toLocaleDateString('de-DE')}.`;
|
|
4559
|
-
|
|
4560
|
-
const lines = [
|
|
4561
|
-
`🏺 **Brain Archaeology: \`${topic}\` before ${date}**`,
|
|
4562
|
-
`_${before.length} of ${all.length} total entries shown_`,
|
|
4563
|
-
'',
|
|
4564
|
-
];
|
|
4565
|
-
for (const l of before.slice(-10)) {
|
|
4566
|
-
const emoji = l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
|
|
4567
|
-
const d = new Date(l.ts).toLocaleDateString('de-DE');
|
|
4568
|
-
lines.push(`**${d}** ${emoji} ${l.outcome}`);
|
|
4569
|
-
if (l.what_worked) lines.push(` → ${l.what_worked.slice(0, 100)}`);
|
|
4570
|
-
lines.push('');
|
|
4571
|
-
}
|
|
4572
|
-
lines.push(`_Full evolution: ${all.map(r => { try { const l = JSON.parse(r) as { outcome?: string }; return l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌'; } catch { return '?'; } }).join(' → ')}_`);
|
|
4573
|
-
return lines.join('\n');
|
|
4574
|
-
}
|
|
4575
|
-
|
|
4576
|
-
// ── trace_dependency — Causal Chain ──────────────────────────────────────
|
|
4577
|
-
case 'trace_dependency': {
|
|
4578
|
-
const { instance_id, dependency, mark_review = false } = args as { instance_id: string; dependency: string; mark_review?: boolean };
|
|
4579
|
-
const redis = await getConnection(instance_id);
|
|
4580
|
-
|
|
4581
|
-
const depKey = `cachly:dep:${dependency}`;
|
|
4582
|
-
const raw = await redis.get(depKey);
|
|
4583
|
-
if (!raw) return `📭 No lessons found that depend on \`${dependency}\`.\n\nAdd dependencies via: \`learn_from_attempts(..., depends_on=["${dependency}"])\``;
|
|
4584
|
-
|
|
4585
|
-
const topics: string[] = JSON.parse(raw);
|
|
4586
|
-
const lines = [
|
|
4587
|
-
`🔗 **Causal Chain: \`${dependency}\`** — ${topics.length} dependent lesson${topics.length === 1 ? '' : 's'}`,
|
|
4588
|
-
'',
|
|
4589
|
-
];
|
|
4590
|
-
|
|
4591
|
-
for (const t of topics) {
|
|
4592
|
-
const lessonRaw = await redis.get(`cachly:lesson:best:${t}`);
|
|
4593
|
-
if (!lessonRaw) { lines.push(` • \`${t}\` _(lesson deleted)_`); continue; }
|
|
4594
|
-
const lesson = JSON.parse(lessonRaw) as { outcome?: string; severity?: string; needs_review?: boolean };
|
|
4595
|
-
const emoji = lesson.outcome === 'success' ? '✅' : lesson.outcome === 'partial' ? '⚠️' : '❌';
|
|
4596
|
-
const reviewBadge = lesson.needs_review ? ' 🔍 **needs_review**' : '';
|
|
4597
|
-
lines.push(` ${emoji} \`${t}\` (${lesson.severity ?? 'major'})${reviewBadge}`);
|
|
4598
|
-
|
|
4599
|
-
if (mark_review) {
|
|
4600
|
-
const updated = { ...lesson, needs_review: true };
|
|
4601
|
-
await redis.set(`cachly:lesson:best:${t}`, JSON.stringify(updated));
|
|
4602
|
-
}
|
|
4603
|
-
}
|
|
4604
|
-
|
|
4605
|
-
if (mark_review) {
|
|
4606
|
-
lines.push('', `🔍 All ${topics.length} lessons marked as **needs_review** — verify they still work with the changed dependency.`);
|
|
4607
|
-
} else {
|
|
4608
|
-
lines.push('', `_Run with \`mark_review: true\` to flag all dependent lessons for re-verification._`);
|
|
4609
|
-
}
|
|
4610
|
-
return lines.join('\n');
|
|
4611
|
-
}
|
|
4612
|
-
|
|
4613
3364
|
// ── global_learn ──────────────────────────────────────────────────────────
|
|
4614
3365
|
case 'global_learn': {
|
|
4615
3366
|
const { instance_id, topic, lesson, severity = 'minor', tags = [] } = args as {
|
|
@@ -4891,148 +3642,65 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
|
|
|
4891
3642
|
// Generate copilot-instructions.md content
|
|
4892
3643
|
const tier = inst.tier.toUpperCase();
|
|
4893
3644
|
const smartRecallNote = hasVector
|
|
4894
|
-
? '
|
|
4895
|
-
: '
|
|
3645
|
+
? '3. Call `smart_recall` with keywords for semantic context search'
|
|
3646
|
+
: '3. Call `recall_context` with relevant key patterns like "arch:*" or "file:*"';
|
|
4896
3647
|
const layerNote = hasVector
|
|
4897
|
-
? `Layer 3 (Semantic): smart_recall uses pgvector HNSW on your ${tier} instance`
|
|
4898
|
-
: `Layer 3 (Autopilot):
|
|
4899
|
-
|
|
4900
|
-
const copilotInstructions = `# cachly AI Brain — ${project_description}
|
|
4901
|
-
|
|
4902
|
-
> AI memory system powered by [cachly.dev](https://cachly.dev). Works with GitHub Copilot, Claude Code, Cursor, Windsurf, Continue.dev, and any MCP-compatible editor.
|
|
4903
|
-
|
|
4904
|
-
---
|
|
4905
|
-
|
|
4906
|
-
## 🔴 MANDATORY RULES — Always enforced, no exceptions
|
|
4907
|
-
|
|
4908
|
-
### Rule 1 — Session Start (FIRST thing in every window)
|
|
4909
|
-
Call \`session_start\` **before reading any source files or making any changes**:
|
|
4910
|
-
|
|
4911
|
-
\`\`\`
|
|
4912
|
-
session_start(
|
|
4913
|
-
instance_id = "${instance_id}",
|
|
4914
|
-
focus = "<what you plan to work on today>",
|
|
4915
|
-
)
|
|
4916
|
-
\`\`\`
|
|
4917
|
-
|
|
4918
|
-
Returns: last session summary, handoff from previous window, recent lessons, open failures, brain health.
|
|
4919
|
-
|
|
4920
|
-
**If \`session_start\` is unavailable**, fall back:
|
|
4921
|
-
1. \`smart_recall("recent lessons <topic>")\`
|
|
4922
|
-
2. \`recall_best_solution("<topic>")\`
|
|
4923
|
-
|
|
4924
|
-
### Rule 2 — Learn After Every Fix/Deploy/Discovery (AUTOMATIC — no prompting needed)
|
|
4925
|
-
After **every** bug fix, deployment, failed attempt, or discovery — call \`learn_from_attempts\` immediately:
|
|
4926
|
-
|
|
4927
|
-
\`\`\`
|
|
4928
|
-
learn_from_attempts(
|
|
4929
|
-
instance_id = "${instance_id}",
|
|
4930
|
-
topic = "category:keyword", # deploy:web | fix:auth | debug:db | infra:docker
|
|
4931
|
-
outcome = "success" | "failure" | "partial",
|
|
4932
|
-
what_worked = "exact approach or command that worked",
|
|
4933
|
-
what_failed = "what did NOT work (prevents future wasted time)",
|
|
4934
|
-
severity = "critical" | "major" | "minor",
|
|
4935
|
-
commands = ["exact command that worked"],
|
|
4936
|
-
file_paths = ["path/to/changed/file"],
|
|
4937
|
-
)
|
|
4938
|
-
\`\`\`
|
|
4939
|
-
|
|
4940
|
-
**These events MUST automatically trigger \`learn_from_attempts\`:**
|
|
4941
|
-
- ✅ Successfully deployed → \`topic="deploy:<component>", outcome="success"\`
|
|
4942
|
-
- ✅ Fixed a bug → \`topic="fix:<description>", outcome="success"\`
|
|
4943
|
-
- ✅ Discovered root cause → \`topic="debug:<area>", outcome="partial"\`
|
|
4944
|
-
- ✅ Tried something that failed → \`topic="<area>", outcome="failure", what_failed="..."\`
|
|
4945
|
-
- ✅ Found a config quirk → \`topic="infra:<topic>", outcome="success"\`
|
|
4946
|
-
- ✅ Ran a working command → store it in \`commands=[...]\`
|
|
3648
|
+
? `Layer 3 (Semantic): smart_recall uses pgvector HNSW on your ${tier} instance for meaning-based search`
|
|
3649
|
+
: `Layer 3 (Autopilot): This file — upgrade to Speed/Business tier to unlock smart_recall`;
|
|
4947
3650
|
|
|
4948
|
-
|
|
4949
|
-
\`\`\`
|
|
4950
|
-
session_handoff(
|
|
4951
|
-
instance_id = "${instance_id}",
|
|
4952
|
-
completed_tasks = ["what was done"],
|
|
4953
|
-
remaining_tasks = ["what is still to do"],
|
|
4954
|
-
files_changed = [{"path": "src/...", "status": "modified", "description": "what changed"}],
|
|
4955
|
-
instructions = "IMPORTANT: what the next window MUST know to not break things",
|
|
4956
|
-
context_summary = "current state of the system",
|
|
4957
|
-
)
|
|
4958
|
-
\`\`\`
|
|
4959
|
-
|
|
4960
|
-
The next \`session_start\` automatically includes this handoff. The new window sees exactly what's done, what remains, and what NOT to break.
|
|
3651
|
+
const copilotInstructions = `# cachly AI Memory — ${project_description}
|
|
4961
3652
|
|
|
4962
|
-
|
|
4963
|
-
\`\`\`
|
|
4964
|
-
session_end(
|
|
4965
|
-
instance_id = "${instance_id}",
|
|
4966
|
-
summary = "What was accomplished (2-3 sentences with specifics)",
|
|
4967
|
-
files_changed = ["list", "of", "changed", "files"],
|
|
4968
|
-
lessons_learned = <number of learn_from_attempts calls made>,
|
|
4969
|
-
)
|
|
4970
|
-
\`\`\`
|
|
3653
|
+
## 3-Layer AI Memory System
|
|
4971
3654
|
|
|
4972
|
-
|
|
3655
|
+
- **Layer 1 — Storage:** cachly instance \`${inst.name}\` (${tier}) — persistent Valkey brain, survives restarts
|
|
3656
|
+
- **Layer 2 — Tools:** learn_from_attempts · recall_best_solution · smart_recall — the memory API
|
|
3657
|
+
- **Layer 3 — Autopilot (this file):** instructs the AI to run memory tools automatically
|
|
4973
3658
|
|
|
4974
|
-
|
|
4975
|
-
- \`index_project\` tracks MD5 hashes per file — **skips unchanged files automatically**
|
|
4976
|
-
- After code changes: next \`index_project\` run re-indexes only modified files
|
|
4977
|
-
- After delete/rename: run \`index_project\` again to sync
|
|
4978
|
-
- No manual invalidation needed
|
|
3659
|
+
## BEFORE every task — check memory first
|
|
4979
3660
|
|
|
4980
|
-
|
|
3661
|
+
1. Call \`recall_best_solution\` with the task topic to get known solutions
|
|
3662
|
+
- \`recall_best_solution("deploy:web")\` → returns working deploy command
|
|
3663
|
+
- \`recall_best_solution("debug:redis-tls")\` → returns root cause + fix
|
|
3664
|
+
- If a solution exists, use it — do NOT re-discover from scratch
|
|
4981
3665
|
|
|
4982
|
-
|
|
3666
|
+
2. Call \`recall_context\` with the area you're working in
|
|
3667
|
+
- \`recall_context("arch:*")\` → cached architecture decisions
|
|
3668
|
+
- \`recall_context("file:src/auth*")\` → cached file summaries
|
|
4983
3669
|
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
| Deploy anything | \`recall_best_solution("deploy:<component>")\` | \`learn_from_attempts(topic="deploy:...")\` |
|
|
4987
|
-
| Fix a bug | \`recall_best_solution("fix:<area>")\` | \`learn_from_attempts(topic="fix:...")\` |
|
|
4988
|
-
| Add a feature | \`session_start(focus="feat:<area>")\` | \`learn_from_attempts(topic="feat:...")\` |
|
|
4989
|
-
| Infra/server work | \`recall_best_solution("infra:<topic>")\` | \`learn_from_attempts(topic="infra:...")\` |
|
|
4990
|
-
| Debug an issue | \`smart_recall("<error message or symptom>")\` | \`learn_from_attempts(topic="debug:...")\` |
|
|
3670
|
+
${smartRecallNote}
|
|
3671
|
+
- \`smart_recall("how does authentication work")\` → returns cached auth notes
|
|
4991
3672
|
|
|
4992
|
-
|
|
3673
|
+
4. If nothing is found, proceed normally — then save (see below)
|
|
4993
3674
|
|
|
4994
|
-
##
|
|
3675
|
+
## AFTER every non-trivial task — save to memory
|
|
4995
3676
|
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
| \`session_handoff\` | When closing window with remaining tasks |
|
|
5001
|
-
| \`learn_from_attempts\` | **AUTOMATIC** after every fix/deploy/discovery |
|
|
5002
|
-
| \`recall_best_solution\` | Before any non-trivial task |
|
|
5003
|
-
| \`remember_context\` | After analyzing code — save findings for future sessions |
|
|
5004
|
-
${smartRecallNote ? `| \`smart_recall\` | Search brain by meaning/keywords |\n` : ''}\
|
|
5005
|
-
| \`recall_context\` | Get exact key (supports glob: \`arch:*\`, \`file:*\`) |
|
|
5006
|
-
| \`brain_search\` | BM25+ full-text search over all brain data |
|
|
5007
|
-
| \`auto_learn_session\` | Batch-learn from a list of observations (optional) |
|
|
5008
|
-
| \`index_project\` | Index source files (smart hash, skips unchanged files) |
|
|
5009
|
-
| \`list_remembered\` | See what's cached in the brain |
|
|
5010
|
-
| \`forget_context\` | Remove stale context |
|
|
3677
|
+
1. Call \`learn_from_attempts\` for deploy/debug/fix tasks:
|
|
3678
|
+
- Always set outcome = "success" | "failure" | "partial"
|
|
3679
|
+
- Always include what_failed — it prevents future wasted time
|
|
3680
|
+
- 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")\`
|
|
5011
3681
|
|
|
5012
|
-
|
|
3682
|
+
2. Call \`remember_context\` for code/architecture findings:
|
|
3683
|
+
- \`remember_context("arch:auth", "Uses Keycloak with JWT...", category="architecture")\`
|
|
3684
|
+
- \`remember_context("file:src/index.ts", "Entry point, registers routes...", category="file_summary")\`
|
|
5013
3685
|
|
|
5014
3686
|
## Instance Details
|
|
5015
3687
|
|
|
5016
|
-
-
|
|
5017
|
-
-
|
|
5018
|
-
-
|
|
5019
|
-
-
|
|
5020
|
-
-
|
|
3688
|
+
- Instance ID: \`${instance_id}\`
|
|
3689
|
+
- Instance name: ${inst.name}
|
|
3690
|
+
- Tier: ${tier}
|
|
3691
|
+
- ${layerNote}
|
|
3692
|
+
- Embedding provider: ${providerArg}
|
|
5021
3693
|
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
## How the 3-layer system works
|
|
3694
|
+
## Quick reference
|
|
5025
3695
|
|
|
5026
3696
|
\`\`\`
|
|
5027
|
-
|
|
5028
|
-
|
|
5029
|
-
|
|
3697
|
+
recall_best_solution("topic") # before task
|
|
3698
|
+
smart_recall("natural language") # find by meaning
|
|
3699
|
+
learn_from_attempts(...) # after task
|
|
3700
|
+
remember_context("key", "content") # save analysis
|
|
5030
3701
|
\`\`\`
|
|
5031
|
-
|
|
5032
|
-
Result: Your AI **never solves the same problem twice** and always picks up exactly where it left off. 🚀
|
|
5033
3702
|
`;
|
|
5034
3703
|
|
|
5035
|
-
|
|
5036
3704
|
const lines: string[] = [
|
|
5037
3705
|
`🧠 **cachly AI Memory Setup Complete**`,
|
|
5038
3706
|
``,
|
|
@@ -5056,8 +3724,8 @@ Result: Your AI **never solves the same problem twice** and always picks up exac
|
|
|
5056
3724
|
`**Step 3 — copilot-instructions.md (Layer 3 Autopilot):**`,
|
|
5057
3725
|
``,
|
|
5058
3726
|
...(project_dir
|
|
5059
|
-
? [
|
|
5060
|
-
: [`Copy this to \`.github/copilot-instructions.md\`
|
|
3727
|
+
? [`Writing to \`${project_dir}/.github/copilot-instructions.md\`…`]
|
|
3728
|
+
: [`Copy this to \`.github/copilot-instructions.md\` in your project:`]),
|
|
5061
3729
|
``,
|
|
5062
3730
|
`\`\`\`markdown`,
|
|
5063
3731
|
copilotInstructions,
|
|
@@ -5072,71 +3740,16 @@ Result: Your AI **never solves the same problem twice** and always picks up exac
|
|
|
5072
3740
|
`Result: Your AI never solves the same problem twice. 🚀`,
|
|
5073
3741
|
];
|
|
5074
3742
|
|
|
5075
|
-
//
|
|
3743
|
+
// Optionally write the file
|
|
5076
3744
|
if (project_dir) {
|
|
5077
|
-
const { mkdir, writeFile
|
|
5078
|
-
const
|
|
5079
|
-
|
|
5080
|
-
const
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
// GitHub Copilot — always write (universal fallback)
|
|
5087
|
-
targets.push({
|
|
5088
|
-
ide: 'GitHub Copilot',
|
|
5089
|
-
path: join(project_dir, '.github', 'copilot-instructions.md'),
|
|
5090
|
-
content: copilotInstructions,
|
|
5091
|
-
});
|
|
5092
|
-
|
|
5093
|
-
// Claude Code — CLAUDE.md in project root
|
|
5094
|
-
if (await exists(join(project_dir, 'CLAUDE.md')) || await exists(join(project_dir, '.claude'))) {
|
|
5095
|
-
targets.push({
|
|
5096
|
-
ide: 'Claude Code',
|
|
5097
|
-
path: join(project_dir, 'CLAUDE.md'),
|
|
5098
|
-
content: copilotInstructions,
|
|
5099
|
-
});
|
|
5100
|
-
}
|
|
5101
|
-
|
|
5102
|
-
// Cursor — .cursor/rules (new format) or .cursorrules (legacy)
|
|
5103
|
-
if (
|
|
5104
|
-
await exists(join(project_dir, '.cursor')) ||
|
|
5105
|
-
await exists(join(project_dir, '.cursorrules'))
|
|
5106
|
-
) {
|
|
5107
|
-
const cursorDir = join(project_dir, '.cursor');
|
|
5108
|
-
await mkdir(cursorDir, { recursive: true });
|
|
5109
|
-
targets.push({
|
|
5110
|
-
ide: 'Cursor',
|
|
5111
|
-
path: join(cursorDir, 'rules'),
|
|
5112
|
-
content: copilotInstructions,
|
|
5113
|
-
});
|
|
5114
|
-
}
|
|
5115
|
-
|
|
5116
|
-
// Windsurf — .windsurfrules
|
|
5117
|
-
if (
|
|
5118
|
-
await exists(join(project_dir, '.windsurfrules')) ||
|
|
5119
|
-
await exists(join(project_dir, '.windsurf'))
|
|
5120
|
-
) {
|
|
5121
|
-
targets.push({
|
|
5122
|
-
ide: 'Windsurf',
|
|
5123
|
-
path: join(project_dir, '.windsurfrules'),
|
|
5124
|
-
content: copilotInstructions,
|
|
5125
|
-
});
|
|
5126
|
-
}
|
|
5127
|
-
|
|
5128
|
-
// VS Code (Copilot) — already covered by .github/copilot-instructions.md above
|
|
5129
|
-
// Continue.dev — .continue/config.json is JSON, not markdown — skip, copilot-instructions handles it
|
|
5130
|
-
|
|
5131
|
-
const written: string[] = [];
|
|
5132
|
-
for (const target of targets) {
|
|
5133
|
-
const dir = target.path.substring(0, target.path.lastIndexOf('/'));
|
|
5134
|
-
await mkdir(dir, { recursive: true });
|
|
5135
|
-
await writeFile(target.path, target.content, 'utf-8');
|
|
5136
|
-
written.push(`✅ [${target.ide}] → \`${target.path.replace(project_dir, '.')}\``);
|
|
5137
|
-
}
|
|
5138
|
-
|
|
5139
|
-
lines.push(...written);
|
|
3745
|
+
const { mkdir, writeFile } = await import('node:fs/promises');
|
|
3746
|
+
const githubDir = join(project_dir, '.github');
|
|
3747
|
+
await mkdir(githubDir, { recursive: true });
|
|
3748
|
+
const filePath = join(githubDir, 'copilot-instructions.md');
|
|
3749
|
+
await writeFile(filePath, copilotInstructions, 'utf-8');
|
|
3750
|
+
lines.splice(lines.indexOf(`Writing to \`${project_dir}/.github/copilot-instructions.md\`…`) + 1, 0,
|
|
3751
|
+
`✅ Written to \`${project_dir}/.github/copilot-instructions.md\``
|
|
3752
|
+
);
|
|
5140
3753
|
}
|
|
5141
3754
|
|
|
5142
3755
|
return lines.join('\n');
|
|
@@ -5264,178 +3877,6 @@ async function handleBulkLockStream(name: string, args: Record<string, unknown>)
|
|
|
5264
3877
|
);
|
|
5265
3878
|
}
|
|
5266
3879
|
|
|
5267
|
-
// ── Roadmap ──────────────────────────────────────────────────────────────
|
|
5268
|
-
|
|
5269
|
-
case 'roadmap_add': {
|
|
5270
|
-
const {
|
|
5271
|
-
instance_id: rid,
|
|
5272
|
-
title,
|
|
5273
|
-
description: desc = '',
|
|
5274
|
-
priority = 'medium',
|
|
5275
|
-
tags: rtags = [],
|
|
5276
|
-
milestone = '',
|
|
5277
|
-
} = args as {
|
|
5278
|
-
instance_id: string; title: string; description?: string;
|
|
5279
|
-
priority?: string; tags?: string[]; milestone?: string;
|
|
5280
|
-
};
|
|
5281
|
-
const redis = await getConnection(rid);
|
|
5282
|
-
const id = `rm_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
5283
|
-
const item = {
|
|
5284
|
-
id, title, description: desc, priority, tags: rtags, milestone,
|
|
5285
|
-
status: 'planned',
|
|
5286
|
-
created: new Date().toISOString(),
|
|
5287
|
-
updated: new Date().toISOString(),
|
|
5288
|
-
notes: '',
|
|
5289
|
-
};
|
|
5290
|
-
await redis.hset(`cachly:roadmap:${rid}`, id, JSON.stringify(item));
|
|
5291
|
-
const PRIORITY_ICON: Record<string, string> = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
|
|
5292
|
-
return [
|
|
5293
|
-
`📋 **Roadmap item added**`,
|
|
5294
|
-
``,
|
|
5295
|
-
` ID: \`${id}\``,
|
|
5296
|
-
` Title: ${title}`,
|
|
5297
|
-
` Priority: ${PRIORITY_ICON[priority] ?? '⚪'} ${priority}`,
|
|
5298
|
-
` Status: planned`,
|
|
5299
|
-
milestone ? ` Milestone: ${milestone}` : '',
|
|
5300
|
-
rtags.length ? ` Tags: ${rtags.join(', ')}` : '',
|
|
5301
|
-
``,
|
|
5302
|
-
`💡 Use \`roadmap_update(id: "${id}", status: "in-progress")\` when you start working on it.`,
|
|
5303
|
-
].filter(Boolean).join('\n');
|
|
5304
|
-
}
|
|
5305
|
-
|
|
5306
|
-
case 'roadmap_update': {
|
|
5307
|
-
const {
|
|
5308
|
-
instance_id: rid,
|
|
5309
|
-
id: itemId,
|
|
5310
|
-
status: newStatus,
|
|
5311
|
-
priority: newPriority,
|
|
5312
|
-
notes: newNotes,
|
|
5313
|
-
title: newTitle,
|
|
5314
|
-
description: newDesc,
|
|
5315
|
-
} = args as {
|
|
5316
|
-
instance_id: string; id: string; status?: string; priority?: string;
|
|
5317
|
-
notes?: string; title?: string; description?: string;
|
|
5318
|
-
};
|
|
5319
|
-
const redis = await getConnection(rid);
|
|
5320
|
-
const raw = await redis.hget(`cachly:roadmap:${rid}`, itemId);
|
|
5321
|
-
if (!raw) return `⚠️ **roadmap_update** – Item \`${itemId}\` not found. Use \`roadmap_list\` to see all items.`;
|
|
5322
|
-
const item = JSON.parse(raw) as Record<string, unknown>;
|
|
5323
|
-
const oldStatus = item.status as string;
|
|
5324
|
-
if (newStatus) item.status = newStatus;
|
|
5325
|
-
if (newPriority) item.priority = newPriority;
|
|
5326
|
-
if (newTitle) item.title = newTitle;
|
|
5327
|
-
if (newDesc) item.description = newDesc;
|
|
5328
|
-
if (newNotes) item.notes = item.notes ? `${item.notes}\n[${new Date().toISOString().slice(0, 10)}] ${newNotes}` : `[${new Date().toISOString().slice(0, 10)}] ${newNotes}`;
|
|
5329
|
-
item.updated = new Date().toISOString();
|
|
5330
|
-
await redis.hset(`cachly:roadmap:${rid}`, itemId, JSON.stringify(item));
|
|
5331
|
-
const statusEmoji: Record<string, string> = { planned: '📋', 'in-progress': '⚡', done: '✅', blocked: '🚫', cancelled: '🗑️' };
|
|
5332
|
-
return [
|
|
5333
|
-
`${statusEmoji[newStatus ?? oldStatus] ?? '📋'} **Roadmap updated** \`${itemId}\``,
|
|
5334
|
-
``,
|
|
5335
|
-
` Title: ${item.title}`,
|
|
5336
|
-
oldStatus !== newStatus ? ` Status: ${oldStatus} → ${newStatus}` : ` Status: ${item.status}`,
|
|
5337
|
-
newNotes ? ` Notes: ${newNotes}` : '',
|
|
5338
|
-
].filter(Boolean).join('\n');
|
|
5339
|
-
}
|
|
5340
|
-
|
|
5341
|
-
case 'roadmap_list': {
|
|
5342
|
-
const {
|
|
5343
|
-
instance_id: rid,
|
|
5344
|
-
status: filterStatus = 'open',
|
|
5345
|
-
tag: filterTag,
|
|
5346
|
-
milestone: filterMilestone,
|
|
5347
|
-
priority: filterPriority,
|
|
5348
|
-
} = args as {
|
|
5349
|
-
instance_id: string; status?: string; tag?: string;
|
|
5350
|
-
milestone?: string; priority?: string;
|
|
5351
|
-
};
|
|
5352
|
-
const redis = await getConnection(rid);
|
|
5353
|
-
const all = await redis.hgetall(`cachly:roadmap:${rid}`);
|
|
5354
|
-
if (!all || Object.keys(all).length === 0) {
|
|
5355
|
-
return '📋 **Roadmap is empty.**\n\nUse `roadmap_add` to create your first item.';
|
|
5356
|
-
}
|
|
5357
|
-
const PRIORITY_ORDER: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
5358
|
-
const PRIORITY_ICON: Record<string, string> = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
|
|
5359
|
-
const STATUS_ICON: Record<string, string> = { planned: '📋', 'in-progress': '⚡', done: '✅', blocked: '🚫', cancelled: '🗑️' };
|
|
5360
|
-
const openStatuses = new Set(['planned', 'in-progress', 'blocked']);
|
|
5361
|
-
let items = Object.values(all).map(v => JSON.parse(v as string) as Record<string, string | string[]>);
|
|
5362
|
-
// Filter
|
|
5363
|
-
if (filterStatus === 'open') items = items.filter(i => openStatuses.has(i.status as string));
|
|
5364
|
-
else if (filterStatus) items = items.filter(i => i.status === filterStatus);
|
|
5365
|
-
if (filterTag) items = items.filter(i => (i.tags as string[]).includes(filterTag));
|
|
5366
|
-
if (filterMilestone) items = items.filter(i => i.milestone === filterMilestone);
|
|
5367
|
-
if (filterPriority) items = items.filter(i => (PRIORITY_ORDER[i.priority as string] ?? 99) <= (PRIORITY_ORDER[filterPriority] ?? 99));
|
|
5368
|
-
// Sort: priority asc, then created asc
|
|
5369
|
-
items.sort((a, b) => {
|
|
5370
|
-
const pa = PRIORITY_ORDER[a.priority as string] ?? 99;
|
|
5371
|
-
const pb = PRIORITY_ORDER[b.priority as string] ?? 99;
|
|
5372
|
-
return pa !== pb ? pa - pb : (a.created as string).localeCompare(b.created as string);
|
|
5373
|
-
});
|
|
5374
|
-
if (items.length === 0) return `📋 **No roadmap items** match the current filter (status: ${filterStatus}).`;
|
|
5375
|
-
const grouped: Record<string, typeof items> = {};
|
|
5376
|
-
for (const it of items) {
|
|
5377
|
-
const st = it.status as string;
|
|
5378
|
-
if (!grouped[st]) grouped[st] = [];
|
|
5379
|
-
grouped[st].push(it);
|
|
5380
|
-
}
|
|
5381
|
-
const lines: string[] = [`📋 **Roadmap** (${items.length} item${items.length !== 1 ? 's' : ''})`, ''];
|
|
5382
|
-
for (const [st, grp] of Object.entries(grouped)) {
|
|
5383
|
-
lines.push(`**${STATUS_ICON[st] ?? '•'} ${st.toUpperCase()}** (${grp.length})`);
|
|
5384
|
-
for (const it of grp) {
|
|
5385
|
-
const tags = (it.tags as string[]).length ? ` [${(it.tags as string[]).join(', ')}]` : '';
|
|
5386
|
-
const milestone = it.milestone ? ` · ${it.milestone}` : '';
|
|
5387
|
-
lines.push(` ${PRIORITY_ICON[it.priority as string] ?? '⚪'} \`${it.id}\` **${it.title}**${tags}${milestone}`);
|
|
5388
|
-
if (it.description) lines.push(` ${(it.description as string).slice(0, 120)}`);
|
|
5389
|
-
if (it.notes) lines.push(` 📝 ${(it.notes as string).split('\n').pop()?.slice(0, 100)}`);
|
|
5390
|
-
}
|
|
5391
|
-
lines.push('');
|
|
5392
|
-
}
|
|
5393
|
-
lines.push(`💡 \`roadmap_update(id, status: "in-progress")\` to start · \`roadmap_next\` for top priority item`);
|
|
5394
|
-
return lines.join('\n');
|
|
5395
|
-
}
|
|
5396
|
-
|
|
5397
|
-
case 'roadmap_next': {
|
|
5398
|
-
const { instance_id: rid, tag: filterTag } = args as { instance_id: string; tag?: string };
|
|
5399
|
-
const redis = await getConnection(rid);
|
|
5400
|
-
const all = await redis.hgetall(`cachly:roadmap:${rid}`);
|
|
5401
|
-
if (!all || Object.keys(all).length === 0) {
|
|
5402
|
-
return '📋 **Roadmap is empty.** Use `roadmap_add` to plan your first task.';
|
|
5403
|
-
}
|
|
5404
|
-
const PRIORITY_ORDER: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
5405
|
-
const PRIORITY_ICON: Record<string, string> = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
|
|
5406
|
-
let items = Object.values(all)
|
|
5407
|
-
.map(v => JSON.parse(v as string) as Record<string, unknown>)
|
|
5408
|
-
.filter(i => i.status === 'in-progress' || i.status === 'planned')
|
|
5409
|
-
.filter(i => !filterTag || (i.tags as string[]).includes(filterTag));
|
|
5410
|
-
if (items.length === 0) return '🎉 **No open roadmap items!** All tasks are done (or use `roadmap_list` to check).';
|
|
5411
|
-
// in-progress first, then by priority
|
|
5412
|
-
items.sort((a, b) => {
|
|
5413
|
-
if (a.status === 'in-progress' && b.status !== 'in-progress') return -1;
|
|
5414
|
-
if (b.status === 'in-progress' && a.status !== 'in-progress') return 1;
|
|
5415
|
-
return (PRIORITY_ORDER[a.priority as string] ?? 99) - (PRIORITY_ORDER[b.priority as string] ?? 99);
|
|
5416
|
-
});
|
|
5417
|
-
const next = items[0];
|
|
5418
|
-
const remaining = items.length - 1;
|
|
5419
|
-
const tags = (next.tags as string[]).length ? `\nTags: ${(next.tags as string[]).join(', ')}` : '';
|
|
5420
|
-
const milestone = next.milestone ? `\nMilestone: ${next.milestone}` : '';
|
|
5421
|
-
const notes = next.notes ? `\nNotes: ${(next.notes as string).split('\n').pop()?.slice(0, 120)}` : '';
|
|
5422
|
-
return [
|
|
5423
|
-
`${next.status === 'in-progress' ? '⚡' : '📋'} **Next up: ${next.title}**`,
|
|
5424
|
-
``,
|
|
5425
|
-
`ID: \`${next.id}\``,
|
|
5426
|
-
`Priority: ${PRIORITY_ICON[next.priority as string] ?? '⚪'} ${next.priority}`,
|
|
5427
|
-
`Status: ${next.status}`,
|
|
5428
|
-
next.description ? `\nWhat to do:\n${next.description}` : '',
|
|
5429
|
-
tags, milestone, notes,
|
|
5430
|
-
``,
|
|
5431
|
-
remaining > 0 ? `(+${remaining} more open item${remaining !== 1 ? 's' : ''} in roadmap)` : '(last open item)',
|
|
5432
|
-
``,
|
|
5433
|
-
next.status === 'planned'
|
|
5434
|
-
? `💡 Start with: \`roadmap_update(id: "${next.id}", status: "in-progress")\``
|
|
5435
|
-
: `💡 Finish with: \`roadmap_update(id: "${next.id}", status: "done", notes: "...")\``,
|
|
5436
|
-
].filter(s => s !== undefined).join('\n');
|
|
5437
|
-
}
|
|
5438
|
-
|
|
5439
3880
|
default:
|
|
5440
3881
|
return null;
|
|
5441
3882
|
}
|
|
@@ -5444,81 +3885,14 @@ async function handleBulkLockStream(name: string, args: Record<string, unknown>)
|
|
|
5444
3885
|
// ── Server setup ──────────────────────────────────────────────────────────────
|
|
5445
3886
|
|
|
5446
3887
|
const server = new Server(
|
|
5447
|
-
{ name: 'cachly-mcp', version:
|
|
3888
|
+
{ name: 'cachly-mcp', version: '0.4.0' },
|
|
5448
3889
|
{ capabilities: { tools: {} } }
|
|
5449
3890
|
);
|
|
5450
3891
|
|
|
5451
3892
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
5452
3893
|
|
|
5453
|
-
// ── Auto-session management ───────────────────────────────────────────────────
|
|
5454
|
-
// Transparently starts a Brain session on the first tool call of a process
|
|
5455
|
-
// and saves a session_end summary on SIGTERM/exit so users never have to call
|
|
5456
|
-
// session_start/session_end manually.
|
|
5457
|
-
|
|
5458
|
-
let _autoSessionInstanceId: string | null = null;
|
|
5459
|
-
let _autoSessionStarted = false;
|
|
5460
|
-
let _autoSessionToolCount = 0;
|
|
5461
|
-
|
|
5462
|
-
async function autoStartSession(instanceId: string): Promise<void> {
|
|
5463
|
-
if (_autoSessionStarted) return;
|
|
5464
|
-
_autoSessionStarted = true;
|
|
5465
|
-
_autoSessionInstanceId = instanceId;
|
|
5466
|
-
try {
|
|
5467
|
-
await handleTool('session_start', { instance_id: instanceId, focus: 'auto (MCP session)' });
|
|
5468
|
-
} catch { /* non-fatal — session tracking is a best-effort feature */ }
|
|
5469
|
-
|
|
5470
|
-
// Auto-index the project if it hasn't been indexed in the last 24h.
|
|
5471
|
-
// This is the main lever for growing token savings: more indexed code →
|
|
5472
|
-
// more semantic cache hits → fewer LLM calls for repeated questions.
|
|
5473
|
-
if (process.env.CACHLY_AUTO_INDEX !== 'false') {
|
|
5474
|
-
try {
|
|
5475
|
-
const redis = await getConnection(instanceId);
|
|
5476
|
-
const lastIndexed = await redis.get(`cachly:index:last_indexed:${instanceId}`);
|
|
5477
|
-
const staleMs = 24 * 60 * 60 * 1000;
|
|
5478
|
-
const isStale = !lastIndexed || (Date.now() - parseInt(lastIndexed, 10)) > staleMs;
|
|
5479
|
-
if (isStale) {
|
|
5480
|
-
// Mark as indexing now to prevent concurrent re-runs.
|
|
5481
|
-
await redis.set(`cachly:index:last_indexed:${instanceId}`, String(Date.now()), 'EX', 90000);
|
|
5482
|
-
// Run in background — don't block the first tool call.
|
|
5483
|
-
handleTool('index_project', {
|
|
5484
|
-
instance_id: instanceId,
|
|
5485
|
-
dir: process.cwd(),
|
|
5486
|
-
max_files: 150,
|
|
5487
|
-
ttl: 86400 * 7, // cache for 7 days
|
|
5488
|
-
namespace: 'cachly:sem:code',
|
|
5489
|
-
}).catch(() => undefined);
|
|
5490
|
-
}
|
|
5491
|
-
} catch { /* never block the session on indexing errors */ }
|
|
5492
|
-
}
|
|
5493
|
-
}
|
|
5494
|
-
|
|
5495
|
-
async function autoEndSession(): Promise<void> {
|
|
5496
|
-
if (!_autoSessionStarted || !_autoSessionInstanceId) return;
|
|
5497
|
-
_autoSessionStarted = false;
|
|
5498
|
-
try {
|
|
5499
|
-
await handleTool('session_end', {
|
|
5500
|
-
instance_id: _autoSessionInstanceId,
|
|
5501
|
-
summary: `Auto-ended after ${_autoSessionToolCount} tool call(s). Session was started automatically.`,
|
|
5502
|
-
files_changed: [],
|
|
5503
|
-
lessons_learned: 0,
|
|
5504
|
-
});
|
|
5505
|
-
} catch { /* non-fatal */ }
|
|
5506
|
-
}
|
|
5507
|
-
|
|
5508
3894
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
5509
3895
|
const { name, arguments: args } = request.params;
|
|
5510
|
-
|
|
5511
|
-
// Auto-start brain session on first tool call that has an instance_id.
|
|
5512
|
-
// Skip session management tools to avoid recursion.
|
|
5513
|
-
const sessionTools = new Set(['session_start', 'session_end', 'auto_learn_session']);
|
|
5514
|
-
if (!sessionTools.has(name) && !_autoSessionStarted) {
|
|
5515
|
-
const instanceId = (args as Record<string, unknown>)?.instance_id as string | undefined;
|
|
5516
|
-
if (instanceId) {
|
|
5517
|
-
await autoStartSession(instanceId).catch(() => undefined);
|
|
5518
|
-
}
|
|
5519
|
-
}
|
|
5520
|
-
if (!sessionTools.has(name)) _autoSessionToolCount++;
|
|
5521
|
-
|
|
5522
3896
|
try {
|
|
5523
3897
|
const text = await handleTool(name, (args ?? {}) as Record<string, unknown>);
|
|
5524
3898
|
return { content: [{ type: 'text', text }] };
|
|
@@ -5528,15 +3902,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
5528
3902
|
}
|
|
5529
3903
|
});
|
|
5530
3904
|
|
|
5531
|
-
// Graceful shutdown – close all Redis connections
|
|
3905
|
+
// Graceful shutdown – close all Redis connections
|
|
5532
3906
|
process.on('SIGTERM', async () => {
|
|
5533
|
-
await autoEndSession().catch(() => undefined);
|
|
5534
|
-
for (const [, client] of pool) await client.quit().catch(() => undefined);
|
|
5535
|
-
process.exit(0);
|
|
5536
|
-
});
|
|
5537
|
-
|
|
5538
|
-
process.on('SIGINT', async () => {
|
|
5539
|
-
await autoEndSession().catch(() => undefined);
|
|
5540
3907
|
for (const [, client] of pool) await client.quit().catch(() => undefined);
|
|
5541
3908
|
process.exit(0);
|
|
5542
3909
|
});
|
|
@@ -5557,8 +3924,6 @@ const EDITOR_FILES: Record<string, string> = {
|
|
|
5557
3924
|
windsurf: '.windsurf/mcp.json',
|
|
5558
3925
|
copilot: '.vscode/mcp.json',
|
|
5559
3926
|
continue: '.continue/config.json',
|
|
5560
|
-
cline: '.vscode/mcp.json', // Cline respects VS Code project MCP config
|
|
5561
|
-
zed: '.zed/settings.json', // Zed project-level context_servers
|
|
5562
3927
|
};
|
|
5563
3928
|
|
|
5564
3929
|
const CLAUDE_MD_MARKER_START = '<!-- cachly-brain-start -->';
|
|
@@ -5575,20 +3940,6 @@ function buildMcpConfig(apiKey: string, instanceId: string, editor: string): str
|
|
|
5575
3940
|
},
|
|
5576
3941
|
}, null, 2);
|
|
5577
3942
|
}
|
|
5578
|
-
if (editor === 'zed') {
|
|
5579
|
-
return JSON.stringify({
|
|
5580
|
-
context_servers: {
|
|
5581
|
-
cachly: {
|
|
5582
|
-
command: {
|
|
5583
|
-
path: 'npx',
|
|
5584
|
-
args: ['-y', '@cachly-dev/mcp-server@latest'],
|
|
5585
|
-
env: { CACHLY_API_URL: 'https://api.cachly.dev', CACHLY_JWT: apiKey, CACHLY_BRAIN_INSTANCE_ID: instanceId },
|
|
5586
|
-
},
|
|
5587
|
-
settings: {},
|
|
5588
|
-
},
|
|
5589
|
-
},
|
|
5590
|
-
}, null, 2);
|
|
5591
|
-
}
|
|
5592
3943
|
return JSON.stringify({
|
|
5593
3944
|
mcpServers: {
|
|
5594
3945
|
cachly: {
|
|
@@ -5602,54 +3953,48 @@ function buildMcpConfig(apiKey: string, instanceId: string, editor: string): str
|
|
|
5602
3953
|
|
|
5603
3954
|
function buildClaudeMdBlock(instanceId: string): string {
|
|
5604
3955
|
return `${CLAUDE_MD_MARKER_START}
|
|
5605
|
-
## Cachly AI Brain
|
|
3956
|
+
## Cachly AI Brain — Persistent Memory
|
|
5606
3957
|
|
|
5607
|
-
|
|
5608
|
-
Your instance ID: \`${instanceId}\`
|
|
3958
|
+
Your AI Brain instance ID: \`${instanceId}\`
|
|
5609
3959
|
|
|
5610
|
-
>
|
|
5611
|
-
>
|
|
3960
|
+
> The Brain gives you persistent memory across sessions. Every lesson you learn,
|
|
3961
|
+
> every bug you fix, every architecture decision — recalled instantly next time.
|
|
3962
|
+
> instance_id is pre-filled below. Do NOT ask the user for it.
|
|
5612
3963
|
|
|
5613
|
-
|
|
3964
|
+
### MANDATORY: Call at the START of every session
|
|
3965
|
+
\`\`\`
|
|
3966
|
+
session_start(instance_id="${instanceId}", focus="<what you're working on today>")
|
|
3967
|
+
\`\`\`
|
|
3968
|
+
This loads your previous context, recent lessons, and open tasks automatically.
|
|
5614
3969
|
|
|
5615
|
-
###
|
|
3970
|
+
### MANDATORY: Call at the END of every session
|
|
5616
3971
|
\`\`\`
|
|
5617
|
-
|
|
5618
|
-
instance_id = "${instanceId}",
|
|
5619
|
-
key = "wip-registry",
|
|
5620
|
-
content = "EDITING: <file:line> — goal: <what you are implementing>",
|
|
5621
|
-
category = "bug",
|
|
5622
|
-
ttl = 86400,
|
|
5623
|
-
)
|
|
3972
|
+
session_end(instance_id="${instanceId}", summary="<2-3 sentences on what was done>", files_changed=["src/..."], lessons_learned=1)
|
|
5624
3973
|
\`\`\`
|
|
5625
|
-
After the edit is complete, update \`content\` to \`"DONE: <file> — <what was completed>"\`.
|
|
5626
3974
|
|
|
5627
|
-
### After fixing
|
|
3975
|
+
### After fixing ANY bug or solving a hard problem
|
|
5628
3976
|
\`\`\`
|
|
5629
3977
|
learn_from_attempts(
|
|
5630
|
-
instance_id
|
|
5631
|
-
topic
|
|
5632
|
-
outcome
|
|
5633
|
-
what_worked
|
|
5634
|
-
what_failed
|
|
5635
|
-
|
|
5636
|
-
file_paths
|
|
5637
|
-
|
|
5638
|
-
tags = ["tag1"],
|
|
3978
|
+
instance_id="${instanceId}",
|
|
3979
|
+
topic="category:keyword", # e.g. "deploy:web", "bug:redis-timeout", "arch:auth"
|
|
3980
|
+
outcome="success",
|
|
3981
|
+
what_worked="exact solution",
|
|
3982
|
+
what_failed="what didn't work (optional)",
|
|
3983
|
+
commands=["the command that worked"],
|
|
3984
|
+
file_paths=["relevant/file.ts"],
|
|
3985
|
+
severity="critical"|"major"|"minor",
|
|
5639
3986
|
)
|
|
5640
3987
|
\`\`\`
|
|
5641
3988
|
|
|
5642
|
-
### Before starting any task
|
|
3989
|
+
### Before starting any non-trivial task
|
|
5643
3990
|
\`\`\`
|
|
5644
|
-
|
|
5645
|
-
instance_id = "${instanceId}",
|
|
5646
|
-
query = "<describe what you are about to do>",
|
|
5647
|
-
)
|
|
3991
|
+
recall_best_solution(instance_id="${instanceId}", topic="relevant:topic")
|
|
5648
3992
|
\`\`\`
|
|
5649
3993
|
|
|
5650
|
-
###
|
|
5651
|
-
|
|
5652
|
-
|
|
3994
|
+
### If a stored lesson is wrong
|
|
3995
|
+
\`\`\`
|
|
3996
|
+
forget_lesson(instance_id="${instanceId}", topic="wrong:topic")
|
|
3997
|
+
\`\`\`
|
|
5653
3998
|
${CLAUDE_MD_MARKER_END}`;
|
|
5654
3999
|
}
|
|
5655
4000
|
|
|
@@ -5740,9 +4085,8 @@ if (process.argv[2] === 'setup') {
|
|
|
5740
4085
|
const AUTH_BASE = 'https://auth.cachly.dev/realms/cachly/protocol/openid-connect';
|
|
5741
4086
|
const CLIENT_ID = 'cachly-cli';
|
|
5742
4087
|
|
|
5743
|
-
console.log('Step 1: Sign in to cachly (free
|
|
4088
|
+
console.log('Step 1: Sign in to cachly (free — no credit card required)\n');
|
|
5744
4089
|
|
|
5745
|
-
// Start device flow
|
|
5746
4090
|
let deviceCode = '', userCode = '', verifyUri = '', pollInterval = 5000;
|
|
5747
4091
|
try {
|
|
5748
4092
|
const deviceRes = await fetch(`${AUTH_BASE}/auth/device`, {
|
|
@@ -5750,91 +4094,92 @@ if (process.argv[2] === 'setup') {
|
|
|
5750
4094
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
5751
4095
|
body: `client_id=${CLIENT_ID}&scope=openid`,
|
|
5752
4096
|
});
|
|
5753
|
-
if (!deviceRes.ok) throw new Error(`
|
|
4097
|
+
if (!deviceRes.ok) throw new Error(`HTTP ${deviceRes.status}`);
|
|
5754
4098
|
const data = await deviceRes.json() as {
|
|
5755
4099
|
device_code: string; user_code: string;
|
|
5756
4100
|
verification_uri_complete: string; interval: number;
|
|
5757
4101
|
};
|
|
5758
|
-
deviceCode
|
|
5759
|
-
userCode
|
|
5760
|
-
verifyUri
|
|
5761
|
-
pollInterval
|
|
4102
|
+
deviceCode = data.device_code;
|
|
4103
|
+
userCode = data.user_code;
|
|
4104
|
+
verifyUri = data.verification_uri_complete;
|
|
4105
|
+
pollInterval = (data.interval ?? 5) * 1000;
|
|
5762
4106
|
} catch (e) {
|
|
5763
|
-
console.error(`\
|
|
5764
|
-
console.error('
|
|
4107
|
+
console.error(`\nCould not reach auth service: ${(e as Error).message}`);
|
|
4108
|
+
console.error('Fallback: sign in at https://cachly.dev and paste your API token.\n');
|
|
5765
4109
|
token = await ask(' Paste API token (cky_live_...): ');
|
|
5766
|
-
if (!token) { console.error('\nToken
|
|
5767
|
-
|
|
5768
|
-
deviceCode = ''; // mark as fallback so we skip polling
|
|
4110
|
+
if (!token) { console.error('\nToken required. Aborting.\n'); rl.close(); process.exit(1); }
|
|
4111
|
+
deviceCode = '';
|
|
5769
4112
|
}
|
|
5770
4113
|
|
|
5771
|
-
if (deviceCode
|
|
5772
|
-
|
|
5773
|
-
console.log(`
|
|
5774
|
-
console.log(` URL: ${verifyUri!}\n`);
|
|
4114
|
+
if (deviceCode) {
|
|
4115
|
+
console.log(` Code: \x1b[1;33m${userCode}\x1b[0m`);
|
|
4116
|
+
console.log(` URL: ${verifyUri}\n`);
|
|
5775
4117
|
try {
|
|
5776
4118
|
const { execSync } = await import('node:child_process');
|
|
5777
4119
|
const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
5778
|
-
execSync(`${openCmd} "${verifyUri
|
|
4120
|
+
execSync(`${openCmd} "${verifyUri}"`, { stdio: 'ignore' });
|
|
5779
4121
|
console.log(' ✓ Browser opened — confirm the code above to continue...\n');
|
|
5780
4122
|
} catch {
|
|
5781
|
-
console.log(' 👉 Open the URL above in your browser and
|
|
4123
|
+
console.log(' 👉 Open the URL above in your browser and confirm the code.\n');
|
|
5782
4124
|
}
|
|
5783
4125
|
|
|
5784
|
-
// Poll for token
|
|
5785
4126
|
process.stdout.write(' Waiting for authorization');
|
|
5786
|
-
const deadline = Date.now() + 10 * 60 * 1000;
|
|
4127
|
+
const deadline = Date.now() + 10 * 60 * 1000;
|
|
5787
4128
|
while (Date.now() < deadline) {
|
|
5788
|
-
await new Promise(r => setTimeout(r, pollInterval
|
|
4129
|
+
await new Promise(r => setTimeout(r, pollInterval));
|
|
5789
4130
|
process.stdout.write('.');
|
|
5790
4131
|
try {
|
|
5791
4132
|
const tokenRes = await fetch(`${AUTH_BASE}/token`, {
|
|
5792
4133
|
method: 'POST',
|
|
5793
4134
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
5794
|
-
body: `client_id=${CLIENT_ID}&grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=${deviceCode
|
|
4135
|
+
body: `client_id=${CLIENT_ID}&grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=${deviceCode}`,
|
|
5795
4136
|
});
|
|
5796
|
-
const
|
|
5797
|
-
if (
|
|
5798
|
-
token =
|
|
4137
|
+
const td = await tokenRes.json() as { access_token?: string; error?: string };
|
|
4138
|
+
if (td.access_token) {
|
|
4139
|
+
token = td.access_token;
|
|
5799
4140
|
console.log(' \x1b[32m✓ Authorized!\x1b[0m\n');
|
|
5800
4141
|
break;
|
|
5801
4142
|
}
|
|
5802
|
-
|
|
5803
|
-
if (
|
|
5804
|
-
|
|
5805
|
-
console.error(`\nAuth error: ${tokenData.error}. Aborting.\n`);
|
|
4143
|
+
if (td.error === 'slow_down') pollInterval = Math.min(pollInterval + 2000, 15000);
|
|
4144
|
+
else if (td.error && td.error !== 'authorization_pending') {
|
|
4145
|
+
console.error(`\nAuth error: ${td.error}. Aborting.\n`);
|
|
5806
4146
|
rl.close(); process.exit(1);
|
|
5807
4147
|
}
|
|
5808
4148
|
} catch { /* network hiccup — keep polling */ }
|
|
5809
4149
|
}
|
|
5810
|
-
if (!token) { console.error('\nTimed out
|
|
5811
|
-
console.log('');
|
|
4150
|
+
if (!token) { console.error('\nTimed out. Aborting.\n'); rl.close(); process.exit(1); }
|
|
5812
4151
|
}
|
|
5813
|
-
}
|
|
5814
4152
|
|
|
5815
|
-
|
|
5816
|
-
|
|
5817
|
-
|
|
5818
|
-
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
|
|
5825
|
-
|
|
5826
|
-
|
|
5827
|
-
|
|
5828
|
-
|
|
5829
|
-
|
|
5830
|
-
|
|
5831
|
-
} catch (e) {
|
|
5832
|
-
console.log(' (skipped)\n');
|
|
5833
|
-
// Non-fatal: fall back to using the Keycloak JWT directly.
|
|
5834
|
-
// It will expire but setup still works for now.
|
|
4153
|
+
// Exchange short-lived Keycloak JWT → long-lived cky_live_ API key
|
|
4154
|
+
if (token.startsWith('eyJ')) {
|
|
4155
|
+
process.stdout.write('⏳ Generating your API key...');
|
|
4156
|
+
try {
|
|
4157
|
+
const keyRes = await fetch(`${API_URL}/api/v1/api-keys`, {
|
|
4158
|
+
method: 'POST',
|
|
4159
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
4160
|
+
body: JSON.stringify({ name: 'cachly-mcp-setup', scope: 'read_write' }),
|
|
4161
|
+
});
|
|
4162
|
+
if (!keyRes.ok) throw new Error(`HTTP ${keyRes.status}`);
|
|
4163
|
+
const keyBody = await keyRes.json() as { key?: string };
|
|
4164
|
+
if (keyBody.key) { token = keyBody.key; console.log(' ✓\n'); }
|
|
4165
|
+
else throw new Error('no key in response');
|
|
4166
|
+
} catch (e) {
|
|
4167
|
+
console.log(` (skipped — JWT will be used directly)\n`);
|
|
4168
|
+
}
|
|
5835
4169
|
}
|
|
5836
4170
|
}
|
|
5837
4171
|
|
|
4172
|
+
// ── Step 1b: Auto-provision a Brain instance if none exist ─────────────────
|
|
4173
|
+
// POST /api/v1/instances/auto is idempotent — returns existing or creates free Brain
|
|
4174
|
+
let autoProvisioned = false;
|
|
4175
|
+
try {
|
|
4176
|
+
const autoRes = await fetch(`${API_URL}/api/v1/instances/auto`, {
|
|
4177
|
+
method: 'POST',
|
|
4178
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
4179
|
+
});
|
|
4180
|
+
if (autoRes.ok) autoProvisioned = true;
|
|
4181
|
+
} catch { /* non-fatal */ }
|
|
4182
|
+
|
|
5838
4183
|
// ── Step 2: Fetch & pick instance ─────────────────────────────────────────
|
|
5839
4184
|
process.stdout.write('⏳ Fetching your instances...');
|
|
5840
4185
|
let instances: Array<{ id: string; name: string; status: string; tier: string; region: string }> = [];
|
|
@@ -5846,62 +4191,31 @@ if (process.argv[2] === 'setup') {
|
|
|
5846
4191
|
const body = await res.json() as { data: typeof instances };
|
|
5847
4192
|
instances = (body.data ?? []).filter(i => i.status === 'running');
|
|
5848
4193
|
} catch (e) {
|
|
5849
|
-
|
|
5850
|
-
console.error(
|
|
5851
|
-
if (msg.includes('401')) {
|
|
5852
|
-
console.error('Token rejected. Get a valid token at https://cachly.dev/setup-ai\n');
|
|
5853
|
-
try {
|
|
5854
|
-
const { execSync } = await import('node:child_process');
|
|
5855
|
-
const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
5856
|
-
execSync(`${openCmd} https://cachly.dev/setup-ai`, { stdio: 'ignore' });
|
|
5857
|
-
} catch { /* ignore */ }
|
|
5858
|
-
}
|
|
4194
|
+
console.error(`\n\nFailed to fetch instances: ${(e as Error).message}`);
|
|
4195
|
+
console.error('Check your token and try again.\n');
|
|
5859
4196
|
rl.close(); process.exit(1);
|
|
5860
4197
|
}
|
|
5861
4198
|
console.log(` found ${instances.length}\n`);
|
|
5862
4199
|
|
|
5863
4200
|
if (instances.length === 0) {
|
|
5864
|
-
|
|
5865
|
-
|
|
5866
|
-
|
|
5867
|
-
|
|
5868
|
-
|
|
5869
|
-
|
|
5870
|
-
|
|
5871
|
-
|
|
5872
|
-
|
|
5873
|
-
|
|
5874
|
-
|
|
5875
|
-
|
|
5876
|
-
|
|
5877
|
-
// Newly created — poll until running or give up after 30 s.
|
|
5878
|
-
const newId = autoBody.instance_id;
|
|
5879
|
-
console.log(` ✓ created (${newId.slice(0, 8)}…)\n`);
|
|
5880
|
-
process.stdout.write('⏳ Waiting for instance to start');
|
|
5881
|
-
for (let attempt = 0; attempt < 15; attempt++) {
|
|
5882
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
5883
|
-
process.stdout.write('.');
|
|
5884
|
-
try {
|
|
5885
|
-
const checkRes = await fetch(`${API_URL}/api/v1/instances/${newId}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
5886
|
-
if (checkRes.ok) {
|
|
5887
|
-
const inst = await checkRes.json() as { id: string; name: string; status: string; tier: string; region: string };
|
|
5888
|
-
if (inst.status === 'running') { instances = [inst]; break; }
|
|
5889
|
-
}
|
|
5890
|
-
} catch { /* keep polling */ }
|
|
5891
|
-
}
|
|
5892
|
-
console.log('');
|
|
4201
|
+
if (autoProvisioned) {
|
|
4202
|
+
// Poll up to 10× every 3s (30s total) waiting for the Brain to start
|
|
4203
|
+
process.stdout.write(' waiting for Brain to start');
|
|
4204
|
+
for (let attempt = 0; attempt < 10 && instances.length === 0; attempt++) {
|
|
4205
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
4206
|
+
process.stdout.write('.');
|
|
4207
|
+
try {
|
|
4208
|
+
const retry = await fetch(`${API_URL}/api/v1/instances`, { headers: { Authorization: `Bearer ${token}` } });
|
|
4209
|
+
if (retry.ok) {
|
|
4210
|
+
const b = await retry.json() as { data: typeof instances };
|
|
4211
|
+
instances = (b.data ?? []).filter(i => i.status === 'running');
|
|
4212
|
+
}
|
|
4213
|
+
} catch { /* ignore */ }
|
|
5893
4214
|
}
|
|
5894
|
-
|
|
5895
|
-
console.log(` failed: ${(e as Error).message}\n`);
|
|
4215
|
+
process.stdout.write(instances.length > 0 ? ' ready!\n' : '\n');
|
|
5896
4216
|
}
|
|
5897
|
-
|
|
5898
4217
|
if (instances.length === 0) {
|
|
5899
|
-
console.error('\
|
|
5900
|
-
try {
|
|
5901
|
-
const { execSync } = await import('node:child_process');
|
|
5902
|
-
const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
5903
|
-
execSync(`${openCmd} https://cachly.dev/instances`, { stdio: 'ignore' });
|
|
5904
|
-
} catch { /* ignore */ }
|
|
4218
|
+
console.error('\nBrain provisioning is taking longer than expected. Run setup again in a minute.\n');
|
|
5905
4219
|
rl.close(); process.exit(1);
|
|
5906
4220
|
}
|
|
5907
4221
|
}
|
|
@@ -5921,28 +4235,15 @@ if (process.argv[2] === 'setup') {
|
|
|
5921
4235
|
// ── Step 3: Detect editors ────────────────────────────────────────────────
|
|
5922
4236
|
const cwd = process.cwd();
|
|
5923
4237
|
const detected: string[] = [];
|
|
5924
|
-
const { homedir } = await import('node:os');
|
|
5925
|
-
const home = homedir();
|
|
5926
4238
|
// Claude Code: always include (CLAUDE.md is universal)
|
|
5927
4239
|
detected.push('claude');
|
|
5928
4240
|
if (existsSync(resolve(cwd, '.cursor'))) detected.push('cursor');
|
|
5929
4241
|
if (existsSync(resolve(cwd, '.windsurf'))) detected.push('windsurf');
|
|
5930
4242
|
if (existsSync(resolve(cwd, '.vscode'))) detected.push('copilot');
|
|
5931
4243
|
if (existsSync(resolve(cwd, '.continue'))) detected.push('continue');
|
|
5932
|
-
// Cline: VS Code extension — detect via globalStorage on the machine
|
|
5933
|
-
const clineGlobalDir = process.platform === 'darwin'
|
|
5934
|
-
? resolve(home, 'Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev')
|
|
5935
|
-
: resolve(home, '.vscode/extensions');
|
|
5936
|
-
if (existsSync(clineGlobalDir) && !detected.includes('copilot')) detected.push('cline');
|
|
5937
|
-
else if (existsSync(clineGlobalDir)) detected.push('cline'); // both copilot + cline share .vscode/mcp.json — fine
|
|
5938
|
-
// Zed editor — detect via app data dir
|
|
5939
|
-
const zedDir = process.platform === 'darwin'
|
|
5940
|
-
? resolve(home, 'Library/Application Support/Zed')
|
|
5941
|
-
: resolve(home, '.config/zed');
|
|
5942
|
-
if (existsSync(zedDir)) detected.push('zed');
|
|
5943
4244
|
|
|
5944
4245
|
const editorLabel = (e: string) =>
|
|
5945
|
-
({ claude: 'Claude Code', cursor: 'Cursor', windsurf: 'Windsurf', copilot: 'GitHub Copilot', continue: 'Continue.dev'
|
|
4246
|
+
({ claude: 'Claude Code', cursor: 'Cursor', windsurf: 'Windsurf', copilot: 'GitHub Copilot', continue: 'Continue.dev' })[e] ?? e;
|
|
5946
4247
|
|
|
5947
4248
|
console.log(`Step 3: Detected editors: ${detected.map(editorLabel).join(', ')}`);
|
|
5948
4249
|
const editorsRaw = await ask(` Configure for which? [all / ${detected.join('/')}] (Enter = all): `);
|
|
@@ -5951,21 +4252,7 @@ if (process.argv[2] === 'setup') {
|
|
|
5951
4252
|
: editorsRaw.split(/[,\s]+/).map(s => s.toLowerCase()).filter(s => EDITOR_FILES[s]);
|
|
5952
4253
|
console.log('');
|
|
5953
4254
|
|
|
5954
|
-
// ── Step 4:
|
|
5955
|
-
console.log('Step 4: Stay in the loop (optional)');
|
|
5956
|
-
const email = await ask(' Email for release notes & tips? [Enter to skip]: ');
|
|
5957
|
-
if (email && email.includes('@')) {
|
|
5958
|
-
try {
|
|
5959
|
-
await fetch(`${API_URL}/api/newsletter/subscribe`, {
|
|
5960
|
-
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
5961
|
-
body: JSON.stringify({ email: email.trim().toLowerCase(), source: 'mcp-setup' }),
|
|
5962
|
-
signal: AbortSignal.timeout(5000),
|
|
5963
|
-
});
|
|
5964
|
-
console.log('✅ Subscribed — you\'ll only hear from us when it matters.\n');
|
|
5965
|
-
} catch { console.log('⚠️ Could not subscribe (no internet?). Continuing...\n'); }
|
|
5966
|
-
} else { console.log(' Skipped.\n'); }
|
|
5967
|
-
|
|
5968
|
-
// ── Step 5: Write editor configs ──────────────────────────────────────────
|
|
4255
|
+
// ── Step 4: Write editor configs ──────────────────────────────────────────
|
|
5969
4256
|
for (const editor of editorsToSetup) {
|
|
5970
4257
|
const configFile = EDITOR_FILES[editor] ?? '.mcp.json';
|
|
5971
4258
|
const configPath = resolve(cwd, configFile);
|
|
@@ -5974,7 +4261,7 @@ if (process.argv[2] === 'setup') {
|
|
|
5974
4261
|
console.log(`✅ Written: ${configFile}`);
|
|
5975
4262
|
}
|
|
5976
4263
|
|
|
5977
|
-
// ── Step
|
|
4264
|
+
// ── Step 5: CLAUDE.md (always — idempotent) ───────────────────────────────
|
|
5978
4265
|
const mdResult = await writeClaudeMd(cwd, instance.id);
|
|
5979
4266
|
const mdLabel = mdResult === 'updated' ? '✅ Updated' : mdResult === 'appended' ? '✅ Appended to' : '✅ Written';
|
|
5980
4267
|
console.log(`${mdLabel}: CLAUDE.md`);
|
|
@@ -5982,172 +4269,11 @@ if (process.argv[2] === 'setup') {
|
|
|
5982
4269
|
rl.close();
|
|
5983
4270
|
console.log(`\n🧠 Done! Restart your editor — the \`cachly\` MCP tools will appear.`);
|
|
5984
4271
|
console.log(` Your AI now has persistent memory across every session.\n`);
|
|
5985
|
-
|
|
5986
|
-
// ── Step 7: Show first session_start preview (Aha moment) ────────────────
|
|
5987
|
-
// Fetch brain health from the API to show the user what their agent will see.
|
|
5988
|
-
process.stdout.write('\n⏳ Fetching your Brain health preview...');
|
|
5989
|
-
try {
|
|
5990
|
-
const brainRes = await fetch(`${API_URL}/api/v1/instances/${instance.id}/brain/stats`, {
|
|
5991
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
5992
|
-
signal: AbortSignal.timeout(6000),
|
|
5993
|
-
});
|
|
5994
|
-
if (brainRes.ok) {
|
|
5995
|
-
const brainData = await brainRes.json() as {
|
|
5996
|
-
lesson_count?: number; context_count?: number;
|
|
5997
|
-
open_failures?: number; quality_score?: number;
|
|
5998
|
-
};
|
|
5999
|
-
const lessons = brainData.lesson_count ?? 0;
|
|
6000
|
-
const contexts = brainData.context_count ?? 0;
|
|
6001
|
-
const score = brainData.quality_score ?? 0;
|
|
6002
|
-
const level = lessons === 0 ? 'Intern 🌱' :
|
|
6003
|
-
lessons < 10 ? 'Junior Dev 🔧' :
|
|
6004
|
-
lessons < 30 ? 'Mid Dev ⚡' :
|
|
6005
|
-
lessons < 60 ? 'Senior Dev 🧠' :
|
|
6006
|
-
lessons < 100 ? 'Staff Eng 🚀' : 'Principal Eng 🏆';
|
|
6007
|
-
console.log(' ✓\n');
|
|
6008
|
-
console.log('┌──────────────────────────────────────────────────────┐');
|
|
6009
|
-
console.log(`│ 🧠 Brain Health Report │`);
|
|
6010
|
-
console.log('├──────────────────────────────────────────────────────┤');
|
|
6011
|
-
console.log(`│ Lessons stored : ${String(lessons).padEnd(6)} │ Level: ${level.padEnd(20)}│`);
|
|
6012
|
-
console.log(`│ Context entries : ${String(contexts).padEnd(6)} │ Quality score: ${String(Math.round(score * 100)).padEnd(3)}% │`);
|
|
6013
|
-
if (lessons === 0) {
|
|
6014
|
-
console.log('├──────────────────────────────────────────────────────┤');
|
|
6015
|
-
console.log('│ Your Brain is empty and ready to learn. │');
|
|
6016
|
-
console.log('│ After your first coding session it will contain: │');
|
|
6017
|
-
console.log('│ • Lessons from every bug you fixed │');
|
|
6018
|
-
console.log('│ • Your project\'s indexed files │');
|
|
6019
|
-
console.log('│ • A session summary for next time │');
|
|
6020
|
-
}
|
|
6021
|
-
console.log('└──────────────────────────────────────────────────────┘');
|
|
6022
|
-
} else {
|
|
6023
|
-
console.log(' (skipped)');
|
|
6024
|
-
}
|
|
6025
|
-
} catch { console.log(' (skipped)'); }
|
|
6026
|
-
|
|
6027
|
-
console.log(`\n Your AI will call session_start automatically when it uses the first tool.`);
|
|
6028
|
-
console.log(` To see your Brain at any time: https://cachly.dev/instances/${instance.id}\n`);
|
|
6029
|
-
process.exit(0);
|
|
6030
|
-
}
|
|
6031
|
-
|
|
6032
|
-
// ── CLI: cachly index <dir> ───────────────────────────────────────────────────
|
|
6033
|
-
// Usage: npx @cachly-dev/mcp-server@latest index [./path/to/project]
|
|
6034
|
-
// Indexes the project directory into the Brain — perfect for CI/CD cron jobs.
|
|
6035
|
-
|
|
6036
|
-
if (process.argv[2] === 'index') {
|
|
6037
|
-
const { resolve } = await import('node:path');
|
|
6038
|
-
const argv = process.argv.slice(3);
|
|
6039
|
-
const flag = (name: string) => { const i = argv.indexOf(`--${name}`); return i !== -1 ? argv[i + 1] : undefined; };
|
|
6040
|
-
|
|
6041
|
-
const dir = resolve(flag('dir') ?? argv.find(a => !a.startsWith('--')) ?? '.');
|
|
6042
|
-
const instanceId = flag('instance-id') ?? process.env.CACHLY_BRAIN_INSTANCE_ID;
|
|
6043
|
-
const maxFiles = parseInt(flag('max-files') ?? '500', 10);
|
|
6044
|
-
const namespace = flag('namespace') ?? 'cachly:sem:code';
|
|
6045
|
-
|
|
6046
|
-
if (!instanceId || !JWT) {
|
|
6047
|
-
console.error('\n❌ CACHLY_BRAIN_INSTANCE_ID and CACHLY_JWT must be set\n');
|
|
6048
|
-
console.error(' export CACHLY_BRAIN_INSTANCE_ID=<uuid>');
|
|
6049
|
-
console.error(' export CACHLY_JWT=<cky_live_...>');
|
|
6050
|
-
console.error(' npx @cachly-dev/mcp-server@latest index ./my-project\n');
|
|
6051
|
-
process.exit(1);
|
|
6052
|
-
}
|
|
6053
|
-
|
|
6054
|
-
console.log(`\n📂 Indexing: ${dir}`);
|
|
6055
|
-
console.log(` Instance: ${instanceId.slice(0, 8)}… Max files: ${maxFiles}\n`);
|
|
6056
|
-
|
|
6057
|
-
try {
|
|
6058
|
-
const result = await handleTool('index_project', {
|
|
6059
|
-
instance_id: instanceId,
|
|
6060
|
-
dir,
|
|
6061
|
-
max_files: maxFiles,
|
|
6062
|
-
ttl: 86400 * 7,
|
|
6063
|
-
namespace,
|
|
6064
|
-
});
|
|
6065
|
-
console.log(result);
|
|
6066
|
-
console.log('\n✅ Indexing complete.\n');
|
|
6067
|
-
} catch (err) {
|
|
6068
|
-
console.error(`\n❌ Indexing failed: ${(err as Error).message}\n`);
|
|
6069
|
-
process.exit(1);
|
|
6070
|
-
}
|
|
6071
4272
|
process.exit(0);
|
|
6072
4273
|
}
|
|
6073
4274
|
|
|
6074
4275
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
6075
4276
|
|
|
6076
|
-
|
|
6077
|
-
|
|
6078
|
-
if (!JWT) {
|
|
6079
|
-
process.stderr.write(
|
|
6080
|
-
'\n' +
|
|
6081
|
-
'╔══════════════════════════════════════════════════════════════════╗\n' +
|
|
6082
|
-
'║ 🧠 cachly AI Brain — Setup required ║\n' +
|
|
6083
|
-
'╠══════════════════════════════════════════════════════════════════╣\n' +
|
|
6084
|
-
'║ ║\n' +
|
|
6085
|
-
'║ CACHLY_JWT is not set. Get your free credentials at: ║\n' +
|
|
6086
|
-
'║ ║\n' +
|
|
6087
|
-
'║ 👉 https://cachly.dev/setup-ai ║\n' +
|
|
6088
|
-
'║ ║\n' +
|
|
6089
|
-
'║ Then run the interactive setup wizard: ║\n' +
|
|
6090
|
-
'║ ║\n' +
|
|
6091
|
-
'║ npx @cachly-dev/mcp-server@latest setup ║\n' +
|
|
6092
|
-
'║ ║\n' +
|
|
6093
|
-
'║ Free tier — no credit card required. ║\n' +
|
|
6094
|
-
'╚══════════════════════════════════════════════════════════════════╝\n' +
|
|
6095
|
-
'\n',
|
|
6096
|
-
);
|
|
6097
|
-
}
|
|
6098
|
-
|
|
6099
|
-
// ── Update nudge (non-blocking, fire-and-forget) ─────────────────────────────
|
|
6100
|
-
// Check npm registry once per process start; if outdated, log to stderr so
|
|
6101
|
-
// the editor's MCP log shows an actionable one-liner. Skipped if opted out.
|
|
6102
|
-
if (!process.env.CACHLY_NO_UPDATE_CHECK) {
|
|
6103
|
-
(async () => {
|
|
6104
|
-
try {
|
|
6105
|
-
const res = await fetch(
|
|
6106
|
-
`https://registry.npmjs.org/@cachly-dev/mcp-server/latest`,
|
|
6107
|
-
{ signal: AbortSignal.timeout(4000) },
|
|
6108
|
-
);
|
|
6109
|
-
if (res.ok) {
|
|
6110
|
-
const data = await res.json() as { version: string };
|
|
6111
|
-
const latest = data?.version ?? '';
|
|
6112
|
-
if (latest && latest !== CURRENT_VERSION) {
|
|
6113
|
-
process.stderr.write(
|
|
6114
|
-
`\n⚡ cachly update available: ${CURRENT_VERSION} → ${latest}\n` +
|
|
6115
|
-
` Run: npx @cachly-dev/mcp-server@latest setup\n\n`,
|
|
6116
|
-
);
|
|
6117
|
-
}
|
|
6118
|
-
}
|
|
6119
|
-
} catch { /* ignore – network unavailable or timeout */ }
|
|
6120
|
-
})();
|
|
6121
|
-
}
|
|
6122
|
-
|
|
6123
|
-
const httpPort = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined;
|
|
6124
|
-
|
|
6125
|
-
if (httpPort) {
|
|
6126
|
-
// ── HTTP mode (Streamable HTTP transport) ───────────────────────────────
|
|
6127
|
-
// Used for Smithery URL deployment: PORT=3000 node dist/index.js
|
|
6128
|
-
const { createServer } = await import('node:http');
|
|
6129
|
-
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
6130
|
-
await server.connect(transport);
|
|
6131
|
-
const httpServer = createServer((req, res) => {
|
|
6132
|
-
const url = new URL(req.url ?? '/', `http://localhost:${httpPort}`);
|
|
6133
|
-
if (url.pathname === '/health') {
|
|
6134
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
6135
|
-
res.end(JSON.stringify({ status: 'ok', version: CURRENT_VERSION }));
|
|
6136
|
-
return;
|
|
6137
|
-
}
|
|
6138
|
-
if (url.pathname === '/mcp' || url.pathname === '/') {
|
|
6139
|
-
transport.handleRequest(req, res);
|
|
6140
|
-
return;
|
|
6141
|
-
}
|
|
6142
|
-
res.writeHead(404);
|
|
6143
|
-
res.end('not found');
|
|
6144
|
-
});
|
|
6145
|
-
httpServer.listen(httpPort, () => {
|
|
6146
|
-
process.stderr.write(`cachly-mcp HTTP server listening on :${httpPort}\n`);
|
|
6147
|
-
});
|
|
6148
|
-
} else {
|
|
6149
|
-
// ── stdio mode (default for local editor use) ───────────────────────────
|
|
6150
|
-
const transport = new StdioServerTransport();
|
|
6151
|
-
await server.connect(transport);
|
|
6152
|
-
}
|
|
4277
|
+
const transport = new StdioServerTransport();
|
|
4278
|
+
await server.connect(transport);
|
|
6153
4279
|
|