@cachly-dev/mcp-server 0.10.42 → 0.10.44

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.
@@ -17,6 +17,7 @@ import { EventEmitter } from 'node:events';
17
17
  import { ckgSlug, extractProblemConcept, ckgUpsertNode, ckgUpdateEdge } from '../ckg.js';
18
18
  import { EMBED_PROVIDER, hasEmbedProvider, embedProviderHint, embedConfig, setEmbedJwt } from '../embeddings.js';
19
19
  import { keywordSearch } from '../search.js';
20
+ import { handleBrainTool } from '../handlers/brain.js';
20
21
  import type { Redis } from 'ioredis';
21
22
 
22
23
  // ── In-memory Redis mock ──────────────────────────────────────────────────────
@@ -67,6 +68,21 @@ class MockRedis {
67
68
  return [...(this.sets.get(key) ?? [])];
68
69
  }
69
70
 
71
+ async mget(...keys: string[]): Promise<(string | null)[]> {
72
+ return keys.map(k => this.store.get(k) ?? null);
73
+ }
74
+
75
+ async exists(key: string): Promise<number> {
76
+ return this.store.has(key) ? 1 : 0;
77
+ }
78
+
79
+ async incrbyfloat(key: string, increment: number): Promise<string> {
80
+ const cur = parseFloat(this.store.get(key) ?? '0');
81
+ const next = cur + increment;
82
+ this.store.set(key, String(next));
83
+ return String(next);
84
+ }
85
+
70
86
  /** Simplified scanStream: returns all matching keys in a single 'data' event. */
71
87
  scanStream(opts: { match: string; count?: number }): EventEmitter {
72
88
  const emitter = new EventEmitter();
@@ -872,3 +888,161 @@ describe('Brain Flow: advanced scenarios', () => {
872
888
  });
873
889
  });
874
890
  });
