cartethyia 2.7.0 → 2.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (330) hide show
  1. package/.env.example +107 -106
  2. package/README.md +25 -2
  3. package/dist/dashboard/assets/{BannedPage-P76QLGJA.js → BannedPage-XaUmiBrC.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-XaUmiBrC.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage-DdBggJS9.js → CliToolDetailPage-CqD7QiLM.js} +1 -1
  6. package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-aN1iXhhP.js → CliToolsPage-0XUo4UMn.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js.gz +0 -0
  9. package/dist/dashboard/assets/{CombosPage-DE-F1nxZ.js → CombosPage-CXGS_Ct-.js} +1 -1
  10. package/dist/dashboard/assets/CombosPage-CXGS_Ct-.js.gz +0 -0
  11. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js +1 -0
  12. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-3CUxMy2X.js → CustomizationPage-DAG5J9EY.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js.gz +0 -0
  17. package/dist/dashboard/assets/LoginPage-BdmRLnq2.js +1 -0
  18. package/dist/dashboard/assets/LoginPage-BdmRLnq2.js.gz +0 -0
  19. package/dist/dashboard/assets/{ModelPicker-DKVslqG3.js → ModelPicker-BH8Tehdi.js} +1 -1
  20. package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js +4 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js.gz +0 -0
  25. package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js +1 -0
  26. package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js.gz +0 -0
  27. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js +1 -0
  28. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js.gz +0 -0
  29. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js +9 -0
  30. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js.gz +0 -0
  31. package/dist/dashboard/assets/{QuotaPage-BjldQwIU.js → QuotaPage-7P2n--Fv.js} +1 -1
  32. package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js.gz +0 -0
  33. package/dist/dashboard/assets/{SettingsPage-DUIoLRxk.js → SettingsPage-CxZ1fTkN.js} +1 -1
  34. package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js.gz +0 -0
  35. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js +1 -0
  36. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js.gz +0 -0
  37. package/dist/dashboard/assets/{SortableList-fI7JkdIB.js → SortableList-Dqf4zpS-.js} +1 -1
  38. package/dist/dashboard/assets/SortableList-Dqf4zpS-.js.gz +0 -0
  39. package/dist/dashboard/assets/{StudioPage-CHVZqskZ.js → StudioPage-Byj2lAEm.js} +3 -3
  40. package/dist/dashboard/assets/StudioPage-Byj2lAEm.js.gz +0 -0
  41. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js +56 -0
  42. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js.gz +0 -0
  43. package/dist/dashboard/assets/{api-keys-DFY_fZU4.js → api-keys-CUi6UXsR.js} +1 -1
  44. package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
  45. package/dist/dashboard/assets/app-KvsUJUMF.css +2 -0
  46. package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
  47. package/dist/dashboard/assets/{assert-CwdnZARt.js → assert-67xXH5rf.js} +1 -1
  48. package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
  49. package/dist/dashboard/assets/{badge-Dr-uf0Au.js → badge-DC2L43Mj.js} +1 -1
  50. package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
  51. package/dist/dashboard/assets/{banner-BddmMdoS.js → banner-BBVcb0J3.js} +1 -1
  52. package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
  53. package/dist/dashboard/assets/{card-CK0PXWlX.js → card-Cca1XC6G.js} +1 -1
  54. package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
  55. package/dist/dashboard/assets/{cli-tools-CcB6mGh2.js → cli-tools-KvsWa1Jl.js} +1 -1
  56. package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
  57. package/dist/dashboard/assets/{clipboard-button-DGb2P9Yx.js → clipboard-button-DUzEbynq.js} +1 -1
  58. package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
  59. package/dist/dashboard/assets/{dialog-LkfuL50u.js → dialog-DFvWhwTa.js} +1 -1
  60. package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
  61. package/dist/dashboard/assets/{drawer-ZbEZ_84c.js → drawer-CFM2sMX1.js} +1 -1
  62. package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-BJfsVMvA.js → entry-Bqy8RFvM.js} +1 -1
  64. package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
  65. package/dist/dashboard/assets/{entry-CWBY5A0f.js → entry-ClCMCWWQ.js} +3 -3
  66. package/dist/dashboard/assets/entry-ClCMCWWQ.js.gz +0 -0
  67. package/dist/dashboard/assets/{entry-CvC1-Zqx.js → entry-_xHo5x6r.js} +1 -1
  68. package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
  69. package/dist/dashboard/assets/formatters-CWWZ-uCZ.js +1 -0
  70. package/dist/dashboard/assets/formatters-CWWZ-uCZ.js.gz +0 -0
  71. package/dist/dashboard/assets/{github-badge-BxpznFLt.js → github-badge-C9e8Ts7e.js} +1 -1
  72. package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
  73. package/dist/dashboard/assets/{index-CDzP3ovJ.js → index-DFVFrnWW.js} +2 -2
  74. package/dist/dashboard/assets/index-DFVFrnWW.js.gz +0 -0
  75. package/dist/dashboard/assets/{inline-Ae13MQ5w.js → inline-BFUcvllj.js} +1 -1
  76. package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
  77. package/dist/dashboard/assets/{input-D23fo7cp.js → input-C_ILAvBb.js} +1 -1
  78. package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
  79. package/dist/dashboard/assets/{layout-CSsxjj5P.js → layout-CBIyT6Rt.js} +1 -1
  80. package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
  81. package/dist/dashboard/assets/{list-row-dmbwZMDY.js → list-row-ebJ_-Nsb.js} +1 -1
  82. package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
  83. package/dist/dashboard/assets/{live-BG1oUsZZ.js → live-D0mm--ME.js} +1 -1
  84. package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-DSF-e4ho.js → mutation-sMD965oO.js} +1 -1
  86. package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
  87. package/dist/dashboard/assets/{numbers-Bm_aECY7.js → numbers-BO9dKaFr.js} +1 -1
  88. package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
  89. package/dist/dashboard/assets/providers/inception.svg +5 -0
  90. package/dist/dashboard/assets/providers/inception.svg.gz +0 -0
  91. package/dist/dashboard/assets/{query-keys-CF56nYIV.js → query-keys-CfL_Zdoh.js} +1 -1
  92. package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
  93. package/dist/dashboard/assets/{routing-CeA71n7G.js → routing-BnIpi1IJ.js} +1 -1
  94. package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
  95. package/dist/dashboard/assets/{select-KIT0rC6G.js → select-DBNVLdUu.js} +1 -1
  96. package/dist/dashboard/assets/select-DBNVLdUu.js.gz +0 -0
  97. package/dist/dashboard/assets/{settings-B_2gkePf.js → settings-BAFhDgX8.js} +1 -1
  98. package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
  99. package/dist/dashboard/assets/{stack-D7WOSzcX.js → stack-EMrOMt4I.js} +1 -1
  100. package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
  101. package/dist/dashboard/assets/{state-BBxtKZOQ.js → state-CWoo7ty5.js} +1 -1
  102. package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
  103. package/dist/dashboard/assets/{switch-DNS_D5Ea.js → switch-CCjT6IkF.js} +1 -1
  104. package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
  105. package/dist/dashboard/assets/{useMutation-BgoxdoMk.js → useMutation-DjkLHUcE.js} +1 -1
  106. package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
  107. package/dist/dashboard/index.html +1 -1
  108. package/dist/dashboard/index.html.gz +0 -0
  109. package/dist/dashboard/precompressed-assets.json +54 -53
  110. package/migrations/full-postgres/0024_add_pool_provider_cooldowns.sql +12 -0
  111. package/migrations/full-postgres/0025_add_pool_random_strategy.sql +3 -0
  112. package/migrations/lite-sqlite3/0024_add_pool_provider_cooldowns.sql +12 -0
  113. package/migrations/lite-sqlite3/0025_add_pool_random_strategy.sql +20 -0
  114. package/package.json +96 -96
  115. package/scripts/cli/console-menu.ts +5 -2
  116. package/scripts/cli/tray.ts +21 -6
  117. package/src/boot/app.ts +684 -714
  118. package/src/boot/dependencies.ts +65 -62
  119. package/src/config/cooldowns.ts +16 -0
  120. package/src/config/providers.ts +13 -1
  121. package/src/config/resources.ts +70 -70
  122. package/src/config/security.ts +37 -37
  123. package/src/config/spec.ts +371 -358
  124. package/src/console/cli-tools/contracts.ts +10 -4
  125. package/src/console/console-router.ts +233 -225
  126. package/src/console/domain-registration.ts +398 -381
  127. package/src/console/providers/catalog/catalog-projections.ts +2 -6
  128. package/src/console/providers/catalog/contracts.ts +19 -0
  129. package/src/console/providers/catalog/provider-operations.ts +38 -0
  130. package/src/console/providers/catalog/routes.ts +14 -0
  131. package/src/console/providers/catalog/store-helpers.ts +326 -0
  132. package/src/console/providers/catalog/store.ts +1292 -1563
  133. package/src/console/quota/account-quota-global.ts +2 -2
  134. package/src/console/quota/account-quota-shared.ts +1 -1
  135. package/src/console/quota/account-quota-tenant.ts +35 -12
  136. package/src/console/quota/quota-view.ts +2 -2
  137. package/src/console/routing/pools/contracts.ts +41 -1
  138. package/src/console/routing/pools/operations.ts +183 -4
  139. package/src/console/routing/pools/relay-deploy.ts +17 -4
  140. package/src/console/routing/pools/routes.ts +30 -0
  141. package/src/console/routing/pools/store.ts +98 -8
  142. package/src/main.ts +183 -167
  143. package/src/network/outbound-fetch.ts +124 -208
  144. package/src/network/pool/agent.ts +15 -0
  145. package/src/network/pool/pool-state.ts +7 -0
  146. package/src/network/pool/selector.ts +110 -2
  147. package/src/network/pool-cooldown-store.ts +252 -0
  148. package/src/network/pool-health-machine.ts +5 -3
  149. package/src/network/pool-health.ts +36 -9
  150. package/src/observability/event-query.ts +57 -0
  151. package/src/observability/metrics.ts +467 -467
  152. package/src/observability/runtime-metrics.ts +269 -269
  153. package/src/persistence/health-event-flush.ts +2 -0
  154. package/src/persistence/schema.ts +19 -0
  155. package/src/persistence/usage-flush.ts +8 -3
  156. package/src/protocol/canonical-model.ts +1 -1
  157. package/src/protocol/dsml.ts +377 -0
  158. package/src/protocol/gateway-error.ts +232 -232
  159. package/src/{providers/provider-reasoning.ts → protocol/reasoning.ts} +10 -9
  160. package/src/protocol/request/chat.ts +7 -7
  161. package/src/protocol/request/codex.ts +2 -2
  162. package/src/protocol/request/gemini.ts +1 -1
  163. package/src/protocol/request/messages.ts +6 -6
  164. package/src/protocol/request/responses.ts +7 -7
  165. package/src/protocol/response/dsml.ts +12 -133
  166. package/src/{transport → protocol}/translation/attachment-integrity.ts +2 -2
  167. package/src/{transport → protocol}/translation/cache-controls.ts +1 -1
  168. package/src/{transport → protocol}/translation/capabilities.ts +2 -2
  169. package/src/{transport → protocol}/translation/normalize.ts +1 -1
  170. package/src/{transport → protocol}/translation/quirks.ts +1 -1
  171. package/src/{transport → protocol}/translation/search.ts +1 -1
  172. package/src/{transport → protocol}/translation/thinking.ts +23 -1
  173. package/src/{transport → protocol}/translation/tool-id.ts +1 -1
  174. package/src/{transport → protocol}/translation/tool-repair.ts +2 -2
  175. package/src/protocol/upstream-parsing.ts +176 -0
  176. package/src/providers/adapters/compatible-adapter.ts +11 -2
  177. package/src/providers/authentication/oauth-refresh-service.ts +9 -5
  178. package/src/providers/default-registry.ts +22 -5
  179. package/src/providers/discovery/probe-phases.ts +16 -3
  180. package/src/providers/discovery/probing-service.ts +4 -1
  181. package/src/providers/integrations/anthropic.ts +1 -1
  182. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +1 -4
  183. package/src/providers/integrations/buddy/buddy-chat-shared.ts +141 -8
  184. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -1
  185. package/src/providers/integrations/buddy/buddy-growth-shared.ts +0 -82
  186. package/src/providers/integrations/buddy/buddy-identity.ts +25 -0
  187. package/src/providers/integrations/buddy/buddy-server-tools.ts +240 -0
  188. package/src/providers/integrations/buddy/codebuddy/cn-catalog.ts +36 -0
  189. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +6 -31
  190. package/src/providers/integrations/buddy/codebuddy/provider.ts +2 -0
  191. package/src/providers/integrations/buddy/codebuddy/shared.ts +19 -4
  192. package/src/providers/integrations/buddy/turing-device-token.ts +262 -0
  193. package/src/providers/integrations/buddy/workbuddy/provider.ts +2 -0
  194. package/src/providers/integrations/buddy/workbuddy/shared.ts +56 -3
  195. package/src/providers/integrations/claude/claude.ts +1 -1
  196. package/src/providers/integrations/cloudflareai/cloudflareai.ts +1 -1
  197. package/src/providers/integrations/codex/codex-errors.ts +1 -1
  198. package/src/providers/integrations/codex/codex.ts +1 -1
  199. package/src/providers/integrations/factory/factory.ts +1 -1
  200. package/src/providers/integrations/github/github.ts +1 -1
  201. package/src/providers/integrations/grok/grok.ts +1 -1
  202. package/src/providers/integrations/inception/provider.ts +91 -0
  203. package/src/providers/integrations/inferhub.ts +1 -1
  204. package/src/providers/integrations/kiro/kiro-request.ts +1 -1
  205. package/src/providers/integrations/mistral.ts +193 -19
  206. package/src/providers/integrations/ollamacloud-models.ts +50 -0
  207. package/src/providers/integrations/opencode/fingerprint.ts +19 -2
  208. package/src/providers/integrations/opencode/prompt.ts +111 -0
  209. package/src/providers/integrations/opencode/provider.ts +101 -23
  210. package/src/providers/integrations/opencode/tool-definitions.ts +4 -4
  211. package/src/providers/integrations/qoder/qoder-checkin.ts +303 -0
  212. package/src/providers/integrations/qoder/qoder-oauth.ts +12 -1
  213. package/src/providers/integrations/qoder/qoder-realm.ts +20 -0
  214. package/src/providers/integrations/qoder/qoder-text-tools.ts +277 -0
  215. package/src/providers/integrations/qoder/qoder.ts +315 -400
  216. package/src/providers/integrations/shared/install-id.ts +2 -1
  217. package/src/providers/integrations/trae/trae-cache.ts +110 -0
  218. package/src/providers/integrations/trae/trae-oauth.ts +29 -4
  219. package/src/providers/integrations/trae/trae-protocol.ts +2 -1
  220. package/src/providers/integrations/trae/trae.ts +106 -23
  221. package/src/providers/operations/account-error-classification.ts +1684 -0
  222. package/src/providers/operations/account-health-service.ts +27 -1503
  223. package/src/providers/operations/account-reset-service.ts +2 -0
  224. package/src/providers/operations/provider-catalog-service.ts +11 -1
  225. package/src/providers/provider-contracts.ts +7 -4
  226. package/src/providers/provider-metadata.ts +25 -4
  227. package/src/providers/provider-probe.ts +2 -2
  228. package/src/{console → providers}/quota/cache.ts +1 -1
  229. package/src/{console → providers}/quota/refresh.ts +5 -5
  230. package/src/security/cli-client-fingerprint.ts +9 -2
  231. package/src/transport/dispatch/attempt-finalize.ts +114 -12
  232. package/src/transport/dispatch/attempt-loop.ts +3 -0
  233. package/src/transport/dispatch/empty-stream.ts +9 -2
  234. package/src/transport/dispatch/generation.ts +37 -55
  235. package/src/transport/dispatch/handler-deps.ts +32 -0
  236. package/src/transport/dispatch/leases.ts +9 -4
  237. package/src/transport/dispatch/proxy-request.ts +6 -30
  238. package/src/transport/dispatch/responses-compact.ts +22 -43
  239. package/src/transport/dispatch/streaming/finalize.ts +3 -0
  240. package/src/transport/dispatch/streaming/stream.ts +0 -8
  241. package/src/transport/dispatch/systemone.ts +22 -42
  242. package/src/transport/dispatch/token-count.ts +23 -39
  243. package/src/transport/dispatch/upstream.ts +1 -3
  244. package/src/transport/dispatch/websearch.ts +22 -41
  245. package/src/transport/failure-policy.ts +92 -178
  246. package/src/transport/guards/abuse-guard.ts +10 -0
  247. package/src/transport/guards/auth-guard.ts +109 -109
  248. package/src/transport/guards/error-lifecycle.ts +1 -1
  249. package/src/transport/guards/pipeline.ts +220 -220
  250. package/src/transport/guards/request-context.ts +245 -238
  251. package/src/transport/request/degradation.ts +20 -1
  252. package/src/transport/request/inflight.ts +4 -114
  253. package/src/transport/request/preparer.ts +6 -6
  254. package/src/transport/request/state.ts +66 -38
  255. package/src/transport/request/tenant-preferences.ts +1 -1
  256. package/src/transport/routing/route-catalog.ts +75 -43
  257. package/src/transport/routing/router.ts +30 -2
  258. package/src/transport/surface/messages/parse.ts +1 -1
  259. package/src/transport/surface/responses/encode.ts +2 -3
  260. package/src/transport/surface/responses/parse.ts +49 -7
  261. package/src/types/undici.d.ts +33 -0
  262. package/src/workers/buddy-growth-tasks.ts +346 -425
  263. package/src/workers/daily-checkin.ts +17 -6
  264. package/src/workers/quota-refresh-sweep.ts +2 -2
  265. package/src/workers/scheduler.ts +17 -1
  266. package/dist/dashboard/assets/BannedPage-P76QLGJA.js.gz +0 -0
  267. package/dist/dashboard/assets/CliToolDetailPage-DdBggJS9.js.gz +0 -0
  268. package/dist/dashboard/assets/CliToolsPage-aN1iXhhP.js.gz +0 -0
  269. package/dist/dashboard/assets/CombosPage-DE-F1nxZ.js.gz +0 -0
  270. package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js +0 -1
  271. package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js.gz +0 -0
  272. package/dist/dashboard/assets/CustomizationPage-3CUxMy2X.js.gz +0 -0
  273. package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js +0 -1
  274. package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js.gz +0 -0
  275. package/dist/dashboard/assets/LoginPage-B_0FUKWc.js +0 -1
  276. package/dist/dashboard/assets/LoginPage-B_0FUKWc.js.gz +0 -0
  277. package/dist/dashboard/assets/ModelPicker-DKVslqG3.js.gz +0 -0
  278. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js +0 -1
  279. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js.gz +0 -0
  280. package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js +0 -3
  281. package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js.gz +0 -0
  282. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +0 -1
  283. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
  284. package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js +0 -1
  285. package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js.gz +0 -0
  286. package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js +0 -7
  287. package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js.gz +0 -0
  288. package/dist/dashboard/assets/QuotaPage-BjldQwIU.js.gz +0 -0
  289. package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js.gz +0 -0
  290. package/dist/dashboard/assets/SetupPage-_bCaTM0v.js +0 -1
  291. package/dist/dashboard/assets/SetupPage-_bCaTM0v.js.gz +0 -0
  292. package/dist/dashboard/assets/SortableList-fI7JkdIB.js.gz +0 -0
  293. package/dist/dashboard/assets/StudioPage-CHVZqskZ.js.gz +0 -0
  294. package/dist/dashboard/assets/UsagePage-DiFBymHj.js +0 -56
  295. package/dist/dashboard/assets/UsagePage-DiFBymHj.js.gz +0 -0
  296. package/dist/dashboard/assets/api-keys-DFY_fZU4.js.gz +0 -0
  297. package/dist/dashboard/assets/app-BOJ1_xUB.css +0 -2
  298. package/dist/dashboard/assets/app-BOJ1_xUB.css.gz +0 -0
  299. package/dist/dashboard/assets/assert-CwdnZARt.js.gz +0 -0
  300. package/dist/dashboard/assets/badge-Dr-uf0Au.js.gz +0 -0
  301. package/dist/dashboard/assets/banner-BddmMdoS.js.gz +0 -0
  302. package/dist/dashboard/assets/card-CK0PXWlX.js.gz +0 -0
  303. package/dist/dashboard/assets/cli-tools-CcB6mGh2.js.gz +0 -0
  304. package/dist/dashboard/assets/clipboard-button-DGb2P9Yx.js.gz +0 -0
  305. package/dist/dashboard/assets/cog-CdWhsajs.js +0 -1
  306. package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
  307. package/dist/dashboard/assets/dialog-LkfuL50u.js.gz +0 -0
  308. package/dist/dashboard/assets/drawer-ZbEZ_84c.js.gz +0 -0
  309. package/dist/dashboard/assets/entry-BJfsVMvA.js.gz +0 -0
  310. package/dist/dashboard/assets/entry-CWBY5A0f.js.gz +0 -0
  311. package/dist/dashboard/assets/entry-CvC1-Zqx.js.gz +0 -0
  312. package/dist/dashboard/assets/github-badge-BxpznFLt.js.gz +0 -0
  313. package/dist/dashboard/assets/index-CDzP3ovJ.js.gz +0 -0
  314. package/dist/dashboard/assets/inline-Ae13MQ5w.js.gz +0 -0
  315. package/dist/dashboard/assets/input-D23fo7cp.js.gz +0 -0
  316. package/dist/dashboard/assets/layout-CSsxjj5P.js.gz +0 -0
  317. package/dist/dashboard/assets/list-row-dmbwZMDY.js.gz +0 -0
  318. package/dist/dashboard/assets/live-BG1oUsZZ.js.gz +0 -0
  319. package/dist/dashboard/assets/mutation-DSF-e4ho.js.gz +0 -0
  320. package/dist/dashboard/assets/numbers-Bm_aECY7.js.gz +0 -0
  321. package/dist/dashboard/assets/query-keys-CF56nYIV.js.gz +0 -0
  322. package/dist/dashboard/assets/routing-CeA71n7G.js.gz +0 -0
  323. package/dist/dashboard/assets/select-KIT0rC6G.js.gz +0 -0
  324. package/dist/dashboard/assets/settings-B_2gkePf.js.gz +0 -0
  325. package/dist/dashboard/assets/stack-D7WOSzcX.js.gz +0 -0
  326. package/dist/dashboard/assets/state-BBxtKZOQ.js.gz +0 -0
  327. package/dist/dashboard/assets/switch-DNS_D5Ea.js.gz +0 -0
  328. package/dist/dashboard/assets/useMutation-BgoxdoMk.js.gz +0 -0
  329. /package/dist/dashboard/assets/{app-CaXZ9SCn.js → app-BaWd0puM.js} +0 -0
  330. /package/dist/dashboard/assets/{app-CaXZ9SCn.js.gz → app-BaWd0puM.js.gz} +0 -0
