@circuitwall/jarela 2.3.0 → 2.5.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 (290) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +5 -5
  3. package/.next/standalone/.next/build-manifest.json +2 -2
  4. package/.next/standalone/.next/prerender-manifest.json +4 -4
  5. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  12. package/.next/standalone/.next/server/app/_not-found.html +14 -2
  13. package/.next/standalone/.next/server/app/_not-found.rsc +16 -4
  14. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +16 -4
  15. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +16 -4
  16. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  18. package/.next/standalone/.next/server/app/api/v1/access/[identity]/route.js +7 -0
  19. package/.next/standalone/.next/server/app/api/v1/access/route.js +7 -0
  20. package/.next/standalone/.next/server/app/api/v1/agents/[id]/compact/route.js +7 -0
  21. package/.next/standalone/.next/server/app/api/v1/agents/[id]/display-filters/route.js +17 -5
  22. package/.next/standalone/.next/server/app/api/v1/agents/[id]/route.js +7 -0
  23. package/.next/standalone/.next/server/app/api/v1/agents/[id]/thread/route.js +17 -5
  24. package/.next/standalone/.next/server/app/api/v1/agents/route.js +7 -0
  25. package/.next/standalone/.next/server/app/api/v1/allowed-sites/[hostname]/route.js +7 -0
  26. package/.next/standalone/.next/server/app/api/v1/allowed-sites/route.js +7 -0
  27. package/.next/standalone/.next/server/app/api/v1/artifacts/lifecycle/route.js +7 -0
  28. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js +26 -32
  29. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/ignores/[remote_jid]/route.js +18 -7
  31. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/ignores/route.js +18 -7
  32. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js +26 -32
  33. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js +27 -33
  35. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js +26 -32
  37. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/routes/[route_id]/route.js +28 -12
  39. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/routes/route.js +28 -12
  40. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js +26 -32
  41. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/api/v1/bridges/kinds/route.js +9 -2
  43. package/.next/standalone/.next/server/app/api/v1/bridges/kinds/route.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/api/v1/bridges/route.js +19 -7
  45. package/.next/standalone/.next/server/app/api/v1/bridges/route.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/api/v1/dashboard/metrics/route.js +17 -5
  47. package/.next/standalone/.next/server/app/api/v1/dashboard/pricing/route.js +7 -0
  48. package/.next/standalone/.next/server/app/api/v1/dashboard/pricing-task/route.js +1 -1
  49. package/.next/standalone/.next/server/app/api/v1/documents/search/route.js +7 -0
  50. package/.next/standalone/.next/server/app/api/v1/documents/settings/route.js +7 -0
  51. package/.next/standalone/.next/server/app/api/v1/documents/sources/[id]/route.js +1 -1
  52. package/.next/standalone/.next/server/app/api/v1/documents/sources/route.js +1 -1
  53. package/.next/standalone/.next/server/app/api/v1/env-sync/allowlist/route.js +7 -0
  54. package/.next/standalone/.next/server/app/api/v1/env-sync/route.js +7 -0
  55. package/.next/standalone/.next/server/app/api/v1/events/route.js +26 -32
  56. package/.next/standalone/.next/server/app/api/v1/events/route.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/api/v1/extension/agents/route.js +1 -1
  58. package/.next/standalone/.next/server/app/api/v1/extension/browser/activate/route.js +7 -0
  59. package/.next/standalone/.next/server/app/api/v1/extension/browser/foreground/route.js +7 -0
  60. package/.next/standalone/.next/server/app/api/v1/extension/browser/history/route.js +7 -0
  61. package/.next/standalone/.next/server/app/api/v1/extension/browser/poll/route.js +7 -0
  62. package/.next/standalone/.next/server/app/api/v1/extension/browser/progress/route.js +7 -0
  63. package/.next/standalone/.next/server/app/api/v1/extension/browser/result/route.js +7 -0
  64. package/.next/standalone/.next/server/app/api/v1/extension/browser/retry/route.js +7 -0
  65. package/.next/standalone/.next/server/app/api/v1/extension/browser/status/route.js +7 -0
  66. package/.next/standalone/.next/server/app/api/v1/extension/browser/tabs/route.js +7 -0
  67. package/.next/standalone/.next/server/app/api/v1/extension/fill/route.js +1 -1
  68. package/.next/standalone/.next/server/app/api/v1/extension/refine/route.js +1 -1
  69. package/.next/standalone/.next/server/app/api/v1/extension/turn/route.js +1 -1
  70. package/.next/standalone/.next/server/app/api/v1/extensions/route.js +1 -1
  71. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/config/route.js +1 -1
  72. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/route.js +1 -1
  73. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/secrets/route.js +1 -1
  74. package/.next/standalone/.next/server/app/api/v1/harnesses/[id]/route.js +7 -0
  75. package/.next/standalone/.next/server/app/api/v1/harnesses/default/route.js +7 -0
  76. package/.next/standalone/.next/server/app/api/v1/harnesses/route.js +7 -0
  77. package/.next/standalone/.next/server/app/api/v1/health/route.js +17 -5
  78. package/.next/standalone/.next/server/app/api/v1/integrations/[name]/route.js +7 -0
  79. package/.next/standalone/.next/server/app/api/v1/integrations/gmail/oauth/callback/route.js +7 -0
  80. package/.next/standalone/.next/server/app/api/v1/integrations/gmail/oauth/start/route.js +7 -0
  81. package/.next/standalone/.next/server/app/api/v1/integrations/gmail/oauth/status/route.js +7 -0
  82. package/.next/standalone/.next/server/app/api/v1/integrations/outlook/oauth/callback/route.js +7 -0
  83. package/.next/standalone/.next/server/app/api/v1/integrations/outlook/oauth/start/route.js +7 -0
  84. package/.next/standalone/.next/server/app/api/v1/integrations/outlook/oauth/status/route.js +7 -0
  85. package/.next/standalone/.next/server/app/api/v1/integrations/route.js +7 -0
  86. package/.next/standalone/.next/server/app/api/v1/lifecycle/adoption/route.js +17 -5
  87. package/.next/standalone/.next/server/app/api/v1/maps/embed/route.js +7 -0
  88. package/.next/standalone/.next/server/app/api/v1/mcp-servers/[name]/route.js +17 -5
  89. package/.next/standalone/.next/server/app/api/v1/mcp-servers/route.js +17 -5
  90. package/.next/standalone/.next/server/app/api/v1/memory/[namespace]/[key]/route.js +7 -0
  91. package/.next/standalone/.next/server/app/api/v1/memory/policy/route.js +7 -0
  92. package/.next/standalone/.next/server/app/api/v1/memory/route.js +7 -0
  93. package/.next/standalone/.next/server/app/api/v1/memory/search/route.js +7 -0
  94. package/.next/standalone/.next/server/app/api/v1/models/[name]/compact-threads/route.js +7 -0
  95. package/.next/standalone/.next/server/app/api/v1/models/[name]/route.js +7 -0
  96. package/.next/standalone/.next/server/app/api/v1/models/route.js +7 -0
  97. package/.next/standalone/.next/server/app/api/v1/packages/defaults/[id]/route.js +28 -4
  98. package/.next/standalone/.next/server/app/api/v1/packages/reload/route.js +28 -4
  99. package/.next/standalone/.next/server/app/api/v1/packages/route.js +28 -4
  100. package/.next/standalone/.next/server/app/api/v1/page-capture/route.js +1 -1
  101. package/.next/standalone/.next/server/app/api/v1/pending-actions/[id]/approve/route.js +27 -12
  102. package/.next/standalone/.next/server/app/api/v1/profile/location/route.js +7 -0
  103. package/.next/standalone/.next/server/app/api/v1/profile/route.js +7 -0
  104. package/.next/standalone/.next/server/app/api/v1/providers/[provider]/models/route.js +585 -58
  105. package/.next/standalone/.next/server/app/api/v1/providers/[provider]/probe/route.js +7 -0
  106. package/.next/standalone/.next/server/app/api/v1/providers/github-copilot/auth/route.js +7 -0
  107. package/.next/standalone/.next/server/app/api/v1/providers/route.js +7 -0
  108. package/.next/standalone/.next/server/app/api/v1/proxy-config/route.js +7 -0
  109. package/.next/standalone/.next/server/app/api/v1/redaction/init/route.js +7 -0
  110. package/.next/standalone/.next/server/app/api/v1/redaction/route.js +7 -0
  111. package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/[id]/route.js +17 -5
  112. package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/[id]/run/route.js +1 -1
  113. package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/route.js +7 -0
  114. package/.next/standalone/.next/server/app/api/v1/security/idle-timeout/route.js +7 -0
  115. package/.next/standalone/.next/server/app/api/v1/security/pin/route.js +7 -0
  116. package/.next/standalone/.next/server/app/api/v1/security/state/route.js +7 -0
  117. package/.next/standalone/.next/server/app/api/v1/security/unlock/route.js +7 -0
  118. package/.next/standalone/.next/server/app/api/v1/security/verify-pin/route.js +7 -0
  119. package/.next/standalone/.next/server/app/api/v1/skills/[id]/route.js +7 -0
  120. package/.next/standalone/.next/server/app/api/v1/skills/repos/[id]/route.js +7 -0
  121. package/.next/standalone/.next/server/app/api/v1/skills/repos/route.js +7 -0
  122. package/.next/standalone/.next/server/app/api/v1/skills/route.js +7 -0
  123. package/.next/standalone/.next/server/app/api/v1/tasks/[agent_id]/route.js +7 -0
  124. package/.next/standalone/.next/server/app/api/v1/tasks/route.js +7 -0
  125. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/activity/route.js +7 -0
  126. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/context-pin/route.js +12 -6
  127. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/route.js +125 -26
  128. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js +1 -2
  129. package/.next/standalone/.next/server/app/api/v1/threads/route.js +17 -5
  130. package/.next/standalone/.next/server/app/api/v1/tools/route.js +1 -1
  131. package/.next/standalone/.next/server/app/api/v1/update/apply/route.js.nft.json +1 -1
  132. package/.next/standalone/.next/server/app/api/v1/voice/transcribe/route.js +17 -5
  133. package/.next/standalone/.next/server/app/api/v1/voice/tts/route.js +17 -5
  134. package/.next/standalone/.next/server/app/api/v1/watchers/[id]/route.js +17 -5
  135. package/.next/standalone/.next/server/app/api/v1/watchers/[id]/run/route.js +1 -1
  136. package/.next/standalone/.next/server/app/api/v1/watchers/route.js +1 -1
  137. package/.next/standalone/.next/server/app/page.js +27323 -27034
  138. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  139. package/.next/standalone/.next/server/app/setup/page.js +8 -0
  140. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  141. package/.next/standalone/.next/server/app-paths-manifest.json +5 -5
  142. package/.next/standalone/.next/server/chunks/1388.js +106 -36
  143. package/.next/standalone/.next/server/chunks/2068.js +47 -10
  144. package/.next/standalone/.next/server/chunks/210.js +6 -6
  145. package/.next/standalone/.next/server/chunks/2447.js +10 -5
  146. package/.next/standalone/.next/server/chunks/2653.js +80 -1
  147. package/.next/standalone/.next/server/chunks/3042.js +10 -8
  148. package/.next/standalone/.next/server/chunks/{4540.js → 309.js} +104 -38
  149. package/.next/standalone/.next/server/chunks/{2350.js → 3272.js} +104 -37
  150. package/.next/standalone/.next/server/chunks/3524.js +150 -67
  151. package/.next/standalone/.next/server/chunks/3566.js +1 -1
  152. package/.next/standalone/.next/server/chunks/4189.js +1 -1
  153. package/.next/standalone/.next/server/chunks/4596.js +28 -4
  154. package/.next/standalone/.next/server/chunks/46.js +91 -31
  155. package/.next/standalone/.next/server/chunks/466.js +53 -7
  156. package/.next/standalone/.next/server/chunks/4955.js +30 -3
  157. package/.next/standalone/.next/server/chunks/5585.js +28 -4
  158. package/.next/standalone/.next/server/chunks/5908.js +63 -42
  159. package/.next/standalone/.next/server/chunks/6142.js +10 -2
  160. package/.next/standalone/.next/server/chunks/8461.js +10 -8
  161. package/.next/standalone/.next/server/chunks/9032.js +5 -5
  162. package/.next/standalone/.next/server/chunks/9164.js +487 -177
  163. package/.next/standalone/.next/server/chunks/9304.js +51 -12
  164. package/.next/standalone/.next/server/chunks/{423.js → 9590.js} +84 -24
  165. package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
  166. package/.next/standalone/.next/server/middleware.js +89 -21
  167. package/.next/standalone/.next/server/pages/404.html +14 -2
  168. package/.next/standalone/.next/server/pages/500.html +1 -1
  169. package/.next/standalone/.next/server/preview-props.json +3 -3
  170. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  171. package/.next/standalone/.next/static/chunks/{1395-e4a99912b8f83044.js → 1395-bfe1f3ef760c0f50.js} +54 -8
  172. package/.next/standalone/.next/static/chunks/app/{layout-9bc0b10127cb7d42.js → layout-7b24ad13faa3944b.js} +69 -2
  173. package/.next/standalone/.next/static/chunks/app/{page-213a623b37226452.js → page-2ef8d75d40eb4311.js} +10643 -10295
  174. package/.next/standalone/.next/static/css/c160ea862133acd2.css +5 -0
  175. package/.next/standalone/docs/adoption/changesets.json +2805 -0
  176. package/.next/standalone/package.json +1 -1
  177. package/CHANGELOG.md +52 -0
  178. package/api/client.models.test.ts +10 -0
  179. package/api/client.startup.test.ts +129 -0
  180. package/api/client.ts +51 -9
  181. package/api/thread-metadata.test.ts +72 -0
  182. package/api/types.ts +6 -3
  183. package/app/api/v1/providers/[provider]/models/route.test.ts +216 -6
  184. package/app/api/v1/providers/[provider]/models/route.ts +259 -40
  185. package/app/api/v1/threads/[thread_id]/route.ts +26 -23
  186. package/app/api/v1/threads/[thread_id]/run/route.ts +0 -1
  187. package/app/globals.css +119 -29
  188. package/app/layout.tsx +12 -0
  189. package/components/agents/AgentsPanel.tsx +5 -13
  190. package/components/chat/ChatView.tsx +9 -18
  191. package/components/chat/InputBar.tsx +1 -1
  192. package/components/chat/MessageBubble.tsx +13 -1
  193. package/components/chat/MessageList.test.tsx +45 -4
  194. package/components/chat/MessageList.tsx +123 -89
  195. package/components/chat/ToolList.tsx +88 -84
  196. package/components/chat/useThreadActivity.test.tsx +2 -2
  197. package/components/chat/useThreadActivity.ts +2 -2
  198. package/components/chat/useThreadData.ts +1 -30
  199. package/components/chat/useThreadMetadataPolling.test.tsx +128 -0
  200. package/components/chat/useThreadMetadataPolling.ts +50 -0
  201. package/components/chat/useUserProfileAndAgent.test.tsx +31 -0
  202. package/components/chat/useUserProfileAndAgent.ts +13 -2
  203. package/components/dashboard/DashboardPanel.tsx +1 -1
  204. package/components/dashboard/Series.tsx +34 -21
  205. package/components/dashboard/SummaryCards.tsx +1 -1
  206. package/components/integrations/NetworkSection.tsx +2 -2
  207. package/components/layout/AppShell.tsx +22 -7
  208. package/components/layout/MenuPanel.tsx +14 -24
  209. package/components/profile/ProfilePanel.tsx +2 -2
  210. package/components/settings/AppearancePanel.tsx +70 -3
  211. package/components/settings/SettingsPanel.tsx +2 -2
  212. package/components/tools/ToolsPanel.tsx +2 -7
  213. package/components/ui/AppearancePreferences.test.tsx +68 -0
  214. package/components/ui/BootScreen.test.tsx +44 -0
  215. package/components/ui/BootScreen.tsx +24 -11
  216. package/components/ui/NotificationStatus.test.tsx +2 -2
  217. package/components/ui/NotificationStatus.tsx +5 -5
  218. package/components/ui/SubTabBar.tsx +2 -3
  219. package/contexts/ThemeContext.tsx +84 -1
  220. package/hooks/useAgentSession.test.ts +12 -1
  221. package/hooks/useAgentSession.ts +6 -4
  222. package/hooks/useUrlSync.test.ts +13 -0
  223. package/hooks/useUrlSync.ts +0 -4
  224. package/lib/agents/citation-checker.test.ts +40 -2
  225. package/lib/agents/citation-checker.ts +56 -11
  226. package/lib/agents/context-boundary.ts +5 -6
  227. package/lib/agents/instruction-edits.test.ts +120 -0
  228. package/lib/agents/instruction-edits.ts +65 -42
  229. package/lib/agents/prepare/request.ts +0 -2
  230. package/lib/agents/prepare/system-prompt.test.ts +10 -0
  231. package/lib/agents/prepare/system-prompt.ts +8 -2
  232. package/lib/agents/proposals.test.ts +76 -0
  233. package/lib/agents/proposals.ts +13 -5
  234. package/lib/agents/run-thread.auto-boundary.test.ts +125 -5
  235. package/lib/agents/run-thread.ts +24 -9
  236. package/lib/agents/sqlite-checkpoint-saver.test.ts +91 -0
  237. package/lib/agents/warm-summary-background.ts +22 -2
  238. package/lib/agents/warm-topics.test.ts +155 -1
  239. package/lib/agents/warm-topics.ts +27 -7
  240. package/lib/bridges/dispatcher.ts +1 -0
  241. package/lib/bridges/message-role.test.ts +81 -0
  242. package/lib/bridges/message-role.ts +23 -10
  243. package/lib/bridges/types.ts +6 -0
  244. package/lib/bridges/whatsapp-link.test.ts +32 -0
  245. package/lib/bridges/whatsapp-link.ts +21 -0
  246. package/lib/bridges/whatsapp.inbound.test.ts +239 -0
  247. package/lib/bridges/whatsapp.sender.test.ts +80 -1
  248. package/lib/bridges/whatsapp.ts +76 -38
  249. package/lib/db/migrations.test.ts +61 -0
  250. package/lib/db/migrations.ts +13 -0
  251. package/lib/embeddings/performance.test.ts +114 -0
  252. package/lib/pricing/snapshot.test.ts +70 -0
  253. package/lib/pricing/snapshot.ts +10 -2
  254. package/lib/proxy/dispatcher.test.ts +119 -1
  255. package/lib/proxy/dispatcher.ts +22 -9
  256. package/lib/stores/agent-configs.ts +10 -5
  257. package/lib/stores/bridges.test.ts +81 -0
  258. package/lib/stores/bridges.ts +9 -8
  259. package/lib/stores/dashboard-metrics-cache.test.ts +99 -0
  260. package/lib/stores/dashboard-metrics.ts +30 -4
  261. package/lib/stores/message-embedding-jobs.test.ts +27 -0
  262. package/lib/stores/message-embedding-jobs.ts +5 -3
  263. package/lib/stores/message-usage.test.ts +21 -1
  264. package/lib/stores/pricing-cache.test.ts +31 -0
  265. package/lib/stores/pricing.ts +3 -0
  266. package/lib/stores/proxy-config.test.ts +120 -0
  267. package/lib/stores/proxy-config.ts +22 -4
  268. package/lib/stores/threads.ts +12 -8
  269. package/lib/tools/communications/calendar.test.ts +43 -2
  270. package/lib/tools/communications/calendar.ts +2 -0
  271. package/lib/tools/communications/gmail.test.ts +51 -1
  272. package/lib/tools/communications/gmail.ts +44 -2
  273. package/lib/tools/communications/outlook-calendar.ts +2 -0
  274. package/lib/tools/communications/outlook.test.ts +33 -2
  275. package/lib/tools/communications/outlook.ts +2 -0
  276. package/lib/tools/filesystem/edit-match.test.ts +133 -0
  277. package/lib/tools/filesystem/edit-match.ts +7 -2
  278. package/lib/tools/packages/default-packages.test.ts +21 -0
  279. package/lib/tools/packages/optional-package.test.ts +72 -0
  280. package/lib/tools/packages/optional-package.ts +33 -4
  281. package/lib/tools/support/result-refs.test.ts +131 -0
  282. package/lib/tools/support/result-refs.ts +41 -9
  283. package/lib/ui/navigate.test.ts +13 -0
  284. package/lib/ui/navigate.ts +10 -2
  285. package/lib/utils/source-link.test.ts +51 -0
  286. package/lib/utils/source-link.ts +45 -0
  287. package/package.json +1 -1
  288. package/.next/standalone/.next/static/css/312542a5725ba0d5.css +0 -5
  289. /package/.next/standalone/.next/static/{bMRK5y3ruT7c3WtBfCQOG → 544OFJV-q0RC6Gjgje4LT}/_buildManifest.js +0 -0
  290. /package/.next/standalone/.next/static/{bMRK5y3ruT7c3WtBfCQOG → 544OFJV-q0RC6Gjgje4LT}/_ssgManifest.js +0 -0
