managed-deepagents 0.9.6-dev.3 → 0.9.6-dev.31

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 (359) hide show
  1. package/README.md +1075 -37
  2. package/dist/agent-auth.d.ts +1 -1
  3. package/dist/agent-auth.d.ts.map +1 -1
  4. package/dist/agent-auth.js +36 -12
  5. package/dist/agent-auth.js.map +1 -1
  6. package/dist/browser.d.ts +37 -0
  7. package/dist/browser.d.ts.map +1 -0
  8. package/dist/browser.js +31 -0
  9. package/dist/browser.js.map +1 -0
  10. package/dist/channels/actor.d.ts +57 -0
  11. package/dist/channels/actor.d.ts.map +1 -0
  12. package/dist/channels/actor.js +24 -0
  13. package/dist/channels/actor.js.map +1 -0
  14. package/dist/channels/assistant-text.d.ts +29 -0
  15. package/dist/channels/assistant-text.d.ts.map +1 -1
  16. package/dist/channels/assistant-text.js +81 -4
  17. package/dist/channels/assistant-text.js.map +1 -1
  18. package/dist/channels/binding.d.ts +20 -1
  19. package/dist/channels/binding.d.ts.map +1 -1
  20. package/dist/channels/binding.js +44 -11
  21. package/dist/channels/binding.js.map +1 -1
  22. package/dist/channels/delivery.d.ts +51 -0
  23. package/dist/channels/delivery.d.ts.map +1 -0
  24. package/dist/channels/delivery.js +424 -0
  25. package/dist/channels/delivery.js.map +1 -0
  26. package/dist/channels/github/app-setup.d.ts +32 -0
  27. package/dist/channels/github/app-setup.d.ts.map +1 -0
  28. package/dist/channels/github/app-setup.js +146 -0
  29. package/dist/channels/github/app-setup.js.map +1 -0
  30. package/dist/channels/github/connections.d.ts +11 -0
  31. package/dist/channels/github/connections.d.ts.map +1 -0
  32. package/dist/channels/github/connections.js +14 -0
  33. package/dist/channels/github/connections.js.map +1 -0
  34. package/dist/channels/github/index.d.ts +12 -0
  35. package/dist/channels/github/index.d.ts.map +1 -0
  36. package/dist/channels/github/index.js +266 -0
  37. package/dist/channels/github/index.js.map +1 -0
  38. package/dist/channels/github/mentions.d.ts +12 -0
  39. package/dist/channels/github/mentions.d.ts.map +1 -0
  40. package/dist/channels/github/mentions.js +185 -0
  41. package/dist/channels/github/mentions.js.map +1 -0
  42. package/dist/channels/github/schema.d.ts +4165 -0
  43. package/dist/channels/github/schema.d.ts.map +1 -0
  44. package/dist/channels/github/schema.js +221 -0
  45. package/dist/channels/github/schema.js.map +1 -0
  46. package/dist/channels/github/types.d.ts +170 -0
  47. package/dist/channels/github/types.d.ts.map +1 -0
  48. package/dist/channels/github/types.js +2 -0
  49. package/dist/channels/github/types.js.map +1 -0
  50. package/dist/channels/http/endpoint.d.ts +137 -0
  51. package/dist/channels/http/endpoint.d.ts.map +1 -0
  52. package/dist/channels/http/endpoint.js +286 -0
  53. package/dist/channels/http/endpoint.js.map +1 -0
  54. package/dist/channels/http/http.d.ts.map +1 -1
  55. package/dist/channels/http/http.js +439 -65
  56. package/dist/channels/http/http.js.map +1 -1
  57. package/dist/channels/http/index.d.ts +8 -1
  58. package/dist/channels/http/index.d.ts.map +1 -1
  59. package/dist/channels/http/index.js +35 -16
  60. package/dist/channels/http/index.js.map +1 -1
  61. package/dist/channels/http/ingress.d.ts +67 -5
  62. package/dist/channels/http/ingress.d.ts.map +1 -1
  63. package/dist/channels/http/ingress.js +155 -43
  64. package/dist/channels/http/ingress.js.map +1 -1
  65. package/dist/channels/http/provider.d.ts +125 -0
  66. package/dist/channels/http/provider.d.ts.map +1 -0
  67. package/dist/channels/http/provider.js +227 -0
  68. package/dist/channels/http/provider.js.map +1 -0
  69. package/dist/channels/http/studio.d.ts +24 -0
  70. package/dist/channels/http/studio.d.ts.map +1 -0
  71. package/dist/channels/http/studio.js +95 -0
  72. package/dist/channels/http/studio.js.map +1 -0
  73. package/dist/channels/http/threads.d.ts +7 -0
  74. package/dist/channels/http/threads.d.ts.map +1 -0
  75. package/dist/channels/http/threads.js +10 -0
  76. package/dist/channels/http/threads.js.map +1 -0
  77. package/dist/channels/http/types.d.ts +166 -2
  78. package/dist/channels/http/types.d.ts.map +1 -1
  79. package/dist/channels/linear/app-setup.d.ts +29 -0
  80. package/dist/channels/linear/app-setup.d.ts.map +1 -0
  81. package/dist/channels/linear/app-setup.js +102 -0
  82. package/dist/channels/linear/app-setup.js.map +1 -0
  83. package/dist/channels/linear/connections.d.ts +11 -0
  84. package/dist/channels/linear/connections.d.ts.map +1 -0
  85. package/dist/channels/linear/connections.js +14 -0
  86. package/dist/channels/linear/connections.js.map +1 -0
  87. package/dist/channels/linear/graphql.d.ts +57 -0
  88. package/dist/channels/linear/graphql.d.ts.map +1 -0
  89. package/dist/channels/linear/graphql.js +167 -0
  90. package/dist/channels/linear/graphql.js.map +1 -0
  91. package/dist/channels/linear/index.d.ts +13 -0
  92. package/dist/channels/linear/index.d.ts.map +1 -0
  93. package/dist/channels/linear/index.js +457 -0
  94. package/dist/channels/linear/index.js.map +1 -0
  95. package/dist/channels/linear/schema.d.ts +1050 -0
  96. package/dist/channels/linear/schema.d.ts.map +1 -0
  97. package/dist/channels/linear/schema.js +255 -0
  98. package/dist/channels/linear/schema.js.map +1 -0
  99. package/dist/channels/linear/types.d.ts +169 -0
  100. package/dist/channels/linear/types.d.ts.map +1 -0
  101. package/dist/channels/linear/types.js +2 -0
  102. package/dist/channels/linear/types.js.map +1 -0
  103. package/dist/channels/message-schema.d.ts +2 -0
  104. package/dist/channels/message-schema.d.ts.map +1 -1
  105. package/dist/channels/message-schema.js +4 -0
  106. package/dist/channels/message-schema.js.map +1 -1
  107. package/dist/channels/messaging-registry.d.ts +16 -0
  108. package/dist/channels/messaging-registry.d.ts.map +1 -1
  109. package/dist/channels/messaging-registry.js +29 -0
  110. package/dist/channels/messaging-registry.js.map +1 -1
  111. package/dist/channels/reserved-connections.d.ts +12 -0
  112. package/dist/channels/reserved-connections.d.ts.map +1 -0
  113. package/dist/channels/reserved-connections.js +55 -0
  114. package/dist/channels/reserved-connections.js.map +1 -0
  115. package/dist/channels/resolve-user-endpoint.d.ts +34 -0
  116. package/dist/channels/resolve-user-endpoint.d.ts.map +1 -0
  117. package/dist/channels/resolve-user-endpoint.js +51 -0
  118. package/dist/channels/resolve-user-endpoint.js.map +1 -0
  119. package/dist/channels/resolve-user.d.ts +95 -0
  120. package/dist/channels/resolve-user.d.ts.map +1 -0
  121. package/dist/channels/resolve-user.js +143 -0
  122. package/dist/channels/resolve-user.js.map +1 -0
  123. package/dist/channels/run-input.d.ts +88 -0
  124. package/dist/channels/run-input.d.ts.map +1 -0
  125. package/dist/channels/run-input.js +177 -0
  126. package/dist/channels/run-input.js.map +1 -0
  127. package/dist/channels/runtime.d.ts +5 -2
  128. package/dist/channels/runtime.d.ts.map +1 -1
  129. package/dist/channels/runtime.js +19 -10
  130. package/dist/channels/runtime.js.map +1 -1
  131. package/dist/channels/schedule-delivery.d.ts +2 -13
  132. package/dist/channels/schedule-delivery.d.ts.map +1 -1
  133. package/dist/channels/schedule-delivery.js +6 -73
  134. package/dist/channels/schedule-delivery.js.map +1 -1
  135. package/dist/channels/slack/api.d.ts +2 -0
  136. package/dist/channels/slack/api.d.ts.map +1 -1
  137. package/dist/channels/slack/api.js +6 -0
  138. package/dist/channels/slack/api.js.map +1 -1
  139. package/dist/channels/slack/connections.d.ts +6 -0
  140. package/dist/channels/slack/connections.d.ts.map +1 -0
  141. package/dist/channels/slack/connections.js +6 -0
  142. package/dist/channels/slack/connections.js.map +1 -0
  143. package/dist/channels/slack/events.d.ts +278 -0
  144. package/dist/channels/slack/events.d.ts.map +1 -0
  145. package/dist/channels/slack/events.js +235 -0
  146. package/dist/channels/slack/events.js.map +1 -0
  147. package/dist/channels/slack/files.d.ts.map +1 -1
  148. package/dist/channels/slack/files.js +2 -3
  149. package/dist/channels/slack/files.js.map +1 -1
  150. package/dist/channels/slack/index.d.ts +90 -2
  151. package/dist/channels/slack/index.d.ts.map +1 -1
  152. package/dist/channels/slack/index.js +188 -6
  153. package/dist/channels/slack/index.js.map +1 -1
  154. package/dist/channels/slack/message-content.d.ts.map +1 -1
  155. package/dist/channels/slack/message-content.js +23 -28
  156. package/dist/channels/slack/message-content.js.map +1 -1
  157. package/dist/channels/slack/status.d.ts +76 -0
  158. package/dist/channels/slack/status.d.ts.map +1 -0
  159. package/dist/channels/slack/status.js +427 -0
  160. package/dist/channels/slack/status.js.map +1 -0
  161. package/dist/channels/source-providers.js +1 -1
  162. package/dist/channels/source-providers.js.map +1 -1
  163. package/dist/channels/trigger/client.d.ts.map +1 -1
  164. package/dist/channels/trigger/client.js +9 -15
  165. package/dist/channels/trigger/client.js.map +1 -1
  166. package/dist/channels/trigger/conversation.d.ts +11 -0
  167. package/dist/channels/trigger/conversation.d.ts.map +1 -0
  168. package/dist/channels/trigger/conversation.js +72 -0
  169. package/dist/channels/trigger/conversation.js.map +1 -0
  170. package/dist/channels/trigger/http.d.ts +130 -4
  171. package/dist/channels/trigger/http.d.ts.map +1 -1
  172. package/dist/channels/trigger/http.js +408 -78
  173. package/dist/channels/trigger/http.js.map +1 -1
  174. package/dist/channels/trigger/identity-context.d.ts.map +1 -1
  175. package/dist/channels/trigger/identity-context.js +2 -5
  176. package/dist/channels/trigger/identity-context.js.map +1 -1
  177. package/dist/channels/trigger/ingress.d.ts +70 -1
  178. package/dist/channels/trigger/ingress.d.ts.map +1 -1
  179. package/dist/channels/trigger/ingress.js +135 -21
  180. package/dist/channels/trigger/ingress.js.map +1 -1
  181. package/dist/channels/trigger/transport.d.ts +10 -0
  182. package/dist/channels/trigger/transport.d.ts.map +1 -1
  183. package/dist/channels/trigger/transport.js +25 -9
  184. package/dist/channels/trigger/transport.js.map +1 -1
  185. package/dist/channels/trigger/types.d.ts +92 -3
  186. package/dist/channels/trigger/types.d.ts.map +1 -1
  187. package/dist/channels/trigger/types.js +37 -0
  188. package/dist/channels/trigger/types.js.map +1 -1
  189. package/dist/channels/types.d.ts +53 -4
  190. package/dist/channels/types.d.ts.map +1 -1
  191. package/dist/channels/types.js +2 -0
  192. package/dist/channels/types.js.map +1 -1
  193. package/dist/connections.d.ts +36 -12
  194. package/dist/connections.d.ts.map +1 -1
  195. package/dist/connections.js +125 -30
  196. package/dist/connections.js.map +1 -1
  197. package/dist/connectors/mcp/langsmith-url.d.ts.map +1 -1
  198. package/dist/connectors/mcp/langsmith-url.js +0 -1
  199. package/dist/connectors/mcp/langsmith-url.js.map +1 -1
  200. package/dist/credential-gate.d.ts +2 -0
  201. package/dist/credential-gate.d.ts.map +1 -1
  202. package/dist/credential-gate.js +37 -3
  203. package/dist/credential-gate.js.map +1 -1
  204. package/dist/define-deep-agent.d.ts +2 -2
  205. package/dist/define-deep-agent.d.ts.map +1 -1
  206. package/dist/define-deep-agent.js +53 -2
  207. package/dist/define-deep-agent.js.map +1 -1
  208. package/dist/identity/index.d.ts.map +1 -1
  209. package/dist/identity/index.js +3 -1
  210. package/dist/identity/index.js.map +1 -1
  211. package/dist/identity/loopback.d.ts +1 -1
  212. package/dist/identity/loopback.d.ts.map +1 -1
  213. package/dist/identity/loopback.js +3 -2
  214. package/dist/identity/loopback.js.map +1 -1
  215. package/dist/identity/options.d.ts +9 -0
  216. package/dist/identity/options.d.ts.map +1 -1
  217. package/dist/identity/options.js +1 -1
  218. package/dist/identity/options.js.map +1 -1
  219. package/dist/identity/slack/links.js +3 -2
  220. package/dist/identity/slack/links.js.map +1 -1
  221. package/dist/identity/types.d.ts +3 -0
  222. package/dist/identity/types.d.ts.map +1 -1
  223. package/dist/index.d.ts +19 -5
  224. package/dist/index.d.ts.map +1 -1
  225. package/dist/index.js +3 -1
  226. package/dist/index.js.map +1 -1
  227. package/dist/ingress/loopback.d.ts +1 -0
  228. package/dist/ingress/loopback.d.ts.map +1 -1
  229. package/dist/ingress/loopback.js +7 -3
  230. package/dist/ingress/loopback.js.map +1 -1
  231. package/dist/ingress/runs.d.ts +74 -1
  232. package/dist/ingress/runs.d.ts.map +1 -1
  233. package/dist/ingress/runs.js +184 -9
  234. package/dist/ingress/runs.js.map +1 -1
  235. package/dist/managed-composition.d.ts +3 -0
  236. package/dist/managed-composition.d.ts.map +1 -1
  237. package/dist/managed-composition.js +29 -13
  238. package/dist/managed-composition.js.map +1 -1
  239. package/dist/namespaces.d.ts +25 -21
  240. package/dist/namespaces.d.ts.map +1 -1
  241. package/dist/namespaces.js +4 -29
  242. package/dist/namespaces.js.map +1 -1
  243. package/dist/runtime/async-subagents.d.ts +27 -0
  244. package/dist/runtime/async-subagents.d.ts.map +1 -0
  245. package/dist/runtime/async-subagents.js +139 -0
  246. package/dist/runtime/async-subagents.js.map +1 -0
  247. package/dist/runtime/auth.d.ts.map +1 -1
  248. package/dist/runtime/auth.js +177 -8
  249. package/dist/runtime/auth.js.map +1 -1
  250. package/dist/runtime/backend.d.ts +9 -1
  251. package/dist/runtime/backend.d.ts.map +1 -1
  252. package/dist/runtime/backend.js +36 -11
  253. package/dist/runtime/backend.js.map +1 -1
  254. package/dist/runtime/browser-facade.d.ts +10 -0
  255. package/dist/runtime/browser-facade.d.ts.map +1 -0
  256. package/dist/runtime/browser-facade.js +23 -0
  257. package/dist/runtime/browser-facade.js.map +1 -0
  258. package/dist/runtime/browser-langsmith.d.ts +143 -0
  259. package/dist/runtime/browser-langsmith.d.ts.map +1 -0
  260. package/dist/runtime/browser-langsmith.js +508 -0
  261. package/dist/runtime/browser-langsmith.js.map +1 -0
  262. package/dist/runtime/browser-recorder.py +831 -0
  263. package/dist/runtime/browser-selection.d.ts +10 -0
  264. package/dist/runtime/browser-selection.d.ts.map +1 -0
  265. package/dist/runtime/browser-selection.js +21 -0
  266. package/dist/runtime/browser-selection.js.map +1 -0
  267. package/dist/runtime/browser.d.ts +138 -0
  268. package/dist/runtime/browser.d.ts.map +1 -0
  269. package/dist/runtime/browser.js +802 -0
  270. package/dist/runtime/browser.js.map +1 -0
  271. package/dist/runtime/channel-icon.d.ts +22 -0
  272. package/dist/runtime/channel-icon.d.ts.map +1 -0
  273. package/dist/runtime/channel-icon.js +81 -0
  274. package/dist/runtime/channel-icon.js.map +1 -0
  275. package/dist/runtime/channel.d.ts +11 -0
  276. package/dist/runtime/channel.d.ts.map +1 -1
  277. package/dist/runtime/channel.js.map +1 -1
  278. package/dist/runtime/graph-build-timing.d.ts +30 -0
  279. package/dist/runtime/graph-build-timing.d.ts.map +1 -0
  280. package/dist/runtime/graph-build-timing.js +50 -0
  281. package/dist/runtime/graph-build-timing.js.map +1 -0
  282. package/dist/runtime/identity-http.d.ts +2 -0
  283. package/dist/runtime/identity-http.d.ts.map +1 -1
  284. package/dist/runtime/identity-http.js +38 -2
  285. package/dist/runtime/identity-http.js.map +1 -1
  286. package/dist/runtime/identity-runtime.d.ts +10 -0
  287. package/dist/runtime/identity-runtime.d.ts.map +1 -1
  288. package/dist/runtime/identity-runtime.js +22 -0
  289. package/dist/runtime/identity-runtime.js.map +1 -1
  290. package/dist/runtime/index.d.ts +38 -1
  291. package/dist/runtime/index.d.ts.map +1 -1
  292. package/dist/runtime/index.js +104 -50
  293. package/dist/runtime/index.js.map +1 -1
  294. package/dist/runtime/langsmith-env.d.ts +19 -0
  295. package/dist/runtime/langsmith-env.d.ts.map +1 -0
  296. package/dist/runtime/langsmith-env.js +38 -0
  297. package/dist/runtime/langsmith-env.js.map +1 -0
  298. package/dist/runtime/managed-thread.d.ts +61 -0
  299. package/dist/runtime/managed-thread.d.ts.map +1 -0
  300. package/dist/runtime/managed-thread.js +76 -0
  301. package/dist/runtime/managed-thread.js.map +1 -0
  302. package/dist/runtime/managed-tools.d.ts +10 -2
  303. package/dist/runtime/managed-tools.d.ts.map +1 -1
  304. package/dist/runtime/managed-tools.js +10 -8
  305. package/dist/runtime/managed-tools.js.map +1 -1
  306. package/dist/runtime/memory/policy.d.ts.map +1 -1
  307. package/dist/runtime/memory/policy.js +1 -3
  308. package/dist/runtime/memory/policy.js.map +1 -1
  309. package/dist/runtime/parent-run.d.ts +33 -0
  310. package/dist/runtime/parent-run.d.ts.map +1 -0
  311. package/dist/runtime/parent-run.js +114 -0
  312. package/dist/runtime/parent-run.js.map +1 -0
  313. package/dist/runtime/run-input.d.ts +14 -1
  314. package/dist/runtime/run-input.d.ts.map +1 -1
  315. package/dist/runtime/run-input.js +100 -7
  316. package/dist/runtime/run-input.js.map +1 -1
  317. package/dist/runtime/sandbox-manager.d.ts +32 -18
  318. package/dist/runtime/sandbox-manager.d.ts.map +1 -1
  319. package/dist/runtime/sandbox-manager.js +109 -65
  320. package/dist/runtime/sandbox-manager.js.map +1 -1
  321. package/dist/runtime/schedule-auth.d.ts +79 -0
  322. package/dist/runtime/schedule-auth.d.ts.map +1 -1
  323. package/dist/runtime/schedule-auth.js +165 -7
  324. package/dist/runtime/schedule-auth.js.map +1 -1
  325. package/dist/runtime/static-entry.d.ts.map +1 -1
  326. package/dist/runtime/static-entry.js +1 -0
  327. package/dist/runtime/static-entry.js.map +1 -1
  328. package/dist/runtime/thread-record.d.ts +88 -0
  329. package/dist/runtime/thread-record.d.ts.map +1 -0
  330. package/dist/runtime/thread-record.js +213 -0
  331. package/dist/runtime/thread-record.js.map +1 -0
  332. package/dist/runtime/types.d.ts +12 -1
  333. package/dist/runtime/types.d.ts.map +1 -1
  334. package/dist/runtime-schedules.d.ts +15 -0
  335. package/dist/runtime-schedules.d.ts.map +1 -1
  336. package/dist/runtime-schedules.js +83 -20
  337. package/dist/runtime-schedules.js.map +1 -1
  338. package/dist/sandbox.d.ts +0 -6
  339. package/dist/sandbox.d.ts.map +1 -1
  340. package/dist/sandbox.js +3 -7
  341. package/dist/sandbox.js.map +1 -1
  342. package/dist/schedule-channel.d.ts +5 -2
  343. package/dist/schedule-channel.d.ts.map +1 -1
  344. package/dist/schedule-channel.js +13 -12
  345. package/dist/schedule-channel.js.map +1 -1
  346. package/dist/telemetry/metadata.d.ts.map +1 -1
  347. package/dist/telemetry/metadata.js +4 -4
  348. package/dist/telemetry/metadata.js.map +1 -1
  349. package/dist/telemetry/otel/exporter.d.ts.map +1 -1
  350. package/dist/telemetry/otel/exporter.js +3 -3
  351. package/dist/telemetry/otel/exporter.js.map +1 -1
  352. package/dist/types.d.ts +19 -2
  353. package/dist/types.d.ts.map +1 -1
  354. package/dist/types.js.map +1 -1
  355. package/dist/values.d.ts +15 -0
  356. package/dist/values.d.ts.map +1 -0
  357. package/dist/values.js +31 -0
  358. package/dist/values.js.map +1 -0
  359. package/package.json +26 -12
