pilothub 0.0.1

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 (272) hide show
  1. package/.env.local.example +19 -0
  2. package/.github/workflows/ci.yml +40 -0
  3. package/.oxlintrc.json +3 -0
  4. package/AGENTS.md +45 -0
  5. package/CHANGELOG.md +138 -0
  6. package/DEPRECATIONS.md +7 -0
  7. package/LICENSE +21 -0
  8. package/README.md +150 -0
  9. package/biome.json +41 -0
  10. package/convex/_generated/api.d.ts +153 -0
  11. package/convex/_generated/api.js +23 -0
  12. package/convex/_generated/dataModel.d.ts +60 -0
  13. package/convex/_generated/server.d.ts +143 -0
  14. package/convex/_generated/server.js +93 -0
  15. package/convex/auth.config.ts +8 -0
  16. package/convex/auth.ts +19 -0
  17. package/convex/comments.ts +88 -0
  18. package/convex/crons.ts +34 -0
  19. package/convex/devSeed.ts +459 -0
  20. package/convex/devSeedExtra.ts +541 -0
  21. package/convex/downloads.ts +78 -0
  22. package/convex/githubBackups.ts +170 -0
  23. package/convex/githubBackupsNode.ts +183 -0
  24. package/convex/githubImport.ts +317 -0
  25. package/convex/githubSoulBackups.ts +170 -0
  26. package/convex/githubSoulBackupsNode.ts +186 -0
  27. package/convex/http.ts +194 -0
  28. package/convex/httpApi.handlers.test.ts +488 -0
  29. package/convex/httpApi.test.ts +70 -0
  30. package/convex/httpApi.ts +305 -0
  31. package/convex/httpApiV1.handlers.test.ts +584 -0
  32. package/convex/httpApiV1.ts +1172 -0
  33. package/convex/leaderboards.ts +39 -0
  34. package/convex/lib/access.ts +36 -0
  35. package/convex/lib/apiTokenAuth.ts +36 -0
  36. package/convex/lib/badges.ts +50 -0
  37. package/convex/lib/changelog.test.ts +34 -0
  38. package/convex/lib/changelog.ts +278 -0
  39. package/convex/lib/embeddings.ts +38 -0
  40. package/convex/lib/githubBackup.ts +443 -0
  41. package/convex/lib/githubImport.test.ts +247 -0
  42. package/convex/lib/githubImport.ts +425 -0
  43. package/convex/lib/githubSoulBackup.ts +443 -0
  44. package/convex/lib/leaderboards.ts +103 -0
  45. package/convex/lib/moderation.ts +42 -0
  46. package/convex/lib/public.ts +89 -0
  47. package/convex/lib/searchText.test.ts +46 -0
  48. package/convex/lib/searchText.ts +27 -0
  49. package/convex/lib/skillBackfill.test.ts +34 -0
  50. package/convex/lib/skillBackfill.ts +67 -0
  51. package/convex/lib/skillPublish.test.ts +28 -0
  52. package/convex/lib/skillPublish.ts +284 -0
  53. package/convex/lib/skillStats.ts +80 -0
  54. package/convex/lib/skills.test.ts +197 -0
  55. package/convex/lib/skills.ts +273 -0
  56. package/convex/lib/soulChangelog.ts +273 -0
  57. package/convex/lib/soulPublish.ts +236 -0
  58. package/convex/lib/tokens.test.ts +33 -0
  59. package/convex/lib/tokens.ts +51 -0
  60. package/convex/lib/webhooks.test.ts +91 -0
  61. package/convex/lib/webhooks.ts +112 -0
  62. package/convex/maintenance.test.ts +270 -0
  63. package/convex/maintenance.ts +840 -0
  64. package/convex/rateLimits.ts +50 -0
  65. package/convex/schema.ts +472 -0
  66. package/convex/search.test.ts +12 -0
  67. package/convex/search.ts +254 -0
  68. package/convex/seed.test.ts +37 -0
  69. package/convex/seed.ts +254 -0
  70. package/convex/seedSouls.ts +111 -0
  71. package/convex/skillStatEvents.ts +568 -0
  72. package/convex/skills.ts +1606 -0
  73. package/convex/soulComments.ts +88 -0
  74. package/convex/soulDownloads.ts +14 -0
  75. package/convex/soulStars.ts +71 -0
  76. package/convex/souls.ts +570 -0
  77. package/convex/stars.ts +108 -0
  78. package/convex/statsMaintenance.ts +205 -0
  79. package/convex/telemetry.ts +434 -0
  80. package/convex/tokens.ts +88 -0
  81. package/convex/tsconfig.json +7 -0
  82. package/convex/uploads.ts +20 -0
  83. package/convex/users.ts +122 -0
  84. package/convex/webhooks.ts +50 -0
  85. package/convex.json +3 -0
  86. package/docs/README.md +32 -0
  87. package/docs/api.md +51 -0
  88. package/docs/architecture.md +61 -0
  89. package/docs/auth.md +54 -0
  90. package/docs/cli.md +117 -0
  91. package/docs/deploy.md +78 -0
  92. package/docs/diffing.md +84 -0
  93. package/docs/github-import.md +171 -0
  94. package/docs/http-api.md +187 -0
  95. package/docs/manual-testing.md +64 -0
  96. package/docs/mintlify.md +43 -0
  97. package/docs/quickstart.md +120 -0
  98. package/docs/skill-format.md +58 -0
  99. package/docs/soul-format.md +37 -0
  100. package/docs/spec.md +177 -0
  101. package/docs/telemetry.md +91 -0
  102. package/docs/troubleshooting.md +49 -0
  103. package/docs/webhook.md +51 -0
  104. package/e2e/menu-smoke.pw.test.ts +49 -0
  105. package/e2e/pilothub.e2e.test.ts +494 -0
  106. package/e2e/search-exact.pw.test.ts +97 -0
  107. package/package.json +84 -0
  108. package/packages/pilothub/LICENSE +22 -0
  109. package/packages/pilothub/README.md +57 -0
  110. package/packages/pilothub/bin/pilothub.js +2 -0
  111. package/packages/pilothub/package.json +41 -0
  112. package/packages/pilothub/src/browserAuth.test.ts +96 -0
  113. package/packages/pilothub/src/browserAuth.ts +174 -0
  114. package/packages/pilothub/src/cli/buildInfo.ts +94 -0
  115. package/packages/pilothub/src/cli/commands/auth.ts +97 -0
  116. package/packages/pilothub/src/cli/commands/delete.test.ts +73 -0
  117. package/packages/pilothub/src/cli/commands/delete.ts +83 -0
  118. package/packages/pilothub/src/cli/commands/publish.test.ts +122 -0
  119. package/packages/pilothub/src/cli/commands/publish.ts +108 -0
  120. package/packages/pilothub/src/cli/commands/skills.test.ts +191 -0
  121. package/packages/pilothub/src/cli/commands/skills.ts +380 -0
  122. package/packages/pilothub/src/cli/commands/star.ts +46 -0
  123. package/packages/pilothub/src/cli/commands/sync.test.ts +310 -0
  124. package/packages/pilothub/src/cli/commands/sync.ts +200 -0
  125. package/packages/pilothub/src/cli/commands/syncHelpers.test.ts +26 -0
  126. package/packages/pilothub/src/cli/commands/syncHelpers.ts +427 -0
  127. package/packages/pilothub/src/cli/commands/syncTypes.ts +27 -0
  128. package/packages/pilothub/src/cli/commands/unstar.ts +48 -0
  129. package/packages/pilothub/src/cli/helpStyle.ts +45 -0
  130. package/packages/pilothub/src/cli/pilotbotConfig.test.ts +159 -0
  131. package/packages/pilothub/src/cli/pilotbotConfig.ts +147 -0
  132. package/packages/pilothub/src/cli/registry.test.ts +63 -0
  133. package/packages/pilothub/src/cli/registry.ts +43 -0
  134. package/packages/pilothub/src/cli/scanSkills.test.ts +64 -0
  135. package/packages/pilothub/src/cli/scanSkills.ts +84 -0
  136. package/packages/pilothub/src/cli/slug.ts +16 -0
  137. package/packages/pilothub/src/cli/types.ts +12 -0
  138. package/packages/pilothub/src/cli/ui.ts +75 -0
  139. package/packages/pilothub/src/cli.ts +311 -0
  140. package/packages/pilothub/src/config.ts +36 -0
  141. package/packages/pilothub/src/discovery.test.ts +75 -0
  142. package/packages/pilothub/src/discovery.ts +19 -0
  143. package/packages/pilothub/src/http.test.ts +156 -0
  144. package/packages/pilothub/src/http.ts +301 -0
  145. package/packages/pilothub/src/schema/ark.ts +29 -0
  146. package/packages/pilothub/src/schema/index.ts +5 -0
  147. package/packages/pilothub/src/schema/routes.ts +22 -0
  148. package/packages/pilothub/src/schema/schemas.ts +260 -0
  149. package/packages/pilothub/src/schema/textFiles.test.ts +23 -0
  150. package/packages/pilothub/src/schema/textFiles.ts +66 -0
  151. package/packages/pilothub/src/skills.test.ts +191 -0
  152. package/packages/pilothub/src/skills.ts +172 -0
  153. package/packages/pilothub/src/types.ts +10 -0
  154. package/packages/pilothub/tsconfig.json +14 -0
  155. package/packages/schema/README.md +3 -0
  156. package/packages/schema/dist/ark.d.ts +4 -0
  157. package/packages/schema/dist/ark.js +26 -0
  158. package/packages/schema/dist/ark.js.map +1 -0
  159. package/packages/schema/dist/index.d.ts +5 -0
  160. package/packages/schema/dist/index.js +5 -0
  161. package/packages/schema/dist/index.js.map +1 -0
  162. package/packages/schema/dist/routes.d.ts +21 -0
  163. package/packages/schema/dist/routes.js +22 -0
  164. package/packages/schema/dist/routes.js.map +1 -0
  165. package/packages/schema/dist/schemas.d.ts +297 -0
  166. package/packages/schema/dist/schemas.js +243 -0
  167. package/packages/schema/dist/schemas.js.map +1 -0
  168. package/packages/schema/dist/textFiles.d.ts +5 -0
  169. package/packages/schema/dist/textFiles.js +66 -0
  170. package/packages/schema/dist/textFiles.js.map +1 -0
  171. package/packages/schema/package.json +26 -0
  172. package/packages/schema/src/ark.ts +29 -0
  173. package/packages/schema/src/index.ts +5 -0
  174. package/packages/schema/src/routes.ts +22 -0
  175. package/packages/schema/src/schemas.test.ts +123 -0
  176. package/packages/schema/src/schemas.ts +287 -0
  177. package/packages/schema/src/textFiles.test.ts +23 -0
  178. package/packages/schema/src/textFiles.ts +66 -0
  179. package/packages/schema/tsconfig.json +15 -0
  180. package/pilothub +46 -0
  181. package/playwright.config.ts +33 -0
  182. package/public/.well-known/pilothub.json +6 -0
  183. package/public/api/v1/openapi.json +379 -0
  184. package/public/favicon.ico +0 -0
  185. package/public/logo192.png +0 -0
  186. package/public/logo512.png +0 -0
  187. package/public/manifest.json +25 -0
  188. package/public/og.png +0 -0
  189. package/public/og.svg +98 -0
  190. package/public/pilot-logo.png +0 -0
  191. package/public/pilot-mark.png +0 -0
  192. package/public/robots.txt +3 -0
  193. package/public/tanstack-circle-logo.png +0 -0
  194. package/public/tanstack-word-logo-white.svg +1 -0
  195. package/scripts/check-peer-deps.ts +56 -0
  196. package/scripts/docs-list.ts +148 -0
  197. package/scripts/run-playwright-local.sh +14 -0
  198. package/server/og/fetchSkillOgMeta.ts +27 -0
  199. package/server/og/fetchSoulOgMeta.ts +27 -0
  200. package/server/og/ogAssets.ts +80 -0
  201. package/server/og/skillOgSvg.test.ts +59 -0
  202. package/server/og/skillOgSvg.ts +258 -0
  203. package/server/og/soulOgSvg.ts +209 -0
  204. package/server/routes/og/skill.png.ts +103 -0
  205. package/server/routes/og/soul.png.ts +111 -0
  206. package/src/__tests__/skill-detail-page.test.tsx +86 -0
  207. package/src/__tests__/skills-index.test.tsx +145 -0
  208. package/src/__tests__/upload.route.test.tsx +228 -0
  209. package/src/components/AppProviders.tsx +19 -0
  210. package/src/components/ClientOnly.tsx +18 -0
  211. package/src/components/Footer.tsx +29 -0
  212. package/src/components/Header.tsx +295 -0
  213. package/src/components/InstallSwitcher.tsx +53 -0
  214. package/src/components/SkillCard.tsx +36 -0
  215. package/src/components/SkillDetailPage.tsx +817 -0
  216. package/src/components/SkillDiffCard.tsx +485 -0
  217. package/src/components/SoulCard.tsx +19 -0
  218. package/src/components/SoulDetailPage.tsx +263 -0
  219. package/src/components/UserBootstrap.tsx +18 -0
  220. package/src/components/ui/dropdown-menu.tsx +67 -0
  221. package/src/components/ui/toggle-group.tsx +35 -0
  222. package/src/convex/client.ts +3 -0
  223. package/src/lib/badges.ts +29 -0
  224. package/src/lib/diffing.test.ts +163 -0
  225. package/src/lib/diffing.ts +106 -0
  226. package/src/lib/gravatar.test.ts +9 -0
  227. package/src/lib/gravatar.ts +158 -0
  228. package/src/lib/og.test.ts +142 -0
  229. package/src/lib/og.ts +156 -0
  230. package/src/lib/publicUser.ts +39 -0
  231. package/src/lib/roles.ts +19 -0
  232. package/src/lib/site.test.ts +130 -0
  233. package/src/lib/site.ts +84 -0
  234. package/src/lib/theme-transition.test.ts +134 -0
  235. package/src/lib/theme-transition.ts +134 -0
  236. package/src/lib/theme.test.tsx +88 -0
  237. package/src/lib/theme.ts +43 -0
  238. package/src/lib/uploadFiles.jsdom.test.ts +33 -0
  239. package/src/lib/uploadFiles.test.ts +123 -0
  240. package/src/lib/uploadFiles.ts +245 -0
  241. package/src/lib/uploadUtils.test.ts +78 -0
  242. package/src/lib/uploadUtils.ts +93 -0
  243. package/src/lib/useAuthStatus.ts +12 -0
  244. package/src/lib/utils.test.ts +9 -0
  245. package/src/lib/utils.ts +6 -0
  246. package/src/logo.svg +12 -0
  247. package/src/routeTree.gen.ts +345 -0
  248. package/src/router.tsx +17 -0
  249. package/src/routes/$owner/$slug.tsx +55 -0
  250. package/src/routes/__root.tsx +136 -0
  251. package/src/routes/admin.tsx +11 -0
  252. package/src/routes/cli/auth.tsx +168 -0
  253. package/src/routes/dashboard.tsx +97 -0
  254. package/src/routes/import.tsx +415 -0
  255. package/src/routes/index.tsx +252 -0
  256. package/src/routes/management.tsx +529 -0
  257. package/src/routes/settings.tsx +203 -0
  258. package/src/routes/skills/index.tsx +422 -0
  259. package/src/routes/souls/$slug.tsx +55 -0
  260. package/src/routes/souls/index.tsx +243 -0
  261. package/src/routes/stars.tsx +68 -0
  262. package/src/routes/u/$handle.tsx +307 -0
  263. package/src/routes/upload/utils.ts +81 -0
  264. package/src/routes/upload.tsx +499 -0
  265. package/src/styles.css +2718 -0
  266. package/tsconfig.json +24 -0
  267. package/tsconfig.oxlint.json +16 -0
  268. package/vercel.json +8 -0
  269. package/vite.config.ts +48 -0
  270. package/vitest.config.ts +47 -0
  271. package/vitest.e2e.config.ts +11 -0
  272. package/vitest.setup.ts +1 -0