891
+
892
+ // ── New feature coverage (0.10.41 / 0.10.42) ─────────────────────────────────
893
+ // Trust badges, Proven Laws crystallization, and focus-less predictive warning.
894
+ // These exercise the REAL handleBrainTool code path with the MockRedis.
895
+
896
+ const noopApiFetch = (async () => null) as unknown as Parameters<typeof handleBrainTool>[3];
897
+
898
+ /** Seed a best-solution lesson with an explicit recall_count / authors / pinned. */
899
+ async function seedLesson(
900
+ redis: MockRedis,
901
+ topic: string,
902
+ opts: {
903
+ outcome?: 'success' | 'failure' | 'partial';
904
+ what_worked?: string;
905
+ what_failed?: string;
906
+ recall_count?: number;
907
+ severity?: 'critical' | 'major' | 'minor';
908
+ pinned?: boolean;
909
+ author?: string;
910
+ tags?: string[];
911
+ } = {},
912
+ ): Promise<void> {
913
+ const ts = new Date().toISOString();
914
+ const obj = {
915
+ topic,
916
+ outcome: opts.outcome ?? 'success',
917
+ what_worked: opts.what_worked ?? `solution for ${topic}`,
918
+ what_failed: opts.what_failed,
919
+ severity: opts.severity ?? 'minor',
920
+ recall_count: opts.recall_count ?? 0,
921
+ ts,
922
+ verified_at: ts,
923
+ confidence: 1.0,
924
+ tags: opts.tags ?? [],
925
+ ...(opts.pinned ? { pinned: true } : {}),
926
+ ...(opts.author ? { author: opts.author } : {}),
927
+ };
928
+ await redis.set(`cachly:lesson:best:${topic}`, JSON.stringify(obj));
929
+ }
930
+
931
+ describe('Trust badges (recall_best_solution)', () => {
932
+ let redis: MockRedis;
933
+ const getConn = async () => redis as unknown as Redis;
934
+ beforeEach(() => { redis = new MockRedis(); });
935
+
936
+ it('shows "Battle-tested" when recall_count crosses 10', async () => {
937
+ await seedLesson(redis, 'redis:pool', { recall_count: 9, what_worked: 'set max_retries=3' });
938
+ const out = (await handleBrainTool('recall_best_solution', { instance_id: 'i1', topic: 'redis:pool' }, getConn, noopApiFetch))!;
939
+ expect(out).toContain('Battle-tested');
940
+ expect(out).toContain('recalled 10×');
941
+ });
942
+
943
+ it('shows "Proven" when recall_count is between 5 and 9', async () => {
944
+ await seedLesson(redis, 'api:auth', { recall_count: 4 });
945
+ const out = (await handleBrainTool('recall_best_solution', { instance_id: 'i1', topic: 'api:auth' }, getConn, noopApiFetch))!;
946
+ expect(out).toContain('Proven');
947
+ expect(out).toContain('recalled 5×');
948
+ expect(out).not.toContain('Battle-tested');
949
+ });
950
+
951
+ it('shows no trust badge for a fresh low-recall lesson', async () => {
952
+ await seedLesson(redis, 'misc:thing', { recall_count: 0 });
953
+ const out = (await handleBrainTool('recall_best_solution', { instance_id: 'i1', topic: 'misc:thing' }, getConn, noopApiFetch))!;
954
+ expect(out).not.toContain('Battle-tested');
955
+ expect(out).not.toContain('Proven —');
956
+ expect(out).not.toContain('Team-verified');
957
+ });
958
+
959
+ it('increments recall_count and persists it', async () => {
960
+ await seedLesson(redis, 'deploy:x', { recall_count: 2 });
961
+ await handleBrainTool('recall_best_solution', { instance_id: 'i1', topic: 'deploy:x' }, getConn, noopApiFetch);
962
+ const stored = JSON.parse((await redis.get('cachly:lesson:best:deploy:x'))!);
963
+ expect(stored.recall_count).toBe(3);
964
+ });
965
+ });
966
+
967
+ describe('Proven Laws crystallization (session_start)', () => {
968
+ let redis: MockRedis;
969
+ const getConn = async () => redis as unknown as Redis;
970
+ beforeEach(async () => {
971
+ redis = new MockRedis();
972
+ // Provide a last session so session_start does not attempt git reconstruction.
973
+ await redis.set('cachly:session:last', JSON.stringify({ summary: 'prior work', ts: new Date().toISOString(), files_changed: [] }));
974
+ });
975
+
976
+ it('surfaces lessons recalled >= 5 in the Proven Laws section', async () => {
977
+ await seedLesson(redis, 'infra:k3s', { recall_count: 7, what_worked: 'add WireGuard IP to TLS-SAN' });
978
+ await seedLesson(redis, 'random:note', { recall_count: 1 });
979
+ const out = (await handleBrainTool('session_start', { instance_id: 'i1' }, getConn, noopApiFetch))!;
980
+ expect(out).toContain('Proven Laws');
981
+ expect(out).toContain('infra:k3s');
982
+ expect(out).toContain('recalled 7×');
983
+ });
984
+
985
+ it('surfaces an explicitly pinned lesson even with low recall', async () => {
986
+ await seedLesson(redis, 'team:convention', { recall_count: 0, pinned: true, what_worked: 'always snake_case handlers' });
987
+ const out = (await handleBrainTool('session_start', { instance_id: 'i1' }, getConn, noopApiFetch))!;
988
+ expect(out).toContain('Proven Laws');
989
+ expect(out).toContain('team:convention');
990
+ expect(out).toContain('pinned');
991
+ });
992
+
993
+ it('omits the Proven Laws section when nothing qualifies', async () => {
994
+ await seedLesson(redis, 'small:thing', { recall_count: 1 });
995
+ const out = (await handleBrainTool('session_start', { instance_id: 'i1' }, getConn, noopApiFetch))!;
996
+ expect(out).not.toContain('Proven Laws');
997
+ });
998
+
999
+ it('does not crystallize failure lessons', async () => {
1000
+ await seedLesson(redis, 'broken:flow', { recall_count: 9, outcome: 'failure', what_failed: 'never works' });
1001
+ const out = (await handleBrainTool('session_start', { instance_id: 'i1' }, getConn, noopApiFetch))!;
1002
+ expect(out).not.toContain('Proven Laws');
1003
+ });
1004
+ });
1005
+
1006
+ describe('Predictive pre-warning without explicit focus (session_start)', () => {
1007
+ let redis: MockRedis;
1008
+ const getConn = async () => redis as unknown as Redis;
1009
+ beforeEach(() => { redis = new MockRedis(); });
1010
+
1011
+ it('derives danger area from last session files and warns', async () => {
1012
+ // Last session touched payment files → likely to continue there.
1013
+ await redis.set('cachly:session:last', JSON.stringify({
1014
+ summary: 'worked on payment integration',
1015
+ ts: new Date().toISOString(),
1016
+ files_changed: ['api/payment/stripe_webhook.go'],
1017
+ }));
1018
+ // A known failure in the payment area.
1019
+ await seedLesson(redis, 'payment:webhook', { outcome: 'failure', what_failed: 'missed idempotency key → double charge', tags: ['payment'] });
1020
+ const out = (await handleBrainTool('session_start', { instance_id: 'i1' }, getConn, noopApiFetch))!;
1021
+ expect(out).toContain('PRE-WARNING');
1022
+ expect(out).toContain('payment:webhook');
1023
+ });
1024
+
1025
+ it('does not warn when last-session area has no known failures', async () => {
1026
+ await redis.set('cachly:session:last', JSON.stringify({
1027
+ summary: 'wrote docs',
1028
+ ts: new Date().toISOString(),
1029
+ files_changed: ['docs/readme.md'],
1030
+ }));
1031
+ await seedLesson(redis, 'payment:webhook', { outcome: 'failure', what_failed: 'x', tags: ['payment'] });
1032
+ const out = (await handleBrainTool('session_start', { instance_id: 'i1' }, getConn, noopApiFetch))!;
1033
+ expect(out).not.toContain('PRE-WARNING');
1034
+ });
1035
+
1036
+ it('filters path noise so common segments do not trigger false matches', async () => {
1037
+ // Last session only touched generic noise paths (src/lib/index).
1038
+ await redis.set('cachly:session:last', JSON.stringify({
1039
+ summary: 'refactor',
1040
+ ts: new Date().toISOString(),
1041
+ files_changed: ['src/lib/index.ts'],
1042
+ }));
1043
+ // A failure tagged 'src' — should NOT match because 'src' is noise.
1044
+ await seedLesson(redis, 'src:thing', { outcome: 'failure', what_failed: 'noise', tags: ['src'] });
1045
+ const out = (await handleBrainTool('session_start', { instance_id: 'i1' }, getConn, noopApiFetch))!;
1046
+ expect(out).not.toContain('PRE-WARNING');
1047
+ });
1048
+ });
@@ -502,6 +502,22 @@ export async function handleBrainTool(
502
502
  10,
503
503
  );
