@cachly-dev/mcp-server 0.10.65 → 0.10.66

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.
Files changed (269) hide show
  1. package/dist/src/handlers/brain.js +1 -1
  2. package/dist/src/index.js +1 -1
  3. package/package.json +7 -1
  4. package/server.json +2 -2
  5. package/.env.test.example +0 -23
  6. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -50
  7. package/.github/ISSUE_TEMPLATE/config.yml +0 -8
  8. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -25
  9. package/.github/workflows/eas-build-android.yml +0 -58
  10. package/.github/workflows/eas-update.yml +0 -55
  11. package/.github/workflows/publish.yml +0 -72
  12. package/BENCH.md +0 -113
  13. package/CHANGELOG.md +0 -653
  14. package/Dockerfile +0 -16
  15. package/LAUNCH_TEXTS.md +0 -182
  16. package/PROGRESS.md +0 -141
  17. package/STRATEGY.md +0 -228
  18. package/VISION_10X.md +0 -133
  19. package/apps/travel-chaos-organizer/.env.example +0 -49
  20. package/apps/travel-chaos-organizer/DEPLOY.md +0 -328
  21. package/apps/travel-chaos-organizer/Makefile +0 -57
  22. package/apps/travel-chaos-organizer/README.md +0 -159
  23. package/apps/travel-chaos-organizer/backend/.env.example +0 -10
  24. package/apps/travel-chaos-organizer/backend/Dockerfile +0 -25
  25. package/apps/travel-chaos-organizer/backend/alembic/env.py +0 -56
  26. package/apps/travel-chaos-organizer/backend/alembic/script.py.mako +0 -24
  27. package/apps/travel-chaos-organizer/backend/alembic/versions/.gitkeep +0 -0
  28. package/apps/travel-chaos-organizer/backend/alembic/versions/0001_stripe_customer_id.py +0 -24
  29. package/apps/travel-chaos-organizer/backend/alembic/versions/0002_telegram_chat_id.py +0 -24
  30. package/apps/travel-chaos-organizer/backend/alembic.ini +0 -39
  31. package/apps/travel-chaos-organizer/backend/app/__init__.py +0 -0
  32. package/apps/travel-chaos-organizer/backend/app/auth/__init__.py +0 -0
  33. package/apps/travel-chaos-organizer/backend/app/auth/keycloak.py +0 -81
  34. package/apps/travel-chaos-organizer/backend/app/config.py +0 -66
  35. package/apps/travel-chaos-organizer/backend/app/db/__init__.py +0 -0
  36. package/apps/travel-chaos-organizer/backend/app/db/database.py +0 -20
  37. package/apps/travel-chaos-organizer/backend/app/db/migrations/001_init.sql +0 -71
  38. package/apps/travel-chaos-organizer/backend/app/db/migrations/002_telemetry_freemium.sql +0 -31
  39. package/apps/travel-chaos-organizer/backend/app/db/migrations/003_telegram_chat_id.sql +0 -4
  40. package/apps/travel-chaos-organizer/backend/app/db/migrations/004_stripe_and_email.sql +0 -5
  41. package/apps/travel-chaos-organizer/backend/app/limiter.py +0 -4
  42. package/apps/travel-chaos-organizer/backend/app/logging_config.py +0 -25
  43. package/apps/travel-chaos-organizer/backend/app/main.py +0 -77
  44. package/apps/travel-chaos-organizer/backend/app/models/__init__.py +0 -0
  45. package/apps/travel-chaos-organizer/backend/app/models/schemas.py +0 -142
  46. package/apps/travel-chaos-organizer/backend/app/routers/__init__.py +0 -0
  47. package/apps/travel-chaos-organizer/backend/app/routers/admin.py +0 -237
  48. package/apps/travel-chaos-organizer/backend/app/routers/cache.py +0 -13
  49. package/apps/travel-chaos-organizer/backend/app/routers/events.py +0 -29
  50. package/apps/travel-chaos-organizer/backend/app/routers/export.py +0 -122
  51. package/apps/travel-chaos-organizer/backend/app/routers/inbox.py +0 -122
  52. package/apps/travel-chaos-organizer/backend/app/routers/items.py +0 -130
  53. package/apps/travel-chaos-organizer/backend/app/routers/mail.py +0 -108
  54. package/apps/travel-chaos-organizer/backend/app/routers/parse.py +0 -303
  55. package/apps/travel-chaos-organizer/backend/app/routers/payments.py +0 -179
  56. package/apps/travel-chaos-organizer/backend/app/routers/trips.py +0 -134
  57. package/apps/travel-chaos-organizer/backend/app/routers/users.py +0 -169
  58. package/apps/travel-chaos-organizer/backend/app/routers/waitlist.py +0 -37
  59. package/apps/travel-chaos-organizer/backend/app/services/__init__.py +0 -0
  60. package/apps/travel-chaos-organizer/backend/app/services/cache.py +0 -131
  61. package/apps/travel-chaos-organizer/backend/app/services/drip.py +0 -128
  62. package/apps/travel-chaos-organizer/backend/app/services/email.py +0 -61
  63. package/apps/travel-chaos-organizer/backend/app/services/errors.py +0 -44
  64. package/apps/travel-chaos-organizer/backend/app/services/notifier.py +0 -10
  65. package/apps/travel-chaos-organizer/backend/app/services/ollama.py +0 -101
  66. package/apps/travel-chaos-organizer/backend/app/services/parser.py +0 -22
  67. package/apps/travel-chaos-organizer/backend/app/services/quota.py +0 -85
  68. package/apps/travel-chaos-organizer/backend/app/services/sentry.py +0 -11
  69. package/apps/travel-chaos-organizer/backend/app/services/telemetry.py +0 -35
  70. package/apps/travel-chaos-organizer/backend/pytest.ini +0 -4
  71. package/apps/travel-chaos-organizer/backend/requirements-dev.txt +0 -6
  72. package/apps/travel-chaos-organizer/backend/requirements.txt +0 -25
  73. package/apps/travel-chaos-organizer/backend/tests/__init__.py +0 -0
  74. package/apps/travel-chaos-organizer/backend/tests/conftest.py +0 -223
  75. package/apps/travel-chaos-organizer/backend/tests/test_admin.py +0 -99
  76. package/apps/travel-chaos-organizer/backend/tests/test_cache.py +0 -142
  77. package/apps/travel-chaos-organizer/backend/tests/test_drip.py +0 -32
  78. package/apps/travel-chaos-organizer/backend/tests/test_e2e_chain.py +0 -121
  79. package/apps/travel-chaos-organizer/backend/tests/test_export.py +0 -48
  80. package/apps/travel-chaos-organizer/backend/tests/test_inbox.py +0 -107
  81. package/apps/travel-chaos-organizer/backend/tests/test_items.py +0 -138
  82. package/apps/travel-chaos-organizer/backend/tests/test_mail.py +0 -113
  83. package/apps/travel-chaos-organizer/backend/tests/test_ollama.py +0 -145
  84. package/apps/travel-chaos-organizer/backend/tests/test_parse_url.py +0 -98
  85. package/apps/travel-chaos-organizer/backend/tests/test_parser.py +0 -72
  86. package/apps/travel-chaos-organizer/backend/tests/test_payments.py +0 -163
  87. package/apps/travel-chaos-organizer/backend/tests/test_quota.py +0 -46
  88. package/apps/travel-chaos-organizer/backend/tests/test_search.py +0 -67
  89. package/apps/travel-chaos-organizer/backend/tests/test_trips.py +0 -123
  90. package/apps/travel-chaos-organizer/backend/tests/test_users.py +0 -52
  91. package/apps/travel-chaos-organizer/backups/.gitkeep +0 -0
  92. package/apps/travel-chaos-organizer/caddy/Caddyfile +0 -27
  93. package/apps/travel-chaos-organizer/docker-compose.node1.yml +0 -31
  94. package/apps/travel-chaos-organizer/docker-compose.override.yml +0 -14
  95. package/apps/travel-chaos-organizer/docker-compose.yml +0 -128
  96. package/apps/travel-chaos-organizer/frontend/.env.example +0 -13
  97. package/apps/travel-chaos-organizer/frontend/app/(app)/_layout.tsx +0 -80
  98. package/apps/travel-chaos-organizer/frontend/app/(app)/inbox.tsx +0 -166
  99. package/apps/travel-chaos-organizer/frontend/app/(app)/search.tsx +0 -130
  100. package/apps/travel-chaos-organizer/frontend/app/(app)/settings.tsx +0 -381
  101. package/apps/travel-chaos-organizer/frontend/app/(app)/trips/[id].tsx +0 -346
  102. package/apps/travel-chaos-organizer/frontend/app/(app)/trips.tsx +0 -363
  103. package/apps/travel-chaos-organizer/frontend/app/(app)/upgrade/cancel.tsx +0 -34
  104. package/apps/travel-chaos-organizer/frontend/app/(app)/upgrade/success.tsx +0 -35
  105. package/apps/travel-chaos-organizer/frontend/app/(auth)/login.tsx +0 -91
  106. package/apps/travel-chaos-organizer/frontend/app/_layout.tsx +0 -71
  107. package/apps/travel-chaos-organizer/frontend/app/auth.tsx +0 -29
  108. package/apps/travel-chaos-organizer/frontend/app/index.tsx +0 -16
  109. package/apps/travel-chaos-organizer/frontend/app/onboarding.tsx +0 -119
  110. package/apps/travel-chaos-organizer/frontend/app.config.ts +0 -54
  111. package/apps/travel-chaos-organizer/frontend/assets/adaptive-icon.svg +0 -66
  112. package/apps/travel-chaos-organizer/frontend/assets/icon.svg +0 -79
  113. package/apps/travel-chaos-organizer/frontend/assets/splash.svg +0 -15
  114. package/apps/travel-chaos-organizer/frontend/components/BottomSheet.tsx +0 -102
  115. package/apps/travel-chaos-organizer/frontend/components/ConfettiCannon.tsx +0 -107
  116. package/apps/travel-chaos-organizer/frontend/components/ConfirmDialog.tsx +0 -109
  117. package/apps/travel-chaos-organizer/frontend/components/ErrorBoundary.tsx +0 -45
  118. package/apps/travel-chaos-organizer/frontend/components/FileUploadButton.tsx +0 -19
  119. package/apps/travel-chaos-organizer/frontend/components/ImportSheet.tsx +0 -155
  120. package/apps/travel-chaos-organizer/frontend/components/OfflineBadge.tsx +0 -55
  121. package/apps/travel-chaos-organizer/frontend/components/QRCodeViewer.tsx +0 -80
  122. package/apps/travel-chaos-organizer/frontend/components/ScoreRing.tsx +0 -39
  123. package/apps/travel-chaos-organizer/frontend/components/Screen.tsx +0 -39
  124. package/apps/travel-chaos-organizer/frontend/components/Skeleton.tsx +0 -70
  125. package/apps/travel-chaos-organizer/frontend/components/SwipeableInboxItem.tsx +0 -121
  126. package/apps/travel-chaos-organizer/frontend/components/SwipeableTimelineItem.tsx +0 -70
  127. package/apps/travel-chaos-organizer/frontend/components/TimelineItem.tsx +0 -73
  128. package/apps/travel-chaos-organizer/frontend/components/TimelineItemDetail.tsx +0 -130
  129. package/apps/travel-chaos-organizer/frontend/components/Toast.tsx +0 -60
  130. package/apps/travel-chaos-organizer/frontend/components/ToastContext.tsx +0 -42
  131. package/apps/travel-chaos-organizer/frontend/components/TripCard.tsx +0 -146
  132. package/apps/travel-chaos-organizer/frontend/components/TripQRModal.tsx +0 -70
  133. package/apps/travel-chaos-organizer/frontend/components/UpgradeBanner.tsx +0 -44
  134. package/apps/travel-chaos-organizer/frontend/eas.json +0 -22
  135. package/apps/travel-chaos-organizer/frontend/hooks/useInbox.ts +0 -24
  136. package/apps/travel-chaos-organizer/frontend/hooks/useNetworkSync.ts +0 -60
  137. package/apps/travel-chaos-organizer/frontend/hooks/useTrips.ts +0 -112
  138. package/apps/travel-chaos-organizer/frontend/lib/api.ts +0 -223
  139. package/apps/travel-chaos-organizer/frontend/lib/auth.ts +0 -93
  140. package/apps/travel-chaos-organizer/frontend/lib/db.ts +0 -86
  141. package/apps/travel-chaos-organizer/frontend/lib/fileCache.ts +0 -54
  142. package/apps/travel-chaos-organizer/frontend/lib/haptics.ts +0 -13
  143. package/apps/travel-chaos-organizer/frontend/lib/notifications.ts +0 -112
  144. package/apps/travel-chaos-organizer/frontend/lib/offlineQueue.ts +0 -145
  145. package/apps/travel-chaos-organizer/frontend/lib/quota.tsx +0 -43
  146. package/apps/travel-chaos-organizer/frontend/lib/sentry.ts +0 -28
  147. package/apps/travel-chaos-organizer/frontend/lib/shareIntent.ts +0 -44
  148. package/apps/travel-chaos-organizer/frontend/package-lock.json +0 -13221
  149. package/apps/travel-chaos-organizer/frontend/package.json +0 -44
  150. package/apps/travel-chaos-organizer/frontend/tsconfig.json +0 -23
  151. package/apps/travel-chaos-organizer/keycloak/realm-export.json +0 -59
  152. package/apps/travel-chaos-organizer/landing/index.html +0 -773
  153. package/apps/travel-chaos-organizer/scripts/backup.sh +0 -14
  154. package/apps/travel-chaos-organizer/scripts/configure-env.sh +0 -82
  155. package/apps/travel-chaos-organizer/scripts/deploy.sh +0 -54
  156. package/apps/travel-chaos-organizer/scripts/health-check.sh +0 -36
  157. package/apps/travel-chaos-organizer/scripts/migrate.sh +0 -7
  158. package/apps/travel-chaos-organizer/scripts/server-setup.sh +0 -85
  159. package/apps/travel-chaos-organizer/scripts/smoke-test.sh +0 -29
  160. package/apps/travel-chaos-organizer/scripts/verify-tracking.sh +0 -71
  161. package/apps/travel-chaos-organizer/telegram-bot/.env.example +0 -3
  162. package/apps/travel-chaos-organizer/telegram-bot/Dockerfile +0 -6
  163. package/apps/travel-chaos-organizer/telegram-bot/bot.py +0 -504
  164. package/apps/travel-chaos-organizer/telegram-bot/requirements.txt +0 -3
  165. package/dist/src/__tests__/auth.test.d.ts +0 -1
  166. package/dist/src/__tests__/auth.test.js +0 -107
  167. package/dist/src/__tests__/auth.test.js.map +0 -1
  168. package/dist/src/__tests__/brain-flow.test.d.ts +0 -12
  169. package/dist/src/__tests__/brain-flow.test.js +0 -1241
  170. package/dist/src/__tests__/brain-flow.test.js.map +0 -1
  171. package/dist/src/__tests__/confidence.test.d.ts +0 -13
  172. package/dist/src/__tests__/confidence.test.js +0 -233
  173. package/dist/src/__tests__/confidence.test.js.map +0 -1
  174. package/dist/src/__tests__/e2e/api-auth.test.d.ts +0 -7
  175. package/dist/src/__tests__/e2e/api-auth.test.js +0 -162
  176. package/dist/src/__tests__/e2e/api-auth.test.js.map +0 -1
  177. package/dist/src/__tests__/e2e/api-health.test.d.ts +0 -7
  178. package/dist/src/__tests__/e2e/api-health.test.js +0 -95
  179. package/dist/src/__tests__/e2e/api-health.test.js.map +0 -1
  180. package/dist/src/__tests__/e2e/cli.test.d.ts +0 -10
  181. package/dist/src/__tests__/e2e/cli.test.js +0 -204
  182. package/dist/src/__tests__/e2e/cli.test.js.map +0 -1
  183. package/dist/src/__tests__/e2e/config.d.ts +0 -35
  184. package/dist/src/__tests__/e2e/config.js +0 -53
  185. package/dist/src/__tests__/e2e/config.js.map +0 -1
  186. package/dist/src/__tests__/e2e/funnel.test.d.ts +0 -13
  187. package/dist/src/__tests__/e2e/funnel.test.js +0 -90
  188. package/dist/src/__tests__/e2e/funnel.test.js.map +0 -1
  189. package/dist/src/__tests__/e2e/mcp-tools.test.d.ts +0 -7
  190. package/dist/src/__tests__/e2e/mcp-tools.test.js +0 -129
  191. package/dist/src/__tests__/e2e/mcp-tools.test.js.map +0 -1
  192. package/dist/src/__tests__/e2e/provisioning.test.d.ts +0 -15
  193. package/dist/src/__tests__/e2e/provisioning.test.js +0 -123
  194. package/dist/src/__tests__/e2e/provisioning.test.js.map +0 -1
  195. package/dist/src/__tests__/handlers-unit.test.d.ts +0 -12
  196. package/dist/src/__tests__/handlers-unit.test.js +0 -505
  197. package/dist/src/__tests__/handlers-unit.test.js.map +0 -1
  198. package/dist/src/__tests__/namespace.test.d.ts +0 -1
  199. package/dist/src/__tests__/namespace.test.js +0 -114
  200. package/dist/src/__tests__/namespace.test.js.map +0 -1
  201. package/dist/src/__tests__/rerank.test.d.ts +0 -1
  202. package/dist/src/__tests__/rerank.test.js +0 -125
  203. package/dist/src/__tests__/rerank.test.js.map +0 -1
  204. package/dist/src/__tests__/utils.test.d.ts +0 -1
  205. package/dist/src/__tests__/utils.test.js +0 -83
  206. package/dist/src/__tests__/utils.test.js.map +0 -1
  207. package/dist/src/bench/cachly-bench.d.ts +0 -39
  208. package/dist/src/bench/cachly-bench.js +0 -223
  209. package/dist/src/bench/cachly-bench.js.map +0 -1
  210. package/dist/src/bench/fixtures.d.ts +0 -37
  211. package/dist/src/bench/fixtures.js +0 -225
  212. package/dist/src/bench/fixtures.js.map +0 -1
  213. package/dist/src/search.test.d.ts +0 -1
  214. package/dist/src/search.test.js +0 -713
  215. package/dist/src/search.test.js.map +0 -1
  216. package/eslint.config.js +0 -16
  217. package/glama.json +0 -36
  218. package/llms.txt +0 -155
  219. package/packages/telegram-notify/SPLIT_PLAN.md +0 -106
  220. package/packages/telegram-notify/client.ts +0 -88
  221. package/packages/telegram-notify/emojis.json +0 -22
  222. package/packages/telegram-notify/pyproject.toml +0 -17
  223. package/packages/telegram-notify/telegram_notify/__init__.py +0 -3
  224. package/packages/telegram-notify/telegram_notify/client.py +0 -87
  225. package/packages/telegram-notify/telegram_notify/emojis.json +0 -22
  226. package/scripts/e2e-run.sh +0 -160
  227. package/scripts/generate-icons.js +0 -28
  228. package/smithery.yaml +0 -45
  229. package/src/__tests__/auth.test.ts +0 -147
  230. package/src/__tests__/brain-flow.test.ts +0 -1472
  231. package/src/__tests__/confidence.test.ts +0 -285
  232. package/src/__tests__/e2e/api-auth.test.ts +0 -182
  233. package/src/__tests__/e2e/api-health.test.ts +0 -103
  234. package/src/__tests__/e2e/cli.test.ts +0 -228
  235. package/src/__tests__/e2e/config.ts +0 -53
  236. package/src/__tests__/e2e/funnel.test.ts +0 -99
  237. package/src/__tests__/e2e/mcp-tools.test.ts +0 -191
  238. package/src/__tests__/e2e/provisioning.test.ts +0 -128
  239. package/src/__tests__/handlers-unit.test.ts +0 -677
  240. package/src/__tests__/namespace.test.ts +0 -126
  241. package/src/__tests__/rerank.test.ts +0 -141
  242. package/src/__tests__/utils.test.ts +0 -81
  243. package/src/auth.ts +0 -46
  244. package/src/bench/cachly-bench.ts +0 -263
  245. package/src/bench/fixtures.ts +0 -256
  246. package/src/ckg.ts +0 -103
  247. package/src/confidence.ts +0 -38
  248. package/src/embeddings.ts +0 -217
  249. package/src/handlers/advanced.ts +0 -647
  250. package/src/handlers/brain.ts +0 -2622
  251. package/src/handlers/cache.ts +0 -633
  252. package/src/handlers/context.ts +0 -222
  253. package/src/handlers/fedbrain.ts +0 -1232
  254. package/src/handlers/instances.ts +0 -180
  255. package/src/handlers/roadmap.ts +0 -201
  256. package/src/handlers/syndicate.ts +0 -460
  257. package/src/handlers/tco.ts +0 -345
  258. package/src/handlers/team.ts +0 -1249
  259. package/src/index.ts +0 -2815
  260. package/src/namespace.ts +0 -128
  261. package/src/notifier.ts +0 -5
  262. package/src/rerank.ts +0 -148
  263. package/src/search.test.ts +0 -848
  264. package/src/search.ts +0 -1781
  265. package/src/tools.ts +0 -2056
  266. package/src/utils.ts +0 -90
  267. package/test-features.cjs +0 -23
  268. package/tsconfig.json +0 -16
  269. package/vitest.config.ts +0 -14
