pilothub 0.0.1 → 0.0.2

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 (388) hide show
  1. package/LICENSE +1 -0
  2. package/README.md +36 -129
  3. package/dist/browserAuth.d.ts +20 -0
  4. package/dist/browserAuth.js +156 -0
  5. package/dist/browserAuth.js.map +1 -0
  6. package/dist/browserAuth.test.d.ts +1 -0
  7. package/dist/browserAuth.test.js +83 -0
  8. package/dist/browserAuth.test.js.map +1 -0
  9. package/dist/cli/buildInfo.d.ts +3 -0
  10. package/dist/cli/buildInfo.js +103 -0
  11. package/dist/cli/buildInfo.js.map +1 -0
  12. package/dist/cli/commands/auth.d.ts +9 -0
  13. package/dist/cli/commands/auth.js +75 -0
  14. package/dist/cli/commands/auth.js.map +1 -0
  15. package/dist/cli/commands/delete.d.ts +11 -0
  16. package/dist/cli/commands/delete.js +67 -0
  17. package/dist/cli/commands/delete.js.map +1 -0
  18. package/dist/cli/commands/delete.test.d.ts +1 -0
  19. package/dist/cli/commands/delete.test.js +52 -0
  20. package/dist/cli/commands/delete.test.js.map +1 -0
  21. package/dist/cli/commands/publish.d.ts +9 -0
  22. package/dist/cli/commands/publish.js +87 -0
  23. package/dist/cli/commands/publish.js.map +1 -0
  24. package/dist/cli/commands/publish.test.d.ts +1 -0
  25. package/dist/cli/commands/publish.test.js +104 -0
  26. package/dist/cli/commands/publish.test.js.map +1 -0
  27. package/dist/cli/commands/skills.d.ts +23 -0
  28. package/dist/cli/commands/skills.js +298 -0
  29. package/dist/cli/commands/skills.js.map +1 -0
  30. package/dist/cli/commands/skills.test.d.ts +1 -0
  31. package/dist/cli/commands/skills.test.js +156 -0
  32. package/dist/cli/commands/skills.test.js.map +1 -0
  33. package/dist/cli/commands/star.d.ts +8 -0
  34. package/dist/cli/commands/star.js +38 -0
  35. package/dist/cli/commands/star.js.map +1 -0
  36. package/dist/cli/commands/sync.d.ts +3 -0
  37. package/dist/cli/commands/sync.js +160 -0
  38. package/dist/cli/commands/sync.js.map +1 -0
  39. package/dist/cli/commands/sync.test.d.ts +1 -0
  40. package/dist/cli/commands/sync.test.js +277 -0
  41. package/dist/cli/commands/sync.test.js.map +1 -0
  42. package/dist/cli/commands/syncHelpers.d.ts +76 -0
  43. package/dist/cli/commands/syncHelpers.js +349 -0
  44. package/dist/cli/commands/syncHelpers.js.map +1 -0
  45. package/dist/cli/commands/syncHelpers.test.d.ts +1 -0
  46. package/dist/cli/commands/syncHelpers.test.js +22 -0
  47. package/dist/cli/commands/syncHelpers.test.js.map +1 -0
  48. package/dist/cli/commands/syncTypes.d.ts +24 -0
  49. package/dist/cli/commands/syncTypes.js +2 -0
  50. package/dist/cli/commands/syncTypes.js.map +1 -0
  51. package/dist/cli/commands/unstar.d.ts +8 -0
  52. package/dist/cli/commands/unstar.js +38 -0
  53. package/dist/cli/commands/unstar.js.map +1 -0
  54. package/dist/cli/helpStyle.d.ts +13 -0
  55. package/dist/cli/helpStyle.js +38 -0
  56. package/dist/cli/helpStyle.js.map +1 -0
  57. package/dist/cli/pilotbotConfig.d.ts +6 -0
  58. package/dist/cli/pilotbotConfig.js +110 -0
  59. package/dist/cli/pilotbotConfig.js.map +1 -0
  60. package/dist/cli/pilotbotConfig.test.d.ts +1 -0
  61. package/dist/cli/pilotbotConfig.test.js +133 -0
  62. package/dist/cli/pilotbotConfig.test.js.map +1 -0
  63. package/dist/cli/registry.d.ts +7 -0
  64. package/dist/cli/registry.js +42 -0
  65. package/dist/cli/registry.js.map +1 -0
  66. package/dist/cli/registry.test.d.ts +1 -0
  67. package/dist/cli/registry.test.js +48 -0
  68. package/dist/cli/registry.test.js.map +1 -0
  69. package/dist/cli/scanSkills.d.ts +7 -0
  70. package/dist/cli/scanSkills.js +75 -0
  71. package/dist/cli/scanSkills.js.map +1 -0
  72. package/dist/cli/scanSkills.test.d.ts +1 -0
  73. package/dist/cli/scanSkills.test.js +60 -0
  74. package/dist/cli/scanSkills.test.js.map +1 -0
  75. package/dist/cli/slug.d.ts +2 -0
  76. package/dist/cli/slug.js +16 -0
  77. package/dist/cli/slug.js.map +1 -0
  78. package/dist/cli/types.d.ts +15 -0
  79. package/dist/cli/types.js +2 -0
  80. package/dist/cli/types.js.map +1 -0
  81. package/dist/cli/ui.d.ts +7 -0
  82. package/dist/cli/ui.js +72 -0
  83. package/dist/cli/ui.js.map +1 -0
  84. package/dist/cli.d.ts +2 -0
  85. package/dist/cli.js +268 -0
  86. package/dist/cli.js.map +1 -0
  87. package/dist/config.d.ts +4 -0
  88. package/dist/config.js +38 -0
  89. package/dist/config.js.map +1 -0
  90. package/dist/discovery.d.ts +5 -0
  91. package/dist/discovery.js +21 -0
  92. package/dist/discovery.js.map +1 -0
  93. package/dist/discovery.test.d.ts +1 -0
  94. package/dist/discovery.test.js +46 -0
  95. package/dist/discovery.test.js.map +1 -0
  96. package/dist/http.d.ts +32 -0
  97. package/dist/http.js +261 -0
  98. package/dist/http.js.map +1 -0
  99. package/dist/http.test.d.ts +1 -0
  100. package/dist/http.test.js +135 -0
  101. package/dist/http.test.js.map +1 -0
  102. package/dist/schema/ark.js.map +1 -0
  103. package/dist/schema/index.js.map +1 -0
  104. package/dist/schema/routes.js.map +1 -0
  105. package/{packages/schema/dist → dist/schema}/schemas.d.ts +0 -39
  106. package/{packages/schema/dist → dist/schema}/schemas.js +0 -22
  107. package/dist/schema/schemas.js.map +1 -0
  108. package/dist/schema/textFiles.js.map +1 -0
  109. package/dist/schema/textFiles.test.d.ts +1 -0
  110. package/dist/schema/textFiles.test.js +20 -0
  111. package/dist/schema/textFiles.test.js.map +1 -0
  112. package/dist/skills.d.ts +43 -0
  113. package/dist/skills.js +163 -0
  114. package/dist/skills.js.map +1 -0
  115. package/dist/skills.test.d.ts +1 -0
  116. package/dist/skills.test.js +144 -0
  117. package/dist/skills.test.js.map +1 -0
  118. package/dist/types.d.ts +7 -0
  119. package/dist/types.js +2 -0
  120. package/dist/types.js.map +1 -0
  121. package/package.json +27 -70
  122. package/.env.local.example +0 -19
  123. package/.github/workflows/ci.yml +0 -40
  124. package/.oxlintrc.json +0 -3
  125. package/AGENTS.md +0 -45
  126. package/CHANGELOG.md +0 -138
  127. package/DEPRECATIONS.md +0 -7
  128. package/biome.json +0 -41
  129. package/convex/_generated/api.d.ts +0 -153
  130. package/convex/_generated/api.js +0 -23
  131. package/convex/_generated/dataModel.d.ts +0 -60
  132. package/convex/_generated/server.d.ts +0 -143
  133. package/convex/_generated/server.js +0 -93
  134. package/convex/auth.config.ts +0 -8
  135. package/convex/auth.ts +0 -19
  136. package/convex/comments.ts +0 -88
  137. package/convex/crons.ts +0 -34
  138. package/convex/devSeed.ts +0 -459
  139. package/convex/devSeedExtra.ts +0 -541
  140. package/convex/downloads.ts +0 -78
  141. package/convex/githubBackups.ts +0 -170
  142. package/convex/githubBackupsNode.ts +0 -183
  143. package/convex/githubImport.ts +0 -317
  144. package/convex/githubSoulBackups.ts +0 -170
  145. package/convex/githubSoulBackupsNode.ts +0 -186
  146. package/convex/http.ts +0 -194
  147. package/convex/httpApi.handlers.test.ts +0 -488
  148. package/convex/httpApi.test.ts +0 -70
  149. package/convex/httpApi.ts +0 -305
  150. package/convex/httpApiV1.handlers.test.ts +0 -584
  151. package/convex/httpApiV1.ts +0 -1172
  152. package/convex/leaderboards.ts +0 -39
  153. package/convex/lib/access.ts +0 -36
  154. package/convex/lib/apiTokenAuth.ts +0 -36
  155. package/convex/lib/badges.ts +0 -50
  156. package/convex/lib/changelog.test.ts +0 -34
  157. package/convex/lib/changelog.ts +0 -278
  158. package/convex/lib/embeddings.ts +0 -38
  159. package/convex/lib/githubBackup.ts +0 -443
  160. package/convex/lib/githubImport.test.ts +0 -247
  161. package/convex/lib/githubImport.ts +0 -425
  162. package/convex/lib/githubSoulBackup.ts +0 -443
  163. package/convex/lib/leaderboards.ts +0 -103
  164. package/convex/lib/moderation.ts +0 -42
  165. package/convex/lib/public.ts +0 -89
  166. package/convex/lib/searchText.test.ts +0 -46
  167. package/convex/lib/searchText.ts +0 -27
  168. package/convex/lib/skillBackfill.test.ts +0 -34
  169. package/convex/lib/skillBackfill.ts +0 -67
  170. package/convex/lib/skillPublish.test.ts +0 -28
  171. package/convex/lib/skillPublish.ts +0 -284
  172. package/convex/lib/skillStats.ts +0 -80
  173. package/convex/lib/skills.test.ts +0 -197
  174. package/convex/lib/skills.ts +0 -273
  175. package/convex/lib/soulChangelog.ts +0 -273
  176. package/convex/lib/soulPublish.ts +0 -236
  177. package/convex/lib/tokens.test.ts +0 -33
  178. package/convex/lib/tokens.ts +0 -51
  179. package/convex/lib/webhooks.test.ts +0 -91
  180. package/convex/lib/webhooks.ts +0 -112
  181. package/convex/maintenance.test.ts +0 -270
  182. package/convex/maintenance.ts +0 -840
  183. package/convex/rateLimits.ts +0 -50
  184. package/convex/schema.ts +0 -472
  185. package/convex/search.test.ts +0 -12
  186. package/convex/search.ts +0 -254
  187. package/convex/seed.test.ts +0 -37
  188. package/convex/seed.ts +0 -254
  189. package/convex/seedSouls.ts +0 -111
  190. package/convex/skillStatEvents.ts +0 -568
  191. package/convex/skills.ts +0 -1606
  192. package/convex/soulComments.ts +0 -88
  193. package/convex/soulDownloads.ts +0 -14
  194. package/convex/soulStars.ts +0 -71
  195. package/convex/souls.ts +0 -570
  196. package/convex/stars.ts +0 -108
  197. package/convex/statsMaintenance.ts +0 -205
  198. package/convex/telemetry.ts +0 -434
  199. package/convex/tokens.ts +0 -88
  200. package/convex/tsconfig.json +0 -7
  201. package/convex/uploads.ts +0 -20
  202. package/convex/users.ts +0 -122
  203. package/convex/webhooks.ts +0 -50
  204. package/convex.json +0 -3
  205. package/docs/README.md +0 -32
  206. package/docs/api.md +0 -51
  207. package/docs/architecture.md +0 -61
  208. package/docs/auth.md +0 -54
  209. package/docs/cli.md +0 -117
  210. package/docs/deploy.md +0 -78
  211. package/docs/diffing.md +0 -84
  212. package/docs/github-import.md +0 -171
  213. package/docs/http-api.md +0 -187
  214. package/docs/manual-testing.md +0 -64
  215. package/docs/mintlify.md +0 -43
  216. package/docs/quickstart.md +0 -120
  217. package/docs/skill-format.md +0 -58
  218. package/docs/soul-format.md +0 -37
  219. package/docs/spec.md +0 -177
  220. package/docs/telemetry.md +0 -91
  221. package/docs/troubleshooting.md +0 -49
  222. package/docs/webhook.md +0 -51
  223. package/e2e/menu-smoke.pw.test.ts +0 -49
  224. package/e2e/pilothub.e2e.test.ts +0 -494
  225. package/e2e/search-exact.pw.test.ts +0 -97
  226. package/packages/pilothub/LICENSE +0 -22
  227. package/packages/pilothub/README.md +0 -57
  228. package/packages/pilothub/package.json +0 -41
  229. package/packages/pilothub/src/browserAuth.test.ts +0 -96
  230. package/packages/pilothub/src/browserAuth.ts +0 -174
  231. package/packages/pilothub/src/cli/buildInfo.ts +0 -94
  232. package/packages/pilothub/src/cli/commands/auth.ts +0 -97
  233. package/packages/pilothub/src/cli/commands/delete.test.ts +0 -73
  234. package/packages/pilothub/src/cli/commands/delete.ts +0 -83
  235. package/packages/pilothub/src/cli/commands/publish.test.ts +0 -122
  236. package/packages/pilothub/src/cli/commands/publish.ts +0 -108
  237. package/packages/pilothub/src/cli/commands/skills.test.ts +0 -191
  238. package/packages/pilothub/src/cli/commands/skills.ts +0 -380
  239. package/packages/pilothub/src/cli/commands/star.ts +0 -46
  240. package/packages/pilothub/src/cli/commands/sync.test.ts +0 -310
  241. package/packages/pilothub/src/cli/commands/sync.ts +0 -200
  242. package/packages/pilothub/src/cli/commands/syncHelpers.test.ts +0 -26
  243. package/packages/pilothub/src/cli/commands/syncHelpers.ts +0 -427
  244. package/packages/pilothub/src/cli/commands/syncTypes.ts +0 -27
  245. package/packages/pilothub/src/cli/commands/unstar.ts +0 -48
  246. package/packages/pilothub/src/cli/helpStyle.ts +0 -45
  247. package/packages/pilothub/src/cli/pilotbotConfig.test.ts +0 -159
  248. package/packages/pilothub/src/cli/pilotbotConfig.ts +0 -147
  249. package/packages/pilothub/src/cli/registry.test.ts +0 -63
  250. package/packages/pilothub/src/cli/registry.ts +0 -43
  251. package/packages/pilothub/src/cli/scanSkills.test.ts +0 -64
  252. package/packages/pilothub/src/cli/scanSkills.ts +0 -84
  253. package/packages/pilothub/src/cli/slug.ts +0 -16
  254. package/packages/pilothub/src/cli/types.ts +0 -12
  255. package/packages/pilothub/src/cli/ui.ts +0 -75
  256. package/packages/pilothub/src/cli.ts +0 -311
  257. package/packages/pilothub/src/config.ts +0 -36
  258. package/packages/pilothub/src/discovery.test.ts +0 -75
  259. package/packages/pilothub/src/discovery.ts +0 -19
  260. package/packages/pilothub/src/http.test.ts +0 -156
  261. package/packages/pilothub/src/http.ts +0 -301
  262. package/packages/pilothub/src/schema/ark.ts +0 -29
  263. package/packages/pilothub/src/schema/index.ts +0 -5
  264. package/packages/pilothub/src/schema/routes.ts +0 -22
  265. package/packages/pilothub/src/schema/schemas.ts +0 -260
  266. package/packages/pilothub/src/schema/textFiles.test.ts +0 -23
  267. package/packages/pilothub/src/schema/textFiles.ts +0 -66
  268. package/packages/pilothub/src/skills.test.ts +0 -191
  269. package/packages/pilothub/src/skills.ts +0 -172
  270. package/packages/pilothub/src/types.ts +0 -10
  271. package/packages/pilothub/tsconfig.json +0 -14
  272. package/packages/schema/README.md +0 -3
  273. package/packages/schema/dist/ark.js.map +0 -1
  274. package/packages/schema/dist/index.js.map +0 -1
  275. package/packages/schema/dist/routes.js.map +0 -1
  276. package/packages/schema/dist/schemas.js.map +0 -1
  277. package/packages/schema/dist/textFiles.js.map +0 -1
  278. package/packages/schema/package.json +0 -26
  279. package/packages/schema/src/ark.ts +0 -29
  280. package/packages/schema/src/index.ts +0 -5
  281. package/packages/schema/src/routes.ts +0 -22
  282. package/packages/schema/src/schemas.test.ts +0 -123
  283. package/packages/schema/src/schemas.ts +0 -287
  284. package/packages/schema/src/textFiles.test.ts +0 -23
  285. package/packages/schema/src/textFiles.ts +0 -66
  286. package/packages/schema/tsconfig.json +0 -15
  287. package/pilothub +0 -46
  288. package/playwright.config.ts +0 -33
  289. package/public/.well-known/pilothub.json +0 -6
  290. package/public/api/v1/openapi.json +0 -379
  291. package/public/favicon.ico +0 -0
  292. package/public/logo192.png +0 -0
  293. package/public/logo512.png +0 -0
  294. package/public/manifest.json +0 -25
  295. package/public/og.png +0 -0
  296. package/public/og.svg +0 -98
  297. package/public/pilot-logo.png +0 -0
  298. package/public/pilot-mark.png +0 -0
  299. package/public/robots.txt +0 -3
  300. package/public/tanstack-circle-logo.png +0 -0
  301. package/public/tanstack-word-logo-white.svg +0 -1
  302. package/scripts/check-peer-deps.ts +0 -56
  303. package/scripts/docs-list.ts +0 -148
  304. package/scripts/run-playwright-local.sh +0 -14
  305. package/server/og/fetchSkillOgMeta.ts +0 -27
  306. package/server/og/fetchSoulOgMeta.ts +0 -27
  307. package/server/og/ogAssets.ts +0 -80
  308. package/server/og/skillOgSvg.test.ts +0 -59
  309. package/server/og/skillOgSvg.ts +0 -258
  310. package/server/og/soulOgSvg.ts +0 -209
  311. package/server/routes/og/skill.png.ts +0 -103
  312. package/server/routes/og/soul.png.ts +0 -111
  313. package/src/__tests__/skill-detail-page.test.tsx +0 -86
  314. package/src/__tests__/skills-index.test.tsx +0 -145
  315. package/src/__tests__/upload.route.test.tsx +0 -228
  316. package/src/components/AppProviders.tsx +0 -19
  317. package/src/components/ClientOnly.tsx +0 -18
  318. package/src/components/Footer.tsx +0 -29
  319. package/src/components/Header.tsx +0 -295
  320. package/src/components/InstallSwitcher.tsx +0 -53
  321. package/src/components/SkillCard.tsx +0 -36
  322. package/src/components/SkillDetailPage.tsx +0 -817
  323. package/src/components/SkillDiffCard.tsx +0 -485
  324. package/src/components/SoulCard.tsx +0 -19
  325. package/src/components/SoulDetailPage.tsx +0 -263
  326. package/src/components/UserBootstrap.tsx +0 -18
  327. package/src/components/ui/dropdown-menu.tsx +0 -67
  328. package/src/components/ui/toggle-group.tsx +0 -35
  329. package/src/convex/client.ts +0 -3
  330. package/src/lib/badges.ts +0 -29
  331. package/src/lib/diffing.test.ts +0 -163
  332. package/src/lib/diffing.ts +0 -106
  333. package/src/lib/gravatar.test.ts +0 -9
  334. package/src/lib/gravatar.ts +0 -158
  335. package/src/lib/og.test.ts +0 -142
  336. package/src/lib/og.ts +0 -156
  337. package/src/lib/publicUser.ts +0 -39
  338. package/src/lib/roles.ts +0 -19
  339. package/src/lib/site.test.ts +0 -130
  340. package/src/lib/site.ts +0 -84
  341. package/src/lib/theme-transition.test.ts +0 -134
  342. package/src/lib/theme-transition.ts +0 -134
  343. package/src/lib/theme.test.tsx +0 -88
  344. package/src/lib/theme.ts +0 -43
  345. package/src/lib/uploadFiles.jsdom.test.ts +0 -33
  346. package/src/lib/uploadFiles.test.ts +0 -123
  347. package/src/lib/uploadFiles.ts +0 -245
  348. package/src/lib/uploadUtils.test.ts +0 -78
  349. package/src/lib/uploadUtils.ts +0 -93
  350. package/src/lib/useAuthStatus.ts +0 -12
  351. package/src/lib/utils.test.ts +0 -9
  352. package/src/lib/utils.ts +0 -6
  353. package/src/logo.svg +0 -12
  354. package/src/routeTree.gen.ts +0 -345
  355. package/src/router.tsx +0 -17
  356. package/src/routes/$owner/$slug.tsx +0 -55
  357. package/src/routes/__root.tsx +0 -136
  358. package/src/routes/admin.tsx +0 -11
  359. package/src/routes/cli/auth.tsx +0 -168
  360. package/src/routes/dashboard.tsx +0 -97
  361. package/src/routes/import.tsx +0 -415
  362. package/src/routes/index.tsx +0 -252
  363. package/src/routes/management.tsx +0 -529
  364. package/src/routes/settings.tsx +0 -203
  365. package/src/routes/skills/index.tsx +0 -422
  366. package/src/routes/souls/$slug.tsx +0 -55
  367. package/src/routes/souls/index.tsx +0 -243
  368. package/src/routes/stars.tsx +0 -68
  369. package/src/routes/u/$handle.tsx +0 -307
  370. package/src/routes/upload/utils.ts +0 -81
  371. package/src/routes/upload.tsx +0 -499
  372. package/src/styles.css +0 -2718
  373. package/tsconfig.json +0 -24
  374. package/tsconfig.oxlint.json +0 -16
  375. package/vercel.json +0 -8
  376. package/vite.config.ts +0 -48
  377. package/vitest.config.ts +0 -47
  378. package/vitest.e2e.config.ts +0 -11
  379. package/vitest.setup.ts +0 -1
  380. /package/{packages/pilothub/bin → bin}/pilothub.js +0 -0
  381. /package/{packages/schema/dist → dist/schema}/ark.d.ts +0 -0
  382. /package/{packages/schema/dist → dist/schema}/ark.js +0 -0
  383. /package/{packages/schema/dist → dist/schema}/index.d.ts +0 -0
  384. /package/{packages/schema/dist → dist/schema}/index.js +0 -0
  385. /package/{packages/schema/dist → dist/schema}/routes.d.ts +0 -0
  386. /package/{packages/schema/dist → dist/schema}/routes.js +0 -0
  387. /package/{packages/schema/dist → dist/schema}/textFiles.d.ts +0 -0
  388. /package/{packages/schema/dist → dist/schema}/textFiles.js +0 -0