@@ -0,0 +1,831 @@
1
+ """Recordings of a browser window's tab: its screencast, encoded as an H.264 mp4.
2
+
3
+ A program for the browser's own sandbox. ``browser.py`` uploads it there and runs it
4
+ beside Chrome, on the window's local CDP port, so the screencast never crosses the
5
+ network and a take outlives the agent's client. It runs with PyAV, Pillow and
6
+ websockets, which ``browser.py`` installs there; the agent server never imports it.
7
+
8
+ python3 _browser_recording.py start FOLDER CDP_URL TAB # returns once recording
9
+ python3 _browser_recording.py stop FOLDER # returns once FOLDER holds the take
10
+
11
+ The recorder holds its own CDP connection to the window, beside Stagehand's, and
12
+ follows the window's newest tab, where the agent's tools act. It pins the viewport, so
13
+ takes match, and notes the pointer, clicks and typing in an isolated world, which the
14
+ page's own scripts cannot see. The cursor, clicks and keys are drawn onto the frames
15
+ when encoding, so nothing is drawn in the page. Typing into password, one-time-code
16
+ and card fields is masked as it is noted, and URLs lose their query and fragment.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import base64
23
+ import bisect
24
+ import contextlib
25
+ import functools
26
+ import io
27
+ import itertools
28
+ import json
29
+ import math
30
+ import os
31
+ import signal
32
+ import subprocess
33
+ import sys
34
+ import tempfile
35
+ import time
36
+ from dataclasses import dataclass, field
37
+ from pathlib import Path
38
+ from typing import TYPE_CHECKING, Any, Literal
39
+ from urllib.parse import urlsplit, urlunsplit
40
+
41
+ import av
42
+ import websockets
43
+ from PIL import Image, ImageDraw, ImageFont, ImageOps
44
+
45
+ if TYPE_CHECKING:
46
+ from collections.abc import Iterator
47
+
48
+ VIEWPORT = (1280, 800)
49
+ FPS = 15
50
+ MAX_HOLD_S = 1.5 # a pause, such as the model thinking between calls, plays this long at most
51
+ END_HOLD_S = 1.0 # the last frame, so the result is readable
52
+ MAX_S = 300 # of real time
53
+ _MAX_EVENTS = 20_000
54
+ # What the kept frames may hold in memory, in the browser's sandbox beside Chrome: a page
55
+ # that keeps changing reaches it before MAX_S.
56
+ _MAX_FRAME_BYTES = 1_000_000_000
57
+ _TIMEOUT_S = 30
58
+ _QUALITY = 90
59
+ # Frames keep up to this many device pixels per CSS pixel: a HiDPI browser paints two,
60
+ # and text scaled down to the viewport's CSS size blurs.
61
+ _DEVICE_SCALE = 2
62
+ _URL_CHARS = 200
63
+ _MASK = "*" * 8 # typing in a sensitive field, whatever its length; the font has "*"
64
+ _SKEW_S = 5.0 # page and frame times further off than this are not on the box's clock
65
+ _WORLD = "mda-recording"
66
+ _BINDING = "__mdaRecordEvent"
67
+ # Notes input in the page's top frame, in CSS pixels. Clicks and keys name their element
68
+ # by tag, id and label, never by value; sensitive fields' typing is masked here.
69
+ _SCRIPT = f"""
70
+ if (self === top && !globalThis.{_BINDING}Installed) {{
71
+ globalThis.{_BINDING}Installed = true;
72
+ // When it happened, on the box's clock, which Chrome's frame times share.
73
+ const send = (e, event) => globalThis.{_BINDING}(JSON.stringify(
74
+ {{...event, at: performance.timeOrigin + e.timeStamp}}));
75
+ const secret = (el) => !!el && (el.type === "password" ||
76
+ /one-time-code|cc-/.test(el.getAttribute?.("autocomplete") || ""));
77
+ const describe = (el) => {{
78
+ if (!(el instanceof Element)) return "";
79
+ const t = el.closest("a,button,input,textarea,select,summary,label,[role]") || el;
80
+ const name = (t.getAttribute("aria-label") || (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)
81
+ ? "" : t.innerText) || t.getAttribute("placeholder") || t.getAttribute("title") ||
82
+ t.getAttribute("name") || "").trim().replace(/\\s+/g, " ").slice(0, 60);
83
+ return t.tagName.toLowerCase() + (t.id ? "#" + t.id : "") + (name ? ` "${{name}}"` : "");
84
+ }};
85
+ let moved = 0;
86
+ addEventListener("pointermove", (e) => {{
87
+ if (e.timeStamp - moved < 40) return;
88
+ moved = e.timeStamp;
89
+ send(e, {{type: "move", x: e.clientX, y: e.clientY}});
90
+ }}, true);
91
+ addEventListener("pointerdown", (e) => {{
92
+ send(e, {{type: "click", x: e.clientX, y: e.clientY, target: describe(e.target)}});
93
+ }}, true);
94
+ addEventListener("keydown", (e) => {{
95
+ const chord = e.ctrlKey || e.metaKey || e.altKey;
96
+ if ((e.key.length === 1 && !chord) || /^(Shift|Control|Alt|Meta)$/.test(e.key)) return;
97
+ const key = (e.metaKey ? "Cmd+" : "") + (e.ctrlKey ? "Ctrl+" : "") +
98
+ (e.altKey ? "Alt+" : "") + (e.key === " " ? "Space" : e.key);
99
+ send(e, {{type: "key", text: key, target: describe(document.activeElement)}});
100
+ }}, true);
101
+ addEventListener("input", (e) => {{
102
+ if (!e.inputType?.startsWith("insert")) return;
103
+ const text = e.data ?? e.target?.value ?? "";
104
+ const typed = secret(e.target) ? "{_MASK}" : text;
105
+ send(e, {{type: "input", text: typed, target: describe(e.target)}});
106
+ }}, true);
107
+ }}
108
+ """
109
+
110
+ EventKind = Literal["move", "click", "key", "input", "navigate"]
111
+
112
+
113
+ @dataclass
114
+ class Frame:
115
+ at: float # when Chrome painted it, in epoch seconds
116
+ jpeg: bytes
117
+ width: int # the page's CSS size, which events are measured in
118
+ height: int
119
+
120
+
121
+ @dataclass
122
+ class Event:
123
+ at: float # when it happened in the page, in epoch seconds
124
+ kind: EventKind
125
+ x: float = 0.0
126
+ y: float = 0.0
127
+ text: str = "" # a key, typed text, or a url
128
+ target: str = "" # the element, e.g. `button "Save"`
129
+
130
+
131
+ @dataclass
132
+ class Take:
133
+ """What a recording captured."""
134
+
135
+ frames: list[Frame] = field(default_factory=list)
136
+ events: list[Event] = field(default_factory=list)
137
+ capped: bool = False # hit MAX_S or a limit, so it ends early
138
+ closed: bool = False # the browser went away, so it ends early
139
+
140
+
141
+ class Recorder:
142
+ """Records the window at ``cdp_url`` from one tab on, following each newer tab."""
143
+
144
+ def __init__(self, cdp_url: str) -> None:
145
+ self.cdp_url = cdp_url # the window's local port: no credentials
146
+ self.take = Take()
147
+ self.target: str | None = None
148
+ self.done = asyncio.Event() # told to stop, or the take hit a limit
149
+ self.reader: asyncio.Task[None] | None = None
150
+ self._session: str | None = None
151
+ self._ws: websockets.ClientConnection | None = None
152
+ self._ids = itertools.count(1)
153
+ self._replies: dict[int, asyncio.Future[dict[str, Any]]] = {}
154
+ self._tabs: list[str] = [] # the window's tabs, oldest first
155
+ self._following = asyncio.Lock()
156
+ self._tasks: set[asyncio.Task[None]] = set() # held until done
157
+ self._acted = -math.inf # when the last click, key, typing or navigation was noted
158
+ self._frame_bytes = 0 # the kept frames' JPEGs
159
+
160
+ async def start(self, target_id: str) -> None:
161
+ self._ws = await websockets.connect(self.cdp_url, max_size=2**25) # in 10 s or fails
162
+ self.reader = asyncio.create_task(self._read(self._ws))
163
+ targets = (await self._send("Target.getTargets"))["targetInfos"]
164
+ self._tabs = [t["targetId"] for t in targets if t["type"] == "page"]
165
+ await self._send("Target.setDiscoverTargets", discover=True) # tabs opened later
166
+ await self.follow(target_id)
167
+
168
+ async def follow(self, target_id: str) -> None:
169
+ """Record ``target_id`` from now on, if it is not the tab being recorded."""
170
+ async with self._following:
171
+ if target_id == self.target:
172
+ return
173
+ attached = await self._send("Target.attachToTarget", targetId=target_id, flatten=True)
174
+ left, self._session, self.target = self._session, attached["sessionId"], target_id
175
+ if left:
176
+ with contextlib.suppress(Exception): # the tab may have closed
177
+ await self._send("Target.detachFromTarget", sessionId=left)
178
+ width, height = VIEWPORT
179
+ on = self._on_tab
180
+ await on(
181
+ "Emulation.setDeviceMetricsOverride",
182
+ width=width,
183
+ height=height,
184
+ deviceScaleFactor=0,
185
+ mobile=False,
186
+ )
187
+ await on("Emulation.setFocusEmulationEnabled", enabled=True) # paints unfocused tabs
188
+ await on("Page.enable") # so the script runs in each new document
189
+ await on("Runtime.enable")
190
+ await on("Runtime.addBinding", name=_BINDING, executionContextName=_WORLD)
191
+ await on("Page.addScriptToEvaluateOnNewDocument", source=_SCRIPT, worldName=_WORLD)
192
+ tree = (await on("Page.getFrameTree"))["frameTree"]["frame"]
193
+ self._navigated(time.time(), tree.get("url", ""))
194
+ world = await on("Page.createIsolatedWorld", frameId=tree["id"], worldName=_WORLD)
195
+ await on("Runtime.evaluate", expression=_SCRIPT, contextId=world["executionContextId"])
196
+ await on(
197
+ "Page.startScreencast",
198
+ format="jpeg",
199
+ quality=_QUALITY,
200
+ maxWidth=width * _DEVICE_SCALE,
201
+ maxHeight=height * _DEVICE_SCALE,
202
+ )
203
+
204
+ async def stop(self) -> Take:
205
+ """Stop recording, ending on how the tab looks now: the last action's result may
206
+ not have reached a screencast frame. Closing drops the viewport and focus overrides.
207
+ """
208
+ frames = self.take.frames
209
+ if self._session and not self.take.capped:
210
+ with contextlib.suppress(Exception): # the tab may have closed
211
+ shot = await self._on_tab("Page.captureScreenshot", format="jpeg", quality=_QUALITY)
212
+ width, height = (frames[-1].width, frames[-1].height) if frames else VIEWPORT
213
+ frames.append(Frame(time.time(), base64.b64decode(shot["data"]), width, height))
214
+ if self.reader:
215
+ self.reader.cancel()
216
+ with contextlib.suppress(asyncio.CancelledError, Exception):
217
+ await self.reader # so a window that went away is not logged as a crash
218
+ if self._ws:
219
+ with contextlib.suppress(Exception):
220
+ await self._ws.close()
221
+ return self.take
222
+
223
+ async def _on_tab(self, method: str, **params: object) -> dict[str, Any]:
224
+ return await self._send(method, session=self._session, **params)
225
+
226
+ async def _send(
227
+ self, method: str, session: str | None = None, **params: object
228
+ ) -> dict[str, Any]:
229
+ if self._ws is None:
230
+ msg = "the recording stopped"
231
+ raise RuntimeError(msg)
232
+ id_ = next(self._ids)
233
+ message: dict[str, object] = {"id": id_, "method": method, "params": params}
234
+ if session:
235
+ message["sessionId"] = session
236
+ self._replies[id_] = reply = asyncio.get_running_loop().create_future()
237
+ try:
238
+ await self._ws.send(json.dumps(message))
239
+ result = await asyncio.wait_for(reply, _TIMEOUT_S)
240
+ finally:
241
+ self._replies.pop(id_, None)
242
+ if "error" in result:
243
+ msg = f"CDP {method} failed: {result['error']}"
244
+ raise RuntimeError(msg)
245
+ return result.get("result", {})
246
+
247
+ async def _read(self, ws: websockets.ClientConnection) -> None:
248
+ async for raw in ws:
249
+ message = json.loads(raw)
250
+ if (id_ := message.get("id")) is not None:
251
+ if (reply := self._replies.get(id_)) and not reply.done():
252
+ reply.set_result(message)
253
+ continue
254
+ method, params, now = message.get("method"), message.get("params", {}), time.time()
255
+ if method == "Target.targetCreated":
256
+ self._opened(params["targetInfo"])
257
+ elif method == "Target.targetDestroyed":
258
+ self._closed(params["targetId"])
259
+ elif message.get("sessionId") != self._session:
260
+ continue # a tab we stopped following
261
+ elif method == "Page.screencastFrame":
262
+ await self._frame(ws, params, now)
263
+ elif method == "Runtime.bindingCalled" and params["name"] == _BINDING:
264
+ with contextlib.suppress(ValueError, KeyError, TypeError):
265
+ self._note(_event(now, json.loads(params["payload"])))
266
+ elif method == "Page.frameNavigated" and not params["frame"].get("parentId"):
267
+ self._navigated(now, params["frame"]["url"])
268
+
269
+ def _opened(self, target: dict[str, Any]) -> None:
270
+ """A tab opened: follow it, as the agent's tools act in the newest tab."""
271
+ if target.get("type") == "page" and target["targetId"] not in self._tabs:
272
+ self._tabs.append(target["targetId"])
273
+ self._spawn(self._follow_newest())
274
+
275
+ def _closed(self, target_id: str) -> None:
276
+ with contextlib.suppress(ValueError):
277
+ self._tabs.remove(target_id)
278
+ if target_id == self.target:
279
+ self._spawn(self._follow_newest())
280
+
281
+ async def _follow_newest(self) -> None:
282
+ if self._tabs:
283
+ with contextlib.suppress(Exception): # it keeps the tab it had
284
+ await self.follow(self._tabs[-1])
285
+
286
+ def _spawn(self, work: Any) -> None:
287
+ task = asyncio.create_task(work)
288
+ self._tasks.add(task)
289
+ task.add_done_callback(self._tasks.discard)
290
+
291
+ async def _frame(
292
+ self, ws: websockets.ClientConnection, params: dict[str, Any], now: float
293
+ ) -> None:
294
+ if self.take.capped:
295
+ return # not acked, so Chrome sends no more
296
+ meta = params["metadata"]
297
+ at = _on_clock(meta.get("timestamp"), now) # painted, which can be a while before
298
+ jpeg = base64.b64decode(params["data"])
299
+ frame = Frame(at, jpeg, meta["deviceWidth"], meta["deviceHeight"])
300
+ frames = self.take.frames
301
+ last = frames[-1] if frames else None
302
+ # The video shows a frame per tick: the newest, if nothing happened since the last.
303
+ if last and int(last.at * FPS) == int(at * FPS) and self._acted <= last.at <= at:
304
+ self._frame_bytes -= len(frames.pop().jpeg)
305
+ frames.append(frame)
306
+ self._frame_bytes += len(jpeg)
307
+ if self._frame_bytes >= _MAX_FRAME_BYTES:
308
+ self.take.capped = True
309
+ self.done.set()
310
+ return
311
+ ack = {"sessionId": params["sessionId"]}
312
+ message = {"id": next(self._ids), "method": "Page.screencastFrameAck", "params": ack}
313
+ await ws.send(json.dumps({**message, "sessionId": self._session}))
314
+
315
+ def _navigated(self, at: float, url: str) -> None:
316
+ if url and url != "about:blank": # a tab before its first page
317
+ self._note(Event(at, "navigate", text=_bare(url)))
318
+
319
+ def _note(self, event: Event) -> None:
320
+ if not self.take.capped and len(self.take.events) < _MAX_EVENTS:
321
+ self.take.events.append(event)
322
+ if event.kind != "move":
323
+ self._acted = event.at
324
+
325
+
326
+ def _event(now: float, payload: dict[str, Any]) -> Event:
327
+ at = payload.get("at")
328
+ return Event(
329
+ _on_clock(at / 1000 if isinstance(at, int | float) else None, now),
330
+ payload["type"],
331
+ float(payload.get("x", 0)),
332
+ float(payload.get("y", 0)),
333
+ str(payload.get("text", ""))[:200],
334
+ str(payload.get("target", ""))[:120],
335
+ )
336
+
337
+
338
+ def _on_clock(at: object, now: float) -> float:
339
+ """When something happened by Chrome's own word, if that is on the box's clock.
340
+
341
+ Its frames arrive a while after they are painted, and the page's events a little
342
+ after they happen, so arrival would put the frame that shows typing after the click
343
+ that follows it.
344
+ """
345
+ if isinstance(at, int | float) and abs(at - now) < _SKEW_S:
346
+ return float(at)
347
+ return now
348
+
349
+
350
+ def _bare(url: str) -> str:
351
+ """``url`` without what can carry a secret: its query, fragment and credentials."""
352
+ parts = urlsplit(url)
353
+ if parts.scheme == "data": # the page itself is in the url
354
+ return f"data:{parts.path.partition(',')[0]},..."
355
+ netloc = parts.netloc.rpartition("@")[2]
356
+ return urlunsplit((parts.scheme, netloc, parts.path, "", ""))[:_URL_CHARS]
357
+
358
+
359
+ # --- the video's timeline ------------------------------------------------------------
360
+
361
+
362
+ # How long the video gives each input to read, however fast the agent was: an
363
+ # agent often clicks, types and submits in one call, within milliseconds.
364
+ _READ_S = {"click": 0.7, "key": 0.35}
365
+ _CHAR_READ_S = 0.07
366
+ _TYPING_READ_S = (0.1, 1.2) # one burst of typed text: shortest, longest
367
+
368
+
369
+ class Clock:
370
+ """The video's clock: real time with pauses cut and bursts of input spread out.
371
+
372
+ A pause, such as the model thinking between calls, plays for at most
373
+ ``MAX_HOLD_S``. Each click, key or bit of typing gets time to read before the
374
+ next thing happens, so a login done in one call plays as steps. Typing gets it
375
+ once the frame that shows it is up, so its caption keeps pace with the page.
376
+ """
377
+
378
+ def __init__(self, take: Take) -> None:
379
+ moments: list[tuple[float, int, Frame | Event]] = sorted(
380
+ [(f.at, 0, f) for f in take.frames]
381
+ + [(e.at, 1, e) for e in take.events if e.kind != "move"],
382
+ key=lambda moment: moment[:2],
383
+ )
384
+ self.reals: list[float] = []
385
+ self.videos: list[float] = []
386
+ self._placed: dict[int, float] = {} # each frame's and event's own time
387
+ video, need, typing = 0.0, 0.0, 0.0
388
+ for at, _, moment in moments:
389
+ shows_typing = isinstance(moment, Frame) and typing
390
+ if self.reals:
391
+ video += max(min(at - self.reals[-1], MAX_HOLD_S), 0.0 if shows_typing else need)
392
+ self.reals.append(at)
393
+ self.videos.append(video)
394
+ self._placed[id(moment)] = video
395
+ if isinstance(moment, Frame):
396
+ need, typing = typing, 0.0
397
+ else:
398
+ need = _read_time(moment)
399
+ typing = need if moment.kind == "input" else 0.0
400
+ self.length = video + max(END_HOLD_S, need)
401
+ self.frame_starts = [self._placed[id(f)] for f in take.frames]
402
+
403
+ def of(self, event: Event) -> float:
404
+ """Where ``event`` lands in the video."""
405
+ if (placed := self._placed.get(id(event))) is not None:
406
+ return placed
407
+ return self.at(event.at) # a pointer move, which takes no time of its own
408
+
409
+ def at(self, real: float) -> float:
410
+ """Where a moment of real time lands in the video."""
411
+ index = bisect.bisect_right(self.reals, real) - 1
412
+ if index < 0:
413
+ return 0.0
414
+ span = self.videos[index + 1] if index + 1 < len(self.videos) else self.length
415
+ return self.videos[index] + min(real - self.reals[index], span - self.videos[index])
416
+
417
+
418
+ def _read_time(event: Event) -> float:
419
+ if event.kind == "input":
420
+ low, high = _TYPING_READ_S
421
+ return min(max(len(event.text) * _CHAR_READ_S, low), high)
422
+ return _READ_S.get(event.kind, 0.0)
423
+
424
+
425
+ _TYPING_GAP_S = 1.5 # typing further apart than this is another burst
426
+
427
+
428
+ def events(take: Take) -> list[dict[str, object]]:
429
+ """What happened, at video time ``t``: for captions, chapters or a PR's notes.
430
+
431
+ Clicks keep their CSS point in the pinned viewport; a burst of typing into one
432
+ field is one ``type`` event; pointer moves are left out.
433
+ """
434
+ clock = Clock(take)
435
+ out: list[dict[str, object]] = []
436
+ typed: dict[str, object] | None = None
437
+ last_typed: Event | None = None
438
+ last_url = None
439
+ for event in take.events:
440
+ t = round(clock.of(event), 2)
441
+ if event.kind == "input" and typed and last_typed and _one_burst(last_typed, event):
442
+ typed["text"] = _typed(str(typed["text"]), event.text)
443
+ elif event.kind == "input":
444
+ typed = {"t": t, "type": "type", "text": event.text, "target": event.target}
445
+ out.append(typed)
446
+ elif event.kind == "click":
447
+ out.append(
448
+ {
449
+ "t": t,
450
+ "type": "click",
451
+ "x": round(event.x),
452
+ "y": round(event.y),
453
+ "target": event.target,
454
+ }
455
+ )
456
+ elif event.kind == "key":
457
+ out.append({"t": t, "type": "key", "key": event.text, "target": event.target})
458
+ elif event.kind == "navigate" and event.text != last_url:
459
+ out.append({"t": t, "type": "navigate", "url": event.text})
460
+ last_url = event.text
461
+ if event.kind == "input":
462
+ last_typed = event
463
+ elif event.kind != "move":
464
+ typed = None
465
+ return out
466
+
467
+
468
+ def summary(happened: list[dict[str, object]], limit: int = 30) -> str:
469
+ """A line per event, for the model to caption or describe the video with."""
470
+
471
+ def line(event: dict[str, object]) -> str:
472
+ at = f"{event['t']:>6.1f}s"
473
+ if event["type"] == "navigate":
474
+ return f"{at} opened {event['url']}"
475
+ if event["type"] == "click":
476
+ return f"{at} clicked {event['target'] or 'the page'} at ({event['x']}, {event['y']})"
477
+ if event["type"] == "type":
478
+ return f"{at} typed {event['text']!r} into {event['target'] or 'the page'}"
479
+ return f"{at} pressed {event['key']}"
480
+
481
+ lines = [line(event) for event in happened[:limit]]
482
+ if len(happened) > limit:
483
+ lines.append(f"... and {len(happened) - limit} more in the JSON.")
484
+ return "\n".join(lines) or "(nothing)"
485
+
486
+
487
+ # --- encoding ------------------------------------------------------------------------
488
+
489
+
490
+ def encode(take: Take) -> bytes:
491
+ """An H.264 mp4 of ``take``, with its cursor, clicks and typing drawn on."""
492
+ if not take.frames:
493
+ msg = "the recording has no frames"
494
+ raise ValueError(msg)
495
+ size = _size(take)
496
+ overlay = _Overlay(take, size)
497
+ # On disk: `faststart`, which lets a page play it before it has all loaded, reopens
498
+ # the file to move its index to the front.
499
+ with tempfile.TemporaryDirectory(prefix="mda-recording-") as tmp:
500
+ out = Path(tmp, "out.mp4")
501
+ with av.open(str(out), mode="w", options={"movflags": "+faststart"}) as container:
502
+ stream = container.add_stream(
503
+ "libx264", rate=FPS, options={"crf": "20", "preset": "veryfast"}
504
+ )
505
+ stream.width, stream.height = size
506
+ stream.pix_fmt = "yuv420p"
507
+ for image in overlay.frames():
508
+ for packet in stream.encode(av.VideoFrame.from_image(image)):
509
+ container.mux(packet)
510
+ for packet in stream.encode():
511
+ container.mux(packet)
512
+ return out.read_bytes()
513
+
514
+
515
+ def _size(take: Take) -> tuple[int, int]:
516
+ """The video's size: its first frame's, with even sides, which yuv420p needs."""
517
+ first = Image.open(io.BytesIO(take.frames[0].jpeg))
518
+ return first.width // 2 * 2, first.height // 2 * 2
519
+
520
+
521
+ _GLIDE_S = (0.25, 0.6) # how long the cursor takes to reach a point: near, far
522
+ _SAME_POINT_S = 0.1 # a move and the click it leads to are one point
523
+ _CLICK_S = 0.6 # how long a click's ripple shows
524
+ _PRESS_S = 0.15 # how long the cursor shrinks as it clicks
525
+ _CAPTION_HOLD_S = 1.2 # how long typing stays up after the last key
526
+ _CAPTION_CHARS = 48
527
+ _ACCENT = (37, 99, 235)
528
+ # A classic arrow, tip at (0, 0), in CSS pixels.
529
+ _ARROW = [(0, 0), (0, 21), (5, 16), (9, 25), (13, 23), (9, 15), (16, 15)]
530
+
531
+
532
+ @dataclass
533
+ class _Point:
534
+ t: float # video time it arrives
535
+ x: float
536
+ y: float
537
+
538
+
539
+ class _Overlay:
540
+ """Draws ``take`` frame by frame: the cursor gliding between points, clicks, typing."""
541
+
542
+ def __init__(self, take: Take, size: tuple[int, int]) -> None:
543
+ self.take = take
544
+ self.size = size
545
+ self.clock = Clock(take)
546
+ frame = take.frames[0]
547
+ self.scale = size[0] / frame.width if frame.width else 1.0 # video pixels per CSS pixel
548
+ self.font = ImageFont.load_default(size=22 * self.scale)
549
+ timed = [(self.clock.of(e), e) for e in take.events]
550
+ self.clicks = [(t, e) for t, e in timed if e.kind == "click"]
551
+ self.points = _points(timed, frame.width, frame.height)
552
+ self.typing = [(t, e) for t, e in timed if e.kind in ("key", "input")]
553
+
554
+ def frames(self) -> Iterator[Image.Image]:
555
+ fitted = functools.lru_cache(maxsize=1)(lambda i: _fit(self.take.frames[i].jpeg, self.size))
556
+ for n in range(math.ceil(self.clock.length * FPS)):
557
+ now = n / FPS
558
+ image = fitted(max(bisect.bisect_right(self.clock.frame_starts, now) - 1, 0)).copy()
559
+ draw = ImageDraw.Draw(image, "RGBA")
560
+ self._draw_clicks(draw, now)
561
+ self._draw_cursor(draw, now)
562
+ self._draw_typing(draw, now)
563
+ yield image
564
+
565
+ def _draw_clicks(self, draw: ImageDraw.ImageDraw, now: float) -> None:
566
+ for shown, click in self.clicks:
567
+ if not 0 <= (age := now - shown) < _CLICK_S:
568
+ continue
569
+ s = self.scale
570
+ x, y, fade = click.x * s, click.y * s, 1 - age / _CLICK_S
571
+ radius = (8 + 26 * (1 - fade**3)) * s # eases out
572
+ box = (x - radius, y - radius, x + radius, y + radius)
573
+ draw.ellipse(box, outline=(255, 255, 255, int(230 * fade)), width=round(7 * s))
574
+ draw.ellipse(box, outline=(*_ACCENT, int(255 * fade)), width=round(3 * s))
575
+ dot = 9 * s
576
+ draw.ellipse((x - dot, y - dot, x + dot, y + dot), fill=(*_ACCENT, int(90 * fade)))
577
+
578
+ def _draw_cursor(self, draw: ImageDraw.ImageDraw, now: float) -> None:
579
+ x, y = (v * self.scale for v in _cursor_at(self.points, now))
580
+ pressed = any(0 <= now - shown < _PRESS_S for shown, _ in self.clicks)
581
+ s = self.scale
582
+ size = s * (0.85 if pressed else 1.0)
583
+ arrow = [(x + px * size, y + py * size) for px, py in _ARROW]
584
+ draw.polygon([(px + 2 * s, py + 3 * s) for px, py in arrow], fill=(0, 0, 0, 70)) # shadow
585
+ outline = (20, 20, 20, 255)
586
+ draw.polygon(arrow, fill=(255, 255, 255, 255), outline=outline, width=round(2 * s))
587
+
588
+ def _draw_typing(self, draw: ImageDraw.ImageDraw, now: float) -> None:
589
+ text = self._caption(now)
590
+ if not text:
591
+ return
592
+ s = self.scale
593
+ left, top, right, bottom = draw.textbbox((0, 0), text, font=self.font)
594
+ pad_x, pad_y = 18 * s, 12 * s
595
+ width, height = right - left + 2 * pad_x, bottom - top + 2 * pad_y
596
+ x0, y0 = (self.size[0] - width) / 2, self.size[1] - height - 36 * s
597
+ draw.rounded_rectangle(
598
+ (x0, y0, x0 + width, y0 + height), radius=height / 2, fill=(17, 17, 17, 210)
599
+ )
600
+ draw.text((x0 + pad_x - left, y0 + pad_y - top), text, font=self.font, fill="white")
601
+
602
+ def _caption(self, now: float) -> str:
603
+ """The burst of typing that ``now`` falls in, as typed so far."""
604
+ shown = [(t, e) for t, e in self.typing if t <= now]
605
+ if not shown or now - shown[-1][0] > _CAPTION_HOLD_S:
606
+ return ""
607
+ first = len(shown) - 1
608
+ while first and _one_burst(shown[first - 1][1], shown[first][1]):
609
+ first -= 1
610
+ text = ""
611
+ for _, event in shown[first:]:
612
+ text = _typed(text, event.text) if event.kind == "input" else f"{text} [{event.text}] "
613
+ text = text.strip()
614
+ return text if len(text) <= _CAPTION_CHARS else "..." + text[-_CAPTION_CHARS:]
615
+
616
+
617
+ def _one_burst(before: Event, after: Event) -> bool:
618
+ """Typing close together, into one field."""
619
+ return after.at - before.at <= _TYPING_GAP_S and after.target == before.target
620
+
621
+
622
+ def _typed(text: str, more: str) -> str:
623
+ """``more`` typed after ``text``; a masked field shows one mask, however long."""
624
+ return text if more == _MASK and text.endswith(_MASK) else text + more
625
+
626
+
627
+ def _points(timed: list[tuple[float, Event]], width: int, height: int) -> list[_Point]:
628
+ """Where the cursor is headed, and by when: the pointer's moves and clicks."""
629
+ points = [_Point(0.0, width / 2, height / 2)]
630
+ for t, event in timed:
631
+ if event.kind not in ("move", "click"):
632
+ continue
633
+ if t - points[-1].t < _SAME_POINT_S and len(points) > 1:
634
+ points[-1] = _Point(points[-1].t, event.x, event.y) # the same gesture
635
+ else:
636
+ points.append(_Point(t, event.x, event.y))
637
+ return points
638
+
639
+
640
+ def _cursor_at(points: list[_Point], now: float) -> tuple[float, float]:
641
+ """The cursor at ``now``, gliding into each point along a slight curve.
642
+
643
+ The glide ends as the point is reached, so the page's pauses make room for it
644
+ and the video does not run long.
645
+ """
646
+ index = bisect.bisect_right([p.t for p in points], now)
647
+ if index >= len(points):
648
+ return points[-1].x, points[-1].y
649
+ before, to = points[index - 1], points[index]
650
+ distance = math.hypot(to.x - before.x, to.y - before.y)
651
+ glide = min(_GLIDE_S[0] + distance / 2500, _GLIDE_S[1], to.t - before.t)
652
+ if glide <= 0 or now < to.t - glide:
653
+ return before.x, before.y
654
+ k = _ease_in_out((now - (to.t - glide)) / glide)
655
+ # Bow the path to one side, alternating, and settle with a slight wobble.
656
+ bend = (0.12 if index % 2 else -0.12) * distance
657
+ nx, ny = (-(to.y - before.y), to.x - before.x) if distance else (0.0, 0.0)
658
+ norm = distance or 1.0
659
+ arc = 4 * k * (1 - k) * bend + 2.5 * math.sin(k * math.pi * 3) * (1 - k)
660
+ x = before.x + (to.x - before.x) * k + nx / norm * arc
661
+ y = before.y + (to.y - before.y) * k + ny / norm * arc
662
+ return x, y
663
+
664
+
665
+ def _ease_in_out(k: float) -> float:
666
+ return 4 * k**3 if k < 1 / 2 else 1 - (-2 * k + 2) ** 3 / 2
667
+
668
+
669
+ def _fit(jpeg: bytes, size: tuple[int, int]) -> Image.Image:
670
+ return ImageOps.pad(Image.open(io.BytesIO(jpeg)).convert("RGB"), size, color="black")
671
+
672
+
673
+ def _still(jpeg: bytes, size: tuple[int, int]) -> bytes:
674
+ """``jpeg`` at ``size``, for the model to look at."""
675
+ if Image.open(io.BytesIO(jpeg)).size == size:
676
+ return jpeg
677
+ out = io.BytesIO()
678
+ _fit(jpeg, size).save(out, "JPEG", quality=85)
679
+ return out.getvalue()
680
+
681
+
682
+ # --- the program, in the browser's sandbox ---------------------------------------------
683
+
684
+ _RESULT = "result.json" # written last: how long the video is, or why there is none
685
+ _SAVE_TIMEOUT_S = 600 # to stop and encode a take
686
+
687
+
688
+ def main(argv: list[str]) -> int:
689
+ """``start``, ``stop`` or, in the background, ``record`` a take in a folder.
690
+
691
+ ``start`` and ``stop`` print one JSON object, with ``error`` if they failed.
692
+ """
693
+ match argv:
694
+ case ["start", folder, cdp_url, tab]:
695
+ return _start(Path(folder), cdp_url, tab)
696
+ case ["stop", folder]:
697
+ return _stop(Path(folder))
698
+ case ["record", folder, cdp_url, tab]:
699
+ asyncio.run(_record(Path(folder), cdp_url, tab))
700
+ return 0
701
+ sys.stderr.write(__doc__ or "")
702
+ return 2
703
+
704
+
705
+ def _start(folder: Path, cdp_url: str, tab: str) -> int:
706
+ """Record in the background, returning once the screencast has begun."""
707
+ folder.mkdir(parents=True, exist_ok=True) # browser.py runs this program from it
708
+ with (folder / "log").open("wb") as log:
709
+ child = subprocess.Popen( # noqa: S603 - this program, with the caller's arguments
710
+ [sys.executable, __file__, "record", str(folder), cdp_url, tab],
711
+ stdin=subprocess.DEVNULL,
712
+ stdout=log,
713
+ stderr=subprocess.STDOUT,
714
+ start_new_session=True, # outlives this command
715
+ )
716
+ (folder / "pid").write_text(str(child.pid))
717
+ deadline = time.monotonic() + _TIMEOUT_S
718
+ while not (folder / "ready").exists():
719
+ if child.poll() is not None or time.monotonic() > deadline:
720
+ child.kill()
721
+ return _reply({"error": _failure(folder, "the recorder did not start")})
722
+ time.sleep(0.05)
723
+ return _reply({"recording": True})
724
+
725
+
726
+ def _stop(folder: Path) -> int:
727
+ """Stop the take, unless it already ended, and wait until it is saved."""
728
+ result = folder / _RESULT
729
+ pid = int((folder / "pid").read_text())
730
+ if not result.exists():
731
+ with contextlib.suppress(ProcessLookupError):
732
+ os.kill(pid, signal.SIGTERM)
733
+ deadline = time.monotonic() + _SAVE_TIMEOUT_S
734
+ while not result.exists():
735
+ if not _alive(pid) or time.monotonic() > deadline:
736
+ return _reply({"error": _failure(folder, "the recorder stopped without saving")})
737
+ time.sleep(0.1)
738
+ sys.stdout.write(result.read_text() + "\n")
739
+ return 0
740
+
741
+
742
+ async def _record(folder: Path, cdp_url: str, tab: str) -> None:
743
+ """Record until told to stop or a limit, then save the take into ``folder``."""
744
+ recorder = Recorder(cdp_url)
745
+ asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, recorder.done.set)
746
+ try:
747
+ await recorder.start(tab)
748
+ except Exception as err: # noqa: BLE001 - reported to the agent
749
+ _result(folder, {"error": f"could not record the tab: {type(err).__name__}: {err}"})
750
+ return
751
+ (folder / "ready").touch()
752
+ done = asyncio.create_task(recorder.done.wait())
753
+ ends = [done, recorder.reader] if recorder.reader else [done] # or the window went away
754
+ ended, _ = await asyncio.wait(ends, timeout=MAX_S, return_when=asyncio.FIRST_COMPLETED)
755
+ if not ended:
756
+ recorder.take.capped = True # MAX_S
757
+ elif not recorder.done.is_set():
758
+ recorder.take.closed = True
759
+ done.cancel()
760
+ take = await recorder.stop()
761
+ try:
762
+ _result(folder, _save(folder, take))
763
+ except Exception as err: # noqa: BLE001 - reported to the agent
764
+ _result(folder, {"error": f"could not save the recording: {type(err).__name__}: {err}"})
765
+
766
+
767
+ def _save(folder: Path, take: Take) -> dict[str, object]:
768
+ """Encode ``take`` beside its timeline and its first and last frames."""
769
+ if not take.frames:
770
+ return {"error": "the recording has no frames: the tab never painted."}
771
+ take.frames.sort(key=lambda frame: frame.at) # they can arrive out of the order painted
772
+ (folder / "take.mp4").write_bytes(encode(take))
773
+ happened = events(take)
774
+ (folder / "take.json").write_text(json.dumps(happened, indent=1))
775
+ # At the page's CSS size: the model reads them, and they stay in the thread.
776
+ first = take.frames[0]
777
+ page = (first.width, first.height) if first.width and first.height else _size(take)
778
+ (folder / "first.jpg").write_bytes(_still(first.jpeg, page))
779
+ (folder / "last.jpg").write_bytes(_still(take.frames[-1].jpeg, page))
780
+ return {
781
+ "seconds": round(Clock(take).length, 2),
782
+ "early": _early(take),
783
+ "summary": summary(happened),
784
+ }
785
+
786
+
787
+ def _early(take: Take) -> str:
788
+ """Why ``take`` ended before it was told to stop, for the model; empty if it did not."""
789
+ if take.closed:
790
+ return "It stopped early, when the browser closed."
791
+ if take.capped:
792
+ return (
793
+ f"It stopped early, at the recorder's limit: {MAX_S // 60} minutes, or less"
794
+ " on a page that kept changing."
795
+ )
796
+ return ""
797
+
798
+
799
+ def _result(folder: Path, result: dict[str, object]) -> None:
800
+ partial = folder / f"{_RESULT}.partial"
801
+ partial.write_text(json.dumps(result))
802
+ partial.replace(folder / _RESULT) # whole, or not there
803
+
804
+
805
+ def _reply(reply: dict[str, object]) -> int:
806
+ sys.stdout.write(json.dumps(reply) + "\n")
807
+ return 1 if "error" in reply else 0
808
+
809
+
810
+ def _failure(folder: Path, otherwise: str) -> str:
811
+ """Why the recorder failed: its own word, or the last line it logged."""
812
+ with contextlib.suppress(OSError, ValueError, KeyError, TypeError):
813
+ return str(json.loads((folder / _RESULT).read_text())["error"])
814
+ with contextlib.suppress(OSError, IndexError):
815
+ last = (folder / "log").read_text(errors="replace").strip().splitlines()[-1]
816
+ return f"{otherwise}: {last[-300:]}"
817
+ return otherwise
818
+
819
+
820
+ def _alive(pid: int) -> bool:
821
+ try:
822
+ os.kill(pid, 0)
823
+ except ProcessLookupError:
824
+ return False
825
+ with contextlib.suppress(OSError, IndexError): # Linux: a zombie no one reaped
826
+ return Path(f"/proc/{pid}/stat").read_text().rpartition(") ")[2][:1] != "Z"
827
+ return True
828
+
829
+
830
+ if __name__ == "__main__":
831
+ sys.exit(main(sys.argv[1:]))