@@ -1,425 +1,346 @@
1
- /**
2
- * The buddy growth/credit tasks: one scheduled task per upstream capability.
3
- *
4
- * Six tasks, each independently switchable through `CARTETHYIA_DISABLED_TASKS`,
5
- * because each is a write to an account's growth state and an operator may want
6
- * some of them and not others. Registration lives in `src/boot/dependencies.ts`;
7
- * this module owns what a tick actually does.
8
- *
9
- * Two properties hold for every task here:
10
- *
11
- * 1. **Once per account per day**, enforced by `buddy_task_ledger` — the same
12
- * day-marker pattern the daily check-in uses. Inserting a row that conflicts
13
- * is exactly the "already settled today" answer, so a sweep that ticks every
14
- * few minutes cannot re-POST a claim.
15
- * 2. **A gamification failure never marks an account unhealthy.** No task here
16
- * records a health failure: an endpoint that 404s or answers a business error
17
- * is a display-level miss, and cooling an account over it would take working
18
- * models out of rotation for a growth-page problem.
19
- */
20
- import { and, eq } from "drizzle-orm";
21
- import { randomUUID } from "node:crypto";
22
- import type { CartethyiaDatabase } from "../persistence/db-handle";
23
- import { buddyTaskLedger, providerAccounts } from "../persistence/schema";
24
- import { log } from "../observability/logger";
25
- import { sleep } from "../shared/sleep";
26
- import { buddyAccountUid } from "../providers/integrations/buddy/buddy-oauth-shared";
27
- import {
28
- adoptFirstBuddy,
29
- agreeBuddyAgreement,
30
- BUDDY_REDEEM_TIERS,
31
- claimBuddyCompensation,
32
- claimBuddyGift,
33
- claimBuddyTravel,
34
- claimBuddyTrial,
35
- departBuddyTravel,
36
- drawBuddyLottery,
37
- readBuddyFirstTask,
38
- readBuddyInfo,
39
- readBuddyLotteryChances,
40
- readBuddyRedemption,
41
- readBuddyTravelStatus,
42
- redeemBuddyStreakTier,
43
- useBuddyMakeupCard,
44
- type BuddyGrowthCall,
45
- type BuddyGrowthResult,
46
- type BuddyRedeemTier,
47
- } from "../providers/integrations/buddy/buddy-growth-shared";
48
- import { fetchBuddyRequestUsage } from "../providers/integrations/buddy/buddy-usage-shared";
49
- import { saveBuddyUsageSnapshots } from "../providers/integrations/buddy/buddy-usage-store";
50
- import { fetchBuddyActivityReport } from "../providers/integrations/buddy/buddy-checkin";
51
- import { withDirectFallback } from "./daily-checkin";
52
- import { providerBaseUrl, BUDDY_PROVIDER_IDS } from "../providers/provider-metadata";
53
- import type { FetchLike } from "../providers/quota/quota-contracts";
54
-
55
- /** The task names an operator can switch off. */
56
- export const BUDDY_TASK_NAMES = [
57
- "buddy-streak",
58
- "buddy-redeem",
59
- "buddy-lottery",
60
- "buddy-travel",
61
- "buddy-adopt",
62
- "buddy-trial",
63
- "buddy-activity-report",
64
- ] as const;
65
- export type BuddyTaskName = (typeof BUDDY_TASK_NAMES)[number];
66
-
67
- /** One account a growth task can run for. */
68
- export interface BuddyTaskAccount {
69
- readonly id: string;
70
- readonly providerId: string;
71
- readonly label?: string | null;
72
- }
73
-
74
- export interface BuddyTaskDeps {
75
- readonly db: CartethyiaDatabase;
76
- /** Resolves the stored credential through the refresh-aware path. */
77
- readonly resolveCredential: (providerId: string, accountId: string) => Promise<string>;
78
- readonly fetcher: FetchLike;
79
- /** Accounts to walk; defaults to every buddy account with a credential. */
80
- readonly listAccounts?: () => Promise<readonly BuddyTaskAccount[]>;
81
- /** Injected clock so a test can advance the day. */
82
- readonly now?: () => Date;
83
- /** Pause between accounts, so a pass does not look like a burst. */
84
- readonly interAccountDelayMs?: number;
85
- }
86
-
87
- /** Providers whose growth facade is worth calling. */
88
- function isBuddy(providerId: string): boolean {
89
- return BUDDY_PROVIDER_IDS.has(providerId);
90
- }
91
-
92
- /** The upstream's own day boundary: growth days roll at 00:00 CST. */
93
- export function buddyDayKey(now: Date = new Date()): string {
94
- return new Date(now.getTime() + 8 * 3600_000).toISOString().slice(0, 10);
95
- }
96
-
97
- /**
98
- * Marks one (day, account, task) settled.
99
- *
100
- * Only ever writes a slot, never releases one — the caller is asserting "this
101
- * account is done with this task today", which is exactly what the row means.
102
- * A conflict is the ordinary case after a restart and is not an error.
103
- */
104
- async function markClaimed(
105
- db: CartethyiaDatabase,
106
- dayKey: string,
107
- accountId: string,
108
- task: string,
109
- ): Promise<void> {
110
- try {
111
- await db
112
- .insert(buddyTaskLedger)
113
- .values({ dayKey, accountId, task })
114
- .onConflictDoNothing();
115
- } catch (error) {
116
- log.warn(`[${task}] ledger stamp failed for account=${accountId}`, error as Error);
117
- }
118
- }
119
-
120
- /** True when this (day, account, task) already has a marker. */
121
- async function claimedToday(
122
- db: CartethyiaDatabase,
123
- dayKey: string,
124
- accountId: string,
125
- task: string,
126
- ): Promise<boolean> {
127
- try {
128
- const rows = await db
129
- .select({ dayKey: buddyTaskLedger.dayKey })
130
- .from(buddyTaskLedger)
131
- .where(
132
- and(
133
- eq(buddyTaskLedger.dayKey, dayKey),
134
- eq(buddyTaskLedger.accountId, accountId),
135
- eq(buddyTaskLedger.task, task),
136
- ),
137
- )
138
- .limit(1);
139
- return rows.length > 0;
140
- } catch {
141
- // Unreadable means unknown, never "not claimed": the safe answer is to try,
142
- // and the upstream's own idempotency is the backstop.
143
- return false;
144
- }
145
- }
146
- /** Every credential-bearing buddy account. */
147
- async function defaultAccounts(db: CartethyiaDatabase): Promise<readonly BuddyTaskAccount[]> {
148
- const rows = await db
149
- .select({
150
- id: providerAccounts.id,
151
- providerId: providerAccounts.providerId,
152
- label: providerAccounts.label,
153
- })
154
- .from(providerAccounts)
155
- .where(eq(providerAccounts.status, "active"));
156
- return rows.filter((row) => isBuddy(row.providerId));
157
- }
158
- /**
159
- * Reads the account uid from a JWT, memoised per credential.
160
- *
161
- * A growth pass walks many accounts, and the uid is only ever present in a
162
- * JWT; an opaque API key has none. Decoding the token once per account is
163
- * cheap, but the same credential can appear in several tasks in one pass, so
164
- * the decode is cached per pass instead of per call.
165
- */
166
- function resolveUid(
167
- credential: string,
168
- cache: Map<string, string | undefined>,
169
- ): string | undefined {
170
- const cached = cache.get(credential);
171
- if (cached !== undefined) return cached;
172
- const uid = buddyAccountUid(credential);
173
- cache.set(credential, uid);
174
- return uid;
175
- }
176
-
177
- /** One account's task run, with the credential resolved and the call wired. */
178
- async function forAccount(
179
- uidCache: Map<string, string | undefined>,
180
- deps: BuddyTaskDeps,
181
- account: BuddyTaskAccount,
182
- task: BuddyTaskName,
183
- run: (call: BuddyGrowthCall) => Promise<BuddyGrowthResult | null>,
184
- ): Promise<BuddyGrowthResult | null> {
185
- let credential: string;
186
- try {
187
- credential = await deps.resolveCredential(account.providerId, account.id);
188
- } catch (error) {
189
- log.warn(
190
- `[${task}] account=${account.id} credential unresolved:`,
191
- error instanceof Error ? error : new Error(String(error)),
192
- );
193
- return null;
194
- }
195
- if (credential.length === 0) return null;
196
- const call: BuddyGrowthCall = {
197
- baseUrl: providerBaseUrl(account.providerId) ?? "",
198
- headers: { "X-Product": "SaaS", "Content-Type": "application/json", Accept: "application/json" },
199
- accountId: account.id,
200
- providerId: account.providerId,
201
- credential,
202
- fetcher: withDirectFallback(deps.fetcher) as FetchLike,
203
- ...(resolveUid(credential, uidCache) === undefined
204
- ? {}
205
- : { uid: resolveUid(credential, uidCache) }),
206
- };
207
- if (call.baseUrl.length === 0) return null;
208
- return run(call);
209
- }
210
-
211
- /** Walks the accounts, skipping any already settled today. */
212
- async function runTask(
213
- deps: BuddyTaskDeps,
214
- task: BuddyTaskName,
215
- run: (call: BuddyGrowthCall) => Promise<BuddyGrowthResult | null>,
216
- ): Promise<{ readonly attempted: number; readonly claimed: number }> {
217
- const dayKey = buddyDayKey(deps.now?.() ?? new Date());
218
- const uidCache = new Map<string, string | undefined>();
219
- const accounts = await (deps.listAccounts?.() ?? defaultAccounts(deps.db));
220
- let attempted = 0;
221
- let claimed = 0;
222
- const delay = deps.interAccountDelayMs ?? 0;
223
- for (const [index, account] of accounts.entries()) {
224
- if (index > 0 && delay > 0) await sleep(delay);
225
- if (await claimedToday(deps.db, dayKey, account.id, task)) continue;
226
- attempted += 1;
227
- let result: BuddyGrowthResult | null = null;
228
- try {
229
- result = await forAccount(uidCache, deps, account, task, run);
230
- } catch (error) {
231
- log.warn(
232
- `[${task}] account=${account.id} failed:`,
233
- error instanceof Error ? error : new Error(String(error)),
234
- );
235
- }
236
- if (result !== null) {
237
- log.info(
238
- `[${task}] account=${account.id} provider=${account.providerId} state=${result.state}` +
239
- `${result.credit !== null ? ` credit=${result.credit}` : ""}` +
240
- `${result.error !== null ? ` detail=${result.error}` : ""}`,
241
- );
242
- if (result.state === "claimed" || result.state === "already") claimed += 1;
243
- }
244
- // Marked whether it landed or not: a repeat write the same day can only
245
- // produce the duplicate answer, and not marking would re-POST it forever.
246
- await markClaimed(deps.db, dayKey, account.id, task);
247
- }
248
- return { attempted, claimed };
249
- }
250
-
251
- // ---------------------------------------------------------------------------
252
- // The six tasks
253
- // ---------------------------------------------------------------------------
254
-
255
- /**
256
- * Streak makeup: spend a card on yesterday's miss so the streak does not break.
257
- *
258
- * Everything other than an actual spend is `skipped`, so an account with no
259
- * miss, no card, or no heatmap produces no log noise beyond one info line.
260
- */
261
- export function buddyStreakTask(deps: BuddyTaskDeps) {
262
- return runTask(deps, "buddy-streak", (call) => useBuddyMakeupCard(call));
263
- }
264
-
265
- /**
266
- * Milestone redeem: claim the highest tier the streak has unlocked and not yet
267
- * claimed this month.
268
- */
269
- export function buddyRedeemTask(deps: BuddyTaskDeps) {
270
- return runTask(deps, "buddy-redeem", async (call) => {
271
- const status = await readBuddyRedemption(call);
272
- if (status === null) return null;
273
- // Highest eligible tier first: tiers are the same reward scaled, and a day
274
- // that could claim 28d should not spend itself on 7d.
275
- const tier = [...BUDDY_REDEEM_TIERS].reverse().find((candidate) => {
276
- const state = status.tiers[candidate];
277
- return state === "available" || state === "unknown";
278
- }) as BuddyRedeemTier | undefined;
279
- if (tier === undefined) return null;
280
- return redeemBuddyStreakTier(call, tier);
281
- });
282
- }
283
-
284
- /** Lottery: spend every chance the redeemed tier granted. */
285
- export function buddyLotteryTask(deps: BuddyTaskDeps) {
286
- return runTask(deps, "buddy-lottery", async (call) => {
287
- const chances = await readBuddyLotteryChances(call);
288
- if (chances === null || chances <= 0) return null;
289
- // Bounded: a large balance is still only one task slot, and burning it in a
290
- // tight loop is exactly the burst the upstream risk-gates on.
291
- let last: BuddyGrowthResult | null = null;
292
- for (let draw = 0; draw < Math.min(chances, 10); draw += 1) {
293
- last = await drawBuddyLottery(call);
294
- if (last.state !== "claimed") break;
295
- }
296
- return last;
297
- });
298
- }
299
-
300
- /** Cat travel: depart an idle cat, claim an arrived one. */
301
- export function buddyTravelTask(deps: BuddyTaskDeps) {
302
- return runTask(deps, "buddy-travel", async (call) => {
303
- const status = await readBuddyTravelStatus(call);
304
- if (status === null) return null;
305
- if (status.state === "arrived") {
306
- if (status.recordId === null) return null;
307
- return claimBuddyTravel(call, status.recordId);
308
- }
309
- if (status.state === "idle" && !status.dailyLimitReached) {
310
- return departBuddyTravel(call);
311
- }
312
- // In flight, or today's departure already used: an expected state.
313
- return null;
314
- });
315
- }
316
-
317
- /**
318
- * First-buddy adoption: accept the agreement, then adopt.
319
- *
320
- * Gated on the task actually being offered and unlocked — adopting blind writes
321
- * to an account's growth state on the strength of an endpoint existing.
322
- */
323
- export function buddyAdoptTask(deps: BuddyTaskDeps) {
324
- return runTask(deps, "buddy-adopt", async (call) => {
325
- const info = await readBuddyInfo(call);
326
- if (info === null || info.hasBuddy) return null;
327
- const task = await readBuddyFirstTask(call);
328
- if (task === null) return null;
329
- const agreed = await agreeBuddyAgreement(call);
330
- // Only a hard error stops the sequence; a duplicate agreement is fine.
331
- if (agreed.state === "error") return agreed;
332
- return adoptFirstBuddy(call);
333
- });
334
- }
335
-
336
- /** Trial pack, newcomer gift, and activity compensation — claim if present. */
337
- export function buddyTrialTask(deps: BuddyTaskDeps) {
338
- return runTask(deps, "buddy-trial", async (call) => {
339
- const trial = await claimBuddyTrial(call);
340
- // The gift and the compensation are independent once-per-account claims;
341
- // both answer a business error once taken, which is not a failure here.
342
- await claimBuddyGift(call);
343
- await claimBuddyCompensation(call);
344
- return trial;
345
- });
346
- }
347
- /**
348
- * Growth-activity report: the `/v2/report` batch that lights the login streak and
349
- * scores the daily growth event.
350
- *
351
- * It is a once-a-day task with its own ledger, gated by the same
352
- * (day, account, task) ledger as every other growth task: running it on the
353
- * quota sweep would post two rounds of four events per account per sweep, and
354
- * the sweep runs every minute.
355
- *
356
- * The report is not a credit claim, so its failure never marks an account
357
- * unhealthy — the result is logged and the ledger is stamped either way.
358
- */
359
- export function buddyActivityReportTask(deps: BuddyTaskDeps) {
360
- return runTask(deps, "buddy-activity-report", async (call) => {
361
- if (call.uid === undefined) {
362
- return { state: "skipped", error: "No uid in this credential.", credit: null };
363
- }
364
- const report = await fetchBuddyActivityReport({
365
- providerId: call.providerId,
366
- accountId: call.accountId,
367
- credential: call.credential,
368
- userId: call.uid,
369
- fetcher: call.fetcher,
370
- });
371
- if (report.state === "reported") return { state: "claimed", error: null, credit: null };
372
- if (report.state === "unavailable") return { state: "already", error: null, credit: null };
373
- return { state: "skipped", error: report.error, credit: null };
374
- });
375
- }
376
-
377
- // ---------------------------------------------------------------------------
378
- // Usage sync: the real credit deductions, stored
379
- // ---------------------------------------------------------------------------
380
-
381
- /** Days of usage detail to read. The upstream's own window is 30. */
382
- const USAGE_DAYS = 30;
383
-
384
- /**
385
- * Reads and stores every buddy account's real credit deductions.
386
- *
387
- * Not a growth task — it is a read — but it shares the walk and the credential
388
- * resolution, and it is what gives the console a usage figure the upstream
389
- * actually reported.
390
- */
391
- export async function syncBuddyUsage(deps: BuddyTaskDeps): Promise<number> {
392
- const accounts = await (deps.listAccounts?.() ?? defaultAccounts(deps.db));
393
- let stored = 0;
394
- for (const account of accounts) {
395
- let credential: string;
396
- try {
397
- credential = await deps.resolveCredential(account.providerId, account.id);
398
- } catch {
399
- continue;
400
- }
401
- if (credential.length === 0) continue;
402
- const base = providerBaseUrl(account.providerId);
403
- if (base === undefined || base.length === 0) continue;
404
- const usage = await fetchBuddyRequestUsage({
405
- // The usage-detail endpoint hangs off the provider's own origin, not the
406
- // `/v2` chat prefix.
407
- url: `${base.replace(/\/+$/, "").replace(/\/v2$/, "")}/billing/meter/get-user-request-usage`,
408
- headers: { "Content-Type": "application/json", Accept: "application/json" },
409
- credential,
410
- fetcher: deps.fetcher,
411
- days: USAGE_DAYS,
412
- ...(buddyAccountUid(credential) === undefined
413
- ? {}
414
- : { uid: buddyAccountUid(credential) }),
415
- });
416
- if (usage.rows.length === 0) continue;
417
- stored += await saveBuddyUsageSnapshots(deps.db, account.id, usage.rows);
418
- }
419
- return stored;
420
- }
421
-
422
- /** A stable idempotency token, for callers that need one outside a task. */
423
- export function growthClientToken(prefix: string): string {
424
- return `${prefix}-${randomUUID()}`;
425
- }
1
+ /**
2
+ * The buddy growth/credit tasks: one scheduled task per upstream capability.
3
+ *
4
+ * Six tasks, each independently switchable through `CARTETHYIA_DISABLED_TASKS`,
5
+ * because each is a write to an account's growth state and an operator may want
6
+ * some of them and not others. Registration lives in `src/boot/dependencies.ts`;
7
+ * this module owns what a tick actually does.
8
+ *
9
+ * Two properties hold for every task here:
10
+ *
11
+ * 1. **Once per account per day**, enforced by `buddy_task_ledger` — the same
12
+ * day-marker pattern the daily check-in uses. Inserting a row that conflicts
13
+ * is exactly the "already settled today" answer, so a sweep that ticks every
14
+ * few minutes cannot re-POST a claim.
15
+ * 2. **A gamification failure never marks an account unhealthy.** No task here
16
+ * records a health failure: an endpoint that 404s or answers a business error
17
+ * is a display-level miss, and cooling an account over it would take working
18
+ * models out of rotation for a growth-page problem.
19
+ */
20
+ import { and, eq } from "drizzle-orm";
21
+ import { randomUUID } from "node:crypto";
22
+ import type { CartethyiaDatabase } from "../persistence/db-handle";
23
+ import { buddyTaskLedger, providerAccounts } from "../persistence/schema";
24
+ import { log } from "../observability/logger";
25
+ import { sleep } from "../shared/sleep";
26
+ import { buddyAccountUid } from "../providers/integrations/buddy/buddy-oauth-shared";
27
+ import {
28
+ adoptFirstBuddy,
29
+ agreeBuddyAgreement,
30
+ readBuddyFirstTask,
31
+ readBuddyInfo,
32
+ useBuddyMakeupCard,
33
+ type BuddyGrowthCall,
34
+ type BuddyGrowthResult,
35
+ } from "../providers/integrations/buddy/buddy-growth-shared";
36
+ import { fetchBuddyRequestUsage } from "../providers/integrations/buddy/buddy-usage-shared";
37
+ import { saveBuddyUsageSnapshots } from "../providers/integrations/buddy/buddy-usage-store";
38
+ import { fetchBuddyActivityReport } from "../providers/integrations/buddy/buddy-checkin";
39
+ import { withDirectFallback } from "./daily-checkin";
40
+ import { providerBaseUrl, BUDDY_PROVIDER_IDS } from "../providers/provider-metadata";
41
+ import type { FetchLike } from "../providers/quota/quota-contracts";
42
+
43
+ /** The task names an operator can switch off. */
44
+ export const BUDDY_TASK_NAMES = [
45
+ "buddy-streak",
46
+ "buddy-adopt",
47
+ "buddy-activity-report",
48
+ ] as const;
49
+ export type BuddyTaskName = (typeof BUDDY_TASK_NAMES)[number];
50
+
51
+ /** One account a growth task can run for. */
52
+ export interface BuddyTaskAccount {
53
+ readonly id: string;
54
+ readonly providerId: string;
55
+ readonly label?: string | null;
56
+ }
57
+
58
+ export interface BuddyTaskDeps {
59
+ readonly db: CartethyiaDatabase;
60
+ /** Resolves the stored credential through the refresh-aware path. */
61
+ readonly resolveCredential: (providerId: string, accountId: string) => Promise<string>;
62
+ readonly fetcher: FetchLike;
63
+ /** Accounts to walk; defaults to every buddy account with a credential. */
64
+ readonly listAccounts?: () => Promise<readonly BuddyTaskAccount[]>;
65
+ /** Injected clock so a test can advance the day. */
66
+ readonly now?: () => Date;
67
+ /** Pause between accounts, so a pass does not look like a burst. */
68
+ readonly interAccountDelayMs?: number;
69
+ }
70
+
71
+ /** Providers whose growth facade is worth calling. */
72
+ function isBuddy(providerId: string): boolean {
73
+ return BUDDY_PROVIDER_IDS.has(providerId);
74
+ }
75
+
76
+ /** The upstream's own day boundary: growth days roll at 00:00 CST. */
77
+ export function buddyDayKey(now: Date = new Date()): string {
78
+ return new Date(now.getTime() + 8 * 3600_000).toISOString().slice(0, 10);
79
+ }
80
+
81
+ /**
82
+ * Marks one (day, account, task) settled.
83
+ *
84
+ * Only ever writes a slot, never releases one — the caller is asserting "this
85
+ * account is done with this task today", which is exactly what the row means.
86
+ * A conflict is the ordinary case after a restart and is not an error.
87
+ */
88
+ async function markClaimed(
89
+ db: CartethyiaDatabase,
90
+ dayKey: string,
91
+ accountId: string,
92
+ task: string,
93
+ ): Promise<void> {
94
+ try {
95
+ await db
96
+ .insert(buddyTaskLedger)
97
+ .values({ dayKey, accountId, task })
98
+ .onConflictDoNothing();
99
+ } catch (error) {
100
+ log.warn(`[${task}] ledger stamp failed for account=${accountId}`, error as Error);
101
+ }
102
+ }
103
+
104
+ /** True when this (day, account, task) already has a marker. */
105
+ async function claimedToday(
106
+ db: CartethyiaDatabase,
107
+ dayKey: string,
108
+ accountId: string,
109
+ task: string,
110
+ ): Promise<boolean> {
111
+ try {
112
+ const rows = await db
113
+ .select({ dayKey: buddyTaskLedger.dayKey })
114
+ .from(buddyTaskLedger)
115
+ .where(
116
+ and(
117
+ eq(buddyTaskLedger.dayKey, dayKey),
118
+ eq(buddyTaskLedger.accountId, accountId),
119
+ eq(buddyTaskLedger.task, task),
120
+ ),
121
+ )
122
+ .limit(1);
123
+ return rows.length > 0;
124
+ } catch {
125
+ // Unreadable means unknown, never "not claimed": the safe answer is to try,
126
+ // and the upstream's own idempotency is the backstop.
127
+ return false;
128
+ }
129
+ }
130
+ /** Every credential-bearing buddy account. */
131
+ async function defaultAccounts(db: CartethyiaDatabase): Promise<readonly BuddyTaskAccount[]> {
132
+ const rows = await db
133
+ .select({
134
+ id: providerAccounts.id,
135
+ providerId: providerAccounts.providerId,
136
+ label: providerAccounts.label,
137
+ })
138
+ .from(providerAccounts)
139
+ .where(eq(providerAccounts.status, "active"));
140
+ return rows.filter((row) => isBuddy(row.providerId));
141
+ }
142
+ /**
143
+ * Reads the account uid from a JWT, memoised per credential.
144
+ *
145
+ * A growth pass walks many accounts, and the uid is only ever present in a
146
+ * JWT; an opaque API key has none. Decoding the token once per account is
147
+ * cheap, but the same credential can appear in several tasks in one pass, so
148
+ * the decode is cached per pass instead of per call.
149
+ */
150
+ function resolveUid(
151
+ credential: string,
152
+ cache: Map<string, string | undefined>,
153
+ ): string | undefined {
154
+ const cached = cache.get(credential);
155
+ if (cached !== undefined) return cached;
156
+ const uid = buddyAccountUid(credential);
157
+ cache.set(credential, uid);
158
+ return uid;
159
+ }
160
+
161
+ /** One account's task run, with the credential resolved and the call wired. */
162
+ async function forAccount(
163
+ uidCache: Map<string, string | undefined>,
164
+ deps: BuddyTaskDeps,
165
+ account: BuddyTaskAccount,
166
+ task: BuddyTaskName,
167
+ run: (call: BuddyGrowthCall) => Promise<BuddyGrowthResult | null>,
168
+ ): Promise<BuddyGrowthResult | null> {
169
+ let credential: string;
170
+ try {
171
+ credential = await deps.resolveCredential(account.providerId, account.id);
172
+ } catch (error) {
173
+ log.warn(
174
+ `[${task}] account=${account.id} credential unresolved:`,
175
+ error instanceof Error ? error : new Error(String(error)),
176
+ );
177
+ return null;
178
+ }
179
+ if (credential.length === 0) return null;
180
+ const call: BuddyGrowthCall = {
181
+ baseUrl: providerBaseUrl(account.providerId) ?? "",
182
+ headers: { "X-Product": "SaaS", "Content-Type": "application/json", Accept: "application/json" },
183
+ accountId: account.id,
184
+ providerId: account.providerId,
185
+ credential,
186
+ fetcher: withDirectFallback(deps.fetcher) as FetchLike,
187
+ ...(resolveUid(credential, uidCache) === undefined
188
+ ? {}
189
+ : { uid: resolveUid(credential, uidCache) }),
190
+ };
191
+ if (call.baseUrl.length === 0) return null;
192
+ return run(call);
193
+ }
194
+
195
+ /** Walks the accounts, skipping any already settled today. */
196
+ async function runTask(
197
+ deps: BuddyTaskDeps,
198
+ task: BuddyTaskName,
199
+ run: (call: BuddyGrowthCall) => Promise<BuddyGrowthResult | null>,
200
+ ): Promise<{ readonly attempted: number; readonly claimed: number }> {
201
+ const dayKey = buddyDayKey(deps.now?.() ?? new Date());
202
+ const uidCache = new Map<string, string | undefined>();
203
+ const accounts = await (deps.listAccounts?.() ?? defaultAccounts(deps.db));
204
+ let attempted = 0;
205
+ let claimed = 0;
206
+ const delay = deps.interAccountDelayMs ?? 0;
207
+ for (const [index, account] of accounts.entries()) {
208
+ if (index > 0 && delay > 0) await sleep(delay);
209
+ if (await claimedToday(deps.db, dayKey, account.id, task)) continue;
210
+ attempted += 1;
211
+ let result: BuddyGrowthResult | null = null;
212
+ try {
213
+ result = await forAccount(uidCache, deps, account, task, run);
214
+ } catch (error) {
215
+ log.warn(
216
+ `[${task}] account=${account.id} failed:`,
217
+ error instanceof Error ? error : new Error(String(error)),
218
+ );
219
+ }
220
+ if (result !== null) {
221
+ log.info(
222
+ `[${task}] account=${account.id} provider=${account.providerId} state=${result.state}` +
223
+ `${result.credit !== null ? ` credit=${result.credit}` : ""}` +
224
+ `${result.error !== null ? ` detail=${result.error}` : ""}`,
225
+ );
226
+ if (result.state === "claimed" || result.state === "already") claimed += 1;
227
+ }
228
+ // Marked whether it landed or not: a repeat write the same day can only
229
+ // produce the duplicate answer, and not marking would re-POST it forever.
230
+ await markClaimed(deps.db, dayKey, account.id, task);
231
+ }
232
+ return { attempted, claimed };
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // The six tasks
237
+ // ---------------------------------------------------------------------------
238
+
239
+ /**
240
+ * Streak makeup: spend a card on yesterday's miss so the streak does not break.
241
+ *
242
+ * Everything other than an actual spend is `skipped`, so an account with no
243
+ * miss, no card, or no heatmap produces no log noise beyond one info line.
244
+ */
245
+ export function buddyStreakTask(deps: BuddyTaskDeps) {
246
+ return runTask(deps, "buddy-streak", (call) => useBuddyMakeupCard(call));
247
+ }
248
+
249
+ /**
250
+ * First-buddy adoption: accept the agreement, then adopt.
251
+ *
252
+ * Gated on the task actually being offered and unlocked — adopting blind writes
253
+ * to an account's growth state on the strength of an endpoint existing.
254
+ */
255
+ export function buddyAdoptTask(deps: BuddyTaskDeps) {
256
+ return runTask(deps, "buddy-adopt", async (call) => {
257
+ const info = await readBuddyInfo(call);
258
+ if (info === null || info.hasBuddy) return null;
259
+ const task = await readBuddyFirstTask(call);
260
+ if (task === null) return null;
261
+ const agreed = await agreeBuddyAgreement(call);
262
+ // Only a hard error stops the sequence; a duplicate agreement is fine.
263
+ if (agreed.state === "error") return agreed;
264
+ return adoptFirstBuddy(call);
265
+ });
266
+ }
267
+
268
+ /**
269
+ * Growth-activity report: the `/v2/report` batch that lights the login streak and
270
+ * scores the daily growth event.
271
+ *
272
+ * It is a once-a-day task with its own ledger, gated by the same
273
+ * (day, account, task) ledger as every other growth task: running it on the
274
+ * quota sweep would post two rounds of four events per account per sweep, and
275
+ * the sweep runs every minute.
276
+ *
277
+ * The report is not a credit claim, so its failure never marks an account
278
+ * unhealthy — the result is logged and the ledger is stamped either way.
279
+ */
280
+ export function buddyActivityReportTask(deps: BuddyTaskDeps) {
281
+ return runTask(deps, "buddy-activity-report", async (call) => {
282
+ if (call.uid === undefined) {
283
+ return { state: "skipped", error: "No uid in this credential.", credit: null };
284
+ }
285
+ const report = await fetchBuddyActivityReport({
286
+ providerId: call.providerId,
287
+ accountId: call.accountId,
288
+ credential: call.credential,
289
+ userId: call.uid,
290
+ fetcher: call.fetcher,
291
+ });
292
+ if (report.state === "reported") return { state: "claimed", error: null, credit: null };
293
+ if (report.state === "unavailable") return { state: "already", error: null, credit: null };
294
+ return { state: "skipped", error: report.error, credit: null };
295
+ });
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Usage sync: the real credit deductions, stored
300
+ // ---------------------------------------------------------------------------
301
+
302
+ /** Days of usage detail to read. The upstream's own window is 30. */
303
+ const USAGE_DAYS = 30;
304
+
305
+ /**
306
+ * Reads and stores every buddy account's real credit deductions.
307
+ *
308
+ * Not a growth task — it is a read — but it shares the walk and the credential
309
+ * resolution, and it is what gives the console a usage figure the upstream
310
+ * actually reported.
311
+ */
312
+ export async function syncBuddyUsage(deps: BuddyTaskDeps): Promise<number> {
313
+ const accounts = await (deps.listAccounts?.() ?? defaultAccounts(deps.db));
314
+ let stored = 0;
315
+ for (const account of accounts) {
316
+ let credential: string;
317
+ try {
318
+ credential = await deps.resolveCredential(account.providerId, account.id);
319
+ } catch {
320
+ continue;
321
+ }
322
+ if (credential.length === 0) continue;
323
+ const base = providerBaseUrl(account.providerId);
324
+ if (base === undefined || base.length === 0) continue;
325
+ const usage = await fetchBuddyRequestUsage({
326
+ // The usage-detail endpoint hangs off the provider's own origin, not the
327
+ // `/v2` chat prefix.
328
+ url: `${base.replace(/\/+$/, "").replace(/\/v2$/, "")}/billing/meter/get-user-request-usage`,
329
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
330
+ credential,
331
+ fetcher: deps.fetcher,
332
+ days: USAGE_DAYS,
333
+ ...(buddyAccountUid(credential) === undefined
334
+ ? {}
335
+ : { uid: buddyAccountUid(credential) }),
336
+ });
337
+ if (usage.rows.length === 0) continue;
338
+ stored += await saveBuddyUsageSnapshots(deps.db, account.id, usage.rows);
339
+ }
340
+ return stored;
341
+ }
342
+
343
+ /** A stable idempotency token, for callers that need one outside a task. */
344
+ export function growthClientToken(prefix: string): string {
345
+ return `${prefix}-${randomUUID()}`;
346
+ }