@@ -29,12 +29,13 @@ import {
29
29
  bumpRouteLastSeenTs,
30
30
  findRoute,
31
31
  getBridge,
32
- getMaxRouteLastSeenTs,
32
+ getRouteLastSeenTsMap,
33
33
  } from "@/lib/stores/bridges";
34
34
  import { ensureWhatsAppAuthDir, removeWhatsAppAuth } from "./modules/whatsapp/auth-state";
35
35
  import type { BridgeAdapter, ChatInfo, InboundEvent, InboundHandler, StatusHandler, InboundMessage, StatusUpdate } from "./types";
36
36
  import type { ContentPart } from "@/lib/tools/runtime/types";
37
37
  import { storeCanonicalBridgeAttachment } from "./attachment-store";
38
+ import { whatsappChatLink } from "./whatsapp-link";
38
39
  import { errorMessage } from "@/lib/utils/error";
39
40
 
40
41
  // Baileys + qrcode are dev-time-installed peer libs. We never import their
@@ -285,12 +286,12 @@ export class WhatsAppBridgeAdapter implements BridgeAdapter {
285
286
  // adapter should re-deliver to agents) and the legacy
286
287
  // `replace`/`update` events that arrive via messages.update.
287
288
  if (payload.type !== "notify" && payload.type !== "append") return;
288
- // On a fresh adapter boot the watermark is whatever the last run
289
- // persisted. Read it once per batch (cheap SQLite call) so we can
290
- // hard-reject very old appends without doing any per-message work.
291
- const watermark = payload.type === "append"
292
- ? getMaxRouteLastSeenTs(this.bridge_id)
293
- : 0;
289
+ // On a fresh adapter boot the watermarks are whatever the last run
290
+ // persisted. Read them once per batch (one cheap SQLite call) so old
291
+ // appends are rejected without per-message queries.
292
+ const watermarks = payload.type === "append"
293
+ ? getRouteLastSeenTsMap(this.bridge_id)
294
+ : null;
294
295
  for (const raw of payload.messages ?? []) {
295
296
  const m = raw as {
296
297
  key?: { remoteJid?: string; remoteJidAlt?: string; fromMe?: boolean; id?: string; participant?: string };
@@ -324,18 +325,20 @@ export class WhatsAppBridgeAdapter implements BridgeAdapter {
324
325
  const ts = typeof m.messageTimestamp === "number"
325
326
  ? m.messageTimestamp * 1000
326
327
  : (m.messageTimestamp?.low ?? 0) * 1000 || Date.now();
327
- // Watermark gate: on `append` batches, skip anything strictly
328
- // older than the highest per-route watermark we've persisted. This
329
- // prevents the server's reconnect replay from re-delivering
330
- // messages we already routed in a previous process lifetime.
331
- // `notify` events skip this check — they're live and may legitimately
332
- // arrive in any order relative to the cross-route max.
333
- if (payload.type === "append" && watermark > 0 && ts > 0 && ts <= watermark) {
328
+ // Watermark gate: on `append` batches, skip anything at or below
329
+ // this chat's own persisted watermark. This prevents the server's
330
+ // reconnect replay from re-delivering messages we already routed in
331
+ // a previous process lifetime. `notify` events skip this check —
332
+ // they're live and may legitimately arrive out of order.
333
+ const watermark = watermarks?.get(remote_jid) ?? 0;
334
+ if (watermark > 0 && ts > 0 && ts <= watermark) {
334
335
  continue;
335
336
  }
336
- // In groups, pushName is typically the participant's display name,
337
- // not the group subject. Avoid poisoning the group chat label.
338
- this.observeChat(remote_jid, is_group ? null : (m.pushName ?? null), ts);
337
+ // pushName names the *sender*: in groups that's a participant, and on
338
+ // fromMe messages it's the user's own name. Only a counterpart's
339
+ // pushName in a 1:1 chat is a valid label for the chat itself.
340
+ const chatPushName = is_group || m.key.fromMe ? null : (m.pushName ?? null);
341
+ this.observeChat(remote_jid, chatPushName, ts);
339
342
 
340
343
  // Unwrap protocol envelopes (view-once, ephemeral, etc.) so we can
341
344
  // see the underlying payload uniformly, then turn it into a text
@@ -357,7 +360,7 @@ export class WhatsAppBridgeAdapter implements BridgeAdapter {
357
360
  const participant_jid = is_group && m.key.participant
358
361
  ? normalizeUserJid(m.key.participant)
359
362
  : null;
360
- const chat_name = this.chats.get(remote_jid)?.name ?? m.pushName ?? null;
363
+ const chat_name = this.chats.get(remote_jid)?.name ?? chatPushName;
361
364
  const sender = resolveWhatsAppSender({
362
365
  fromMe: m.key.fromMe === true,
363
366
  isGroup: is_group,
@@ -422,7 +425,7 @@ export class WhatsAppBridgeAdapter implements BridgeAdapter {
422
425
  };
423
426
  const chatJid = pickContactChatJid(ct);
424
427
  if (!chatJid) continue;
425
- const name = ct.name ?? ct.verifiedName ?? ct.notify ?? null;
428
+ const name = [ct.name, ct.verifiedName, ct.notify].find((n) => n?.trim()) ?? null;
426
429
  this.observeChat(chatJid, name, null);
427
430
  }
428
431
  });
@@ -457,7 +460,7 @@ export class WhatsAppBridgeAdapter implements BridgeAdapter {
457
460
  for (const ct of list) {
458
461
  const chatJid = pickContactChatJid(ct);
459
462
  if (!chatJid) continue;
460
- this.observeChat(chatJid, ct.name ?? ct.verifiedName ?? ct.notify ?? null, null);
463
+ this.observeChat(chatJid, [ct.name, ct.verifiedName, ct.notify].find((n) => n?.trim()) ?? null, null);
461
464
  }
462
465
  });
463
466
 
@@ -592,6 +595,10 @@ export class WhatsAppBridgeAdapter implements BridgeAdapter {
592
595
  this.pushStatus({ status: "disconnected", qr_data_url: null, error: null });
593
596
  }
594
597
 
598
+ chatLink(remote_jid: string): string | null {
599
+ return whatsappChatLink(remote_jid);
600
+ }
601
+
595
602
  async sendText(remote_jid: string, text: string): Promise<void> {
596
603
  // Hard guard: refuse to send to any chat whose route is in silent_mode.
597
604
  // The dispatcher already short-circuits before calling sendText, but
@@ -912,8 +919,11 @@ export class WhatsAppBridgeAdapter implements BridgeAdapter {
912
919
  });
913
920
  attachments.push(ref);
914
921
  const sizeKb = Math.max(1, Math.round(buf.length / 1024));
922
+ // Sender-supplied document names can carry newlines or brackets;
923
+ // describe the attachment by the store's sanitized name.
924
+ const shownName = oneLine(("filename" in ref && ref.filename) || filename);
915
925
  text = (text ? text + "\n" : "")
916
- + `[Attached ${label}: ${filename} (${mime}, ${sizeKb} KB); retained as a Jarela file reference.]`;
926
+ + `[Attached ${label}: ${shownName} (${mime}, ${sizeKb} KB); retained as a Jarela file reference.]`;
917
927
  } catch (err) {
918
928
  const m = errorMessage(err);
919
929
  console.warn(`[bridge ${this.bridge_id}] failed to spill ${label} from ${remote_jid}: ${m}`);
@@ -975,8 +985,8 @@ export class WhatsAppBridgeAdapter implements BridgeAdapter {
975
985
  if (inner.locationMessage) {
976
986
  const { degreesLatitude, degreesLongitude, name, address } = inner.locationMessage;
977
987
  const parts = [`lat=${degreesLatitude ?? "?"}`, `lng=${degreesLongitude ?? "?"}`];
978
- if (name) parts.push(`name="${name}"`);
979
- if (address) parts.push(`address="${address}"`);
988
+ if (name) parts.push(`name=${JSON.stringify(oneLine(name))}`);
989
+ if (address) parts.push(`address=${JSON.stringify(oneLine(address))}`);
980
990
  text = (text ? text + "\n" : "") + `[location: ${parts.join(" ")}]`;
981
991
  }
982
992
 
@@ -986,14 +996,14 @@ export class WhatsAppBridgeAdapter implements BridgeAdapter {
986
996
  }
987
997
 
988
998
  if (inner.contactMessage) {
989
- const name = inner.contactMessage.displayName ?? "unknown";
990
- const vcard = inner.contactMessage.vcard ?? "";
999
+ const name = oneLine(inner.contactMessage.displayName ?? "") || "unknown";
1000
+ const vcard = stripVcardBinary(inner.contactMessage.vcard ?? "");
991
1001
  text = (text ? text + "\n" : "") + `[contact: ${name}]\n${vcard}`;
992
1002
  }
993
1003
 
994
1004
  if (inner.contactsArrayMessage) {
995
1005
  const names = (inner.contactsArrayMessage.contacts ?? [])
996
- .map((c) => c?.displayName ?? "unknown")
1006
+ .map((c) => oneLine(c?.displayName ?? "") || "unknown")
997
1007
  .join(", ");
998
1008
  text = (text ? text + "\n" : "") + `[contacts: ${names}]`;
999
1009
  }
@@ -1005,8 +1015,9 @@ export class WhatsAppBridgeAdapter implements BridgeAdapter {
1005
1015
  if (!remote_jid) return;
1006
1016
  const existing = this.chats.get(remote_jid);
1007
1017
  // Prefer the most recently-observed name (group subjects rename; users
1008
- // change push names). Don't overwrite a known name with null.
1009
- const nextName = name ?? existing?.name ?? null;
1018
+ // change push names). Don't overwrite a known name with a missing or
1019
+ // blank one.
1020
+ const nextName = name?.trim() || existing?.name || null;
1010
1021
  const nextTs = ts && (!existing?.last_message_at || ts > existing.last_message_at)
1011
1022
  ? ts
1012
1023
  : existing?.last_message_at ?? null;
@@ -1026,6 +1037,30 @@ export class WhatsAppBridgeAdapter implements BridgeAdapter {
1026
1037
  get status(): StatusUpdate["status"] { return this.currentStatus; }
1027
1038
  }
1028
1039
 
1040
+ export function oneLine(value: string): string {
1041
+ return value.replace(/[\s\u0085\u2028\u2029]+/g, " ").trim();
1042
+ }
1043
+
1044
+ /**
1045
+ * Phones embed contact photos and logos as base64 vCard properties, often
1046
+ * tens of KB of noise in the agent's context. Drop those properties,
1047
+ * including their folded continuation lines (RFC 6350 §3.2), and keep the
1048
+ * readable fields.
1049
+ */
1050
+ export function stripVcardBinary(vcard: string): string {
1051
+ const out: string[] = [];
1052
+ let skipping = false;
1053
+ for (const line of vcard.split(/\r\n|\r|\n/)) {
1054
+ if (/^[ \t]/.test(line)) {
1055
+ if (!skipping) out.push(line);
1056
+ continue;
1057
+ }
1058
+ skipping = /^(?:[\w-]+\.)?(?:PHOTO|LOGO|SOUND|KEY)[;:]/i.test(line);
1059
+ if (!skipping) out.push(line);
1060
+ }
1061
+ return out.join("\n").trim();
1062
+ }
1063
+
1029
1064
  // Minimal pino-compatible silent logger. Baileys uses `logger.child()`
1030
1065
  // chaining internally, so we return ourselves for every child call.
1031
1066
  function makeSilentLogger(): unknown {
@@ -1070,7 +1105,7 @@ const MEDIA_TYPE_ALLOWLIST: Record<"image" | "audio" | "video" | "document", Rea
1070
1105
  ]),
1071
1106
  };
1072
1107
 
1073
- function sanitizeMediaType(
1108
+ export function sanitizeMediaType(
1074
1109
  raw: string | undefined,
1075
1110
  kind: "image" | "audio" | "video" | "document",
1076
1111
  fallback: string,
@@ -1088,7 +1123,7 @@ function sanitizeMediaType(
1088
1123
  * in several envelope variants (view-once, ephemeral, V2 versions); see
1089
1124
  * `unwrapMessage` for how we collapse them.
1090
1125
  */
1091
- type WAMessageContent = {
1126
+ export type WAMessageContent = {
1092
1127
  conversation?: string;
1093
1128
  extendedTextMessage?: { text?: string };
1094
1129
  stickerMessage?: { mimetype?: string; isAnimated?: boolean };
@@ -1113,7 +1148,7 @@ type WAMessageContent = {
1113
1148
  * payload. Bounded recursion — these envelopes never nest deeper than 2
1114
1149
  * levels in practice, but we cap at 5 as defence in depth.
1115
1150
  */
1116
- function unwrapMessage(msg: WAMessageContent | undefined, depth = 0): WAMessageContent | undefined {
1151
+ export function unwrapMessage(msg: WAMessageContent | undefined, depth = 0): WAMessageContent | undefined {
1117
1152
  if (!msg || depth > 5) return msg;
1118
1153
  if (msg.ephemeralMessage?.message) return unwrapMessage(msg.ephemeralMessage.message, depth + 1);
1119
1154
  if (msg.viewOnceMessage?.message) return unwrapMessage(msg.viewOnceMessage.message, depth + 1);
@@ -1128,7 +1163,7 @@ function unwrapMessage(msg: WAMessageContent | undefined, depth = 0): WAMessageC
1128
1163
  * country-code + number with no separators, no leading '+'. Accepts the
1129
1164
  * common human formats: "+1 (555) 123-4567", "5511 99999-0000", etc.
1130
1165
  */
1131
- function normalizePhoneDigits(input: string): string {
1166
+ export function normalizePhoneDigits(input: string): string {
1132
1167
  return input.replace(/\D+/g, "");
1133
1168
  }
1134
1169
 
@@ -1142,7 +1177,7 @@ function normalizePhoneDigits(input: string): string {
1142
1177
  * the `@s.whatsapp.net` form — without this normalization, every inbound
1143
1178
  * `@lid` message would silently fail to match its route.
1144
1179
  */
1145
- function pickRoutableJid(primary: string, alt: string | undefined): string {
1180
+ export function pickRoutableJid(primary: string, alt: string | undefined): string {
1146
1181
  const isRoutable = (j: string | undefined) =>
1147
1182
  !!j && (j.endsWith("@s.whatsapp.net") || j.endsWith("@g.us"));
1148
1183
  if (isRoutable(primary)) return primary;
@@ -1160,6 +1195,9 @@ export function resolveWhatsAppSender(input: {
1160
1195
  chatName: string | null;
1161
1196
  participantName: string | null;
1162
1197
  }): { senderJid: string; senderName: string } {
1198
+ // Platforms send blank display names ("" or whitespace) for contacts that
1199
+ // never set one; treat those as missing so the fallbacks still apply.
1200
+ const named = (...names: Array<string | null>) => names.find((n) => !!n && n.trim().length > 0)?.trim();
1163
1201
  if (input.fromMe) {
1164
1202
  return {
1165
1203
  senderJid: input.selfJid ?? "me",
@@ -1170,12 +1208,12 @@ export function resolveWhatsAppSender(input: {
1170
1208
  const senderJid = input.participantJid ?? input.remoteJid;
1171
1209
  return {
1172
1210
  senderJid,
1173
- senderName: input.participantName ?? input.pushName ?? senderJid,
1211
+ senderName: named(input.participantName, input.pushName) ?? senderJid,
1174
1212
  };
1175
1213
  }
1176
1214
  return {
1177
1215
  senderJid: input.remoteJid,
1178
- senderName: input.pushName ?? input.chatName ?? input.remoteJid,
1216
+ senderName: named(input.pushName, input.chatName) ?? input.remoteJid,
1179
1217
  };
1180
1218
  }
1181
1219
 
@@ -1198,7 +1236,7 @@ export function resolveWhatsAppSender(input: {
1198
1236
  * Returns null for JIDs that aren't routable chats (self broadcast,
1199
1237
  * status@broadcast, malformed ids, empty input).
1200
1238
  */
1201
- function pickContactChatJid(ct: {
1239
+ export function pickContactChatJid(ct: {
1202
1240
  id?: string;
1203
1241
  phoneNumber?: string;
1204
1242
  lid?: string;
@@ -1220,7 +1258,7 @@ function pickContactChatJid(ct: {
1220
1258
  * routable user JID — `sendMessage` won't deliver to a JID with `:NN`.
1221
1259
  * Returns null if the input is malformed.
1222
1260
  */
1223
- function normalizeUserJid(id: unknown): string | null {
1261
+ export function normalizeUserJid(id: unknown): string | null {
1224
1262
  if (typeof id !== "string") return null;
1225
1263
  // Strip any device suffix (":NN") before the '@'.
1226
1264
  const at = id.indexOf("@");
@@ -1234,7 +1272,7 @@ function normalizeUserJid(id: unknown): string | null {
1234
1272
  return `${user}@${normHost}`;
1235
1273
  }
1236
1274
 
1237
- function humanList(items: string[], max: number): string {
1275
+ export function humanList(items: string[], max: number): string {
1238
1276
  const clean = items.filter((s) => s && s.trim().length > 0);
1239
1277
  if (clean.length === 0) return "(none)";
1240
1278
  if (clean.length <= max) return clean.join(", ");
@@ -18,6 +18,67 @@ afterAll(() => {
18
18
  try { rmSync(tmpRoot, { recursive: true, force: true }); } catch {}
19
19
  });
20
20
 
21
+ describe("responsive transcript indexes (ADR-0103)", () => {
22
+ it("restores missing indexes on upgrade and remains idempotent", async () => {
23
+ const db = getDb();
24
+ const names = [
25
+ "idx_messages_thread_seq", "idx_messages_created_at",
26
+ "idx_threads_updated", "idx_message_usage_thread_created",
27
+ "idx_memory_namespace_updated", "idx_memory_updated",
28
+ "idx_runtime_operations_thread_state", "idx_signal_events_thread",
29
+ "idx_signal_events_operation", "idx_signal_deliveries_lease",
30
+ "idx_background_results_expiry", "idx_message_embedding_jobs_lease",
31
+ ];
32
+ for (const name of names) db.exec(`DROP INDEX IF EXISTS ${name}`);
33
+ const { runMigrations } = await import("./migrations");
34
+ runMigrations(db);
35
+ runMigrations(db);
36
+ const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index'").all()
37
+ .map((row) => row.name);
38
+ expect(indexes).toEqual(expect.arrayContaining(names));
39
+ });
40
+
41
+ it.each([
42
+ ["SELECT rowid, msg_id, content FROM messages WHERE thread_id=? ORDER BY rowid DESC LIMIT ?", ["thread", 51]],
43
+ ["SELECT rowid, msg_id, content FROM messages WHERE thread_id=? AND rowid<? ORDER BY rowid DESC LIMIT ?", ["thread", 100, 51]],
44
+ ["SELECT rowid, msg_id, content FROM messages WHERE thread_id=? AND rowid>? ORDER BY rowid ASC LIMIT ?", ["thread", 100, 50]],
45
+ ["SELECT content FROM messages WHERE created_at>=? ORDER BY created_at ASC", ["2026-10-01"]],
46
+ ["SELECT * FROM threads WHERE agent_id=? ORDER BY updated_at DESC LIMIT ?", ["agent", 50]],
47
+ ["SELECT * FROM threads ORDER BY updated_at DESC LIMIT ? OFFSET ?", [50, 0]],
48
+ ])("uses ordered indexes for %s", (query, params) => {
49
+ const plan = getDb().prepare(`EXPLAIN QUERY PLAN ${query}`).all(...params)
50
+ .map((row) => String(row.detail)).join("; ");
51
+ expect(plan).not.toMatch(/USE TEMP B-TREE|SCAN (?:messages|threads)(?! USING INDEX)/);
52
+ expect(plan).toMatch(/USING INDEX/);
53
+ });
54
+
55
+ it.each([
56
+ ["SELECT * FROM message_usage WHERE thread_id=? ORDER BY created_at DESC LIMIT 1", ["thread"], "idx_message_usage_thread_created"],
57
+ ["SELECT namespace,key,value FROM memory_store WHERE namespace=? ORDER BY updated_at DESC LIMIT ?", ["facts", 50], "idx_memory_namespace_updated"],
58
+ ["SELECT namespace,key,value FROM memory_store ORDER BY updated_at DESC LIMIT ?", [50], "idx_memory_updated"],
59
+ ["SELECT rowid FROM runtime_operations WHERE thread_id=? AND state='accepted'", ["thread"], "idx_runtime_operations_thread_state"],
60
+ ["SELECT rowid FROM signal_events WHERE thread_id=?", ["thread"], "idx_signal_events_thread"],
61
+ ["SELECT rowid FROM signal_events WHERE operation_id=?", ["operation"], "idx_signal_events_operation"],
62
+ ["SELECT key FROM background_tool_results WHERE expires_at<=?", [1000], "idx_background_results_expiry"],
63
+ ["SELECT event_id FROM signal_deliveries WHERE state='leased' AND lease_until<=?", [1000], "idx_signal_deliveries_lease"],
64
+ ["SELECT message_id FROM message_embedding_jobs WHERE state='processing' AND lease_until_ms<=?", [1000], "idx_message_embedding_jobs_lease"],
65
+ ])("indexes secondary lookup %s", (query, params, index) => {
66
+ const plan = getDb().prepare(`EXPLAIN QUERY PLAN ${query}`).all(...params)
67
+ .map((row) => String(row.detail)).join("; ");
68
+ expect(plan).not.toMatch(/USE TEMP B-TREE/);
69
+ expect(plan).toContain(index);
70
+ if (!query.includes("FROM memory_store ORDER BY")) expect(plan).toContain("SEARCH");
71
+ });
72
+
73
+ it("reuses the unique agent index instead of adding a redundant ordering index", () => {
74
+ const indexes = getDb().prepare("PRAGMA index_list(threads)").all();
75
+ expect(indexes).toEqual(expect.arrayContaining([
76
+ expect.objectContaining({ name: "idx_threads_agent_id", unique: 1 }),
77
+ ]));
78
+ expect(indexes.some((row) => row.name === "idx_threads_agent_updated")).toBe(false);
79
+ });
80
+ });
81
+
21
82
  describe("warm summary limit migration", () => {
22
83
  it("adds the default limit to existing agents", async () => {
23
84
  const db = getDb();
@@ -35,6 +35,9 @@ export function runMigrations(db: DatabaseSync): void {
35
35
  -- (getMessages, getRecentMessagesWindow, getMessagesPage, clear).
36
36
  -- Without this every read full-scans the messages table.
37
37
  CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages(thread_id, created_at);
38
+ CREATE INDEX IF NOT EXISTS idx_messages_thread_seq ON messages(thread_id);
39
+ CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
40
+ CREATE INDEX IF NOT EXISTS idx_threads_updated ON threads(updated_at DESC);
38
41
  CREATE TABLE IF NOT EXISTS runtime_operations (
39
42
  id TEXT PRIMARY KEY,
40
43
  kind TEXT NOT NULL,
@@ -58,6 +61,9 @@ export function runMigrations(db: DatabaseSync): void {
58
61
  created_at INTEGER NOT NULL,
59
62
  UNIQUE(kind,operation_id,agent_id,thread_id)
60
63
  );
64
+ CREATE INDEX IF NOT EXISTS idx_runtime_operations_thread_state ON runtime_operations(thread_id,state);
65
+ CREATE INDEX IF NOT EXISTS idx_signal_events_thread ON signal_events(thread_id);
66
+ CREATE INDEX IF NOT EXISTS idx_signal_events_operation ON signal_events(operation_id);
61
67
  CREATE TABLE IF NOT EXISTS signal_deliveries (
62
68
  event_id TEXT PRIMARY KEY REFERENCES signal_events(id) ON DELETE CASCADE,
63
69
  wake_eligible INTEGER NOT NULL DEFAULT 0,
@@ -72,6 +78,7 @@ export function runMigrations(db: DatabaseSync): void {
72
78
  );
73
79
  CREATE INDEX IF NOT EXISTS idx_signal_events_target ON signal_events(agent_id,thread_id,seq);
74
80
  CREATE INDEX IF NOT EXISTS idx_signal_deliveries_due ON signal_deliveries(state,available_at);
81
+ CREATE INDEX IF NOT EXISTS idx_signal_deliveries_lease ON signal_deliveries(state,lease_until);
75
82
  CREATE TABLE IF NOT EXISTS background_tool_results (
76
83
  key TEXT PRIMARY KEY REFERENCES runtime_operations(id) ON DELETE CASCADE,
77
84
  thread_id TEXT NOT NULL REFERENCES threads(thread_id) ON DELETE CASCADE,
@@ -83,6 +90,7 @@ export function runMigrations(db: DatabaseSync): void {
83
90
  expires_at INTEGER NOT NULL
84
91
  );
85
92
  CREATE INDEX IF NOT EXISTS idx_background_results_owner ON background_tool_results(thread_id,expires_at);
93
+ CREATE INDEX IF NOT EXISTS idx_background_results_expiry ON background_tool_results(expires_at);
86
94
  CREATE TABLE IF NOT EXISTS memory_store (
87
95
  namespace TEXT NOT NULL,
88
96
  key TEXT NOT NULL,
@@ -91,6 +99,8 @@ export function runMigrations(db: DatabaseSync): void {
91
99
  updated_at TEXT NOT NULL,
92
100
  PRIMARY KEY (namespace, key)
93
101
  );
102
+ CREATE INDEX IF NOT EXISTS idx_memory_namespace_updated ON memory_store(namespace,updated_at DESC);
103
+ CREATE INDEX IF NOT EXISTS idx_memory_updated ON memory_store(updated_at DESC);
94
104
  CREATE TABLE IF NOT EXISTS model_configs (
95
105
  name TEXT PRIMARY KEY,
96
106
  provider TEXT NOT NULL,
@@ -660,6 +670,7 @@ function ensureMessageUsageTable(db: DatabaseSync): void {
660
670
  );
661
671
  CREATE INDEX IF NOT EXISTS idx_message_usage_created_at ON message_usage(created_at);
662
672
  CREATE INDEX IF NOT EXISTS idx_message_usage_agent_id ON message_usage(agent_id);
673
+ CREATE INDEX IF NOT EXISTS idx_message_usage_thread_created ON message_usage(thread_id,created_at DESC);
663
674
  `);
664
675
  }
665
676
 
@@ -703,6 +714,8 @@ function ensureMessageEmbeddingJobsTable(db: DatabaseSync): void {
703
714
  );
704
715
  CREATE INDEX IF NOT EXISTS idx_message_embedding_jobs_due
705
716
  ON message_embedding_jobs(state, available_at_ms, lease_until_ms);
717
+ CREATE INDEX IF NOT EXISTS idx_message_embedding_jobs_lease
718
+ ON message_embedding_jobs(state, lease_until_ms);
706
719
  INSERT OR IGNORE INTO message_embedding_jobs (message_id, created_at, updated_at)
707
720
  SELECT msg_id, created_at, created_at FROM messages WHERE embedding IS NULL;
708
721
  `);
@@ -0,0 +1,114 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { performance } from "node:perf_hooks";
3
+ import { afterAll, expect, it, vi } from "vitest";
4
+ import { LOCAL_EMBEDDING_MODEL_ID } from "./constants";
5
+
6
+ // Opt-in, local-only benchmark. No user database, credentials, or downloads.
7
+ const state = vi.hoisted(() => ({ db: null as DatabaseSync | null }));
8
+ vi.mock("@/lib/db", () => ({
9
+ getDb: () => {
10
+ if (!state.db) throw new Error("benchmark database not initialized");
11
+ return state.db;
12
+ },
13
+ }));
14
+ vi.mock("@/lib/providers", () => ({
15
+ getProvider: () => { throw new Error("remote providers are forbidden in this benchmark"); },
16
+ }));
17
+ vi.mock("@/lib/stores/model-config", () => ({
18
+ getModelConfig: () => null,
19
+ getDefaultModelConfig: () => null,
20
+ getModelParams: () => ({}),
21
+ listModelConfigs: () => [],
22
+ }));
23
+
24
+ const previousDownload = process.env.JARELA_LOCAL_EMBEDDING_DOWNLOAD;
25
+ afterAll(() => {
26
+ state.db?.close();
27
+ if (previousDownload === undefined) delete process.env.JARELA_LOCAL_EMBEDDING_DOWNLOAD;
28
+ else process.env.JARELA_LOCAL_EMBEDDING_DOWNLOAD = previousDownload;
29
+ });
30
+
31
+ it.skipIf(process.env.JARELA_BENCHMARK_EMBEDDINGS !== "1")(
32
+ "reports local embedding and synthetic memory-search latency",
33
+ async () => {
34
+ process.env.JARELA_LOCAL_EMBEDDING_DOWNLOAD = "0";
35
+ const { getLocalModelStatus } = await import("./local-model");
36
+ expect(getLocalModelStatus().state, "Install the local model before running this offline benchmark").toBe("ready");
37
+ const { runMigrations } = await import("@/lib/db/migrations");
38
+ state.db = new DatabaseSync(":memory:");
39
+ runMigrations(state.db);
40
+ const { setLocalEmbeddingsEnabled, setEmbeddingVectorsSignature } = await import("@/lib/stores/app-settings");
41
+ setLocalEmbeddingsEnabled(true);
42
+ setEmbeddingVectorsSignature(LOCAL_EMBEDDING_MODEL_ID);
43
+ const { embedLocally } = await import("./local");
44
+ const { embedQueryOne, searchMemory, _resetEmbeddingCache, _resetEmbedCaches, reembedStaleVectors } = await import("./index");
45
+ const results: Array<{ operation: string; samples: number; median_ms: number; p95_ms: number }> = [];
46
+ async function time(operation: string, count: number, run: (index: number) => Promise<void>) {
47
+ const elapsed: number[] = [];
48
+ for (let i = 0; i < count; i++) {
49
+ const start = performance.now();
50
+ await run(i);
51
+ elapsed.push(performance.now() - start);
52
+ }
53
+ elapsed.sort((a, b) => a - b);
54
+ results.push({
55
+ operation, samples: count,
56
+ median_ms: Number(elapsed[Math.floor(elapsed.length / 2)].toFixed(3)),
57
+ p95_ms: Number(elapsed[Math.ceil(elapsed.length * 0.95) - 1].toFixed(3)),
58
+ });
59
+ }
60
+ const query = "What is the deployment rollback procedure?";
61
+ const validate = (vector: number[] | null) => {
62
+ expect(vector).toHaveLength(384);
63
+ expect(vector?.every(Number.isFinite)).toBe(true);
64
+ };
65
+ await time("cold pipeline + first query inference", 1, async () => {
66
+ validate((await embedLocally([query], "query"))[0]);
67
+ });
68
+ await time("warm pipeline, uncached short query", 10, async (i) => {
69
+ validate((await embedLocally([`${query} revision ${i}`], "query"))[0]);
70
+ });
71
+ await time("warm pipeline, 16 short passages", 3, async () => {
72
+ const vectors = await embedLocally(Array.from({ length: 16 }, (_, i) => `Deployment step ${i}: preserve backups and verify health.`), "passage");
73
+ expect(vectors).toHaveLength(16);
74
+ vectors.forEach(validate);
75
+ });
76
+ await embedQueryOne(query);
77
+ await time("query embedding content-cache hit", 20, async () => { validate(await embedQueryOne(query)); });
78
+ const vector = await embedQueryOne(query);
79
+ const encoded = JSON.stringify(vector);
80
+ const db = state.db;
81
+ db.prepare("INSERT INTO threads(thread_id,agent_id,created_at,updated_at) VALUES ('bench','bench',?,?)")
82
+ .run("2026-01-01", "2026-01-01");
83
+ const memory = db.prepare("INSERT INTO memory_store(namespace,key,value,created_at,updated_at,embedding) VALUES ('benchmark',?,?,?,?,?)");
84
+ const message = db.prepare("INSERT INTO messages(msg_id,thread_id,role,content,created_at,embedding) VALUES (?,'bench','assistant',?,?,?)");
85
+ const job = db.prepare("INSERT INTO message_embedding_jobs(message_id,source_hash,state,created_at,updated_at,model_signature) VALUES (?,?,'done','2026-01-01','2026-01-01',?)");
86
+ for (const rows of [1_000, 10_000]) {
87
+ db.exec("DELETE FROM memory_store WHERE namespace='benchmark'; DELETE FROM message_embedding_jobs; DELETE FROM messages; BEGIN");
88
+ for (let i = 0; i < rows / 2; i++) {
89
+ const content = `Synthetic deployment rollback procedure ${i}: restore backup, verify health, then resume traffic.`;
90
+ memory.run(`key-${i}`, content, "2026-01-01", "2026-01-01", encoded);
91
+ message.run(`msg-${i}`, content, "2026-01-01", encoded);
92
+ job.run(`msg-${i}`, "synthetic", LOCAL_EMBEDDING_MODEL_ID);
93
+ }
94
+ db.exec("COMMIT");
95
+ _resetEmbedCaches();
96
+ await time(`${rows} vectors: first search, cold row cache`, 1, async () => {
97
+ const hits = await searchMemory(query, { literal: true, limit: 10 });
98
+ expect(hits).toHaveLength(10);
99
+ });
100
+ await reembedStaleVectors(384);
101
+ await time(`${rows} vectors: warm rows + cached query`, 10, async () => {
102
+ expect(await searchMemory(query, { literal: true, limit: 10 })).toHaveLength(10);
103
+ });
104
+ await time(`${rows} vectors: warm rows + new query inference`, 5, async (i) => {
105
+ _resetEmbeddingCache();
106
+ expect(await searchMemory(`${query} check ${i}`, { literal: true, limit: 10 })).toHaveLength(10);
107
+ });
108
+ await reembedStaleVectors(384);
109
+ }
110
+ console.table(results);
111
+ console.log("Synthetic repeated 384-dimensional vectors; measures scan/sort cost, not retrieval quality. No HTTP transport or provider network latency.");
112
+ },
113
+ 120_000,
114
+ );
@@ -324,3 +324,73 @@ describe("pricing snapshot refresh", () => {
324
324
  expect(modelRate?.output_per_1m_usd).toBe(0.28);
325
325
  });
326
326
  });
327
+
328
+ describe("pricing snapshot edge cases", () => {
329
+ beforeEach(() => {
330
+ vi.resetModules();
331
+ readFileMock.mockReset();
332
+ mkdirMock.mockReset();
333
+ writeFileMock.mockReset();
334
+ vi.unstubAllGlobals();
335
+ });
336
+
337
+ afterEach(() => {
338
+ vi.unstubAllGlobals();
339
+ });
340
+
341
+ it("refreshes a snapshot stamped in the future instead of trusting it forever", async () => {
342
+ readFileMock.mockResolvedValueOnce(JSON.stringify({
343
+ generated_at: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
344
+ disclaimer: "skewed",
345
+ ttl_days: 3,
346
+ sources: [],
347
+ }));
348
+ vi.stubGlobal("fetch", vi.fn(async () => new Response("input $1 / 1M tokens output $2 / 1M tokens", { status: 200 })));
349
+
350
+ const { refreshPricingSnapshot } = await import("./snapshot");
351
+ const res = await refreshPricingSnapshot({ providers: ["cohere"] });
352
+
353
+ expect(res).toMatchObject({ refreshed: true, reason: "stale" });
354
+ });
355
+
356
+ it("treats a corrupt snapshot file as missing", async () => {
357
+ readFileMock.mockResolvedValueOnce("{not json");
358
+ vi.stubGlobal("fetch", vi.fn(async () => new Response("input $1 / 1M tokens output $2 / 1M tokens", { status: 200 })));
359
+
360
+ const { refreshPricingSnapshot } = await import("./snapshot");
361
+ const res = await refreshPricingSnapshot({ providers: ["cohere"] });
362
+
363
+ expect(res).toMatchObject({ refreshed: true, reason: "missing" });
364
+ });
365
+
366
+ it("parses each source's search results independently and skips Google's own links", async () => {
367
+ readFileMock.mockRejectedValueOnce(new Error("ENOENT"));
368
+ const link = (u: string) => `<a href="/url?q=${encodeURIComponent(u)}&amp;sa=U">x</a>`;
369
+ const anthropicSearch = "<html>" + "x".repeat(4000)
370
+ + link("https://www.google.com/intl/en/policies/")
371
+ + Array.from({ length: 9 }, (_, i) => link(`https://a${i}.example.com/pricing`)).join("")
372
+ + "</html>";
373
+ const cohereSearch = `<html>${link("https://c.example.com/pricing")}</html>`;
374
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
375
+ const url = String(input);
376
+ if (url.startsWith("https://www.google.com/search")) {
377
+ if (url.includes("Anthropic")) return new Response(anthropicSearch, { status: 200 });
378
+ // Only Cohere's first query has results, so a leaked regex cursor cannot be masked by a retry.
379
+ return new Response(url.includes("1M") ? cohereSearch : "<html></html>", { status: 200 });
380
+ }
381
+ if (/^https:\/\/(a0|c)\.example\.com\//.test(url)) {
382
+ return new Response("input $3 / 1M tokens output $15 / 1M tokens", { status: 200 });
383
+ }
384
+ return new Response("not found", { status: 404 });
385
+ });
386
+ vi.stubGlobal("fetch", fetchMock);
387
+
388
+ const { refreshPricingSnapshot } = await import("./snapshot");
389
+ const res = await refreshPricingSnapshot({ force: true, providers: ["anthropic", "cohere"] });
390
+ const byId = new Map(res.snapshot.sources.map((s) => [s.id, s]));
391
+
392
+ expect(byId.get("anthropic")).toMatchObject({ ok: true, resolved_url: "https://a0.example.com/pricing" });
393
+ expect(byId.get("cohere")).toMatchObject({ ok: true, resolved_url: "https://c.example.com/pricing" });
394
+ expect(fetchMock.mock.calls.map(([u]) => String(u))).not.toContain("https://www.google.com/intl/en/policies/");
395
+ });
396
+ });
@@ -85,6 +85,7 @@ const PRICE_LINE_RE = new RegExp(
85
85
  );
86
86
 
87
87
  const GOOGLE_RESULT_RE = /href="\/url\?q=([^"&]+)[^"]*"/gi;
88
+ const GOOGLE_OWN_HOST_RE = /^(?:www|accounts|support|maps|policies|webcache)?\.?google\.[a-z.]+$/i;
88
89
 
89
90
  type FetchAttempt = {
90
91
  url: string;
@@ -168,7 +169,8 @@ export async function refreshPricingSnapshot(opts?: { force?: boolean; ttlDays?:
168
169
  const existing = await readPricingSnapshot();
169
170
  if (!force && existing) {
170
171
  const ageDays = (Date.now() - Date.parse(existing.generated_at)) / (24 * 60 * 60 * 1000);
171
- if (Number.isFinite(ageDays) && ageDays < ttlDays) {
172
+ // A future generated_at (clock skew, copied snapshot) must not pin the cache as fresh.
173
+ if (Number.isFinite(ageDays) && ageDays >= 0 && ageDays < ttlDays) {
172
174
  return { refreshed: false, reason: "fresh", snapshot: existing };
173
175
  }
174
176
  }
@@ -332,6 +334,8 @@ function parseGoogleResultLinks(html: string): string[] {
332
334
  const seen = new Set<string>();
333
335
 
334
336
  let m: RegExpExecArray | null;
337
+ // Shared /g regex: an early break leaves lastIndex mid-string for the next page.
338
+ GOOGLE_RESULT_RE.lastIndex = 0;
335
339
  while ((m = GOOGLE_RESULT_RE.exec(html)) !== null) {
336
340
  const raw = m[1] ?? "";
337
341
  let url = "";
@@ -341,7 +345,11 @@ function parseGoogleResultLinks(html: string): string[] {
341
345
  continue;
342
346
  }
343
347
  if (!/^https?:\/\//i.test(url)) continue;
344
- if (/\/google\./i.test(url)) continue;
348
+ try {
349
+ if (GOOGLE_OWN_HOST_RE.test(new URL(url).hostname)) continue;
350
+ } catch {
351
+ continue;
352
+ }
345
353
  if (seen.has(url)) continue;
346
354
  seen.add(url);
347
355
  out.push(url);