hive-am 1.0.0 → 1.2.0

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 (268) hide show
  1. package/CHANGELOG.md +114 -1
  2. package/README.md +34 -4
  3. package/package.json +13 -2
  4. package/server/dist/api.js +42 -677
  5. package/server/dist/connections/addressing.js +87 -0
  6. package/server/dist/connections/channel-skill.js +62 -0
  7. package/server/dist/connections/fake.js +1 -0
  8. package/server/dist/connections/files.js +45 -1
  9. package/server/dist/connections/format.js +9 -1
  10. package/server/dist/connections/manager.js +60 -3
  11. package/server/dist/connections/outbound.js +2 -1
  12. package/server/dist/connections/prompt.js +12 -3
  13. package/server/dist/connections/rate.js +20 -0
  14. package/server/dist/connections/router.js +55 -22
  15. package/server/dist/connections/secrets.js +52 -0
  16. package/server/dist/connections/store.js +6 -43
  17. package/server/dist/connections/telegram.js +105 -14
  18. package/server/dist/cron.js +138 -0
  19. package/server/dist/db/json.js +16 -0
  20. package/server/dist/db/migrations.js +199 -0
  21. package/server/dist/db.js +98 -196
  22. package/server/dist/errors.js +11 -0
  23. package/server/dist/git/browse.js +99 -0
  24. package/server/dist/git/ops.js +136 -19
  25. package/server/dist/git/repo.js +2 -1
  26. package/server/dist/history/claude.js +33 -20
  27. package/server/dist/history/index.js +17 -9
  28. package/server/dist/history/kiro.js +17 -12
  29. package/server/dist/history/lines.js +12 -0
  30. package/server/dist/history/opencode.js +18 -1
  31. package/server/dist/http/body.js +46 -0
  32. package/server/dist/http/errors.js +21 -0
  33. package/server/dist/http/guards.js +44 -0
  34. package/server/dist/http/router.js +24 -0
  35. package/server/dist/http/routes/agents.js +103 -0
  36. package/server/dist/http/routes/channel.js +16 -0
  37. package/server/dist/http/routes/colonies.js +9 -0
  38. package/server/dist/http/routes/connections.js +36 -0
  39. package/server/dist/http/routes/fs.js +22 -0
  40. package/server/dist/http/routes/git.js +90 -0
  41. package/server/dist/http/routes/meta.js +11 -0
  42. package/server/dist/http/routes/notebook.js +34 -0
  43. package/server/dist/http/routes/orchestration.js +32 -0
  44. package/server/dist/http/routes/resources.js +68 -0
  45. package/server/dist/http/routes/schedules.js +40 -0
  46. package/server/dist/http/routes/skills.js +47 -0
  47. package/server/dist/http/routes/terminals.js +18 -0
  48. package/server/dist/http/routes/types.js +36 -0
  49. package/server/dist/http/validate.js +65 -0
  50. package/server/dist/index.js +74 -4
  51. package/server/dist/instructions.js +23 -11
  52. package/server/dist/objects/agent.js +49 -0
  53. package/server/dist/objects/database.js +451 -0
  54. package/server/dist/objects/db-history.js +79 -0
  55. package/server/dist/objects/docker-info.js +75 -0
  56. package/server/dist/objects/docker.js +285 -0
  57. package/server/dist/objects/driver-props.js +196 -0
  58. package/server/dist/objects/exec.js +16 -0
  59. package/server/dist/objects/http-history.js +44 -0
  60. package/server/dist/objects/http-script.js +644 -0
  61. package/server/dist/objects/http.js +735 -0
  62. package/server/dist/objects/index.js +445 -0
  63. package/server/dist/objects/model.js +250 -0
  64. package/server/dist/objects/runner.js +211 -0
  65. package/server/dist/objects/stats.js +106 -0
  66. package/server/dist/objects/store.js +115 -0
  67. package/server/dist/objects/supervisor.js +111 -0
  68. package/server/dist/objects/templates.js +67 -0
  69. package/server/dist/providers/claude.js +5 -13
  70. package/server/dist/providers/index.js +7 -1
  71. package/server/dist/providers/kiro.js +4 -5
  72. package/server/dist/providers/opencode-server.js +164 -0
  73. package/server/dist/providers/opencode.js +60 -37
  74. package/server/dist/providers/preamble.js +2 -0
  75. package/server/dist/providers/spawn.js +31 -3
  76. package/server/dist/runtime.js +10 -7
  77. package/server/dist/schedules.js +222 -0
  78. package/server/dist/seed.js +15 -11
  79. package/server/dist/services/agents.js +90 -0
  80. package/server/dist/services/colonies.js +38 -0
  81. package/server/dist/services/connections.js +76 -0
  82. package/server/dist/services/notebook.js +22 -0
  83. package/server/dist/services/providers.js +27 -0
  84. package/server/dist/services/schedules.js +18 -0
  85. package/server/dist/sessions-summary.js +51 -0
  86. package/server/dist/skills/defaults.js +120 -0
  87. package/server/dist/skills/notebook.js +3 -8
  88. package/server/dist/terminals.js +177 -0
  89. package/server/dist/wake.js +149 -0
  90. package/server/dist/watch.js +201 -0
  91. package/server/mcp/dispatch.mjs +169 -1
  92. package/web/.next/BUILD_ID +1 -1
  93. package/web/.next/app-build-manifest.json +145 -85
  94. package/web/.next/app-path-routes-manifest.json +5 -2
  95. package/web/.next/build-manifest.json +14 -14
  96. package/web/.next/prerender-manifest.json +85 -37
  97. package/web/.next/react-loadable-manifest.json +26 -1
  98. package/web/.next/required-server-files.json +13 -1
  99. package/web/.next/routes-manifest.json +32 -0
  100. package/web/.next/server/app/_not-found/page.js +2 -2
  101. package/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  102. package/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  103. package/web/.next/server/app/_not-found.html +1 -1
  104. package/web/.next/server/app/_not-found.rsc +12 -11
  105. package/web/.next/server/app/agents/[id]/page.js +4 -4
  106. package/web/.next/server/app/agents/[id]/page.js.nft.json +1 -1
  107. package/web/.next/server/app/agents/[id]/page_client-reference-manifest.js +1 -1
  108. package/web/.next/server/app/agents/page.js +2 -2
  109. package/web/.next/server/app/agents/page.js.nft.json +1 -1
  110. package/web/.next/server/app/agents/page_client-reference-manifest.js +1 -1
  111. package/web/.next/server/app/agents.html +1 -1
  112. package/web/.next/server/app/agents.rsc +14 -13
  113. package/web/.next/server/app/connections/page.js +2 -2
  114. package/web/.next/server/app/connections/page.js.nft.json +1 -1
  115. package/web/.next/server/app/connections/page_client-reference-manifest.js +1 -1
  116. package/web/.next/server/app/connections.html +1 -1
  117. package/web/.next/server/app/connections.rsc +14 -13
  118. package/web/.next/server/app/index.html +1 -1
  119. package/web/.next/server/app/index.rsc +14 -13
  120. package/web/.next/server/app/page.js +2 -2
  121. package/web/.next/server/app/page.js.nft.json +1 -1
  122. package/web/.next/server/app/page_client-reference-manifest.js +1 -1
  123. package/web/.next/server/app/relations/page.js +2 -2
  124. package/web/.next/server/app/relations/page.js.nft.json +1 -1
  125. package/web/.next/server/app/relations/page_client-reference-manifest.js +1 -1
  126. package/web/.next/server/app/relations.html +1 -1
  127. package/web/.next/server/app/relations.rsc +14 -13
  128. package/web/.next/server/app/resources/[id]/page.js +2 -0
  129. package/web/.next/server/app/resources/[id]/page.js.nft.json +1 -0
  130. package/web/.next/server/app/resources/[id]/page_client-reference-manifest.js +1 -0
  131. package/web/.next/server/app/resources/page.js +2 -0
  132. package/web/.next/server/app/resources/page.js.nft.json +1 -0
  133. package/web/.next/server/app/resources/page_client-reference-manifest.js +1 -0
  134. package/web/.next/server/app/resources.html +1 -0
  135. package/web/.next/server/app/resources.meta +15 -0
  136. package/web/.next/server/app/resources.rsc +25 -0
  137. package/web/.next/server/app/schedules/page.js +2 -0
  138. package/web/.next/server/app/schedules/page.js.nft.json +1 -0
  139. package/web/.next/server/app/schedules/page_client-reference-manifest.js +1 -0
  140. package/web/.next/server/app/schedules.html +1 -0
  141. package/web/.next/server/app/schedules.meta +15 -0
  142. package/web/.next/server/app/schedules.rsc +25 -0
  143. package/web/.next/server/app/sessions/page.js +2 -2
  144. package/web/.next/server/app/sessions/page.js.nft.json +1 -1
  145. package/web/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
  146. package/web/.next/server/app/sessions.html +1 -1
  147. package/web/.next/server/app/sessions.rsc +14 -13
  148. package/web/.next/server/app/skills/page.js +2 -2
  149. package/web/.next/server/app/skills/page.js.nft.json +1 -1
  150. package/web/.next/server/app/skills/page_client-reference-manifest.js +1 -1
  151. package/web/.next/server/app/skills.html +1 -1
  152. package/web/.next/server/app/skills.rsc +14 -13
  153. package/web/.next/server/app/types/page.js +2 -2
  154. package/web/.next/server/app/types/page.js.nft.json +1 -1
  155. package/web/.next/server/app/types/page_client-reference-manifest.js +1 -1
  156. package/web/.next/server/app/types.html +1 -1
  157. package/web/.next/server/app/types.rsc +14 -13
  158. package/web/.next/server/app-paths-manifest.json +5 -2
  159. package/web/.next/server/chunks/143.js +1 -1
  160. package/web/.next/server/chunks/274.js +1 -0
  161. package/web/.next/server/chunks/306.js +1 -0
  162. package/web/.next/server/chunks/423.js +1 -0
  163. package/web/.next/server/chunks/505.js +6 -0
  164. package/web/.next/server/chunks/555.js +1 -0
  165. package/web/.next/server/chunks/612.js +1 -0
  166. package/web/.next/server/chunks/638.js +1 -0
  167. package/web/.next/server/chunks/692.js +18 -0
  168. package/web/.next/server/chunks/716.js +2 -0
  169. package/web/.next/server/chunks/790.js +11 -0
  170. package/web/.next/server/chunks/802.js +1 -0
  171. package/web/.next/server/chunks/890.js +1 -1
  172. package/web/.next/server/chunks/919.js +1 -0
  173. package/web/.next/server/chunks/950.js +6 -0
  174. package/web/.next/server/middleware-build-manifest.js +1 -1
  175. package/web/.next/server/middleware-react-loadable-manifest.js +1 -1
  176. package/web/.next/server/next-font-manifest.js +1 -1
  177. package/web/.next/server/next-font-manifest.json +1 -1
  178. package/web/.next/server/pages/404.html +1 -1
  179. package/web/.next/server/pages/500.html +1 -1
  180. package/web/.next/server/server-reference-manifest.json +1 -1
  181. package/web/.next/static/chunks/0937d497-114a2a3de515565e.js +1 -0
  182. package/web/.next/static/chunks/176-aeaf907a6ea5627d.js +1 -0
  183. package/web/.next/static/chunks/291-efae53ea5f7ee92d.js +1 -0
  184. package/web/.next/static/chunks/2b9ff8ee-969e65cc354b2150.js +1 -0
  185. package/web/.next/static/chunks/30-9adcbe92b15a6815.js +1 -0
  186. package/web/.next/static/chunks/316-26da9cc233ba7557.js +1 -0
  187. package/web/.next/static/chunks/350-56599dc3bf06066d.js +1 -0
  188. package/web/.next/static/chunks/486-13e08542f3a1cbac.js +1 -0
  189. package/web/.next/static/chunks/491-6b4e6201f5110dff.js +1 -0
  190. package/web/.next/static/chunks/517-ccaf8cc5c733cbd8.js +1 -0
  191. package/web/.next/static/chunks/545.bfbd92b7632c09de.js +1 -0
  192. package/web/.next/static/chunks/{805-d946ebedb7e85f73.js → 562-7c2e33d838e869bf.js} +1 -1
  193. package/web/.next/static/chunks/581-3ad5c4343dad3f50.js +1 -0
  194. package/web/.next/static/chunks/586-f7017d585a84738d.js +1 -0
  195. package/web/.next/static/chunks/657.b4c39e676c3e20dc.js +1 -0
  196. package/web/.next/static/chunks/701.6abcaf5e60463eef.js +6 -0
  197. package/web/.next/static/chunks/705-a16e1fe09be1ac7f.js +1 -0
  198. package/web/.next/static/chunks/706-fa2be0f10ffebe6a.js +1 -0
  199. package/web/.next/static/chunks/837-33c7480524f48577.js +1 -0
  200. package/web/.next/static/chunks/839-23c5508622fae942.js +1 -0
  201. package/web/.next/static/chunks/844-fe55292e0701f436.js +1 -0
  202. package/web/.next/static/chunks/89-b194fcffe5120c44.js +1 -0
  203. package/web/.next/static/chunks/97.594543c2196de524.js +1 -0
  204. package/web/.next/static/chunks/app/_not-found/page-238a7b80bda6b4ae.js +1 -0
  205. package/web/.next/static/chunks/app/agents/[id]/page-4af6c246c4ca7a97.js +1 -0
  206. package/web/.next/static/chunks/app/agents/page-f4c4c80f955f3967.js +1 -0
  207. package/web/.next/static/chunks/app/connections/page-0936beaf02d863c6.js +1 -0
  208. package/web/.next/static/chunks/app/layout-87ebb70bd9cb8443.js +1 -0
  209. package/web/.next/static/chunks/app/page-a7f292117b7d76bf.js +1 -0
  210. package/web/.next/static/chunks/app/relations/page-fba25393c8688714.js +1 -0
  211. package/web/.next/static/chunks/app/resources/[id]/page-d110d89bb8b5fca7.js +1 -0
  212. package/web/.next/static/chunks/app/resources/page-bbd0b450d3f3131d.js +1 -0
  213. package/web/.next/static/chunks/app/schedules/page-a1124397fc760413.js +1 -0
  214. package/web/.next/static/chunks/app/sessions/page-0e322811b0cde724.js +1 -0
  215. package/web/.next/static/chunks/app/skills/page-22ea0ef89d9f7a13.js +1 -0
  216. package/web/.next/static/chunks/app/types/page-18c4e1db2724d41d.js +1 -0
  217. package/web/.next/static/chunks/fcafe1ac.366e4950d3b0d4b7.js +18 -0
  218. package/web/.next/static/chunks/framework-bbac72adbbe2d328.js +1 -0
  219. package/web/.next/static/chunks/main-app-6086068e5fb5eacd.js +1 -0
  220. package/web/.next/static/chunks/main-c15f3fee674e0303.js +1 -0
  221. package/web/.next/static/chunks/pages/_app-d1670a2c6ce392fc.js +1 -0
  222. package/web/.next/static/chunks/pages/_error-95aac014d202fa7a.js +1 -0
  223. package/web/.next/static/chunks/webpack-ef515e41a83a16ac.js +1 -0
  224. package/web/.next/static/css/945795da867f5250.css +1 -0
  225. package/web/.next/static/css/dd4e32c285f75776.css +1 -0
  226. package/web/.next/static/p4S3i9kvO359GW91DQiHu/_buildManifest.js +1 -0
  227. package/web/.next/trace +29 -23
  228. package/web/.next/types/app/resources/[id]/page.ts +84 -0
  229. package/web/.next/types/app/resources/page.ts +84 -0
  230. package/web/.next/types/app/schedules/page.ts +84 -0
  231. package/web/.next/types/routes.d.ts +7 -2
  232. package/web/.next/types/validator.ts +27 -0
  233. package/web/next.config.mjs +4 -0
  234. package/web/.next/server/chunks/149.js +0 -11
  235. package/web/.next/server/chunks/247.js +0 -1
  236. package/web/.next/server/chunks/851.js +0 -1
  237. package/web/.next/server/chunks/932.js +0 -2
  238. package/web/.next/static/84BpRSahg2BTwk3mX6AG8/_buildManifest.js +0 -1
  239. package/web/.next/static/chunks/0937d497-44abb0f9355ace92.js +0 -1
  240. package/web/.next/static/chunks/2b9ff8ee-c3b61ac1e608c57d.js +0 -1
  241. package/web/.next/static/chunks/351-65da8c1a6e25f93b.js +0 -1
  242. package/web/.next/static/chunks/463-6ae35cdefdc59169.js +0 -1
  243. package/web/.next/static/chunks/493-92266798be872b8f.js +0 -1
  244. package/web/.next/static/chunks/517-eb26c72f6059aa26.js +0 -1
  245. package/web/.next/static/chunks/657.f1e03b7b65eba1d9.js +0 -1
  246. package/web/.next/static/chunks/687-3a4205f51be38602.js +0 -1
  247. package/web/.next/static/chunks/837-fd7623601dc78ed8.js +0 -1
  248. package/web/.next/static/chunks/850-318f32484989dd59.js +0 -1
  249. package/web/.next/static/chunks/89-ace2e1aa63babfdf.js +0 -1
  250. package/web/.next/static/chunks/951-e250b541b67da7ce.js +0 -1
  251. package/web/.next/static/chunks/app/_not-found/page-bbd54d7b9a55d7ae.js +0 -1
  252. package/web/.next/static/chunks/app/agents/[id]/page-5eaf094fec246f1b.js +0 -1
  253. package/web/.next/static/chunks/app/agents/page-e9b17f1c3439ef98.js +0 -1
  254. package/web/.next/static/chunks/app/connections/page-c6afd90abd82d47a.js +0 -1
  255. package/web/.next/static/chunks/app/layout-c415d5a847cd94b3.js +0 -1
  256. package/web/.next/static/chunks/app/page-6fc87fc5a128a204.js +0 -1
  257. package/web/.next/static/chunks/app/relations/page-0355a93a8eb67989.js +0 -1
  258. package/web/.next/static/chunks/app/sessions/page-beae9a74a07d69e0.js +0 -1
  259. package/web/.next/static/chunks/app/skills/page-4f839ac913e26d32.js +0 -1
  260. package/web/.next/static/chunks/app/types/page-4f6b99af3232f861.js +0 -1
  261. package/web/.next/static/chunks/framework-0e68884e76d9a8d3.js +0 -1
  262. package/web/.next/static/chunks/main-app-d6f73ccd41f325c5.js +0 -1
  263. package/web/.next/static/chunks/main-cd22509db15764e2.js +0 -1
  264. package/web/.next/static/chunks/pages/_app-0f75d689c738a6fc.js +0 -1
  265. package/web/.next/static/chunks/pages/_error-d481f6d49fb4455d.js +0 -1
  266. package/web/.next/static/chunks/webpack-d40de72e52f65f79.js +0 -1
  267. package/web/.next/static/css/abe1a6216491f944.css +0 -1
  268. /package/web/.next/static/{84BpRSahg2BTwk3mX6AG8 → p4S3i9kvO359GW91DQiHu}/_ssgManifest.js +0 -0
