failproofai 1.0.0-beta.2 → 1.0.0-beta.3

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 (196) 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.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
  37. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  38. package/.next/standalone/.next/server/app/audit/page.js +1 -1
  39. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  41. package/.next/standalone/.next/server/app/index.html +1 -1
  42. package/.next/standalone/.next/server/app/index.rsc +15 -15
  43. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  44. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  45. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  46. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  47. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  48. package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
  49. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  50. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  51. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  52. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
  53. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
  54. package/.next/standalone/.next/server/app/policies/page.js +2 -2
  55. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  57. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
  58. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  59. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  63. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  66. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
  67. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  68. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  69. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  70. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +1 -1
  71. package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
  75. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
  78. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
  79. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ath6v_._.js → [root-of-the-server]__0ls0gsr._.js} +3 -3
  80. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
  81. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
  82. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
  83. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1psjybo._.js → [root-of-the-server]__10s_z4l._.js} +2 -2
  84. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
  85. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0nb0syg._.js → [root-of-the-server]__1ky3im9._.js} +2 -2
  86. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
  87. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  89. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  90. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0oy_-36.js → node_modules_html-to-image_es_index_0jfts82.js} +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
  93. package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
  94. package/.next/standalone/.next/server/pages/404.html +1 -1
  95. package/.next/standalone/.next/server/pages/500.html +1 -1
  96. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  97. package/.next/standalone/.next/server/server-reference-manifest.json +32 -20
  98. package/.next/standalone/.next/static/chunks/{3s7myk_co0gbh.js → 01h6lfjz1_oie.js} +1 -1
  99. package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
  100. package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 0a7k_x4rp_97z.js} +1 -1
  101. package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 0cvd6bcdwchc_.js} +1 -1
  102. package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 13-obgukzg_ke.js} +1 -1
  103. package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
  104. package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 1h0_yaoq5f3yx.js} +1 -1
  105. package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 29g8ykjk6sx_8.js} +1 -1
  106. package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 2rqm-klet_gbl.js} +1 -1
  107. package/.next/standalone/.next/static/chunks/{31feojxhjyjpl.js → 2uvyh8y0naofj.js} +1 -1
  108. package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
  109. package/.next/standalone/.next/static/chunks/3p-n_j80zlzi3.js +6 -0
  110. package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
  111. package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
  112. package/.next/standalone/Cargo.lock +1992 -198
  113. package/.next/standalone/Cargo.toml +1 -1
  114. package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
  115. package/.next/standalone/app/components/pause-notices.tsx +111 -0
  116. package/.next/standalone/app/policies/hooks-client.tsx +81 -6
  117. package/.next/standalone/crates/CLOUD_POLICIES.md +136 -0
  118. package/.next/standalone/crates/failproofaid/Cargo.toml +7 -0
  119. package/.next/standalone/crates/failproofaid/src/cloud_client.rs +484 -0
  120. package/.next/standalone/crates/failproofaid/src/cloud_policies.rs +921 -0
  121. package/.next/standalone/crates/failproofaid/src/main.rs +452 -3
  122. package/.next/standalone/crates/failproofaid/src/paths.rs +29 -0
  123. package/.next/standalone/crates/fpai-collect/Cargo.toml +43 -0
  124. package/.next/standalone/crates/fpai-collect/src/config.rs +412 -0
  125. package/.next/standalone/crates/fpai-collect/src/cursor.rs +520 -0
  126. package/.next/standalone/crates/fpai-collect/src/delivery.rs +472 -0
  127. package/.next/standalone/crates/fpai-collect/src/filetail.rs +484 -0
  128. package/.next/standalone/crates/fpai-collect/src/health.rs +369 -0
  129. package/.next/standalone/crates/fpai-collect/src/lib.rs +34 -0
  130. package/.next/standalone/crates/fpai-collect/src/redact.rs +516 -0
  131. package/.next/standalone/crates/fpai-collect/src/sources/claude/mod.rs +279 -0
  132. package/.next/standalone/crates/fpai-collect/src/sources/claude/transform.rs +619 -0
  133. package/.next/standalone/crates/fpai-collect/src/sources/codex/mod.rs +156 -0
  134. package/.next/standalone/crates/fpai-collect/src/sources/codex/transform.rs +715 -0
  135. package/.next/standalone/crates/fpai-collect/src/sources/copilot/mod.rs +158 -0
  136. package/.next/standalone/crates/fpai-collect/src/sources/copilot/transform.rs +512 -0
  137. package/.next/standalone/crates/fpai-collect/src/sources/goose/mod.rs +511 -0
  138. package/.next/standalone/crates/fpai-collect/src/sources/goose/transform.rs +586 -0
  139. package/.next/standalone/crates/fpai-collect/src/sources/hermes/mod.rs +318 -0
  140. package/.next/standalone/crates/fpai-collect/src/sources/hermes/transform.rs +579 -0
  141. package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +310 -0
  142. package/.next/standalone/crates/fpai-collect/src/sources/hooks/transform.rs +448 -0
  143. package/.next/standalone/crates/fpai-collect/src/sources/mod.rs +23 -0
  144. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/mod.rs +176 -0
  145. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/transform.rs +500 -0
  146. package/.next/standalone/crates/fpai-collect/src/sources/opencode/mod.rs +315 -0
  147. package/.next/standalone/crates/fpai-collect/src/sources/opencode/transform.rs +493 -0
  148. package/.next/standalone/crates/fpai-collect/src/sources/pi/mod.rs +201 -0
  149. package/.next/standalone/crates/fpai-collect/src/sources/pi/transform.rs +533 -0
  150. package/.next/standalone/crates/fpai-collect/src/spool.rs +458 -0
  151. package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +387 -0
  152. package/.next/standalone/crates/fpai-collect/src/supervisor.rs +423 -0
  153. package/.next/standalone/crates/fpai-collect/src/uploader.rs +600 -0
  154. package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +738 -0
  155. package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +393 -0
  156. package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +975 -0
  157. package/.next/standalone/crates/fpai-collect/tests/config.rs +286 -0
  158. package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1019 -0
  159. package/.next/standalone/crates/fpai-collect/tests/delivery.rs +281 -0
  160. package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1008 -0
  161. package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1129 -0
  162. package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +621 -0
  163. package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +854 -0
  164. package/.next/standalone/crates/fpai-collect/tests/opencode_source.rs +1478 -0
  165. package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1191 -0
  166. package/.next/standalone/crates/fpai-collect/tests/supervisor.rs +290 -0
  167. package/.next/standalone/crates/fpai-collect/tests/uploader.rs +506 -0
  168. package/.next/standalone/package.json +2 -2
  169. package/.next/standalone/server.js +1 -1
  170. package/bin/failproofai.mjs +129 -0
  171. package/dist/cli.mjs +2195 -770
  172. package/dist/worker.mjs +365 -52
  173. package/package.json +2 -2
  174. package/src/hooks/builtin-policies.ts +139 -0
  175. package/src/hooks/cloud-connection.ts +205 -0
  176. package/src/hooks/cloud-enrollment-cli.ts +227 -0
  177. package/src/hooks/cloud-enrollment.ts +173 -0
  178. package/src/hooks/cloud-managed-policies.ts +138 -0
  179. package/src/hooks/collector-config.ts +226 -0
  180. package/src/hooks/configure-wizard.ts +221 -5
  181. package/src/hooks/custom-hooks-loader.ts +69 -25
  182. package/src/hooks/daemon-client.ts +18 -0
  183. package/src/hooks/handler.ts +144 -6
  184. package/src/hooks/hook-activity-store.ts +50 -0
  185. package/src/hooks/loader-utils.ts +6 -4
  186. package/src/hooks/policy-types.ts +35 -0
  187. package/src/hooks/session-pause-cli.ts +157 -0
  188. package/src/hooks/session-pause.ts +236 -0
  189. package/src/hooks/tui.ts +115 -0
  190. package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
  191. package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
  192. package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
  193. package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
  194. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_buildManifest.js +0 -0
  195. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_clientMiddlewareManifest.js +0 -0
  196. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_ssgManifest.js +0 -0