504
504
 
505
+ // Increment recall_count on matched lessons (fire-and-forget — same as recall_best_solution).
506
+ // This ensures the dashboard metric, Proven Laws, and trust badges reflect real smart_recall usage.
507
+ const lessonMatches = kwMatches.filter(m => m.key.startsWith('cachly:lesson:best:'));
508
+ for (const m of lessonMatches.slice(0, 5)) {
509
+ const existing = await redis.get(m.key).catch(() => null);
510
+ if (existing) {
511
+ const lesson = safeJsonParse(existing, null as null | { recall_count?: number; [k: string]: unknown });
512
+ if (lesson) {
513
+ const updated = { ...lesson, recall_count: (lesson.recall_count ?? 0) + 1, verified_at: new Date().toISOString() };
514
+ redis.set(m.key, JSON.stringify(updated)).catch(() => {});
515
+ const savedMins = (lesson.severity as string) === 'critical' ? 240 : (lesson.severity as string) === 'major' ? 60 : 30;
516
+ redis.incrbyfloat(`cachly:stats:time_saved_mins:${instance_id}`, savedMins).catch(() => {});
517
+ }
518
+ }
519
+ }
520
+
505
521
  const lines: string[] = [`🧠 **Smart Recall** for: _"${query}"_\n`];
506
522
 
507
523
  // Show sub-query info if multi-topic was detected
