@cachly-dev/mcp-server 0.10.14 → 0.10.15
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/ISSUE_TEMPLATE/bug_report.md +50 -0
- package/.github/ISSUE_TEMPLATE/config.yml +8 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +25 -0
- package/.github/workflows/publish.yml +72 -0
- package/CHANGELOG.md +28 -0
- package/LAUNCH_TEXTS.md +182 -0
- package/LICENSE +202 -0
- package/README.md +113 -112
- package/dist/__tests__/auth.test.d.ts +1 -0
- package/dist/__tests__/auth.test.js +107 -0
- package/dist/__tests__/auth.test.js.map +1 -0
- package/dist/handlers/brain.js +7 -67
- package/dist/handlers/brain.js.map +1 -1
- package/dist/handlers/fedbrain.js +93 -44
- package/dist/handlers/fedbrain.js.map +1 -1
- package/dist/handlers/roadmap.js +8 -2
- package/dist/handlers/roadmap.js.map +1 -1
- package/dist/handlers/syndicate.js +15 -7
- package/dist/handlers/syndicate.js.map +1 -1
- package/dist/handlers/team.js +62 -50
- package/dist/handlers/team.js.map +1 -1
- package/dist/index.js +555 -93
- package/dist/index.js.map +1 -1
- package/dist/tools.d.ts +3 -54
- package/dist/tools.js +8 -62
- package/dist/tools.js.map +1 -1
- package/glama.json +29 -0
- package/llms.txt +155 -0
- package/package.json +29 -13
- package/scripts/postinstall.js +12 -12
- package/server.json +3 -9
- package/smithery.yaml +6 -8
- package/src/__tests__/auth.test.ts +147 -0
- package/src/handlers/brain.ts +7 -64
- package/src/handlers/fedbrain.ts +88 -38
- package/src/handlers/roadmap.ts +7 -2
- package/src/handlers/syndicate.ts +13 -7
- package/src/handlers/team.ts +57 -33
- package/src/index.ts +599 -106
- package/src/tools.ts +8 -66
- package/dist/__tests__/brain-predict-failures.test.d.ts +0 -17
- package/dist/__tests__/brain-predict-failures.test.js +0 -339
- package/dist/__tests__/brain-predict-failures.test.js.map +0 -1
- package/src/__tests__/brain-predict-failures.test.ts +0 -430
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
import { jwtExpiryMs, checkJwt, handleApiError } from '../auth.js';
|
|
4
|
+
|
|
5
|
+
function makeJwt(payload: Record<string, unknown>): string {
|
|
6
|
+
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
|
7
|
+
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
8
|
+
return `${header}.${body}.fakesig`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const nowSec = () => Math.floor(Date.now() / 1000);
|
|
12
|
+
|
|
13
|
+
describe('jwtExpiryMs', () => {
|
|
14
|
+
it('returns null for an empty string', () => {
|
|
15
|
+
expect(jwtExpiryMs('')).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns null for a plain (non-JWT) string', () => {
|
|
19
|
+
expect(jwtExpiryMs('not-a-token')).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns null when the JWT has only two parts', () => {
|
|
23
|
+
expect(jwtExpiryMs('header.payload')).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns null when the JWT has four parts', () => {
|
|
27
|
+
expect(jwtExpiryMs('a.b.c.d')).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('returns null when the payload base64 is invalid', () => {
|
|
31
|
+
expect(jwtExpiryMs('header.!!!.sig')).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns null when the payload has no exp field', () => {
|
|
35
|
+
expect(jwtExpiryMs(makeJwt({ sub: 'user-123', iat: nowSec() }))).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns null when exp is 0', () => {
|
|
39
|
+
expect(jwtExpiryMs(makeJwt({ exp: 0 }))).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('converts exp (seconds) to milliseconds', () => {
|
|
43
|
+
const expSec = nowSec() + 3600;
|
|
44
|
+
expect(jwtExpiryMs(makeJwt({ exp: expSec }))).toBe(expSec * 1000);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns a past timestamp for an expired token', () => {
|
|
48
|
+
expect(jwtExpiryMs(makeJwt({ exp: nowSec() - 300 }))).toBeLessThan(Date.now());
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('returns a future timestamp for a valid token', () => {
|
|
52
|
+
expect(jwtExpiryMs(makeJwt({ exp: nowSec() + 86400 }))).toBeGreaterThan(Date.now());
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('handles a token expiring in less than 60 minutes', () => {
|
|
56
|
+
const expSec = nowSec() + 30 * 60;
|
|
57
|
+
const result = jwtExpiryMs(makeJwt({ exp: expSec }))!;
|
|
58
|
+
const minsLeft = Math.floor((result - Date.now()) / 60_000);
|
|
59
|
+
expect(minsLeft).toBeGreaterThanOrEqual(29);
|
|
60
|
+
expect(minsLeft).toBeLessThanOrEqual(30);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('ignores extra claims', () => {
|
|
64
|
+
const expSec = nowSec() + 3600;
|
|
65
|
+
expect(jwtExpiryMs(makeJwt({ sub: 'u', iat: nowSec(), exp: expSec, plan: 'free' }))).toBe(expSec * 1000);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('handles padding-free base64url payloads', () => {
|
|
69
|
+
// base64url (no =) — Buffer.from handles this natively
|
|
70
|
+
const expSec = nowSec() + 7200;
|
|
71
|
+
expect(jwtExpiryMs(makeJwt({ exp: expSec, x: 'a' }))).toBe(expSec * 1000);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('checkJwt', () => {
|
|
76
|
+
it('throws InvalidRequest when jwt is empty', () => {
|
|
77
|
+
expect(() => checkJwt('')).toThrowError(
|
|
78
|
+
expect.objectContaining({ code: ErrorCode.InvalidRequest, message: expect.stringContaining('CACHLY_JWT env var not set') }),
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('throws with setup URL when jwt is missing', () => {
|
|
83
|
+
expect(() => checkJwt('')).toThrowError(
|
|
84
|
+
expect.objectContaining({ message: expect.stringContaining('cachly.dev/setup-ai') }),
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('throws InvalidRequest with expiry timestamp for an expired token', () => {
|
|
89
|
+
const expSec = nowSec() - 60;
|
|
90
|
+
const token = makeJwt({ exp: expSec });
|
|
91
|
+
expect(() => checkJwt(token)).toThrowError(
|
|
92
|
+
expect.objectContaining({
|
|
93
|
+
code: ErrorCode.InvalidRequest,
|
|
94
|
+
message: expect.stringContaining('expired at'),
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('does not throw for a valid non-expired token', () => {
|
|
100
|
+
expect(() => checkJwt(makeJwt({ exp: nowSec() + 3600 }))).not.toThrow();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('does not throw for a token with no exp claim', () => {
|
|
104
|
+
expect(() => checkJwt(makeJwt({ sub: 'user' }))).not.toThrow();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('handleApiError', () => {
|
|
109
|
+
it('throws InvalidRequest for 401 with the detail message', () => {
|
|
110
|
+
expect(() => handleApiError(401, 'token expired')).toThrowError(
|
|
111
|
+
expect.objectContaining({
|
|
112
|
+
code: ErrorCode.InvalidRequest,
|
|
113
|
+
message: expect.stringContaining('Authentication failed (401): token expired'),
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('throws InvalidRequest for 403 with the detail message', () => {
|
|
119
|
+
expect(() => handleApiError(403, 'plan limit reached')).toThrowError(
|
|
120
|
+
expect.objectContaining({
|
|
121
|
+
code: ErrorCode.InvalidRequest,
|
|
122
|
+
message: expect.stringContaining('Access denied (403): plan limit reached'),
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('throws InternalError for other status codes', () => {
|
|
128
|
+
expect(() => handleApiError(500, 'server crash')).toThrowError(
|
|
129
|
+
expect.objectContaining({
|
|
130
|
+
code: ErrorCode.InternalError,
|
|
131
|
+
message: expect.stringContaining('cachly API error 500: server crash'),
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('includes the cachly.dev/setup-ai link for 401 errors', () => {
|
|
137
|
+
expect(() => handleApiError(401, 'Unauthorized')).toThrowError(
|
|
138
|
+
expect.objectContaining({ message: expect.stringContaining('cachly.dev/setup-ai') }),
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('includes account access hint for 403 errors', () => {
|
|
143
|
+
expect(() => handleApiError(403, 'Forbidden')).toThrowError(
|
|
144
|
+
expect.objectContaining({ message: expect.stringContaining('access to this resource') }),
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
});
|
package/src/handlers/brain.ts
CHANGED
|
@@ -12,13 +12,15 @@ import { computeEmbedding, hasEmbedProvider, embedProviderHint, EMBED_PROVIDER }
|
|
|
12
12
|
import { detectNamespace } from '../namespace.js';
|
|
13
13
|
|
|
14
14
|
// ── Changelog (shown once per version in session_start) ──────────────────────
|
|
15
|
-
const MCP_VERSION = '0.10.
|
|
15
|
+
const MCP_VERSION = '0.10.0';
|
|
16
16
|
const WHATS_NEW: Record<string, string[]> = {
|
|
17
|
-
'0.10.
|
|
17
|
+
'0.10.0': [
|
|
18
18
|
`🆕 **What's new in v${MCP_VERSION}:**`,
|
|
19
|
-
`
|
|
20
|
-
`
|
|
21
|
-
` ✅
|
|
19
|
+
` ✅ Zero-friction setup — auto-provisions your Brain, no manual config needed`,
|
|
20
|
+
` ✅ 10× faster briefings — batch Redis fetches replace per-key loops`,
|
|
21
|
+
` ✅ Crash-proof data reads — corrupted cache entries no longer break tools`,
|
|
22
|
+
` ✅ \`session_end\` warns when called without an active session`,
|
|
23
|
+
` ✅ \`get_api_status\` now shows live Redis ping + all instance statuses`,
|
|
22
24
|
],
|
|
23
25
|
};
|
|
24
26
|
|
|
@@ -340,20 +342,6 @@ export async function handleBrainTool(
|
|
|
340
342
|
}
|
|
341
343
|
} catch { /* CKG updates are non-critical */ }
|
|
342
344
|
|
|
343
|
-
// ── Auto-pin critical lessons ──────────────────────────────────────────
|
|
344
|
-
// severity=critical → always pin automatically so the lesson
|
|
345
|
-
// appears in every session_start without the user ever calling pin_lesson.
|
|
346
|
-
let autoPinNote = '';
|
|
347
|
-
if (severity === 'critical' && (outcome === 'success' || outcome === 'partial')) {
|
|
348
|
-
try {
|
|
349
|
-
const alreadyPinned = await redis.exists(`cachly:lesson:pin:${topic}`);
|
|
350
|
-
if (!alreadyPinned) {
|
|
351
|
-
await redis.set(`cachly:lesson:pin:${topic}`, '1');
|
|
352
|
-
autoPinNote = `📌 Auto-pinned (critical severity) — will appear in every session_start.`;
|
|
353
|
-
}
|
|
354
|
-
} catch { /* non-critical */ }
|
|
355
|
-
}
|
|
356
|
-
|
|
357
345
|
const emoji = outcome === 'success' ? '✅' : outcome === 'partial' ? '⚠️' : '❌';
|
|
358
346
|
const sevEmoji = severity === 'critical' ? '🔴' : severity === 'major' ? '🟡' : '🟢';
|
|
359
347
|
const action = isUpdate ? 'updated' : 'stored';
|
|
@@ -374,7 +362,6 @@ export async function handleBrainTool(
|
|
|
374
362
|
depends_on.length > 0
|
|
375
363
|
? `🔗 Depends on: ${depends_on.map(d => `\`${d}\``).join(', ')} → trace with \`trace_dependency\``
|
|
376
364
|
: '',
|
|
377
|
-
autoPinNote,
|
|
378
365
|
...templateWarnings,
|
|
379
366
|
...iWasWrongWarning,
|
|
380
367
|
].filter(l => l !== '').join('\n');
|
|
@@ -946,35 +933,6 @@ export async function handleBrainTool(
|
|
|
946
933
|
lines.push('');
|
|
947
934
|
}
|
|
948
935
|
|
|
949
|
-
// ── Pinned lessons — always shown regardless of age/confidence ───────────
|
|
950
|
-
try {
|
|
951
|
-
const pinKeys: string[] = [];
|
|
952
|
-
const pinStream = redis.scanStream({ match: 'cachly:lesson:pin:*', count: 100 });
|
|
953
|
-
await new Promise<void>((res, rej) => {
|
|
954
|
-
pinStream.on('data', (b: string[]) => pinKeys.push(...b));
|
|
955
|
-
pinStream.on('end', res);
|
|
956
|
-
pinStream.on('error', rej);
|
|
957
|
-
});
|
|
958
|
-
if (pinKeys.length > 0) {
|
|
959
|
-
const pinnedTopics = pinKeys.map(k => k.replace('cachly:lesson:pin:', ''));
|
|
960
|
-
const pinLessonRaws = await redis.mget(...pinnedTopics.map(t => `cachly:lesson:best:${t}`));
|
|
961
|
-
const pinLines: string[] = [];
|
|
962
|
-
for (const raw of pinLessonRaws) {
|
|
963
|
-
if (!raw) continue;
|
|
964
|
-
const l = safeJsonParse<{ topic: string; outcome: string; what_worked: string; severity?: string } | null>(raw, null);
|
|
965
|
-
if (!l) continue;
|
|
966
|
-
const emoji = l.outcome === 'success' ? '✅' : l.outcome === 'partial' ? '⚠️' : '❌';
|
|
967
|
-
const sev = l.severity === 'critical' ? '🔴' : l.severity === 'major' ? '🟡' : '';
|
|
968
|
-
pinLines.push(` 📌${emoji}${sev} \`${l.topic}\` — ${l.what_worked.slice(0, 100)}`);
|
|
969
|
-
}
|
|
970
|
-
if (pinLines.length > 0) {
|
|
971
|
-
lines.push(`📌 **Pinned lessons (always shown):**`);
|
|
972
|
-
lines.push(...pinLines);
|
|
973
|
-
lines.push('');
|
|
974
|
-
}
|
|
975
|
-
}
|
|
976
|
-
} catch { /* non-critical */ }
|
|
977
|
-
|
|
978
936
|
// Recent lessons
|
|
979
937
|
if (lessons.length > 0) {
|
|
980
938
|
lines.push(`🕐 **Recent lessons:**`);
|
|
@@ -1461,20 +1419,6 @@ export async function handleBrainTool(
|
|
|
1461
1419
|
}
|
|
1462
1420
|
|
|
1463
1421
|
const durationStr = durationMin !== undefined ? ` · ${durationMin} min` : '';
|
|
1464
|
-
|
|
1465
|
-
// ── Mini brain-stats snapshot at session end ───────────────────────────
|
|
1466
|
-
let miniStatsLines: string[] = [];
|
|
1467
|
-
try {
|
|
1468
|
-
const totalLessons = await redis.keys('cachly:lesson:best:*').then(k => k.length).catch(() => 0);
|
|
1469
|
-
const pinCount = await redis.keys('cachly:lesson:pin:*').then(k => k.length).catch(() => 0);
|
|
1470
|
-
const streakRaw = await redis.get('cachly:streak:current');
|
|
1471
|
-
const streak = streakRaw ? (JSON.parse(streakRaw) as { days: number }).days : 0;
|
|
1472
|
-
const parts: string[] = [`📚 ${totalLessons} lesson${totalLessons !== 1 ? 's' : ''} total`];
|
|
1473
|
-
if (pinCount > 0) parts.push(`📌 ${pinCount} pinned`);
|
|
1474
|
-
if (streak > 1) parts.push(`🔥 ${streak}-day streak`);
|
|
1475
|
-
miniStatsLines = [`🧠 **Brain:** ${parts.join(' · ')}`];
|
|
1476
|
-
} catch { /* non-critical */ }
|
|
1477
|
-
|
|
1478
1422
|
return [
|
|
1479
1423
|
`✅ **Session saved**${durationStr}`,
|
|
1480
1424
|
``,
|
|
@@ -1483,7 +1427,6 @@ export async function handleBrainTool(
|
|
|
1483
1427
|
lessons_learned !== undefined ? `🧠 **Lessons stored:** ${lessons_learned}` : '',
|
|
1484
1428
|
autoLearned.length > 0 ? `🤖 **Auto-learned:** ${autoLearned.length} lessons extracted from summary (${autoLearned.slice(0, 3).map(t => `\`${t}\``).join(', ')}${autoLearned.length > 3 ? '…' : ''})` : '',
|
|
1485
1429
|
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 ? '…' : ''})` : '',
|
|
1486
|
-
...miniStatsLines,
|
|
1487
1430
|
``,
|
|
1488
1431
|
`💡 Next session: \`session_start(focus="...")\` to see this summary.`,
|
|
1489
1432
|
].filter(l => l !== '').join('\n');
|
package/src/handlers/fedbrain.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { calculateConfidence, confidenceBadge, CONFIDENCE_WARN_VALUE, CONFIDENCE
|
|
|
6
6
|
import { ckgSlug, extractProblemConcept, ckgUpsertNode, ckgUpdateEdge } from '../ckg.js';
|
|
7
7
|
import type { CKGEdge, CKGNode } from '../ckg.js';
|
|
8
8
|
import { keywordSearch, tokenize } from '../search.js';
|
|
9
|
+
import { safeJsonParse } from '../utils.js';
|
|
9
10
|
import { computeEmbedding, hasEmbedProvider } from '../embeddings.js';
|
|
10
11
|
|
|
11
12
|
// Git concurrency semaphore (for brain_from_git parallel workers)
|
|
@@ -397,7 +398,8 @@ export async function handleFedbrainTool(
|
|
|
397
398
|
const raw = await redis.get(`cachly:lesson:best:${lesson_key}`);
|
|
398
399
|
if (!raw) return `❌ Lesson \`${lesson_key}\` not found. Store it first with \`learn_from_attempts\`.`;
|
|
399
400
|
|
|
400
|
-
const lesson =
|
|
401
|
+
const lesson = safeJsonParse(raw, null as null | { topic: string; outcome: string; what_worked: string; what_failed?: string; tags?: string[]; commands?: string[]; severity?: string; ts?: string });
|
|
402
|
+
if (!lesson) return `❌ Lesson \`${lesson_key}\` data is corrupted. Re-store with \`learn_from_attempts\`.`;
|
|
401
403
|
|
|
402
404
|
const domainTokens = [lesson.topic.split(':')[0], ...(lesson.tags ?? [])].filter(Boolean);
|
|
403
405
|
const domainFingerprint = [...new Set(domainTokens)].sort().join(',');
|
|
@@ -747,7 +749,8 @@ export async function handleFedbrainTool(
|
|
|
747
749
|
}
|
|
748
750
|
|
|
749
751
|
type Crystal = { label: string; ts: string; session_count: number; lesson_count: number; top_patterns: Array<{ category: string; insight: string; count: number }>; categories: string[]; created_from: string };
|
|
750
|
-
const crystal
|
|
752
|
+
const crystal = safeJsonParse<Crystal | null>(raw, null);
|
|
753
|
+
if (!crystal) return `❌ Memory Crystal data is corrupted. Re-create with \`memory_crystalize()\`.`;
|
|
751
754
|
const age = Math.floor((Date.now() - new Date(crystal.ts).getTime()) / 86400000);
|
|
752
755
|
const freshEmoji = age <= 7 ? '🟢' : age <= 30 ? '🟡' : '🔴';
|
|
753
756
|
|
|
@@ -784,49 +787,55 @@ export async function handleFedbrainTool(
|
|
|
784
787
|
const crystalRaw = await redis.get('cachly:crystal:latest');
|
|
785
788
|
if (crystalRaw) {
|
|
786
789
|
type Crystal = { label: string; ts: string; session_count: number; lesson_count: number; top_patterns: Array<{ category: string; insight: string; count: number }> };
|
|
787
|
-
const crystal
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
790
|
+
const crystal = safeJsonParse<Crystal | null>(crystalRaw, null);
|
|
791
|
+
if (crystal) {
|
|
792
|
+
lines.push(`### 💎 Memory Crystal: ${crystal.label}`);
|
|
793
|
+
lines.push(`Compressed from ${crystal.session_count} sessions, ${crystal.lesson_count} lessons.`);
|
|
794
|
+
const topN = focus
|
|
795
|
+
? crystal.top_patterns.filter(p => p.category.toLowerCase().includes(focus.toLowerCase()) || p.insight.toLowerCase().includes(focus.toLowerCase())).slice(0, 4)
|
|
796
|
+
: crystal.top_patterns.slice(0, 4);
|
|
797
|
+
for (const p of topN) lines.push(` • **${p.category}**: ${p.insight.slice(0, 100)}`);
|
|
798
|
+
lines.push('');
|
|
799
|
+
}
|
|
795
800
|
}
|
|
796
801
|
|
|
797
802
|
// 2. Last session summary
|
|
798
803
|
const lastSession = await redis.get('cachly:session:last');
|
|
799
804
|
if (lastSession) {
|
|
800
805
|
type Session = { summary?: string; ts?: string; focus?: string };
|
|
801
|
-
const sess
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
+
const sess = safeJsonParse<Session | null>(lastSession, null);
|
|
807
|
+
if (sess) {
|
|
808
|
+
lines.push(`### 🕐 Last Session`);
|
|
809
|
+
if (sess.focus) lines.push(`Focus: _${sess.focus}_`);
|
|
810
|
+
if (sess.summary) lines.push(`Summary: ${sess.summary.slice(0, 300)}`);
|
|
811
|
+
lines.push('');
|
|
812
|
+
}
|
|
806
813
|
}
|
|
807
814
|
|
|
808
815
|
// 3. Session handoff
|
|
809
816
|
const handoff = await redis.get('cachly:session:handoff');
|
|
810
817
|
if (handoff) {
|
|
811
818
|
type Handoff = { remaining_tasks?: string[]; instructions?: string; context_summary?: string; blocked_on?: string };
|
|
812
|
-
const h
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
819
|
+
const h = safeJsonParse<Handoff | null>(handoff, null);
|
|
820
|
+
if (h) {
|
|
821
|
+
lines.push(`### 📋 Handoff (from last window)`);
|
|
822
|
+
if (h.context_summary) lines.push(`Context: ${h.context_summary.slice(0, 200)}`);
|
|
823
|
+
if (h.remaining_tasks?.length) {
|
|
824
|
+
lines.push(`Remaining tasks:`);
|
|
825
|
+
for (const t of h.remaining_tasks.slice(0, 5)) lines.push(` • ${t}`);
|
|
826
|
+
}
|
|
827
|
+
if (h.instructions) lines.push(`⚠️ Instructions: ${h.instructions.slice(0, 200)}`);
|
|
828
|
+
if (h.blocked_on) lines.push(`🚧 Blocked on: ${h.blocked_on}`);
|
|
829
|
+
lines.push('');
|
|
818
830
|
}
|
|
819
|
-
if (h.instructions) lines.push(`⚠️ Instructions: ${h.instructions.slice(0, 200)}`);
|
|
820
|
-
if (h.blocked_on) lines.push(`🚧 Blocked on: ${h.blocked_on}`);
|
|
821
|
-
lines.push('');
|
|
822
831
|
}
|
|
823
832
|
|
|
824
833
|
// 4. WIP registry
|
|
825
834
|
const wipRaw = await redis.get('cachly:ctx:wip-registry');
|
|
826
835
|
if (wipRaw) {
|
|
827
836
|
type Ctx = { content?: string };
|
|
828
|
-
const wip
|
|
829
|
-
if (wip
|
|
837
|
+
const wip = safeJsonParse<Ctx | null>(wipRaw, null);
|
|
838
|
+
if (wip?.content) {
|
|
830
839
|
lines.push(`### 🔧 WIP Registry`);
|
|
831
840
|
lines.push(wip.content.slice(0, 400));
|
|
832
841
|
lines.push('');
|
|
@@ -884,8 +893,8 @@ export async function handleFedbrainTool(
|
|
|
884
893
|
|
|
885
894
|
// ── brain_from_git ────────────────────────────────────────────────────────
|
|
886
895
|
case 'brain_from_git': {
|
|
887
|
-
const { instance_id, repo_path = '.', limit = 100, branch = 'HEAD', since = '' } = args as {
|
|
888
|
-
instance_id: string; repo_path?: string; limit?: number; branch?: string; since?: string;
|
|
896
|
+
const { instance_id, repo_path = '.', limit = 100, branch = 'HEAD', since = '', incremental = true } = args as {
|
|
897
|
+
instance_id: string; repo_path?: string; limit?: number; branch?: string; since?: string; incremental?: boolean;
|
|
889
898
|
};
|
|
890
899
|
const redis = await getConnection(instance_id);
|
|
891
900
|
const { execSync } = await import('node:child_process');
|
|
@@ -904,9 +913,21 @@ export async function handleFedbrainTool(
|
|
|
904
913
|
return `❌ Not a git repository: \`${repoDir}\`. Pass \`repo_path\` pointing to a git checkout.`;
|
|
905
914
|
}
|
|
906
915
|
|
|
916
|
+
// Incremental mode: resume from last processed commit SHA
|
|
917
|
+
const lastShaKey = `cachly:brain_from_git:last_sha:${Buffer.from(repoDir).toString('base64').slice(0, 32)}`;
|
|
918
|
+
let lastSha = '';
|
|
919
|
+
let isIncremental = false;
|
|
920
|
+
if (incremental !== false && !since) {
|
|
921
|
+
lastSha = (await redis.get(lastShaKey)) ?? '';
|
|
922
|
+
isIncremental = Boolean(lastSha);
|
|
923
|
+
}
|
|
924
|
+
|
|
907
925
|
// Build git log command
|
|
926
|
+
// In incremental mode, only fetch commits after the last processed SHA
|
|
908
927
|
const sinceFlag = since ? `--since="${since}"` : '';
|
|
909
|
-
const
|
|
928
|
+
const afterFlag = isIncremental ? `${lastSha}..HEAD` : '';
|
|
929
|
+
const revRange = afterFlag || branch;
|
|
930
|
+
const logCmd = `git log ${revRange} ${sinceFlag} --pretty=format:"%H|||%s|||%ad|||%an" --date=short --no-merges -n ${maxCommits}`;
|
|
910
931
|
|
|
911
932
|
let logOutput = '';
|
|
912
933
|
try {
|
|
@@ -921,6 +942,9 @@ export async function handleFedbrainTool(
|
|
|
921
942
|
});
|
|
922
943
|
|
|
923
944
|
if (commits.length === 0) {
|
|
945
|
+
if (isIncremental) {
|
|
946
|
+
return `✅ brain_from_git: Brain is up to date — no new commits since last run (last SHA: \`${lastSha.slice(0, 8)}\`).`;
|
|
947
|
+
}
|
|
924
948
|
return `⚠️ No commits found in \`${repoDir}\` on branch \`${branch}\`${since ? ` since ${since}` : ''}.`;
|
|
925
949
|
}
|
|
926
950
|
|
|
@@ -951,8 +975,21 @@ export async function handleFedbrainTool(
|
|
|
951
975
|
let ingested = 0;
|
|
952
976
|
let skipped = 0;
|
|
953
977
|
const categoryCount = new Map<string, number>();
|
|
978
|
+
const total = commits.length;
|
|
979
|
+
const progressInterval = Math.max(1, Math.floor(total / 10)); // emit ~10 progress updates
|
|
980
|
+
let lastProgressAt = 0;
|
|
981
|
+
|
|
982
|
+
process.stderr.write(`\n🧠 brain_from_git: processing ${total} commits from ${repoDir}...\n`);
|
|
983
|
+
|
|
984
|
+
for (let i = 0; i < commits.length; i++) {
|
|
985
|
+
const commit = commits[i]!;
|
|
986
|
+
|
|
987
|
+
// Emit progress to stderr so editors can display it in MCP logs
|
|
988
|
+
if (i - lastProgressAt >= progressInterval) {
|
|
989
|
+
process.stderr.write(` ⏳ Processing ${i}/${total} commits (${ingested} lessons so far)...\n`);
|
|
990
|
+
lastProgressAt = i;
|
|
991
|
+
}
|
|
954
992
|
|
|
955
|
-
for (const commit of commits) {
|
|
956
993
|
if (!commit.subject) { skipped++; continue; }
|
|
957
994
|
const { category, outcome, severity } = classifyCommit(commit.subject);
|
|
958
995
|
const domain = extractDomain(commit.subject);
|
|
@@ -987,11 +1024,20 @@ export async function handleFedbrainTool(
|
|
|
987
1024
|
ingested++;
|
|
988
1025
|
}
|
|
989
1026
|
|
|
1027
|
+
process.stderr.write(` ✅ brain_from_git complete: ${ingested}/${total} commits ingested\n\n`);
|
|
1028
|
+
|
|
1029
|
+
// Save the latest commit SHA for incremental runs
|
|
1030
|
+
if (commits.length > 0 && commits[0]?.sha) {
|
|
1031
|
+
await redis.set(lastShaKey, commits[0].sha, 'EX', 90 * 24 * 3600); // expire after 90 days
|
|
1032
|
+
}
|
|
1033
|
+
|
|
990
1034
|
const categoryBreakdown = [...categoryCount.entries()].sort((a, b) => b[1] - a[1]).map(([k, v]) => ` • **${k}** (${v})`).join('\n');
|
|
991
1035
|
|
|
992
1036
|
return [
|
|
993
|
-
`🔁 **brain_from_git: ${repoDir}**`,
|
|
994
|
-
|
|
1037
|
+
`🔁 **brain_from_git: ${repoDir}**`,
|
|
1038
|
+
isIncremental ? `🔄 Incremental mode — only new commits since \`${lastSha.slice(0, 8)}\` were processed` : '',
|
|
1039
|
+
``,
|
|
1040
|
+
`📂 Branch: \`${revRange}\` | Processed: **${commits.length}** commits | Ingested: **${ingested}** lessons | Skipped: ${skipped}`,
|
|
995
1041
|
``,
|
|
996
1042
|
`**Breakdown by category:**`,
|
|
997
1043
|
categoryBreakdown,
|
|
@@ -999,7 +1045,8 @@ export async function handleFedbrainTool(
|
|
|
999
1045
|
`💡 New lessons are stored with confidence 0.55 (auto-inferred).`,
|
|
1000
1046
|
`💡 As you confirm them via \`learn_from_attempts\`, confidence rises automatically.`,
|
|
1001
1047
|
`🔍 Explore: \`brain_search(query="fix")\` | \`ckg_inspect(concept="deploy")\``,
|
|
1002
|
-
|
|
1048
|
+
`💾 Next run will automatically continue from the latest commit (incremental mode).`,
|
|
1049
|
+
].filter(Boolean).join('\n');
|
|
1003
1050
|
} finally {
|
|
1004
1051
|
_gitSemRelease();
|
|
1005
1052
|
}
|
|
@@ -1026,12 +1073,14 @@ export async function handleFedbrainTool(
|
|
|
1026
1073
|
for (const nk of nodeKeys.slice(0, 5)) {
|
|
1027
1074
|
const nodeRaw = await redis.get(nk);
|
|
1028
1075
|
if (!nodeRaw) continue;
|
|
1029
|
-
const node
|
|
1076
|
+
const node = safeJsonParse<CKGNode | null>(nodeRaw, null);
|
|
1077
|
+
if (!node) continue;
|
|
1030
1078
|
const edgeKeys = await redis.smembers(`cachly:ckg:idx:from:${node.id}`);
|
|
1031
1079
|
for (const ek of edgeKeys.slice(0, 20)) {
|
|
1032
1080
|
const edgeRaw = await redis.get(ek);
|
|
1033
1081
|
if (!edgeRaw) continue;
|
|
1034
|
-
const edge
|
|
1082
|
+
const edge = safeJsonParse<CKGEdge | null>(edgeRaw, null);
|
|
1083
|
+
if (!edge) continue;
|
|
1035
1084
|
if (edge.edgeType !== 'causes' && edge.edgeType !== 'degrades_under') continue;
|
|
1036
1085
|
|
|
1037
1086
|
// Look up fix for this failure from CKG 'fixes' edges
|
|
@@ -1040,12 +1089,13 @@ export async function handleFedbrainTool(
|
|
|
1040
1089
|
for (const fek of fixEdgeKeys.slice(0, 10)) {
|
|
1041
1090
|
const feRaw = await redis.get(fek);
|
|
1042
1091
|
if (!feRaw) continue;
|
|
1043
|
-
const fe
|
|
1092
|
+
const fe = safeJsonParse<CKGEdge | null>(feRaw, null);
|
|
1093
|
+
if (!fe) continue;
|
|
1044
1094
|
if (fe.edgeType === 'fixes') {
|
|
1045
1095
|
const lessonRaw = await redis.get(`cachly:lesson:best:${fe.from}`);
|
|
1046
1096
|
if (lessonRaw) {
|
|
1047
|
-
const lesson =
|
|
1048
|
-
fix = lesson
|
|
1097
|
+
const lesson = safeJsonParse(lessonRaw, null as null | { what_worked?: string });
|
|
1098
|
+
fix = lesson?.what_worked?.slice(0, 120);
|
|
1049
1099
|
break;
|
|
1050
1100
|
}
|
|
1051
1101
|
}
|
package/src/handlers/roadmap.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Redis } from 'ioredis';
|
|
2
|
+
import { safeJsonParse } from '../utils.js';
|
|
2
3
|
|
|
3
4
|
type GetConnection = (instanceId: string) => Promise<Redis>;
|
|
4
5
|
type ApiFetch = <T>(path: string, options?: RequestInit) => Promise<T>;
|
|
@@ -67,7 +68,8 @@ export async function handleRoadmapTool(
|
|
|
67
68
|
const redis = await getConnection(rid);
|
|
68
69
|
const raw = await redis.hget(`cachly:roadmap:${rid}`, itemId);
|
|
69
70
|
if (!raw) return `⚠️ **roadmap_update** – Item \`${itemId}\` not found. Use \`roadmap_list\` to see all items.`;
|
|
70
|
-
const item =
|
|
71
|
+
const item = safeJsonParse<Record<string, unknown> | null>(raw, null);
|
|
72
|
+
if (!item) return `⚠️ **roadmap_update** – Item \`${itemId}\` data is corrupted. Re-add with \`roadmap_add\`.`;
|
|
71
73
|
const oldStatus = item.status as string;
|
|
72
74
|
if (newStatus) item.status = newStatus;
|
|
73
75
|
if (newPriority) item.priority = newPriority;
|
|
@@ -106,7 +108,10 @@ export async function handleRoadmapTool(
|
|
|
106
108
|
const PRIORITY_ICON: Record<string, string> = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
|
|
107
109
|
const STATUS_ICON: Record<string, string> = { planned: '📋', 'in-progress': '⚡', done: '✅', blocked: '🚫', cancelled: '🗑️' };
|
|
108
110
|
const openStatuses = new Set(['planned', 'in-progress', 'blocked']);
|
|
109
|
-
let items = Object.values(all).
|
|
111
|
+
let items = Object.values(all).flatMap(v => {
|
|
112
|
+
const item = safeJsonParse<Record<string, string | string[]> | null>(v as string, null);
|
|
113
|
+
return item ? [item] : [];
|
|
114
|
+
});
|
|
110
115
|
// Filter
|
|
111
116
|
if (filterStatus === 'open') items = items.filter(i => openStatuses.has(i.status as string));
|
|
112
117
|
else if (filterStatus) items = items.filter(i => i.status === filterStatus);
|
|
@@ -4,6 +4,7 @@ import { calculateConfidence } from '../confidence.js';
|
|
|
4
4
|
import { ckgSlug, ckgUpdateEdge } from '../ckg.js';
|
|
5
5
|
import type { CKGEdge, CKGNode } from '../ckg.js';
|
|
6
6
|
import { keywordSearch, tokenize } from '../search.js';
|
|
7
|
+
import { safeJsonParse } from '../utils.js';
|
|
7
8
|
|
|
8
9
|
type GetConnection = (instanceId: string) => Promise<Redis>;
|
|
9
10
|
type ApiFetch = <T>(path: string, options?: RequestInit) => Promise<T>;
|
|
@@ -292,7 +293,8 @@ export async function handleSyndicateTool(
|
|
|
292
293
|
for (const ek of [...fromKeys, ...toKeys].slice(0, 50)) {
|
|
293
294
|
const raw = await redis.get(ek);
|
|
294
295
|
if (!raw) continue;
|
|
295
|
-
const edge
|
|
296
|
+
const edge = safeJsonParse<CKGEdge | null>(raw, null);
|
|
297
|
+
if (!edge) continue;
|
|
296
298
|
allEdges.push(edge);
|
|
297
299
|
if (hop < max_hops) {
|
|
298
300
|
if (!visited.has(edge.from)) queue.push({ id: edge.from, hop: hop + 1 });
|
|
@@ -366,16 +368,18 @@ export async function handleSyndicateTool(
|
|
|
366
368
|
for (const nk of nodeKeys.slice(0, 5)) {
|
|
367
369
|
const nodeRaw = await redis.get(nk);
|
|
368
370
|
if (!nodeRaw) continue;
|
|
369
|
-
const node
|
|
371
|
+
const node = safeJsonParse<CKGNode | null>(nodeRaw, null);
|
|
372
|
+
if (!node) continue;
|
|
370
373
|
const edgeKeys = await redis.smembers(`cachly:ckg:idx:from:${node.id}`);
|
|
371
374
|
for (const ek of edgeKeys.slice(0, 20)) {
|
|
372
375
|
const edgeRaw = await redis.get(ek);
|
|
373
376
|
if (!edgeRaw) continue;
|
|
374
|
-
const edge
|
|
377
|
+
const edge = safeJsonParse<CKGEdge | null>(edgeRaw, null);
|
|
378
|
+
if (!edge) continue;
|
|
375
379
|
// Only interested in fixes and co-occurs for prediction
|
|
376
380
|
if (edge.edgeType !== 'fixes' && edge.edgeType !== 'co-occurs' && edge.edgeType !== 'causes') continue;
|
|
377
381
|
const lessonRaw = await redis.get(`cachly:lesson:best:${edge.from}`);
|
|
378
|
-
const lesson = lessonRaw ?
|
|
382
|
+
const lesson = lessonRaw ? safeJsonParse<{ what_worked?: string; topic: string } | null>(lessonRaw, null) ?? undefined : undefined;
|
|
379
383
|
predictions.push({ concept: node.id, edgeType: edge.edgeType, target: edge.to, confidence: edge.confidence, lesson });
|
|
380
384
|
}
|
|
381
385
|
}
|
|
@@ -389,15 +393,17 @@ export async function handleSyndicateTool(
|
|
|
389
393
|
for (const k of lessonKeys) {
|
|
390
394
|
const raw = await redis.get(k);
|
|
391
395
|
if (!raw) continue;
|
|
392
|
-
|
|
393
|
-
|
|
396
|
+
{
|
|
397
|
+
type PredLesson = { topic: string; what_worked?: string; what_failed?: string; outcome: string; severity?: string; ts: string; verified_at?: string; recall_count?: number };
|
|
398
|
+
const l = safeJsonParse<PredLesson | null>(raw, null);
|
|
399
|
+
if (!l) continue;
|
|
394
400
|
const haystack = [l.topic, l.what_failed ?? '', l.what_worked ?? ''].join(' ').toLowerCase();
|
|
395
401
|
const score = ctxTokens.reduce((s, t) => s + (haystack.includes(t) ? 1 : 0), 0);
|
|
396
402
|
if (score >= 1 && l.outcome !== 'failure') {
|
|
397
403
|
const conf = calculateConfidence(l);
|
|
398
404
|
textPredictions.push({ ...l, confidence: conf });
|
|
399
405
|
}
|
|
400
|
-
}
|
|
406
|
+
}
|
|
401
407
|
}
|
|
402
408
|
textPredictions.sort((a, b) => b.confidence - a.confidence);
|
|
403
409
|
|