@@ -0,0 +1,579 @@
1
+ //! Hermes `messages` rows → AgentEye events.
2
+ //!
3
+ //! Every function here is pure in its arguments. The contract on
4
+ //! [`crate::sqlitepoll::SqliteFormat::poll`] is that re-reading the same rows
5
+ //! produces identical bytes, because the server dedups on a content hash —
6
+ //! anything that consulted the clock, a counter, or which poll pass we happen
7
+ //! to be on would turn one turn into a fresh row on every pass.
8
+ //!
9
+ //! Row shapes verified against a real `~/.hermes/state.db` (schema_version 22,
10
+ //! captured from a containerised probe):
11
+ //!
12
+ //! ```text
13
+ //! user role='user', content=<prompt>, finish_reason=NULL
14
+ //! assistant role='assistant', content=<text>, finish_reason='stop'
15
+ //! OR role='assistant', content='', finish_reason='tool_calls',
16
+ //! tool_calls='[{id, call_id, response_item_id, type:"function",
17
+ //! function:{name, arguments:"<JSON *STRING*>"}}, …]'
18
+ //! tool role='tool', content='<JSON string>', tool_call_id, tool_name
19
+ //! ```
20
+ //!
21
+ //! Three details of that shape are load-bearing and each has its own trap:
22
+ //!
23
+ //! * `tool_calls` is an ARRAY that really does carry several entries — the
24
+ //! probe capture has one assistant row issuing two parallel `session_search`
25
+ //! calls, so a transform reading `[0]` would silently drop half the tool
26
+ //! traffic on any parallel turn.
27
+ //! * `function.arguments` is a JSON-encoded **string**, not an object. Passed
28
+ //! through unparsed it reaches the product as one escaped blob per call
29
+ //! instead of queryable fields.
30
+ //! * an assistant row that is calling tools has `content=''` — an empty string,
31
+ //! not NULL. A `content IS NOT NULL` test emits a blank `model_response` for
32
+ //! every tool turn.
33
+
34
+ use std::collections::HashMap;
35
+
36
+ use serde_json::{Map, Value, json};
37
+
38
+ use crate::sqlitepoll::epoch_to_millis;
39
+
40
+ /// Longest derived id component. Matches the other sources so one project's
41
+ /// agent id is the same string whichever agent produced the session.
42
+ const MAX_ID_PART: usize = 48;
43
+
44
+ /// Longest goal string lifted from a session's opening prompt.
45
+ const MAX_GOAL_CHARS: usize = 1024;
46
+
47
+ /// Sub-second offset given to `agent_end`, so it sorts after every content
48
+ /// event that shares its millisecond. The server's tie-break is a random id,
49
+ /// so without this a session can appear to end before its own last turn.
50
+ const END_INDEX: u32 = 999;
51
+
52
+ /// One `messages` row, as far as this source cares.
53
+ ///
54
+ /// The reasoning columns (`reasoning`, `reasoning_content`, `reasoning_details`,
55
+ /// `codex_reasoning_items`, `codex_message_items`) and `api_content` are
56
+ /// deliberately absent: they are provider-shaped scratch space Hermes rewrites
57
+ /// in place, and shipping them would multiply the payload for content already
58
+ /// present in `content`/`tool_calls`.
59
+ #[derive(Debug, Clone, Default)]
60
+ pub struct MessageRow {
61
+ pub id: i64,
62
+ pub session_id: String,
63
+ pub role: String,
64
+ pub content: Option<String>,
65
+ pub tool_call_id: Option<String>,
66
+ pub tool_calls: Option<String>,
67
+ pub tool_name: Option<String>,
68
+ /// REAL epoch **seconds** with sub-second precision, e.g. `1785744251.65`.
69
+ pub timestamp: f64,
70
+ pub finish_reason: Option<String>,
71
+ /// 0 once a rewind or a compaction has taken the row out of the model's
72
+ /// context. The row itself survives, which is why we still ship it.
73
+ pub active: i64,
74
+ }
75
+
76
+ /// The `sessions` row a message belongs to.
77
+ ///
78
+ /// `user_id`, `chat_id` and `session_key` are deliberately not carried: on a
79
+ /// gateway session they are the Slack/Telegram account and channel identifiers,
80
+ /// i.e. personal data with no analytic use here. `system_prompt` is skipped too
81
+ /// — it is kilobytes, identical across every session of an install, and can
82
+ /// carry operator secrets pasted into `SOUL.md`.
83
+ #[derive(Debug, Clone, Default)]
84
+ pub struct SessionMeta {
85
+ pub source: Option<String>,
86
+ pub model: Option<String>,
87
+ pub cwd: Option<String>,
88
+ pub git_branch: Option<String>,
89
+ pub git_repo_root: Option<String>,
90
+ pub chat_type: Option<String>,
91
+ pub title: Option<String>,
92
+ pub started_at: Option<f64>,
93
+ pub ended_at: Option<f64>,
94
+ pub end_reason: Option<String>,
95
+ pub input_tokens: Option<i64>,
96
+ pub output_tokens: Option<i64>,
97
+ /// Lowest and highest surviving `messages.id` for this session. What makes
98
+ /// `agent_start` / `agent_end` fire exactly once without the poller having
99
+ /// to remember anything between passes — see [`agent_start`].
100
+ pub first_message_id: Option<i64>,
101
+ pub last_message_id: Option<i64>,
102
+ }
103
+
104
+ /// Format epoch milliseconds as the RFC3339-with-microseconds string ingest
105
+ /// requires, offsetting by `index` microseconds.
106
+ ///
107
+ /// Microseconds are forced rather than left to RFC3339's variable precision so
108
+ /// every source emits one shape and the hook stream and the session stream sort
109
+ /// against each other on a shared timeline.
110
+ ///
111
+ /// The offset is what keeps several events derived from ONE row in order: the
112
+ /// server sorts by `(ts, random id)`, so an assistant row's two parallel tool
113
+ /// calls would otherwise come back shuffled. It is capped at 999 µs so it can
114
+ /// never carry into the next millisecond and reorder an event past a genuinely
115
+ /// later row.
116
+ pub fn to_rfc3339_micros(epoch_ms: i64, index: u32) -> Option<String> {
117
+ let nanos = (epoch_ms as i128) * 1_000_000 + (index.min(999) as i128) * 1_000;
118
+ let dt = time::OffsetDateTime::from_unix_timestamp_nanos(nanos).ok()?;
119
+ const FMT: &[time::format_description::BorrowedFormatItem<'_>] = time::macros::format_description!(
120
+ "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:6]Z"
121
+ );
122
+ dt.format(FMT).ok()
123
+ }
124
+
125
+ /// Make a derived id component safe and bounded.
126
+ pub fn sanitize_id_part(s: &str) -> String {
127
+ let cleaned: String = s
128
+ .chars()
129
+ .map(|c| {
130
+ if c.is_ascii_alphanumeric() || c == '.' || c == '_' {
131
+ c
132
+ } else {
133
+ '-'
134
+ }
135
+ })
136
+ .collect();
137
+ cleaned
138
+ .split('-')
139
+ .filter(|p| !p.is_empty())
140
+ .collect::<Vec<_>>()
141
+ .join("-")
142
+ .chars()
143
+ .take(MAX_ID_PART)
144
+ .collect()
145
+ }
146
+
147
+ /// Last path component of a working directory, sanitized.
148
+ fn project_name(cwd: &str) -> Option<String> {
149
+ let last = cwd
150
+ .trim_end_matches('/')
151
+ .rsplit('/')
152
+ .find(|p| !p.is_empty())?;
153
+ let cleaned = sanitize_id_part(last);
154
+ (!cleaned.is_empty()).then_some(cleaned)
155
+ }
156
+
157
+ /// The agent id a session's events are filed under.
158
+ ///
159
+ /// Hermes has BOTH shapes and each needs its own answer:
160
+ ///
161
+ /// * A **CLI** session has a real `cwd` (5/5 in the probe capture, alongside
162
+ /// `git_branch` and `git_repo_root`), so it groups by project exactly like
163
+ /// Claude and the hook source do — which is the whole point: hook events and
164
+ /// session events for one run must land under a single agent rather than two
165
+ /// that look unrelated.
166
+ /// * A **gateway** session (Slack, Telegram, cron) genuinely has NULL `cwd`.
167
+ /// There is no project to group by, so it falls back to the transport in
168
+ /// `source`. Grouping every gateway session under one bare `hermes` would
169
+ /// merge Slack and Telegram traffic into an agent nobody can act on.
170
+ ///
171
+ /// `fallback` — the collector's configured default — is used only when the
172
+ /// session row itself is missing, which means a message referencing a deleted
173
+ /// session.
174
+ pub fn agent_id(meta: Option<&SessionMeta>, fallback: &str) -> String {
175
+ let Some(meta) = meta else {
176
+ return fallback.to_string();
177
+ };
178
+ if let Some(project) = meta.cwd.as_deref().and_then(project_name) {
179
+ return format!("hermes-{project}");
180
+ }
181
+ if let Some(source) = meta
182
+ .source
183
+ .as_deref()
184
+ .map(sanitize_id_part)
185
+ .filter(|s| !s.is_empty())
186
+ {
187
+ return format!("hermes-{source}");
188
+ }
189
+ fallback.to_string()
190
+ }
191
+
192
+ /// The row's timestamp as epoch milliseconds, or `None` when it is implausible.
193
+ ///
194
+ /// [`epoch_to_millis`] reports anything below ~2001 as 0, which would render as
195
+ /// 1970-01-01 and park the event at the head of every timeline it lands on.
196
+ /// That is worse than not shipping it: one corrupt row would silently reorder
197
+ /// a whole session. The watermark advances past such a row regardless, so it is
198
+ /// dropped once rather than retried forever.
199
+ fn row_millis(row: &MessageRow) -> Option<i64> {
200
+ let ms = epoch_to_millis(row.timestamp);
201
+ (ms > 0).then_some(ms)
202
+ }
203
+
204
+ /// The envelope every emitted event carries.
205
+ ///
206
+ /// `hermes_row_id` is the dedup discriminator. Two identical turns — the same
207
+ /// prompt asked twice in one session — must hash differently or the server
208
+ /// collapses them into one row; `messages.id` is an `INTEGER PRIMARY KEY
209
+ /// AUTOINCREMENT`, so it is unique and never reused even after a rewind
210
+ /// deletes the rows around it.
211
+ fn base(
212
+ kind: &str,
213
+ epoch_ms: i64,
214
+ index: u32,
215
+ row: &MessageRow,
216
+ agent_id: &str,
217
+ environment: &str,
218
+ ) -> Option<Map<String, Value>> {
219
+ let mut m = Map::new();
220
+ m.insert(
221
+ "timestamp".into(),
222
+ json!(to_rfc3339_micros(epoch_ms, index)?),
223
+ );
224
+ m.insert("session_id".into(), json!(row.session_id));
225
+ m.insert("agent_id".into(), json!(agent_id));
226
+ m.insert("type".into(), json!(kind));
227
+ m.insert("environment".into(), json!(environment));
228
+ m.insert("hermes_row_id".into(), json!(row.id));
229
+ if index > 0 {
230
+ m.insert("hermes_block_index".into(), json!(index));
231
+ }
232
+ // Only ever present on a row a rewind or compaction has taken out of the
233
+ // model's context. Emitted as the exception rather than on every row so the
234
+ // common case costs no bytes, and so the product can explain why a message
235
+ // the user can see was not in the prompt that followed it.
236
+ if row.active == 0 {
237
+ m.insert("hermes_active".into(), json!(false));
238
+ }
239
+ Some(m)
240
+ }
241
+
242
+ /// Attach the session-level context every event of a session should carry.
243
+ fn with_session_context(m: &mut Map<String, Value>, meta: Option<&SessionMeta>) {
244
+ let Some(meta) = meta else { return };
245
+ if let Some(s) = meta.source.as_deref().filter(|s| !s.is_empty()) {
246
+ m.insert("hermes_source".into(), json!(s));
247
+ }
248
+ if let Some(c) = meta.cwd.as_deref().filter(|s| !s.is_empty()) {
249
+ m.insert("hermes_cwd".into(), json!(c));
250
+ }
251
+ }
252
+
253
+ /// The session's `agent_start`, emitted from its FIRST surviving message row.
254
+ ///
255
+ /// Firing it off `first_message_id` rather than "the first time we see this
256
+ /// session" is what makes it survive the poller having no memory between
257
+ /// passes: the answer is a pure function of what is in the database, so a
258
+ /// re-read produces the identical event and the server dedups it, whereas an
259
+ /// "emit on first sight" rule would ship a new start every time the daemon
260
+ /// restarted.
261
+ ///
262
+ /// The timestamp comes from the session's own `started_at`, not the message's:
263
+ /// Hermes stamps `started_at` when the session opens, which on the capture is
264
+ /// 12–34 ms before the user's first prompt lands. Using the message time would
265
+ /// report every session as starting late and hide the gateway's own latency.
266
+ pub fn agent_start(
267
+ row: &MessageRow,
268
+ meta: &SessionMeta,
269
+ agent_id: &str,
270
+ environment: &str,
271
+ ) -> Option<Value> {
272
+ let ms = meta
273
+ .started_at
274
+ .map(epoch_to_millis)
275
+ .filter(|ms| *ms > 0)
276
+ .or_else(|| row_millis(row))?;
277
+ let mut m = base("agent_start", ms, 0, row, agent_id, environment)?;
278
+
279
+ // The session's goal is its opening human prompt. Only read off the first
280
+ // row so this stays a pure function of the rows in hand — searching forward
281
+ // for a user row would give a different answer depending on where the batch
282
+ // happened to be cut.
283
+ if row.role == "user"
284
+ && let Some(text) = row.content.as_deref().filter(|t| !t.is_empty())
285
+ {
286
+ m.insert(
287
+ "goal".into(),
288
+ json!(text.chars().take(MAX_GOAL_CHARS).collect::<String>()),
289
+ );
290
+ }
291
+ if let Some(model) = meta.model.as_deref().filter(|s| !s.is_empty()) {
292
+ m.insert("model".into(), json!(model));
293
+ }
294
+ if let Some(b) = meta.git_branch.as_deref().filter(|s| !s.is_empty()) {
295
+ m.insert("hermes_git_branch".into(), json!(b));
296
+ }
297
+ if let Some(r) = meta.git_repo_root.as_deref().filter(|s| !s.is_empty()) {
298
+ m.insert("hermes_git_repo_root".into(), json!(r));
299
+ }
300
+ if let Some(t) = meta.chat_type.as_deref().filter(|s| !s.is_empty()) {
301
+ m.insert("hermes_chat_type".into(), json!(t));
302
+ }
303
+ if let Some(t) = meta.title.as_deref().filter(|s| !s.is_empty()) {
304
+ m.insert("hermes_title".into(), json!(t));
305
+ }
306
+ with_session_context(&mut m, Some(meta));
307
+ Some(Value::Object(m))
308
+ }
309
+
310
+ /// The session's `agent_end`, emitted from its LAST surviving message row.
311
+ ///
312
+ /// Gated on `ended_at` being set, so a session still in flight does not get an
313
+ /// end event on every poll that touches its newest row. The cost is a narrow
314
+ /// race: Hermes writes the final assistant message and only then `UPDATE`s
315
+ /// `sessions.ended_at` — 11 ms later on the capture. A poll landing inside that
316
+ /// window consumes the last row while the session still looks open, and no
317
+ /// later row will ever arrive to re-trigger it, so that session ends up with no
318
+ /// `agent_end`. Accepted over the alternative, which is ending every live
319
+ /// session on every pass.
320
+ pub fn agent_end(
321
+ row: &MessageRow,
322
+ meta: &SessionMeta,
323
+ agent_id: &str,
324
+ environment: &str,
325
+ ) -> Option<Value> {
326
+ let ms = meta
327
+ .ended_at
328
+ .map(epoch_to_millis)
329
+ .filter(|ms| *ms > 0)
330
+ .or_else(|| row_millis(row))?;
331
+ let mut m = base("agent_end", ms, END_INDEX, row, agent_id, environment)?;
332
+ if let Some(reason) = meta.end_reason.as_deref().filter(|s| !s.is_empty()) {
333
+ m.insert("hermes_end_reason".into(), json!(reason));
334
+ }
335
+ if let Some(n) = meta.input_tokens.filter(|n| *n > 0) {
336
+ m.insert("input_tokens".into(), json!(n));
337
+ }
338
+ if let Some(n) = meta.output_tokens.filter(|n| *n > 0) {
339
+ m.insert("output_tokens".into(), json!(n));
340
+ }
341
+ with_session_context(&mut m, Some(meta));
342
+ Some(Value::Object(m))
343
+ }
344
+
345
+ /// One `messages` row to the events it yields.
346
+ ///
347
+ /// `pending` carries tool-call ids to names within a poll so a result is not a
348
+ /// blank row; see [`tool_events`] for why it is not the only source of the name.
349
+ pub fn message_events(
350
+ row: &MessageRow,
351
+ meta: Option<&SessionMeta>,
352
+ agent_id: &str,
353
+ environment: &str,
354
+ pending: &mut HashMap<String, String>,
355
+ ) -> Vec<Value> {
356
+ let Some(ms) = row_millis(row) else {
357
+ return Vec::new();
358
+ };
359
+ match row.role.as_str() {
360
+ "user" => user_events(row, meta, agent_id, environment, ms),
361
+ "assistant" => assistant_events(row, meta, agent_id, environment, ms, pending),
362
+ "tool" => tool_events(row, meta, agent_id, environment, ms, pending),
363
+ // Hermes only writes user/assistant/tool today. An unknown role is
364
+ // skipped rather than guessed at: a wrong event type is harder to
365
+ // notice than a missing one, and a new role would need its own mapping
366
+ // anyway.
367
+ _ => Vec::new(),
368
+ }
369
+ }
370
+
371
+ /// A `user` row is a prompt.
372
+ fn user_events(
373
+ row: &MessageRow,
374
+ meta: Option<&SessionMeta>,
375
+ agent_id: &str,
376
+ environment: &str,
377
+ ms: i64,
378
+ ) -> Vec<Value> {
379
+ let Some(text) = row.content.as_deref().filter(|t| !t.is_empty()) else {
380
+ return Vec::new();
381
+ };
382
+ let Some(mut m) = base("model_request", ms, 0, row, agent_id, environment) else {
383
+ return Vec::new();
384
+ };
385
+ // The model is on the SESSION, never on the message. The server builds this
386
+ // row's summary from the model alone, so without lifting it here every
387
+ // prompt renders as an empty row.
388
+ if let Some(model) = meta
389
+ .and_then(|s| s.model.as_deref())
390
+ .filter(|s| !s.is_empty())
391
+ {
392
+ m.insert("model".into(), json!(model));
393
+ }
394
+ m.insert(
395
+ "messages".into(),
396
+ json!([{ "role": "user", "content": text }]),
397
+ );
398
+ with_session_context(&mut m, meta);
399
+ vec![Value::Object(m)]
400
+ }
401
+
402
+ /// An `assistant` row is text, or tool calls, or (defensively) both.
403
+ ///
404
+ /// The probe capture never has both — `finish_reason` is either `stop` with
405
+ /// text or `tool_calls` with an empty `content` — but handling the pair costs
406
+ /// nothing and a provider that starts emitting a preamble alongside its calls
407
+ /// would otherwise lose the preamble silently.
408
+ fn assistant_events(
409
+ row: &MessageRow,
410
+ meta: Option<&SessionMeta>,
411
+ agent_id: &str,
412
+ environment: &str,
413
+ ms: i64,
414
+ pending: &mut HashMap<String, String>,
415
+ ) -> Vec<Value> {
416
+ let model = meta
417
+ .and_then(|s| s.model.as_deref())
418
+ .filter(|s| !s.is_empty());
419
+ let mut out = Vec::new();
420
+
421
+ if let Some(text) = row.content.as_deref().filter(|t| !t.is_empty())
422
+ && let Some(mut m) = base("model_response", ms, 0, row, agent_id, environment)
423
+ {
424
+ m.insert("role".into(), json!("assistant"));
425
+ m.insert("content".into(), json!(text));
426
+ if let Some(model) = model {
427
+ m.insert("model".into(), json!(model));
428
+ }
429
+ if let Some(fr) = row.finish_reason.as_deref().filter(|s| !s.is_empty()) {
430
+ m.insert("hermes_finish_reason".into(), json!(fr));
431
+ }
432
+ with_session_context(&mut m, meta);
433
+ out.push(Value::Object(m));
434
+ }
435
+
436
+ for (i, call) in parse_tool_calls(row.tool_calls.as_deref())
437
+ .iter()
438
+ .enumerate()
439
+ {
440
+ // Index 0 belongs to the text event whether or not it exists, so a call's
441
+ // sub-second offset does not shift depending on whether the model also
442
+ // spoke — the same row must always produce the same bytes.
443
+ let index = (i + 1) as u32;
444
+ let Some(mut m) = base("tool_use", ms, index, row, agent_id, environment) else {
445
+ continue;
446
+ };
447
+ let function = call.get("function");
448
+ let name = function
449
+ .and_then(|f| f.get("name"))
450
+ .and_then(|n| n.as_str())
451
+ .unwrap_or("tool");
452
+ // `id` and `call_id` are the same value on every row of the capture;
453
+ // `id` wins and `call_id` is the fallback in case a provider adapter
454
+ // fills only one. Failing both, an id derived from (row, index) keeps
455
+ // two parallel identical calls from hash-collapsing into one row.
456
+ let id = call
457
+ .get("id")
458
+ .and_then(|x| x.as_str())
459
+ .or_else(|| call.get("call_id").and_then(|x| x.as_str()))
460
+ .map(str::to_string)
461
+ .unwrap_or_else(|| format!("hermes-{}-{i}", row.id));
462
+ pending.insert(id.clone(), name.to_string());
463
+ m.insert("tool_name".into(), json!(name));
464
+ m.insert("tool_call_id".into(), json!(id));
465
+ m.insert("input".into(), call_input(function));
466
+ if let Some(model) = model {
467
+ m.insert("model".into(), json!(model));
468
+ }
469
+ with_session_context(&mut m, meta);
470
+ out.push(Value::Object(m));
471
+ }
472
+
473
+ out
474
+ }
475
+
476
+ /// A `tool` row is one call's result.
477
+ fn tool_events(
478
+ row: &MessageRow,
479
+ meta: Option<&SessionMeta>,
480
+ agent_id: &str,
481
+ environment: &str,
482
+ ms: i64,
483
+ pending: &mut HashMap<String, String>,
484
+ ) -> Vec<Value> {
485
+ let Some(mut m) = base("tool_result", ms, 0, row, agent_id, environment) else {
486
+ return Vec::new();
487
+ };
488
+ if let Some(id) = row.tool_call_id.as_deref().filter(|s| !s.is_empty()) {
489
+ m.insert("tool_call_id".into(), json!(id));
490
+ // The call's name wins because it is what the model actually invoked;
491
+ // Hermes also stores `tool_name` on the result row, which is the only
492
+ // thing that saves the pairing when the batch was cut between the call
493
+ // and its result — then `pending` is empty and the result would
494
+ // otherwise be a blank row in the product.
495
+ if let Some(name) = pending
496
+ .get(id)
497
+ .map(String::as_str)
498
+ .or(row.tool_name.as_deref())
499
+ .filter(|s| !s.is_empty())
500
+ {
501
+ m.insert("tool_name".into(), json!(name));
502
+ }
503
+ } else if let Some(name) = row.tool_name.as_deref().filter(|s| !s.is_empty()) {
504
+ m.insert("tool_name".into(), json!(name));
505
+ }
506
+
507
+ if let Some(output) = row.content.as_deref() {
508
+ m.insert("output".into(), json!(output));
509
+ if let Some(err) = tool_error(output) {
510
+ m.insert("error".into(), json!(err));
511
+ m.insert("error_type".into(), json!("hermes_tool_error"));
512
+ }
513
+ }
514
+ with_session_context(&mut m, meta);
515
+ vec![Value::Object(m)]
516
+ }
517
+
518
+ /// Parse the `tool_calls` column into its entries.
519
+ ///
520
+ /// Returns empty for NULL, for the empty string, and for anything that is not
521
+ /// a JSON array — a malformed column must cost this row's tool calls, not the
522
+ /// whole poll.
523
+ fn parse_tool_calls(raw: Option<&str>) -> Vec<Value> {
524
+ let Some(raw) = raw.filter(|s| !s.trim().is_empty()) else {
525
+ return Vec::new();
526
+ };
527
+ match serde_json::from_str::<Value>(raw) {
528
+ Ok(Value::Array(items)) => items,
529
+ _ => Vec::new(),
530
+ }
531
+ }
532
+
533
+ /// A call's `input`, unwrapping the JSON-encoded `arguments` string.
534
+ ///
535
+ /// Hermes stores OpenAI's wire shape verbatim, where `arguments` is a STRING
536
+ /// containing JSON: `{"function":{"arguments":"{\"command\": \"ls /work\"}"}}`.
537
+ /// Shipping that string straight through gives the product one escaped blob per
538
+ /// tool call instead of the fields a policy or a query can reach.
539
+ ///
540
+ /// A string that does not parse is kept verbatim under `arguments` rather than
541
+ /// dropped: a truncated argument list is still the best evidence available of
542
+ /// what the agent tried to run.
543
+ fn call_input(function: Option<&Value>) -> Value {
544
+ match function.and_then(|f| f.get("arguments")) {
545
+ Some(Value::String(s)) => {
546
+ serde_json::from_str::<Value>(s).unwrap_or_else(|_| json!({ "arguments": s }))
547
+ }
548
+ Some(other) => other.clone(),
549
+ None => json!({}),
550
+ }
551
+ }
552
+
553
+ /// The error a tool result reports, if it reports one.
554
+ ///
555
+ /// Hermes tool results are JSON strings whose shape is the tool's own. Two
556
+ /// signals are unambiguous across the capture: a non-null `error`
557
+ /// (`{"error": "around_message_id 1 not in session_id …", "success": false}`)
558
+ /// and an explicit `success: false`.
559
+ ///
560
+ /// A non-zero `exit_code` is deliberately NOT treated as an error even though
561
+ /// `terminal` reports one: `grep` finding nothing exits 1, and flagging that as
562
+ /// a failed tool call would fill the product with false alarms that are
563
+ /// indistinguishable from real ones.
564
+ fn tool_error(content: &str) -> Option<String> {
565
+ let obj = serde_json::from_str::<Value>(content).ok()?;
566
+ let obj = obj.as_object()?;
567
+ if let Some(err) = obj.get("error") {
568
+ match err {
569
+ Value::Null => {}
570
+ Value::String(s) if s.is_empty() => {}
571
+ Value::String(s) => return Some(s.clone()),
572
+ other => return Some(other.to_string()),
573
+ }
574
+ }
575
+ if obj.get("success").and_then(Value::as_bool) == Some(false) {
576
+ return Some(content.to_string());
577
+ }
578
+ None
579
+ }