@@ -916,6 +932,31 @@ export async function handleBrainTool(
916
932
  // Brain health
917
933
  lines.push(`📊 **Brain:** ${lessons.length} lessons · ${ctxCount} context entries`, '');
918
934
 
935
+ // ── 📌 Proven Laws — auto-crystallized lessons (quick win 3) ─────────────
936
+ // A lesson that has proven itself (recalled ≥ CRYSTALLIZE_RECALLS times) or
937
+ // was explicitly pinned becomes a "law" — surfaced at the top of every
938
+ // briefing so the most battle-tested knowledge is never buried. This is
939
+ // the today-safe version of swarm crystallization: enough confirmations
940
+ // auto-promote a lesson without any manual step.
941
+ {
942
+ type LessonPin = typeof lessons[0] & { pinned?: boolean };
943
+ const CRYSTALLIZE_RECALLS = 5;
944
+ const laws = (lessons as LessonPin[])
945
+ .filter(l => l.outcome === 'success' && (l.pinned === true || (l.recall_count ?? 0) >= CRYSTALLIZE_RECALLS))
946
+ .sort((a, b) => (b.recall_count ?? 0) - (a.recall_count ?? 0))
947
+ .slice(0, 5);
948
+ if (laws.length > 0) {
949
+ lines.push(`📌 **Proven Laws** (auto-crystallized — your most-trusted knowledge):`);
950
+ for (const l of laws) {
951
+ const sev = l.severity === 'critical' ? '🔴' : l.severity === 'major' ? '🟡' : '';
952
+ const rc = l.recall_count ?? 0;
953
+ const why = l.pinned === true ? 'pinned' : `recalled ${rc}×`;
954
+ lines.push(` 🏆${sev} \`${l.topic}\` _(${why})_ — ${l.what_worked.slice(0, 100)}`);
955
+ }
956
+ lines.push('');
957
+ }
958
+ }
959
+
919
960
  // ── Layer 7: MCM Domain Coverage Map ────────────────────────────────────
920
961
  if (lessons.length >= 3) {
921
962
  const domainMap = new Map<string, { total: number; success: number; critical: number }>();
package/src/index.ts CHANGED
@@ -60,7 +60,7 @@ import { Redis } from 'ioredis';
60
60
  const API_URL = process.env.CACHLY_API_URL ?? 'https://api.cachly.dev';
61
61
  let JWT = process.env.CACHLY_JWT ?? '';
62
62
  const EMBED_MODEL = process.env.CACHLY_EMBED_MODEL ?? '';
63
- const CURRENT_VERSION = '0.10.42';
63
+ const CURRENT_VERSION = '0.10.44';
64
64
 
65
65
  // ── Default Instance Resolution (for Smithery & single-credential setups) ────
66
66
  // When CACHLY_BRAIN_INSTANCE_ID is set, tools can omit the instance_id parameter.
@@ -2156,6 +2156,7 @@ if (process.argv[2] === 'setup') {
2156
2156
  const CLIENT_ID = 'cachly-cli';
2157
2157
 
2158
2158
  console.log('Step 1: Sign in to cachly (free, no credit card)\n');
2159
+ sendFunnelEvent('setup_auth_started');
2159
2160
 
2160
2161
  // Start device flow
2161
2162
  let deviceCode = '', userCode = '', verifyUri = '', pollInterval = 5000;
@@ -2179,6 +2180,7 @@ if (process.argv[2] === 'setup') {
2179
2180
  console.error('Falling back: sign in at https://cachly.dev/setup-ai and paste your API token.\n');
2180
2181
  token = await ask(' Paste API token (cky_live_...): ');
2181
2182
  if (!token) { console.error('\nToken is required. Aborting.\n'); rl.close(); process.exit(1); }
2183
+ sendFunnelEvent('setup_auth_completed');
2182
2184
  console.log('');
2183
2185
  deviceCode = ''; // mark as fallback so we skip polling
2184
2186
  }
@@ -2212,6 +2214,7 @@ if (process.argv[2] === 'setup') {
2212
2214
  if (tokenData.access_token) {
2213
2215
  token = tokenData.access_token;
2214
2216
  console.log(' \x1b[32m✓ Authorized!\x1b[0m\n');
2217
+ sendFunnelEvent('setup_auth_completed');
2215
2218
  break;
2216
2219
  }
2217
2220
  // authorization_pending = keep polling; slow_down = increase interval
@@ -2328,6 +2331,7 @@ if (process.argv[2] === 'setup') {
2328
2331
  console.log(` (Run with --instance-id <id> to use a different one)\n`);
2329
2332
  }
2330
2333
  console.log(`✓ Instance: ${instance.name} (${instance.id.slice(0, 8)}…)\n`);
2334
+ sendFunnelEvent('setup_instance_ready', { instance_id: instance.id });
2331
2335
 
2332
2336
  // ── Step 3: Detect editors ────────────────────────────────────────────────
2333
2337
  const cwd = process.cwd();