@@ -0,0 +1,39 @@
1
+ import { v } from 'convex/values'
2
+ import { internalMutation } from './_generated/server'
3
+ import { buildTrendingLeaderboard } from './lib/leaderboards'
4
+
5
+ const MAX_TRENDING_LIMIT = 200
6
+ const KEEP_LEADERBOARD_ENTRIES = 3
7
+
8
+ export const rebuildTrendingLeaderboardInternal = internalMutation({
9
+ args: { limit: v.optional(v.number()) },
10
+ handler: async (ctx, args) => {
11
+ const limit = clampInt(args.limit ?? MAX_TRENDING_LIMIT, 1, MAX_TRENDING_LIMIT)
12
+ const now = Date.now()
13
+ const { startDay, endDay, items } = await buildTrendingLeaderboard(ctx, { limit, now })
14
+
15
+ await ctx.db.insert('skillLeaderboards', {
16
+ kind: 'trending',
17
+ generatedAt: now,
18
+ rangeStartDay: startDay,
19
+ rangeEndDay: endDay,
20
+ items,
21
+ })
22
+
23
+ const recent = await ctx.db
24
+ .query('skillLeaderboards')
25
+ .withIndex('by_kind', (q) => q.eq('kind', 'trending'))
26
+ .order('desc')
27
+ .take(KEEP_LEADERBOARD_ENTRIES + 5)
28
+
29
+ for (const entry of recent.slice(KEEP_LEADERBOARD_ENTRIES)) {
30
+ await ctx.db.delete(entry._id)
31
+ }
32
+
33
+ return { ok: true as const, count: items.length }
34
+ },
35
+ })
36
+
37
+ function clampInt(value: number, min: number, max: number) {
38
+ return Math.min(Math.max(value, min), max)
39
+ }
@@ -0,0 +1,36 @@
1
+ import { getAuthUserId } from '@convex-dev/auth/server'
2
+ import { internal } from '../_generated/api'
3
+ import type { Doc } from '../_generated/dataModel'
4
+ import type { ActionCtx, MutationCtx, QueryCtx } from '../_generated/server'
5
+
6
+ export type Role = 'admin' | 'moderator' | 'user'
7
+
8
+ export async function requireUser(ctx: MutationCtx | QueryCtx) {
9
+ const userId = await getAuthUserId(ctx)
10
+ if (!userId) throw new Error('Unauthorized')
11
+ const user = await ctx.db.get(userId)
12
+ if (!user || user.deletedAt) throw new Error('User not found')
13
+ return { userId, user }
14
+ }
15
+
16
+ export async function requireUserFromAction(ctx: ActionCtx) {
17
+ const userId = await getAuthUserId(ctx)
18
+ if (!userId) throw new Error('Unauthorized')
19
+ const user = await ctx.runQuery(internal.users.getByIdInternal, { userId })
20
+ if (!user || user.deletedAt) throw new Error('User not found')
21
+ return { userId, user: user as Doc<'users'> }
22
+ }
23
+
24
+ export function assertRole(user: Doc<'users'>, allowed: Role[]) {
25
+ if (!user.role || !allowed.includes(user.role as Role)) {
26
+ throw new Error('Forbidden')
27
+ }
28
+ }
29
+
30
+ export function assertAdmin(user: Doc<'users'>) {
31
+ assertRole(user, ['admin'])
32
+ }
33
+
34
+ export function assertModerator(user: Doc<'users'>) {
35
+ assertRole(user, ['admin', 'moderator'])
36
+ }
@@ -0,0 +1,36 @@
1
+ import { ConvexError } from 'convex/values'
2
+ import { internal } from '../_generated/api'
3
+ import type { Doc } from '../_generated/dataModel'
4
+ import type { ActionCtx } from '../_generated/server'
5
+ import { hashToken } from './tokens'
6
+
7
+ type TokenAuthResult = { user: Doc<'users'>; userId: Doc<'users'>['_id'] }
8
+
9
+ export async function requireApiTokenUser(
10
+ ctx: ActionCtx,
11
+ request: Request,
12
+ ): Promise<TokenAuthResult> {
13
+ const header = request.headers.get('authorization') ?? request.headers.get('Authorization')
14
+ const token = parseBearerToken(header)
15
+ if (!token) throw new ConvexError('Unauthorized')
16
+
17
+ const tokenHash = await hashToken(token)
18
+ const apiToken = await ctx.runQuery(internal.tokens.getByHashInternal, { tokenHash })
19
+ if (!apiToken || apiToken.revokedAt) throw new ConvexError('Unauthorized')
20
+
21
+ const user = await ctx.runQuery(internal.tokens.getUserForTokenInternal, {
22
+ tokenId: apiToken._id,
23
+ })
24
+ if (!user || user.deletedAt) throw new ConvexError('Unauthorized')
25
+
26
+ await ctx.runMutation(internal.tokens.touchInternal, { tokenId: apiToken._id })
27
+ return { user, userId: user._id }
28
+ }
29
+
30
+ function parseBearerToken(header: string | null) {
31
+ if (!header) return null
32
+ const trimmed = header.trim()
33
+ if (!trimmed.toLowerCase().startsWith('bearer ')) return null
34
+ const token = trimmed.slice(7).trim()
35
+ return token || null
36
+ }
@@ -0,0 +1,50 @@
1
+ import type { Doc, Id } from '../_generated/dataModel'
2
+ import type { QueryCtx } from '../_generated/server'
3
+
4
+ type BadgeKind = Doc<'skillBadges'>['kind']
5
+
6
+ export type SkillBadgeMap = Partial<Record<BadgeKind, { byUserId: Id<'users'>; at: number }>>
7
+
8
+ export type SkillBadgeSource = { badges?: SkillBadgeMap | null }
9
+
10
+ type BadgeCtx = Pick<QueryCtx, 'db'>
11
+
12
+ export function isSkillHighlighted(skill: SkillBadgeSource) {
13
+ return Boolean(skill.badges?.highlighted)
14
+ }
15
+
16
+ export function isSkillOfficial(skill: SkillBadgeSource) {
17
+ return Boolean(skill.badges?.official)
18
+ }
19
+
20
+ export function isSkillDeprecated(skill: SkillBadgeSource) {
21
+ return Boolean(skill.badges?.deprecated)
22
+ }
23
+
24
+ export function buildBadgeMap(records: Doc<'skillBadges'>[]): SkillBadgeMap {
25
+ return records.reduce<SkillBadgeMap>((acc, record) => {
26
+ acc[record.kind] = { byUserId: record.byUserId, at: record.at }
27
+ return acc
28
+ }, {})
29
+ }
30
+
31
+ export async function getSkillBadgeMap(
32
+ ctx: BadgeCtx,
33
+ skillId: Id<'skills'>,
34
+ ): Promise<SkillBadgeMap> {
35
+ const records = await ctx.db
36
+ .query('skillBadges')
37
+ .withIndex('by_skill', (q) => q.eq('skillId', skillId))
38
+ .collect()
39
+ return buildBadgeMap(records)
40
+ }
41
+
42
+ export async function getSkillBadgeMaps(
43
+ ctx: BadgeCtx,
44
+ skillIds: Array<Id<'skills'>>,
45
+ ): Promise<Map<Id<'skills'>, SkillBadgeMap>> {
46
+ const entries = await Promise.all(
47
+ skillIds.map(async (skillId) => [skillId, await getSkillBadgeMap(ctx, skillId)] as const),
48
+ )
49
+ return new Map(entries)
50
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { __test } from './changelog'
3
+
4
+ describe('changelog utils', () => {
5
+ it('summarizes file diffs', () => {
6
+ const diff = __test.summarizeFileDiff(
7
+ [
8
+ { path: 'a.txt', sha256: 'aaa' },
9
+ { path: 'b.txt', sha256: 'bbb' },
10
+ ],
11
+ [
12
+ { path: 'a.txt', sha256: 'aaa' },
13
+ { path: 'b.txt', sha256: 'ccc' },
14
+ { path: 'c.txt', sha256: 'ddd' },
15
+ ],
16
+ )
17
+
18
+ expect(diff.added).toEqual(['c.txt'])
19
+ expect(diff.removed).toEqual([])
20
+ expect(diff.changed).toEqual(['b.txt'])
21
+ expect(__test.formatDiffSummary(diff)).toBe('1 added, 1 changed')
22
+ })
23
+
24
+ it('generates a fallback initial release note', () => {
25
+ const text = __test.generateFallback({
26
+ slug: 'demo',
27
+ version: '1.0.0',
28
+ oldReadme: null,
29
+ nextReadme: 'hi',
30
+ fileDiff: null,
31
+ })
32
+ expect(text).toMatch(/Initial release/i)
33
+ })
34
+ })
@@ -0,0 +1,278 @@
1
+ import { internal } from '../_generated/api'
2
+ import type { Doc, Id } 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
+ `Skill: ${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 SKILL.md:\n${oldReadme}` : null,
110
+ `New SKILL.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 skill 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 SKILL.md and bundle contents.`)
158
+ return lines.join('\n')
159
+ }
160
+
161
+ export async function generateChangelogForPublish(
162
+ ctx: ActionCtx,
163
+ args: { slug: string; version: string; readmeText: string; files: FileMeta[] },
164
+ ): Promise<string> {
165
+ try {
166
+ const skill = (await ctx.runQuery(internal.skills.getSkillBySlugInternal, {
167
+ slug: args.slug,
168
+ })) as Doc<'skills'> | null
169
+ const previous: Doc<'skillVersions'> | null =
170
+ skill?.latestVersionId && !skill.softDeletedAt
171
+ ? ((await ctx.runQuery(internal.skills.getVersionByIdInternal, {
172
+ versionId: skill.latestVersionId,
173
+ })) as Doc<'skillVersions'> | 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 skill.'
204
+ }
205
+ }
206
+
207
+ export async function generateChangelogPreview(
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 skill = (await ctx.runQuery(internal.skills.getSkillBySlugInternal, {
218
+ slug: args.slug,
219
+ })) as Doc<'skills'> | null
220
+ const previous: Doc<'skillVersions'> | null =
221
+ skill?.latestVersionId && !skill.softDeletedAt
222
+ ? ((await ctx.runQuery(internal.skills.getVersionByIdInternal, {
223
+ versionId: skill.latestVersionId,
224
+ })) as Doc<'skillVersions'> | null)
225
+ : null
226
+
227
+ const oldReadmeText: string | null = previous
228
+ ? await readReadmeFromVersion(ctx, previous)
229
+ : null
230
+ const fileDiff =
231
+ previous && args.filePaths
232
+ ? summarizeFileDiff(
233
+ previous.files.map((file) => ({ path: file.path, sha256: file.sha256 })),
234
+ args.filePaths.map((path) => ({ path })),
235
+ )
236
+ : null
237
+
238
+ const ai = await generateWithOpenAI({
239
+ slug: args.slug,
240
+ version: args.version,
241
+ oldReadme: oldReadmeText,
242
+ nextReadme: args.readmeText,
243
+ fileDiff,
244
+ }).catch(() => null)
245
+
246
+ return (
247
+ ai ??
248
+ generateFallback({
249
+ slug: args.slug,
250
+ version: args.version,
251
+ oldReadme: oldReadmeText,
252
+ nextReadme: args.readmeText,
253
+ fileDiff,
254
+ })
255
+ )
256
+ } catch {
257
+ return '- Updated skill.'
258
+ }
259
+ }
260
+
261
+ async function readReadmeFromVersion(ctx: ActionCtx, version: Doc<'skillVersions'>) {
262
+ const readmeFile = version.files.find((file) => {
263
+ const lower = file.path.toLowerCase()
264
+ return lower === 'skill.md' || lower === 'skills.md'
265
+ })
266
+ if (!readmeFile) return null
267
+ const blob = await ctx.storage.get(readmeFile.storageId as Id<'_storage'>)
268
+ if (!blob) return null
269
+ return blob.text()
270
+ }
271
+
272
+ export const __test = {
273
+ clampText,
274
+ extractResponseText,
275
+ formatDiffSummary,
276
+ summarizeFileDiff,
277
+ generateFallback,
278
+ }
@@ -0,0 +1,38 @@
1
+ export const EMBEDDING_MODEL = 'text-embedding-3-small'
2
+ export const EMBEDDING_DIMENSIONS = 1536
3
+
4
+ function emptyEmbedding() {
5
+ return Array.from({ length: EMBEDDING_DIMENSIONS }, () => 0)
6
+ }
7
+
8
+ export async function generateEmbedding(text: string) {
9
+ const apiKey = process.env.OPENAI_API_KEY
10
+ if (!apiKey) {
11
+ console.warn('OPENAI_API_KEY is not configured; using zero embeddings')
12
+ return emptyEmbedding()
13
+ }
14
+
15
+ const response = await fetch('https://api.openai.com/v1/embeddings', {
16
+ method: 'POST',
17
+ headers: {
18
+ 'Content-Type': 'application/json',
19
+ Authorization: `Bearer ${apiKey}`,
20
+ },
21
+ body: JSON.stringify({
22
+ model: EMBEDDING_MODEL,
23
+ input: text,
24
+ }),
25
+ })
26
+
27
+ if (!response.ok) {
28
+ const message = await response.text()
29
+ throw new Error(`Embedding failed: ${message}`)
30
+ }
31
+
32
+ const payload = (await response.json()) as {
33
+ data?: Array<{ embedding: number[] }>
34
+ }
35
+ const embedding = payload.data?.[0]?.embedding
36
+ if (!embedding) throw new Error('Embedding missing from response')
37
+ return embedding
38
+ }