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,533 @@
1
+ //! Pi transcript line → AgentEye events.
2
+ //!
3
+ //! Every function here is pure in `(line, ctx, offset, state)`, which is what
4
+ //! makes a live tail and a full re-read byte-identical and therefore
5
+ //! dedup-collapsible. Nothing may consult the clock, a counter, or anything
6
+ //! outside its arguments.
7
+ //!
8
+ //! Record shapes verified against real transcripts on disk, captured from
9
+ //! pi-coding-agent **0.73.1 and 0.83.0** — the grammar is identical across the
10
+ //! two, which is why one `Format` covers both:
11
+ //!
12
+ //! ```text
13
+ //! session {type:"session", version:3, id:<uuid-v7>, timestamp, cwd}
14
+ //! model_change {type:"model_change", id, parentId, timestamp, provider, modelId}
15
+ //! message {type:"message", id, parentId, timestamp, message:{…}}
16
+ //! role:"user" content:[{type:"text", text}], timestamp:<epoch-ms>
17
+ //! role:"assistant" content:[{type:"text"|"toolCall", …}], model, provider,
18
+ //! usage:{input,output,cacheRead,cacheWrite,totalTokens},
19
+ //! stopReason:"stop"|"toolUse"|"error", responseId?,
20
+ //! errorMessage?
21
+ //! role:"toolResult" toolCallId, toolName, content:[{type:"text", text}],
22
+ //! isError, timestamp:<epoch-ms>
23
+ //! ```
24
+ //!
25
+ //! Two things about that grammar are easy to get wrong and are load-bearing
26
+ //! here:
27
+ //!
28
+ //! * **`toolResult` is a THIRD role, on its own record.** It is not folded into
29
+ //! the next user turn the way Claude Code folds `tool_result` blocks, so the
30
+ //! pairing is record-to-record and must be done by `toolCallId` — never by
31
+ //! arrival order. Real transcripts issue two calls in ONE assistant record
32
+ //! and then write two separate result records.
33
+ //! * **Assistant content is not homogeneous.** 0.73.1 emitted `["toolCall",
34
+ //! "toolCall"]` where 0.83.0 emitted `["text","toolCall","toolCall"]` for the
35
+ //! same prompt. Anything that peeks at `content[0]` to decide what a turn is
36
+ //! silently drops the prose on one version and the calls on the other.
37
+
38
+ use serde_json::{Map, Value, json};
39
+
40
+ use crate::cursor::TailState;
41
+ use crate::filetail::Ctx;
42
+
43
+ /// Longest derived id component.
44
+ const MAX_ID_PART: usize = 48;
45
+ /// Longest session goal kept from the opening prompt.
46
+ const MAX_GOAL_CHARS: usize = 1024;
47
+
48
+ /// Make a derived id component safe and bounded.
49
+ pub fn sanitize_id_part(s: &str) -> String {
50
+ let cleaned: String = s
51
+ .chars()
52
+ .map(|c| {
53
+ if c.is_ascii_alphanumeric() || c == '.' || c == '_' {
54
+ c
55
+ } else {
56
+ '-'
57
+ }
58
+ })
59
+ .collect();
60
+ cleaned
61
+ .split('-')
62
+ .filter(|p| !p.is_empty())
63
+ .collect::<Vec<_>>()
64
+ .join("-")
65
+ .chars()
66
+ .take(MAX_ID_PART)
67
+ .collect()
68
+ }
69
+
70
+ /// Normalize a transcript timestamp to the microsecond form ingest expects,
71
+ /// offset by `index` microseconds.
72
+ ///
73
+ /// The offset is what keeps several events derived from ONE line in order: the
74
+ /// server sorts by `(ts, random id)`, and a single pi assistant record routinely
75
+ /// yields three events (prose plus two tool calls) that all share one
76
+ /// millisecond — so without it a turn comes back shuffled, with the calls
77
+ /// appearing to precede the sentence that introduced them. Index 0 is
78
+ /// untouched, so the primary event of a line keeps the line's exact time.
79
+ ///
80
+ /// Saturates within the second rather than carrying, so an offset can never
81
+ /// reorder an event past a genuinely later line.
82
+ pub fn with_index(ts: &str, index: usize) -> Option<String> {
83
+ let body = ts.strip_suffix('Z')?;
84
+ let (main, frac) = match body.rsplit_once('.') {
85
+ Some((m, f)) => (m, f),
86
+ None => (body, ""),
87
+ };
88
+ // pi writes `new Date().toISOString()`, i.e. milliseconds; pad to
89
+ // microseconds so every event has one shape and sorts against the hook
90
+ // source's events correctly.
91
+ let mut micros: u32 = if frac.is_empty() {
92
+ 0
93
+ } else {
94
+ let padded = format!("{frac:0<6}");
95
+ padded.get(..6)?.parse().ok()?
96
+ };
97
+ micros = (micros + index.min(999) as u32).min(999_999);
98
+ Some(format!("{main}.{micros:06}Z"))
99
+ }
100
+
101
+ fn ts_of(v: &Value) -> Option<&str> {
102
+ v.get("timestamp")?.as_str()
103
+ }
104
+
105
+ /// The envelope every emitted event carries.
106
+ ///
107
+ /// `pi_line_offset` is the dedup discriminator: two identical events from
108
+ /// different lines must hash differently, or the server collapses them. pi makes
109
+ /// this easy to need — a retried prompt is literally the same JSON body — so the
110
+ /// byte offset is the only thing distinguishing them.
111
+ fn base(ctx: &Ctx, kind: &str, ts: &str, index: usize, offset: u64) -> Option<Map<String, Value>> {
112
+ let mut m = Map::new();
113
+ m.insert("timestamp".into(), json!(with_index(ts, index)?));
114
+ m.insert("session_id".into(), json!(ctx.session_id));
115
+ m.insert("agent_id".into(), json!(ctx.agent_id));
116
+ m.insert("type".into(), json!(kind));
117
+ m.insert("environment".into(), json!(ctx.environment));
118
+ m.insert("pi_line_offset".into(), json!(offset));
119
+ if index > 0 {
120
+ m.insert("pi_block_index".into(), json!(index));
121
+ }
122
+ Some(m)
123
+ }
124
+
125
+ /// Build the session's `agent_start` from its header lines.
126
+ ///
127
+ /// pi's line 1 is always the `session` record and always carries both the
128
+ /// timestamp and the real `cwd`, but this still scans rather than trusting line
129
+ /// 1: a session with no start event is not merely incomplete, it is absent from
130
+ /// the product entirely, so the one place that must never be brittle is this.
131
+ pub fn agent_start(header: &[String], ctx: &Ctx, offset: u64) -> Option<(Value, Option<String>)> {
132
+ let mut first_ts: Option<String> = None;
133
+ let mut cwd: Option<String> = None;
134
+ let mut version: Option<u64> = None;
135
+ let mut parent_session: Option<String> = None;
136
+ let mut model: Option<String> = None;
137
+ let mut provider: Option<String> = None;
138
+ let mut goal: Option<String> = None;
139
+
140
+ for line in header {
141
+ let Ok(v) = serde_json::from_str::<Value>(line) else {
142
+ continue;
143
+ };
144
+ if first_ts.is_none()
145
+ && let Some(t) = ts_of(&v)
146
+ {
147
+ first_ts = Some(t.to_string());
148
+ }
149
+ if cwd.is_none()
150
+ && let Some(c) = v.get("cwd").and_then(|c| c.as_str())
151
+ {
152
+ cwd = Some(c.to_string());
153
+ }
154
+ if version.is_none() {
155
+ // A NUMBER, not a string — the header reads `"version": 3`.
156
+ version = v.get("version").and_then(|x| x.as_u64());
157
+ }
158
+ // Present only on a forked or branched session, and the only on-disk
159
+ // link back to the transcript it was cut from.
160
+ if parent_session.is_none()
161
+ && let Some(p) = v.get("parentSession").and_then(|p| p.as_str())
162
+ {
163
+ parent_session = Some(p.to_string());
164
+ }
165
+ if model.is_none() {
166
+ model = model_of(&v);
167
+ }
168
+ if provider.is_none() {
169
+ provider = provider_of(&v);
170
+ }
171
+ // The session's goal is its first human prompt.
172
+ if goal.is_none()
173
+ && v.get("type").and_then(|t| t.as_str()) == Some("message")
174
+ && let Some(message) = v.get("message")
175
+ && message.get("role").and_then(|r| r.as_str()) == Some("user")
176
+ && let Some(text) = user_text(message)
177
+ {
178
+ goal = Some(text.chars().take(MAX_GOAL_CHARS).collect());
179
+ }
180
+ }
181
+
182
+ let ts = first_ts?;
183
+ let mut m = base(ctx, "agent_start", &ts, 0, offset)?;
184
+ if let Some(g) = goal {
185
+ m.insert("goal".into(), json!(g));
186
+ }
187
+ if let Some(c) = cwd {
188
+ m.insert("pi_cwd".into(), json!(c));
189
+ }
190
+ if let Some(x) = version {
191
+ m.insert("pi_session_version".into(), json!(x));
192
+ }
193
+ if let Some(p) = parent_session {
194
+ m.insert("pi_parent_session".into(), json!(p));
195
+ }
196
+ if let Some(x) = model {
197
+ m.insert("pi_model".into(), json!(x));
198
+ }
199
+ if let Some(p) = provider {
200
+ m.insert("pi_provider".into(), json!(p));
201
+ }
202
+ Some((Value::Object(m), Some(ts)))
203
+ }
204
+
205
+ /// The single `agent_end`, derived deterministically from the last timestamp and
206
+ /// the file size (used as its offset, so it is unique per session).
207
+ ///
208
+ /// Index 999 puts it after every content event sharing that timestamp — the
209
+ /// server's tie-break is a random id, so without this a session can appear to
210
+ /// end before its last turn.
211
+ pub fn agent_end(ctx: &Ctx, last_ts: &str, size: u64) -> Value {
212
+ match base(ctx, "agent_end", last_ts, 999, size) {
213
+ Some(m) => Value::Object(m),
214
+ // `base` only fails on an unparseable timestamp, which cannot happen
215
+ // here: `last_ts` came from a line this transform already accepted.
216
+ None => json!({
217
+ "timestamp": last_ts,
218
+ "session_id": ctx.session_id,
219
+ "agent_id": ctx.agent_id,
220
+ "type": "agent_end",
221
+ "environment": ctx.environment,
222
+ }),
223
+ }
224
+ }
225
+
226
+ /// One content line to its timestamp and the events it yields.
227
+ ///
228
+ /// Unlike Claude Code, EVERY pi record carries a top-level `timestamp` — so the
229
+ /// "no timestamp means metadata" shortcut that source relies on does not exist
230
+ /// here and the dispatch has to be explicit. Records pi models but we do not
231
+ /// (`compaction`, `branch_summary`, `label`, `session_info`, `custom`,
232
+ /// `thinking_level_change`) still return their timestamp, so `agent_end` lands
233
+ /// on the session's real last activity rather than on its last *message*.
234
+ pub fn transform_line(
235
+ line: &str,
236
+ ctx: &Ctx,
237
+ offset: u64,
238
+ state: &mut TailState,
239
+ ) -> (Option<String>, Vec<Value>) {
240
+ let Ok(v) = serde_json::from_str::<Value>(line) else {
241
+ return (None, Vec::new());
242
+ };
243
+ let Some(ts) = ts_of(&v).map(str::to_string) else {
244
+ return (None, Vec::new());
245
+ };
246
+
247
+ let events = match v.get("type").and_then(|t| t.as_str()) {
248
+ Some("message") => message_events(&v, ctx, &ts, offset, state),
249
+ // Emits nothing, but is the reason this format needs no `seed_state`:
250
+ // pi writes `model_change` BEFORE the session's first user message, so
251
+ // the opening prompt already has a model to inherit. Claude Code has to
252
+ // seed one out of band, which is not dedup-safe between a live tail and
253
+ // a later re-read; pi gets the same result from the file's own order.
254
+ Some("model_change") => {
255
+ if let Some(id) = v.get("modelId").and_then(|m| m.as_str()) {
256
+ state.last_model = Some(id.to_string());
257
+ }
258
+ Vec::new()
259
+ }
260
+ _ => Vec::new(),
261
+ };
262
+ (Some(ts), events)
263
+ }
264
+
265
+ /// A `message` record is a user prompt, an assistant turn, or ONE tool result.
266
+ fn message_events(
267
+ v: &Value,
268
+ ctx: &Ctx,
269
+ ts: &str,
270
+ offset: u64,
271
+ state: &mut TailState,
272
+ ) -> Vec<Value> {
273
+ let Some(message) = v.get("message") else {
274
+ return Vec::new();
275
+ };
276
+ match message.get("role").and_then(|r| r.as_str()) {
277
+ Some("user") => user_events(message, ctx, ts, offset, state),
278
+ Some("assistant") => assistant_events(message, ctx, ts, offset, state),
279
+ Some("toolResult") => tool_result_events(message, ctx, ts, offset, state),
280
+ // `custom` (and pre-v3 `hookMessage`, which the v2→v3 migration renames
281
+ // to it) carries extension output, not conversation. Skipped rather
282
+ // than mapped, so it cannot masquerade as a real turn.
283
+ _ => Vec::new(),
284
+ }
285
+ }
286
+
287
+ /// A `user` message is one prompt, whatever number of text blocks it is spread
288
+ /// across.
289
+ fn user_events(
290
+ message: &Value,
291
+ ctx: &Ctx,
292
+ ts: &str,
293
+ offset: u64,
294
+ state: &mut TailState,
295
+ ) -> Vec<Value> {
296
+ let Some(text) = user_text(message) else {
297
+ return Vec::new();
298
+ };
299
+ let Some(mut m) = base(ctx, "model_request", ts, 0, offset) else {
300
+ return Vec::new();
301
+ };
302
+ // A user record names no model. Inherited from the preceding `model_change`
303
+ // or assistant turn, because the server builds this row's summary from the
304
+ // model alone and would otherwise render the row blank.
305
+ if let Some(model) = &state.last_model {
306
+ m.insert("model".into(), json!(model));
307
+ }
308
+ m.insert(
309
+ "messages".into(),
310
+ json!([{ "role": "user", "content": text }]),
311
+ );
312
+ vec![Value::Object(m)]
313
+ }
314
+
315
+ /// An `assistant` message is prose and/or tool calls, plus token usage.
316
+ fn assistant_events(
317
+ message: &Value,
318
+ ctx: &Ctx,
319
+ ts: &str,
320
+ offset: u64,
321
+ state: &mut TailState,
322
+ ) -> Vec<Value> {
323
+ let model = message.get("model").and_then(|m| m.as_str());
324
+ if let Some(m) = model {
325
+ state.last_model = Some(m.to_string());
326
+ }
327
+ let response_id = message.get("responseId").and_then(|i| i.as_str());
328
+ let stop_reason = message.get("stopReason").and_then(|s| s.as_str());
329
+
330
+ let empty: Vec<Value> = Vec::new();
331
+ let blocks = message
332
+ .get("content")
333
+ .and_then(|c| c.as_array())
334
+ .unwrap_or(&empty);
335
+
336
+ let mut out: Vec<Value> = Vec::new();
337
+ for (i, block) in blocks.iter().enumerate() {
338
+ match block.get("type").and_then(|t| t.as_str()) {
339
+ Some("text") => {
340
+ let Some(text) = block.get("text").and_then(|t| t.as_str()) else {
341
+ continue;
342
+ };
343
+ if text.is_empty() {
344
+ continue;
345
+ }
346
+ let Some(mut m) = base(ctx, "model_response", ts, i, offset) else {
347
+ continue;
348
+ };
349
+ m.insert("role".into(), json!("assistant"));
350
+ m.insert("content".into(), json!(text));
351
+ if let Some(model) = model {
352
+ m.insert("model".into(), json!(model));
353
+ }
354
+ if let Some(id) = response_id {
355
+ m.insert("pi_response_id".into(), json!(id));
356
+ }
357
+ if let Some(sr) = stop_reason {
358
+ m.insert("pi_stop_reason".into(), json!(sr));
359
+ }
360
+ out.push(Value::Object(m));
361
+ }
362
+ Some("toolCall") => {
363
+ let Some(mut m) = base(ctx, "tool_use", ts, i, offset) else {
364
+ continue;
365
+ };
366
+ let name = block.get("name").and_then(|n| n.as_str()).unwrap_or("tool");
367
+ // Fall back to an offset-derived id so parallel identical calls
368
+ // cannot hash-collapse into one row.
369
+ let id = block
370
+ .get("id")
371
+ .and_then(|x| x.as_str())
372
+ .map(str::to_string)
373
+ .unwrap_or_else(|| format!("pi-{offset}-{i}"));
374
+ state.remember_tool(id.clone(), name.to_string());
375
+ m.insert("tool_name".into(), json!(name));
376
+ m.insert("tool_call_id".into(), json!(id));
377
+ // pi names the payload `arguments`; ingest reads `input`.
378
+ if let Some(args) = block.get("arguments") {
379
+ m.insert("input".into(), args.clone());
380
+ }
381
+ if let Some(model) = model {
382
+ m.insert("model".into(), json!(model));
383
+ }
384
+ out.push(Value::Object(m));
385
+ }
386
+ // `thinking` blocks are deliberately not shipped: they are reasoning
387
+ // traces, not conversation, and carry the same disclosure weight as
388
+ // a prompt without adding a row anyone acts on.
389
+ _ => {}
390
+ }
391
+ }
392
+
393
+ // A failed turn writes an EMPTY content array plus `errorMessage` — a real
394
+ // 404 looks exactly like that on disk. Without this the prompt that
395
+ // triggered it is the last thing in the session and the failure is
396
+ // invisible. Indexed past the last block so it can never collide with one.
397
+ if let Some(err) = message.get("errorMessage").and_then(|e| e.as_str())
398
+ && !err.is_empty()
399
+ && let Some(mut m) = base(ctx, "model_response", ts, blocks.len(), offset)
400
+ {
401
+ m.insert("role".into(), json!("assistant"));
402
+ m.insert("content".into(), json!(err));
403
+ m.insert("error".into(), json!(err));
404
+ m.insert("error_type".into(), json!("pi_model_error"));
405
+ if let Some(model) = model {
406
+ m.insert("model".into(), json!(model));
407
+ }
408
+ if let Some(sr) = stop_reason {
409
+ m.insert("pi_stop_reason".into(), json!(sr));
410
+ }
411
+ out.push(Value::Object(m));
412
+ }
413
+
414
+ // Attach usage to the FIRST event of the record, so a record yielding
415
+ // several events reports its tokens once.
416
+ //
417
+ // Billed unconditionally, unlike the Claude source, which has to gate on a
418
+ // message id: Claude Code splits ONE API response across several lines that
419
+ // each repeat the same usage object, whereas pi writes exactly one record
420
+ // per response with its own `usage`. Gating here would instead DROP the
421
+ // usage of every errored turn, which carries no `responseId` to gate on.
422
+ if let Some(usage) = message.get("usage")
423
+ && let Some(first) = out.first_mut()
424
+ && let Some(obj) = first.as_object_mut()
425
+ {
426
+ if let Some(n) = usage.get("input").and_then(|x| x.as_u64()) {
427
+ obj.insert("input_tokens".into(), json!(n));
428
+ }
429
+ if let Some(n) = usage.get("output").and_then(|x| x.as_u64()) {
430
+ obj.insert("output_tokens".into(), json!(n));
431
+ }
432
+ obj.insert("pi_usage".into(), usage.clone());
433
+ }
434
+
435
+ out
436
+ }
437
+
438
+ /// A `toolResult` message is exactly one result for exactly one call.
439
+ fn tool_result_events(
440
+ message: &Value,
441
+ ctx: &Ctx,
442
+ ts: &str,
443
+ offset: u64,
444
+ state: &mut TailState,
445
+ ) -> Vec<Value> {
446
+ let Some(mut m) = base(ctx, "tool_result", ts, 0, offset) else {
447
+ return Vec::new();
448
+ };
449
+ let call_id = message.get("toolCallId").and_then(|x| x.as_str());
450
+ if let Some(id) = call_id {
451
+ m.insert("tool_call_id".into(), json!(id));
452
+ }
453
+
454
+ // pi does name the tool on the result record — but the carried name is
455
+ // still consulted as the fallback, because it is the only source that
456
+ // cannot go missing: a result whose `toolName` is absent (an older writer,
457
+ // a truncated record) would otherwise be a blank row, which is exactly the
458
+ // failure `TailState::pending_tools` exists to prevent. Pairing is BY ID,
459
+ // never by arrival order — one assistant record can issue several calls.
460
+ let name = message
461
+ .get("toolName")
462
+ .and_then(|n| n.as_str())
463
+ .map(str::to_string)
464
+ .or_else(|| {
465
+ call_id
466
+ .and_then(|id| state.tool_name(id))
467
+ .map(str::to_string)
468
+ });
469
+ if let Some(n) = name {
470
+ m.insert("tool_name".into(), json!(n));
471
+ }
472
+
473
+ if let Some(content) = message.get("content") {
474
+ let text = stringify(content);
475
+ m.insert("output".into(), json!(text));
476
+ if message.get("isError").and_then(|e| e.as_bool()) == Some(true) {
477
+ m.insert("error".into(), json!(text));
478
+ m.insert("error_type".into(), json!("pi_tool_error"));
479
+ }
480
+ }
481
+ vec![Value::Object(m)]
482
+ }
483
+
484
+ /// The concatenated text of a message's content blocks.
485
+ ///
486
+ /// Tolerates a bare string as well as the v3 typed-block array: the v1→v3
487
+ /// migrations rewrite roles rather than shapes, so an older transcript that is
488
+ /// still on disk and never reopened by pi keeps whatever it was written with.
489
+ fn user_text(message: &Value) -> Option<String> {
490
+ let content = message.get("content")?;
491
+ if let Some(s) = content.as_str() {
492
+ return (!s.is_empty()).then(|| s.to_string());
493
+ }
494
+ let joined = content
495
+ .as_array()?
496
+ .iter()
497
+ .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text"))
498
+ .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
499
+ .collect::<Vec<_>>()
500
+ .join("\n");
501
+ (!joined.is_empty()).then_some(joined)
502
+ }
503
+
504
+ /// Render a tool result payload as text. pi uses an array of typed blocks; a
505
+ /// bare string and an arbitrary value are both tolerated rather than dropped,
506
+ /// since an unrenderable result is still evidence of what the agent did.
507
+ fn stringify(v: &Value) -> String {
508
+ match v {
509
+ Value::String(s) => s.clone(),
510
+ Value::Array(items) => items
511
+ .iter()
512
+ .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
513
+ .collect::<Vec<_>>()
514
+ .join("\n"),
515
+ other => other.to_string(),
516
+ }
517
+ }
518
+
519
+ /// The model named by a record, wherever that version puts it.
520
+ fn model_of(v: &Value) -> Option<String> {
521
+ v.get("modelId")
522
+ .and_then(|m| m.as_str())
523
+ .or_else(|| v.get("message")?.get("model")?.as_str())
524
+ .map(str::to_string)
525
+ }
526
+
527
+ /// The provider named by a record, wherever that version puts it.
528
+ fn provider_of(v: &Value) -> Option<String> {
529
+ v.get("provider")
530
+ .and_then(|p| p.as_str())
531
+ .or_else(|| v.get("message")?.get("provider")?.as_str())
532
+ .map(str::to_string)
533
+ }