@@ -0,0 +1,52 @@
1
+ import { connections } from './store.js';
2
+ /**
3
+ * Last line of defence for text an agent sends to a chat. The instructions tell the agent never to share secrets, but a model can be
4
+ * talked into it (by anyone in a group, or by an admin who does not realise what the output holds), so the text is checked here
5
+ * before it leaves this machine. It looks for well-known credential shapes and for the exact secrets this server knows about.
6
+ * It cannot recognise every secret: it is a net under the rule, not a replacement for it.
7
+ */
8
+ const SHAPES = [
9
+ [/-----BEGIN [A-Z ]*PRIVATE KEY(?: BLOCK)?-----/, 'a private key'],
10
+ [/\b\d{8,10}:[A-Za-z0-9_-]{30,}\b/, 'a Telegram bot token'],
11
+ [/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/, 'an AWS access key'],
12
+ [/\bgh[pousr]_[A-Za-z0-9]{30,}\b|\bgithub_pat_[A-Za-z0-9_]{30,}\b/, 'a GitHub token'],
13
+ [/\bxox[abprs]-[A-Za-z0-9-]{10,}\b/, 'a Slack token'],
14
+ [/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/, 'a Stripe key'],
15
+ [/\bsk-[A-Za-z0-9_-]{32,}\b/, 'an API key'],
16
+ [/\bAIza[0-9A-Za-z_-]{35}\b/, 'a Google API key'],
17
+ [/\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/, 'a JWT'],
18
+ [/\bBearer\s+[A-Za-z0-9._~+/=-]{24,}/, 'a bearer token'],
19
+ [/\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s/@]{3,}@[^\s]+/i, 'a URL with a password in it'],
20
+ ];
21
+ /** `NAME=value` / `name: value` where the name says it is a secret and the value looks real (not a placeholder). */
22
+ const ASSIGN = /\b([A-Za-z0-9_.-]*(?:password|passwd|pwd|secret|token|api[_-]?key|private[_-]?key|access[_-]?key|credential|auth)[A-Za-z0-9_.-]*)\s*["']?\s*[:=]\s*["']?([^\s"'`,;]{8,})/gi;
23
+ const PLACEHOLDER = /^(<.*>|\{.*\}|\$\{?\w+\}?|\*+|x{4,}|\.{3,}|your[_-]|example|changeme|redacted|none|null|undefined|true|false|process\.env|env\.)/i;
24
+ const SECRET_ENV = /(password|passwd|secret|token|api[_-]?key|private[_-]?key|credential)/i;
25
+ /** Values that are secret because this server holds them: connection tokens and secret-looking environment variables. */
26
+ function knownSecrets() {
27
+ const out = new Set();
28
+ for (const c of connections.list())
29
+ for (const k of ['token', 'signing_secret', 'app_token', 'bot_token']) {
30
+ const v = c.config?.[k];
31
+ if (typeof v === 'string' && v.length >= 8)
32
+ out.add(v);
33
+ }
34
+ for (const [k, v] of Object.entries(process.env))
35
+ if (v && v.length >= 12 && SECRET_ENV.test(k))
36
+ out.add(v);
37
+ return [...out];
38
+ }
39
+ /** What kind of secret the text seems to contain, or null when it looks fine. Never returns the secret itself. */
40
+ export function findSecret(text) {
41
+ for (const s of knownSecrets())
42
+ if (text.includes(s))
43
+ return 'a secret held by hive-am or this machine';
44
+ for (const [re, what] of SHAPES)
45
+ if (re.test(text))
46
+ return what;
47
+ for (const m of text.matchAll(ASSIGN))
48
+ if (!PLACEHOLDER.test(m[2]))
49
+ return `the value of "${m[1]}"`;
50
+ return null;
51
+ }
52
+ export const SECRET_REFUSAL = (what) => `Not sent: the message seems to contain ${what}. Secrets (passwords, tokens, keys, connection strings) are never shared in a chat, not even with an admin or in a private chat. Tell the person you cannot share it and, if it helps, describe where it lives or how they can read it themselves, without writing its value.`;
@@ -1,52 +1,13 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { db } from '../db.js';
3
- db.exec(`
4
- CREATE TABLE IF NOT EXISTS connections (
5
- id TEXT PRIMARY KEY, kind TEXT NOT NULL, name TEXT NOT NULL UNIQUE,
6
- agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL,
7
- config TEXT NOT NULL DEFAULT '{}', allowed TEXT NOT NULL DEFAULT '[]',
8
- enabled INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL
9
- );
10
- -- Known threads: where to send a reply and when it was last active. The conversation itself is the agent's one session.
11
- CREATE TABLE IF NOT EXISTS threads (
12
- id TEXT PRIMARY KEY, connection_id TEXT NOT NULL REFERENCES connections(id) ON DELETE CASCADE,
13
- external_key TEXT NOT NULL, title TEXT NOT NULL DEFAULT '', target TEXT NOT NULL, last_user TEXT,
14
- created_at INTEGER NOT NULL, last_activity INTEGER NOT NULL,
15
- UNIQUE (connection_id, external_key)
16
- );
17
- CREATE TABLE IF NOT EXISTS thread_messages (
18
- id INTEGER PRIMARY KEY AUTOINCREMENT, thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
19
- direction TEXT NOT NULL, external_id TEXT NOT NULL, user_id TEXT, user_name TEXT,
20
- text TEXT NOT NULL, ts INTEGER NOT NULL,
21
- UNIQUE (thread_id, direction, external_id)
22
- );
23
- CREATE TABLE IF NOT EXISTS connection_cursor (
24
- connection_id TEXT PRIMARY KEY REFERENCES connections(id) ON DELETE CASCADE, value TEXT NOT NULL
25
- );
26
- `);
27
- // Columns added after the first release.
28
- for (const col of ['muted INTEGER NOT NULL DEFAULT 0', 'seen_id INTEGER NOT NULL DEFAULT 0']) {
29
- try {
30
- db.exec(`ALTER TABLE threads ADD COLUMN ${col}`);
31
- }
32
- catch { /* already there */ }
33
- }
34
- try {
35
- db.exec('ALTER TABLE thread_messages ADD COLUMN files TEXT');
36
- }
37
- catch { /* already there */ }
3
+ import { parseJson } from '../db/json.js';
4
+ // The tables (connections, threads, thread_messages, connection_cursor) are created by db/migrations.ts.
38
5
  const now = () => Date.now();
39
- const json = (s, fallback) => { try {
40
- return JSON.parse(s);
41
- }
42
- catch {
43
- return fallback;
44
- } };
45
6
  const connRow = (r) => ({
46
7
  id: r.id, kind: r.kind, name: r.name, agent_id: r.agent_id,
47
- config: json(r.config, {}), allowed: json(r.allowed, []), enabled: !!r.enabled, created_at: r.created_at,
8
+ config: parseJson(r.config, {}, `connection ${r.id} config`), allowed: parseJson(r.allowed, [], `connection ${r.id} allowed`), enabled: !!r.enabled, created_at: r.created_at,
48
9
  });
49
- const threadRow = (r) => ({ ...r, target: json(r.target, { chat: '' }), muted: !!r.muted, seen_id: r.seen_id ?? 0 });
10
+ const threadRow = (r) => ({ ...r, target: parseJson(r.target, { chat: '' }, `thread ${r.id} target`), muted: !!r.muted, seen_id: r.seen_id ?? 0 });
50
11
  export const connections = {
51
12
  list: () => db.prepare('SELECT * FROM connections ORDER BY name').all().map(connRow),
52
13
  get(id) { const r = db.prepare('SELECT * FROM connections WHERE id=?').get(id); return r ? connRow(r) : undefined; },
@@ -97,6 +58,8 @@ export const threadMessages = {
97
58
  /** Remembers the files saved for an inbound message, so they can be listed later as context. */
98
59
  setFiles: (threadId, externalId, files) => { db.prepare("UPDATE thread_messages SET files=? WHERE thread_id=? AND direction='in' AND external_id=?").run(JSON.stringify(files), threadId, externalId); },
99
60
  /** Id of the newest message of the thread (0 when none). */
61
+ /** The people who wrote in a thread recently (not the agent). */
62
+ senders: (threadId) => db.prepare("SELECT DISTINCT user_id AS id, user_name AS name FROM (SELECT user_id, user_name FROM thread_messages WHERE thread_id=? AND direction='in' AND user_name IS NOT NULL ORDER BY id DESC LIMIT 300)").all(threadId),
100
63
  lastId: (threadId) => db.prepare('SELECT MAX(id) AS m FROM thread_messages WHERE thread_id=?').get(threadId).m ?? 0,
101
64
  /** Messages people wrote that the agent has not been shown yet (after `afterId`, before `beforeId`), oldest first. */
102
65
  unseen: (threadId, afterId, beforeId, limit = 30) => db.prepare(`SELECT * FROM (SELECT * FROM thread_messages WHERE thread_id=? AND direction='in' AND id>? AND id<? ORDER BY id DESC LIMIT ?) ORDER BY id`).all(threadId, afterId, beforeId, limit),
@@ -3,7 +3,10 @@ import { MAX_FILE_BYTES } from './files.js';
3
3
  import { mdToTelegramHtml, splitMessage } from './format.js';
4
4
  import { cursors } from './store.js';
5
5
  const MAX_PART = 3500; // Telegram allows 4096; leave room for the HTML tags added after splitting
6
+ /** The emoji Telegram lets a bot put on a message (Bot API `ReactionTypeEmoji`), without the rude ones (🖕 🤬 💩 🤮 😡). */
7
+ export const TELEGRAM_REACTIONS = ['👍', '👎', '❤', '🔥', '🥰', '👏', '😁', '🤔', '🤯', '😱', '😢', '🎉', '🤩', '🙏', '👌', '🕊', '🤡', '🥱', '🥴', '😍', '🐳', '❤‍🔥', '🌚', '🌭', '💯', '🤣', '⚡', '🍌', '🏆', '💔', '🤨', '😐', '🍓', '🍾', '💋', '😈', '😴', '😭', '🤓', '👻', '👨‍💻', '👀', '🎃', '🙈', '😇', '😨', '🤝', '✍', '🤗', '🫡', '🎅', '🎄', '☃', '💅', '🤪', '🗿', '🆒', '💘', '🙉', '🦄', '😘', '💊', '🙊', '😎', '👾', '🤷', '🤷‍♂', '🤷‍♀'];
6
8
  const TYPING_EVERY_MS = 4500; // «typing…» lasts ~5 s
9
+ const TYPING_MAX_MS = 20 * 60_000; // safety net: never pulse for longer than this, whatever happens to the turn
7
10
  const MIN_GAP_MS = 350; // stay well under Telegram's ~1 message/second per chat
8
11
  const ALBUM_WAIT_MS = 1200; // the photos of an album arrive as separate messages: wait for the rest
9
12
  class TelegramError extends Error {
@@ -16,6 +19,13 @@ class TelegramError extends Error {
16
19
  }
17
20
  }
18
21
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
22
+ const CALL_TIMEOUT_MS = Number(process.env.HIVE_AM_TELEGRAM_TIMEOUT_MS) || 30_000; // a Bot API call that does not answer in this long is a dead connection (a half-open socket never errors by itself)
23
+ const POLL_SLACK_MS = 15_000; // on top of the long-poll wait Telegram is told about
24
+ const UPLOAD_TIMEOUT_MS = 120_000;
25
+ const DOWNLOAD_TIMEOUT_MS = 60_000;
26
+ /** Aborts when `signal` does or when `ms` pass, whichever comes first. */
27
+ const within = (ms, signal) => (signal ? AbortSignal.any([signal, AbortSignal.timeout(ms)]) : AbortSignal.timeout(ms));
28
+ const timedOut = (e) => e instanceof Error && e.name === 'TimeoutError';
19
29
  /** The files of a Telegram message: photos (the biggest size), documents, audio, voice notes, videos and GIFs. Stickers are ignored. */
20
30
  function attachmentsOf(msg) {
21
31
  const id = String(msg.message_id), out = [];
@@ -52,6 +62,7 @@ export class TelegramAdapter {
52
62
  stopped = false;
53
63
  abort = new AbortController();
54
64
  typing = new Map();
65
+ typingWarned = 0;
55
66
  lastSend = new Map();
56
67
  albums = new Map();
57
68
  constructor(conn) {
@@ -59,7 +70,14 @@ export class TelegramAdapter {
59
70
  }
60
71
  get base() { return `${this.conn.config.api_base || 'https://api.telegram.org'}/bot${this.conn.config.token}`; }
61
72
  async call(method, body = {}, signal) {
62
- const res = await fetch(`${this.base}/${method}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal });
73
+ const wait = method === 'getUpdates' ? Number(body.timeout ?? 0) * 1000 + POLL_SLACK_MS : CALL_TIMEOUT_MS;
74
+ let res;
75
+ try {
76
+ res = await fetch(`${this.base}/${method}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: within(wait, signal) });
77
+ }
78
+ catch (e) {
79
+ throw timedOut(e) ? new TelegramError(`Telegram ${method} did not answer in time`, 0) : e;
80
+ }
63
81
  const j = await res.json().catch(() => ({}));
64
82
  if (!j.ok)
65
83
  throw new TelegramError(j.description ?? `Telegram ${method} failed (${res.status})`, j.error_code ?? res.status, j.parameters?.retry_after);
@@ -82,13 +100,15 @@ export class TelegramAdapter {
82
100
  this.stopped = true;
83
101
  this.abort.abort();
84
102
  for (const t of this.typing.values())
85
- clearInterval(t);
103
+ clearInterval(t.timer);
86
104
  this.typing.clear();
87
105
  for (const a of this.albums.values())
88
106
  clearTimeout(a.timer);
89
107
  this.albums.clear();
90
108
  this.state = { state: 'stopped' };
91
109
  }
110
+ /** A link to the person's account: it notifies them in a group even without a @username. */
111
+ mention(u) { return `[${u.name.replace(/[\[\]]/g, '')}](tg://user?id=${u.id})`; }
92
112
  status() { return this.state; }
93
113
  /** «Connected», with a warning when the connection listens to whole groups but Telegram's privacy mode hides them. */
94
114
  connected() {
@@ -153,10 +173,29 @@ export class TelegramAdapter {
153
173
  if (cmd && cmd[2] && cmd[2].toLowerCase() !== this.me.username.toLowerCase())
154
174
  return null; // a command for another bot
155
175
  const mention = new RegExp(`@${this.me.username}\\b`, 'i');
156
- const addressed = isPrivate || !!cmd || mention.test(text) || msg.reply_to_message?.from?.id === this.me.id;
176
+ // In a forum topic every message carries a `reply_to_message` that is just the topic's first message (not a real reply to someone).
177
+ const realReply = msg.reply_to_message && !msg.reply_to_message.forum_topic_created && !(msg.is_topic_message && msg.reply_to_message.message_id === msg.message_thread_id) ? msg.reply_to_message : undefined;
178
+ const addressed = isPrivate || !!cmd || mention.test(text) || realReply?.from?.id === this.me.id;
157
179
  text = text.replace(mention, '').trim();
158
180
  if (!text && !attachments.length)
159
181
  return null;
182
+ // Meant for another person of the chat: a reply to their message, or an @mention / text mention of them.
183
+ const raw = msg.text ?? msg.caption ?? '';
184
+ let directedAt;
185
+ const to = realReply?.from;
186
+ if (!isPrivate && to && !to.is_bot && to.id !== this.me.id && to.id !== msg.from?.id)
187
+ directedAt = to.username ?? to.first_name ?? String(to.id);
188
+ for (const e of (msg.entities ?? msg.caption_entities ?? [])) {
189
+ if (directedAt || isPrivate)
190
+ break;
191
+ if (e.type === 'text_mention' && e.user && !e.user.is_bot && e.user.id !== this.me.id)
192
+ directedAt = e.user.first_name ?? String(e.user.id);
193
+ else if (e.type === 'mention') {
194
+ const u = raw.slice(e.offset + 1, e.offset + e.length);
195
+ if (u && u.toLowerCase() !== this.me.username.toLowerCase())
196
+ directedAt = u;
197
+ }
198
+ }
160
199
  const topic = msg.is_topic_message && msg.message_thread_id !== undefined ? String(msg.message_thread_id) : undefined;
161
200
  const from = msg.from ?? {};
162
201
  return {
@@ -170,12 +209,19 @@ export class TelegramAdapter {
170
209
  command: cmd ? { name: cmd[1].toLowerCase(), args: (cmd[3] ?? '').trim() } : undefined,
171
210
  group: !isPrivate,
172
211
  addressed,
212
+ ...(directedAt ? { directedAt } : {}),
173
213
  ...(attachments.length ? { attachments } : {}),
174
214
  };
175
215
  }
176
216
  /** Telegram methods that take a file are multipart, not JSON. */
177
217
  async upload(method, form) {
178
- const res = await fetch(`${this.base}/${method}`, { method: 'POST', body: form });
218
+ let res;
219
+ try {
220
+ res = await fetch(`${this.base}/${method}`, { method: 'POST', body: form, signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS) });
221
+ }
222
+ catch (e) {
223
+ throw timedOut(e) ? new TelegramError(`Telegram ${method} did not answer in time`, 0) : e;
224
+ }
179
225
  const j = await res.json().catch(() => ({}));
180
226
  if (!j.ok)
181
227
  throw new TelegramError(j.description ?? `Telegram ${method} failed (${res.status})`, j.error_code ?? res.status, j.parameters?.retry_after);
@@ -216,7 +262,7 @@ export class TelegramAdapter {
216
262
  if ((info.file_size ?? 0) > MAX_FILE_BYTES)
217
263
  throw new Error('too big (limit 20 MB)');
218
264
  const base = this.conn.config.api_base || 'https://api.telegram.org';
219
- const res = await fetch(`${base}/file/bot${this.conn.config.token}/${info.file_path}`, { signal: this.abort.signal });
265
+ const res = await fetch(`${base}/file/bot${this.conn.config.token}/${info.file_path}`, { signal: within(DOWNLOAD_TIMEOUT_MS, this.abort.signal) });
220
266
  if (!res.ok)
221
267
  throw new Error(`download failed (${res.status})`);
222
268
  const buf = Buffer.from(await res.arrayBuffer());
@@ -225,6 +271,24 @@ export class TelegramAdapter {
225
271
  writeFileSync(dest, buf);
226
272
  return buf.length;
227
273
  }
274
+ /** What a bot may react with on Telegram (the fixed list of the Bot API, minus the rude ones). A group can narrow it further. */
275
+ reactions = TELEGRAM_REACTIONS;
276
+ async react(to, ref, emoji) {
277
+ const wanted = emoji.replace(/\uFE0F/g, '').trim();
278
+ const emo = TELEGRAM_REACTIONS.find((r) => r.replace(/\uFE0F/g, '') === wanted);
279
+ if (!emo)
280
+ throw new Error(`Telegram does not allow “${emoji}” as a reaction. The ones you can use: ${TELEGRAM_REACTIONS.join(' ')}`);
281
+ if (!ref)
282
+ throw new Error('There is no message to react to.');
283
+ try {
284
+ await this.call('setMessageReaction', { chat_id: to.chat, message_id: Number(ref), reaction: [{ type: 'emoji', emoji: emo }] });
285
+ }
286
+ catch (e) {
287
+ if (e instanceof TelegramError && /REACTION_INVALID|reaction/i.test(e.message))
288
+ throw new Error(`This chat does not accept ${emo} as a reaction (a group can limit them). Try another one, or answer with text.`);
289
+ throw e;
290
+ }
291
+ }
228
292
  async send(to, text) {
229
293
  let last = '';
230
294
  for (const part of splitMessage(text, MAX_PART)) {
@@ -236,6 +300,8 @@ export class TelegramAdapter {
236
300
  }));
237
301
  last = String(sent.message_id);
238
302
  }
303
+ if (this.typing.has(`${to.chat}:${to.thread ?? ''}`))
304
+ this.ping(to); // a sent message cancels «typing…»; the agent is still working
239
305
  return { externalId: last };
240
306
  }
241
307
  /** Keeps one chat's messages spaced out and honours Telegram's `retry_after` when it pushes back. */
@@ -257,18 +323,43 @@ export class TelegramAdapter {
257
323
  }
258
324
  }
259
325
  }
260
- async busy(to, state) {
326
+ /** One «typing…» pulse. A failure is logged (once a minute) instead of being swallowed, so a silent problem is visible. */
327
+ ping(to) {
328
+ void this.call('sendChatAction', { chat_id: to.chat, action: 'typing', ...(to.thread ? { message_thread_id: Number(to.thread) } : {}) }).catch((e) => {
329
+ if (Date.now() - this.typingWarned > 60_000) {
330
+ this.typingWarned = Date.now();
331
+ console.warn(`[connections] ${this.conn.name}: «typing…» failed: ${e.message}`);
332
+ }
333
+ });
334
+ }
335
+ async busy(to, state, ref = '') {
261
336
  const key = `${to.chat}:${to.thread ?? ''}`;
262
- const old = this.typing.get(key);
263
- if (old) {
264
- clearInterval(old);
265
- this.typing.delete(key);
337
+ const cur = this.typing.get(key);
338
+ if (state === 'working') {
339
+ if (cur) {
340
+ cur.refs.add(ref);
341
+ return;
342
+ }
343
+ this.ping(to);
344
+ const since = Date.now();
345
+ const timer = setInterval(() => {
346
+ if (Date.now() - since > TYPING_MAX_MS) {
347
+ clearInterval(timer);
348
+ this.typing.delete(key);
349
+ return;
350
+ }
351
+ this.ping(to);
352
+ }, TYPING_EVERY_MS);
353
+ this.typing.set(key, { timer, refs: new Set([ref]) });
354
+ return;
266
355
  }
267
- if (state !== 'working')
356
+ if (!cur)
268
357
  return;
269
- const ping = () => void this.call('sendChatAction', { chat_id: to.chat, action: 'typing', ...(to.thread ? { message_thread_id: Number(to.thread) } : {}) }).catch(() => undefined);
270
- ping();
271
- this.typing.set(key, setInterval(ping, TYPING_EVERY_MS));
358
+ cur.refs.delete(ref);
359
+ if (!cur.refs.size) {
360
+ clearInterval(cur.timer);
361
+ this.typing.delete(key);
362
+ }
272
363
  }
273
364
  async test(userIds) {
274
365
  const me = await this.call('getMe');
@@ -0,0 +1,138 @@
1
+ import { DomainError } from './errors.js';
2
+ /**
3
+ * Cron expressions (5 fields: minute hour day-of-month month day-of-week) evaluated in a time zone.
4
+ * The next run is computed from calendar days and local times and then mapped to an instant, so daylight-saving changes behave:
5
+ * a local time that does not exist (the hour skipped in spring) is skipped, and one that happens twice (autumn) runs once, the first time.
6
+ */
7
+ export class CronError extends DomainError {
8
+ }
9
+ const NAMES = {
10
+ month: { JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6, JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12 },
11
+ dow: { SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6 },
12
+ };
13
+ const MACROS = { '@hourly': '0 * * * *', '@daily': '0 0 * * *', '@midnight': '0 0 * * *', '@weekly': '0 0 * * 0', '@monthly': '0 0 1 * *' };
14
+ function field(src, min, max, names) {
15
+ const num = (s) => {
16
+ const v = names?.[s.toUpperCase()] ?? (/^\d+$/.test(s) ? Number(s) : NaN);
17
+ if (!Number.isInteger(v) || v < min || v > max)
18
+ throw new CronError(`"${s}" is out of range (${min}-${max})`);
19
+ return v;
20
+ };
21
+ const out = new Set();
22
+ let any = false;
23
+ for (const part of src.split(',')) {
24
+ const [range, stepText] = part.split('/');
25
+ const step = stepText === undefined ? 1 : Number(stepText);
26
+ if (!Number.isInteger(step) || step < 1)
27
+ throw new CronError(`Invalid step "${stepText}"`);
28
+ let from, to;
29
+ if (range === '*' || range === '?') {
30
+ from = min;
31
+ to = max;
32
+ if (stepText === undefined)
33
+ any = true;
34
+ }
35
+ else if (range.includes('-')) {
36
+ const [a, b] = range.split('-');
37
+ from = num(a);
38
+ to = num(b);
39
+ if (from > to)
40
+ throw new CronError(`Range ${range} goes backwards`);
41
+ }
42
+ else {
43
+ from = num(range);
44
+ to = stepText === undefined ? from : max;
45
+ }
46
+ for (let v = from; v <= to; v += step)
47
+ out.add(v);
48
+ }
49
+ return { values: [...out].sort((a, b) => a - b), any };
50
+ }
51
+ export function parseCron(expression) {
52
+ const text = MACROS[expression.trim().toLowerCase()] ?? expression.trim();
53
+ const parts = text.split(/\s+/);
54
+ if (parts.length !== 5)
55
+ throw new CronError('A cron expression has 5 fields: minute hour day-of-month month day-of-week (for example "0 9 * * MON-FRI")');
56
+ const minute = field(parts[0], 0, 59), hour = field(parts[1], 0, 23), dom = field(parts[2], 1, 31), month = field(parts[3], 1, 12, NAMES.month);
57
+ const dowRaw = field(parts[4].replace(/\b7\b/g, '0'), 0, 6, NAMES.dow);
58
+ return { minute: minute.values, hour: hour.values, dom: dom.values, month: month.values, dow: dowRaw.values, domAny: dom.any, dowAny: dowRaw.any };
59
+ }
60
+ // ---- time zones ----
61
+ const formats = new Map();
62
+ function format(tz) {
63
+ let f = formats.get(tz);
64
+ if (!f) {
65
+ f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' });
66
+ formats.set(tz, f);
67
+ }
68
+ return f;
69
+ }
70
+ export function validTimezone(tz) { try {
71
+ format(tz);
72
+ return true;
73
+ }
74
+ catch {
75
+ return false;
76
+ } }
77
+ export const serverTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
78
+ function local(ts, tz) {
79
+ const p = {};
80
+ for (const x of format(tz).formatToParts(new Date(ts)))
81
+ if (x.type !== 'literal')
82
+ p[x.type] = Number(x.value);
83
+ return { y: p.year, m: p.month, d: p.day, h: p.hour % 24, mi: p.minute, s: p.second };
84
+ }
85
+ const offsetMs = (ts, tz) => { const l = local(ts, tz); return Date.UTC(l.y, l.m - 1, l.d, l.h, l.mi, l.s) - Math.floor(ts / 1000) * 1000; };
86
+ /** The instant at which the wall clock of `tz` reads y-m-d h:mi, or null when that time does not exist there (spring-forward gap). */
87
+ function zonedToUtc(y, m, d, h, mi, tz) {
88
+ const wall = Date.UTC(y, m - 1, d, h, mi);
89
+ let utc = wall - offsetMs(wall, tz);
90
+ utc = wall - offsetMs(utc, tz); // second pass: the offset may differ at the new instant
91
+ const l = local(utc, tz);
92
+ return l.y === y && l.m === m && l.d === d && l.h === h && l.mi === mi ? utc : null;
93
+ }
94
+ /** First run strictly after `after` (ms), or null if there is none in the next 8 years. */
95
+ export function nextCron(expression, tz, after) {
96
+ const spec = typeof expression === 'string' ? parseCron(expression) : expression;
97
+ const start = local(after, tz);
98
+ for (let k = 0; k < 366 * 8; k++) {
99
+ const day = new Date(Date.UTC(start.y, start.m - 1, start.d + k));
100
+ const y = day.getUTCFullYear(), m = day.getUTCMonth() + 1, d = day.getUTCDate(), wd = day.getUTCDay();
101
+ if (!spec.month.includes(m))
102
+ continue;
103
+ const domOk = spec.dom.includes(d), dowOk = spec.dow.includes(wd);
104
+ const dayOk = spec.domAny || spec.dowAny ? domOk && dowOk : domOk || dowOk; // both restricted: either matches (classic cron)
105
+ if (!dayOk)
106
+ continue;
107
+ for (const h of spec.hour)
108
+ for (const mi of spec.minute) {
109
+ const t = zonedToUtc(y, m, d, h, mi, tz);
110
+ if (t !== null && t > after)
111
+ return t;
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+ /** The next `count` runs. */
117
+ export function nextRuns(expression, tz, after, count) {
118
+ const spec = parseCron(expression), out = [];
119
+ let t = after;
120
+ while (out.length < count) {
121
+ const n = nextCron(spec, tz, t);
122
+ if (n === null)
123
+ break;
124
+ out.push(n);
125
+ t = n;
126
+ }
127
+ return out;
128
+ }
129
+ /** Smallest gap, in minutes, between the next runs: protects against "* * * * *" and the like. */
130
+ export function minGapMinutes(expression, tz, after) {
131
+ const runs = nextRuns(expression, tz, after, 12);
132
+ if (runs.length < 2)
133
+ return Infinity;
134
+ let min = Infinity;
135
+ for (let i = 1; i < runs.length; i++)
136
+ min = Math.min(min, (runs[i] - runs[i - 1]) / 60000);
137
+ return min;
138
+ }
@@ -0,0 +1,16 @@
1
+ /** JSON.parse for a column or a file that may be damaged: the fallback is used (and the problem is logged once per label) instead of throwing into a request. */
2
+ const warned = new Set();
3
+ export function parseJson(raw, fallback, label = 'json') {
4
+ if (raw === null || raw === undefined || raw === '')
5
+ return fallback;
6
+ try {
7
+ return JSON.parse(String(raw));
8
+ }
9
+ catch (e) {
10
+ if (!warned.has(label)) {
11
+ warned.add(label);
12
+ console.warn(`[db] ${label}: unreadable JSON, using a default (${e instanceof Error ? e.message : e})`);
13
+ }
14
+ return fallback;
15
+ }
16
+ }