@@ -1,1472 +0,0 @@
1
- /**
2
- * Use-case tests for the Cachly Brain flow.
3
- *
4
- * Tests the core Brain operations end-to-end using an in-memory Redis mock:
5
- * learn_from_attempts → CKG update → recall_best_solution (BM25)
6
- * session_start → lessons summary
7
- * CKG: Bayesian confidence, contradiction detection
8
- * Embeddings: provider detection, config sync
9
- *
10
- * Run: npx vitest run src/__tests__/brain-flow.test.ts
11
- */
12
-
13
- import { describe, it, expect, beforeEach } from 'vitest';
14
- import { EventEmitter } from 'node:events';
15
-
16
- // ── Modules under test ────────────────────────────────────────────────────────
17
- import { ckgSlug, extractProblemConcept, ckgUpsertNode, ckgUpdateEdge,
18
- ckgUpsertPersonNode, ckgUpsertFileNode } from '../ckg.js';
19
- import type { PersonNode, FileNode } from '../ckg.js';
20
- import { EMBED_PROVIDER, hasEmbedProvider, embedProviderHint, embedConfig, setEmbedJwt } from '../embeddings.js';
21
- import { keywordSearch } from '../search.js';
22
- import { handleBrainTool } from '../handlers/brain.js';
23
- import type { Redis } from 'ioredis';
24
-
25
- // ── In-memory Redis mock ──────────────────────────────────────────────────────
26
-
27
- class MockRedis {
28
- private store = new Map<string, string>();
29
- private lists = new Map<string, string[]>();
30
- private sets = new Map<string, Set<string>>();
31
-
32
- async get(key: string): Promise<string | null> {
33
- return this.store.get(key) ?? null;
34
- }
35
-
36
- async set(key: string, value: string, ...opts: unknown[]): Promise<'OK' | null> {
37
- // Honor a trailing 'NX' flag so first-wins semantics (born_at, first_recall_at) work.
38
- if (opts.includes('NX') && this.store.has(key)) return null;
39
- this.store.set(key, value);
40
- return 'OK';
41
- }
42
-
43
- async incr(key: string): Promise<number> {
44
- const next = parseInt(this.store.get(key) ?? '0', 10) + 1;
45
- this.store.set(key, String(next));
46
- return next;
47
- }
48
-
49
- async rpush(key: string, ...values: string[]): Promise<number> {
50
- const list = this.lists.get(key) ?? [];
51
- list.push(...values);
52
- this.lists.set(key, list);
53
- return list.length;
54
- }
55
-
56
- async lrange(key: string, start: number, stop: number): Promise<string[]> {
57
- const list = this.lists.get(key) ?? [];
58
- const end = stop < 0 ? list.length + stop + 1 : stop + 1;
59
- return list.slice(start < 0 ? list.length + start : start, end);
60
- }
61
-
62
- async ltrim(key: string, start: number, stop: number): Promise<'OK'> {
63
- const list = this.lists.get(key) ?? [];
64
- const end = stop < 0 ? list.length + stop + 1 : stop + 1;
65
- this.lists.set(key, list.slice(start < 0 ? list.length + start : start, end));
66
- return 'OK';
67
- }
68
-
69
- async sadd(key: string, ...members: string[]): Promise<number> {
70
- const s = this.sets.get(key) ?? new Set<string>();
71
- let added = 0;
72
- for (const m of members) { if (!s.has(m)) { s.add(m); added++; } }
73
- this.sets.set(key, s);
74
- return added;
75
- }
76
-
77
- async smembers(key: string): Promise<string[]> {
78
- return [...(this.sets.get(key) ?? [])];
79
- }
80
-
81
- async mget(...keys: string[]): Promise<(string | null)[]> {
82
- return keys.map(k => this.store.get(k) ?? null);
83
- }
84
-
85
- async exists(key: string): Promise<number> {
86
- return this.store.has(key) ? 1 : 0;
87
- }
88
-
89
- async incrbyfloat(key: string, increment: number): Promise<string> {
90
- const cur = parseFloat(this.store.get(key) ?? '0');
91
- const next = cur + increment;
92
- this.store.set(key, String(next));
93
- return String(next);
94
- }
95
-
96
- async expire(_key: string, _ttl: number): Promise<number> { return 1; }
97
-
98
- async del(...keys: string[]): Promise<number> {
99
- let count = 0;
100
- for (const k of keys) { if (this.store.delete(k)) count++; }
101
- return count;
102
- }
103
-
104
- /** Simplified scanStream: returns all matching keys in a single 'data' event. */
105
- scanStream(opts: { match: string; count?: number }): EventEmitter {
106
- const emitter = new EventEmitter();
107
- const pattern = opts.match.replace(/\*/g, '.*').replace(/\?/g, '.');
108
- const regex = new RegExp(`^${pattern}$`);
109
- const matches = [...this.store.keys()].filter(k => regex.test(k));
110
- setImmediate(() => {
111
- emitter.emit('data', matches);
112
- emitter.emit('end');
113
- });
114
- return emitter;
115
- }
116
-
117
- /** Minimal pipeline: batches GET calls, returns [err, value][] */
118
- pipeline() {
119
- const commands: Array<{ cmd: string; key: string }> = [];
120
- const store = this.store;
121
- return {
122
- get(key: string) { commands.push({ cmd: 'get', key }); return this; },
123
- async exec(): Promise<Array<[null, string | null]>> {
124
- const out: Array<[null, string | null]> = [];
125
- for (const c of commands) {
126
- out.push([null, store.get(c.key) ?? null]);
127
- }
128
- return out;
129
- },
130
- };
131
- }
132
-
133
- // Expose raw store for assertions
134
- _getStore() { return this.store; }
135
- _getLists() { return this.lists; }
136
- _getSets() { return this.sets; }
137
- }
138
-
139
- // Lesson schema (mirrors what learn_from_attempts stores)
140
- interface LessonObj {
141
- topic: string;
142
- outcome: 'success' | 'failure' | 'partial';
143
- what_worked: string;
144
- what_failed?: string;
145
- context?: string;
146
- severity?: 'critical' | 'major' | 'minor';
147
- commands?: string[];
148
- tags?: string[];
149
- file_paths?: string[];
150
- recall_count: number;
151
- ts: string;
152
- verified_at?: string;
153
- confidence: number;
154
- audit_trail: Array<{ ts: string; action: string; prev_outcome?: string }>;
155
- version: number;
156
- }
157
-
158
- /** Minimal implementation of the learn_from_attempts Redis storage logic */
159
- async function storeLessonInRedis(
160
- redis: MockRedis,
161
- lesson: Omit<LessonObj, 'recall_count' | 'ts' | 'verified_at' | 'confidence' | 'audit_trail' | 'version'>,
162
- ): Promise<LessonObj> {
163
- const ts = new Date().toISOString();
164
- const existingRaw = await redis.get(`cachly:lesson:best:${lesson.topic}`);
165
- const auditTrail: LessonObj['audit_trail'] = existingRaw
166
- ? [{ ts, action: 'updated', prev_outcome: (JSON.parse(existingRaw) as LessonObj).outcome }]
167
- : [{ ts, action: 'created' }];
168
- const obj: LessonObj = {
169
- ...lesson,
170
- recall_count: 0,
171
- ts,
172
- verified_at: lesson.outcome !== 'failure' ? ts : undefined,
173
- confidence: 1.0,
174
- audit_trail: auditTrail,
175
- version: 3,
176
- };
177
- const serialized = JSON.stringify(obj);
178
- await redis.rpush(`cachly:lessons:${lesson.topic}`, serialized);
179
- if (lesson.outcome === 'success' || lesson.outcome === 'partial' || !existingRaw) {
180
- await redis.set(`cachly:lesson:best:${lesson.topic}`, serialized);
181
- }
182
- return obj;
183
- }
184
-
185
- // ── Tests ─────────────────────────────────────────────────────────────────────
186
-
187
- describe('CKG helpers (ckg.ts)', () => {
188
- let redis: MockRedis;
189
- beforeEach(() => { redis = new MockRedis(); });
190
-
191
- // ── ckgSlug ─────────────────────────────────────────────────────────────────
192
- describe('ckgSlug', () => {
193
- it('lowercases and replaces non-alnum with hyphens', () => {
194
- // trailing '!' becomes '-' which is then stripped by the trailing-hyphen rule
195
- expect(ckgSlug('Deploy API!')).toBe('deploy-api');
196
- });
197
- it('collapses multiple hyphens', () => {
198
- expect(ckgSlug('foo -- bar')).toBe('foo-bar');
199
- });
200
- it('strips leading/trailing hyphens', () => {
201
- expect(ckgSlug('--hello--')).toBe('hello');
202
- });
203
- it('allows colon for namespaced slugs', () => {
204
- expect(ckgSlug('deploy:api')).toBe('deploy:api');
205
- });
206
- it('truncates at 80 chars', () => {
207
- expect(ckgSlug('a'.repeat(100))).toHaveLength(80);
208
- });
209
- });
210
-
211
- // ── extractProblemConcept ────────────────────────────────────────────────────
212
- describe('extractProblemConcept', () => {
213
- it('extracts 2 keywords from text', () => {
214
- const result = extractProblemConcept('docker container fails to start');
215
- expect(result).toBe('docker-container');
216
- });
217
- it('filters short words', () => {
218
- const result = extractProblemConcept('the key issue');
219
- // 'the' is stopword, 'key' is 3 chars (filtered), 'issue' passes
220
- expect(result).toBe('issue');
221
- });
222
- it('returns null for empty/all-stopword text', () => {
223
- expect(extractProblemConcept('')).toBeNull();
224
- });
225
- });
226
-
227
- // ── ckgUpsertNode ────────────────────────────────────────────────────────────
228
- describe('ckgUpsertNode', () => {
229
- it('creates a new node with count=1', async () => {
230
- await ckgUpsertNode(redis as unknown as Redis, 'deploy-api', 'backend', 'concept');
231
- const raw = await redis.get('cachly:ckg:node:deploy-api');
232
- expect(raw).not.toBeNull();
233
- const node = JSON.parse(raw!);
234
- expect(node.id).toBe('deploy-api');
235
- expect(node.domain).toBe('backend');
236
- expect(node.type).toBe('concept');
237
- expect(node.count).toBe(1);
238
- });
239
- it('increments count on repeated upsert', async () => {
240
- await ckgUpsertNode(redis as unknown as Redis, 'deploy-api', 'backend', 'concept');
241
- await ckgUpsertNode(redis as unknown as Redis, 'deploy-api', 'backend', 'concept');
242
- const node = JSON.parse((await redis.get('cachly:ckg:node:deploy-api'))!);
243
- expect(node.count).toBe(2);
244
- });
245
- });
246
-
247
- // ── ckgUpdateEdge ────────────────────────────────────────────────────────────
248
- describe('ckgUpdateEdge', () => {
249
- it('creates edge with Bayesian smoothed confidence', async () => {
250
- await ckgUpdateEdge(redis as unknown as Redis, 'problem-A', 'solved_by', 'solution-B', true);
251
- const edgeKey = 'cachly:ckg:edge:problem-A:solved_by:solution-B';
252
- const edge = JSON.parse((await redis.get(edgeKey))!);
253
- expect(edge.successes).toBe(1);
254
- expect(edge.trials).toBe(1);
255
- // Beta(successes+1, trials+2) = 2/3 ≈ 0.667
256
- expect(edge.confidence).toBeCloseTo(2 / 3, 5);
257
- });
258
- it('increases confidence after multiple successes', async () => {
259
- const r = redis as unknown as Redis;
260
- await ckgUpdateEdge(r, 'A', 'leads_to', 'B', true);
261
- await ckgUpdateEdge(r, 'A', 'leads_to', 'B', true);
262
- await ckgUpdateEdge(r, 'A', 'leads_to', 'B', true);
263
- const edge = JSON.parse((await redis.get('cachly:ckg:edge:A:leads_to:B'))!);
264
- // 3 successes, 3 trials → (3+1)/(3+2) = 0.8
265
- expect(edge.confidence).toBeCloseTo(0.8, 5);
266
- });
267
- it('partial success adds 0.5 to successes', async () => {
268
- const r = redis as unknown as Redis;
269
- await ckgUpdateEdge(r, 'A', 'leads_to', 'B', false, true); // partial
270
- const edge = JSON.parse((await redis.get('cachly:ckg:edge:A:leads_to:B'))!);
271
- expect(edge.successes).toBe(0.5);
272
- // (0.5+1)/(1+2) = 1.5/3 = 0.5
273
- expect(edge.confidence).toBeCloseTo(0.5, 5);
274
- });
275
- it('decreases confidence on failure', async () => {
276
- const r = redis as unknown as Redis;
277
- await ckgUpdateEdge(r, 'A', 'leads_to', 'B', true);
278
- const before = JSON.parse((await redis.get('cachly:ckg:edge:A:leads_to:B'))!).confidence;
279
- await ckgUpdateEdge(r, 'A', 'leads_to', 'B', false);
280
- const after = JSON.parse((await redis.get('cachly:ckg:edge:A:leads_to:B'))!).confidence;
281
- expect(after).toBeLessThan(before);
282
- });
283
- it('indexes edge in from/to index sets', async () => {
284
- const r = redis as unknown as Redis;
285
- await ckgUpdateEdge(r, 'prob', 'solved_by', 'sol', true);
286
- const fromIdx = await redis.smembers('cachly:ckg:idx:from:prob');
287
- const toIdx = await redis.smembers('cachly:ckg:idx:to:sol');
288
- expect(fromIdx).toContain('cachly:ckg:edge:prob:solved_by:sol');
289
- expect(toIdx).toContain('cachly:ckg:edge:prob:solved_by:sol');
290
- });
291
- });
292
- });
293
-
294
- // ── Embeddings (embeddings.ts) ────────────────────────────────────────────────
295
-
296
- describe('Embeddings config (embeddings.ts)', () => {
297
- it('EMBED_PROVIDER is a string', () => {
298
- expect(typeof EMBED_PROVIDER).toBe('string');
299
- });
300
-
301
- it('hasEmbedProvider returns false when no provider keys are set', () => {
302
- // In test environment (no API keys), EMBED_PROVIDER is 'none'
303
- // hasEmbedProvider() depends on env — just verify it returns a boolean
304
- expect(typeof hasEmbedProvider()).toBe('boolean');
305
- });
306
-
307
- it('embedProviderHint returns a non-empty string', () => {
308
- const hint = embedProviderHint();
309
- expect(typeof hint).toBe('string');
310
- expect(hint.length).toBeGreaterThan(0);
311
- });
312
-
313
- it('setEmbedJwt updates embedConfig.jwt', () => {
314
- const prev = embedConfig.jwt;
315
- setEmbedJwt('test-jwt-token-xyz');
316
- expect(embedConfig.jwt).toBe('test-jwt-token-xyz');
317
- // Restore
318
- setEmbedJwt(prev);
319
- });
320
-
321
- it('embedConfig.apiUrl defaults to cachly.dev', () => {
322
- expect(embedConfig.apiUrl).toContain('cachly.dev');
323
- });
324
- });
325
-
326
- // ── Brain Lesson Flow (learn → store → recall) ───────────────────────────────
327
-
328
- describe('Brain lesson flow', () => {
329
- let redis: MockRedis;
330
- beforeEach(() => { redis = new MockRedis(); });
331
-
332
- describe('storeLessonInRedis (learn_from_attempts logic)', () => {
333
- it('stores lesson at cachly:lesson:best:{topic}', async () => {
334
- await storeLessonInRedis(redis, {
335
- topic: 'deploy:api',
336
- outcome: 'success',
337
- what_worked: 'docker compose up -d --build api',
338
- what_failed: 'docker compose up (hangs on SSH timeout)',
339
- severity: 'critical',
340
- commands: ['docker compose up -d --build api'],
341
- tags: ['docker', 'deploy'],
342
- file_paths: [],
343
- });
344
-
345
- const raw = await redis.get('cachly:lesson:best:deploy:api');
346
- expect(raw).not.toBeNull();
347
- const lesson = JSON.parse(raw!) as LessonObj;
348
- expect(lesson.topic).toBe('deploy:api');
349
- expect(lesson.outcome).toBe('success');
350
- expect(lesson.what_worked).toBe('docker compose up -d --build api');
351
- expect(lesson.commands).toContain('docker compose up -d --build api');
352
- expect(lesson.confidence).toBe(1.0);
353
- expect(lesson.version).toBe(3);
354
- });
355
-
356
- it('appends to history list cachly:lessons:{topic}', async () => {
357
- await storeLessonInRedis(redis, {
358
- topic: 'fix:redis-timeout',
359
- outcome: 'success',
360
- what_worked: 'increase timeout to 30s',
361
- severity: 'major',
362
- commands: [],
363
- tags: [],
364
- file_paths: [],
365
- });
366
- await storeLessonInRedis(redis, {
367
- topic: 'fix:redis-timeout',
368
- outcome: 'partial',
369
- what_worked: 'add retry logic',
370
- severity: 'minor',
371
- commands: [],
372
- tags: [],
373
- file_paths: [],
374
- });
375
-
376
- const history = await redis.lrange('cachly:lessons:fix:redis-timeout', 0, -1);
377
- expect(history).toHaveLength(2);
378
- });
379
-
380
- it('failure does not overwrite an existing success lesson', async () => {
381
- await storeLessonInRedis(redis, {
382
- topic: 'infra:wireguard',
383
- outcome: 'success',
384
- what_worked: 'add WireGuard IP to TLS-SAN',
385
- severity: 'critical',
386
- commands: [],
387
- tags: [],
388
- file_paths: [],
389
- });
390
- await storeLessonInRedis(redis, {
391
- topic: 'infra:wireguard',
392
- outcome: 'failure',
393
- what_worked: '',
394
- severity: 'major',
395
- commands: [],
396
- tags: [],
397
- file_paths: [],
398
- });
399
-
400
- const best = JSON.parse((await redis.get('cachly:lesson:best:infra:wireguard'))!) as LessonObj;
401
- // The success lesson must win
402
- expect(best.outcome).toBe('success');
403
- });
404
-
405
- it('audit trail records creation and update', async () => {
406
- await storeLessonInRedis(redis, {
407
- topic: 'debug:clickhouse',
408
- outcome: 'failure',
409
- what_worked: '',
410
- severity: 'major',
411
- commands: [],
412
- tags: [],
413
- file_paths: [],
414
- });
415
- await storeLessonInRedis(redis, {
416
- topic: 'debug:clickhouse',
417
- outcome: 'success',
418
- what_worked: 'use 127.0.0.1 not localhost',
419
- severity: 'major',
420
- commands: [],
421
- tags: [],
422
- file_paths: [],
423
- });
424
-
425
- const best = JSON.parse((await redis.get('cachly:lesson:best:debug:clickhouse'))!) as LessonObj;
426
- expect(best.audit_trail.length).toBe(1);
427
- expect(best.audit_trail[0].action).toBe('updated');
428
- expect(best.audit_trail[0].prev_outcome).toBe('failure');
429
- });
430
- });
431
-
432
- // ── BM25 recall ─────────────────────────────────────────────────────────────
433
-
434
- describe('BM25 recall (recall_best_solution keyword search)', () => {
435
- beforeEach(async () => {
436
- // Seed lessons
437
- const lessons = [
438
- { topic: 'deploy:api', what_worked: 'nohup docker compose up -d --build api' },
439
- { topic: 'infra:clickhouse-ipv6', what_worked: 'use 127.0.0.1 not localhost in ClickHouse healthcheck' },
440
- { topic: 'fix:keycloak-auth', what_worked: 'set redirect_uri to https not http' },
441
- { topic: 'bash:macos-lowercase', what_worked: 'use tr instead of ${var,,} on macOS' },
442
- { topic: 'deploy:web', what_worked: 'rsync --checksum --delete with ssh port 2222' },
443
- ];
444
- for (const l of lessons) {
445
- await storeLessonInRedis(redis, {
446
- ...l,
447
- outcome: 'success',
448
- severity: 'critical',
449
- commands: [],
450
- tags: [],
451
- file_paths: [],
452
- });
453
- }
454
- });
455
-
456
- it('finds deploy lesson by keyword', async () => {
457
- const results = await keywordSearch(
458
- redis as unknown as Redis,
459
- ['cachly:lesson:best:*'],
460
- 'docker deploy api',
461
- 5,
462
- );
463
- expect(results.length).toBeGreaterThan(0);
464
- expect(results[0].content).toContain('docker');
465
- });
466
-
467
- it('finds clickhouse lesson by infrastructure topic', async () => {
468
- const results = await keywordSearch(
469
- redis as unknown as Redis,
470
- ['cachly:lesson:best:*'],
471
- 'clickhouse localhost healthcheck',
472
- 5,
473
- );
474
- expect(results.length).toBeGreaterThan(0);
475
- const topResult = results[0];
476
- expect(topResult.content.toLowerCase()).toContain('clickhouse');
477
- });
478
-
479
- it('ranks more specific matches higher', async () => {
480
- const results = await keywordSearch(
481
- redis as unknown as Redis,
482
- ['cachly:lesson:best:*'],
483
- 'macos bash lowercase variable',
484
- 5,
485
- );
486
- expect(results.length).toBeGreaterThan(0);
487
- // macOS bash lesson should rank highest since it matches all terms
488
- expect(results[0].content.toLowerCase()).toContain('macos');
489
- });
490
-
491
- it('returns empty array for completely unrelated query', async () => {
492
- const results = await keywordSearch(
493
- redis as unknown as Redis,
494
- ['cachly:lesson:best:*'],
495
- 'xyzzy quantum entanglement unobtanium',
496
- 5,
497
- );
498
- // Might return 0 or very low-score results; score should be minimal
499
- // Either empty or score near 0 is acceptable
500
- if (results.length > 0) {
501
- expect(results[0].score).toBeLessThan(0.1);
502
- }
503
- });
504
- });
505
-
506
- // ── session_start context ────────────────────────────────────────────────────
507
-
508
- describe('session_start lesson aggregation', () => {
509
- it('correctly sorts lessons by timestamp (newest first)', async () => {
510
- const topics = ['old:lesson', 'mid:lesson', 'new:lesson'];
511
- for (let i = 0; i < topics.length; i++) {
512
- await storeLessonInRedis(redis, {
513
- topic: topics[i],
514
- outcome: 'success',
515
- what_worked: `fix ${i}`,
516
- severity: 'minor',
517
- commands: [],
518
- tags: [],
519
- file_paths: [],
520
- });
521
- // Tiny delay to ensure different timestamps
522
- await new Promise(r => setTimeout(r, 5));
523
- }
524
-
525
- // Simulate what session_start does: scan all lesson keys, sort by ts descending
526
- const allKeys: string[] = [];
527
- await new Promise<void>((resolve, reject) => {
528
- const stream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 200 });
529
- stream.on('data', (batch: string[]) => allKeys.push(...batch));
530
- stream.on('end', resolve);
531
- stream.on('error', reject);
532
- });
533
-
534
- const lessons: LessonObj[] = [];
535
- for (const k of allKeys) {
536
- const raw = await redis.get(k);
537
- if (raw) lessons.push(JSON.parse(raw) as LessonObj);
538
- }
539
- lessons.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
540
-
541
- expect(lessons[0].topic).toBe('new:lesson');
542
- expect(lessons[lessons.length - 1].topic).toBe('old:lesson');
543
- });
544
-
545
- it('scanStream returns all stored lesson keys', async () => {
546
- await storeLessonInRedis(redis, { topic: 'alpha', outcome: 'success', what_worked: 'x', severity: 'minor', commands: [], tags: [], file_paths: [] });
547
- await storeLessonInRedis(redis, { topic: 'beta', outcome: 'success', what_worked: 'y', severity: 'minor', commands: [], tags: [], file_paths: [] });
548
- await storeLessonInRedis(redis, { topic: 'gamma', outcome: 'success', what_worked: 'z', severity: 'minor', commands: [], tags: [], file_paths: [] });
549
-
550
- const keys: string[] = [];
551
- await new Promise<void>((resolve, reject) => {
552
- const stream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 100 });
553
- stream.on('data', (batch: string[]) => keys.push(...batch));
554
- stream.on('end', resolve);
555
- stream.on('error', reject);
556
- });
557
-
558
- expect(keys).toHaveLength(3);
559
- expect(keys.some(k => k.includes('alpha'))).toBe(true);
560
- expect(keys.some(k => k.includes('beta'))).toBe(true);
561
- expect(keys.some(k => k.includes('gamma'))).toBe(true);
562
- });
563
- });
564
- });
565
-
566
- // ── End-to-end Brain flow integration ────────────────────────────────────────
567
-
568
- describe('Brain E2E: learn → CKG → recall', () => {
569
- let redis: MockRedis;
570
- beforeEach(() => { redis = new MockRedis(); });
571
-
572
- it('full cycle: store lesson + update CKG + retrieve via BM25', async () => {
573
- const r = redis as unknown as Redis;
574
-
575
- // 1. Learn: store a deploy lesson
576
- await storeLessonInRedis(redis, {
577
- topic: 'deploy:api',
578
- outcome: 'success',
579
- what_worked: 'nohup docker compose up -d --build api avoids SSH timeout',
580
- what_failed: 'plain docker compose up hangs when SSH connection drops',
581
- severity: 'critical',
582
- commands: ['nohup docker compose up -d --build api'],
583
- tags: ['docker', 'deploy', 'ssh'],
584
- file_paths: ['docker-compose.yml'],
585
- });
586
-
587
- // 2. CKG: record the causal connection
588
- await ckgUpsertNode(r, 'deploy-api', 'infra', 'concept');
589
- await ckgUpdateEdge(r, 'ssh-timeout', 'solved_by', 'nohup-docker', true);
590
-
591
- // 3. Recall via BM25
592
- const results = await keywordSearch(
593
- r,
594
- ['cachly:lesson:best:*'],
595
- 'docker compose ssh timeout deploy',
596
- 5,
597
- );
598
-
599
- expect(results.length).toBeGreaterThan(0);
600
- expect(results[0].content).toContain('nohup');
601
-
602
- // 4. Verify CKG state
603
- const edge = JSON.parse((await redis.get('cachly:ckg:edge:ssh-timeout:solved_by:nohup-docker'))!);
604
- expect(edge.confidence).toBeCloseTo(2 / 3, 5); // Beta(1+1, 1+2)
605
-
606
- // 5. Verify lesson persisted correctly
607
- const lesson = JSON.parse((await redis.get('cachly:lesson:best:deploy:api'))!) as LessonObj;
608
- expect(lesson.outcome).toBe('success');
609
- expect(lesson.commands).toContain('nohup docker compose up -d --build api');
610
- });
611
-
612
- it('contradiction: failure after success is detected', async () => {
613
- await storeLessonInRedis(redis, {
614
- topic: 'infra:k3s',
615
- outcome: 'success',
616
- what_worked: 'add WireGuard IP to k3s TLS-SAN',
617
- severity: 'critical',
618
- commands: [],
619
- tags: [],
620
- file_paths: [],
621
- });
622
- await storeLessonInRedis(redis, {
623
- topic: 'infra:k3s',
624
- outcome: 'failure',
625
- what_worked: '',
626
- severity: 'major',
627
- commands: [],
628
- tags: [],
629
- file_paths: [],
630
- });
631
-
632
- // Success lesson should still be the "best" lesson
633
- const best = JSON.parse((await redis.get('cachly:lesson:best:infra:k3s'))!) as LessonObj;
634
- expect(best.outcome).toBe('success');
635
-
636
- // But failure is logged in history
637
- const history = await redis.lrange('cachly:lessons:infra:k3s', 0, -1);
638
- expect(history).toHaveLength(2);
639
- const lastAttempt = JSON.parse(history[1]) as LessonObj;
640
- expect(lastAttempt.outcome).toBe('failure');
641
- });
642
- });
643
-
644
- // ── Extended Brain Flow Scenarios ─────────────────────────────────────────────
645
-
646
- describe('Brain Flow: advanced scenarios', () => {
647
- let redis: MockRedis;
648
- beforeEach(() => { redis = new MockRedis(); });
649
-
650
- describe('recall_count and lesson update flow', () => {
651
- it('partial outcome can later be upgraded to success', async () => {
652
- await storeLessonInRedis(redis, {
653
- topic: 'fix:timeout',
654
- outcome: 'partial',
655
- what_worked: 'increase timeout to 15s',
656
- severity: 'major',
657
- commands: [],
658
- tags: [],
659
- file_paths: [],
660
- });
661
- // Upgrade to success
662
- await storeLessonInRedis(redis, {
663
- topic: 'fix:timeout',
664
- outcome: 'success',
665
- what_worked: 'increase timeout to 30s + add retry logic',
666
- severity: 'major',
667
- commands: ['--timeout 30s', '--retry 3'],
668
- tags: ['reliability'],
669
- file_paths: [],
670
- });
671
-
672
- const best = JSON.parse((await redis.get('cachly:lesson:best:fix:timeout'))!) as LessonObj;
673
- expect(best.outcome).toBe('success');
674
- expect(best.what_worked).toContain('retry logic');
675
-
676
- const history = await redis.lrange('cachly:lessons:fix:timeout', 0, -1);
677
- expect(history).toHaveLength(2);
678
- });
679
-
680
- it('lesson version is always 3', async () => {
681
- await storeLessonInRedis(redis, {
682
- topic: 'deploy:mcp',
683
- outcome: 'success',
684
- what_worked: 'npm publish --access public',
685
- severity: 'minor',
686
- commands: [],
687
- tags: [],
688
- file_paths: [],
689
- });
690
- const lesson = JSON.parse((await redis.get('cachly:lesson:best:deploy:mcp'))!) as LessonObj;
691
- expect(lesson.version).toBe(3);
692
- });
693
-
694
- it('verified_at is set for success/partial outcomes', async () => {
695
- await storeLessonInRedis(redis, {
696
- topic: 'infra:ssl',
697
- outcome: 'success',
698
- what_worked: 'certbot renew',
699
- severity: 'critical',
700
- commands: [],
701
- tags: [],
702
- file_paths: [],
703
- });
704
- const lesson = JSON.parse((await redis.get('cachly:lesson:best:infra:ssl'))!) as LessonObj;
705
- expect(lesson.verified_at).toBeDefined();
706
- expect(new Date(lesson.verified_at!).getTime()).toBeLessThanOrEqual(Date.now());
707
- });
708
-
709
- it('verified_at is NOT set for failure outcomes', async () => {
710
- await storeLessonInRedis(redis, {
711
- topic: 'infra:ssl',
712
- outcome: 'failure',
713
- what_worked: '',
714
- severity: 'critical',
715
- commands: [],
716
- tags: [],
717
- file_paths: [],
718
- });
719
- const lesson = JSON.parse((await redis.get('cachly:lesson:best:infra:ssl'))!) as LessonObj;
720
- expect(lesson.verified_at).toBeUndefined();
721
- });
722
- });
723
-
724
- describe('multi-topic lesson management', () => {
725
- it('different topics do not interfere with each other', async () => {
726
- const topics = [
727
- { topic: 'deploy:api', what_worked: 'docker compose up api' },
728
- { topic: 'deploy:web', what_worked: 'rsync + docker build web' },
729
- { topic: 'deploy:mcp', what_worked: 'npm publish mcp server' },
730
- { topic: 'infra:nginx', what_worked: 'nginx -t && systemctl reload' },
731
- { topic: 'fix:memory', what_worked: 'increase heap to 4G' },
732
- ];
733
-
734
- for (const t of topics) {
735
- await storeLessonInRedis(redis, { ...t, outcome: 'success', severity: 'minor', commands: [], tags: [], file_paths: [] });
736
- }
737
-
738
- for (const t of topics) {
739
- const raw = await redis.get(`cachly:lesson:best:${t.topic}`);
740
- expect(raw, `lesson for ${t.topic} should exist`).not.toBeNull();
741
- const lesson = JSON.parse(raw!) as LessonObj;
742
- expect(lesson.what_worked).toBe(t.what_worked);
743
- }
744
- });
745
-
746
- it('all topics are reachable via scanStream', async () => {
747
- const topics = ['alpha:test', 'beta:test', 'gamma:test', 'delta:test'];
748
- for (const t of topics) {
749
- await storeLessonInRedis(redis, { topic: t, outcome: 'success', what_worked: 'x', severity: 'minor', commands: [], tags: [], file_paths: [] });
750
- }
751
-
752
- const keys: string[] = [];
753
- await new Promise<void>((resolve, reject) => {
754
- const stream = redis.scanStream({ match: 'cachly:lesson:best:*', count: 100 });
755
- stream.on('data', (batch: string[]) => keys.push(...batch));
756
- stream.on('end', resolve);
757
- stream.on('error', reject);
758
- });
759
-
760
- expect(keys).toHaveLength(4);
761
- for (const t of topics) {
762
- expect(keys.some(k => k.endsWith(t))).toBe(true);
763
- }
764
- });
765
-
766
- it('pipeline fetches all lessons in a single batch', async () => {
767
- const topics = ['a:1', 'b:2', 'c:3'];
768
- for (const t of topics) {
769
- await storeLessonInRedis(redis, { topic: t, outcome: 'success', what_worked: 'fix', severity: 'minor', commands: [], tags: [], file_paths: [] });
770
- }
771
-
772
- const keys = topics.map(t => `cachly:lesson:best:${t}`);
773
- const pipeline = redis.pipeline();
774
- for (const k of keys) pipeline.get(k);
775
- const results = await pipeline.exec();
776
-
777
- expect(results).toHaveLength(3);
778
- for (const [err, val] of results) {
779
- expect(err).toBeNull();
780
- expect(val).not.toBeNull();
781
- const lesson = JSON.parse(val as string) as LessonObj;
782
- expect(lesson.outcome).toBe('success');
783
- }
784
- });
785
- });
786
-
787
- describe('BM25 recency boost integration', () => {
788
- it('newer lesson with same content ranks higher than older', async () => {
789
- // Seed two similar lessons — same keywords, different age
790
- const oldTs = new Date(Date.now() - 30 * 86400000).toISOString(); // 30 days ago
791
-
792
- // Store "old" lesson manually with backdated timestamp
793
- const oldLesson: LessonObj = {
794
- topic: 'deploy:old',
795
- outcome: 'success',
796
- what_worked: 'docker compose deploy redis service restart',
797
- what_failed: undefined,
798
- context: undefined,
799
- severity: 'minor',
800
- commands: [],
801
- tags: [],
802
- file_paths: [],
803
- recall_count: 0,
804
- ts: oldTs,
805
- verified_at: oldTs,
806
- confidence: 1.0,
807
- audit_trail: [{ ts: oldTs, action: 'created' }],
808
- version: 3,
809
- };
810
- await redis.rpush('cachly:lessons:deploy:old', JSON.stringify(oldLesson));
811
- await redis.set('cachly:lesson:best:deploy:old', JSON.stringify(oldLesson));
812
-
813
- // Store "new" lesson with current timestamp (similar content, different service)
814
- await storeLessonInRedis(redis, {
815
- topic: 'deploy:new',
816
- outcome: 'success',
817
- what_worked: 'docker compose deploy redis service restart',
818
- severity: 'minor',
819
- commands: [],
820
- tags: [],
821
- file_paths: [],
822
- });
823
-
824
- const results = await keywordSearch(
825
- redis as unknown as Redis,
826
- ['cachly:lesson:best:*'],
827
- 'docker compose redis restart',
828
- 5,
829
- );
830
-
831
- // Both should match; newer should rank higher or equal
832
- expect(results.length).toBe(2);
833
- const newIdx = results.findIndex(r => r.key.includes('deploy:new'));
834
- const oldIdx = results.findIndex(r => r.key.includes('deploy:old'));
835
- expect(newIdx).toBeLessThanOrEqual(oldIdx); // new ≤ old (higher rank = lower index)
836
- });
837
-
838
- it('lesson with matching topic-keyword gets higher BM25 score', async () => {
839
- await storeLessonInRedis(redis, {
840
- topic: 'fix:ssl-certificate',
841
- outcome: 'success',
842
- what_worked: 'renew SSL certificate via certbot on Nginx',
843
- severity: 'critical',
844
- commands: ['certbot renew'],
845
- tags: ['ssl', 'nginx'],
846
- file_paths: [],
847
- });
848
- await storeLessonInRedis(redis, {
849
- topic: 'deploy:api',
850
- outcome: 'success',
851
- what_worked: 'docker compose up -d api',
852
- severity: 'minor',
853
- commands: [],
854
- tags: [],
855
- file_paths: [],
856
- });
857
-
858
- const results = await keywordSearch(
859
- redis as unknown as Redis,
860
- ['cachly:lesson:best:*'],
861
- 'ssl certificate nginx renew',
862
- 5,
863
- );
864
-
865
- expect(results.length).toBeGreaterThan(0);
866
- expect(results[0].content.toLowerCase()).toContain('ssl');
867
- });
868
- });
869
-
870
- describe('CKG multi-edge scenarios', () => {
871
- it('same node can have multiple typed edges to different targets', async () => {
872
- const r = redis as unknown as Redis;
873
- await ckgUpsertNode(r, 'api-service', 'backend', 'concept');
874
- await ckgUpdateEdge(r, 'api-service', 'depends_on', 'postgres', true);
875
- await ckgUpdateEdge(r, 'api-service', 'depends_on', 'redis', true);
876
- await ckgUpdateEdge(r, 'api-service', 'deployed_with', 'docker', true);
877
-
878
- const fromIdx = await redis.smembers('cachly:ckg:idx:from:api-service');
879
- expect(fromIdx).toHaveLength(3);
880
- expect(fromIdx.some(k => k.includes('postgres'))).toBe(true);
881
- expect(fromIdx.some(k => k.includes('redis'))).toBe(true);
882
- expect(fromIdx.some(k => k.includes('docker'))).toBe(true);
883
- });
884
-
885
- it('same edge accumulates confidence across multiple successes', async () => {
886
- const r = redis as unknown as Redis;
887
- for (let i = 0; i < 10; i++) {
888
- await ckgUpdateEdge(r, 'problem', 'solved_by', 'solution', true);
889
- }
890
- const edge = JSON.parse((await redis.get('cachly:ckg:edge:problem:solved_by:solution'))!);
891
- // After 10 successes: (10+1)/(10+2) = 11/12 ≈ 0.9167
892
- expect(edge.confidence).toBeGreaterThan(0.9);
893
- expect(edge.trials).toBe(10);
894
- expect(edge.successes).toBe(10);
895
- });
896
-
897
- it('mixed success/failure converges to true rate', async () => {
898
- const r = redis as unknown as Redis;
899
- // 6 success, 4 failure = 60% actual rate
900
- for (let i = 0; i < 6; i++) await ckgUpdateEdge(r, 'X', 'leads_to', 'Y', true);
901
- for (let i = 0; i < 4; i++) await ckgUpdateEdge(r, 'X', 'leads_to', 'Y', false);
902
- const edge = JSON.parse((await redis.get('cachly:ckg:edge:X:leads_to:Y'))!);
903
- // Beta(6+1, 10+2) = 7/12 ≈ 0.583 — close to 60%
904
- expect(edge.confidence).toBeGreaterThan(0.5);
905
- expect(edge.confidence).toBeLessThan(0.75);
906
- });
907
- });
908
- });
909
-
910
- // ── New feature coverage (0.10.41 / 0.10.42) ─────────────────────────────────
911
- // Trust badges, Proven Laws crystallization, and focus-less predictive warning.
912
- // These exercise the REAL handleBrainTool code path with the MockRedis.
913
-
914
- const noopApiFetch = (async () => null) as unknown as Parameters<typeof handleBrainTool>[3];
915
-
916
- /** Seed a best-solution lesson with an explicit recall_count / authors / pinned. */
917
- async function seedLesson(
918
- redis: MockRedis,
919
- topic: string,
920
- opts: {
921
- outcome?: 'success' | 'failure' | 'partial';
922
- what_worked?: string;
923
- what_failed?: string;
924
- recall_count?: number;
925
- severity?: 'critical' | 'major' | 'minor';
926
- pinned?: boolean;
927
- author?: string;
928
- tags?: string[];
929
- } = {},
930
- ): Promise<void> {
931
- const ts = new Date().toISOString();
932
- const obj = {
933
- topic,
934
- outcome: opts.outcome ?? 'success',
935
- what_worked: opts.what_worked ?? `solution for ${topic}`,
936
- what_failed: opts.what_failed,
937
- severity: opts.severity ?? 'minor',
938
- recall_count: opts.recall_count ?? 0,
939
- ts,
940
- verified_at: ts,
941
- confidence: 1.0,
942
- tags: opts.tags ?? [],
943
- ...(opts.pinned ? { pinned: true } : {}),
944
- ...(opts.author ? { author: opts.author } : {}),
945
- };
946
- await redis.set(`cachly:lesson:best:${topic}`, JSON.stringify(obj));
947
- }
948
-
949
- describe('Trust badges (recall_best_solution)', () => {
950
- let redis: MockRedis;
951
- const getConn = async () => redis as unknown as Redis;
952
- beforeEach(() => { redis = new MockRedis(); });
953
-
954
- it('shows "Battle-tested" when recall_count crosses 10', async () => {
955
- await seedLesson(redis, 'redis:pool', { recall_count: 9, what_worked: 'set max_retries=3' });
956
- const out = (await handleBrainTool('recall_best_solution', { instance_id: 'i1', topic: 'redis:pool' }, getConn, noopApiFetch))!;
957
- expect(out).toContain('Battle-tested');
958
- expect(out).toContain('recalled 10×');
959
- });
960
-
961
- it('shows "Proven" when recall_count is between 5 and 9', async () => {
962
- await seedLesson(redis, 'api:auth', { recall_count: 4 });
963
- const out = (await handleBrainTool('recall_best_solution', { instance_id: 'i1', topic: 'api:auth' }, getConn, noopApiFetch))!;
964
- expect(out).toContain('Proven');
965
- expect(out).toContain('recalled 5×');
966
- expect(out).not.toContain('Battle-tested');
967
- });
968
-
969
- it('shows no trust badge for a fresh low-recall lesson', async () => {
970
- await seedLesson(redis, 'misc:thing', { recall_count: 0 });
971
- const out = (await handleBrainTool('recall_best_solution', { instance_id: 'i1', topic: 'misc:thing' }, getConn, noopApiFetch))!;
972
- expect(out).not.toContain('Battle-tested');
973
- expect(out).not.toContain('Proven —');
974
- expect(out).not.toContain('Team-verified');
975
- });
976
-
977
- it('increments recall_count and persists it', async () => {
978
- await seedLesson(redis, 'deploy:x', { recall_count: 2 });
979
- await handleBrainTool('recall_best_solution', { instance_id: 'i1', topic: 'deploy:x' }, getConn, noopApiFetch);
980
- const stored = JSON.parse((await redis.get('cachly:lesson:best:deploy:x'))!);
981
- expect(stored.recall_count).toBe(3);
982
- });
983
- });
984
-
985
- describe('Proven Laws crystallization (session_start)', () => {
986
- let redis: MockRedis;
987
- const getConn = async () => redis as unknown as Redis;
988
- beforeEach(async () => {
989
- redis = new MockRedis();
990
- // Provide a last session so session_start does not attempt git reconstruction.
991
- await redis.set('cachly:session:last', JSON.stringify({ summary: 'prior work', ts: new Date().toISOString(), files_changed: [] }));
992
- });
993
-
994
- it('surfaces lessons recalled >= 5 in the Proven Laws section', async () => {
995
- await seedLesson(redis, 'infra:k3s', { recall_count: 7, what_worked: 'add WireGuard IP to TLS-SAN' });
996
- await seedLesson(redis, 'random:note', { recall_count: 1 });
997
- const out = (await handleBrainTool('session_start', { instance_id: 'i1' }, getConn, noopApiFetch))!;
998
- expect(out).toContain('Proven Laws');
999
- expect(out).toContain('infra:k3s');
1000
- expect(out).toContain('recalled 7×');
1001
- });
1002
-
1003
- it('surfaces an explicitly pinned lesson even with low recall', async () => {
1004
- await seedLesson(redis, 'team:convention', { recall_count: 0, pinned: true, what_worked: 'always snake_case handlers' });
1005
- const out = (await handleBrainTool('session_start', { instance_id: 'i1' }, getConn, noopApiFetch))!;
1006
- expect(out).toContain('Proven Laws');
1007
- expect(out).toContain('team:convention');
1008
- expect(out).toContain('pinned');
1009
- });
1010
-
1011
- it('omits the Proven Laws section when nothing qualifies', async () => {
1012
- await seedLesson(redis, 'small:thing', { recall_count: 1 });
1013
- const out = (await handleBrainTool('session_start', { instance_id: 'i1' }, getConn, noopApiFetch))!;
1014
- expect(out).not.toContain('Proven Laws');
1015
- });
1016
-
1017
- it('does not crystallize failure lessons', async () => {
1018
- await seedLesson(redis, 'broken:flow', { recall_count: 9, outcome: 'failure', what_failed: 'never works' });
1019
- const out = (await handleBrainTool('session_start', { instance_id: 'i1' }, getConn, noopApiFetch))!;
1020
- expect(out).not.toContain('Proven Laws');
1021
- });
1022
- });
1023
-
1024
- describe('Predictive pre-warning without explicit focus (session_start)', () => {
1025
- let redis: MockRedis;
1026
- const getConn = async () => redis as unknown as Redis;
1027
- beforeEach(() => { redis = new MockRedis(); });
1028
-
1029
- it('derives danger area from last session files and warns', async () => {
1030
- // Last session touched payment files → likely to continue there.
1031
- await redis.set('cachly:session:last', JSON.stringify({
1032
- summary: 'worked on payment integration',
1033
- ts: new Date().toISOString(),
1034
- files_changed: ['api/payment/stripe_webhook.go'],
1035
- }));
1036
- // A known failure in the payment area.
1037
- await seedLesson(redis, 'payment:webhook', { outcome: 'failure', what_failed: 'missed idempotency key → double charge', tags: ['payment'] });
1038
- const out = (await handleBrainTool('session_start', { instance_id: 'i1' }, getConn, noopApiFetch))!;
1039
- expect(out).toContain('PRE-WARNING');
1040
- expect(out).toContain('payment:webhook');
1041
- });
1042
-
1043
- it('does not warn when last-session area has no known failures', async () => {
1044
- await redis.set('cachly:session:last', JSON.stringify({
1045
- summary: 'wrote docs',
1046
- ts: new Date().toISOString(),
1047
- files_changed: ['docs/readme.md'],
1048
- }));
1049
- await seedLesson(redis, 'payment:webhook', { outcome: 'failure', what_failed: 'x', tags: ['payment'] });
1050
- const out = (await handleBrainTool('session_start', { instance_id: 'i1' }, getConn, noopApiFetch))!;
1051
- expect(out).not.toContain('PRE-WARNING');
1052
- });
1053
-
1054
- it('filters path noise so common segments do not trigger false matches', async () => {
1055
- // Last session only touched generic noise paths (src/lib/index).
1056
- await redis.set('cachly:session:last', JSON.stringify({
1057
- summary: 'refactor',
1058
- ts: new Date().toISOString(),
1059
- files_changed: ['src/lib/index.ts'],
1060
- }));
1061
- // A failure tagged 'src' — should NOT match because 'src' is noise.
1062
- await seedLesson(redis, 'src:thing', { outcome: 'failure', what_failed: 'noise', tags: ['src'] });
1063
- const out = (await handleBrainTool('session_start', { instance_id: 'i1' }, getConn, noopApiFetch))!;
1064
- expect(out).not.toContain('PRE-WARNING');
1065
- });
1066
- });
1067
-
1068
- describe('Phase 3A: Person + File nodes in CKG (ckg.ts)', () => {
1069
- let redis: MockRedis;
1070
- beforeEach(() => { redis = new MockRedis(); });
1071
-
1072
- it('ckgUpsertPersonNode creates a person node with correct shape', async () => {
1073
- const id = await ckgUpsertPersonNode(redis as unknown as Redis, 'alice', 'fix');
1074
- expect(id).toBe('person:alice');
1075
- const raw = await redis.get('cachly:ckg:node:person:alice');
1076
- expect(raw).not.toBeNull();
1077
- const node = JSON.parse(raw!) as PersonNode;
1078
- expect(node.handle).toBe('alice');
1079
- expect(node.type).toBe('person');
1080
- expect(node.count).toBe(1);
1081
- expect(node.domain).toBe('fix');
1082
- expect(typeof node.last_active).toBe('string');
1083
- });
1084
-
1085
- it('ckgUpsertPersonNode increments count on repeated calls', async () => {
1086
- await ckgUpsertPersonNode(redis as unknown as Redis, 'bob', 'deploy');
1087
- await ckgUpsertPersonNode(redis as unknown as Redis, 'bob', 'deploy');
1088
- const raw = await redis.get('cachly:ckg:node:person:bob');
1089
- const node = JSON.parse(raw!) as PersonNode;
1090
- expect(node.count).toBe(2);
1091
- });
1092
-
1093
- it('ckgUpsertFileNode creates a file node with correct shape', async () => {
1094
- const id = await ckgUpsertFileNode(redis as unknown as Redis, 'src/auth/jwt.ts');
1095
- expect(id).toBe('file:src-auth-jwt-ts');
1096
- const raw = await redis.get('cachly:ckg:node:file:src-auth-jwt-ts');
1097
- expect(raw).not.toBeNull();
1098
- const node = JSON.parse(raw!) as FileNode;
1099
- expect(node.path).toBe('src/auth/jwt.ts');
1100
- expect(node.type).toBe('file');
1101
- expect(node.count).toBe(1);
1102
- });
1103
-
1104
- it('ckgUpsertFileNode increments count on repeated calls', async () => {
1105
- await ckgUpsertFileNode(redis as unknown as Redis, 'src/api.ts');
1106
- await ckgUpsertFileNode(redis as unknown as Redis, 'src/api.ts');
1107
- const raw = await redis.get(`cachly:ckg:node:file:${ckgSlug('src/api.ts')}`);
1108
- const node = JSON.parse(raw!) as FileNode;
1109
- expect(node.count).toBe(2);
1110
- });
1111
- });
1112
-
1113
- describe('Phase 3A: brain_who_knows', () => {
1114
- let redis: MockRedis;
1115
- const iid = 'i1';
1116
- const getConn = async () => redis as unknown as Redis;
1117
-
1118
- beforeEach(() => { redis = new MockRedis(); });
1119
-
1120
- it('returns empty-state message when no lessons have author attribution', async () => {
1121
- const out = await handleBrainTool('brain_who_knows', { instance_id: iid, topic: 'deploy:k8s' }, getConn, noopApiFetch);
1122
- expect(out).toContain('No attributed lessons');
1123
- expect(out).toContain('learn_from_attempts');
1124
- });
1125
-
1126
- it('returns ranked contributors after learn_from_attempts with author', async () => {
1127
- // Alice stored 2 successful deploy lessons
1128
- await handleBrainTool('learn_from_attempts', {
1129
- instance_id: iid, topic: 'deploy:k8s', outcome: 'success',
1130
- what_worked: 'kubectl apply with --dry-run first', author: 'alice',
1131
- }, getConn, noopApiFetch);
1132
- await handleBrainTool('learn_from_attempts', {
1133
- instance_id: iid, topic: 'deploy:helm', outcome: 'success',
1134
- what_worked: 'helm upgrade --atomic', author: 'alice',
1135
- }, getConn, noopApiFetch);
1136
- // Bob stored 1 lesson in same domain
1137
- await handleBrainTool('learn_from_attempts', {
1138
- instance_id: iid, topic: 'deploy:docker', outcome: 'success',
1139
- what_worked: 'multi-stage build', author: 'bob',
1140
- }, getConn, noopApiFetch);
1141
-
1142
- const out = await handleBrainTool('brain_who_knows', { instance_id: iid, topic: 'deploy' }, getConn, noopApiFetch);
1143
- expect(out).toContain('alice');
1144
- expect(out).toContain('bob');
1145
- expect(out).toContain('🥇');
1146
- });
1147
-
1148
- it('shows the most experienced author first', async () => {
1149
- // alice: 3 lessons, bob: 1 lesson
1150
- for (let i = 0; i < 3; i++) {
1151
- await handleBrainTool('learn_from_attempts', {
1152
- instance_id: iid, topic: `fix:bug${i}`, outcome: 'success',
1153
- what_worked: `solution ${i}`, author: 'alice',
1154
- }, getConn, noopApiFetch);
1155
- }
1156
- await handleBrainTool('learn_from_attempts', {
1157
- instance_id: iid, topic: 'fix:other', outcome: 'success',
1158
- what_worked: 'quick patch', author: 'bob',
1159
- }, getConn, noopApiFetch);
1160
-
1161
- const out = await handleBrainTool('brain_who_knows', { instance_id: iid, topic: 'fix' }, getConn, noopApiFetch);
1162
- // alice should appear before bob
1163
- const aliceIdx = out!.indexOf('alice');
1164
- const bobIdx = out!.indexOf('bob');
1165
- expect(aliceIdx).toBeGreaterThan(-1);
1166
- expect(aliceIdx).toBeLessThan(bobIdx);
1167
- });
1168
- });
1169
-
1170
- describe('Visibility scopes (learn_from_attempts)', () => {
1171
- let redis: MockRedis;
1172
- const iid = 'i1';
1173
- const getConn = async () => redis as unknown as Redis;
1174
-
1175
- beforeEach(() => { redis = new MockRedis(); });
1176
-
1177
- it('stores visibility=private on the lesson object', async () => {
1178
- await handleBrainTool('learn_from_attempts', {
1179
- instance_id: iid, topic: 'personal:note', outcome: 'success',
1180
- what_worked: 'local trick only I know', visibility: 'private',
1181
- }, getConn, noopApiFetch);
1182
- const raw = await redis.get('cachly:lesson:best:personal:note');
1183
- expect(raw).not.toBeNull();
1184
- const lesson = JSON.parse(raw!) as { visibility: string };
1185
- expect(lesson.visibility).toBe('private');
1186
- });
1187
-
1188
- it('defaults to visibility=team when not specified', async () => {
1189
- await handleBrainTool('learn_from_attempts', {
1190
- instance_id: iid, topic: 'team:lesson', outcome: 'success',
1191
- what_worked: 'shared approach',
1192
- }, getConn, noopApiFetch);
1193
- const raw = await redis.get('cachly:lesson:best:team:lesson');
1194
- const lesson = JSON.parse(raw!) as { visibility: string };
1195
- expect(lesson.visibility).toBe('team');
1196
- });
1197
-
1198
- it('private lessons are excluded from smart_recall results', async () => {
1199
- // Store a private lesson with distinctive keyword
1200
- await handleBrainTool('learn_from_attempts', {
1201
- instance_id: iid, topic: 'secret:api-key', outcome: 'success',
1202
- what_worked: 'xyzzy-unique-private-token', visibility: 'private',
1203
- }, getConn, noopApiFetch);
1204
- // Store a public lesson for the same query
1205
- await handleBrainTool('learn_from_attempts', {
1206
- instance_id: iid, topic: 'public:api-key', outcome: 'success',
1207
- what_worked: 'use env vars for xyzzy tokens', visibility: 'team',
1208
- }, getConn, noopApiFetch);
1209
-
1210
- const out = await handleBrainTool('smart_recall', {
1211
- instance_id: iid, query: 'xyzzy api key',
1212
- }, getConn, noopApiFetch);
1213
- expect(out).toContain('public:api-key');
1214
- expect(out).not.toContain('secret:api-key');
1215
- });
1216
- });
1217
-
1218
- describe('Phase 3B: brain_file_map', () => {
1219
- let redis: MockRedis;
1220
- const iid = 'i1';
1221
- const getConn = async () => redis as unknown as Redis;
1222
-
1223
- beforeEach(() => { redis = new MockRedis(); });
1224
-
1225
- it('returns a message for unknown files with no attribution', async () => {
1226
- const out = await handleBrainTool('brain_file_map', {
1227
- instance_id: iid, file_paths: ['src/unknown-file.ts'],
1228
- }, getConn, noopApiFetch);
1229
- expect(out).toContain('src/unknown-file.ts');
1230
- expect(out).toContain('None yet');
1231
- });
1232
-
1233
- it('shows expert after learn_from_attempts with author + file_paths', async () => {
1234
- await handleBrainTool('learn_from_attempts', {
1235
- instance_id: iid, topic: 'fix:payment-bug', outcome: 'success',
1236
- what_worked: 'null check before charge', author: 'carol',
1237
- file_paths: ['src/payments/stripe.ts'],
1238
- }, getConn, noopApiFetch);
1239
-
1240
- const out = await handleBrainTool('brain_file_map', {
1241
- instance_id: iid, file_paths: ['src/payments/stripe.ts'],
1242
- }, getConn, noopApiFetch);
1243
- expect(out).toContain('src/payments/stripe.ts');
1244
- expect(out).toContain('carol');
1245
- });
1246
-
1247
- it('returns error message for empty file_paths', async () => {
1248
- const out = await handleBrainTool('brain_file_map', {
1249
- instance_id: iid, file_paths: [],
1250
- }, getConn, noopApiFetch);
1251
- expect(out).toContain('at least one');
1252
- });
1253
- });
1254
-
1255
- describe('Phase 3B: team_expertise_map', () => {
1256
- let redis: MockRedis;
1257
- const iid = 'i1';
1258
- const getConn = async () => redis as unknown as Redis;
1259
-
1260
- beforeEach(() => { redis = new MockRedis(); });
1261
-
1262
- it('returns empty-state message when no contributors exist', async () => {
1263
- const out = await handleBrainTool('team_expertise_map', { instance_id: iid }, getConn, noopApiFetch);
1264
- expect(out).toContain('No contributors');
1265
- expect(out).toContain('learn_from_attempts');
1266
- });
1267
-
1268
- it('shows all contributors after learn_from_attempts with different authors', async () => {
1269
- await handleBrainTool('learn_from_attempts', {
1270
- instance_id: iid, topic: 'deploy:k8s', outcome: 'success',
1271
- what_worked: 'use rolling update', author: 'alice',
1272
- }, getConn, noopApiFetch);
1273
- await handleBrainTool('learn_from_attempts', {
1274
- instance_id: iid, topic: 'fix:auth', outcome: 'success',
1275
- what_worked: 'check token expiry', author: 'bob',
1276
- }, getConn, noopApiFetch);
1277
-
1278
- const out = await handleBrainTool('team_expertise_map', { instance_id: iid }, getConn, noopApiFetch);
1279
- expect(out).toContain('alice');
1280
- expect(out).toContain('bob');
1281
- expect(out).toContain('Team Expertise Map');
1282
- });
1283
- });
1284
-
1285
- describe('Phase 3C: skill_gaps', () => {
1286
- let redis: MockRedis;
1287
- const iid = 'i1';
1288
- const getConn = async () => redis as unknown as Redis;
1289
-
1290
- beforeEach(() => { redis = new MockRedis(); });
1291
-
1292
- it('returns clean message when no gaps exist', async () => {
1293
- await handleBrainTool('learn_from_attempts', {
1294
- instance_id: iid, topic: 'fix:auth', outcome: 'success',
1295
- what_worked: 'rotate the token', author: 'alice',
1296
- }, getConn, noopApiFetch);
1297
- const out = await handleBrainTool('skill_gaps', { instance_id: iid }, getConn, noopApiFetch);
1298
- expect(out).toContain('No significant knowledge gaps');
1299
- });
1300
-
1301
- it('flags domain with failures and no success lessons', async () => {
1302
- await handleBrainTool('learn_from_attempts', {
1303
- instance_id: iid, topic: 'deploy:prod', outcome: 'failure',
1304
- what_failed: 'oom kill', what_worked: 'tried increasing limits',
1305
- }, getConn, noopApiFetch);
1306
- const out = await handleBrainTool('skill_gaps', { instance_id: iid }, getConn, noopApiFetch);
1307
- expect(out).toContain('deploy');
1308
- expect(out).toContain('unresolved failures');
1309
- });
1310
-
1311
- it('flags domain with no attribution when lessons exist but no author', async () => {
1312
- for (let i = 0; i < 4; i++) {
1313
- await handleBrainTool('learn_from_attempts', {
1314
- instance_id: iid, topic: `infra:node${i}`, outcome: 'success',
1315
- what_worked: `solution ${i}`,
1316
- // no author
1317
- }, getConn, noopApiFetch);
1318
- }
1319
- const out = await handleBrainTool('skill_gaps', { instance_id: iid }, getConn, noopApiFetch);
1320
- expect(out).toContain('infra');
1321
- expect(out).toContain('no attribution');
1322
- });
1323
- });
1324
-
1325
- describe('Phase 3C: brain_coverage', () => {
1326
- let redis: MockRedis;
1327
- const iid = 'i1';
1328
- const getConn = async () => redis as unknown as Redis;
1329
-
1330
- beforeEach(() => { redis = new MockRedis(); });
1331
-
1332
- it('returns a coverage report with score', async () => {
1333
- await handleBrainTool('learn_from_attempts', {
1334
- instance_id: iid, topic: 'fix:bug', outcome: 'success',
1335
- what_worked: 'fixed it', author: 'alice',
1336
- }, getConn, noopApiFetch);
1337
- const out = await handleBrainTool('brain_coverage', { instance_id: iid }, getConn, noopApiFetch);
1338
- expect(out).toContain('Brain Coverage Report');
1339
- expect(out).toContain('Overall score:');
1340
- expect(out).toContain('/100');
1341
- });
1342
-
1343
- it('shows 0/100 score when no lessons exist', async () => {
1344
- const out = await handleBrainTool('brain_coverage', { instance_id: iid }, getConn, noopApiFetch);
1345
- expect(out).toContain('0/100');
1346
- });
1347
-
1348
- it('score increases with more lessons and attribution', async () => {
1349
- const outBefore = await handleBrainTool('brain_coverage', { instance_id: iid }, getConn, noopApiFetch);
1350
- const scoreBefore = parseInt((outBefore!.match(/Overall score: (\d+)/) ?? ['', '0'])[1]!);
1351
-
1352
- for (let i = 0; i < 5; i++) {
1353
- await handleBrainTool('learn_from_attempts', {
1354
- instance_id: iid, topic: `fix:issue${i}`, outcome: 'success',
1355
- what_worked: `solution ${i}`, author: `dev${i % 3}`,
1356
- }, getConn, noopApiFetch);
1357
- }
1358
- const outAfter = await handleBrainTool('brain_coverage', { instance_id: iid }, getConn, noopApiFetch);
1359
- const scoreAfter = parseInt((outAfter!.match(/Overall score: (\d+)/) ?? ['', '0'])[1]!);
1360
- expect(scoreAfter).toBeGreaterThan(scoreBefore);
1361
- });
1362
- });
1363
-
1364
- describe('Phase 3 stability: input guards', () => {
1365
- let redis: MockRedis;
1366
- const iid = 'i1';
1367
- const getConn = async () => redis as unknown as Redis;
1368
-
1369
- beforeEach(() => { redis = new MockRedis(); });
1370
-
1371
- it('brain_who_knows rejects empty topic without crashing', async () => {
1372
- const out = await handleBrainTool('brain_who_knows', { instance_id: iid, topic: '' }, getConn, noopApiFetch);
1373
- expect(out).toContain('requires a non-empty');
1374
- });
1375
-
1376
- it('brain_who_knows rejects undefined topic without crashing', async () => {
1377
- const out = await handleBrainTool('brain_who_knows', { instance_id: iid }, getConn, noopApiFetch);
1378
- expect(out).toContain('requires a non-empty');
1379
- });
1380
-
1381
- it('brain_who_knows clamps an absurd limit', async () => {
1382
- await handleBrainTool('learn_from_attempts', {
1383
- instance_id: iid, topic: 'fix:x', outcome: 'success',
1384
- what_worked: 'patch', author: 'alice',
1385
- }, getConn, noopApiFetch);
1386
- const out = await handleBrainTool('brain_who_knows', { instance_id: iid, topic: 'fix', limit: 99999 }, getConn, noopApiFetch);
1387
- expect(out).toContain('alice');
1388
- });
1389
-
1390
- it('brain_file_map filters out empty path strings', async () => {
1391
- const out = await handleBrainTool('brain_file_map', { instance_id: iid, file_paths: ['', ' ', ''] }, getConn, noopApiFetch);
1392
- expect(out).toContain('Pass at least one file path');
1393
- });
1394
-
1395
- it('brain_file_map handles non-array file_paths gracefully', async () => {
1396
- const out = await handleBrainTool('brain_file_map', { instance_id: iid, file_paths: 'not-an-array' as unknown as string[] }, getConn, noopApiFetch);
1397
- expect(out).toContain('Pass at least one file path');
1398
- });
1399
- });
1400
-
1401
- describe('Phase 3C: brain_metrics — the three decisive metrics', () => {
1402
- let redis: MockRedis;
1403
- const iid = 'i1';
1404
- const getConn = async () => redis as unknown as Redis;
1405
-
1406
- beforeEach(() => { redis = new MockRedis(); });
1407
-
1408
- it('reports "not enough data" before any learning', async () => {
1409
- const out = await handleBrainTool('brain_metrics', { instance_id: iid }, getConn, noopApiFetch);
1410
- expect(out).toContain('Brain Metrics');
1411
- expect(out).toContain('Not enough data');
1412
- });
1413
-
1414
- it('always shows the published recall-lift moat number', async () => {
1415
- const out = await handleBrainTool('brain_metrics', { instance_id: iid }, getConn, noopApiFetch);
1416
- expect(out).toContain('+22.2 % Precision@1');
1417
- });
1418
-
1419
- it('records born_at on first learn (first-wins NX semantics)', async () => {
1420
- await handleBrainTool('learn_from_attempts', {
1421
- instance_id: iid, topic: 'fix:a', outcome: 'success', what_worked: 'x', author: 'alice',
1422
- }, getConn, noopApiFetch);
1423
- const born1 = await redis.get(`cachly:stats:born_at:${iid}`);
1424
- expect(born1).not.toBeNull();
1425
- // Second learn must NOT overwrite born_at
1426
- await handleBrainTool('learn_from_attempts', {
1427
- instance_id: iid, topic: 'fix:b', outcome: 'success', what_worked: 'y', author: 'bob',
1428
- }, getConn, noopApiFetch);
1429
- const born2 = await redis.get(`cachly:stats:born_at:${iid}`);
1430
- expect(born2).toBe(born1);
1431
- });
1432
-
1433
- it('tracks cross-author reuse when one person recalls another\'s lesson', async () => {
1434
- // Alice stores a proven lesson, recalled once so it counts as "proven"
1435
- await handleBrainTool('learn_from_attempts', {
1436
- instance_id: iid, topic: 'fix:race', outcome: 'success',
1437
- what_worked: 'add mutex around xyzzy counter', author: 'alice',
1438
- }, getConn, noopApiFetch);
1439
- // Bob recalls it (twice → first recall marks proven, second counts reuse on a proven lesson)
1440
- await handleBrainTool('smart_recall', { instance_id: iid, query: 'xyzzy counter mutex', author: 'bob' }, getConn, noopApiFetch);
1441
- await handleBrainTool('smart_recall', { instance_id: iid, query: 'xyzzy counter mutex', author: 'bob' }, getConn, noopApiFetch);
1442
-
1443
- const cross = await redis.get(`cachly:stats:cross_author_recalls:${iid}`);
1444
- expect(Number(cross)).toBeGreaterThanOrEqual(1);
1445
-
1446
- const out = await handleBrainTool('brain_metrics', { instance_id: iid }, getConn, noopApiFetch);
1447
- expect(out).toContain('Team-knowledge-reuse');
1448
- expect(out).toContain('distinct reuse relationship');
1449
- });
1450
-
1451
- it('does not count self-recall as cross-author reuse', async () => {
1452
- await handleBrainTool('learn_from_attempts', {
1453
- instance_id: iid, topic: 'fix:solo', outcome: 'success',
1454
- what_worked: 'fixed the wibble myself', author: 'alice',
1455
- }, getConn, noopApiFetch);
1456
- await handleBrainTool('smart_recall', { instance_id: iid, query: 'wibble', author: 'alice' }, getConn, noopApiFetch);
1457
- await handleBrainTool('smart_recall', { instance_id: iid, query: 'wibble', author: 'alice' }, getConn, noopApiFetch);
1458
- const cross = await redis.get(`cachly:stats:cross_author_recalls:${iid}`);
1459
- expect(Number(cross ?? 0)).toBe(0);
1460
- });
1461
-
1462
- it('surfaces the team-reuse banner inline in smart_recall', async () => {
1463
- await handleBrainTool('learn_from_attempts', {
1464
- instance_id: iid, topic: 'fix:plimsoll', outcome: 'success',
1465
- what_worked: 'adjust the plimsoll threshold', author: 'carol',
1466
- }, getConn, noopApiFetch);
1467
- // First recall by dave marks it proven; second recall shows the reuse banner
1468
- await handleBrainTool('smart_recall', { instance_id: iid, query: 'plimsoll threshold', author: 'dave' }, getConn, noopApiFetch);
1469
- const out = await handleBrainTool('smart_recall', { instance_id: iid, query: 'plimsoll threshold', author: 'dave' }, getConn, noopApiFetch);
1470
- expect(out).toContain('Team knowledge reuse');
1471
- });
1472
- });