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,254 @@
1
+ import { v } from 'convex/values'
2
+ import { internal } from './_generated/api'
3
+ import type { Doc, Id } from './_generated/dataModel'
4
+ import { action, internalQuery } from './_generated/server'
5
+ import { getSkillBadgeMaps, isSkillHighlighted, type SkillBadgeMap } from './lib/badges'
6
+ import { generateEmbedding } from './lib/embeddings'
7
+ import { toPublicSkill, toPublicSoul } from './lib/public'
8
+ import { matchesExactTokens, tokenize } from './lib/searchText'
9
+
10
+ type HydratedEntry = {
11
+ embeddingId: Id<'skillEmbeddings'>
12
+ skill: NonNullable<ReturnType<typeof toPublicSkill>>
13
+ version: Doc<'skillVersions'> | null
14
+ ownerHandle: string | null
15
+ }
16
+
17
+ type SearchResult = HydratedEntry & { score: number }
18
+
19
+ function getNextCandidateLimit(current: number, max: number) {
20
+ const next = Math.min(current * 2, max)
21
+ return next > current ? next : null
22
+ }
23
+
24
+ export const searchSkills: ReturnType<typeof action> = action({
25
+ args: {
26
+ query: v.string(),
27
+ limit: v.optional(v.number()),
28
+ highlightedOnly: v.optional(v.boolean()),
29
+ },
30
+ handler: async (ctx, args): Promise<SearchResult[]> => {
31
+ const query = args.query.trim()
32
+ if (!query) return []
33
+ const queryTokens = tokenize(query)
34
+ if (queryTokens.length === 0) return []
35
+ let vector: number[]
36
+ try {
37
+ vector = await generateEmbedding(query)
38
+ } catch (error) {
39
+ console.warn('Search embedding generation failed', error)
40
+ return []
41
+ }
42
+ const limit = args.limit ?? 10
43
+ // Convex vectorSearch max limit is 256; clamp candidate sizes accordingly.
44
+ const maxCandidate = Math.min(Math.max(limit * 10, 200), 256)
45
+ let candidateLimit = Math.min(Math.max(limit * 3, 50), 256)
46
+ let hydrated: HydratedEntry[] = []
47
+ let scoreById = new Map<Id<'skillEmbeddings'>, number>()
48
+ let exactMatches: HydratedEntry[] = []
49
+
50
+ while (candidateLimit <= maxCandidate) {
51
+ const results = await ctx.vectorSearch('skillEmbeddings', 'by_embedding', {
52
+ vector,
53
+ limit: candidateLimit,
54
+ filter: (q) => q.or(q.eq('visibility', 'latest'), q.eq('visibility', 'latest-approved')),
55
+ })
56
+
57
+ hydrated = (await ctx.runQuery(internal.search.hydrateResults, {
58
+ embeddingIds: results.map((result) => result._id),
59
+ })) as HydratedEntry[]
60
+
61
+ scoreById = new Map<Id<'skillEmbeddings'>, number>(
62
+ results.map((result) => [result._id, result._score]),
63
+ )
64
+
65
+ const badgeMapEntries = (await ctx.runQuery(internal.search.getSkillBadgeMapsInternal, {
66
+ skillIds: hydrated.map((entry) => entry.skill._id),
67
+ })) as Array<[Id<'skills'>, SkillBadgeMap]>
68
+ const badgeMapBySkillId = new Map(badgeMapEntries)
69
+ const hydratedWithBadges = hydrated.map((entry) => ({
70
+ ...entry,
71
+ skill: {
72
+ ...entry.skill,
73
+ badges: badgeMapBySkillId.get(entry.skill._id) ?? {},
74
+ },
75
+ }))
76
+
77
+ const filtered = args.highlightedOnly
78
+ ? hydratedWithBadges.filter((entry) => isSkillHighlighted(entry.skill))
79
+ : hydratedWithBadges
80
+
81
+ exactMatches = filtered.filter((entry) =>
82
+ matchesExactTokens(queryTokens, [
83
+ entry.skill.displayName,
84
+ entry.skill.slug,
85
+ entry.skill.summary,
86
+ ]),
87
+ )
88
+
89
+ if (exactMatches.length >= limit || results.length < candidateLimit) {
90
+ break
91
+ }
92
+
93
+ const nextLimit = getNextCandidateLimit(candidateLimit, maxCandidate)
94
+ if (!nextLimit) break
95
+ candidateLimit = nextLimit
96
+ }
97
+
98
+ return exactMatches
99
+ .map((entry) => ({
100
+ ...entry,
101
+ score: scoreById.get(entry.embeddingId) ?? 0,
102
+ }))
103
+ .filter((entry) => entry.skill)
104
+ .slice(0, limit)
105
+ },
106
+ })
107
+
108
+ export const getBadgeMapsForSkills = internalQuery({
109
+ args: { skillIds: v.array(v.id('skills')) },
110
+ handler: async (ctx, args): Promise<Array<[Id<'skills'>, SkillBadgeMap]>> => {
111
+ const badgeMap = await getSkillBadgeMaps(ctx, args.skillIds)
112
+ return Array.from(badgeMap.entries())
113
+ },
114
+ })
115
+
116
+ export const hydrateResults = internalQuery({
117
+ args: { embeddingIds: v.array(v.id('skillEmbeddings')) },
118
+ handler: async (ctx, args): Promise<HydratedEntry[]> => {
119
+ const ownerHandleCache = new Map<Id<'users'>, Promise<string | null>>()
120
+
121
+ const getOwnerHandle = (ownerUserId: Id<'users'>) => {
122
+ const cached = ownerHandleCache.get(ownerUserId)
123
+ if (cached) return cached
124
+ const handlePromise = ctx.db
125
+ .get(ownerUserId)
126
+ .then((owner) => owner?.handle ?? owner?._id ?? null)
127
+ ownerHandleCache.set(ownerUserId, handlePromise)
128
+ return handlePromise
129
+ }
130
+
131
+ const entries = await Promise.all(
132
+ args.embeddingIds.map(async (embeddingId) => {
133
+ const embedding = await ctx.db.get(embeddingId)
134
+ if (!embedding) return null
135
+ const skill = await ctx.db.get(embedding.skillId)
136
+ if (!skill || skill.softDeletedAt) return null
137
+ const [version, ownerHandle] = await Promise.all([
138
+ ctx.db.get(embedding.versionId),
139
+ getOwnerHandle(skill.ownerUserId),
140
+ ])
141
+ const publicSkill = toPublicSkill(skill)
142
+ if (!publicSkill) return null
143
+ return { embeddingId, skill: publicSkill, version, ownerHandle }
144
+ }),
145
+ )
146
+
147
+ return entries.filter((entry): entry is HydratedEntry => entry !== null)
148
+ },
149
+ })
150
+
151
+ type HydratedSoulEntry = {
152
+ embeddingId: Id<'soulEmbeddings'>
153
+ soul: NonNullable<ReturnType<typeof toPublicSoul>>
154
+ version: Doc<'soulVersions'> | null
155
+ }
156
+
157
+ type SoulSearchResult = HydratedSoulEntry & { score: number }
158
+
159
+ export const searchSouls: ReturnType<typeof action> = action({
160
+ args: {
161
+ query: v.string(),
162
+ limit: v.optional(v.number()),
163
+ },
164
+ handler: async (ctx, args): Promise<SoulSearchResult[]> => {
165
+ const query = args.query.trim()
166
+ if (!query) return []
167
+ const queryTokens = tokenize(query)
168
+ if (queryTokens.length === 0) return []
169
+ let vector: number[]
170
+ try {
171
+ vector = await generateEmbedding(query)
172
+ } catch (error) {
173
+ console.warn('Search embedding generation failed', error)
174
+ return []
175
+ }
176
+ const limit = args.limit ?? 10
177
+ // Convex vectorSearch max limit is 256; clamp candidate sizes accordingly.
178
+ const maxCandidate = Math.min(Math.max(limit * 10, 200), 256)
179
+ let candidateLimit = Math.min(Math.max(limit * 3, 50), 256)
180
+ let hydrated: HydratedSoulEntry[] = []
181
+ let scoreById = new Map<Id<'soulEmbeddings'>, number>()
182
+ let exactMatches: HydratedSoulEntry[] = []
183
+
184
+ while (candidateLimit <= maxCandidate) {
185
+ const results = await ctx.vectorSearch('soulEmbeddings', 'by_embedding', {
186
+ vector,
187
+ limit: candidateLimit,
188
+ filter: (q) => q.or(q.eq('visibility', 'latest'), q.eq('visibility', 'latest-approved')),
189
+ })
190
+
191
+ hydrated = (await ctx.runQuery(internal.search.hydrateSoulResults, {
192
+ embeddingIds: results.map((result) => result._id),
193
+ })) as HydratedSoulEntry[]
194
+
195
+ scoreById = new Map<Id<'soulEmbeddings'>, number>(
196
+ results.map((result) => [result._id, result._score]),
197
+ )
198
+
199
+ exactMatches = hydrated.filter((entry) =>
200
+ matchesExactTokens(queryTokens, [
201
+ entry.soul.displayName,
202
+ entry.soul.slug,
203
+ entry.soul.summary,
204
+ ]),
205
+ )
206
+
207
+ if (exactMatches.length >= limit || results.length < candidateLimit) {
208
+ break
209
+ }
210
+
211
+ const nextLimit = getNextCandidateLimit(candidateLimit, maxCandidate)
212
+ if (!nextLimit) break
213
+ candidateLimit = nextLimit
214
+ }
215
+
216
+ return exactMatches
217
+ .map((entry) => ({
218
+ ...entry,
219
+ score: scoreById.get(entry.embeddingId) ?? 0,
220
+ }))
221
+ .filter((entry) => entry.soul)
222
+ .slice(0, limit)
223
+ },
224
+ })
225
+
226
+ export const hydrateSoulResults = internalQuery({
227
+ args: { embeddingIds: v.array(v.id('soulEmbeddings')) },
228
+ handler: async (ctx, args): Promise<HydratedSoulEntry[]> => {
229
+ const entries: HydratedSoulEntry[] = []
230
+
231
+ for (const embeddingId of args.embeddingIds) {
232
+ const embedding = await ctx.db.get(embeddingId)
233
+ if (!embedding) continue
234
+ const soul = await ctx.db.get(embedding.soulId)
235
+ if (soul?.softDeletedAt) continue
236
+ const version = await ctx.db.get(embedding.versionId)
237
+ const publicSoul = toPublicSoul(soul)
238
+ if (!publicSoul) continue
239
+ entries.push({ embeddingId, soul: publicSoul, version })
240
+ }
241
+
242
+ return entries
243
+ },
244
+ })
245
+
246
+ export const getSkillBadgeMapsInternal = internalQuery({
247
+ args: { skillIds: v.array(v.id('skills')) },
248
+ handler: async (ctx, args) => {
249
+ const badgeMap = await getSkillBadgeMaps(ctx, args.skillIds)
250
+ return Array.from(badgeMap.entries())
251
+ },
252
+ })
253
+
254
+ export const __test = { getNextCandidateLimit }
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { Doc } from './_generated/dataModel'
3
+ import { decideSeedStart } from './seed'
4
+
5
+ function seedState(cursor: string, updatedAt: number) {
6
+ return { cursor, updatedAt } as unknown as Doc<'githubBackupSyncState'>
7
+ }
8
+
9
+ describe('decideSeedStart', () => {
10
+ it('returns done when done', () => {
11
+ expect(decideSeedStart(seedState('done', Date.now()), Date.now())).toEqual({
12
+ started: false,
13
+ reason: 'done',
14
+ })
15
+ })
16
+
17
+ it('returns running when lock fresh', () => {
18
+ const now = Date.now()
19
+ expect(decideSeedStart(seedState('running', now), now + 1000)).toEqual({
20
+ started: false,
21
+ reason: 'running',
22
+ })
23
+ })
24
+
25
+ it('starts when lock stale', () => {
26
+ const now = Date.now()
27
+ const stale = now - 10 * 60 * 1000 - 1
28
+ expect(decideSeedStart(seedState('running', stale), now)).toEqual({
29
+ started: true,
30
+ reason: 'patched',
31
+ })
32
+ })
33
+
34
+ it('starts when missing', () => {
35
+ expect(decideSeedStart(null, Date.now())).toEqual({ started: true, reason: 'inserted' })
36
+ })
37
+ })
package/convex/seed.ts ADDED
@@ -0,0 +1,254 @@
1
+ import { v } from 'convex/values'
2
+ import { internal } from './_generated/api'
3
+ import type { Doc, Id } from './_generated/dataModel'
4
+ import type { ActionCtx, DatabaseReader, DatabaseWriter } from './_generated/server'
5
+ import { action, internalMutation, internalQuery } from './_generated/server'
6
+ import { publishSoulVersionForUser } from './lib/soulPublish'
7
+ import { SOUL_SEED_DISPLAY_NAME, SOUL_SEED_HANDLE, SOUL_SEED_KEY, SOUL_SEEDS } from './seedSouls'
8
+
9
+ const SEED_LOCK_STALE_MS = 10 * 60 * 1000
10
+
11
+ type SeedStateDoc = Doc<'githubBackupSyncState'>
12
+
13
+ type SeedStartDecision = {
14
+ started: boolean
15
+ reason: 'done' | 'running' | 'patched' | 'inserted'
16
+ }
17
+
18
+ async function getSeedState(ctx: { db: DatabaseReader }): Promise<SeedStateDoc | null> {
19
+ const entries = (await ctx.db
20
+ .query('githubBackupSyncState')
21
+ .withIndex('by_key', (q) => q.eq('key', SOUL_SEED_KEY))
22
+ .order('desc')
23
+ .take(2)) as SeedStateDoc[]
24
+ return entries[0] ?? null
25
+ }
26
+
27
+ async function cleanupSeedState(ctx: { db: DatabaseWriter }, keepId: Id<'githubBackupSyncState'>) {
28
+ const entries = (await ctx.db
29
+ .query('githubBackupSyncState')
30
+ .withIndex('by_key', (q) => q.eq('key', SOUL_SEED_KEY))
31
+ .order('desc')
32
+ .take(50)) as SeedStateDoc[]
33
+
34
+ for (const entry of entries) {
35
+ if (entry._id === keepId) continue
36
+ await ctx.db.delete(entry._id)
37
+ }
38
+ }
39
+
40
+ export function decideSeedStart(existing: SeedStateDoc | null, now: number): SeedStartDecision {
41
+ const cursor = existing?.cursor ?? null
42
+ if (cursor === 'done') return { started: false, reason: 'done' }
43
+ if (cursor === 'running' && existing && now - existing.updatedAt < SEED_LOCK_STALE_MS) {
44
+ return { started: false, reason: 'running' }
45
+ }
46
+ return existing ? { started: true, reason: 'patched' } : { started: true, reason: 'inserted' }
47
+ }
48
+
49
+ export const getSoulSeedStateInternal = internalQuery({
50
+ args: {},
51
+ handler: async (ctx) => getSeedState(ctx),
52
+ })
53
+
54
+ export const setSoulSeedStateInternal = internalMutation({
55
+ args: { status: v.string() },
56
+ handler: async (ctx, args) => {
57
+ const existing = await getSeedState(ctx)
58
+ const now = Date.now()
59
+ if (existing) {
60
+ await ctx.db.patch(existing._id, { cursor: args.status, updatedAt: now })
61
+ await cleanupSeedState(ctx, existing._id)
62
+ return existing._id
63
+ }
64
+ const id = await ctx.db.insert('githubBackupSyncState', {
65
+ key: SOUL_SEED_KEY,
66
+ cursor: args.status,
67
+ updatedAt: now,
68
+ })
69
+ await cleanupSeedState(ctx, id)
70
+ return id
71
+ },
72
+ })
73
+
74
+ export const tryStartSoulSeedInternal = internalMutation({
75
+ args: {},
76
+ handler: async (ctx) => {
77
+ const now = Date.now()
78
+ const existing = await getSeedState(ctx)
79
+ const decision = decideSeedStart(existing, now)
80
+
81
+ if (!decision.started) return decision
82
+
83
+ if (existing) {
84
+ await ctx.db.patch(existing._id, { cursor: 'running', updatedAt: now })
85
+ await cleanupSeedState(ctx, existing._id)
86
+ return { started: true, reason: 'patched' as const }
87
+ }
88
+
89
+ const id = await ctx.db.insert('githubBackupSyncState', {
90
+ key: SOUL_SEED_KEY,
91
+ cursor: 'running',
92
+ updatedAt: now,
93
+ })
94
+ await cleanupSeedState(ctx, id)
95
+ return { started: true, reason: 'inserted' as const }
96
+ },
97
+ })
98
+
99
+ export const hasAnySoulsInternal = internalQuery({
100
+ args: {},
101
+ handler: async (ctx) => {
102
+ const entry = await ctx.db.query('souls').take(1)
103
+ return entry.length > 0
104
+ },
105
+ })
106
+
107
+ export const ensureSoulSeeds = action({
108
+ args: {},
109
+ handler: async (ctx) => {
110
+ const started = (await ctx.runMutation(internal.seed.tryStartSoulSeedInternal, {})) as {
111
+ started: boolean
112
+ reason: 'done' | 'running' | 'patched' | 'inserted'
113
+ }
114
+ if (!started.started) {
115
+ if (started.reason === 'done') return { seeded: false, reason: 'already-seeded' as const }
116
+ return { seeded: false, reason: 'in-progress' as const }
117
+ }
118
+
119
+ const hasSouls = (await ctx.runQuery(internal.seed.hasAnySoulsInternal, {})) as boolean
120
+ if (hasSouls) {
121
+ await ctx.runMutation(internal.seed.setSoulSeedStateInternal, { status: 'done' })
122
+ return { seeded: false, reason: 'souls-exist' as const }
123
+ }
124
+
125
+ try {
126
+ const result = await runSeed(ctx)
127
+ await ctx.runMutation(internal.seed.setSoulSeedStateInternal, { status: 'done' })
128
+ return { seeded: true, reason: 'seeded' as const, ...result }
129
+ } catch (error) {
130
+ await ctx.runMutation(internal.seed.setSoulSeedStateInternal, { status: 'error' })
131
+ throw error
132
+ }
133
+ },
134
+ })
135
+
136
+ export const seed = action({
137
+ args: {},
138
+ handler: async (ctx) => runSeed(ctx),
139
+ })
140
+
141
+ async function runSeed(ctx: ActionCtx) {
142
+ const userId = (await ctx.runMutation(internal.seed.ensureSeedUserInternal, {
143
+ handle: SOUL_SEED_HANDLE,
144
+ displayName: SOUL_SEED_DISPLAY_NAME,
145
+ })) as Id<'users'>
146
+
147
+ const created: string[] = []
148
+ const skipped: string[] = []
149
+
150
+ for (const seedEntry of SOUL_SEEDS) {
151
+ const existing = (await ctx.runQuery(internal.souls.getSoulBySlugInternal, {
152
+ slug: seedEntry.slug,
153
+ })) as Doc<'souls'> | null
154
+ if (existing) {
155
+ if (existing.softDeletedAt && existing.ownerUserId === userId) {
156
+ await ctx.runMutation(internal.souls.setSoulSoftDeletedInternal, {
157
+ userId,
158
+ slug: seedEntry.slug,
159
+ deleted: false,
160
+ })
161
+ }
162
+ skipped.push(seedEntry.slug)
163
+ continue
164
+ }
165
+
166
+ const body = seedEntry.readme
167
+ if (!body) {
168
+ skipped.push(seedEntry.slug)
169
+ continue
170
+ }
171
+
172
+ const bytes = new TextEncoder().encode(body)
173
+ const sha256 = await sha256Hex(bytes)
174
+ const storageId = await ctx.storage.store(new Blob([bytes], { type: 'text/markdown' }))
175
+
176
+ try {
177
+ await publishSoulVersionForUser(ctx, userId, {
178
+ slug: seedEntry.slug,
179
+ displayName: seedEntry.displayName,
180
+ version: seedEntry.version,
181
+ changelog: '',
182
+ tags: seedEntry.tags,
183
+ files: [
184
+ {
185
+ path: 'SOUL.md',
186
+ size: bytes.byteLength,
187
+ storageId,
188
+ sha256,
189
+ contentType: 'text/markdown',
190
+ },
191
+ ],
192
+ })
193
+ created.push(seedEntry.slug)
194
+ } catch (error) {
195
+ if (!isExpectedSeedSkipError(error)) throw error
196
+ skipped.push(seedEntry.slug)
197
+ }
198
+ }
199
+
200
+ return { created, skipped }
201
+ }
202
+
203
+ function isExpectedSeedSkipError(error: unknown) {
204
+ const message = error instanceof Error ? error.message : String(error)
205
+ return (
206
+ message.includes('Version already exists') || message.includes('Only the owner can publish')
207
+ )
208
+ }
209
+
210
+ export const ensureSeedUserInternal = internalMutation({
211
+ args: {
212
+ handle: v.string(),
213
+ displayName: v.string(),
214
+ },
215
+ handler: async (ctx, args) => {
216
+ const baseHandle = args.handle.trim()
217
+ const displayName = args.displayName.trim()
218
+ const candidates = [baseHandle, `${baseHandle}-bot`]
219
+ for (let i = 2; i <= 6; i += 1) candidates.push(`${baseHandle}-bot-${i}`)
220
+
221
+ for (const candidate of candidates) {
222
+ const existing = await ctx.db
223
+ .query('users')
224
+ .withIndex('handle', (q) => q.eq('handle', candidate))
225
+ .take(2)
226
+ const user = (existing[0] ?? null) as Doc<'users'> | null
227
+ if (user) {
228
+ if ((user.displayName ?? user.name) === displayName) return user._id
229
+ continue
230
+ }
231
+
232
+ return ctx.db.insert('users', {
233
+ handle: candidate,
234
+ displayName,
235
+ createdAt: Date.now(),
236
+ updatedAt: Date.now(),
237
+ })
238
+ }
239
+
240
+ throw new Error('Unable to allocate seed user handle')
241
+ },
242
+ })
243
+
244
+ async function sha256Hex(bytes: Uint8Array) {
245
+ const data = new Uint8Array(bytes)
246
+ const digest = await crypto.subtle.digest('SHA-256', data)
247
+ return toHex(new Uint8Array(digest))
248
+ }
249
+
250
+ function toHex(bytes: Uint8Array) {
251
+ let out = ''
252
+ for (const byte of bytes) out += byte.toString(16).padStart(2, '0')
253
+ return out
254
+ }