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,205 @@
1
+ import { v } 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 { internalAction, internalMutation, internalQuery } from './_generated/server'
6
+
7
+ const DEFAULT_BATCH_SIZE = 200
8
+ const MAX_BATCH_SIZE = 1000
9
+ const DEFAULT_MAX_BATCHES = 5
10
+ const MAX_MAX_BATCHES = 50
11
+ const BACKFILL_STATE_KEY = 'default'
12
+
13
+ export const backfillSkillStatFieldsInternal = internalMutation({
14
+ args: {
15
+ cursor: v.optional(v.string()),
16
+ batchSize: v.optional(v.number()),
17
+ },
18
+ handler: async (ctx, args) => {
19
+ const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE)
20
+ const { page, isDone, continueCursor } = await ctx.db
21
+ .query('skills')
22
+ .order('asc')
23
+ .paginate({ cursor: args.cursor ?? null, numItems: batchSize })
24
+
25
+ let patched = 0
26
+ for (const skill of page) {
27
+ const next = buildSkillStatPatch(skill)
28
+ if (!next) continue
29
+ await ctx.db.patch(skill._id, next)
30
+ patched += 1
31
+ }
32
+
33
+ return {
34
+ ok: true as const,
35
+ scanned: page.length,
36
+ patched,
37
+ cursor: isDone ? null : continueCursor,
38
+ isDone,
39
+ }
40
+ },
41
+ })
42
+
43
+ type BackfillState = {
44
+ cursor: string | null
45
+ doneAt?: number
46
+ }
47
+
48
+ type BackfillActionArgs = {
49
+ batchSize?: number
50
+ maxBatches?: number
51
+ resetCursor?: boolean
52
+ }
53
+
54
+ type BackfillStats = {
55
+ scanned: number
56
+ patched: number
57
+ batches: number
58
+ }
59
+
60
+ type BackfillActionResult = {
61
+ ok: true
62
+ isDone: boolean
63
+ cursor: string | null
64
+ stats: BackfillStats
65
+ }
66
+
67
+ export const getSkillStatBackfillStateInternal = internalQuery({
68
+ args: {},
69
+ handler: async (ctx): Promise<BackfillState> => {
70
+ const state = await ctx.db
71
+ .query('skillStatBackfillState')
72
+ .withIndex('by_key', (q) => q.eq('key', BACKFILL_STATE_KEY))
73
+ .unique()
74
+ return { cursor: state?.cursor ?? null, doneAt: state?.doneAt }
75
+ },
76
+ })
77
+
78
+ export const setSkillStatBackfillStateInternal = internalMutation({
79
+ args: {
80
+ cursor: v.optional(v.string()),
81
+ doneAt: v.optional(v.number()),
82
+ },
83
+ handler: async (ctx, args) => {
84
+ const now = Date.now()
85
+ const state = await ctx.db
86
+ .query('skillStatBackfillState')
87
+ .withIndex('by_key', (q) => q.eq('key', BACKFILL_STATE_KEY))
88
+ .unique()
89
+
90
+ if (!state) {
91
+ await ctx.db.insert('skillStatBackfillState', {
92
+ key: BACKFILL_STATE_KEY,
93
+ cursor: args.cursor,
94
+ doneAt: args.doneAt,
95
+ updatedAt: now,
96
+ })
97
+ return { ok: true as const }
98
+ }
99
+
100
+ await ctx.db.patch(state._id, {
101
+ cursor: args.cursor,
102
+ doneAt: args.doneAt,
103
+ updatedAt: now,
104
+ })
105
+
106
+ return { ok: true as const }
107
+ },
108
+ })
109
+
110
+ async function runSkillStatBackfillInternalHandler(
111
+ ctx: ActionCtx,
112
+ args: BackfillActionArgs,
113
+ ): Promise<BackfillActionResult> {
114
+ const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE)
115
+ const maxBatches = clampInt(args.maxBatches ?? DEFAULT_MAX_BATCHES, 1, MAX_MAX_BATCHES)
116
+
117
+ if (args.resetCursor) {
118
+ await ctx.runMutation(internal.statsMaintenance.setSkillStatBackfillStateInternal, {
119
+ cursor: undefined,
120
+ doneAt: undefined,
121
+ })
122
+ }
123
+
124
+ const state = (await ctx.runQuery(
125
+ internal.statsMaintenance.getSkillStatBackfillStateInternal,
126
+ {},
127
+ )) as BackfillState
128
+ if (state.doneAt && !args.resetCursor) {
129
+ return {
130
+ ok: true,
131
+ isDone: true,
132
+ cursor: null,
133
+ stats: { scanned: 0, patched: 0, batches: 0 },
134
+ }
135
+ }
136
+
137
+ let cursor: string | null = state.cursor ?? null
138
+ const stats: BackfillStats = { scanned: 0, patched: 0, batches: 0 }
139
+
140
+ for (let i = 0; i < maxBatches; i += 1) {
141
+ const result = (await ctx.runMutation(
142
+ internal.statsMaintenance.backfillSkillStatFieldsInternal,
143
+ {
144
+ cursor: cursor ?? undefined,
145
+ batchSize,
146
+ },
147
+ )) as { scanned: number; patched: number; cursor: string | null; isDone: boolean }
148
+ stats.scanned += result.scanned
149
+ stats.patched += result.patched
150
+ stats.batches += 1
151
+ cursor = result.cursor
152
+
153
+ if (result.isDone) {
154
+ await ctx.runMutation(internal.statsMaintenance.setSkillStatBackfillStateInternal, {
155
+ cursor: undefined,
156
+ doneAt: Date.now(),
157
+ })
158
+ return { ok: true, isDone: true, cursor: null, stats }
159
+ }
160
+
161
+ await ctx.runMutation(internal.statsMaintenance.setSkillStatBackfillStateInternal, {
162
+ cursor: cursor ?? undefined,
163
+ doneAt: undefined,
164
+ })
165
+ }
166
+
167
+ return { ok: true, isDone: false, cursor, stats }
168
+ }
169
+
170
+ export const runSkillStatBackfillInternal: ReturnType<typeof internalAction> = internalAction({
171
+ args: {
172
+ batchSize: v.optional(v.number()),
173
+ maxBatches: v.optional(v.number()),
174
+ resetCursor: v.optional(v.boolean()),
175
+ },
176
+ handler: runSkillStatBackfillInternalHandler,
177
+ })
178
+
179
+ function buildSkillStatPatch(skill: Doc<'skills'>) {
180
+ const stats = skill.stats
181
+ const nextDownloads = stats.downloads
182
+ const nextStars = stats.stars
183
+ const nextInstallsCurrent = stats.installsCurrent ?? 0
184
+ const nextInstallsAllTime = stats.installsAllTime ?? 0
185
+
186
+ if (
187
+ skill.statsDownloads === nextDownloads &&
188
+ skill.statsStars === nextStars &&
189
+ skill.statsInstallsCurrent === nextInstallsCurrent &&
190
+ skill.statsInstallsAllTime === nextInstallsAllTime
191
+ ) {
192
+ return null
193
+ }
194
+
195
+ return {
196
+ statsDownloads: nextDownloads,
197
+ statsStars: nextStars,
198
+ statsInstallsCurrent: nextInstallsCurrent,
199
+ statsInstallsAllTime: nextInstallsAllTime,
200
+ }
201
+ }
202
+
203
+ function clampInt(value: number, min: number, max: number) {
204
+ return Math.min(Math.max(value, min), max)
205
+ }
@@ -0,0 +1,434 @@
1
+ import { getAuthUserId } from '@convex-dev/auth/server'
2
+ import { v } from 'convex/values'
3
+ import type { Id } from './_generated/dataModel'
4
+ import type { MutationCtx, QueryCtx } from './_generated/server'
5
+ import { internalMutation, mutation, query } from './_generated/server'
6
+ import { requireUser } from './lib/access'
7
+ import { insertStatEvent } from './skillStatEvents'
8
+
9
+ const TELEMETRY_STALE_MS = 120 * 24 * 60 * 60 * 1000
10
+
11
+ type RootPayload = {
12
+ rootId: string
13
+ label: string
14
+ skills: Array<{ slug: string; version?: string | null }>
15
+ }
16
+
17
+ export const reportCliSyncInternal = internalMutation({
18
+ args: {
19
+ userId: v.id('users'),
20
+ roots: v.array(
21
+ v.object({
22
+ rootId: v.string(),
23
+ label: v.string(),
24
+ skills: v.array(
25
+ v.object({
26
+ slug: v.string(),
27
+ version: v.optional(v.string()),
28
+ }),
29
+ ),
30
+ }),
31
+ ),
32
+ },
33
+ handler: async (ctx, args) => {
34
+ const now = Date.now()
35
+ const stalenessCutoff = now - TELEMETRY_STALE_MS
36
+
37
+ await expireStaleRoots(ctx, { userId: args.userId, stalenessCutoff, now })
38
+
39
+ const roots = normalizeRoots(args.roots)
40
+ const skillsBySlug = await resolveSkillsBySlug(ctx, roots)
41
+
42
+ for (const root of roots) {
43
+ await upsertRoot(ctx, { userId: args.userId, rootId: root.rootId, now, label: root.label })
44
+ await applyRootReport(ctx, {
45
+ userId: args.userId,
46
+ root,
47
+ skillsBySlug,
48
+ now,
49
+ })
50
+ }
51
+ },
52
+ })
53
+
54
+ export const clearMyTelemetry = mutation({
55
+ args: {},
56
+ handler: async (ctx) => {
57
+ const { userId } = await requireUser(ctx)
58
+ await clearTelemetryForUser(ctx, { userId })
59
+ },
60
+ })
61
+
62
+ export const clearUserTelemetryInternal = internalMutation({
63
+ args: { userId: v.id('users') },
64
+ handler: async (ctx, args) => {
65
+ await clearTelemetryForUser(ctx, { userId: args.userId })
66
+ },
67
+ })
68
+
69
+ export const getMyInstalled = query({
70
+ args: {
71
+ includeRemoved: v.optional(v.boolean()),
72
+ },
73
+ handler: async (ctx, args) => {
74
+ const userId = await getAuthUserId(ctx)
75
+ if (!userId) return null
76
+
77
+ const roots = await ctx.db
78
+ .query('userSyncRoots')
79
+ .withIndex('by_user', (q) => q.eq('userId', userId))
80
+ .order('desc')
81
+ .take(200)
82
+
83
+ const includeRemoved = Boolean(args.includeRemoved)
84
+ const resultRoots: Array<{
85
+ rootId: string
86
+ label: string
87
+ firstSeenAt: number
88
+ lastSeenAt: number
89
+ expiredAt?: number
90
+ skills: Array<{
91
+ skill: {
92
+ slug: string
93
+ displayName: string
94
+ summary?: string
95
+ stats: unknown
96
+ ownerUserId: Id<'users'>
97
+ }
98
+ firstSeenAt: number
99
+ lastSeenAt: number
100
+ lastVersion?: string
101
+ removedAt?: number
102
+ }>
103
+ }> = []
104
+
105
+ for (const root of roots) {
106
+ const installs = await ctx.db
107
+ .query('userSkillRootInstalls')
108
+ .withIndex('by_user_root', (q) => q.eq('userId', userId).eq('rootId', root.rootId))
109
+ .order('desc')
110
+ .take(2000)
111
+
112
+ const filtered = includeRemoved ? installs : installs.filter((entry) => !entry.removedAt)
113
+ const skills: Array<{
114
+ skill: {
115
+ slug: string
116
+ displayName: string
117
+ summary?: string
118
+ stats: unknown
119
+ ownerUserId: Id<'users'>
120
+ }
121
+ firstSeenAt: number
122
+ lastSeenAt: number
123
+ lastVersion?: string
124
+ removedAt?: number
125
+ }> = []
126
+
127
+ for (const entry of filtered) {
128
+ const skill = await ctx.db.get(entry.skillId)
129
+ if (!skill) continue
130
+ skills.push({
131
+ skill: {
132
+ slug: skill.slug,
133
+ displayName: skill.displayName,
134
+ summary: skill.summary,
135
+ stats: skill.stats,
136
+ ownerUserId: skill.ownerUserId,
137
+ },
138
+ firstSeenAt: entry.firstSeenAt,
139
+ lastSeenAt: entry.lastSeenAt,
140
+ lastVersion: entry.lastVersion,
141
+ removedAt: entry.removedAt,
142
+ })
143
+ }
144
+
145
+ resultRoots.push({
146
+ rootId: root.rootId,
147
+ label: root.label,
148
+ firstSeenAt: root.firstSeenAt,
149
+ lastSeenAt: root.lastSeenAt,
150
+ expiredAt: root.expiredAt,
151
+ skills,
152
+ })
153
+ }
154
+
155
+ return {
156
+ roots: resultRoots,
157
+ cutoffDays: 120,
158
+ }
159
+ },
160
+ })
161
+
162
+ async function clearTelemetryForUser(ctx: MutationCtx, params: { userId: Id<'users'> }) {
163
+ const installs = await ctx.db
164
+ .query('userSkillInstalls')
165
+ .withIndex('by_user', (q) => q.eq('userId', params.userId))
166
+ .take(5000)
167
+
168
+ for (const entry of installs) {
169
+ const skill = await ctx.db.get(entry.skillId)
170
+ if (!skill) {
171
+ await ctx.db.delete(entry._id)
172
+ continue
173
+ }
174
+ await insertStatEvent(ctx, {
175
+ skillId: skill._id,
176
+ kind: 'install_clear',
177
+ delta: {
178
+ allTime: -1,
179
+ current: entry.activeRoots > 0 ? -1 : 0,
180
+ },
181
+ })
182
+ await ctx.db.delete(entry._id)
183
+ }
184
+
185
+ const roots = await ctx.db
186
+ .query('userSyncRoots')
187
+ .withIndex('by_user', (q) => q.eq('userId', params.userId))
188
+ .take(5000)
189
+ for (const root of roots) {
190
+ await ctx.db.delete(root._id)
191
+ }
192
+
193
+ const rootInstalls = await ctx.db
194
+ .query('userSkillRootInstalls')
195
+ .withIndex('by_user', (q) => q.eq('userId', params.userId))
196
+ .take(10000)
197
+ for (const entry of rootInstalls) {
198
+ await ctx.db.delete(entry._id)
199
+ }
200
+ }
201
+
202
+ function normalizeRoots(roots: RootPayload[]): RootPayload[] {
203
+ const seen = new Set<string>()
204
+ const unique: RootPayload[] = []
205
+ for (const root of roots) {
206
+ const id = root.rootId.trim()
207
+ if (!id) continue
208
+ if (seen.has(id)) continue
209
+ seen.add(id)
210
+ unique.push({
211
+ rootId: id,
212
+ label: root.label.trim() || 'Unknown',
213
+ skills: root.skills
214
+ .map((skill) => ({
215
+ slug: skill.slug.trim().toLowerCase(),
216
+ version: skill.version ?? null,
217
+ }))
218
+ .filter((skill) => Boolean(skill.slug)),
219
+ })
220
+ }
221
+ return unique
222
+ }
223
+
224
+ async function upsertRoot(
225
+ ctx: MutationCtx,
226
+ params: { userId: Id<'users'>; rootId: string; now: number; label: string },
227
+ ) {
228
+ const existing = await ctx.db
229
+ .query('userSyncRoots')
230
+ .withIndex('by_user_root', (q) => q.eq('userId', params.userId).eq('rootId', params.rootId))
231
+ .unique()
232
+ if (existing) {
233
+ await ctx.db.patch(existing._id, {
234
+ label: params.label,
235
+ lastSeenAt: params.now,
236
+ expiredAt: undefined,
237
+ })
238
+ return
239
+ }
240
+ await ctx.db.insert('userSyncRoots', {
241
+ userId: params.userId,
242
+ rootId: params.rootId,
243
+ label: params.label,
244
+ firstSeenAt: params.now,
245
+ lastSeenAt: params.now,
246
+ expiredAt: undefined,
247
+ })
248
+ }
249
+
250
+ async function applyRootReport(
251
+ ctx: MutationCtx,
252
+ params: {
253
+ userId: Id<'users'>
254
+ root: RootPayload
255
+ skillsBySlug: Map<string, { skillId: Id<'skills'> }>
256
+ now: number
257
+ },
258
+ ) {
259
+ const expected = new Set<Id<'skills'>>()
260
+ const versionsBySkill = new Map<Id<'skills'>, string | undefined>()
261
+ for (const entry of params.root.skills) {
262
+ const resolved = params.skillsBySlug.get(entry.slug)
263
+ if (!resolved) continue
264
+ expected.add(resolved.skillId)
265
+ const version = entry.version?.trim() || undefined
266
+ if (version) versionsBySkill.set(resolved.skillId, version)
267
+ }
268
+
269
+ const previous = await ctx.db
270
+ .query('userSkillRootInstalls')
271
+ .withIndex('by_user_root', (q) =>
272
+ q.eq('userId', params.userId).eq('rootId', params.root.rootId),
273
+ )
274
+ .take(5000)
275
+
276
+ const active = previous.filter((entry) => !entry.removedAt)
277
+
278
+ for (const skillId of expected) {
279
+ const existing = await ctx.db
280
+ .query('userSkillRootInstalls')
281
+ .withIndex('by_user_root_skill', (q) =>
282
+ q.eq('userId', params.userId).eq('rootId', params.root.rootId).eq('skillId', skillId),
283
+ )
284
+ .unique()
285
+
286
+ const reportedVersion = versionsBySkill.get(skillId)
287
+
288
+ if (existing) {
289
+ const wasRemoved = Boolean(existing.removedAt)
290
+ await ctx.db.patch(existing._id, {
291
+ lastSeenAt: params.now,
292
+ lastVersion: reportedVersion ?? existing.lastVersion,
293
+ removedAt: undefined,
294
+ })
295
+ if (wasRemoved) {
296
+ await incrementActiveRoots(ctx, {
297
+ userId: params.userId,
298
+ skillId,
299
+ now: params.now,
300
+ version: reportedVersion,
301
+ })
302
+ }
303
+ continue
304
+ }
305
+
306
+ await ctx.db.insert('userSkillRootInstalls', {
307
+ userId: params.userId,
308
+ rootId: params.root.rootId,
309
+ skillId,
310
+ firstSeenAt: params.now,
311
+ lastSeenAt: params.now,
312
+ lastVersion: reportedVersion,
313
+ })
314
+ await incrementActiveRoots(ctx, {
315
+ userId: params.userId,
316
+ skillId,
317
+ now: params.now,
318
+ version: reportedVersion,
319
+ })
320
+ }
321
+
322
+ for (const entry of active) {
323
+ if (expected.has(entry.skillId)) continue
324
+ await ctx.db.patch(entry._id, { removedAt: params.now })
325
+ await decrementActiveRoots(ctx, { userId: params.userId, skillId: entry.skillId })
326
+ }
327
+ }
328
+
329
+ async function incrementActiveRoots(
330
+ ctx: MutationCtx,
331
+ params: { userId: Id<'users'>; skillId: Id<'skills'>; now: number; version?: string },
332
+ ) {
333
+ const existing = await ctx.db
334
+ .query('userSkillInstalls')
335
+ .withIndex('by_user_skill', (q) => q.eq('userId', params.userId).eq('skillId', params.skillId))
336
+ .unique()
337
+
338
+ if (!existing) {
339
+ await ctx.db.insert('userSkillInstalls', {
340
+ userId: params.userId,
341
+ skillId: params.skillId,
342
+ firstSeenAt: params.now,
343
+ lastSeenAt: params.now,
344
+ activeRoots: 1,
345
+ lastVersion: params.version,
346
+ })
347
+ await bumpSkillInstallCounts(ctx, { skillId: params.skillId, deltaAllTime: 1, deltaCurrent: 1 })
348
+ return
349
+ }
350
+
351
+ const nextActive = Math.max(0, (existing.activeRoots ?? 0) + 1)
352
+ await ctx.db.patch(existing._id, {
353
+ activeRoots: nextActive,
354
+ lastSeenAt: params.now,
355
+ lastVersion: params.version ?? existing.lastVersion,
356
+ })
357
+ if ((existing.activeRoots ?? 0) === 0 && nextActive > 0) {
358
+ await bumpSkillInstallCounts(ctx, { skillId: params.skillId, deltaAllTime: 0, deltaCurrent: 1 })
359
+ }
360
+ }
361
+
362
+ async function decrementActiveRoots(
363
+ ctx: MutationCtx,
364
+ params: { userId: Id<'users'>; skillId: Id<'skills'> },
365
+ ) {
366
+ const existing = await ctx.db
367
+ .query('userSkillInstalls')
368
+ .withIndex('by_user_skill', (q) => q.eq('userId', params.userId).eq('skillId', params.skillId))
369
+ .unique()
370
+ if (!existing) return
371
+
372
+ const nextActive = Math.max(0, (existing.activeRoots ?? 0) - 1)
373
+ await ctx.db.patch(existing._id, { activeRoots: nextActive })
374
+ if ((existing.activeRoots ?? 0) > 0 && nextActive === 0) {
375
+ await bumpSkillInstallCounts(ctx, {
376
+ skillId: params.skillId,
377
+ deltaAllTime: 0,
378
+ deltaCurrent: -1,
379
+ })
380
+ }
381
+ }
382
+
383
+ async function bumpSkillInstallCounts(
384
+ ctx: MutationCtx,
385
+ params: { skillId: Id<'skills'>; deltaAllTime: number; deltaCurrent: number },
386
+ ) {
387
+ if (params.deltaAllTime === 1 && params.deltaCurrent === 1) {
388
+ await insertStatEvent(ctx, { skillId: params.skillId, kind: 'install_new' })
389
+ } else if (params.deltaAllTime === 0 && params.deltaCurrent === 1) {
390
+ await insertStatEvent(ctx, { skillId: params.skillId, kind: 'install_reactivate' })
391
+ } else if (params.deltaAllTime === 0 && params.deltaCurrent === -1) {
392
+ await insertStatEvent(ctx, { skillId: params.skillId, kind: 'install_deactivate' })
393
+ }
394
+ }
395
+
396
+ async function expireStaleRoots(
397
+ ctx: MutationCtx,
398
+ params: { userId: Id<'users'>; stalenessCutoff: number; now: number },
399
+ ) {
400
+ const roots = await ctx.db
401
+ .query('userSyncRoots')
402
+ .withIndex('by_user', (q) => q.eq('userId', params.userId))
403
+ .take(5000)
404
+
405
+ const stale = roots.filter((root) => !root.expiredAt && root.lastSeenAt < params.stalenessCutoff)
406
+ for (const root of stale) {
407
+ await ctx.db.patch(root._id, { expiredAt: params.now })
408
+ const installs = await ctx.db
409
+ .query('userSkillRootInstalls')
410
+ .withIndex('by_user_root', (q) => q.eq('userId', params.userId).eq('rootId', root.rootId))
411
+ .take(5000)
412
+ for (const entry of installs) {
413
+ if (entry.removedAt) continue
414
+ await ctx.db.patch(entry._id, { removedAt: params.now })
415
+ await decrementActiveRoots(ctx, { userId: params.userId, skillId: entry.skillId })
416
+ }
417
+ }
418
+ }
419
+
420
+ async function resolveSkillsBySlug(ctx: QueryCtx | MutationCtx, roots: RootPayload[]) {
421
+ const slugs = new Set<string>()
422
+ for (const root of roots) {
423
+ for (const entry of root.skills) slugs.add(entry.slug)
424
+ }
425
+ const map = new Map<string, { skillId: Id<'skills'> }>()
426
+ for (const slug of slugs) {
427
+ const skill = await ctx.db
428
+ .query('skills')
429
+ .withIndex('by_slug', (q) => q.eq('slug', slug))
430
+ .unique()
431
+ if (skill && !skill.softDeletedAt) map.set(slug, { skillId: skill._id })
432
+ }
433
+ return map
434
+ }