@@ -1,273 +0,0 @@
1
- import { internal } from '../_generated/api'
2
- import type { Doc } from '../_generated/dataModel'
3
- import type { ActionCtx } from '../_generated/server'
4
-
5
- const CHANGELOG_MODEL = process.env.OPENAI_CHANGELOG_MODEL ?? 'gpt-4.1'
6
- const MAX_README_CHARS = 8_000
7
- const MAX_PATHS_IN_PROMPT = 30
8
-
9
- type FileMeta = { path: string; sha256?: string }
10
-
11
- type FileDiffSummary = {
12
- added: string[]
13
- removed: string[]
14
- changed: string[]
15
- }
16
-
17
- function clampText(value: string, maxChars: number) {
18
- const trimmed = value.trim()
19
- if (trimmed.length <= maxChars) return trimmed
20
- return `${trimmed.slice(0, maxChars).trimEnd()}\n…`
21
- }
22
-
23
- function summarizeFileDiff(oldFiles: FileMeta[], nextFiles: FileMeta[]): FileDiffSummary {
24
- const oldByPath = new Map(oldFiles.map((f) => [f.path, f] as const))
25
- const nextByPath = new Map(nextFiles.map((f) => [f.path, f] as const))
26
-
27
- const added: string[] = []
28
- const removed: string[] = []
29
- const changed: string[] = []
30
-
31
- for (const [path, file] of nextByPath.entries()) {
32
- const prev = oldByPath.get(path)
33
- if (!prev) {
34
- added.push(path)
35
- continue
36
- }
37
- if (file.sha256 && prev.sha256 && file.sha256 !== prev.sha256) changed.push(path)
38
- }
39
- for (const path of oldByPath.keys()) {
40
- if (!nextByPath.has(path)) removed.push(path)
41
- }
42
-
43
- added.sort()
44
- removed.sort()
45
- changed.sort()
46
- return { added, removed, changed }
47
- }
48
-
49
- function formatDiffSummary(diff: FileDiffSummary) {
50
- const parts: string[] = []
51
- if (diff.added.length) parts.push(`${diff.added.length} added`)
52
- if (diff.changed.length) parts.push(`${diff.changed.length} changed`)
53
- if (diff.removed.length) parts.push(`${diff.removed.length} removed`)
54
- return parts.join(', ') || 'no file changes detected'
55
- }
56
-
57
- function pickPaths(values: string[]) {
58
- if (values.length <= MAX_PATHS_IN_PROMPT) return values
59
- return values.slice(0, MAX_PATHS_IN_PROMPT)
60
- }
61
-
62
- function extractResponseText(payload: unknown) {
63
- if (!payload || typeof payload !== 'object') return null
64
- const output = (payload as { output?: unknown }).output
65
- if (!Array.isArray(output)) return null
66
- const chunks: string[] = []
67
- for (const item of output) {
68
- if (!item || typeof item !== 'object') continue
69
- if ((item as { type?: unknown }).type !== 'message') continue
70
- const content = (item as { content?: unknown }).content
71
- if (!Array.isArray(content)) continue
72
- for (const part of content) {
73
- if (!part || typeof part !== 'object') continue
74
- if ((part as { type?: unknown }).type !== 'output_text') continue
75
- const text = (part as { text?: unknown }).text
76
- if (typeof text === 'string' && text.trim()) chunks.push(text)
77
- }
78
- }
79
- const joined = chunks.join('\n').trim()
80
- return joined || null
81
- }
82
-
83
- async function generateWithOpenAI(args: {
84
- slug: string
85
- version: string
86
- oldReadme: string | null
87
- nextReadme: string
88
- fileDiff: FileDiffSummary | null
89
- }) {
90
- const apiKey = process.env.OPENAI_API_KEY
91
- if (!apiKey) return null
92
-
93
- const oldReadme = args.oldReadme ? clampText(args.oldReadme, MAX_README_CHARS) : ''
94
- const nextReadme = clampText(args.nextReadme, MAX_README_CHARS)
95
-
96
- const fileDiff = args.fileDiff
97
- const diffSummary = fileDiff ? formatDiffSummary(fileDiff) : 'unknown'
98
- const changedPaths = fileDiff ? pickPaths(fileDiff.changed) : []
99
- const addedPaths = fileDiff ? pickPaths(fileDiff.added) : []
100
- const removedPaths = fileDiff ? pickPaths(fileDiff.removed) : []
101
-
102
- const input = [
103
- `Soul: ${args.slug}`,
104
- `Version: ${args.version}`,
105
- `File changes: ${diffSummary}`,
106
- changedPaths.length ? `Changed files (sample): ${changedPaths.join(', ')}` : null,
107
- addedPaths.length ? `Added files (sample): ${addedPaths.join(', ')}` : null,
108
- removedPaths.length ? `Removed files (sample): ${removedPaths.join(', ')}` : null,
109
- oldReadme ? `Previous SOUL.md:\n${oldReadme}` : null,
110
- `New SOUL.md:\n${nextReadme}`,
111
- ]
112
- .filter(Boolean)
113
- .join('\n\n')
114
-
115
- const response = await fetch('https://api.openai.com/v1/responses', {
116
- method: 'POST',
117
- headers: {
118
- 'Content-Type': 'application/json',
119
- Authorization: `Bearer ${apiKey}`,
120
- },
121
- body: JSON.stringify({
122
- model: CHANGELOG_MODEL,
123
- instructions:
124
- 'Write a concise changelog for this soul version. Audience: everyone. Output plain text. Prefer 2–6 bullet points. If it is a big change, include a short 1-line summary first, then bullets. Don’t mention that you are AI. Don’t invent details; only use the inputs.',
125
- input,
126
- max_output_tokens: 220,
127
- }),
128
- })
129
-
130
- if (!response.ok) return null
131
- const payload = (await response.json()) as unknown
132
- return extractResponseText(payload)
133
- }
134
-
135
- function generateFallback(args: {
136
- slug: string
137
- version: string
138
- oldReadme: string | null
139
- nextReadme: string
140
- fileDiff: FileDiffSummary | null
141
- }) {
142
- const lines: string[] = []
143
- if (!args.oldReadme) {
144
- lines.push(`- Initial release.`)
145
- return lines.join('\n')
146
- }
147
-
148
- const diff = args.fileDiff
149
- if (diff) {
150
- const parts: string[] = []
151
- if (diff.added.length) parts.push(`added ${diff.added.length}`)
152
- if (diff.changed.length) parts.push(`updated ${diff.changed.length}`)
153
- if (diff.removed.length) parts.push(`removed ${diff.removed.length}`)
154
- if (parts.length) lines.push(`- ${parts.join(', ')} file(s).`)
155
- }
156
-
157
- lines.push(`- Updated SOUL.md.`)
158
- return lines.join('\n')
159
- }
160
-
161
- export async function generateSoulChangelogForPublish(
162
- ctx: ActionCtx,
163
- args: { slug: string; version: string; readmeText: string; files: FileMeta[] },
164
- ): Promise<string> {
165
- try {
166
- const soul = (await ctx.runQuery(internal.souls.getSoulBySlugInternal, {
167
- slug: args.slug,
168
- })) as Doc<'souls'> | null
169
- const previous: Doc<'soulVersions'> | null =
170
- soul?.latestVersionId && !soul.softDeletedAt
171
- ? ((await ctx.runQuery(internal.souls.getVersionByIdInternal, {
172
- versionId: soul.latestVersionId,
173
- })) as Doc<'soulVersions'> | null)
174
- : null
175
-
176
- const oldReadmeText: string | null = previous
177
- ? await readReadmeFromVersion(ctx, previous)
178
- : null
179
- const oldFiles = previous
180
- ? previous.files.map((file) => ({ path: file.path, sha256: file.sha256 }))
181
- : []
182
- const fileDiff = previous ? summarizeFileDiff(oldFiles, args.files) : null
183
-
184
- const ai = await generateWithOpenAI({
185
- slug: args.slug,
186
- version: args.version,
187
- oldReadme: oldReadmeText,
188
- nextReadme: args.readmeText,
189
- fileDiff,
190
- }).catch(() => null)
191
-
192
- return (
193
- ai ??
194
- generateFallback({
195
- slug: args.slug,
196
- version: args.version,
197
- oldReadme: oldReadmeText,
198
- nextReadme: args.readmeText,
199
- fileDiff,
200
- })
201
- )
202
- } catch {
203
- return '- Updated soul.'
204
- }
205
- }
206
-
207
- export async function generateSoulChangelogPreview(
208
- ctx: ActionCtx,
209
- args: {
210
- slug: string
211
- version: string
212
- readmeText: string
213
- filePaths?: string[]
214
- },
215
- ): Promise<string> {
216
- try {
217
- const soul = (await ctx.runQuery(internal.souls.getSoulBySlugInternal, {
218
- slug: args.slug,
219
- })) as Doc<'souls'> | null
220
- const previous: Doc<'soulVersions'> | null =
221
- soul?.latestVersionId && !soul.softDeletedAt
222
- ? ((await ctx.runQuery(internal.souls.getVersionByIdInternal, {
223
- versionId: soul.latestVersionId,
224
- })) as Doc<'soulVersions'> | null)
225
- : null
226
-
227
- const oldReadmeText: string | null = previous
228
- ? await readReadmeFromVersion(ctx, previous)
229
- : null
230
- const oldPaths = previous ? previous.files.map((file) => file.path) : []
231
- const nextPaths = args.filePaths ?? []
232
- const diff = previous ? summarizeFileDiffFromPaths(oldPaths, nextPaths) : null
233
-
234
- const ai = await generateWithOpenAI({
235
- slug: args.slug,
236
- version: args.version,
237
- oldReadme: oldReadmeText,
238
- nextReadme: args.readmeText,
239
- fileDiff: diff,
240
- }).catch(() => null)
241
-
242
- return (
243
- ai ??
244
- generateFallback({
245
- slug: args.slug,
246
- version: args.version,
247
- oldReadme: oldReadmeText,
248
- nextReadme: args.readmeText,
249
- fileDiff: diff,
250
- })
251
- )
252
- } catch {
253
- return '- Updated soul.'
254
- }
255
- }
256
-
257
- async function readReadmeFromVersion(ctx: ActionCtx, version: Doc<'soulVersions'>) {
258
- const file = version.files.find((entry) => entry.path.toLowerCase() === 'soul.md')
259
- if (!file) return null
260
- const blob = await ctx.storage.get(file.storageId)
261
- if (!blob) return null
262
- return blob.text()
263
- }
264
-
265
- function summarizeFileDiffFromPaths(oldPaths: string[], nextPaths: string[]) {
266
- const oldFiles = oldPaths.map((path) => ({ path }))
267
- const nextFiles = nextPaths.map((path) => ({ path }))
268
- return summarizeFileDiff(oldFiles, nextFiles)
269
- }
270
-
271
- export const __test = {
272
- summarizeFileDiff,
273
- }
@@ -1,236 +0,0 @@
1
- import { ConvexError } from 'convex/values'
2
- import semver from 'semver'
3
- import { internal } from '../_generated/api'
4
- import type { Doc, Id } from '../_generated/dataModel'
5
- import type { ActionCtx } from '../_generated/server'
6
- import { generateEmbedding } from './embeddings'
7
- import {
8
- buildEmbeddingText,
9
- getFrontmatterMetadata,
10
- getFrontmatterValue,
11
- hashSkillFiles,
12
- isTextFile,
13
- parseFrontmatter,
14
- sanitizePath,
15
- } from './skills'
16
- import { generateSoulChangelogForPublish } from './soulChangelog'
17
-
18
- const MAX_TOTAL_BYTES = 50 * 1024 * 1024
19
-
20
- const MAX_SUMMARY_LENGTH = 160
21
-
22
- function deriveSoulSummary(readmeText: string) {
23
- const lines = readmeText.split(/\r?\n/)
24
- let inFrontmatter = false
25
- for (const raw of lines) {
26
- const trimmed = raw.trim()
27
- if (!trimmed) continue
28
- if (!inFrontmatter && trimmed === '---') {
29
- inFrontmatter = true
30
- continue
31
- }
32
- if (inFrontmatter) {
33
- if (trimmed === '---') {
34
- inFrontmatter = false
35
- }
36
- continue
37
- }
38
- const cleaned = trimmed.replace(/^#+\s*/, '')
39
- if (!cleaned) continue
40
- if (cleaned.length > MAX_SUMMARY_LENGTH) {
41
- return `${cleaned.slice(0, MAX_SUMMARY_LENGTH - 3).trimEnd()}...`
42
- }
43
- return cleaned
44
- }
45
- return undefined
46
- }
47
-
48
- export type PublishResult = {
49
- soulId: Id<'souls'>
50
- versionId: Id<'soulVersions'>
51
- embeddingId: Id<'soulEmbeddings'>
52
- }
53
-
54
- export type PublishVersionArgs = {
55
- slug: string
56
- displayName: string
57
- version: string
58
- changelog: string
59
- tags?: string[]
60
- source?: {
61
- kind: 'github'
62
- url: string
63
- repo: string
64
- ref: string
65
- commit: string
66
- path: string
67
- importedAt: number
68
- }
69
- files: Array<{
70
- path: string
71
- size: number
72
- storageId: Id<'_storage'>
73
- sha256: string
74
- contentType?: string
75
- }>
76
- }
77
-
78
- export async function publishSoulVersionForUser(
79
- ctx: ActionCtx,
80
- userId: Id<'users'>,
81
- args: PublishVersionArgs,
82
- ): Promise<PublishResult> {
83
- const version = args.version.trim()
84
- const slug = args.slug.trim().toLowerCase()
85
- const displayName = args.displayName.trim()
86
- if (!slug || !displayName) throw new ConvexError('Slug and display name required')
87
- if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
88
- throw new ConvexError('Slug must be lowercase and url-safe')
89
- }
90
- if (!semver.valid(version)) {
91
- throw new ConvexError('Version must be valid semver')
92
- }
93
- const suppliedChangelog = args.changelog.trim()
94
- const changelogSource = suppliedChangelog ? ('user' as const) : ('auto' as const)
95
-
96
- const sanitizedFiles = args.files.map((file) => {
97
- const path = sanitizePath(file.path)
98
- if (!path) throw new ConvexError('Invalid file paths')
99
- if (!isTextFile(path, file.contentType ?? undefined)) {
100
- throw new ConvexError('Only text-based files are allowed')
101
- }
102
- return { ...file, path }
103
- })
104
-
105
- const totalBytes = sanitizedFiles.reduce((sum, file) => sum + file.size, 0)
106
- if (totalBytes > MAX_TOTAL_BYTES) {
107
- throw new ConvexError('Soul bundle exceeds 50MB limit')
108
- }
109
-
110
- const isSoulFile = (path: string) => path.toLowerCase() === 'soul.md'
111
- const readmeFile = sanitizedFiles.find((file) => isSoulFile(file.path))
112
- if (!readmeFile) throw new ConvexError('SOUL.md is required')
113
-
114
- const nonSoulFiles = sanitizedFiles.filter((file) => !isSoulFile(file.path))
115
- if (nonSoulFiles.length > 0) {
116
- throw new ConvexError('Only SOUL.md is allowed for soul bundles')
117
- }
118
-
119
- const readmeText = await fetchText(ctx, readmeFile.storageId)
120
- const frontmatter = parseFrontmatter(readmeText)
121
- const summary = getFrontmatterValue(frontmatter, 'description') ?? deriveSoulSummary(readmeText)
122
- const metadata = mergeSourceIntoMetadata(getFrontmatterMetadata(frontmatter), args.source)
123
-
124
- const embeddingText = buildEmbeddingText({
125
- frontmatter,
126
- readme: readmeText,
127
- otherFiles: [],
128
- })
129
-
130
- const fingerprint = await hashSkillFiles(
131
- sanitizedFiles.map((file) => ({
132
- path: file.path ?? '',
133
- sha256: file.sha256,
134
- })),
135
- )
136
-
137
- const changelogPromise =
138
- changelogSource === 'user'
139
- ? Promise.resolve(suppliedChangelog)
140
- : generateSoulChangelogForPublish(ctx, {
141
- slug,
142
- version,
143
- readmeText,
144
- files: sanitizedFiles.map((file) => ({ path: file.path ?? '', sha256: file.sha256 })),
145
- })
146
-
147
- const embeddingPromise = generateEmbedding(embeddingText)
148
-
149
- const [changelogText, embedding] = await Promise.all([
150
- changelogPromise,
151
- embeddingPromise.catch((error) => {
152
- throw new ConvexError(formatEmbeddingError(error))
153
- }),
154
- ])
155
-
156
- const publishResult = (await ctx.runMutation(internal.souls.insertVersion, {
157
- userId,
158
- slug,
159
- displayName,
160
- version,
161
- changelog: changelogText,
162
- changelogSource,
163
- tags: args.tags?.map((tag) => tag.trim()).filter(Boolean),
164
- fingerprint,
165
- files: sanitizedFiles,
166
- parsed: {
167
- frontmatter,
168
- metadata,
169
- },
170
- summary,
171
- embedding,
172
- })) as PublishResult
173
-
174
- const owner = (await ctx.runQuery(internal.users.getByIdInternal, {
175
- userId,
176
- })) as Doc<'users'> | null
177
- const ownerHandle = owner?.handle ?? owner?.name ?? userId
178
-
179
- void ctx.scheduler
180
- .runAfter(0, internal.githubSoulBackupsNode.backupSoulForPublishInternal, {
181
- slug,
182
- version,
183
- displayName,
184
- ownerHandle,
185
- files: sanitizedFiles,
186
- publishedAt: Date.now(),
187
- })
188
- .catch((error) => {
189
- console.error('GitHub soul backup scheduling failed', error)
190
- })
191
-
192
- return publishResult
193
- }
194
-
195
- function mergeSourceIntoMetadata(metadata: unknown, source: PublishVersionArgs['source']) {
196
- if (!source) return metadata === undefined ? undefined : metadata
197
- const sourceValue = {
198
- kind: source.kind,
199
- url: source.url,
200
- repo: source.repo,
201
- ref: source.ref,
202
- commit: source.commit,
203
- path: source.path,
204
- importedAt: source.importedAt,
205
- }
206
-
207
- if (!metadata) return { source: sourceValue }
208
- if (typeof metadata !== 'object' || Array.isArray(metadata)) return { source: sourceValue }
209
- return { ...(metadata as Record<string, unknown>), source: sourceValue }
210
- }
211
-
212
- export async function fetchText(
213
- ctx: { storage: { get: (id: Id<'_storage'>) => Promise<Blob | null> } },
214
- storageId: Id<'_storage'>,
215
- ) {
216
- const blob = await ctx.storage.get(storageId)
217
- if (!blob) throw new Error('File missing in storage')
218
- return blob.text()
219
- }
220
-
221
- function formatEmbeddingError(error: unknown) {
222
- if (error instanceof Error) {
223
- if (error.message.includes('OPENAI_API_KEY')) {
224
- return 'OPENAI_API_KEY is not configured.'
225
- }
226
- if (error.message.startsWith('Embedding failed')) {
227
- return error.message
228
- }
229
- }
230
- return 'Embedding failed. Please try again.'
231
- }
232
-
233
- export const __test = {
234
- getSummary: (frontmatter: Record<string, unknown>) =>
235
- getFrontmatterValue(frontmatter, 'description'),
236
- }
@@ -1,33 +0,0 @@
1
- /* @vitest-environment node */
2
-
3
- import { describe, expect, it } from 'vitest'
4
- import { __test, generateToken, hashToken } from './tokens'
5
-
6
- describe('tokens', () => {
7
- it('hashToken returns sha256 hex', async () => {
8
- await expect(hashToken('test')).resolves.toBe(
9
- '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
10
- )
11
- })
12
-
13
- it('generateToken returns token + prefix', () => {
14
- const { token, prefix } = generateToken()
15
- expect(token).toMatch(/^clh_[A-Za-z0-9_-]+$/)
16
- expect(prefix).toBe(token.slice(0, 12))
17
- })
18
-
19
- it('toHex encodes bytes', () => {
20
- expect(__test.toHex(new Uint8Array([0, 15, 255]))).toBe('000fff')
21
- })
22
-
23
- it('toBase64 encodes 1/2/3-byte tails', () => {
24
- expect(__test.toBase64(new Uint8Array([0xff]))).toBe('/w==')
25
- expect(__test.toBase64(new Uint8Array([0xff, 0xee]))).toBe('/+4=')
26
- expect(__test.toBase64(new Uint8Array([0xff, 0xee, 0xdd]))).toBe('/+7d')
27
- })
28
-
29
- it('toBase64Url replaces alphabet and strips padding', () => {
30
- expect(__test.toBase64Url(new Uint8Array([0xff]))).toBe('_w')
31
- expect(__test.toBase64Url(new Uint8Array([0xfa, 0x00, 0x00]))).toBe('-gAA')
32
- })
33
- })
@@ -1,51 +0,0 @@
1
- const encoder = new TextEncoder()
2
-
3
- export const API_TOKEN_PREFIX = 'clh_'
4
-
5
- export async function hashToken(token: string) {
6
- const bytes = encoder.encode(token)
7
- const digest = await crypto.subtle.digest('SHA-256', bytes)
8
- return toHex(new Uint8Array(digest))
9
- }
10
-
11
- export function generateToken() {
12
- const bytes = new Uint8Array(32)
13
- crypto.getRandomValues(bytes)
14
- const token = `${API_TOKEN_PREFIX}${toBase64Url(bytes)}`
15
- const prefix = token.slice(0, 12)
16
- return { token, prefix }
17
- }
18
-
19
- function toHex(bytes: Uint8Array) {
20
- let out = ''
21
- for (const byte of bytes) out += byte.toString(16).padStart(2, '0')
22
- return out
23
- }
24
-
25
- function toBase64Url(bytes: Uint8Array) {
26
- const base64 = toBase64(bytes)
27
- return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
28
- }
29
-
30
- const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
31
-
32
- function toBase64(bytes: Uint8Array) {
33
- let output = ''
34
- for (let i = 0; i < bytes.length; i += 3) {
35
- const a = bytes[i] ?? 0
36
- const b = bytes[i + 1] ?? 0
37
- const c = bytes[i + 2] ?? 0
38
- const triple = (a << 16) | (b << 8) | c
39
- output += BASE64_ALPHABET[(triple >> 18) & 63]
40
- output += BASE64_ALPHABET[(triple >> 12) & 63]
41
- output += i + 1 < bytes.length ? BASE64_ALPHABET[(triple >> 6) & 63] : '='
42
- output += i + 2 < bytes.length ? BASE64_ALPHABET[triple & 63] : '='
43
- }
44
- return output
45
- }
46
-
47
- export const __test = {
48
- toHex,
49
- toBase64,
50
- toBase64Url,
51
- }
@@ -1,91 +0,0 @@
1
- /* @vitest-environment node */
2
- import { afterEach, describe, expect, it } from 'vitest'
3
- import { buildDiscordPayload, buildSkillUrl, getWebhookConfig, shouldSendWebhook } from './webhooks'
4
-
5
- const originalEnv = { ...process.env }
6
-
7
- afterEach(() => {
8
- process.env = { ...originalEnv }
9
- })
10
-
11
- describe('webhook config', () => {
12
- it('parses highlighted-only flag', () => {
13
- process.env.DISCORD_WEBHOOK_URL = 'https://example.com'
14
- process.env.DISCORD_WEBHOOK_HIGHLIGHTED_ONLY = 'true'
15
- const config = getWebhookConfig()
16
- expect(config.highlightedOnly).toBe(true)
17
- })
18
-
19
- it('defaults site url when missing', () => {
20
- delete process.env.SITE_URL
21
- process.env.DISCORD_WEBHOOK_URL = 'https://example.com'
22
- const config = getWebhookConfig()
23
- expect(config.siteUrl).toBe('https://pilothub.com')
24
- })
25
- })
26
-
27
- describe('webhook filtering', () => {
28
- it('skips when url missing', () => {
29
- const config = getWebhookConfig({} as NodeJS.ProcessEnv)
30
- expect(shouldSendWebhook('skill.publish', { slug: 'demo', displayName: 'Demo' }, config)).toBe(
31
- false,
32
- )
33
- })
34
-
35
- it('filters non-highlighted when highlighted-only', () => {
36
- const config = {
37
- url: 'https://example.com',
38
- highlightedOnly: true,
39
- siteUrl: 'https://pilothub.com',
40
- }
41
- const allowed = shouldSendWebhook(
42
- 'skill.publish',
43
- { slug: 'demo', displayName: 'Demo', highlighted: false },
44
- config,
45
- )
46
- expect(allowed).toBe(false)
47
- })
48
-
49
- it('allows highlighted event when highlighted-only', () => {
50
- const config = {
51
- url: 'https://example.com',
52
- highlightedOnly: true,
53
- siteUrl: 'https://pilothub.com',
54
- }
55
- const allowed = shouldSendWebhook(
56
- 'skill.highlighted',
57
- { slug: 'demo', displayName: 'Demo', highlighted: true },
58
- config,
59
- )
60
- expect(allowed).toBe(true)
61
- })
62
- })
63
-
64
- describe('payload building', () => {
65
- it('builds canonical url with owner', () => {
66
- const url = buildSkillUrl(
67
- { slug: 'beeper', displayName: 'Beeper', ownerHandle: 'KrauseFx' },
68
- 'https://pilothub.com',
69
- )
70
- expect(url).toBe('https://pilothub.com/KrauseFx/beeper')
71
- })
72
-
73
- it('builds a publish embed', () => {
74
- const payload = buildDiscordPayload(
75
- 'skill.publish',
76
- {
77
- slug: 'demo',
78
- displayName: 'Demo Skill',
79
- summary: 'Nice skill',
80
- version: '1.2.3',
81
- ownerHandle: 'steipete',
82
- tags: ['latest', 'discord'],
83
- },
84
- { url: 'https://example.com', highlightedOnly: false, siteUrl: 'https://pilothub.com' },
85
- )
86
- const embed = payload.embeds[0]
87
- expect(embed.title).toBe('Demo Skill')
88
- expect(embed.description).toBe('Nice skill')
89
- expect(embed.fields[0].value).toBe('v1.2.3')
90
- })
91
- })