failproofai 1.0.0-beta.2 → 1.0.0-beta.4

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 (234) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +6 -6
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
  6. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  12. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  15. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
  17. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  18. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  22. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  23. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  24. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
  25. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  26. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  27. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  28. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/audit/run/route.js +2 -2
  30. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +1 -1
  37. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
  39. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  40. package/.next/standalone/.next/server/app/audit/page.js +1 -1
  41. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  43. package/.next/standalone/.next/server/app/index.html +1 -1
  44. package/.next/standalone/.next/server/app/index.rsc +15 -15
  45. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  46. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  47. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  48. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  49. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  50. package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
  51. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  52. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
  55. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
  56. package/.next/standalone/.next/server/app/policies/page.js +2 -2
  57. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  58. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  59. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
  60. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/page.js +1 -1
  62. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  63. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  66. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  67. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +1 -1
  68. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  69. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  70. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
  71. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  72. package/.next/standalone/.next/server/app/projects/page.js +1 -1
  73. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  74. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  75. package/.next/standalone/.next/server/chunks/[externals]__0h9_83k._.js +3 -0
  76. package/.next/standalone/.next/server/chunks/[externals]__1ifpkx-._.js +3 -0
  77. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-dh5jy._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0f8nu8d._.js +3 -0
  79. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0h3lfmp._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0l4vuqm._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0longh-._.js → [root-of-the-server]__0szqzl8._.js} +2 -2
  82. package/.next/standalone/.next/server/chunks/[root-of-the-server]__10oa-v9._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/[root-of-the-server]__18d-9oi._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__16le-kd._.js → [root-of-the-server]__1p7s8-d._.js} +2 -2
  88. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
  89. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -1
  90. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -1
  91. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/[externals]__1aq139v._.js +3 -0
  93. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
  94. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0nb0syg._.js → [root-of-the-server]__0cmdyn_._.js} +2 -2
  95. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
  97. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ath6v_._.js → [root-of-the-server]__0ls0gsr._.js} +3 -3
  98. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0uno39i._.js → [root-of-the-server]__0n_uvv0._.js} +2 -2
  99. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
  100. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01od0m6._.js → [root-of-the-server]__0rag5dg._.js} +2 -2
  101. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1apb7k_._.js → [root-of-the-server]__0rtdfof._.js} +2 -2
  102. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1psjybo._.js → [root-of-the-server]__0sdrz-j._.js} +2 -2
  103. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
  104. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
  105. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
  106. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1qapotl._.js +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
  108. package/.next/standalone/.next/server/chunks/ssr/_1e19atl._.js +1 -1
  109. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  110. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  112. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0oy_-36.js → node_modules_html-to-image_es_index_1q9xxca.js} +1 -1
  113. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -1
  114. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
  115. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
  116. package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
  117. package/.next/standalone/.next/server/pages/404.html +1 -1
  118. package/.next/standalone/.next/server/pages/500.html +1 -1
  119. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  120. package/.next/standalone/.next/server/server-reference-manifest.json +32 -20
  121. package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 06jakt_xhoolk.js} +1 -1
  122. package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
  123. package/.next/standalone/.next/static/chunks/0oe2-hp0_2tzs.js +6 -0
  124. package/.next/standalone/.next/static/chunks/{1hdhteui932bq.js → 0qmo2t50kpb30.js} +1 -1
  125. package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 0ue9ak_xexiqh.js} +1 -1
  126. package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
  127. package/.next/standalone/.next/static/chunks/1fw3pxobk_otl.js +1 -0
  128. package/.next/standalone/.next/static/chunks/23kev26by1e4f.js +1 -0
  129. package/.next/standalone/.next/static/chunks/2c90h1g6x8z8y.js +1 -0
  130. package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 2zb3m1tzq6h98.js} +1 -1
  131. package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
  132. package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 31cqiv_cpgj9m.js} +1 -1
  133. package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 3h5_3u46gu9jx.js} +1 -1
  134. package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
  135. package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 43a1ywqsg9bq9.js} +1 -1
  136. package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
  137. package/.next/standalone/Cargo.lock +1992 -198
  138. package/.next/standalone/Cargo.toml +1 -1
  139. package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
  140. package/.next/standalone/app/components/pause-notices.tsx +111 -0
  141. package/.next/standalone/app/policies/hooks-client.tsx +81 -6
  142. package/.next/standalone/crates/CLOUD_POLICIES.md +136 -0
  143. package/.next/standalone/crates/failproofaid/Cargo.toml +7 -0
  144. package/.next/standalone/crates/failproofaid/src/cloud_client.rs +484 -0
  145. package/.next/standalone/crates/failproofaid/src/cloud_policies.rs +921 -0
  146. package/.next/standalone/crates/failproofaid/src/main.rs +452 -3
  147. package/.next/standalone/crates/failproofaid/src/paths.rs +29 -0
  148. package/.next/standalone/crates/fpai-collect/Cargo.toml +43 -0
  149. package/.next/standalone/crates/fpai-collect/src/config.rs +412 -0
  150. package/.next/standalone/crates/fpai-collect/src/cursor.rs +520 -0
  151. package/.next/standalone/crates/fpai-collect/src/delivery.rs +472 -0
  152. package/.next/standalone/crates/fpai-collect/src/filetail.rs +484 -0
  153. package/.next/standalone/crates/fpai-collect/src/health.rs +369 -0
  154. package/.next/standalone/crates/fpai-collect/src/lib.rs +34 -0
  155. package/.next/standalone/crates/fpai-collect/src/redact.rs +516 -0
  156. package/.next/standalone/crates/fpai-collect/src/sources/claude/mod.rs +279 -0
  157. package/.next/standalone/crates/fpai-collect/src/sources/claude/transform.rs +619 -0
  158. package/.next/standalone/crates/fpai-collect/src/sources/codex/mod.rs +156 -0
  159. package/.next/standalone/crates/fpai-collect/src/sources/codex/transform.rs +715 -0
  160. package/.next/standalone/crates/fpai-collect/src/sources/copilot/mod.rs +158 -0
  161. package/.next/standalone/crates/fpai-collect/src/sources/copilot/transform.rs +512 -0
  162. package/.next/standalone/crates/fpai-collect/src/sources/goose/mod.rs +511 -0
  163. package/.next/standalone/crates/fpai-collect/src/sources/goose/transform.rs +586 -0
  164. package/.next/standalone/crates/fpai-collect/src/sources/hermes/mod.rs +318 -0
  165. package/.next/standalone/crates/fpai-collect/src/sources/hermes/transform.rs +579 -0
  166. package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +310 -0
  167. package/.next/standalone/crates/fpai-collect/src/sources/hooks/transform.rs +448 -0
  168. package/.next/standalone/crates/fpai-collect/src/sources/mod.rs +23 -0
  169. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/mod.rs +176 -0
  170. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/transform.rs +500 -0
  171. package/.next/standalone/crates/fpai-collect/src/sources/opencode/mod.rs +315 -0
  172. package/.next/standalone/crates/fpai-collect/src/sources/opencode/transform.rs +493 -0
  173. package/.next/standalone/crates/fpai-collect/src/sources/pi/mod.rs +201 -0
  174. package/.next/standalone/crates/fpai-collect/src/sources/pi/transform.rs +533 -0
  175. package/.next/standalone/crates/fpai-collect/src/spool.rs +458 -0
  176. package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +387 -0
  177. package/.next/standalone/crates/fpai-collect/src/supervisor.rs +423 -0
  178. package/.next/standalone/crates/fpai-collect/src/uploader.rs +600 -0
  179. package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +738 -0
  180. package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +393 -0
  181. package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +975 -0
  182. package/.next/standalone/crates/fpai-collect/tests/config.rs +286 -0
  183. package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1019 -0
  184. package/.next/standalone/crates/fpai-collect/tests/delivery.rs +281 -0
  185. package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1008 -0
  186. package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1129 -0
  187. package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +621 -0
  188. package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +854 -0
  189. package/.next/standalone/crates/fpai-collect/tests/opencode_source.rs +1478 -0
  190. package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1191 -0
  191. package/.next/standalone/crates/fpai-collect/tests/supervisor.rs +290 -0
  192. package/.next/standalone/crates/fpai-collect/tests/uploader.rs +506 -0
  193. package/.next/standalone/package.json +15 -9
  194. package/.next/standalone/server.js +1 -1
  195. package/bin/failproofai.mjs +129 -0
  196. package/dist/cli.mjs +2255 -779
  197. package/dist/worker.mjs +365 -52
  198. package/package.json +15 -9
  199. package/scripts/build-daemon-packages.mjs +210 -0
  200. package/scripts/daemon-platforms.mjs +42 -0
  201. package/scripts/prune-standalone.mjs +6 -0
  202. package/scripts/publish-aliases.mjs +62 -41
  203. package/src/hooks/builtin-policies.ts +139 -0
  204. package/src/hooks/cloud-connection.ts +205 -0
  205. package/src/hooks/cloud-enrollment-cli.ts +227 -0
  206. package/src/hooks/cloud-enrollment.ts +173 -0
  207. package/src/hooks/cloud-managed-policies.ts +138 -0
  208. package/src/hooks/collector-config.ts +226 -0
  209. package/src/hooks/configure-wizard.ts +221 -5
  210. package/src/hooks/custom-hooks-loader.ts +69 -25
  211. package/src/hooks/daemon-client.ts +18 -0
  212. package/src/hooks/daemon-download.ts +137 -27
  213. package/src/hooks/daemon-service.ts +23 -8
  214. package/src/hooks/handler.ts +144 -6
  215. package/src/hooks/hook-activity-store.ts +50 -0
  216. package/src/hooks/loader-utils.ts +6 -4
  217. package/src/hooks/policy-types.ts +35 -0
  218. package/src/hooks/session-pause-cli.ts +157 -0
  219. package/src/hooks/session-pause.ts +236 -0
  220. package/src/hooks/tui.ts +115 -0
  221. package/.next/standalone/.next/server/chunks/[externals]__14odj07._.js +0 -3
  222. package/.next/standalone/.next/server/chunks/[externals]__1s61mel._.js +0 -3
  223. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +0 -3
  224. package/.next/standalone/.next/server/chunks/ssr/[externals]__1_g_b3t._.js +0 -3
  225. package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
  226. package/.next/standalone/.next/static/chunks/2mazwb1qmur1p.js +0 -1
  227. package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
  228. package/.next/standalone/.next/static/chunks/31feojxhjyjpl.js +0 -1
  229. package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
  230. package/.next/standalone/.next/static/chunks/3s7myk_co0gbh.js +0 -1
  231. package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
  232. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_buildManifest.js +0 -0
  233. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_clientMiddlewareManifest.js +0 -0
  234. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_ssgManifest.js +0 -0
@@ -0,0 +1,448 @@
1
+ //! Turning a failproofai hook-activity row into AgentEye events.
2
+ //!
3
+ //! # Why this maps onto an existing schema
4
+ //!
5
+ //! AgentEye already has `hook_triggered` and `hook_completed` as first-class
6
+ //! event types, with `hook_name` and `hook_id` promoted to their own columns, a
7
+ //! `/hooks` page, and a latency endpoint that pairs the two legs on `hook_id`.
8
+ //! So this needs no server or dashboard work — it only has to emit the shape
9
+ //! that machinery already reads.
10
+ //!
11
+ //! One activity row carries a duration, so it yields BOTH legs with exact
12
+ //! latency rather than a start whose end has to be inferred.
13
+ //!
14
+ //! # `hook_id` uniqueness is load-bearing
15
+ //!
16
+ //! The server pairs legs on `hook_id` and dedups on a content hash. Two rows
17
+ //! colliding on an id would collapse into one row in the product — the
18
+ //! measured corpus has 8,613 `PreToolUse` rows in a single session, and a
19
+ //! per-session id would have merged all of them. The id therefore carries the
20
+ //! row's byte offset, which is unique within a file by construction and stable
21
+ //! across a re-read.
22
+
23
+ use serde::Deserialize;
24
+ use serde_json::{Map, Value, json};
25
+
26
+ /// One row of `~/.failproofai/cache/hook-activity/*.jsonl`.
27
+ ///
28
+ /// Field names match `HookActivityEntry` in `hook-activity-store.ts`. Every
29
+ /// field except the timestamp is optional here even where TypeScript declares
30
+ /// it required: this parses rows written by older versions, and a row that
31
+ /// fails to deserialize is a row that never reaches the dashboard.
32
+ /// `Default` is derived so test literals can use `..Default::default()`. The
33
+ /// store gains fields as the product does, and every construction site
34
+ /// enumerating all of them turns each addition into unrelated breakage.
35
+ #[derive(Debug, Clone, Default, Deserialize)]
36
+ pub struct HookRow {
37
+ /// Epoch milliseconds.
38
+ pub timestamp: i64,
39
+ #[serde(rename = "eventType")]
40
+ pub event_type: Option<String>,
41
+ /// Which agent CLI fired the hook.
42
+ pub integration: Option<String>,
43
+ #[serde(rename = "toolName")]
44
+ pub tool_name: Option<String>,
45
+ #[serde(rename = "policyName")]
46
+ pub policy_name: Option<String>,
47
+ #[serde(rename = "matchedPolicies")]
48
+ pub matched_policies: Option<Vec<String>>,
49
+ /// `allow` | `deny` | `instruct`.
50
+ pub decision: Option<String>,
51
+ pub reason: Option<String>,
52
+ #[serde(rename = "durationMs")]
53
+ pub duration_ms: Option<f64>,
54
+ #[serde(rename = "sessionId")]
55
+ pub session_id: Option<String>,
56
+ pub cwd: Option<String>,
57
+ #[serde(rename = "permissionMode")]
58
+ pub permission_mode: Option<String>,
59
+
60
+ // ---- Decision attribution -------------------------------------------
61
+ // Which policy decided, and under what deployment. Without these the
62
+ // dashboard can group decisions only by the policy's display NAME, which
63
+ // is exactly the substring-parsing this data was added to replace: every
64
+ // row arrives unattributed, so "how much is my org's policy actually
65
+ // doing" has no answer.
66
+ /// `builtin` | `custom` | `convention` | `cloud`. Absent on rows written
67
+ /// before attribution existed, which is meaningful — see the note in
68
+ /// `hook-activity-store.ts` about not guessing a bucket.
69
+ #[serde(rename = "policySource")]
70
+ pub policy_source: Option<String>,
71
+ #[serde(rename = "cloudPolicyId")]
72
+ pub cloud_policy_id: Option<String>,
73
+ #[serde(rename = "cloudRevision")]
74
+ pub cloud_revision: Option<i64>,
75
+ /// Present on EVERY row of a managed machine, not just cloud-decided ones:
76
+ /// "what was deployed here" is a different question from "what decided",
77
+ /// and only the former separates a rollout that changed no outcomes from
78
+ /// one that never reached the machine.
79
+ #[serde(rename = "cloudGeneration")]
80
+ pub cloud_generation: Option<i64>,
81
+
82
+ // ---- Suspension ------------------------------------------------------
83
+ /// Set while `failproofai config --pause` is in effect. An `allow` on such
84
+ /// a row proves nothing, so shipping it without this would assert a clean
85
+ /// window over exactly the window that was not enforced.
86
+ #[serde(rename = "pausedBy")]
87
+ pub paused_by: Option<String>,
88
+ #[serde(rename = "pauseExpiresAt")]
89
+ pub pause_expires_at: Option<i64>,
90
+
91
+ /// Verdicts from observe-mode policies: evaluated, then discarded. The
92
+ /// whole measurement a trial exists to produce.
93
+ pub observed: Option<Value>,
94
+ }
95
+
96
+ /// The attribution facts for one row.
97
+ ///
98
+ /// This is both what gets emitted AND part of the aggregation key. Those have
99
+ /// to be the same set: an aggregate that mixed two policy sources could carry
100
+ /// no honest attribution at all, so anything emitted here must be something
101
+ /// rows were grouped by.
102
+ #[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord)]
103
+ pub struct Attribution {
104
+ pub policy_source: Option<String>,
105
+ pub cloud_policy_id: Option<String>,
106
+ pub cloud_revision: Option<i64>,
107
+ pub cloud_generation: Option<i64>,
108
+ pub paused: bool,
109
+ }
110
+
111
+ impl Attribution {
112
+ pub fn of(row: &HookRow) -> Self {
113
+ Self {
114
+ policy_source: row.policy_source.clone(),
115
+ cloud_policy_id: row.cloud_policy_id.clone(),
116
+ cloud_revision: row.cloud_revision,
117
+ cloud_generation: row.cloud_generation,
118
+ paused: row.paused_by.is_some(),
119
+ }
120
+ }
121
+
122
+ /// Write the attribution onto an outgoing event.
123
+ ///
124
+ /// Names are snake_case to match every other payload key the server reads.
125
+ /// `paused` is emitted as a real boolean because the server tests it with
126
+ /// `JSONExtractBool`, which a string would fail.
127
+ fn apply(&self, m: &mut Map<String, Value>) {
128
+ if let Some(s) = &self.policy_source {
129
+ m.insert("policy_source".into(), json!(s));
130
+ }
131
+ if let Some(id) = &self.cloud_policy_id {
132
+ m.insert("cloud_policy_id".into(), json!(id));
133
+ }
134
+ if let Some(r) = self.cloud_revision {
135
+ m.insert("cloud_revision".into(), json!(r));
136
+ }
137
+ if let Some(g) = self.cloud_generation {
138
+ m.insert("cloud_generation".into(), json!(g));
139
+ }
140
+ // Always emitted, never conditionally: an absent key and `false` must
141
+ // not be distinguishable to a reader counting unenforced calls.
142
+ m.insert("paused".into(), json!(self.paused));
143
+ }
144
+ }
145
+
146
+ impl HookRow {
147
+ pub fn decision_str(&self) -> &str {
148
+ self.decision.as_deref().unwrap_or("allow")
149
+ }
150
+
151
+ /// True when observe-mode policies recorded a would-be verdict.
152
+ ///
153
+ /// Such a row is an `allow` by construction — the verdict was discarded —
154
+ /// so it would otherwise be swept into an allow aggregate and the trial's
155
+ /// only measurement would be erased by the roll-up.
156
+ pub fn has_observation(&self) -> bool {
157
+ match &self.observed {
158
+ Some(Value::Array(a)) => !a.is_empty(),
159
+ Some(Value::Null) | None => false,
160
+ Some(_) => true,
161
+ }
162
+ }
163
+
164
+ /// True for the 99.1% of rows that are plain no-ops.
165
+ pub fn is_allow(&self) -> bool {
166
+ self.decision_str() == "allow"
167
+ }
168
+ }
169
+
170
+ /// Format epoch milliseconds as the RFC3339-with-microseconds string ingest
171
+ /// requires, offsetting by `index` microseconds.
172
+ ///
173
+ /// The offset exists because the server orders events by `(ts, random id)`.
174
+ /// Both legs of a pair derived from one row share a millisecond, so without it
175
+ /// `hook_completed` can sort before its own `hook_triggered` — visibly wrong
176
+ /// on a timeline. Index 0 keeps the row's exact time, so the start leg is
177
+ /// never moved.
178
+ pub fn to_rfc3339_micros(epoch_ms: i64, index: u32) -> Option<String> {
179
+ let nanos = (epoch_ms as i128) * 1_000_000 + (index as i128) * 1_000;
180
+ let dt = time::OffsetDateTime::from_unix_timestamp_nanos(nanos).ok()?;
181
+ // Microseconds are forced rather than left to RFC3339's variable
182
+ // precision, so every event has one shape and the server parses one
183
+ // format. It also matches what the session sources emit, so the two
184
+ // streams sort against each other correctly on a shared timeline.
185
+ const FMT: &[time::format_description::BorrowedFormatItem<'_>] = time::macros::format_description!(
186
+ "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:6]Z"
187
+ );
188
+ dt.format(FMT).ok()
189
+ }
190
+
191
+ /// Sanitize one component of a derived agent id.
192
+ fn sanitize_id_part(s: &str) -> String {
193
+ let cleaned: String = s
194
+ .chars()
195
+ .map(|c| {
196
+ if c.is_ascii_alphanumeric() || c == '.' || c == '_' {
197
+ c
198
+ } else {
199
+ '-'
200
+ }
201
+ })
202
+ .collect();
203
+ let collapsed = cleaned
204
+ .split('-')
205
+ .filter(|p| !p.is_empty())
206
+ .collect::<Vec<_>>()
207
+ .join("-");
208
+ collapsed.chars().take(48).collect()
209
+ }
210
+
211
+ /// The agent id an event is filed under.
212
+ ///
213
+ /// `<integration>-<project>` where a working directory is known, matching the
214
+ /// scheme the session sources use, so a hook event and the transcript events
215
+ /// for the same run land under one agent rather than two that look unrelated.
216
+ /// Falls back to the bare integration name when there is no cwd — a gateway
217
+ /// session, say.
218
+ pub fn agent_id(row: &HookRow) -> String {
219
+ let base = row.integration.as_deref().unwrap_or("failproofai");
220
+ match row.cwd.as_deref().and_then(project_name) {
221
+ Some(project) => format!("{}-{}", sanitize_id_part(base), project),
222
+ None => sanitize_id_part(base),
223
+ }
224
+ }
225
+
226
+ /// Last path component of a working directory.
227
+ fn project_name(cwd: &str) -> Option<String> {
228
+ let trimmed = cwd.trim_end_matches('/');
229
+ let last = trimmed.rsplit('/').find(|p| !p.is_empty())?;
230
+ let cleaned = sanitize_id_part(last);
231
+ (!cleaned.is_empty()).then_some(cleaned)
232
+ }
233
+
234
+ /// Common envelope every emitted event carries.
235
+ fn base(
236
+ row: &HookRow,
237
+ session_id: &str,
238
+ environment: &str,
239
+ kind: &str,
240
+ index: u32,
241
+ ) -> Option<Map<String, Value>> {
242
+ let mut m = Map::new();
243
+ m.insert(
244
+ "timestamp".into(),
245
+ json!(to_rfc3339_micros(row.timestamp, index)?),
246
+ );
247
+ m.insert("session_id".into(), json!(session_id));
248
+ m.insert("agent_id".into(), json!(agent_id(row)));
249
+ m.insert("type".into(), json!(kind));
250
+ m.insert("environment".into(), json!(environment));
251
+ Some(m)
252
+ }
253
+
254
+ /// Build the `hook_triggered` / `hook_completed` pair for one row.
255
+ ///
256
+ /// `offset` is the row's byte position in its file, which is what makes
257
+ /// `hook_id` unique and stable across a re-read.
258
+ ///
259
+ /// Returns nothing when the row has no session id: ingest requires one, so
260
+ /// emitting would produce a line the server silently skips.
261
+ pub fn to_events(row: &HookRow, offset: u64, environment: &str) -> Vec<Value> {
262
+ let Some(session_id) = row.session_id.as_deref().filter(|s| !s.is_empty()) else {
263
+ return Vec::new();
264
+ };
265
+ let event_name = row.event_type.as_deref().unwrap_or("Hook");
266
+ let hook_id = format!("{session_id}:{}:{offset}", row.timestamp);
267
+
268
+ let Some(mut start) = base(row, session_id, environment, "hook_triggered", 0) else {
269
+ return Vec::new();
270
+ };
271
+ start.insert("hook_name".into(), json!(event_name));
272
+ start.insert("hook_id".into(), json!(hook_id));
273
+ // `trigger_event` is emitted on the START leg only. The server filters on
274
+ // it via a semijoin over hook_id, so duplicating it on the end leg would
275
+ // double-count every hook in that view.
276
+ start.insert("trigger_event".into(), json!(event_name));
277
+ if let Some(d) = row.duration_ms {
278
+ start.insert("duration_ms".into(), json!(d));
279
+ }
280
+ let mut input = Map::new();
281
+ if let Some(t) = &row.tool_name {
282
+ input.insert("tool_name".into(), json!(t));
283
+ }
284
+ if let Some(c) = &row.cwd {
285
+ input.insert("cwd".into(), json!(c));
286
+ }
287
+ if let Some(p) = &row.permission_mode {
288
+ input.insert("permission_mode".into(), json!(p));
289
+ }
290
+ if !input.is_empty() {
291
+ start.insert("input".into(), Value::Object(input));
292
+ }
293
+
294
+ let Some(mut end) = base(row, session_id, environment, "hook_completed", 1) else {
295
+ return Vec::new();
296
+ };
297
+ end.insert("hook_name".into(), json!(event_name));
298
+ end.insert("hook_id".into(), json!(hook_id));
299
+ end.insert("outcome".into(), json!(row.decision_str()));
300
+ if let Some(d) = row.duration_ms {
301
+ end.insert("duration_ms".into(), json!(d));
302
+ }
303
+ if let Some(p) = &row.policy_name {
304
+ end.insert("failproofai_policy".into(), json!(p));
305
+ }
306
+ if let Some(m) = &row.matched_policies {
307
+ end.insert("failproofai_matched".into(), json!(m));
308
+ }
309
+ if let Some(t) = &row.tool_name {
310
+ end.insert("tool_name".into(), json!(t));
311
+ }
312
+ // Attribution rides the END leg only: it is a property of the decision,
313
+ // and the start leg is emitted before one exists.
314
+ Attribution::of(row).apply(&mut end);
315
+ if let Some(by) = &row.paused_by {
316
+ end.insert("paused_by".into(), json!(by));
317
+ }
318
+ if let Some(exp) = row.pause_expires_at {
319
+ end.insert("pause_expires_at".into(), json!(exp));
320
+ }
321
+ if row.has_observation() {
322
+ // Carried whole rather than flattened: a row can observe several
323
+ // policies at once, and the id/revision/decision only mean anything
324
+ // together.
325
+ end.insert(
326
+ "failproofai_observed".into(),
327
+ row.observed.clone().unwrap_or(Value::Null),
328
+ );
329
+ }
330
+ if !row.is_allow() {
331
+ // The server's `is_error` is a truthiness check, so this must never be
332
+ // an empty string — a deny with no reason would otherwise render as a
333
+ // success.
334
+ let reason = row
335
+ .reason
336
+ .as_deref()
337
+ .filter(|r| !r.trim().is_empty())
338
+ .unwrap_or("blocked by failproofai");
339
+ end.insert("error".into(), json!(reason));
340
+ end.insert(
341
+ "error_type".into(),
342
+ json!(format!("failproofai_{}", row.decision_str())),
343
+ );
344
+ }
345
+
346
+ vec![Value::Object(start), Value::Object(end)]
347
+ }
348
+
349
+ /// One rolled-up `allow` bucket.
350
+ ///
351
+ /// Under the default verbosity, `allow` rows are aggregated rather than
352
+ /// dropped. Keeping the count is what preserves the denominator: "we evaluated
353
+ /// 19,000 calls and blocked 15" stays answerable, which it would not be if the
354
+ /// no-ops were simply discarded.
355
+ #[derive(Debug, Clone)]
356
+ pub struct AllowBucket {
357
+ pub session_id: String,
358
+ pub agent_id: String,
359
+ pub event_name: String,
360
+ pub tool_name: Option<String>,
361
+ /// Truncated to the minute; also the bucket's emitted timestamp.
362
+ pub minute_ms: i64,
363
+ pub count: u64,
364
+ pub total_duration_ms: f64,
365
+ pub max_duration_ms: f64,
366
+ /// Shared by every row in the bucket — see `BucketKey`.
367
+ pub attribution: Attribution,
368
+ }
369
+
370
+ /// The key rows are grouped under: same session, event, tool, minute — and the
371
+ /// same attribution.
372
+ ///
373
+ /// Attribution is part of the key rather than a field sampled from the first
374
+ /// row because a bucket is emitted as ONE event carrying ONE set of facts. Group
375
+ /// a cloud-decided allow with an unattributed one and whichever attribution is
376
+ /// emitted is wrong for the rest, which is worse than no attribution: it moves
377
+ /// a count into a bucket someone is using to judge a rollout. Splitting instead
378
+ /// costs extra events only when a minute genuinely mixed sources.
379
+ pub type BucketKey = (String, String, Option<String>, i64, Attribution);
380
+
381
+ pub fn bucket_key(row: &HookRow) -> Option<BucketKey> {
382
+ let session = row.session_id.clone().filter(|s| !s.is_empty())?;
383
+ let event = row.event_type.clone().unwrap_or_else(|| "Hook".into());
384
+ let minute = row.timestamp - row.timestamp.rem_euclid(60_000);
385
+ Some((
386
+ session,
387
+ event,
388
+ row.tool_name.clone(),
389
+ minute,
390
+ Attribution::of(row),
391
+ ))
392
+ }
393
+
394
+ impl AllowBucket {
395
+ pub fn add(&mut self, row: &HookRow) {
396
+ self.count += 1;
397
+ let d = row.duration_ms.unwrap_or(0.0);
398
+ self.total_duration_ms += d;
399
+ if d > self.max_duration_ms {
400
+ self.max_duration_ms = d;
401
+ }
402
+ }
403
+
404
+ /// A single `hook_completed` standing for every allow in the bucket.
405
+ ///
406
+ /// One leg, not a pair: an aggregate has no single invocation to pair
407
+ /// with, and inventing a `hook_triggered` would make the latency endpoint
408
+ /// report a duration that belongs to no real hook call.
409
+ pub fn to_event(&self, environment: &str) -> Option<Value> {
410
+ let mut m = Map::new();
411
+ m.insert(
412
+ "timestamp".into(),
413
+ json!(to_rfc3339_micros(self.minute_ms, 0)?),
414
+ );
415
+ m.insert("session_id".into(), json!(self.session_id));
416
+ m.insert("agent_id".into(), json!(self.agent_id));
417
+ m.insert("type".into(), json!("hook_completed"));
418
+ m.insert("environment".into(), json!(environment));
419
+ m.insert("hook_name".into(), json!(self.event_name));
420
+ m.insert(
421
+ "hook_id".into(),
422
+ json!(format!(
423
+ "{}:{}:{}:{}:agg",
424
+ self.session_id,
425
+ self.minute_ms,
426
+ self.event_name,
427
+ self.tool_name.as_deref().unwrap_or("-")
428
+ )),
429
+ );
430
+ m.insert("outcome".into(), json!("allow"));
431
+ m.insert("failproofai_allow_count".into(), json!(self.count));
432
+ m.insert(
433
+ "duration_ms".into(),
434
+ json!((self.total_duration_ms / self.count as f64 * 1000.0).round() / 1000.0),
435
+ );
436
+ m.insert(
437
+ "failproofai_max_duration_ms".into(),
438
+ json!(self.max_duration_ms),
439
+ );
440
+ if let Some(t) = &self.tool_name {
441
+ m.insert("tool_name".into(), json!(t));
442
+ }
443
+ // Honest by construction: every row in this bucket was grouped BY this
444
+ // attribution, so it describes all of them.
445
+ self.attribution.apply(&mut m);
446
+ Some(Value::Object(m))
447
+ }
448
+ }
@@ -0,0 +1,23 @@
1
+ //! Per-source capture. Each module owns one agent's on-disk format and turns
2
+ //! it into AgentEye events dropped into the shared spool; everything
3
+ //! downstream — spool, cursor persistence, redaction, delivery — is common.
4
+ //!
5
+ //! Sources fall into three shapes:
6
+ //!
7
+ //! * **File tailers** ([`crate::filetail`]) — claude, codex, copilot, openclaw,
8
+ //! pi. Each supplies a `Format` table of pure functions.
9
+ //! * **SQLite pollers** ([`crate::sqlitepoll`]) — goose, opencode, hermes. Each
10
+ //! supplies a `SqliteFormat` and declares how its database orders changes.
11
+ //! * **The hook stream** ([`hooks`]) — CLI-agnostic, and the one capability
12
+ //! that comes from failproofai sitting in the hook path rather than reading
13
+ //! somebody else's files.
14
+
15
+ pub mod claude;
16
+ pub mod codex;
17
+ pub mod copilot;
18
+ pub mod goose;
19
+ pub mod hermes;
20
+ pub mod hooks;
21
+ pub mod openclaw;
22
+ pub mod opencode;
23
+ pub mod pi;
@@ -0,0 +1,176 @@
1
+ //! OpenClaw session capture — a [`filetail`](crate::filetail) adapter.
2
+ //!
3
+ //! OpenClaw writes live-appended JSONL transcripts at
4
+ //! `<state>/agents/<agentId>/sessions/<sessionId>.jsonl`, where `<state>` is
5
+ //! `$OPENCLAW_STATE_DIR`, else `$OPENCLAW_HOME`, else `~/.openclaw`. We open
6
+ //! them read-only; OpenClaw's own files are never written, moved or deleted.
7
+ //!
8
+ //! # The sibling that must never be discovered
9
+ //!
10
+ //! `<sessionId>.trajectory.jsonl` sits *directly beside* the transcript, shares
11
+ //! its stem, and has the same `.jsonl` extension — so the obvious "tail every
12
+ //! `.jsonl` under the sessions directory" predicate picks it up. It is an
13
+ //! OpenTelemetry trace of the same session, and it is enormous: measured on the
14
+ //! probe capture, the transcript is 5,640 bytes and the trajectory beside it is
15
+ //! 334,468 — 59x for zero additional information, since every turn it describes
16
+ //! is already in the transcript. Shipping it turns ~6 KB per session into
17
+ //! ~340 KB, i.e. it decides whether this source is affordable at all. That is
18
+ //! why [`is_transcript`] rejects it by name *before* any other check, even
19
+ //! though the bare-UUID stem rule would already exclude it — one rule silently
20
+ //! loosened must not be able to cost 59x.
21
+ //!
22
+ //! Three more neighbours are excluded for their own reasons:
23
+ //!
24
+ //! * `<sessionId>.trajectory-path.json` — a pointer at the trace above.
25
+ //! Excluded by requiring `.jsonl`.
26
+ //! * `sessions.json` — the per-agent session index, **rewritten in place** on
27
+ //! every interaction. A byte cursor over a rewritten file re-ships its whole
28
+ //! contents forever. Excluded by requiring `.jsonl` and a UUID stem.
29
+ //! * `sessions/skills-prompts/sha256/**` — content-addressed prompt blobs,
30
+ //! nested below the sessions directory. Excluded by requiring the transcript
31
+ //! to sit *directly* in `sessions/`.
32
+ //!
33
+ //! `<state>/state/openclaw.sqlite` (964 KB on the probe capture) and the
34
+ //! agent's `workspace/` git checkout are outside the root entirely — see
35
+ //! [`default_roots`], which points at `agents/` rather than the state directory
36
+ //! so neither is even walked.
37
+ //!
38
+ //! # Grouping is by agent, not by working directory
39
+ //!
40
+ //! Every other source derives its agent id from the session's `cwd`. OpenClaw's
41
+ //! `cwd` is the agent *workspace* (`/root/.openclaw/workspace` on the capture —
42
+ //! identical in the `session` header and in every tool result's
43
+ //! `details.cwd`), which is a fixed per-agent scratch directory and has nothing
44
+ //! to do with what the session was about: the probe session read and counted
45
+ //! files in `/work`, and `cwd` never mentions it. Grouping on it would file
46
+ //! every session of every agent under one meaningless project. The `agentId`
47
+ //! path component is the real axis — see [`agent_id_from_path`].
48
+ //!
49
+ //! # Scope
50
+ //!
51
+ //! Covers the records that carry the session: its `session` header, the model
52
+ //! it runs on, user prompts, assistant text, tool calls and tool results.
53
+ //! `thinking_level_change` and `custom` records (bootstrap-context markers,
54
+ //! model snapshots) advance the session clock but emit nothing — they describe
55
+ //! the harness, not the conversation.
56
+
57
+ pub mod transform;
58
+
59
+ use std::path::{Path, PathBuf};
60
+
61
+ use crate::filetail::{Format, RereadPolicy, no_seed_state};
62
+
63
+ /// Main transcripts: `<uuid>.jsonl` sitting directly inside a `sessions/`
64
+ /// directory.
65
+ pub const FORMAT: Format = Format {
66
+ kind: "openclaw",
67
+ is_source_file: is_transcript,
68
+ session_id_from_path,
69
+ agent_id_from_path,
70
+ agent_start: transform::agent_start,
71
+ // No seeding. OpenClaw announces its model in a `model_change` RECORD
72
+ // written before the first user prompt (line 2 of the capture, against the
73
+ // first prompt on line 5), so the model reaches `TailState` through the
74
+ // ordinary line stream. That sidesteps the live-tail-versus-re-read
75
+ // divergence documented on `Format::seed_state` entirely: a session
76
+ // discovered while nearly empty and a full re-read of the finished file
77
+ // both learn the model at the same byte offset.
78
+ seed_state: no_seed_state,
79
+ agent_end: transform::agent_end,
80
+ transform_line: transform::transform_line,
81
+ reread: RereadPolicy::ByteCursor,
82
+ };
83
+
84
+ /// The agent id used when nothing can be derived from the path.
85
+ pub const DEFAULT_AGENT_ID: &str = "openclaw";
86
+
87
+ /// The directory a transcript must sit directly inside.
88
+ const SESSIONS_DIR: &str = "sessions";
89
+ /// The directory holding one subdirectory per agent.
90
+ const AGENTS_DIR: &str = "agents";
91
+ /// The OpenTelemetry trace written beside every transcript. 59x its size.
92
+ const TRAJECTORY_SUFFIX: &str = ".trajectory.jsonl";
93
+
94
+ fn is_uuid36(s: &str) -> bool {
95
+ s.len() == 36 && s.chars().all(|c| c.is_ascii_hexdigit() || c == '-')
96
+ }
97
+
98
+ /// Whether `path`'s immediate parent is the `sessions/` directory.
99
+ ///
100
+ /// "Immediate" is load-bearing: the walk is recursive, and
101
+ /// `sessions/skills-prompts/sha256/<ab>/<hash>.txt` is a real subtree of
102
+ /// content-addressed prompt blobs. Requiring the parent — rather than any
103
+ /// ancestor — keeps a future blob that happens to be named `.jsonl` from being
104
+ /// tailed as if it were a conversation.
105
+ fn in_sessions_dir(path: &Path) -> bool {
106
+ path.parent()
107
+ .and_then(|p| p.file_name())
108
+ .is_some_and(|n| n == SESSIONS_DIR)
109
+ }
110
+
111
+ /// A transcript is `<uuid>.jsonl` directly inside `sessions/`.
112
+ ///
113
+ /// The directory walk is recursive, so this predicate carries the whole filter.
114
+ /// See the module docs for what each clause keeps out; the trajectory check is
115
+ /// first and by name because it is the one whose failure is measured in
116
+ /// hundreds of kilobytes per session rather than in wrong rows.
117
+ fn is_transcript(path: &Path) -> bool {
118
+ let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
119
+ return false;
120
+ };
121
+ if name.ends_with(TRAJECTORY_SUFFIX) {
122
+ return false;
123
+ }
124
+ let Some(stem) = name.strip_suffix(".jsonl") else {
125
+ return false;
126
+ };
127
+ is_uuid36(stem) && in_sessions_dir(path)
128
+ }
129
+
130
+ /// The filename stem IS the session id.
131
+ ///
132
+ /// Deliberately the only source of it, even though the `session` header record
133
+ /// repeats it: two answers that can disagree is worse than one, and the path is
134
+ /// what the cursor keys on.
135
+ fn session_id_from_path(path: &Path) -> Option<String> {
136
+ let stem = path.file_name()?.to_str()?.strip_suffix(".jsonl")?;
137
+ is_uuid36(stem).then(|| stem.to_string())
138
+ }
139
+
140
+ /// `openclaw-<agentId>`, from the `agents/<agentId>/sessions/` path shape.
141
+ ///
142
+ /// NOT from the transcript's `cwd`, which is the agent's fixed workspace and
143
+ /// identical across every session it ever runs — see the module docs. The
144
+ /// header is accepted and ignored so the signature matches
145
+ /// [`crate::filetail::Format`].
146
+ ///
147
+ /// The full `agents/<id>/sessions/` shape is required rather than searched for,
148
+ /// because a bare "find the component after `agents`" scan would pick the wrong
149
+ /// one for a state directory living under, say, `/home/agents/`. Returning
150
+ /// `None` when the shape does not hold leaves the engine to fall back to the
151
+ /// configured default id, which files the session under a plainly-generic agent
152
+ /// instead of a confidently wrong one.
153
+ fn agent_id_from_path(path: &Path, _header: &[String]) -> Option<String> {
154
+ let sessions = path.parent()?;
155
+ let agent_dir = sessions.parent()?;
156
+ if agent_dir.parent()?.file_name()? != AGENTS_DIR {
157
+ return None;
158
+ }
159
+ let id = transform::sanitize_id_part(agent_dir.file_name()?.to_str()?);
160
+ (!id.is_empty()).then(|| format!("openclaw-{id}"))
161
+ }
162
+
163
+ /// Where transcripts live, honoring OpenClaw's own state-directory overrides.
164
+ ///
165
+ /// Points at `agents/` rather than the state directory itself. The state
166
+ /// directory also holds `state/openclaw.sqlite` (964 KB on the capture) and the
167
+ /// agent's `workspace/`, which is a git checkout — re-walking a `.git` tree on
168
+ /// every poll is work that can never produce an event.
169
+ pub fn default_roots() -> Vec<PathBuf> {
170
+ let state = std::env::var_os("OPENCLAW_STATE_DIR")
171
+ .or_else(|| std::env::var_os("OPENCLAW_HOME"))
172
+ .map(PathBuf::from)
173
+ .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".openclaw")))
174
+ .unwrap_or_else(|| PathBuf::from(".openclaw"));
175
+ vec![state.join(AGENTS_DIR)]
176
+ }